diff --git a/.changeset/slow-crabs-whisper.md b/.changeset/slow-crabs-whisper.md new file mode 100644 index 0000000000..6f2cdfffa4 --- /dev/null +++ b/.changeset/slow-crabs-whisper.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/ui': minor +--- + +Sync TextInput with the latest designs diff --git a/packages/ui/src/AddressView/index.tsx b/packages/ui/src/AddressView/index.tsx index 11ff36a919..cee4a4c07d 100644 --- a/packages/ui/src/AddressView/index.tsx +++ b/packages/ui/src/AddressView/index.tsx @@ -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,31 +45,19 @@ export const AddressViewComponent = ({
)}
- {/* eslint-disable-next-line no-nested-ternary -- can alternatively use dynamic prop object like {...fontProps} */} {addressIndex ? ( - density === 'sparse' ? ( - - {isRandomized && 'IBC Deposit Address for '} - {getAccountLabel(addressIndex.account)} - - ) : ( - - {isRandomized && 'IBC Deposit Address for '} - {getAccountLabel(addressIndex.account)} - - ) - ) : density === 'sparse' ? ( - - {encodedAddress} + + {isRandomized && 'IBC Deposit Address for '} + {getAccountLabel(addressIndex.account)} ) : ( - + {encodedAddress} )} @@ -86,7 +65,9 @@ export const AddressViewComponent = ({ {copyable && !isRandomized && (
- + + +
)}
diff --git a/packages/ui/src/Density/index.tsx b/packages/ui/src/Density/index.tsx index 8658925ca8..57a6738423 100644 --- a/packages/ui/src/Density/index.tsx +++ b/packages/ui/src/Density/index.tsx @@ -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 & Partial, never>>; +}[TDensity]; + +type DensityPropType = + | (DensityType & { variant?: never }) + | (Partial> & { + /** 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 + * + * ``` */ -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 {children}; }; diff --git a/packages/ui/src/Text/index.stories.tsx b/packages/ui/src/Text/index.stories.tsx index 5756524324..87264afe06 100644 --- a/packages/ui/src/Text/index.stories.tsx +++ b/packages/ui/src/Text/index.stories.tsx @@ -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 = { 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 = { ), ); + const isChecked = (option: TextVariant): boolean => + Object.keys(props).some(key => key === option); + return (
Text style: {OPTIONS.map(option => ( -
diff --git a/packages/ui/src/Text/index.tsx b/packages/ui/src/Text/index.tsx index fa2bda3c41..4565294cb9 100644 --- a/packages/ui/src/Text/index.tsx +++ b/packages/ui/src/Text/index.tsx @@ -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 = { + 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. * * ``` + * + * 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 + * Content + * ``` */ export const Text = (props: TextProps) => { const classes = getTextOptionClasses(props); - const SpanElement = props.as ?? 'span'; - - if (props.h1) { - const Element = props.as ?? 'h1'; - return {props.children}; - } - if (props.h2) { - const Element = props.as ?? 'h2'; - return {props.children}; - } - if (props.h3) { - const Element = props.as ?? 'h3'; - return {props.children}; - } - if (props.h4) { - const Element = props.as ?? 'h4'; - return {props.children}; - } - - if (props.xxl) { - return {props.children}; - } - if (props.large) { - return {props.children}; - } - if (props.strong) { - return {props.children}; - } - if (props.detail) { - return {props.children}; - } - if (props.xxs) { - return {props.children}; - } - if (props.small) { - return {props.children}; - } - if (props.detailTechnical) { - return {props.children}; - } - if (props.technical) { - return {props.children}; - } - if (props.p) { - const Element = props.as ?? 'p'; - return {props.children}; - } + 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 {props.children}; + return {props.children}; }; diff --git a/packages/ui/src/Text/types.ts b/packages/ui/src/Text/types.ts index 7ce5f8753c..c10246b727 100644 --- a/packages/ui/src/Text/types.ts +++ b/packages/ui/src/Text/types.ts @@ -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 & { - /** - * Renders a styled `

`. Pass the `as` prop to use a different HTML - * element with the same styling. - */ - h1: true; - }) - | (Omit & { - /** - * Renders a styled `

`. Pass the `as` prop to use a different HTML - * element with the same styling. - */ - h2: true; - }) - | (Omit & { - /** - * Renders a styled `

`. Pass the `as` prop to use a different HTML - * element with the same styling. - */ - h3: true; - }) - | (Omit & { - /** - * Renders a styled `

`. Pass the `as` prop to use a different HTML - * element with the same styling. - */ - h4: true; - }) - | (Omit & { - /** - * Renders bigger text used for section titles. Renders a `` by - * default; pass the `as` prop to use a different HTML element with the - * same styling. - */ - xxl: true; - }) - | (Omit & { - /** - * Renders big text used for section titles. Renders a `` by - * default; pass the `as` prop to use a different HTML element with the - * same styling. - */ - large: true; - }) - | (Omit & { - /** - * Renders a styled `

` tag with a bottom-margin (unless it's the last - * child). Aside from the margin, `

` is identical to ``. - * - * 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 `` in a `

` with the - * appropriate margin, or a flex columnn with a gap. - */ - p: true; - }) - | (Omit & { - /** - * Emphasized body text. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - strong: true; - }) - | (Omit & { - /** - * Detail text used for small bits of tertiary information. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - detail: true; - }) - | (Omit & { - /** - * xxs text used for extra small bits of tertiary information. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - xxs: true; - }) - | (Omit & { - /** - * Small text used for secondary information. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - small: true; - }) - | (Omit & { - /** - * Small monospaced text used for code, values, and other technical - * information. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - detailTechnical: true; - }) - | (Omit & { - /** - * Monospaced text used for code, values, and other technical information. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - technical: true; - }) - | (Omit & { - /** - * Body text used throughout most of our UIs. - * - * Renders a `` by default; pass the `as` prop to use a different - * HTML element with the same styling. - */ - body?: true; - }); +type TextType = { + [K in TextVariant]: Record & Partial, never>>; +}[TextVariant]; + +export type TypographyProps = + | (TextType & { variant?: never }) + | { + /** dynamic typography variant as a string: `'h1' | 'body' | 'large' | 'p' | 'strong' | etc. */ + variant?: TextVariant; + }; diff --git a/packages/ui/src/TextInput/index.stories.tsx b/packages/ui/src/TextInput/index.stories.tsx index c3812d2312..938789c5a8 100644 --- a/packages/ui/src/TextInput/index.stories.tsx +++ b/packages/ui/src/TextInput/index.stories.tsx @@ -15,7 +15,7 @@ const SampleButton = () => ( ); -const addressBookIcon = ; +const addressBookIcon = ; const meta: Meta = { component: TextInput, @@ -47,6 +47,7 @@ export const Basic: Story = { args: { actionType: 'default', placeholder: 'penumbra1abc123...', + label: '', value: '', disabled: false, type: 'text', diff --git a/packages/ui/src/TextInput/index.tsx b/packages/ui/src/TextInput/index.tsx index 10410210e8..753ffaec09 100644 --- a/packages/ui/src/TextInput/index.tsx +++ b/packages/ui/src/TextInput/index.tsx @@ -3,6 +3,18 @@ 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; @@ -10,6 +22,7 @@ export interface TextInputProps { 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( ( { + label, value, onChange, placeholder, @@ -48,18 +61,31 @@ export const TextInput = forwardRef( }: TextInputProps, ref, ) => ( -
- {startAdornment} + {startAdornment && ( +
+ {startAdornment} +
+ )} + + {label && ( + + {label} + + )} ( 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} -
+ {endAdornment && ( +
+ {endAdornment} +
+ )} + ), ); +TextInput.displayName = 'TextInput'; diff --git a/packages/ui/src/utils/button.ts b/packages/ui/src/utils/button.ts index f57c4355dd..2f24749a4e 100644 --- a/packages/ui/src/utils/button.ts +++ b/packages/ui/src/utils/button.ts @@ -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'); } diff --git a/packages/ui/src/utils/typography.ts b/packages/ui/src/utils/typography.ts index 2d747fe795..126f4ece34 100644 --- a/packages/ui/src/utils/typography.ts +++ b/packages/ui/src/utils/typography.ts @@ -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');