From 839a0a19293b831f48bef53601e5f317b661b50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:08:49 -0500 Subject: [PATCH 01/18] feat: add shared typography class utilities --- src/typography/typography.module.css | 57 ++++++++++++++++++++++++++++ src/typography/typography.ts | 56 +++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/typography/typography.module.css create mode 100644 src/typography/typography.ts diff --git a/src/typography/typography.module.css b/src/typography/typography.module.css new file mode 100644 index 00000000..10b11243 --- /dev/null +++ b/src/typography/typography.module.css @@ -0,0 +1,57 @@ +:root { + --reactist-typography-font-family-sf-for-web: 'SF Pro Display', sans-serif; + --reactist-typography-font-weight-medium: 500; + --reactist-typography-font-weight-semibold: 600; +} + +.typography { + color: var(--product-library-display-primary-idle-tint); +} + +.font-family-default { + font-family: var(--reactist-font-family); +} + +.font-family-sf-for-web { + font-family: var(--reactist-typography-font-family-sf-for-web); +} + +.tone-secondary { + color: var(--product-library-display-secondary-idle-tint); +} + +.tone-danger { + color: var(--product-library-actionable-destructive-idle-tint); +} + +.tone-positive { + color: var(--product-library-info-positive-primary-idle-tint); +} + +.lineClampMultipleLines { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.lineClamp-1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lineClamp-2 { + -webkit-line-clamp: 2; +} + +.lineClamp-3 { + -webkit-line-clamp: 3; +} + +.lineClamp-4 { + -webkit-line-clamp: 4; +} + +.lineClamp-5 { + -webkit-line-clamp: 5; +} diff --git a/src/typography/typography.ts b/src/typography/typography.ts new file mode 100644 index 00000000..683d5f92 --- /dev/null +++ b/src/typography/typography.ts @@ -0,0 +1,56 @@ +import classNames from 'classnames' + +import { getBoxClassNames } from '../box' +import { getClassNames } from '../utils/responsive-props' + +import styles from './typography.module.css' + +import type { BoxProps } from '../box' +import type { ObfuscatedClassName, Tone } from '../utils/common-types' + +type TypographyLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' + +type TypographyStyleProps = ObfuscatedClassName & { + /** The semantic color of the text. */ + tone?: Tone + /** Horizontal text alignment, including responsive values. */ + align?: BoxProps['textAlign'] + /** Truncates text after the given number of lines. */ + lineClamp?: TypographyLineClamp +} + +type TypographyClassNameOptions = TypographyStyleProps & { + variantClassName: string + fontFamilyClassName?: string + modifierClassNames?: Array +} + +function getTypographyClassName({ + variantClassName, + fontFamilyClassName = styles['font-family-default'], + modifierClassNames, + tone = 'normal', + align, + lineClamp, + exceptionallySetClassName, +}: TypographyClassNameOptions) { + const lineClampMultipleLines = Number(lineClamp ?? 0) > 1 + + return classNames( + getBoxClassNames({ + textAlign: align, + paddingRight: lineClamp ? 'xsmall' : undefined, + }), + exceptionallySetClassName, + styles.typography, + fontFamilyClassName, + variantClassName, + modifierClassNames, + tone !== 'normal' ? getClassNames(styles, 'tone', tone) : null, + lineClampMultipleLines ? styles.lineClampMultipleLines : null, + lineClamp ? getClassNames(styles, 'lineClamp', String(lineClamp)) : null, + ) +} + +export type { TypographyLineClamp, TypographyStyleProps } +export { getTypographyClassName } From 4ce6796d90ccc8924382c1854f83f4f81ef03b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:08:58 -0500 Subject: [PATCH 02/18] feat!: rework Text around named typography variants Text now covers the full typography scale with a single variant prop: display-*, heading-*, subheader, body, callout, caption, and footnote styles from the Figma reference. Heading variants render the matching heading element and all other variants render a div; use render to override the element. BREAKING CHANGE: Text no longer accepts size, weight, or as props. Pick a variant instead, and use render for custom elements. --- src/avatar/avatar.stories.tsx | 6 +- src/base-field/base-field.tsx | 9 +- src/box/box.stories.tsx | 9 +- .../expansion-panel.stories.tsx | 2 +- src/menu/menu.stories.jsx | 6 +- src/modal/modal.stories.tsx | 41 ++- src/password-field/password-field.stories.jsx | 8 +- src/select-field/select-field.stories.jsx | 8 +- src/text-area/text-area.stories.jsx | 10 +- src/text-field/text-field.stories.jsx | 8 +- src/text/text.mdx | 49 ++++ src/text/text.module.css | 178 +++++++++--- src/text/text.stories.tsx | 164 ++++++------ src/text/text.test.tsx | 253 ++++++++++++++---- src/text/text.tsx | 192 ++++++++----- src/toast/static-toast.tsx | 2 +- src/toast/toast.stories.tsx | 15 +- src/tooltip/tooltip.stories.tsx | 6 +- 18 files changed, 662 insertions(+), 304 deletions(-) create mode 100644 src/text/text.mdx diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index cd88227a..4561d478 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 08c253d6..27c66986 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 8e8399d3..3b96320a 100644 --- a/src/box/box.stories.tsx +++ b/src/box/box.stories.tsx @@ -1,6 +1,5 @@ import * as React from 'react' -import { Heading } from '../heading' import { Inline } from '../inline' import { Stack } from '../stack' import { Text } from '../text' @@ -148,7 +147,7 @@ function PaddedBox({ prop, value }: { prop: keyof BoxPaddingProps; value: Space export function PaddingStory({ padding }: { padding: Space }) { return ( - The transparent bordered box has padding + The transparent bordered box has padding @@ -214,7 +213,7 @@ function MarginBox({ prop, value }: { prop: keyof BoxMarginProps; value: Space } export function MarginStory({ margin }: { margin: Space }) { return ( - The shaded box has margin + The shaded box has margin When margin is negative, you will see the outer bordered box appear to be inside the shaded box. @@ -272,7 +271,7 @@ export function OverlayScrollStory() { background="aside" > - Scrollable Content with Overlay Scroll + Scrollable Content with Overlay Scroll This Box component demonstrates the overlay scroll functionality. The scrollbar is hidden by default and appears on hover. @@ -280,7 +279,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 a42df5c2..d3df904e 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/menu/menu.stories.jsx b/src/menu/menu.stories.jsx index 8a582829..b6f43f43 100644 --- a/src/menu/menu.stories.jsx +++ b/src/menu/menu.stories.jsx @@ -47,7 +47,7 @@ function StructuredMenuItem({ icon, label, shortcut }) { ) : null} - {label} + {label} {shortcut ? ( @@ -224,7 +224,7 @@ export const LinkMenuItemStory = { } > - Link without an icon + Link without an icon } > - Disabled link without an icon + Disabled link without an icon diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx index 45fab9a5..b8cef3f2 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -8,7 +8,6 @@ import { IconButton } from '../button' import { Column, Columns } from '../columns' import ThreeDotsIcon from '../components/icons/ThreeDotsIcon.svg' import { Divider } from '../divider' -import { Heading } from '../heading' import { Inline } from '../inline' import { Menu, MenuButton, MenuItem, MenuList } from '../menu' import { Stack } from '../stack' @@ -95,10 +94,10 @@ export function ModalWithStandardActionsFooter() { } > - Modal with standard actions footer + Modal with standard actions footer - Customize modal} /> + Customize modal} /> @@ -130,17 +129,15 @@ export function ModalWithHeaderBodyAndCustomFooter() { - Modal with header, body and custom footer + Modal with header, body and custom footer - Customize modal} /> + Customize modal} /> - - Do whatever you want down here - + Do whatever you want down here @@ -176,7 +173,7 @@ export function ModalWithSidebar() { - Settings + Settings
  • @@ -194,7 +191,7 @@ export function ModalWithSidebar() { - Customize modal + Customize modal @@ -242,7 +239,7 @@ export function ModalWithScrollableTabPanels() { flexDirection="column" > - Task content goest here + Task content goest here @@ -304,7 +301,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 @@ -396,7 +391,7 @@ export function ModalAutofocus() { - Update your info + Update your info @@ -435,18 +430,18 @@ 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`. - Parent modal + Parent modal - + Modals may be stacked on top of one another, with each of them having their independent states, e.g. `width` and `height`. @@ -460,7 +455,7 @@ export function StackingModals() { - Nested modal + Nested modal diff --git a/src/password-field/password-field.stories.jsx b/src/password-field/password-field.stories.jsx index 596f22bc..5b6a5897 100644 --- a/src/password-field/password-field.stories.jsx +++ b/src/password-field/password-field.stories.jsx @@ -190,8 +190,8 @@ export const WithoutLabel = { render: () => ( - Custom label is up here - + Custom label is up here + (click me to focus the textarea) @@ -202,8 +202,8 @@ export const WithoutLabel = { placeholder="Password field without a built-in label" /> - Custom description is down here - + Custom description is down here + (inspect the input element accessibility properties if you are curious) diff --git a/src/select-field/select-field.stories.jsx b/src/select-field/select-field.stories.jsx index 44ac73ad..86beb595 100644 --- a/src/select-field/select-field.stories.jsx +++ b/src/select-field/select-field.stories.jsx @@ -184,8 +184,8 @@ export const WithoutLabel = { render: () => ( - Custom label is up here - + Custom label is up here + (click me to focus the select element) @@ -195,8 +195,8 @@ export const WithoutLabel = { - Custom description is down here - + Custom description is down here + (inspect the select element accessibility properties if you are curious) diff --git a/src/text-area/text-area.stories.jsx b/src/text-area/text-area.stories.jsx index c97bd070..e30a6f9b 100644 --- a/src/text-area/text-area.stories.jsx +++ b/src/text-area/text-area.stories.jsx @@ -54,7 +54,7 @@ function AutoExpandStory(props) { } }} /> - + If you press Enter, the input will be cleared. This allows you to test that auto-expand works when the input is cleared programmatically, shrinking the textarea to the new expected height. @@ -265,8 +265,8 @@ export const WithoutLabel = { render: () => ( - Custom label is up here - + Custom label is up here + (click me to focus the textarea) @@ -277,8 +277,8 @@ export const WithoutLabel = { rows={8} /> - Custom description is down here - + Custom description is down here + (inspect the textarea accessibility properties if you are curious) diff --git a/src/text-field/text-field.stories.jsx b/src/text-field/text-field.stories.jsx index a709e6ff..4167953a 100644 --- a/src/text-field/text-field.stories.jsx +++ b/src/text-field/text-field.stories.jsx @@ -366,8 +366,8 @@ export const WithoutLabel = { render: () => ( - Custom label is up here - + Custom label is up here + (click me to focus the input element) @@ -378,8 +378,8 @@ export const WithoutLabel = { placeholder="Text field without a built-in label" /> - Custom description is down here - + Custom description is down here + (inspect the input element accessibility properties if you are curious) diff --git a/src/text/text.mdx b/src/text/text.mdx new file mode 100644 index 00000000..ac2936ea --- /dev/null +++ b/src/text/text.mdx @@ -0,0 +1,49 @@ +import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks' +import { Text } from './text' +import * as TextStories from './text.stories' + + + +# Text + + + +## Usage + +`Text` covers the entire typography scale with named variants. Choose one variant for the complete +Figma style; do not combine independent size and weight values. It uses `body-3` by default. + +```tsx +Default body copy +Emphasized body copy +Underlined caption +Metadata +``` + +Heading variants render the matching heading element (`heading-1` renders `h1`, and so on); all +other variants render `div`. + +```tsx +Page title // renders an h1 +42 completed tasks // renders a div +``` + +Use `render` when the copy needs another HTML element. The rendered element owns its semantics, so +pick heading levels from the document outline, not from the variant number. + +```tsx +}> + Prominent section title + +}> + Edit title + +}> + Project name + + +``` + +## Props + + diff --git a/src/text/text.module.css b/src/text/text.module.css index 69d340f1..b28c33dc 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,60 +1,164 @@ -.text { - font-family: var(--reactist-font-family); - font-size: var(--reactist-font-size-body); - font-weight: var(--reactist-font-weight-regular); - color: var(--product-library-display-primary-idle-tint); +.display { + font-weight: var(--reactist-typography-font-weight-medium); + line-height: normal; } -.size-caption { - font-size: var(--reactist-font-size-caption); +.variant-display-1 { + font-size: 96px; + letter-spacing: 0; } -.size-copy { - font-size: var(--reactist-font-size-copy); + +.variant-display-2 { + font-size: 88px; + letter-spacing: 0; } -.size-subtitle { - font-size: var(--reactist-font-size-subtitle); + +.variant-display-3 { + font-size: 72px; + letter-spacing: 0.14px; +} + +.variant-display-4 { + font-size: 56px; + letter-spacing: 0.3px; } -.weight-semibold { - font-weight: var(--reactist-font-weight-medium); +.variant-display-5 { + font-size: 42px; + letter-spacing: 0.37px; } -.weight-bold { + +.variant-heading-1 { + font-size: 32px; font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0.41px; + line-height: normal; } -.tone-secondary { - color: var(--product-library-display-secondary-idle-tint); +.variant-heading-2 { + font-size: 26px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0.22px; + line-height: normal; } -.tone-danger { - color: var(--product-library-actionable-destructive-idle-tint); + +.variant-heading-3 { + font-size: 20px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0; + line-height: normal; } -.tone-positive { - color: var(--product-library-info-positive-primary-idle-tint); + +.variant-heading-4 { + font-size: 18px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: 0; + line-height: normal; } -/* truncated text */ +.variant-subheader-1 { + font-size: 16px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: 0; + line-height: 23px; +} -.lineClampMultipleLines { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; +.variant-subheader-2 { + font-size: 16px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 23px; } -.lineClamp-1 { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.variant-body-1 { + font-size: 14px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: -0.15px; + line-height: 21px; } -.lineClamp-2 { - -webkit-line-clamp: 2; +.variant-body-2 { + font-size: 14px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 21px; } -.lineClamp-3 { - -webkit-line-clamp: 3; + +.variant-body-3 { + font-size: 14px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 21px; } -.lineClamp-4 { - -webkit-line-clamp: 4; + +.variant-callout-1 { + font-size: 13px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 20px; +} + +.variant-callout-2 { + font-size: 13px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 20px; } -.lineClamp-5 { - -webkit-line-clamp: 5; + +.variant-caption-1 { + font-size: 12px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0; + line-height: 20px; +} + +.variant-caption-2 { + font-size: 12px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 15px; +} + +.variant-caption-3 { + font-size: 12px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 20px; +} + +.variant-footnote-1 { + font-size: 10px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 1px; + line-height: 13px; +} + +.variant-footnote-2 { + font-size: 10px; + font-weight: var(--reactist-typography-font-weight-medium); + letter-spacing: 1px; + line-height: 13px; +} + +.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; +} + +.variant-caption-2.decoration-underline, +.variant-caption-3.decoration-underline { + text-decoration-line: underline; + text-decoration-skip-ink: none; + text-underline-position: from-font; +} + +.variant-footnote-1.case-uppercase { + text-transform: uppercase; } diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 8800540a..7890d8b9 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -5,14 +5,35 @@ import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-h import { Text } from './text' +const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const + +const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const + +const bodyVariants = [ + '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 allVariants = [...displayVariants, ...headingVariants, ...bodyVariants] as const + export default { title: '🔤 Typography/Text', component: Text, parameters: { badges: ['accessible'], figma: { - path: 'Global › Text Styles › SF *FOR WEB* › Body 1', - url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=2524-3594', + path: 'Global › Text Styles › SF *FOR WEB*', + url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316', }, }, } @@ -21,87 +42,68 @@ export function TextStory() { return (
    - - Subtitle Regular - - - Subtitle Secondary - - - Subtitle Danger + {bodyVariants.map((variant) => ( + + {variant} + + ))} + + caption-2 underline - - Subtitle Positive + + caption-3 strikethrough - - Subtitle Semibold - - - Subtitle Bold + + footnote-1 uppercase + +
    + ) +} - - Body Regular - - - Body Secondary - - - Body Danger - - - Body Positive - - - Body Semibold - - - Body Bold - +TextStory.parameters = { + chromatic: { disableSnapshot: false }, +} - - Copy Regular - - - Copy Secondary - - - Copy Danger - - - Copy Positive - - - Copy Semibold +export function HeadingTextStory() { + return ( +
    + + {headingVariants.map((variant) => ( + + {variant} + + ))} + }> + Semantic h2, visual heading-1 - - Copy Bold + }> + Button with heading typography + +
    + ) +} - - Caption Regular - - - Caption Secondary - - - Caption Danger - - - Caption Positive - - - Caption Semibold - - - Caption Bold - +HeadingTextStory.parameters = { + chromatic: { disableSnapshot: false }, +} + +export function DisplayTextStory() { + return ( +
    + + {displayVariants.map((variant) => ( + + {variant} + + ))}
    ) } -TextStory.parameters = { +DisplayTextStory.parameters = { chromatic: { disableSnapshot: false }, } @@ -134,6 +136,12 @@ export function TruncatedTextStory() { temporibus, omnis laborum quidem autem totam. Iure, numquam. Totam facilis dolorum, consequatur, eligendi est dolores modi dolore maiores ipsum magnam a.
    + + + This is a long title which we will use demonstrate truncating content. When this + overflows and begins to drop to a new line, its overflowing content will be + replaced by ellipses. +
    ) @@ -153,15 +161,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(allVariants), + decoration: selectWithNone(['strikethrough', 'underline']), + case: selectWithNone(['uppercase']), lineClamp: selectWithNone([1, 2, 3, 4, 5]), tone: select(['normal', 'secondary', 'danger']), align: { control: false }, @@ -179,15 +187,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(allVariants), + 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 0e058516..b3667cd7 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -1,9 +1,45 @@ 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 displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const + +const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const + +const bodyVariants = [ + '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,71 +56,127 @@ describe('Text', () => { expect(screen.getByTestId('text-element')).not.toHaveClass('wrong') }) - it('can be rendered as any HTML element', () => { + it('defaults to body-3 rendered as a div', () => { + render(Text) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('DIV') + expect(element).toHaveClass('variant-body-3') + }) + + it.each([...displayVariants, ...headingVariants, ...bodyVariants])( + 'applies the %s variant', + (variant) => { + render( + + Text + , + ) + expect(screen.getByTestId('text-element')).toHaveClass('variant-' + variant) + }, + ) + + it.each(bodyVariants)('renders %s as a div', (variant) => { render( - + Text , ) - expect(screen.getByTestId('text-element').tagName).toBe('NAV') + expect(screen.getByTestId('text-element').tagName).toBe('DIV') }) - it('renders its children as its content', () => { + it.each([ + ['heading-1', 'H1'], + ['heading-2', 'H2'], + ['heading-3', 'H3'], + ['heading-4', 'H4'], + ] as const)('renders %s as %s', (variant, tagName) => { render( - - Hello world + + Text , ) - expect(screen.getByTestId('text-element').innerHTML).toMatchInlineSnapshot( - `"Hello world"`, + expect(screen.getByTestId('text-element').tagName).toBe(tagName) + }) + + it.each(displayVariants)('renders %s as a div with the display font', (variant) => { + render( + + Text + , ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('DIV') + expect(element).toHaveClass('display') + expect(element).toHaveClass('font-family-sf-for-web') }) - 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') + it('renders custom elements through Ariakit Role', () => { + render( + }> + Name + , + ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('LABEL') + expect(element).toHaveAttribute('for', 'name') + }) - for (const size of ['caption', 'copy', 'subtitle'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`size-${size}`) - } - }) + it('lets render override the heading variant default element', () => { + render( + }> + Text + , + ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('H2') + expect(element).toHaveClass('variant-heading-1') }) - 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') + it('lets render override the display variant default element', () => { + render( + }> + Text + , + ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('H1') + expect(element).toHaveClass('variant-display-1') + }) - for (const weight of ['semibold', 'bold'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`weight-${weight}`) - } - }) + it('applies heading typography to non-heading controls', () => { + render( + }> + Edit title + , + ) + expect(screen.getByRole('button', { name: 'Edit title' })).toHaveClass('variant-heading-2') + }) + + it('forwards its ref', () => { + const ref = React.createRef() + render(Text) + expect(ref.current?.tagName).toBe('DIV') + }) + + it('forwards its ref to the variant default element', () => { + const ref = React.createRef() + render( + + Text + , + ) + expect(ref.current?.tagName).toBe('H2') + }) + + it('renders its children as its content', () => { + render( + + Hello world + , + ) + expect(screen.getByTestId('text-element').innerHTML).toMatchInlineSnapshot( + `"Hello world"`, + ) }) describe('tone="…"', () => { @@ -175,4 +267,67 @@ 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 invalidBodyModifier = ( + // @ts-expect-error body-1 does not support decoration + + Invalid + + ) + const invalidHeadingModifier = ( + // @ts-expect-error heading variants do not support decoration + + Invalid + + ) + const invalidDisplayModifier = ( + // @ts-expect-error display variants do not support case + + Invalid + + ) + expect(invalidBodyModifier).toBeDefined() + expect(invalidHeadingModifier).toBeDefined() + expect(invalidDisplayModifier).toBeDefined() + }) + + it('has no accessibility violations', async () => { + const { container } = render( + <> + Display + Heading + }> + Button heading + + Default text + + Caption + + }> + Name + + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) }) diff --git a/src/text/text.tsx b/src/text/text.tsx index b0bf3642..e0c39d07 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -1,98 +1,150 @@ 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 typographyStyles from '../typography/typography.module.css' import styles from './text.module.css' -import type { BoxProps } from '../box' -import type { Tone } from '../utils/common-types' - -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'] +import type { RoleProps } from '@ariakit/react' +import type { TypographyStyleProps } from '../typography/typography' + +type DisplayTextVariant = 'display-1' | 'display-2' | 'display-3' | 'display-4' | 'display-5' +type HeadingTextVariant = 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' +type BodyTextVariant = + | '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 TextVariant = DisplayTextVariant | HeadingTextVariant | BodyTextVariant + +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 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( +/** Renders interface copy with a named typography variant, from display text to footnotes. */ +type TextProps = Omit, 'children' | 'className'> & + TypographyStyleProps & { + /** Text content. */ + children: React.ReactNode + /** + * Custom element rendered with the variant's typography. Defaults to the matching heading + * element for heading variants, and a div otherwise. + */ + render?: RoleProps['render'] + } & (StrikethroughTextProps | UnderlinedTextProps | UppercaseTextProps | UnmodifiedTextProps) + +function isDisplayVariant(variant: TextVariant): variant is DisplayTextVariant { + return variant.startsWith('display-') +} + +function isHeadingVariant(variant: TextVariant): variant is HeadingTextVariant { + return variant.startsWith('heading-') +} + +function getDefaultRender(variant: TextVariant): RoleProps['render'] { + if (isHeadingVariant(variant)) { + return React.createElement('h' + variant.slice('heading-'.length)) + } + + return undefined +} + +/** Renders interface copy with a named typography variant, from display text to footnotes. */ +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 + const display = isDisplayVariant(variant) 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 39f8b680..02c081a8 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/toast/toast.stories.tsx b/src/toast/toast.stories.tsx index fec548ee..3fb03770 100644 --- a/src/toast/toast.stories.tsx +++ b/src/toast/toast.stories.tsx @@ -5,7 +5,6 @@ import { action as storybookAction } from 'storybook/actions' import { Box } from '../box' import { Button, IconButton } from '../button' import { CheckboxField } from '../checkbox-field' -import { Heading } from '../heading' import { AlertIcon } from '../icons/alert-icon' import { PasswordVisibleIcon } from '../icons/password-visible-icon' import { Inline } from '../inline' @@ -65,9 +64,9 @@ export function NotificationToastsStory() { return ( - + }> Toasts - + Use the useToast hook to fire notification-like toasts. @@ -172,9 +171,9 @@ export function StaticToastStory() { return ( - + }> Statically-rendered toasts - + Use the StaticToast component to render a toast in custom positions. @@ -216,7 +215,7 @@ export function StaticToastStory() { - Message only + Message only - Message and description + Message and description - Very long content + Very long content - - Upgrade to Pro - + Upgrade to Pro
    • Add reminders to tasks
    • Unlimited projects
    • @@ -253,7 +251,7 @@ export function TooltipImperativeControl() { - + Try hovering the button, then clicking "Force hide" before the 3-second timeout expires. From 03b67f4927810cab3a89f5314bd741d92013a6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:09:07 -0500 Subject: [PATCH 03/18] feat!: drop Heading in favor of Text heading variants BREAKING CHANGE: Heading is removed. Use Text with a heading variant, e.g. becomes , which renders an h2. Use render to decouple semantics from the visual style. --- src/button/button.mdx | 1 - src/button/button.stories.jsx | 5 +- src/button/icon-button.mdx | 1 - src/button/icon-button.stories.jsx | 3 +- src/heading/heading.module.css | 114 --------------- src/heading/heading.stories.tsx | 156 -------------------- src/heading/heading.test.tsx | 219 ----------------------------- src/heading/heading.tsx | 122 ---------------- src/heading/index.ts | 1 - src/index.ts | 1 - src/inline/inline.stories.tsx | 4 +- src/stack/stack.stories.tsx | 8 +- src/utils/storybook-helper.tsx | 4 +- 13 files changed, 11 insertions(+), 628 deletions(-) delete mode 100644 src/heading/heading.module.css delete mode 100644 src/heading/heading.stories.tsx delete mode 100644 src/heading/heading.test.tsx delete mode 100644 src/heading/heading.tsx delete mode 100644 src/heading/index.ts diff --git a/src/button/button.mdx b/src/button/button.mdx index f8da6a30..3c2a1427 100644 --- a/src/button/button.mdx +++ b/src/button/button.mdx @@ -4,7 +4,6 @@ import { Box } from '../box' import { Inline } from '../inline' import { Stack } from '../stack' import { Text } from '../text' -import { Heading } from '../heading' import { Button } from './button' import * as ButtonStories from './button.stories' diff --git a/src/button/button.stories.jsx b/src/button/button.stories.jsx index 9597ef6a..0fbb4dd7 100644 --- a/src/button/button.stories.jsx +++ b/src/button/button.stories.jsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react' import { withDarkTheme } from '../../.storybook/dark-theme' import { Box } from '../box' -import { Heading } from '../heading' import { Inline } from '../inline' import { Stack } from '../stack' import { Text } from '../text' @@ -53,7 +52,7 @@ function FullWidthTemplate({ label, ...otherProps }) { } return ( - Full-width buttons and label alignment + Full-width buttons and label alignment When buttons have `width` other than the default `auto` they can also customize how the label is aligned horizontally. @@ -87,7 +86,7 @@ function PlaygroundTemplate({ label, ...props }) { } return ( - Click on the buttons to see the loading state + Click on the buttons to see the loading state diff --git a/src/button/icon-button.mdx b/src/button/icon-button.mdx index ffbd23e7..f1698ee2 100644 --- a/src/button/icon-button.mdx +++ b/src/button/icon-button.mdx @@ -4,7 +4,6 @@ import { Box } from '../box' import { Inline } from '../inline' import { Stack } from '../stack' import { Text } from '../text' -import { Heading } from '../heading' import { IconButton } from '../button' import * as IconButtonStories from './icon-button.stories' diff --git a/src/button/icon-button.stories.jsx b/src/button/icon-button.stories.jsx index 0e06ceca..3d86c926 100644 --- a/src/button/icon-button.stories.jsx +++ b/src/button/icon-button.stories.jsx @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react' import { withDarkTheme } from '../../.storybook/dark-theme' import { Box } from '../box' import { IconButton } from '../button' -import { Heading } from '../heading' import { Inline } from '../inline' import { Stack } from '../stack' import { Text } from '../text' @@ -35,7 +34,7 @@ function LoadingButton(props) { function PlaygroundTemplate({ label, ...props }) { return ( - Click on the buttons to see the loading state + Click on the buttons to see the loading state diff --git a/src/heading/heading.module.css b/src/heading/heading.module.css deleted file mode 100644 index 2ca4387a..00000000 --- a/src/heading/heading.module.css +++ /dev/null @@ -1,114 +0,0 @@ -.heading { - color: var(--product-library-display-primary-idle-tint); - font-weight: var(--reactist-font-weight-strong); - font-family: var(--reactist-font-family); -} - -.weight-medium { - font-weight: var(--reactist-font-weight-medium); -} - -.weight-light { - font-weight: var(--reactist-font-weight-regular); -} - -/* tone */ - -.tone-secondary { - color: var(--product-library-display-secondary-idle-tint); -} -.tone-danger { - color: rgb(209, 69, 59); -} - -/* font size */ - -h1.heading { - font-size: var(--reactist-font-size-header); -} -h1.size-largest { - font-size: var(--reactist-font-size-header-xlarge); -} -h1.size-larger { - font-size: var(--reactist-font-size-header-large); -} -h1.size-smaller { - font-size: var(--reactist-font-size-subtitle); -} - -h2.heading { - font-size: var(--reactist-font-size-subtitle); -} -h2.size-largest { - font-size: var(--reactist-font-size-header-large); -} -h2.size-larger { - font-size: var(--reactist-font-size-header); -} -h2.size-smaller { - font-size: var(--reactist-font-size-body); -} - -h3.heading { - font-size: var(--reactist-font-size-body); -} -h3.size-largest { - font-size: var(--reactist-font-size-header); -} -h3.size-larger { - font-size: var(--reactist-font-size-subtitle); -} -h3.size-smaller { - font-size: var(--reactist-font-size-caption); -} - -h4.heading, -h5.heading, -h6.heading { - /* - * unlike at higher levels, this one is kept as the same size as h3's - * you can make it two levels larger visually, but making it smaller has no effect - */ - font-size: var(--reactist-font-size-body); -} - -h4.size-largest, -h5.size-largest, -h6.size-largest { - font-size: var(--reactist-font-size-header); -} - -h4.size-larger, -h5.size-larger, -h6.size-larger { - font-size: var(--reactist-font-size-subtitle); -} - -/* h4/h5/h6 can't be made smaller, maybe we reconsider this? */ - -/* truncated text */ - -.lineClampMultipleLines { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.lineClamp-1 { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -.lineClamp-2 { - -webkit-line-clamp: 2; -} -.lineClamp-3 { - -webkit-line-clamp: 3; -} -.lineClamp-4 { - -webkit-line-clamp: 4; -} -.lineClamp-5 { - -webkit-line-clamp: 5; -} diff --git a/src/heading/heading.stories.tsx b/src/heading/heading.stories.tsx deleted file mode 100644 index 3ecf79e4..00000000 --- a/src/heading/heading.stories.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import * as React from 'react' - -import { Stack } from '../stack' -import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-helper' - -import { Heading } from './heading' - -export default { - title: '🔤 Typography/Heading', - component: Heading, - parameters: { - badges: ['accessible'], - figma: { - path: 'Global › Text Styles › SF *FOR WEB* › Header 1', - url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=2524-3589', - }, - }, -} - -export function HeadingStory() { - return ( -
      - - - - Heading level 1, largest - - - Heading level 1, larger - - Heading level 1 - - Heading level 1, smaller - - - - - - Heading level 2, largest - - - Heading level 2, larger - - Heading level 2 - - Heading level 2, smaller - - - - - - Heading level 3, largest - - - Heading level 3, larger - - Heading level 3 - - Heading level 3, smaller - - - - - - Heading level 4 / 5 / 6, largest - - - Heading level 4 / 5 / 6, larger - - Heading level 4 / 5 / 6 - - -
      - ) -} - -HeadingStory.parameters = { - chromatic: { disableSnapshot: false }, -} - -export function TruncatedHeadingStory() { - return ( -
      - - This is a long title which we will use demonstrate truncating content. When this - overflows and begins to drop to a new line, its overflowing content will be replaced - by ellipses. - - - Now we have a subtitle which we will use to demostrate truncating to multiple lines. - Sometimes we need to provide more context yet still remain skimmable to users, and - subtitles are a good way to do this. As it’s much longer now we can allow a - second line to be displayed before truncating it at two lines. - -
      - ) -} - -TruncatedHeadingStory.parameters = { - chromatic: { disableSnapshot: false }, -} - -export function ResponsiveHeadingStory(props: React.ComponentProps) { - return ( - <> - - - - ) -} - -ResponsiveHeadingStory.args = { - level: '1', - weight: 'regular', - tone: 'normal', - children: 'Lorem ipsum dolor, sit amet consectetur adipisicing elit', -} - -ResponsiveHeadingStory.argTypes = { - level: select(['1', '2', '3', '4', '5', '6']), - size: selectWithNone(['largest', 'larger', 'smaller']), - weight: select(['regular', 'light']), - lineClamp: selectWithNone([1, 2, 3, 4, 5]), - tone: select(['normal', 'secondary', 'danger']), - align: { control: false }, - children: { - control: { type: 'text' }, - }, -} - -export function HeadingPlaygroundStory(props: React.ComponentProps) { - return ( -
      - -
      - ) -} - -HeadingPlaygroundStory.args = { - level: '1', - weight: 'regular', - tone: 'normal', - children: 'Lorem ipsum dolor, sit amet consectetur adipisicing elit', -} - -HeadingPlaygroundStory.argTypes = { - level: select(['1', '2', '3', '4', '5', '6']), - size: selectWithNone(['largest', 'larger', 'smaller']), - weight: select(['regular', 'medium', 'light']), - lineClamp: selectWithNone([1, 2, 3, 4, 5]), - tone: select(['normal', 'secondary', 'danger']), - align: selectWithNone(['start', 'center', 'end', 'justify']), - children: { - control: { type: 'text' }, - }, -} diff --git a/src/heading/heading.test.tsx b/src/heading/heading.test.tsx deleted file mode 100644 index 2fd9f9b6..00000000 --- a/src/heading/heading.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import * as React from 'react' - -import { render, screen } from '@testing-library/react' -import { axe } from 'jest-axe' - -import { Heading } from './heading' - -describe('Heading', () => { - it('does not acknowledge the className prop, but exceptionallySetClassName instead', () => { - render( - - Heading - , - ) - expect(screen.getByTestId('heading-element')).toHaveClass('right') - expect(screen.getByTestId('heading-element')).not.toHaveClass('wrong') - }) - - it('renders the expected heading tag name based on the level', () => { - const { rerender } = render( - - Heading - , - ) - expect(screen.getByTestId('heading-element').tagName).toBe('H1') - - for (const level of [2, 3, 4, 5, 6] as const) { - rerender( - - Heading - , - ) - expect(screen.getByTestId('heading-element').tagName).toBe(`H${level}`) - } - }) - - it('renders its children as its content', () => { - render( - - Hello world - , - ) - expect(screen.getByTestId('heading-element').innerHTML).toMatchInlineSnapshot( - `"Hello world"`, - ) - }) - - describe('size="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement).not.toHaveClass('size-smaller') - expect(textElement).not.toHaveClass('size-larger') - expect(textElement).not.toHaveClass('size-largest') - - for (const size of ['smaller', 'larger', 'largest'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`size-${size}`) - } - }) - }) - - describe('weight="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement).not.toHaveClass('weight-regular') - expect(textElement).not.toHaveClass('weight-light') - - rerender( - - Heading - , - ) - expect(textElement).toHaveClass('weight-medium') - - rerender( - - Heading - , - ) - expect(textElement).toHaveClass('weight-light') - }) - }) - - describe('tone="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement).not.toHaveClass('tone-normal') - expect(textElement).not.toHaveClass('tone-secondary') - expect(textElement).not.toHaveClass('tone-danger') - - for (const tone of ['secondary', 'danger'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`tone-${tone}`) - } - }) - }) - - describe('align="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement).not.toHaveClass('textAlign-start') - expect(textElement).not.toHaveClass('textAlign-center') - expect(textElement).not.toHaveClass('textAlign-end') - expect(textElement).not.toHaveClass('textAlign-justify') - - for (const align of ['start', 'center', 'end', 'justify'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`textAlign-${align}`) - } - }) - - it('supports responsive values', () => { - render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement).toHaveClass('textAlign-start') - expect(textElement).toHaveClass('tablet-textAlign-center') - expect(textElement).toHaveClass('desktop-textAlign-end') - }) - }) - - describe('lineClamp="…"', () => { - it('adds the expected class names', () => { - const { rerender } = render( - - Heading - , - ) - const textElement = screen.getByTestId('heading-element') - expect(textElement.className).not.toMatch(/lineClamp/) - expect(textElement).not.toHaveClass('paddingRight-xsmall') - - for (const lineClamp of [1, '1'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`lineClamp-${lineClamp}`) - expect(textElement).not.toHaveClass(`lineClampMultipleLines`) - expect(textElement).toHaveClass('paddingRight-xsmall') - } - - for (const lineClamp of [2, 3, 4, 5, '2', '3', '4', '5'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`lineClamp-${lineClamp}`) - expect(textElement).toHaveClass(`lineClampMultipleLines`) - expect(textElement).toHaveClass('paddingRight-xsmall') - } - }) - }) - - describe('a11y', () => { - it('renders with no a11y violations', async () => { - const { container } = render( - <> - Heading - Heading - Heading - Heading - Heading - Heading - , - ) - const results = await axe(container) - - expect(results).toHaveNoViolations() - }) - }) -}) diff --git a/src/heading/heading.tsx b/src/heading/heading.tsx deleted file mode 100644 index fb3501e9..00000000 --- a/src/heading/heading.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import * as React from 'react' - -import { Box } from '../box' -import { getClassNames } from '../utils/responsive-props' - -import styles from './heading.module.css' - -import type { BoxProps } from '../box' -import type { ObfuscatedClassName, Tone } from '../utils/common-types' - -type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6' -type HeadingElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' - -type HeadingProps = Omit, 'className' | 'children'> & { - children: React.ReactNode - /** - * The semantic level of the heading. - */ - level: HeadingLevel - - /** - * The weight of the heading. Used to de-emphasize the heading visually when using 'medium' or 'light'. - * - * @default 'regular' - */ - weight?: 'regular' | 'medium' | 'light' - - /** - * Shifts the default heading visual text size up or down, depending on the original size - * imposed by the `level`. The heading continues to be semantically at the given level. - * - * By default, no value is applied, and the default size from the level is applied. The values - * have the following effect: - * - * - 'smaller' shifts the default level size down in the font-size scale (it tends to make the - * level look visually as if it were of the immediately lower level). - * - 'larger' has the opposite effect than 'smaller' shifting the visual font size up in the - * scale. - * - 'largest' can be thought of as applying 'larger' twice. - * - * @see level - * @default undefined - */ - size?: 'smaller' | 'larger' | 'largest' - - /** - * The tone (semantic color) of the heading. - * - * @default 'normal' - */ - tone?: Tone - - /** - * Used to truncate the heading 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 heading text horizontally. - * - * @default 'start' - */ - align?: BoxProps['textAlign'] -} - -const Heading = React.forwardRef( - function Heading( - { - level, - weight = 'regular', - size, - tone = 'normal', - children, - lineClamp, - align, - exceptionallySetClassName, - ...props - }, - ref, - ) { - // In TypeScript v4.1, this would be properly recognized without needing the type assertion - // https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types - const headingElementName = `h${level}` as HeadingElement - const lineClampMultipleLines = - typeof lineClamp === 'string' ? parseInt(lineClamp, 10) > 1 : (lineClamp || 0) > 1 - - return ( - - {children} - - ) - }, -) - -Heading.displayName = 'Heading' - -export type { HeadingLevel, HeadingProps } -export { Heading } diff --git a/src/heading/index.ts b/src/heading/index.ts deleted file mode 100644 index 84e33f1f..00000000 --- a/src/heading/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './heading' diff --git a/src/index.ts b/src/index.ts index 6df21637..3f022411 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,6 @@ export * from './notice' export * from './toast' // text and typography -export * from './heading' export * from './prose' export * from './text' diff --git a/src/inline/inline.stories.tsx b/src/inline/inline.stories.tsx index 8c1d56c4..f71c3a84 100644 --- a/src/inline/inline.stories.tsx +++ b/src/inline/inline.stories.tsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { Heading } from '../heading' import { Stack } from '../stack' +import { Text } from '../text' import { disableResponsiveProps, Placeholder, @@ -96,7 +96,7 @@ export function NestedStackStory({ space }: PartialProps) { const spaceStr = typeof space !== 'string' ? 'none' : space return ( - Parent stack with space=“{spaceStr}” + Parent stack with space=“{spaceStr}” {renderInlineContent()} {renderInlineContent()} diff --git a/src/stack/stack.stories.tsx b/src/stack/stack.stories.tsx index 6131dd4a..632aeba3 100644 --- a/src/stack/stack.stories.tsx +++ b/src/stack/stack.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Heading } from '../heading' +import { Text } from '../text' import { disableResponsiveProps, Placeholder, @@ -99,15 +99,15 @@ export function NestedStacksStory(args: PartialProps) { const spaceStr = typeof args.space !== 'string' ? 'none' : args.space return ( - Parent stack with space=“{spaceStr}” + Parent stack with space=“{spaceStr}” - Nested stack with space=“xsmall” + Nested stack with space=“xsmall” - Nested stack with space=“xsmall” + Nested stack with space=“xsmall” diff --git a/src/utils/storybook-helper.tsx b/src/utils/storybook-helper.tsx index 60035af2..dd840c13 100644 --- a/src/utils/storybook-helper.tsx +++ b/src/utils/storybook-helper.tsx @@ -3,8 +3,8 @@ import '../styles/design-tokens.css' import * as React from 'react' import { Box } from '../box' -import { Heading } from '../heading' import { Stack } from '../stack' +import { Text } from '../text' import type { JSX } from 'react' import type { BoxProps } from '../box' @@ -93,7 +93,7 @@ function Wrapper({ }) { return ( - {title ? {title} : null} + {title ? {title} : null} {children} From 4c4e7036198b160dc02355073291ae1db76b1e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:09:10 -0500 Subject: [PATCH 04/18] docs: add SF Pro typography reference story --- src/typography/typography.stories.module.css | 39 +++++ src/typography/typography.stories.tsx | 149 +++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/typography/typography.stories.module.css create mode 100644 src/typography/typography.stories.tsx diff --git a/src/typography/typography.stories.module.css b/src/typography/typography.stories.module.css new file mode 100644 index 00000000..bd846d27 --- /dev/null +++ b/src/typography/typography.stories.module.css @@ -0,0 +1,39 @@ +.reference { + --reactist-content-primary: #000; + + width: 400px; + height: 1628px; + color: #000; +} + +.header { + box-sizing: border-box; + width: 400px; + height: 43px; + margin: 0; + padding: 0; + font-family: var(--reactist-font-family); + font-size: 32px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0.41px; + line-height: normal; +} + +.rows { + display: flex; + flex-direction: column; + gap: 16px; + width: 400px; +} + +.row, +.rowEnd { + display: flex; + flex: 0 0 auto; + align-items: center; + width: 400px; +} + +.rowEnd { + align-items: flex-end; +} diff --git a/src/typography/typography.stories.tsx b/src/typography/typography.stories.tsx new file mode 100644 index 00000000..310150de --- /dev/null +++ b/src/typography/typography.stories.tsx @@ -0,0 +1,149 @@ +import * as React from 'react' + +import { Text } from '../text' + +import styles from './typography.stories.module.css' + +type ReferenceRow = { + height: number + content?: React.ReactNode + alignEnd?: boolean +} + +const rows: ReferenceRow[] = [ + { height: 128, content: Display 1, alignEnd: true }, + { height: 117, content: Display 2 }, + { height: 96, content: Display 3 }, + { height: 74, content: Display 4 }, + { height: 56, content: Display 5 }, + { height: 43, content: Header 1 }, + { height: 35, content: Header 2 }, + { height: 27, content: Header 3 }, + { height: 24, content: Header 4 }, + { height: 23, content: Subheader 1 }, + { + height: 23, + content: ( + + Subheader 1 Strikethrough + + ), + }, + { height: 24, content: Subheader 2 }, + { + height: 24, + content: ( + + Subheader 2 Strikethrough + + ), + }, + { height: 24 }, + { height: 24 }, + { height: 21, content: Body 1 }, + { height: 22, content: Body 2 }, + { height: 22, content: Body 3 }, + { + height: 22, + content: ( + + Body 3 Strikethrough + + ), + }, + { height: 20, content: Callout 1 }, + { + height: 20, + content: ( + + Callout 1 Strikethrough + + ), + }, + { height: 20, content: Callout 2 }, + { + height: 20, + content: ( + + Callout 2 Strikethrough + + ), + }, + { height: 20, content: Caption 1 }, + { height: 15, content: Caption 2 }, + { + height: 15, + content: ( + + Caption 2 Strikethrough + + ), + }, + { + height: 15, + content: ( + + Caption 2 Underline + + ), + }, + { height: 20, content: Caption 3 }, + { + height: 20, + content: ( + + Caption 3 Underline + + ), + }, + { + height: 20, + content: ( + + Caption 3 Strikethrough + + ), + }, + { height: 13, content: Footnote 1 }, + { + height: 13, + content: ( + + Footnote 1 Caps + + ), + }, + { height: 13, content: Footnote 2 }, +] + +export default { + title: '🔤 Typography/SF Pro Reference', + parameters: { + figma: { + path: 'Global > Text Styles > SF *FOR WEB*', + url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316', + }, + }, +} + +export function SFReference() { + return ( +
      +
      Web (SF – default)
      +
      + {rows.map(({ height, content, alignEnd }, index) => ( +
      + {content} +
      + ))} +
      +
      + ) +} + +SFReference.parameters = { chromatic: { disableSnapshot: false } } From d71dcd3b2f5010e7624a983c9933d3ccd0d85784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:40:01 -0500 Subject: [PATCH 05/18] refactor(text): simplify modifier selectors --- src/text/text.module.css | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/text/text.module.css b/src/text/text.module.css index b28c33dc..122dbba2 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -140,25 +140,18 @@ line-height: 13px; } -.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 { +.decoration-strikethrough { text-decoration-line: line-through; text-decoration-skip-ink: none; text-underline-position: from-font; } -.variant-caption-2.decoration-underline, -.variant-caption-3.decoration-underline { +.decoration-underline { text-decoration-line: underline; text-decoration-skip-ink: none; text-underline-position: from-font; } -.variant-footnote-1.case-uppercase { +.case-uppercase { text-transform: uppercase; } From 1180fd62ca29a12f62dbc464e618001be80aed16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:43:47 -0500 Subject: [PATCH 06/18] refactor(text): centralize variant lists --- src/text/index.ts | 3 ++- src/text/text.stories.tsx | 21 +-------------------- src/text/text.test.tsx | 21 +-------------------- src/text/text.tsx | 39 +++++++++++++++++++++++---------------- 4 files changed, 27 insertions(+), 57 deletions(-) diff --git a/src/text/index.ts b/src/text/index.ts index 1693b580..62f1523a 100644 --- a/src/text/index.ts +++ b/src/text/index.ts @@ -1 +1,2 @@ -export * from './text' +export type { TextProps, TextVariant } from './text' +export { Text } from './text' diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 7890d8b9..491e1a0e 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -3,26 +3,7 @@ import * as React from 'react' import { Stack } from '../stack' import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-helper' -import { Text } from './text' - -const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const - -const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const - -const bodyVariants = [ - '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 +import { bodyVariants, displayVariants, headingVariants, Text } from './text' const allVariants = [...displayVariants, ...headingVariants, ...bodyVariants] as const diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index b3667cd7..2fce1e20 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -3,29 +3,10 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Text } from './text' +import { bodyVariants, displayVariants, headingVariants, Text } from './text' import type { TextProps } from './text' -const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const - -const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const - -const bodyVariants = [ - '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' }, diff --git a/src/text/text.tsx b/src/text/text.tsx index e0c39d07..ef1270b6 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -10,21 +10,28 @@ import styles from './text.module.css' import type { RoleProps } from '@ariakit/react' import type { TypographyStyleProps } from '../typography/typography' -type DisplayTextVariant = 'display-1' | 'display-2' | 'display-3' | 'display-4' | 'display-5' -type HeadingTextVariant = 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' -type BodyTextVariant = - | 'subheader-1' - | 'subheader-2' - | 'body-1' - | 'body-2' - | 'body-3' - | 'callout-1' - | 'callout-2' - | 'caption-1' - | 'caption-2' - | 'caption-3' - | 'footnote-1' - | 'footnote-2' +const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const + +const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const + +const bodyVariants = [ + '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 + +type DisplayTextVariant = (typeof displayVariants)[number] +type HeadingTextVariant = (typeof headingVariants)[number] +type BodyTextVariant = (typeof bodyVariants)[number] type TextVariant = DisplayTextVariant | HeadingTextVariant | BodyTextVariant @@ -147,4 +154,4 @@ const Text = React.forwardRef(function Text( Text.displayName = 'Text' export type { TextProps, TextVariant } -export { Text } +export { bodyVariants, displayVariants, headingVariants, Text } From f47d53e3ebb32f62eee39e59ee411ddd05acf651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 16:47:07 -0500 Subject: [PATCH 07/18] docs(text): remove redundant prop comments --- src/text/text.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/text/text.tsx b/src/text/text.tsx index ef1270b6..b491d7ba 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -45,43 +45,34 @@ type StrikethroughTextProps = { | '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 UppercaseTextProps = { /** Visual footnote style supporting uppercase. */ variant: 'footnote-1' - /** Decoration is unavailable with uppercase presentation. */ decoration?: never - /** Figma-supported uppercase presentation. */ case: 'uppercase' } /** Renders interface copy with a named typography variant, from display text to footnotes. */ type TextProps = Omit, 'children' | 'className'> & TypographyStyleProps & { - /** Text content. */ children: React.ReactNode /** * Custom element rendered with the variant's typography. Defaults to the matching heading From ea815da4fd8dcba926c5c1688fd1e7c41bc69dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 17:02:48 -0500 Subject: [PATCH 08/18] refactor(text): absorb typography internals --- src/text/text.module.css | 72 ++++++++- src/text/text.stories.tsx | 53 +------ src/text/text.tsx | 59 +++++++- src/typography/typography.module.css | 57 ------- src/typography/typography.stories.module.css | 39 ----- src/typography/typography.stories.tsx | 149 ------------------- src/typography/typography.ts | 56 ------- 7 files changed, 117 insertions(+), 368 deletions(-) delete mode 100644 src/typography/typography.module.css delete mode 100644 src/typography/typography.stories.module.css delete mode 100644 src/typography/typography.stories.tsx delete mode 100644 src/typography/typography.ts diff --git a/src/text/text.module.css b/src/text/text.module.css index 122dbba2..4298aa35 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,5 +1,63 @@ +:root { + --reactist-text-font-family-sf-for-web: 'SF Pro Display', sans-serif; + --reactist-text-font-weight-medium: 500; + --reactist-text-font-weight-semibold: 600; +} + +.text { + color: var(--product-library-display-primary-idle-tint); +} + +.font-family-default { + font-family: var(--reactist-font-family); +} + +.font-family-sf-for-web { + font-family: var(--reactist-text-font-family-sf-for-web); +} + +.tone-secondary { + color: var(--product-library-display-secondary-idle-tint); +} + +.tone-danger { + color: var(--product-library-actionable-destructive-idle-tint); +} + +.tone-positive { + color: var(--product-library-info-positive-primary-idle-tint); +} + +.lineClampMultipleLines { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.lineClamp-1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lineClamp-2 { + -webkit-line-clamp: 2; +} + +.lineClamp-3 { + -webkit-line-clamp: 3; +} + +.lineClamp-4 { + -webkit-line-clamp: 4; +} + +.lineClamp-5 { + -webkit-line-clamp: 5; +} + .display { - font-weight: var(--reactist-typography-font-weight-medium); + font-weight: var(--reactist-text-font-weight-medium); line-height: normal; } @@ -51,14 +109,14 @@ .variant-heading-4 { font-size: 18px; - font-weight: var(--reactist-typography-font-weight-semibold); + font-weight: var(--reactist-text-font-weight-semibold); letter-spacing: 0; line-height: normal; } .variant-subheader-1 { font-size: 16px; - font-weight: var(--reactist-typography-font-weight-semibold); + font-weight: var(--reactist-text-font-weight-semibold); letter-spacing: 0; line-height: 23px; } @@ -79,7 +137,7 @@ .variant-body-2 { font-size: 14px; - font-weight: var(--reactist-typography-font-weight-semibold); + font-weight: var(--reactist-text-font-weight-semibold); letter-spacing: -0.15px; line-height: 21px; } @@ -93,7 +151,7 @@ .variant-callout-1 { font-size: 13px; - font-weight: var(--reactist-typography-font-weight-semibold); + font-weight: var(--reactist-text-font-weight-semibold); letter-spacing: -0.15px; line-height: 20px; } @@ -114,7 +172,7 @@ .variant-caption-2 { font-size: 12px; - font-weight: var(--reactist-typography-font-weight-semibold); + font-weight: var(--reactist-text-font-weight-semibold); letter-spacing: -0.15px; line-height: 15px; } @@ -135,7 +193,7 @@ .variant-footnote-2 { font-size: 10px; - font-weight: var(--reactist-typography-font-weight-medium); + font-weight: var(--reactist-text-font-weight-medium); letter-spacing: 1px; line-height: 13px; } diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 491e1a0e..067ca645 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -23,20 +23,11 @@ export function TextStory() { return (
      - {bodyVariants.map((variant) => ( + {allVariants.map((variant) => ( {variant} ))} - - caption-2 underline - - - caption-3 strikethrough - - - footnote-1 uppercase -
      ) @@ -46,48 +37,6 @@ TextStory.parameters = { chromatic: { disableSnapshot: false }, } -export function HeadingTextStory() { - return ( -
      - - {headingVariants.map((variant) => ( - - {variant} - - ))} - }> - Semantic h2, visual heading-1 - - }> - Button with heading typography - - -
      - ) -} - -HeadingTextStory.parameters = { - chromatic: { disableSnapshot: false }, -} - -export function DisplayTextStory() { - return ( -
      - - {displayVariants.map((variant) => ( - - {variant} - - ))} - -
      - ) -} - -DisplayTextStory.parameters = { - chromatic: { disableSnapshot: false }, -} - export function TruncatedTextStory() { return (
      diff --git a/src/text/text.tsx b/src/text/text.tsx index b491d7ba..4681d382 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -1,14 +1,16 @@ import * as React from 'react' import { Role } from '@ariakit/react' +import classNames from 'classnames' -import { getTypographyClassName } from '../typography/typography' +import { getBoxClassNames } from '../box' +import { getClassNames } from '../utils/responsive-props' -import typographyStyles from '../typography/typography.module.css' import styles from './text.module.css' import type { RoleProps } from '@ariakit/react' -import type { TypographyStyleProps } from '../typography/typography' +import type { BoxProps } from '../box' +import type { ObfuscatedClassName, Tone } from '../utils/common-types' const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const @@ -34,6 +36,22 @@ type HeadingTextVariant = (typeof headingVariants)[number] type BodyTextVariant = (typeof bodyVariants)[number] type TextVariant = DisplayTextVariant | HeadingTextVariant | BodyTextVariant +type TextLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' + +type TextStyleProps = ObfuscatedClassName & { + /** The semantic color of the text. */ + tone?: Tone + /** Horizontal text alignment, including responsive values. */ + align?: BoxProps['textAlign'] + /** Truncates text after the given number of lines. */ + lineClamp?: TextLineClamp +} + +type TextClassNameOptions = TextStyleProps & { + variantClassName: string + fontFamilyClassName?: string + modifierClassNames?: Array +} type StrikethroughTextProps = { /** Visual text style supporting strikethrough. */ @@ -72,7 +90,7 @@ type UppercaseTextProps = { /** Renders interface copy with a named typography variant, from display text to footnotes. */ type TextProps = Omit, 'children' | 'className'> & - TypographyStyleProps & { + TextStyleProps & { children: React.ReactNode /** * Custom element rendered with the variant's typography. Defaults to the matching heading @@ -97,6 +115,33 @@ function getDefaultRender(variant: TextVariant): RoleProps['render'] { return undefined } +function getTextClassName({ + variantClassName, + fontFamilyClassName = styles['font-family-default'], + modifierClassNames, + tone = 'normal', + align, + lineClamp, + exceptionallySetClassName, +}: TextClassNameOptions) { + const lineClampMultipleLines = Number(lineClamp ?? 0) > 1 + + return classNames( + getBoxClassNames({ + textAlign: align, + paddingRight: lineClamp ? 'xsmall' : undefined, + }), + exceptionallySetClassName, + styles.text, + fontFamilyClassName, + variantClassName, + modifierClassNames, + tone !== 'normal' ? getClassNames(styles, 'tone', tone) : null, + lineClampMultipleLines ? styles.lineClampMultipleLines : null, + lineClamp ? getClassNames(styles, 'lineClamp', String(lineClamp)) : null, + ) +} + /** Renders interface copy with a named typography variant, from display text to footnotes. */ const Text = React.forwardRef(function Text( { @@ -119,11 +164,9 @@ const Text = React.forwardRef(function Text( Display 1, alignEnd: true }, - { height: 117, content: Display 2 }, - { height: 96, content: Display 3 }, - { height: 74, content: Display 4 }, - { height: 56, content: Display 5 }, - { height: 43, content: Header 1 }, - { height: 35, content: Header 2 }, - { height: 27, content: Header 3 }, - { height: 24, content: Header 4 }, - { height: 23, content: Subheader 1 }, - { - height: 23, - content: ( - - Subheader 1 Strikethrough - - ), - }, - { height: 24, content: Subheader 2 }, - { - height: 24, - content: ( - - Subheader 2 Strikethrough - - ), - }, - { height: 24 }, - { height: 24 }, - { height: 21, content: Body 1 }, - { height: 22, content: Body 2 }, - { height: 22, content: Body 3 }, - { - height: 22, - content: ( - - Body 3 Strikethrough - - ), - }, - { height: 20, content: Callout 1 }, - { - height: 20, - content: ( - - Callout 1 Strikethrough - - ), - }, - { height: 20, content: Callout 2 }, - { - height: 20, - content: ( - - Callout 2 Strikethrough - - ), - }, - { height: 20, content: Caption 1 }, - { height: 15, content: Caption 2 }, - { - height: 15, - content: ( - - Caption 2 Strikethrough - - ), - }, - { - height: 15, - content: ( - - Caption 2 Underline - - ), - }, - { height: 20, content: Caption 3 }, - { - height: 20, - content: ( - - Caption 3 Underline - - ), - }, - { - height: 20, - content: ( - - Caption 3 Strikethrough - - ), - }, - { height: 13, content: Footnote 1 }, - { - height: 13, - content: ( - - Footnote 1 Caps - - ), - }, - { height: 13, content: Footnote 2 }, -] - -export default { - title: '🔤 Typography/SF Pro Reference', - parameters: { - figma: { - path: 'Global > Text Styles > SF *FOR WEB*', - url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316', - }, - }, -} - -export function SFReference() { - return ( -
      -
      Web (SF – default)
      -
      - {rows.map(({ height, content, alignEnd }, index) => ( -
      - {content} -
      - ))} -
      -
      - ) -} - -SFReference.parameters = { chromatic: { disableSnapshot: false } } diff --git a/src/typography/typography.ts b/src/typography/typography.ts deleted file mode 100644 index 683d5f92..00000000 --- a/src/typography/typography.ts +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames' - -import { getBoxClassNames } from '../box' -import { getClassNames } from '../utils/responsive-props' - -import styles from './typography.module.css' - -import type { BoxProps } from '../box' -import type { ObfuscatedClassName, Tone } from '../utils/common-types' - -type TypographyLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' - -type TypographyStyleProps = ObfuscatedClassName & { - /** The semantic color of the text. */ - tone?: Tone - /** Horizontal text alignment, including responsive values. */ - align?: BoxProps['textAlign'] - /** Truncates text after the given number of lines. */ - lineClamp?: TypographyLineClamp -} - -type TypographyClassNameOptions = TypographyStyleProps & { - variantClassName: string - fontFamilyClassName?: string - modifierClassNames?: Array -} - -function getTypographyClassName({ - variantClassName, - fontFamilyClassName = styles['font-family-default'], - modifierClassNames, - tone = 'normal', - align, - lineClamp, - exceptionallySetClassName, -}: TypographyClassNameOptions) { - const lineClampMultipleLines = Number(lineClamp ?? 0) > 1 - - return classNames( - getBoxClassNames({ - textAlign: align, - paddingRight: lineClamp ? 'xsmall' : undefined, - }), - exceptionallySetClassName, - styles.typography, - fontFamilyClassName, - variantClassName, - modifierClassNames, - tone !== 'normal' ? getClassNames(styles, 'tone', tone) : null, - lineClampMultipleLines ? styles.lineClampMultipleLines : null, - lineClamp ? getClassNames(styles, 'lineClamp', String(lineClamp)) : null, - ) -} - -export type { TypographyLineClamp, TypographyStyleProps } -export { getTypographyClassName } From 6811383db96c6dc1f077261c173ca4bb72b8a90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 17:05:56 -0500 Subject: [PATCH 09/18] fix(text): preserve root class name --- src/text/text.module.css | 2 +- src/text/text.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text/text.module.css b/src/text/text.module.css index 4298aa35..1cd59536 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -4,7 +4,7 @@ --reactist-text-font-weight-semibold: 600; } -.text { +.typography { color: var(--product-library-display-primary-idle-tint); } diff --git a/src/text/text.tsx b/src/text/text.tsx index 4681d382..387def34 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -132,7 +132,7 @@ function getTextClassName({ paddingRight: lineClamp ? 'xsmall' : undefined, }), exceptionallySetClassName, - styles.text, + styles.typography, fontFamilyClassName, variantClassName, modifierClassNames, From 18208259be4230c724aa31b964fa60d42c63e3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 12 Aug 2026 17:29:14 -0500 Subject: [PATCH 10/18] test(text): trim redundant coverage --- src/text/text.test.tsx | 58 ++---------------------------------------- 1 file changed, 2 insertions(+), 56 deletions(-) diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 2fce1e20..feea0a9b 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -8,15 +8,8 @@ import { bodyVariants, displayVariants, headingVariants, Text } from './text' import type { TextProps } from './text' 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'> > @@ -56,15 +49,6 @@ describe('Text', () => { }, ) - it.each(bodyVariants)('renders %s as a div', (variant) => { - render( - - Text - , - ) - expect(screen.getByTestId('text-element').tagName).toBe('DIV') - }) - it.each([ ['heading-1', 'H1'], ['heading-2', 'H2'], @@ -79,9 +63,9 @@ describe('Text', () => { expect(screen.getByTestId('text-element').tagName).toBe(tagName) }) - it.each(displayVariants)('renders %s as a div with the display font', (variant) => { + it('renders display text as a div with the display font', () => { render( - + Text , ) @@ -113,42 +97,12 @@ describe('Text', () => { expect(element).toHaveClass('variant-heading-1') }) - it('lets render override the display variant default element', () => { - render( - }> - Text - , - ) - const element = screen.getByTestId('text-element') - expect(element.tagName).toBe('H1') - expect(element).toHaveClass('variant-display-1') - }) - - it('applies heading typography to non-heading controls', () => { - render( - }> - Edit title - , - ) - expect(screen.getByRole('button', { name: 'Edit title' })).toHaveClass('variant-heading-2') - }) - it('forwards its ref', () => { const ref = React.createRef() render(Text) expect(ref.current?.tagName).toBe('DIV') }) - it('forwards its ref to the variant default element', () => { - const ref = React.createRef() - render( - - Text - , - ) - expect(ref.current?.tagName).toBe('H2') - }) - it('renders its children as its content', () => { render( @@ -294,15 +248,7 @@ describe('Text', () => { it('has no accessibility violations', async () => { const { container } = render( <> - Display Heading - }> - Button heading - - Default text - - Caption - }> Name From 450dc800aa6863d5a04dc62b217b3b0a9c431d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 01:03:54 -0500 Subject: [PATCH 11/18] refactor(text): fold shadow tokens into canonical layer Move the SF web font stack to design-tokens.css and use literal font weights in the variant classes, matching their literal px sizes. Removes the module-local :root tokens whose 'medium' (500) collided with the canonical --reactist-font-weight-medium (600). --- src/styles/design-tokens.css | 1 + src/text/text.module.css | 42 ++++++++++++++++-------------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css index 407793b8..26b61706 100644 --- a/src/styles/design-tokens.css +++ b/src/styles/design-tokens.css @@ -47,6 +47,7 @@ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --reactist-font-family-monospace: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + --reactist-font-family-sf-for-web: 'SF Pro Display', sans-serif; /* font sizes */ --reactist-font-size-caption: 12px; diff --git a/src/text/text.module.css b/src/text/text.module.css index 1cd59536..eeaed636 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,9 +1,3 @@ -:root { - --reactist-text-font-family-sf-for-web: 'SF Pro Display', sans-serif; - --reactist-text-font-weight-medium: 500; - --reactist-text-font-weight-semibold: 600; -} - .typography { color: var(--product-library-display-primary-idle-tint); } @@ -13,7 +7,7 @@ } .font-family-sf-for-web { - font-family: var(--reactist-text-font-family-sf-for-web); + font-family: var(--reactist-font-family-sf-for-web); } .tone-secondary { @@ -57,7 +51,7 @@ } .display { - font-weight: var(--reactist-text-font-weight-medium); + font-weight: 500; line-height: normal; } @@ -88,112 +82,112 @@ .variant-heading-1 { font-size: 32px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: 0.41px; line-height: normal; } .variant-heading-2 { font-size: 26px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: 0.22px; line-height: normal; } .variant-heading-3 { font-size: 20px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: 0; line-height: normal; } .variant-heading-4 { font-size: 18px; - font-weight: var(--reactist-text-font-weight-semibold); + font-weight: 600; letter-spacing: 0; line-height: normal; } .variant-subheader-1 { font-size: 16px; - font-weight: var(--reactist-text-font-weight-semibold); + font-weight: 600; letter-spacing: 0; line-height: 23px; } .variant-subheader-2 { font-size: 16px; - font-weight: var(--reactist-font-weight-regular); + font-weight: 400; letter-spacing: 0; line-height: 23px; } .variant-body-1 { font-size: 14px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: -0.15px; line-height: 21px; } .variant-body-2 { font-size: 14px; - font-weight: var(--reactist-text-font-weight-semibold); + font-weight: 600; letter-spacing: -0.15px; line-height: 21px; } .variant-body-3 { font-size: 14px; - font-weight: var(--reactist-font-weight-regular); + font-weight: 400; letter-spacing: -0.15px; line-height: 21px; } .variant-callout-1 { font-size: 13px; - font-weight: var(--reactist-text-font-weight-semibold); + font-weight: 600; letter-spacing: -0.15px; line-height: 20px; } .variant-callout-2 { font-size: 13px; - font-weight: var(--reactist-font-weight-regular); + font-weight: 400; letter-spacing: -0.15px; line-height: 20px; } .variant-caption-1 { font-size: 12px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: 0; line-height: 20px; } .variant-caption-2 { font-size: 12px; - font-weight: var(--reactist-text-font-weight-semibold); + font-weight: 600; letter-spacing: -0.15px; line-height: 15px; } .variant-caption-3 { font-size: 12px; - font-weight: var(--reactist-font-weight-regular); + font-weight: 400; letter-spacing: 0; line-height: 20px; } .variant-footnote-1 { font-size: 10px; - font-weight: var(--reactist-font-weight-strong); + font-weight: 700; letter-spacing: 1px; line-height: 13px; } .variant-footnote-2 { font-size: 10px; - font-weight: var(--reactist-text-font-weight-medium); + font-weight: 500; letter-spacing: 1px; line-height: 13px; } From b478e52e5917f8acfe5197d252b91c44b888daaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 01:05:00 -0500 Subject: [PATCH 12/18] refactor(text): drop leftover class-name indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline getTextClassName into Text — its options-object API was a fossil of the deleted typography layer with a single caller. Replace heading element string-building with a plain lookup map. --- src/text/text.tsx | 83 +++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/src/text/text.tsx b/src/text/text.tsx index 387def34..5a2ee4ed 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -4,7 +4,6 @@ import { Role } from '@ariakit/react' import classNames from 'classnames' import { getBoxClassNames } from '../box' -import { getClassNames } from '../utils/responsive-props' import styles from './text.module.css' @@ -31,11 +30,10 @@ const bodyVariants = [ 'footnote-2', ] as const -type DisplayTextVariant = (typeof displayVariants)[number] type HeadingTextVariant = (typeof headingVariants)[number] type BodyTextVariant = (typeof bodyVariants)[number] -type TextVariant = DisplayTextVariant | HeadingTextVariant | BodyTextVariant +type TextVariant = (typeof displayVariants)[number] | HeadingTextVariant | BodyTextVariant type TextLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' type TextStyleProps = ObfuscatedClassName & { @@ -47,12 +45,6 @@ type TextStyleProps = ObfuscatedClassName & { lineClamp?: TextLineClamp } -type TextClassNameOptions = TextStyleProps & { - variantClassName: string - fontFamilyClassName?: string - modifierClassNames?: Array -} - type StrikethroughTextProps = { /** Visual text style supporting strikethrough. */ variant: @@ -99,47 +91,15 @@ type TextProps = Omit, 'children' | 'className render?: RoleProps['render'] } & (StrikethroughTextProps | UnderlinedTextProps | UppercaseTextProps | UnmodifiedTextProps) -function isDisplayVariant(variant: TextVariant): variant is DisplayTextVariant { - return variant.startsWith('display-') -} - function isHeadingVariant(variant: TextVariant): variant is HeadingTextVariant { return variant.startsWith('heading-') } -function getDefaultRender(variant: TextVariant): RoleProps['render'] { - if (isHeadingVariant(variant)) { - return React.createElement('h' + variant.slice('heading-'.length)) - } - - return undefined -} - -function getTextClassName({ - variantClassName, - fontFamilyClassName = styles['font-family-default'], - modifierClassNames, - tone = 'normal', - align, - lineClamp, - exceptionallySetClassName, -}: TextClassNameOptions) { - const lineClampMultipleLines = Number(lineClamp ?? 0) > 1 - - return classNames( - getBoxClassNames({ - textAlign: align, - paddingRight: lineClamp ? 'xsmall' : undefined, - }), - exceptionallySetClassName, - styles.typography, - fontFamilyClassName, - variantClassName, - modifierClassNames, - tone !== 'normal' ? getClassNames(styles, 'tone', tone) : null, - lineClampMultipleLines ? styles.lineClampMultipleLines : null, - lineClamp ? getClassNames(styles, 'lineClamp', String(lineClamp)) : null, - ) +const headingElements: Record = { + 'heading-1':

      , + 'heading-2':

      , + 'heading-3':

      , + 'heading-4':

      , } /** Renders interface copy with a named typography variant, from display text to footnotes. */ @@ -158,25 +118,28 @@ const Text = React.forwardRef(function Text( }, ref, ) { - const display = isDisplayVariant(variant) + const display = variant.startsWith('display-') return ( 1 ? styles.lineClampMultipleLines : null, + lineClamp ? styles['lineClamp-' + lineClamp] : null, + )} // the rendered element varies by variant and render, so the ref is typed broadly ref={ref as React.ForwardedRef} > From 94c1788405f6d3f9e944e554160804966328a917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 14:32:43 -0500 Subject: [PATCH 13/18] fix(stories): preserve heading styles --- src/box/box.stories.tsx | 12 +++++-- src/button/button.stories.jsx | 8 +++-- src/button/icon-button.stories.jsx | 4 ++- src/inline/inline.stories.tsx | 4 ++- src/modal/modal.stories.tsx | 52 +++++++++++++++++++++++------- src/stack/stack.stories.tsx | 12 +++++-- src/toast/toast.stories.tsx | 12 +++++-- src/utils/storybook-helper.tsx | 6 +++- 8 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/box/box.stories.tsx b/src/box/box.stories.tsx index 3b96320a..26348197 100644 --- a/src/box/box.stories.tsx +++ b/src/box/box.stories.tsx @@ -147,7 +147,9 @@ function PaddedBox({ prop, value }: { prop: keyof BoxPaddingProps; value: Space export function PaddingStory({ padding }: { padding: Space }) { return ( - The transparent bordered box has padding + }> + The transparent bordered box has padding + @@ -213,7 +215,9 @@ function MarginBox({ prop, value }: { prop: keyof BoxMarginProps; value: Space } export function MarginStory({ margin }: { margin: Space }) { return ( - The shaded box has margin + }> + The shaded box has margin + When margin is negative, you will see the outer bordered box appear to be inside the shaded box. @@ -271,7 +275,9 @@ export function OverlayScrollStory() { background="aside" > - Scrollable Content with Overlay Scroll + }> + Scrollable Content with Overlay Scroll + This Box component demonstrates the overlay scroll functionality. The scrollbar is hidden by default and appears on hover. diff --git a/src/button/button.stories.jsx b/src/button/button.stories.jsx index 0fbb4dd7..e4e890ec 100644 --- a/src/button/button.stories.jsx +++ b/src/button/button.stories.jsx @@ -52,7 +52,9 @@ function FullWidthTemplate({ label, ...otherProps }) { } return ( - Full-width buttons and label alignment + }> + Full-width buttons and label alignment + When buttons have `width` other than the default `auto` they can also customize how the label is aligned horizontally. @@ -86,7 +88,9 @@ function PlaygroundTemplate({ label, ...props }) { } return ( - Click on the buttons to see the loading state + }> + Click on the buttons to see the loading state + diff --git a/src/button/icon-button.stories.jsx b/src/button/icon-button.stories.jsx index 3d86c926..b54c7065 100644 --- a/src/button/icon-button.stories.jsx +++ b/src/button/icon-button.stories.jsx @@ -34,7 +34,9 @@ function LoadingButton(props) { function PlaygroundTemplate({ label, ...props }) { return ( - Click on the buttons to see the loading state + }> + Click on the buttons to see the loading state + diff --git a/src/inline/inline.stories.tsx b/src/inline/inline.stories.tsx index f71c3a84..7c3e4f94 100644 --- a/src/inline/inline.stories.tsx +++ b/src/inline/inline.stories.tsx @@ -96,7 +96,9 @@ export function NestedStackStory({ space }: PartialProps) { const spaceStr = typeof space !== 'string' ? 'none' : space return ( - Parent stack with space=“{spaceStr}” + }> + Parent stack with space=“{spaceStr}” + {renderInlineContent()} {renderInlineContent()} diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx index b8cef3f2..19ebe64e 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -94,10 +94,18 @@ export function ModalWithStandardActionsFooter() { } > - Modal with standard actions footer + }> + Modal with standard actions footer + - Customize modal} /> + }> + Customize modal + + } + /> @@ -129,10 +137,18 @@ export function ModalWithHeaderBodyAndCustomFooter() { - Modal with header, body and custom footer + }> + Modal with header, body and custom footer + - Customize modal} /> + }> + Customize modal + + } + /> @@ -173,7 +189,9 @@ export function ModalWithSidebar() { - Settings + }> + Settings +
    • @@ -191,7 +209,9 @@ export function ModalWithSidebar() { - Customize modal + }> + Customize modal + @@ -239,7 +259,9 @@ export function ModalWithScrollableTabPanels() { flexDirection="column" > - Task content goest here + }> + Task content goest here + @@ -337,7 +359,9 @@ export function EnrichedConfirmationModal() { - Please confirm + }> + Please confirm + @@ -391,7 +415,9 @@ export function ModalAutofocus() { - Update your info + }> + Update your info + @@ -437,7 +463,9 @@ export function StackingModals() { - Parent modal + }> + Parent modal + @@ -455,7 +483,9 @@ export function StackingModals() { - Nested modal + }> + Nested modal + diff --git a/src/stack/stack.stories.tsx b/src/stack/stack.stories.tsx index 632aeba3..c67e0e84 100644 --- a/src/stack/stack.stories.tsx +++ b/src/stack/stack.stories.tsx @@ -99,15 +99,21 @@ export function NestedStacksStory(args: PartialProps) { const spaceStr = typeof args.space !== 'string' ? 'none' : args.space return ( - Parent stack with space=“{spaceStr}” + }> + Parent stack with space=“{spaceStr}” + - Nested stack with space=“xsmall” + }> + Nested stack with space=“xsmall” + - Nested stack with space=“xsmall” + }> + Nested stack with space=“xsmall” + diff --git a/src/toast/toast.stories.tsx b/src/toast/toast.stories.tsx index 3fb03770..04a0c74a 100644 --- a/src/toast/toast.stories.tsx +++ b/src/toast/toast.stories.tsx @@ -215,7 +215,9 @@ export function StaticToastStory() { - Message only + }> + Message only + - Message and description + }> + Message and description + - Very long content + }> + Very long content + - {title ? {title} : null} + {title ? ( + }> + {title} + + ) : null} {children} From cd1be2bdd0fa835bf8b1830b3a0d432fcf88aec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 15:21:39 -0500 Subject: [PATCH 14/18] fix(text): allow decoration on every variant --- src/text/text.mdx | 7 ++++-- src/text/text.test.tsx | 51 +++++++++++++++++++++--------------------- src/text/text.tsx | 31 +++++-------------------- 3 files changed, 36 insertions(+), 53 deletions(-) diff --git a/src/text/text.mdx b/src/text/text.mdx index ac2936ea..3da78d1a 100644 --- a/src/text/text.mdx +++ b/src/text/text.mdx @@ -16,10 +16,13 @@ Figma style; do not combine independent size and weight values. It uses `body-3` ```tsx Default body copy Emphasized body copy -Underlined caption -Metadata +Underlined heading +Metadata ``` +Every variant supports `underline` and `strikethrough`. Decoration can be combined with uppercase on +`footnote-1`. + Heading variants render the matching heading element (`heading-1` renders `h1`, and so on); all other variants render `div`. diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index feea0a9b..2feddcab 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -5,14 +5,10 @@ import { axe } from 'jest-axe' import { bodyVariants, displayVariants, headingVariants, Text } from './text' -import type { TextProps } from './text' - -const decoratedTextProps = [ - { variant: 'body-3', decoration: 'strikethrough' }, - { variant: 'caption-2', decoration: 'underline' }, -] as const satisfies ReadonlyArray< - Omit, 'children'> -> +const decorations = ['strikethrough', 'underline'] as const +const decoratedTextProps = [...displayVariants, ...headingVariants, ...bodyVariants].flatMap( + (variant) => decorations.map((decoration) => ({ variant, decoration })), +) describe('Text', () => { it('does not acknowledge the className prop, but exceptionallySetClassName instead', () => { @@ -212,37 +208,40 @@ describe('Text', () => { expect(screen.getByTestId('text-element')).toHaveClass('decoration-' + textProps.decoration) }) - it('supports uppercase only for footnote-1', () => { + it.each(decorations)('supports the default variant with %s', (decoration) => { render( - + Text , ) - expect(screen.getByTestId('text-element')).toHaveClass('case-uppercase') + expect(screen.getByTestId('text-element')).toHaveClass('decoration-' + decoration) }) - it('rejects invalid modifiers at type level', () => { - const invalidBodyModifier = ( - // @ts-expect-error body-1 does not support decoration - - Invalid - + it('supports uppercase and decoration together for footnote-1', () => { + render( + + Text + , ) - const invalidHeadingModifier = ( - // @ts-expect-error heading variants do not support decoration - - Invalid - + expect(screen.getByTestId('text-element')).toHaveClass( + 'case-uppercase', + 'decoration-strikethrough', ) - const invalidDisplayModifier = ( + }) + + it('rejects uppercase for unsupported variants at type level', () => { + const invalidCase = ( // @ts-expect-error display variants do not support case Invalid ) - expect(invalidBodyModifier).toBeDefined() - expect(invalidHeadingModifier).toBeDefined() - expect(invalidDisplayModifier).toBeDefined() + expect(invalidCase).toBeDefined() }) it('has no accessibility violations', async () => { diff --git a/src/text/text.tsx b/src/text/text.tsx index 5a2ee4ed..b11514c5 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -43,40 +43,21 @@ type TextStyleProps = ObfuscatedClassName & { align?: BoxProps['textAlign'] /** Truncates text after the given number of lines. */ lineClamp?: TextLineClamp + /** Adds a line under or through the text. */ + decoration?: 'strikethrough' | 'underline' } -type StrikethroughTextProps = { - /** Visual text style supporting strikethrough. */ - variant: - | 'subheader-1' - | 'subheader-2' - | 'body-3' - | 'callout-1' - | 'callout-2' - | 'caption-2' - | 'caption-3' - decoration: 'strikethrough' - case?: never -} - -type UnderlinedTextProps = { - /** Visual caption style supporting underline. */ - variant: 'caption-2' | 'caption-3' - decoration: 'underline' - case?: never -} - -type UnmodifiedTextProps = { +type DefaultCaseTextProps = { /** Visual text style; defaults to body-3. */ variant?: TextVariant - decoration?: never + /** Uppercase text is only available with footnote-1. */ case?: never } type UppercaseTextProps = { /** Visual footnote style supporting uppercase. */ variant: 'footnote-1' - decoration?: never + /** Converts the text to uppercase. */ case: 'uppercase' } @@ -89,7 +70,7 @@ type TextProps = Omit, 'children' | 'className * element for heading variants, and a div otherwise. */ render?: RoleProps['render'] - } & (StrikethroughTextProps | UnderlinedTextProps | UppercaseTextProps | UnmodifiedTextProps) + } & (DefaultCaseTextProps | UppercaseTextProps) function isHeadingVariant(variant: TextVariant): variant is HeadingTextVariant { return variant.startsWith('heading-') From 813dd3c0eb4ba6e429dc2b3e266da4da87971daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 15:55:31 -0500 Subject: [PATCH 15/18] fix(text): use default font family --- src/styles/design-tokens.css | 1 - src/text/text.module.css | 4 ---- src/text/text.stories.tsx | 4 ++-- src/text/text.test.tsx | 4 ++-- src/text/text.tsx | 2 +- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css index 26b61706..407793b8 100644 --- a/src/styles/design-tokens.css +++ b/src/styles/design-tokens.css @@ -47,7 +47,6 @@ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --reactist-font-family-monospace: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; - --reactist-font-family-sf-for-web: 'SF Pro Display', sans-serif; /* font sizes */ --reactist-font-size-caption: 12px; diff --git a/src/text/text.module.css b/src/text/text.module.css index eeaed636..96aef3f6 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -6,10 +6,6 @@ font-family: var(--reactist-font-family); } -.font-family-sf-for-web { - font-family: var(--reactist-font-family-sf-for-web); -} - .tone-secondary { color: var(--product-library-display-secondary-idle-tint); } diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 067ca645..db0ad233 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -13,8 +13,8 @@ export default { parameters: { badges: ['accessible'], figma: { - path: 'Global › Text Styles › SF *FOR WEB*', - url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316', + path: 'Global › Text Styles', + url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=2527-3732&t=zfZi3AJ6FfmLeBUT-4', }, }, } diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 2feddcab..131910c9 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -59,7 +59,7 @@ describe('Text', () => { expect(screen.getByTestId('text-element').tagName).toBe(tagName) }) - it('renders display text as a div with the display font', () => { + it('renders display text as a div with the default font', () => { render( Text @@ -68,7 +68,7 @@ describe('Text', () => { const element = screen.getByTestId('text-element') expect(element.tagName).toBe('DIV') expect(element).toHaveClass('display') - expect(element).toHaveClass('font-family-sf-for-web') + expect(element).toHaveClass('font-family-default') }) it('renders custom elements through Ariakit Role', () => { diff --git a/src/text/text.tsx b/src/text/text.tsx index b11514c5..25a6f07e 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -112,7 +112,7 @@ const Text = React.forwardRef(function Text( }), exceptionallySetClassName, styles.typography, - display ? styles['font-family-sf-for-web'] : styles['font-family-default'], + styles['font-family-default'], styles['variant-' + variant], display ? styles.display : null, decoration ? styles['decoration-' + decoration] : null, From ebe0ec6e0cf0d4903b87d3c584248518c447bf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 16:00:39 -0500 Subject: [PATCH 16/18] refactor(text): rename root class --- src/text/text.module.css | 2 +- src/text/text.test.tsx | 2 +- src/text/text.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/text/text.module.css b/src/text/text.module.css index 96aef3f6..df8edba5 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,4 +1,4 @@ -.typography { +.text { color: var(--product-library-display-primary-idle-tint); } diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 131910c9..62c95372 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -30,7 +30,7 @@ describe('Text', () => { render(Text) const element = screen.getByTestId('text-element') expect(element.tagName).toBe('DIV') - expect(element).toHaveClass('variant-body-3') + expect(element).toHaveClass('text', 'variant-body-3') }) it.each([...displayVariants, ...headingVariants, ...bodyVariants])( diff --git a/src/text/text.tsx b/src/text/text.tsx index 25a6f07e..25ace781 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -111,7 +111,7 @@ const Text = React.forwardRef(function Text( paddingRight: lineClamp ? 'xsmall' : undefined, }), exceptionallySetClassName, - styles.typography, + styles.text, styles['font-family-default'], styles['variant-' + variant], display ? styles.display : null, From d7e2e528dce8632dea8cfc698d0323182b4f06c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 16:10:53 -0500 Subject: [PATCH 17/18] refactor(text): rename heading variants to header --- src/inline/inline.stories.tsx | 2 +- src/modal/modal.stories.tsx | 16 +++++++-------- src/stack/stack.stories.tsx | 2 +- src/text/text.mdx | 10 ++++----- src/text/text.module.css | 8 ++++---- src/text/text.stories.test.tsx | 37 ++++++++++++++++++++++++++++++++++ src/text/text.stories.tsx | 15 ++++++++++---- src/text/text.test.tsx | 22 ++++++++++---------- src/text/text.tsx | 26 ++++++++++++------------ src/toast/toast.stories.tsx | 4 ++-- 10 files changed, 93 insertions(+), 49 deletions(-) create mode 100644 src/text/text.stories.test.tsx diff --git a/src/inline/inline.stories.tsx b/src/inline/inline.stories.tsx index 7c3e4f94..a01063a2 100644 --- a/src/inline/inline.stories.tsx +++ b/src/inline/inline.stories.tsx @@ -96,7 +96,7 @@ export function NestedStackStory({ space }: PartialProps) { const spaceStr = typeof space !== 'string' ? 'none' : space return ( - }> + }> Parent stack with space=“{spaceStr}” {renderInlineContent()} diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx index 19ebe64e..20910ba4 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -94,7 +94,7 @@ export function ModalWithStandardActionsFooter() { } > - }> + }> Modal with standard actions footer @@ -137,7 +137,7 @@ export function ModalWithHeaderBodyAndCustomFooter() { - }> + }> Modal with header, body and custom footer @@ -189,7 +189,7 @@ export function ModalWithSidebar() { - }> + }> Settings @@ -259,7 +259,7 @@ export function ModalWithScrollableTabPanels() { flexDirection="column" > - }> + }> Task content goest here @@ -359,7 +359,7 @@ export function EnrichedConfirmationModal() { - }> + }> Please confirm @@ -415,7 +415,7 @@ export function ModalAutofocus() { - }> + }> Update your info @@ -463,7 +463,7 @@ export function StackingModals() { - }> + }> Parent modal @@ -483,7 +483,7 @@ export function StackingModals() { - }> + }> Nested modal diff --git a/src/stack/stack.stories.tsx b/src/stack/stack.stories.tsx index c67e0e84..16f6f780 100644 --- a/src/stack/stack.stories.tsx +++ b/src/stack/stack.stories.tsx @@ -99,7 +99,7 @@ export function NestedStacksStory(args: PartialProps) { const spaceStr = typeof args.space !== 'string' ? 'none' : args.space return ( - }> + }> Parent stack with space=“{spaceStr}” diff --git a/src/text/text.mdx b/src/text/text.mdx index 3da78d1a..f39fcfa7 100644 --- a/src/text/text.mdx +++ b/src/text/text.mdx @@ -16,18 +16,18 @@ Figma style; do not combine independent size and weight values. It uses `body-3` ```tsx Default body copy Emphasized body copy -Underlined heading +Underlined heading Metadata ``` Every variant supports `underline` and `strikethrough`. Decoration can be combined with uppercase on `footnote-1`. -Heading variants render the matching heading element (`heading-1` renders `h1`, and so on); all +Header variants render the matching heading element (`header-1` renders `h1`, and so on); all other variants render `div`. ```tsx -Page title // renders an h1 +Page title // renders an h1 42 completed tasks // renders a div ``` @@ -35,10 +35,10 @@ Use `render` when the copy needs another HTML element. The rendered element owns pick heading levels from the document outline, not from the variant number. ```tsx -}> +}> Prominent section title -}> +}> Edit title }> diff --git a/src/text/text.module.css b/src/text/text.module.css index df8edba5..953fffb9 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -76,28 +76,28 @@ letter-spacing: 0.37px; } -.variant-heading-1 { +.variant-header-1 { font-size: 32px; font-weight: 700; letter-spacing: 0.41px; line-height: normal; } -.variant-heading-2 { +.variant-header-2 { font-size: 26px; font-weight: 700; letter-spacing: 0.22px; line-height: normal; } -.variant-heading-3 { +.variant-header-3 { font-size: 20px; font-weight: 700; letter-spacing: 0; line-height: normal; } -.variant-heading-4 { +.variant-header-4 { font-size: 18px; font-weight: 600; letter-spacing: 0; diff --git a/src/text/text.stories.test.tsx b/src/text/text.stories.test.tsx new file mode 100644 index 00000000..a180827f --- /dev/null +++ b/src/text/text.stories.test.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' + +import { TextStory } from './text.stories' + +describe('TextStory', () => { + it('shows all variant names in Title Case', () => { + render() + + for (const variantName of [ + 'Display 1', + 'Display 2', + 'Display 3', + 'Display 4', + 'Display 5', + 'Header 1', + 'Header 2', + 'Header 3', + 'Header 4', + 'Subheader 1', + 'Subheader 2', + 'Body 1', + 'Body 2', + 'Body 3', + 'Callout 1', + 'Callout 2', + 'Caption 1', + 'Caption 2', + 'Caption 3', + 'Footnote 1', + 'Footnote 2', + ]) { + expect(screen.getByText(variantName)).toBeInTheDocument() + } + }) +}) diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index db0ad233..fbd29d25 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -3,9 +3,16 @@ import * as React from 'react' import { Stack } from '../stack' import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-helper' -import { bodyVariants, displayVariants, headingVariants, Text } from './text' +import { bodyVariants, displayVariants, headerVariants, Text } from './text' -const allVariants = [...displayVariants, ...headingVariants, ...bodyVariants] as const +const allVariants = [...displayVariants, ...headerVariants, ...bodyVariants] as const + +function formatVariantName(variant: (typeof allVariants)[number]) { + return variant + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} export default { title: '🔤 Typography/Text', @@ -25,7 +32,7 @@ export function TextStory() { {allVariants.map((variant) => ( - {variant} + {formatVariantName(variant)} ))} @@ -67,7 +74,7 @@ export function TruncatedTextStory() { dolorum, consequatur, eligendi est dolores modi dolore maiores ipsum magnam a. - + This is a long title which we will use demonstrate truncating content. When this overflows and begins to drop to a new line, its overflowing content will be replaced by ellipses. diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 62c95372..1b8c2adb 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -3,10 +3,10 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { bodyVariants, displayVariants, headingVariants, Text } from './text' +import { bodyVariants, displayVariants, headerVariants, Text } from './text' const decorations = ['strikethrough', 'underline'] as const -const decoratedTextProps = [...displayVariants, ...headingVariants, ...bodyVariants].flatMap( +const decoratedTextProps = [...displayVariants, ...headerVariants, ...bodyVariants].flatMap( (variant) => decorations.map((decoration) => ({ variant, decoration })), ) @@ -33,7 +33,7 @@ describe('Text', () => { expect(element).toHaveClass('text', 'variant-body-3') }) - it.each([...displayVariants, ...headingVariants, ...bodyVariants])( + it.each([...displayVariants, ...headerVariants, ...bodyVariants])( 'applies the %s variant', (variant) => { render( @@ -46,10 +46,10 @@ describe('Text', () => { ) it.each([ - ['heading-1', 'H1'], - ['heading-2', 'H2'], - ['heading-3', 'H3'], - ['heading-4', 'H4'], + ['header-1', 'H1'], + ['header-2', 'H2'], + ['header-3', 'H3'], + ['header-4', 'H4'], ] as const)('renders %s as %s', (variant, tagName) => { render( @@ -82,15 +82,15 @@ describe('Text', () => { expect(element).toHaveAttribute('for', 'name') }) - it('lets render override the heading variant default element', () => { + it('lets render override the header variant default element', () => { render( - }> + }> Text , ) const element = screen.getByTestId('text-element') expect(element.tagName).toBe('H2') - expect(element).toHaveClass('variant-heading-1') + expect(element).toHaveClass('variant-header-1') }) it('forwards its ref', () => { @@ -247,7 +247,7 @@ describe('Text', () => { it('has no accessibility violations', async () => { const { container } = render( <> - Heading + Heading }> Name diff --git a/src/text/text.tsx b/src/text/text.tsx index 25ace781..c966096d 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -13,7 +13,7 @@ import type { ObfuscatedClassName, Tone } from '../utils/common-types' const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const -const headingVariants = ['heading-1', 'heading-2', 'heading-3', 'heading-4'] as const +const headerVariants = ['header-1', 'header-2', 'header-3', 'header-4'] as const const bodyVariants = [ 'subheader-1', @@ -30,10 +30,10 @@ const bodyVariants = [ 'footnote-2', ] as const -type HeadingTextVariant = (typeof headingVariants)[number] +type HeaderTextVariant = (typeof headerVariants)[number] type BodyTextVariant = (typeof bodyVariants)[number] -type TextVariant = (typeof displayVariants)[number] | HeadingTextVariant | BodyTextVariant +type TextVariant = (typeof displayVariants)[number] | HeaderTextVariant | BodyTextVariant type TextLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' type TextStyleProps = ObfuscatedClassName & { @@ -67,20 +67,20 @@ type TextProps = Omit, 'children' | 'className children: React.ReactNode /** * Custom element rendered with the variant's typography. Defaults to the matching heading - * element for heading variants, and a div otherwise. + * element for header variants, and a div otherwise. */ render?: RoleProps['render'] } & (DefaultCaseTextProps | UppercaseTextProps) -function isHeadingVariant(variant: TextVariant): variant is HeadingTextVariant { - return variant.startsWith('heading-') +function isHeaderVariant(variant: TextVariant): variant is HeaderTextVariant { + return variant.startsWith('header-') } -const headingElements: Record = { - 'heading-1':

      , - 'heading-2':

      , - 'heading-3':

      , - 'heading-4':

      , +const headerElements: Record = { + 'header-1':

      , + 'header-2':

      , + 'header-3':

      , + 'header-4':

      , } /** Renders interface copy with a named typography variant, from display text to footnotes. */ @@ -104,7 +104,7 @@ const Text = React.forwardRef(function Text( return ( (function Text( Text.displayName = 'Text' export type { TextProps, TextVariant } -export { bodyVariants, displayVariants, headingVariants, Text } +export { bodyVariants, displayVariants, headerVariants, Text } diff --git a/src/toast/toast.stories.tsx b/src/toast/toast.stories.tsx index 04a0c74a..c4aaaee5 100644 --- a/src/toast/toast.stories.tsx +++ b/src/toast/toast.stories.tsx @@ -64,7 +64,7 @@ export function NotificationToastsStory() { return ( - }> + }> Toasts @@ -171,7 +171,7 @@ export function StaticToastStory() { return ( - }> + }> Statically-rendered toasts From bb8f452f036026f414c7495a35176dc50b44306f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Thu, 13 Aug 2026 16:13:52 -0500 Subject: [PATCH 18/18] fix(stories): match text variant spacing --- src/text/text.stories.test.tsx | 6 ++++++ src/text/text.stories.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/text/text.stories.test.tsx b/src/text/text.stories.test.tsx index a180827f..aaa61bc7 100644 --- a/src/text/text.stories.test.tsx +++ b/src/text/text.stories.test.tsx @@ -5,6 +5,12 @@ import { render, screen } from '@testing-library/react' import { TextStory } from './text.stories' describe('TextStory', () => { + it('uses a 16 px gap between variants', () => { + render() + + expect(screen.getByText('Display 1').parentElement).toHaveClass('gap-large') + }) + it('shows all variant names in Title Case', () => { render() diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index fbd29d25..242c5a03 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -29,7 +29,7 @@ export default { export function TextStory() { return (
      - + {allVariants.map((variant) => ( {formatVariantName(variant)}