Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): TextInput updates #1942

Merged
merged 5 commits into from
Dec 11, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-crabs-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Sync TextInput with the latest designs
39 changes: 10 additions & 29 deletions packages/ui/src/AddressView/index.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,8 @@ import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra';
import { CopyToClipboardButton } from '../CopyToClipboardButton';
import { AddressIcon } from './AddressIcon';
import { Text } from '../Text';
import { Density, useDensity } from '../utils/density';
import { useDensity } from '../utils/density';
import { Density } from '../Density';

export interface AddressViewProps {
addressView: AddressView | undefined;
@@ -13,16 +14,6 @@ export interface AddressViewProps {
truncate?: boolean;
}

export const getIconSize = (density: Density): number => {
if (density === 'compact') {
return 16;
}
if (density === 'slim') {
return 12;
}
return 24;
};

// Renders an address or an address view.
// If the view is given and is "visible", the account information will be displayed instead.
export const AddressViewComponent = ({
@@ -54,39 +45,29 @@ export const AddressViewComponent = ({
<div className='shrink'>
<AddressIcon
address={addressView.addressView.value.address}
size={getIconSize(density)}
size={density === 'sparse' ? 24 : 16}
/>
</div>
)}

<div className={truncate ? 'max-w-[150px] truncate' : ''}>
{/* eslint-disable-next-line no-nested-ternary -- can alternatively use dynamic prop object like {...fontProps} */}
{addressIndex ? (
density === 'sparse' ? (
<Text strong-bold truncate={truncate}>
{isRandomized && 'IBC Deposit Address for '}
{getAccountLabel(addressIndex.account)}
</Text>
) : (
<Text small truncate={truncate}>
{isRandomized && 'IBC Deposit Address for '}
{getAccountLabel(addressIndex.account)}
</Text>
)
) : density === 'sparse' ? (
<Text strong-bold truncate={truncate}>
{encodedAddress}
<Text variant={density === 'sparse' ? 'strong' : 'small'} truncate={truncate}>
{isRandomized && 'IBC Deposit Address for '}
{getAccountLabel(addressIndex.account)}
</Text>
) : (
<Text small truncate={truncate}>
<Text variant={density === 'sparse' ? 'strong' : 'small'} truncate={truncate}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: variant property on density is a nice feature

{encodedAddress}
</Text>
)}
</div>

{copyable && !isRandomized && (
<div className='shrink'>
<CopyToClipboardButton text={encodedAddress} />
<Density variant={density === 'sparse' ? 'compact' : 'slim'}>
<CopyToClipboardButton text={encodedAddress} />
</Density>
</div>
)}
</div>
24 changes: 18 additions & 6 deletions packages/ui/src/Density/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { ReactNode } from 'react';
import { Density as TDensity, DensityContext } from '../utils/density';

export type DensityPropType =
| { sparse: true; slim?: never; compact?: never }
| { slim: true; sparse?: never; compact?: never }
| { compact: true; sparse?: never; slim?: never };
type DensityType = {
[K in TDensity]: Record<K, true> & Partial<Record<Exclude<TDensity, K>, never>>;
}[TDensity];

type DensityPropType =
| (DensityType & { variant?: never })
| (Partial<Record<TDensity, never>> & {
/** dynamic density variant as a string: `'sparse' | 'compact' | 'slim'` */
variant?: TDensity;
});

export type DensityProps = DensityPropType & {
children?: ReactNode;
@@ -70,10 +76,16 @@ export type DensityProps = DensityPropType & {
* }
* />
* ```
*
* If you need to change density dynamically, you can use the `variant` property.
*
* ```tsx
* <Density variant={isDense ? 'compact' : 'sparse'} />
* ```
*/
export const Density = ({ children, sparse, slim, compact }: DensityProps) => {
export const Density = ({ children, sparse, slim, compact, variant }: DensityProps) => {
const density: TDensity =
(sparse && 'sparse') ?? (compact && 'compact') ?? (slim && 'slim') ?? 'sparse';
variant ?? (sparse && 'sparse') ?? (compact && 'compact') ?? (slim && 'slim') ?? 'sparse';

return <DensityContext.Provider value={density}>{children}</DensityContext.Provider>;
};
9 changes: 6 additions & 3 deletions packages/ui/src/Text/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';

import { Text } from '.';
import { useArgs } from '@storybook/preview-api';
import { TextVariant } from './types';

const meta: Meta<typeof Text> = {
component: Text,
@@ -41,7 +42,7 @@ const OPTIONS = [
'small',
'technical',
'detailTechnical',
] as const;
] as TextVariant[];

const Option = ({
value,
@@ -57,7 +58,6 @@ const Option = ({
type='radio'
name='textStyle'
value={value}
defaultChecked={checked}
checked={checked}
onChange={() => onSelect(value)}
/>
@@ -86,12 +86,15 @@ export const KitchenSink: StoryObj<typeof Text> = {
),
);

const isChecked = (option: TextVariant): boolean =>
Object.keys(props).some(key => key === option);

return (
<form className='flex flex-col gap-2 text-text-primary'>
<div className='flex items-center gap-2'>
<Text>Text style:</Text>
{OPTIONS.map(option => (
<Option key={option} value={option} checked={!!props[option]} onSelect={onSelect} />
<Option key={option} value={option} checked={isChecked(option)} onSelect={onSelect} />
))}
</div>

82 changes: 32 additions & 50 deletions packages/ui/src/Text/index.tsx
Original file line number Diff line number Diff line change
@@ -19,9 +19,9 @@ import {
} from '../utils/typography';
import { ElementType, ReactNode } from 'react';
import { ThemeColor } from '../utils/color';
import { TextType } from './types';
import { TextVariant, TypographyProps } from './types';

export type TextProps = TextType & {
export type TextProps = TypographyProps & {
children?: ReactNode;
/**
* Which component or HTML element to render this text as.
@@ -120,6 +120,23 @@ const getTextOptionClasses = ({
);
};

const VARIANT_MAP: Record<TextVariant, { element: ElementType; classes: string }> = {
h1: { element: 'h1', classes: h1 },
h2: { element: 'h2', classes: h2 },
h3: { element: 'h3', classes: h3 },
h4: { element: 'h4', classes: h4 },
xxl: { element: 'span', classes: xxl },
large: { element: 'span', classes: large },
p: { element: 'p', classes: p },
strong: { element: 'span', classes: strong },
detail: { element: 'span', classes: detail },
xxs: { element: 'span', classes: xxs },
small: { element: 'span', classes: small },
detailTechnical: { element: 'span', classes: detailTechnical },
technical: { element: 'span', classes: technical },
body: { element: 'span', classes: body },
};

/**
* All-purpose text wrapper for quickly styling text per the Penumbra UI
* guidelines.
@@ -147,57 +164,22 @@ const getTextOptionClasses = ({
* This will render with the h1 style, but inside an inline span tag.
* </Text>
* ```
*
* If you need to use dynamic Text styles, use `variant` property with a string value.
* However, it is recommended to use the static Text styles for most cases:
*
* ```tsx
* <Text variant={emphasized ? 'strong' : 'body'}>Content</Text>
* ```
*/
export const Text = (props: TextProps) => {
const classes = getTextOptionClasses(props);
const SpanElement = props.as ?? 'span';

if (props.h1) {
const Element = props.as ?? 'h1';
return <Element className={cn(h1, classes)}>{props.children}</Element>;
}
if (props.h2) {
const Element = props.as ?? 'h2';
return <Element className={cn(h2, classes)}>{props.children}</Element>;
}
if (props.h3) {
const Element = props.as ?? 'h3';
return <Element className={cn(h3, classes)}>{props.children}</Element>;
}
if (props.h4) {
const Element = props.as ?? 'h4';
return <Element className={cn(h4, classes)}>{props.children}</Element>;
}

if (props.xxl) {
return <SpanElement className={cn(xxl, classes)}>{props.children}</SpanElement>;
}
if (props.large) {
return <SpanElement className={cn(large, classes)}>{props.children}</SpanElement>;
}
if (props.strong) {
return <SpanElement className={cn(strong, classes)}>{props.children}</SpanElement>;
}
if (props.detail) {
return <SpanElement className={cn(detail, classes)}>{props.children}</SpanElement>;
}
if (props.xxs) {
return <SpanElement className={cn(xxs, classes)}>{props.children}</SpanElement>;
}
if (props.small) {
return <SpanElement className={cn(small, classes)}>{props.children}</SpanElement>;
}
if (props.detailTechnical) {
return <SpanElement className={cn(detailTechnical, classes)}>{props.children}</SpanElement>;
}
if (props.technical) {
return <SpanElement className={cn(technical, classes)}>{props.children}</SpanElement>;
}

if (props.p) {
const Element = props.as ?? 'p';
return <Element className={cn(p, classes)}>{props.children}</Element>;
}
const variantKey: TextVariant =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the default fallback is necessary
(Object.keys(props).find(key => VARIANT_MAP[key as TextVariant]) as TextVariant) ?? 'body';
const variant = VARIANT_MAP[variantKey];
const Element = props.as ?? variant.element;

return <SpanElement className={cn(body, classes)}>{props.children}</SpanElement>;
return <Element className={cn(variant.classes, classes)}>{props.children}</Element>;
};
166 changes: 25 additions & 141 deletions packages/ui/src/Text/types.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,26 @@
/**
* Utility interface to be used below to ensure that only one text type is used
* at a time.
*/
interface NeverTextTypes {
h1?: never;
h2?: never;
h3?: never;
h4?: never;
xxl?: never;
large?: never;
p?: never;
strong?: never;
detail?: never;
xxs?: never;
small?: never;
detailTechnical?: never;
technical?: never;
body?: never;
}
export type TextVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'xxl'
| 'large'
| 'p'
| 'strong'
| 'detail'
| 'xxs'
| 'small'
| 'detailTechnical'
| 'technical'
| 'body';

export type TextType =
| (Omit<NeverTextTypes, 'h1'> & {
/**
* Renders a styled `<h1 />`. Pass the `as` prop to use a different HTML
* element with the same styling.
*/
h1: true;
})
| (Omit<NeverTextTypes, 'h2'> & {
/**
* Renders a styled `<h2 />`. Pass the `as` prop to use a different HTML
* element with the same styling.
*/
h2: true;
})
| (Omit<NeverTextTypes, 'h3'> & {
/**
* Renders a styled `<h3 />`. Pass the `as` prop to use a different HTML
* element with the same styling.
*/
h3: true;
})
| (Omit<NeverTextTypes, 'h4'> & {
/**
* Renders a styled `<h4 />`. Pass the `as` prop to use a different HTML
* element with the same styling.
*/
h4: true;
})
| (Omit<NeverTextTypes, 'xxl'> & {
/**
* Renders bigger text used for section titles. Renders a `<span />` by
* default; pass the `as` prop to use a different HTML element with the
* same styling.
*/
xxl: true;
})
| (Omit<NeverTextTypes, 'large'> & {
/**
* Renders big text used for section titles. Renders a `<span />` by
* default; pass the `as` prop to use a different HTML element with the
* same styling.
*/
large: true;
})
| (Omit<NeverTextTypes, 'p'> & {
/**
* Renders a styled `<p />` tag with a bottom-margin (unless it's the last
* child). Aside from the margin, `<P />` is identical to `<Body />`.
*
* Note that this is the only component in the entire Penumbra UI library
* that renders an external margin. It's a convenience for developers who
* don't want to wrap each `<Text p />` in a `<div />` with the
* appropriate margin, or a flex columnn with a gap.
*/
p: true;
})
| (Omit<NeverTextTypes, 'strong'> & {
/**
* Emphasized body text.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
strong: true;
})
| (Omit<NeverTextTypes, 'detail'> & {
/**
* Detail text used for small bits of tertiary information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
detail: true;
})
| (Omit<NeverTextTypes, 'xxs'> & {
/**
* xxs text used for extra small bits of tertiary information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
xxs: true;
})
| (Omit<NeverTextTypes, 'small'> & {
/**
* Small text used for secondary information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
small: true;
})
| (Omit<NeverTextTypes, 'detailTechnical'> & {
/**
* Small monospaced text used for code, values, and other technical
* information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
detailTechnical: true;
})
| (Omit<NeverTextTypes, 'technical'> & {
/**
* Monospaced text used for code, values, and other technical information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
technical: true;
})
| (Omit<NeverTextTypes, 'body'> & {
/**
* Body text used throughout most of our UIs.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
body?: true;
});
type TextType = {
[K in TextVariant]: Record<K, true> & Partial<Record<Exclude<TextVariant, K>, never>>;
}[TextVariant];

export type TypographyProps =
| (TextType & { variant?: never })
| {
/** dynamic typography variant as a string: `'h1' | 'body' | 'large' | 'p' | 'strong' | etc. */
variant?: TextVariant;
};
3 changes: 2 additions & 1 deletion packages/ui/src/TextInput/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ const SampleButton = () => (
</Density>
);

const addressBookIcon = <Icon IconComponent={BookUser} size='sm' color='text.primary' />;
const addressBookIcon = <Icon IconComponent={BookUser} size='sm' />;

const meta: Meta<typeof TextInput> = {
component: TextInput,
@@ -47,6 +47,7 @@ export const Basic: Story = {
args: {
actionType: 'default',
placeholder: 'penumbra1abc123...',
label: '',
value: '',
disabled: false,
type: 'text',
63 changes: 48 additions & 15 deletions packages/ui/src/TextInput/index.tsx
Original file line number Diff line number Diff line change
@@ -3,13 +3,26 @@ import { small } from '../utils/typography';
import { ActionType, getFocusWithinOutlineColorByActionType } from '../utils/action-type';
import { useDisabled } from '../utils/disabled-context';
import cn from 'clsx';
import { Text } from '../Text';
import { ThemeColor } from '../utils/color';

const getLabelColor = (actionType: ActionType, disabled?: boolean): ThemeColor => {
if (disabled) {
return 'text.muted';
}
if (actionType === 'destructive') {
return 'destructive.light';
}
return 'text.secondary';
};

export interface TextInputProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
actionType?: ActionType;
disabled?: boolean;
label?: string;
type?: 'email' | 'number' | 'password' | 'tel' | 'text' | 'url';
/**
* Markup to render inside the text input's visual frame, before the text
@@ -31,10 +44,10 @@ export interface TextInputProps {
* Can be enriched with start and end adornments, which are markup that render
* inside the text input's visual frame.
*/
// eslint-disable-next-line react/display-name -- exotic component
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
(
{
label,
value,
onChange,
placeholder,
@@ -48,18 +61,31 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
}: TextInputProps,
ref,
) => (
<div
<label
className={cn(
'flex items-center gap-2 bg-other-tonalFill5',
startAdornment && 'pl-3',
endAdornment && 'pr-3',
'outline outline-2 outline-transparent',
'h-14 flex items-center gap-2 bg-other-tonalFill5 rounded-sm py-3 pl-3 pr-2 border-none',
'cursor-text outline outline-2 outline-transparent',
'hover:bg-action-hoverOverlay',
'transition-[background-color,outline-color] duration-150',
getFocusWithinOutlineColorByActionType(actionType),
)}
>
{startAdornment}
{startAdornment && (
<div
className={cn(
'flex items-center gap-2',
disabled ? 'text-text-muted' : 'text-neutral-light',
)}
>
{startAdornment}
</div>
)}

{label && (
<Text body color={getLabelColor(actionType, disabled)}>
{label}
</Text>
)}

<input
value={value}
@@ -71,20 +97,27 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
min={min}
ref={ref}
className={cn(
'box-border grow appearance-none border-none bg-base-transparent py-2',
startAdornment ? 'pl-0' : 'pl-3',
endAdornment ? 'pr-0' : 'pr-3',
disabled ? 'text-text-muted' : 'text-text-primary',
small,
disabled ? 'text-text-muted' : 'text-text-primary',
'box-border grow appearance-none border-none bg-base-transparent py-2',
'placeholder:text-text-secondary',
'disabled:cursor-not-allowed',
'disabled:placeholder:text-text-muted',
'disabled:cursor-not-allowed disabled:placeholder:text-text-muted',
'focus:outline-0',
'[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
)}
/>

{endAdornment}
</div>
{endAdornment && (
<div
className={cn(
'flex items-center gap-2',
disabled ? 'text-text-muted' : 'text-neutral-light',
)}
>
{endAdornment}
</div>
)}
</label>
),
);
TextInput.displayName = 'TextInput';
15 changes: 12 additions & 3 deletions packages/ui/src/utils/button.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cn from 'clsx';
import type { Density } from './density';
import { button, buttonMedium, buttonSmall } from './typography';
import {
getBeforeOutlineColorByActionType,
ActionType,
@@ -20,12 +21,12 @@ export const buttonBase = cn('appearance-none border-none text-inherit cursor-po

export const getFont = ({ density }: ButtonStyleAttributes): string => {
if (density === 'compact') {
return cn('font-default text-textSm font-medium leading-textSm');
return buttonMedium;
}
if (density === 'slim') {
return cn('font-default text-textXs font-medium leading-textXs');
return buttonSmall;
}
return cn('font-default text-textBase font-medium leading-textBase');
return button;
};

/** Adds overlays to a button for when it's hovered, active, or disabled. */
@@ -59,6 +60,14 @@ export const getBackground = ({
};

export const getSize = ({ iconOnly, density }: ButtonStyleAttributes) => {
if (iconOnly === 'adornment' && density === 'compact') {
return cn('rounded-full size-6 min-w-6 p-1');
}

if (iconOnly === 'adornment' && density === 'slim') {
return cn('rounded-full size-4 min-w-4 p-[2px]');
}

if (density === 'compact') {
return cn('rounded-full h-8 min-w-8 w-max', iconOnly ? 'pl-2 pr-2' : 'pl-4 pr-4');
}
14 changes: 14 additions & 0 deletions packages/ui/src/utils/typography.ts
Original file line number Diff line number Diff line change
@@ -40,11 +40,25 @@ export const tabMedium = cn('font-default text-textSm font-medium leading-textLg

export const tableItem = cn('font-default text-textBase font-normal leading-textBase');

export const tableItemMedium = cn('font-default text-textSm font-normal leading-textSm');

export const tableItemSmall = cn('font-default text-textXs font-normal leading-textXs');

export const tableHeading = cn('font-default text-textBase font-medium leading-textBase');

export const tableHeadingMedium = cn('font-default text-textSm font-medium leading-textSm');

export const tableHeadingSmall = cn('font-default text-textXs font-medium leading-textXs');

export const technical = cn('font-mono text-textBase font-medium leading-textBase');

export const xxl = cn('font-default text-text2xl font-medium leading-text2xl');

// equals to body with the bottom margin
export const p = cn('font-default text-textBase font-normal leading-textBase mb-6 last:mb-0');

export const button = cn('font-default text-textBase font-medium leading-textBase');

export const buttonMedium = cn('font-default text-textBase font-medium leading-textBase');

export const buttonSmall = cn('font-default text-textBase font-medium leading-textBase');