From 40385d02ac40e8652f9bbf380f15f32e1a73cdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 22 Jul 2026 12:31:37 +0200 Subject: [PATCH 01/17] feat!: add Text typography variants --- src/avatar/avatar.stories.tsx | 6 +- src/base-field/base-field.tsx | 9 +- src/box/box.stories.tsx | 2 +- .../expansion-panel.stories.tsx | 2 +- src/modal/modal.stories.tsx | 18 +- src/text/text.module.css | 124 +++++++++----- src/text/text.stories.tsx | 114 ++++--------- src/text/text.test.tsx | 151 +++++++++++------ src/text/text.tsx | 157 ++++++++++-------- src/toast/static-toast.tsx | 2 +- src/tooltip/tooltip.stories.tsx | 6 +- src/typography/typography.module.css | 49 ++++++ src/typography/typography.ts | 53 ++++++ 13 files changed, 429 insertions(+), 264 deletions(-) create mode 100644 src/typography/typography.module.css create mode 100644 src/typography/typography.ts diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index cd88227a7..4561d4787 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -135,9 +135,9 @@ function StorySection({ return ( - {title} + {title} {description ? ( - + {description} ) : null} @@ -152,7 +152,7 @@ function AvatarExample({ label, children }: { label: string; children: React.Rea {children} - + {label} diff --git a/src/base-field/base-field.tsx b/src/base-field/base-field.tsx index 08c253d63..27c66986a 100644 --- a/src/base-field/base-field.tsx +++ b/src/base-field/base-field.tsx @@ -30,7 +30,7 @@ function fieldToneToTextTone(tone: FieldTone) { function FieldMessage({ id, children, tone }: FieldMessageProps) { return ( - + } tone={fieldToneToTextTone(tone)} variant="callout-2" id={id}> {tone === 'loading' ? ( + {children} ) @@ -334,9 +334,8 @@ function BaseField({ alignItems="flexEnd" > } > {label ? ( {label} diff --git a/src/box/box.stories.tsx b/src/box/box.stories.tsx index 8e8399d35..9fe1ca420 100644 --- a/src/box/box.stories.tsx +++ b/src/box/box.stories.tsx @@ -280,7 +280,7 @@ export function OverlayScrollStory() { {Array.from({ length: 20 }, (_, i) => ( Content item {i + 1} - + This is some additional content to make the item taller and ensure scrolling is needed. diff --git a/src/expansion-panel/expansion-panel.stories.tsx b/src/expansion-panel/expansion-panel.stories.tsx index a42df5c2e..d3df904e9 100644 --- a/src/expansion-panel/expansion-panel.stories.tsx +++ b/src/expansion-panel/expansion-panel.stories.tsx @@ -26,7 +26,7 @@ export const IconToggle = { alignItems="center" justifyContent="spaceBetween" > - + Fruit diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx index 9c82f3428..c02d9da9c 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -137,9 +137,7 @@ export function ModalWithHeaderBodyAndCustomFooter() { - - Do whatever you want down here - + Do whatever you want down here @@ -307,7 +305,7 @@ export function MinimalisticConfirmationModal() { - Are you sure you want to leave? + Are you sure you want to leave? - + By default the `autoFocus` prop is `true`, which shifts the focus onto the first focusable element in the modal. You can further refine this by using the `data-autofocus` attribute if you wish to focus on elements other than the first @@ -438,7 +434,7 @@ export function StackingModals() { - + Modals may be stacked on top of one another, with each of them having their independent states, e.g. `width` and `height`. @@ -449,7 +445,7 @@ export function StackingModals() { - + Modals may be stacked on top of one another, with each of them having their independent states, e.g. `width` and `height`. diff --git a/src/text/text.module.css b/src/text/text.module.css index d78d3aabd..ce2882be6 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,60 +1,106 @@ -.text { - font-family: var(--reactist-font-family); - font-size: var(--reactist-font-size-body); - font-weight: var(--reactist-font-weight-regular); - color: var(--reactist-content-primary); +.variant-subheader-1 { + font-size: 16px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: 0; + line-height: 23px; } -.size-caption { - font-size: var(--reactist-font-size-caption); -} -.size-copy { - font-size: var(--reactist-font-size-copy); +.variant-subheader-2 { + font-size: 16px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 23px; } -.size-subtitle { - font-size: var(--reactist-font-size-subtitle); + +.variant-body-1 { + font-size: 14px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: -0.15px; + line-height: 21px; } -.weight-semibold { - font-weight: var(--reactist-font-weight-medium); +.variant-body-2 { + font-size: 14px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 21px; } -.weight-bold { - font-weight: var(--reactist-font-weight-strong); + +.variant-body-3 { + font-size: 14px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 21px; } -.tone-secondary { - color: var(--reactist-content-secondary); +.variant-callout-1 { + font-size: 13px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 20px; } -.tone-danger { - color: var(--reactist-content-danger); + +.variant-callout-2 { + font-size: 13px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 20px; } -.tone-positive { - color: var(--reactist-content-positive); + +.variant-caption-1 { + font-size: 12px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0; + line-height: 20px; } -/* truncated text */ +.variant-caption-2 { + font-size: 12px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 15px; +} -.lineClampMultipleLines { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; +.variant-caption-3 { + font-size: 12px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 20px; } -.lineClamp-1 { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.variant-footnote-1 { + font-size: 10px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 1px; + line-height: 13px; } -.lineClamp-2 { - -webkit-line-clamp: 2; +.variant-footnote-2 { + font-size: 10px; + font-weight: var(--reactist-typography-font-weight-medium); + letter-spacing: 1px; + line-height: 13px; } -.lineClamp-3 { - -webkit-line-clamp: 3; + +.variant-subheader-1.decoration-strikethrough, +.variant-subheader-2.decoration-strikethrough, +.variant-body-3.decoration-strikethrough, +.variant-callout-1.decoration-strikethrough, +.variant-callout-2.decoration-strikethrough, +.variant-caption-2.decoration-strikethrough, +.variant-caption-3.decoration-strikethrough { + text-decoration-line: line-through; + text-decoration-skip-ink: none; + text-underline-position: from-font; } -.lineClamp-4 { - -webkit-line-clamp: 4; + +.variant-caption-2.decoration-underline, +.variant-caption-3.decoration-underline { + text-decoration-line: underline; + text-decoration-skip-ink: none; + text-underline-position: from-font; } -.lineClamp-5 { - -webkit-line-clamp: 5; + +.variant-footnote-1.case-uppercase { + text-transform: uppercase; } diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 8800540af..fbd8ed7e0 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -5,6 +5,21 @@ import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-h import { Text } from './text' +const textVariants = [ + 'subheader-1', + 'subheader-2', + 'body-1', + 'body-2', + 'body-3', + 'callout-1', + 'callout-2', + 'caption-1', + 'caption-2', + 'caption-3', + 'footnote-1', + 'footnote-2', +] as const + export default { title: '🔤 Typography/Text', component: Text, @@ -21,80 +36,19 @@ export function TextStory() { return (
- - Subtitle Regular - - - Subtitle Secondary - - - Subtitle Danger - - - Subtitle Positive - - - Subtitle Semibold - - - Subtitle Bold - - - - Body Regular - - - Body Secondary - - - Body Danger - - - Body Positive - - - Body Semibold - - - Body Bold - - - - Copy Regular - - - Copy Secondary - - - Copy Danger - - - Copy Positive - - - Copy Semibold - - - Copy Bold - - - - Caption Regular - - - Caption Secondary - - - Caption Danger - - - Caption Positive + {textVariants.map((variant) => ( + + {variant} + + ))} + + caption-2 underline - - Caption Semibold + + caption-3 strikethrough - - Caption Bold + + footnote-1 uppercase
@@ -153,15 +107,15 @@ export function ResponsiveTextStory(props: React.ComponentProps) { } ResponsiveTextStory.args = { - size: 'body', - weight: 'regular', + variant: 'body-3', tone: 'normal', children: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit', } ResponsiveTextStory.argTypes = { - size: select(['caption', 'copy', 'body', 'subtitle']), - weight: select(['regular', 'semibold', 'bold']), + variant: select(textVariants), + decoration: selectWithNone(['strikethrough', 'underline']), + case: selectWithNone(['uppercase']), lineClamp: selectWithNone([1, 2, 3, 4, 5]), tone: select(['normal', 'secondary', 'danger']), align: { control: false }, @@ -179,15 +133,15 @@ export function TextPlaygroundStory(props: React.ComponentProps) { } TextPlaygroundStory.args = { - size: 'body', - weight: 'regular', + variant: 'body-3', tone: 'normal', children: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit', } TextPlaygroundStory.argTypes = { - size: select(['caption', 'copy', 'body', 'subtitle']), - weight: select(['regular', 'semibold', 'bold']), + variant: select(textVariants), + decoration: selectWithNone(['strikethrough', 'underline']), + case: selectWithNone(['uppercase']), lineClamp: selectWithNone([1, 2, 3, 4, 5]), tone: select(['normal', 'secondary', 'danger']), align: selectWithNone(['start', 'center', 'end', 'justify']), diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 0e058516e..6e30fc585 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -1,9 +1,41 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' import { Text } from './text' +import type { TextProps } from './text' + +const textVariants = [ + 'subheader-1', + 'subheader-2', + 'body-1', + 'body-2', + 'body-3', + 'callout-1', + 'callout-2', + 'caption-1', + 'caption-2', + 'caption-3', + 'footnote-1', + 'footnote-2', +] as const + +const decoratedTextProps = [ + { variant: 'subheader-1', decoration: 'strikethrough' }, + { variant: 'subheader-2', decoration: 'strikethrough' }, + { variant: 'body-3', decoration: 'strikethrough' }, + { variant: 'callout-1', decoration: 'strikethrough' }, + { variant: 'callout-2', decoration: 'strikethrough' }, + { variant: 'caption-2', decoration: 'strikethrough' }, + { variant: 'caption-2', decoration: 'underline' }, + { variant: 'caption-3', decoration: 'strikethrough' }, + { variant: 'caption-3', decoration: 'underline' }, +] as const satisfies ReadonlyArray< + Omit, 'children'> +> + describe('Text', () => { it('does not acknowledge the className prop, but exceptionallySetClassName instead', () => { render( @@ -20,13 +52,35 @@ describe('Text', () => { expect(screen.getByTestId('text-element')).not.toHaveClass('wrong') }) - it('can be rendered as any HTML element', () => { + it('defaults to body-3', () => { + render(Text) + expect(screen.getByTestId('text-element')).toHaveClass('variant-body-3') + }) + + it.each(textVariants)('applies the %s variant', (variant) => { render( - + Text , ) - expect(screen.getByTestId('text-element').tagName).toBe('NAV') + expect(screen.getByTestId('text-element')).toHaveClass('variant-' + variant) + }) + + it('renders through Ariakit Role', () => { + render( + }> + Name + , + ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('LABEL') + expect(element).toHaveAttribute('for', 'name') + }) + + it('forwards its ref', () => { + const ref = React.createRef() + render(Text) + expect(ref.current?.tagName).toBe('DIV') }) it('renders its children as its content', () => { @@ -40,53 +94,6 @@ describe('Text', () => { ) }) - describe('size="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Text - , - ) - const textElement = screen.getByTestId('text-element') - expect(textElement).not.toHaveClass('size-body') - expect(textElement).not.toHaveClass('size-caption') - expect(textElement).not.toHaveClass('size-copy') - expect(textElement).not.toHaveClass('size-subtitle') - - for (const size of ['caption', 'copy', 'subtitle'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`size-${size}`) - } - }) - }) - - describe('weight="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Text - , - ) - const textElement = screen.getByTestId('text-element') - expect(textElement).not.toHaveClass('weight-regular') - expect(textElement).not.toHaveClass('weight-semibold') - expect(textElement).not.toHaveClass('weight-bold') - - for (const weight of ['semibold', 'bold'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`weight-${weight}`) - } - }) - }) - describe('tone="…"', () => { it('adds the appropriate class names', () => { const { rerender } = render( @@ -175,4 +182,48 @@ describe('Text', () => { } }) }) + + it.each(decoratedTextProps)('supports $variant with $decoration', (textProps) => { + render( + + Text + , + ) + expect(screen.getByTestId('text-element')).toHaveClass('decoration-' + textProps.decoration) + }) + + it('supports uppercase only for footnote-1', () => { + render( + + Text + , + ) + expect(screen.getByTestId('text-element')).toHaveClass('case-uppercase') + }) + + it('rejects invalid modifiers at type level', () => { + const invalidTextModifier = ( + // @ts-expect-error body-1 does not support decoration + + Invalid + + ) + expect(invalidTextModifier).toBeDefined() + }) + + it('has no accessibility violations', async () => { + const { container } = render( + <> + Default text + + Caption + + }> + Name + + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) }) diff --git a/src/text/text.tsx b/src/text/text.tsx index b0bf36427..86850c2c4 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -1,98 +1,117 @@ import * as React from 'react' -import { Box } from '../box' -import { polymorphicComponent } from '../utils/polymorphism' -import { getClassNames } from '../utils/responsive-props' +import { Role } from '@ariakit/react' + +import { getTypographyClassName } from '../typography/typography' import styles from './text.module.css' -import type { BoxProps } from '../box' -import type { Tone } from '../utils/common-types' +import type { RoleProps } from '@ariakit/react' +import type { TypographyStyleProps } from '../typography/typography' + +type TextVariant = + | 'subheader-1' + | 'subheader-2' + | 'body-1' + | 'body-2' + | 'body-3' + | 'callout-1' + | 'callout-2' + | 'caption-1' + | 'caption-2' + | 'caption-3' + | 'footnote-1' + | 'footnote-2' + +type StrikethroughTextProps = { + /** Visual text style supporting strikethrough. */ + variant: + | 'subheader-1' + | 'subheader-2' + | 'body-3' + | 'callout-1' + | 'callout-2' + | 'caption-2' + | 'caption-3' + /** Figma-supported strikethrough decoration. */ + decoration: 'strikethrough' + /** Uppercase presentation is unavailable with strikethrough. */ + case?: never +} + +type UnderlinedTextProps = { + /** Visual caption style supporting underline. */ + variant: 'caption-2' | 'caption-3' + /** Figma-supported underline decoration. */ + decoration: 'underline' + /** Uppercase presentation is unavailable with underline. */ + case?: never +} + +type UnmodifiedTextProps = { + /** Visual text style; defaults to body-3. */ + variant?: TextVariant + /** Decoration is omitted for the base variant. */ + decoration?: never + /** Case override is omitted for the base variant. */ + case?: never +} -type TextProps = { - children: React.ReactNode - /** - * The size of the text. - * - * The supported values, from smaller size to larger size, are: - * 'caption', 'copy', 'body', and 'subtitle' - * - * @default 'body' - */ - size?: 'caption' | 'copy' | 'body' | 'subtitle' - /** - * The weight of the text font. - * - * @default 'regular' - */ - weight?: 'regular' | 'semibold' | 'bold' - /** - * The tone (semantic color) of the text. - * - * @default 'normal' - */ - tone?: Tone - /** - * Used to truncate the text to a given number of lines. - * - * It will add an ellipsis (`…`) to the text at the end of the last line, only if the text was - * truncated. If the text fits without it being truncated, no ellipsis is added. - * - * By default, the text is not truncated at all, no matter how many lines it takes to render it. - * - * @default undefined - */ - lineClamp?: 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' - /** - * How to align the text horizontally. - * - * @default 'start' - */ - align?: BoxProps['textAlign'] +type UppercaseTextProps = { + /** Visual footnote style supporting uppercase. */ + variant: 'footnote-1' + /** Decoration is unavailable with uppercase presentation. */ + decoration?: never + /** Figma-supported uppercase presentation. */ + case: 'uppercase' } -const Text = polymorphicComponent<'div', TextProps>(function Text( +type TextProps = Omit, 'children' | 'className'> & + TypographyStyleProps & { + /** Text content. */ + children: React.ReactNode + /** Custom element rendered with text typography; defaults to a div. */ + render?: RoleProps['render'] + } & (StrikethroughTextProps | UnderlinedTextProps | UppercaseTextProps | UnmodifiedTextProps) + +const Text = React.forwardRef(function Text( { - as, - size = 'body', - weight = 'regular', + variant = 'body-3', + decoration, + case: textCase, tone = 'normal', align, - children, lineClamp, exceptionallySetClassName, + render, + children, ...props }, ref, ) { - const lineClampMultipleLines = - typeof lineClamp === 'string' ? Number(lineClamp) > 1 : (lineClamp ?? 1) > 1 - return ( - {children} - + ) }) Text.displayName = 'Text' -export type { TextProps } +export type { TextProps, TextVariant } export { Text } diff --git a/src/toast/static-toast.tsx b/src/toast/static-toast.tsx index 39f8b6809..02c081a8b 100644 --- a/src/toast/static-toast.tsx +++ b/src/toast/static-toast.tsx @@ -96,7 +96,7 @@ const StaticToast = React.forwardRef(function {description ? ( - {message} + {message} {description} ) : ( diff --git a/src/tooltip/tooltip.stories.tsx b/src/tooltip/tooltip.stories.tsx index 17f70e504..148c38c6c 100644 --- a/src/tooltip/tooltip.stories.tsx +++ b/src/tooltip/tooltip.stories.tsx @@ -116,9 +116,7 @@ export function TooltipRichContent({ hideTimeout={hideTimeout} content={ - - Upgrade to Pro - + Upgrade to Pro