diff --git a/.eslintignore b/.eslintignore index 57bc5417d..04dda1c10 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,6 +2,7 @@ lint-staged.config.js jest.config.js jest.config.react18.js babel.config.js +codemods/ plopfile.mjs release.config.js coverage/ diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 000000000..f7953ce22 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,61 @@ +# Task 3: Display + +## Implementation + +- Added `Display` with required `display-1` through `display-5` variants. +- Uses `Role.div`, shared typography classes, forwarded div refs, and obfuscated class names. +- Exported from `src/display` and the root package entry. + +## TDD evidence + +- RED: `npm test -- src/display/display.test.tsx --runInBand` failed because `./display` was absent. +- GREEN: focused suite passes 11 tests after implementation. + +## Verification + +- `npm test -- src/display/display.test.tsx --runInBand` — pass, 11 tests. +- `npm run type-check` — pass. +- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors. + +## Files + +- `src/display/display.tsx` +- `src/display/display.module.css` +- `src/display/display.test.tsx` +- `src/display/index.ts` +- `src/index.ts` + +## Self-review + +- Checked exact typography values, Role render behavior, type exports, root export, ref forwarding, a11y, responsive typography classes, and no manual memoization. +- `git diff --check` passes. + +## Concerns + +None. + +## Review fix + +- Display now opts into the shared SF for Web font-family class (`'SF Pro Display', sans-serif`); Text and Heading retain the existing default font-family class. +- Replaced Display test-ID queries with text/role queries and retained the variant and behavior assertions. +- Moved Display metrics to component-scoped custom properties and centralized its common weight and line-height rule. + +## Fix verification + +- `npm test -- src/display/display.test.tsx --runInBand` — RED: 5 variant assertions failed after temporarily removing the SF/display classes; GREEN: passes after restoration. +- `npm test -- src/display/display.test.tsx src/text/text.test.tsx src/heading/heading.test.tsx --runInBand` — pass, 3 suites, 60 tests. +- `npm run type-check` — pass. +- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors. +- `git diff --check` — pass. + +## Mapping fix + +- Display now obtains `font-family-sf-for-web` from `typography.module.css`, where the class is defined, rather than from its local CSS module. +- Added a source-mapping regression test because the global CSS mock returns every property name and cannot expose a wrong CSS-module lookup. + +## Mapping verification + +- `npm test -- src/display/display.test.tsx --runInBand` — RED: shared CSS-module mapping assertion failed before the import fix; GREEN: pass, 1 suite, 12 tests. +- `npm run type-check` — pass. +- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors. +- `git diff --check` — pass. diff --git a/codemods/README.md b/codemods/README.md new file mode 100644 index 000000000..c8060b4f8 --- /dev/null +++ b/codemods/README.md @@ -0,0 +1,35 @@ +# Reactist typography codemod + +Migrates the breaking Text and Heading typography APIs to named SF variants. + +## Run + +```bash +npx jscodeshift@17.3.0 \ + --transform ./node_modules/@doist/reactist/codemods/typography-variants.js \ + --extensions js,jsx,ts,tsx \ + --parser tsx \ + src +``` + +Run Prettier and the application's type-check after the transform. + +## Exact mappings + +| Legacy Text size | Regular or omitted | Semibold | Bold | +| ---------------- | ------------------ | ----------- | --------- | +| subtitle | subheader-2 | subheader-1 | Manual | +| body or omitted | body-3 | body-2 | body-1 | +| copy | callout-2 | callout-1 | Manual | +| caption | caption-3 | caption-2 | caption-1 | + +Heading maps only exact 32px/700 uses to heading-1 and exact 20px/700 uses to heading-3. + +## Manual migrations + +The transform changes only exact static mappings. Ambiguous size/weight combinations, dynamic +expressions, and prop spreads remain unchanged. Each unresolved element receives a +TODO(reactist-codemod) comment, and the command prints its file and line. + +Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose +the nearest variant: confirm the intended visual hierarchy with design. diff --git a/codemods/__testfixtures__/typography-variants-heading.input.tsx b/codemods/__testfixtures__/typography-variants-heading.input.tsx new file mode 100644 index 000000000..8d502ef23 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-heading.input.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +import { Heading, Heading as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + + Large + + Current default + + Visual 20 + + + Visual 20 + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-heading.output.tsx b/codemods/__testfixtures__/typography-variants-heading.output.tsx new file mode 100644 index 000000000..d8f6d5ed1 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-heading.output.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { Heading, Heading as Title } from '@doist/reactist' + +export function ExactHeadings() { + return ( + <> + + Large + + + Current default + + + Visual 20 + + + Visual 20 + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-idempotence.input.tsx b/codemods/__testfixtures__/typography-variants-idempotence.input.tsx new file mode 100644 index 000000000..8282cc753 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-idempotence.input.tsx @@ -0,0 +1,14 @@ +import { Heading } from '@doist/reactist' + +export function HeadingVariants() { + return ( + <> + + Page title + + + Prominent subsection + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-idempotence.output.tsx b/codemods/__testfixtures__/typography-variants-idempotence.output.tsx new file mode 100644 index 000000000..8282cc753 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-idempotence.output.tsx @@ -0,0 +1,14 @@ +import { Heading } from '@doist/reactist' + +export function HeadingVariants() { + return ( + <> + + Page title + + + Prominent subsection + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-manual.input.tsx b/codemods/__testfixtures__/typography-variants-manual.input.tsx new file mode 100644 index 000000000..d9d429300 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-manual.input.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +export function ManualCases({ size, weight, useLabel, level, props }) { + return ( + <> + + No exact match + + Dynamic + Dynamic weight + Dynamic element + Unsupported element + }> + Existing render + + + Duplicate size + + + Duplicate weight + + + Duplicate element + + Spread + + 24px + + 16px + 14px + + 12px + + + Medium + + + Light + + Dynamic level + + Dynamic size + + + Dynamic weight + + Spread + + Spread and dynamic props + + + ) +} + +export function Shadowed() { + const Text = (props: React.ComponentProps<'span'>) => + + return Shadowed Text +} diff --git a/codemods/__testfixtures__/typography-variants-manual.output.tsx b/codemods/__testfixtures__/typography-variants-manual.output.tsx new file mode 100644 index 000000000..c09291b74 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-manual.output.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +export function ManualCases({ size, weight, useLabel, level, props }) { + return ( + <> + + {/* TODO(reactist-codemod): Text size and weight have no exact variant */} + No exact match + + + {/* TODO(reactist-codemod): dynamic Text size */} + Dynamic + + + {/* TODO(reactist-codemod): dynamic Text weight */} + Dynamic weight + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Dynamic element + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Unsupported element + + }> + {/* TODO(reactist-codemod): Text already has render prop */} + Existing render + + + {/* TODO(reactist-codemod): duplicate Text size props */} + Duplicate size + + + {/* TODO(reactist-codemod): duplicate Text weight props */} + Duplicate weight + + + {/* TODO(reactist-codemod): duplicate Text as props */} + Duplicate element + + + {/* TODO(reactist-codemod): spread props may supply or override typography props */} + Spread + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 24px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 16px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 14px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + 12px + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + Medium + + + {/* TODO(reactist-codemod): Heading metrics have no exact variant */} + Light + + + {/* TODO(reactist-codemod): dynamic Heading level */} + Dynamic level + + + {/* TODO(reactist-codemod): dynamic Heading size */} + Dynamic size + + + {/* TODO(reactist-codemod): dynamic Heading weight */} + Dynamic weight + + + {/* TODO(reactist-codemod): spread props may supply or override typography props; dynamic Heading level */} + Spread + + + {/* TODO(reactist-codemod): spread props may supply or override typography props; dynamic Heading level; dynamic Heading size; dynamic Heading weight */} + Spread and dynamic props + + + ) +} + +export function Shadowed() { + const Text = (props: React.ComponentProps<'span'>) => + + return Shadowed Text +} diff --git a/codemods/__testfixtures__/typography-variants-safety.input.tsx b/codemods/__testfixtures__/typography-variants-safety.input.tsx new file mode 100644 index 000000000..6e4769369 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-safety.input.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +const anchorRef = React.createRef() + +function RequiredLink({ targetId }: { targetId: string }) { + return +} + +function span() { + return +} + +const UI = { Link: RequiredLink } + +export function SafetyCases() { + return ( + <> + Safe span + + Anchor with props + + + Custom component with required props + + Lowercase variable component + Static member component + + Mixed Text props + + + Mixed Heading props + + } level={1}> + Mixed Heading render props + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-safety.output.tsx b/codemods/__testfixtures__/typography-variants-safety.output.tsx new file mode 100644 index 000000000..6d6124446 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-safety.output.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' + +import { Heading, Text } from '@doist/reactist' + +const anchorRef = React.createRef() + +function RequiredLink({ targetId }: { targetId: string }) { + return +} + +function span() { + return +} + +const UI = { Link: RequiredLink } + +export function SafetyCases() { + return ( + <> + }>Safe span + + {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + Anchor with props + + + {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */} + Custom component with required props + + + {/* TODO(reactist-codemod): dynamic Text as target */} + Lowercase variable component + + }>Static member component + + {/* TODO(reactist-codemod): Text mixes variant with legacy size or weight props */} + Mixed Text props + + + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} + Mixed Heading props + + } level={1}> + {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */} + Mixed Heading render props + + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-text.input.tsx b/codemods/__testfixtures__/typography-variants-text.input.tsx new file mode 100644 index 000000000..38e44921e --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-text.input.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +function Link(props: React.ComponentProps<'a'>) { + return +} + +export function Example() { + return ( + <> + Subheader regular + + Subheader semibold + + Body regular + + Body semibold + + + Body bold + + Callout regular + + Callout semibold + + Caption regular + + Caption semibold + + + Caption bold + + Default body + Default link + + ) +} diff --git a/codemods/__testfixtures__/typography-variants-text.output.tsx b/codemods/__testfixtures__/typography-variants-text.output.tsx new file mode 100644 index 000000000..bd8f7b935 --- /dev/null +++ b/codemods/__testfixtures__/typography-variants-text.output.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' + +import { Text, Text as Copy } from '@doist/reactist' + +function Link(props: React.ComponentProps<'a'>) { + return +} + +export function Example() { + return ( + <> + Subheader regular + Subheader semibold + Body regular + Body semibold + }> + Body bold + + Callout regular + Callout semibold + Caption regular + Caption semibold + Caption bold + Default body + }>Default link + + ) +} diff --git a/codemods/typography-variants.js b/codemods/typography-variants.js new file mode 100644 index 000000000..6cad44424 --- /dev/null +++ b/codemods/typography-variants.js @@ -0,0 +1,364 @@ +const TEXT_VARIANTS = { + subtitle: { regular: 'subheader-2', semibold: 'subheader-1' }, + body: { regular: 'body-3', semibold: 'body-2', bold: 'body-1' }, + copy: { regular: 'callout-2', semibold: 'callout-1' }, + caption: { regular: 'caption-3', semibold: 'caption-2', bold: 'caption-1' }, +} + +const HEADING_SIZES = { + 1: { default: 20, smaller: 16, larger: 24, largest: 32 }, + 2: { default: 16, smaller: 14, larger: 20, largest: 24 }, + 3: { default: 14, smaller: 12, larger: 16, largest: 20 }, + 4: { default: 14, smaller: 14, larger: 16, largest: 20 }, + 5: { default: 14, smaller: 14, larger: 16, largest: 20 }, + 6: { default: 14, smaller: 14, larger: 16, largest: 20 }, +} + +const HEADING_WEIGHTS = { + regular: 700, + medium: 600, + light: 400, +} + +const HEADING_VARIANTS = { + '32:700': 'heading-1', + '20:700': 'heading-3', +} + +const DYNAMIC = Symbol('dynamic') + +function getImportedNames(root, j, importedName) { + const names = new Set() + root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => { + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === importedName + ) { + names.add(specifier.local?.name ?? importedName) + } + } + }) + return names +} + +function isImportedBinding(path, name, importedName) { + const bindings = path.scope.lookup(name)?.getBindings()[name] ?? [] + + return bindings.some( + (binding) => + binding.parent?.node.type === 'ImportSpecifier' && + binding.parent.parent?.node.type === 'ImportDeclaration' && + binding.parent.parent.node.source.value === '@doist/reactist' && + binding.parent.node.imported.type === 'Identifier' && + binding.parent.node.imported.name === importedName, + ) +} + +function getAttribute(openingElement, name) { + return openingElement.attributes.find( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ) +} + +function getDuplicateAttributes(openingElement, names) { + return names.filter( + (name) => + openingElement.attributes.filter( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ).length > 1, + ) +} + +function hasSpread(openingElement) { + return openingElement.attributes.some((attribute) => attribute.type === 'JSXSpreadAttribute') +} + +function readStaticString(attribute, fallback) { + if (!attribute) return fallback + if (!attribute.value) return DYNAMIC + if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') { + return String(attribute.value.value) + } + if (attribute.value.type !== 'JSXExpressionContainer') return DYNAMIC + + const expression = attribute.value.expression + if ( + expression.type === 'StringLiteral' || + (expression.type === 'Literal' && typeof expression.value === 'string') + ) { + return String(expression.value) + } + return DYNAMIC +} + +function readStaticLevel(attribute) { + const value = readStaticString(attribute, DYNAMIC) + if (value !== DYNAMIC && /^[1-6]$/.test(value)) return Number(value) + + if (attribute?.value?.type !== 'JSXExpressionContainer') return DYNAMIC + const expression = attribute.value.expression + if ( + (expression.type === 'NumericLiteral' || expression.type === 'Literal') && + Number.isInteger(expression.value) && + expression.value >= 1 && + expression.value <= 6 + ) { + return Number(expression.value) + } + + return DYNAMIC +} + +function removeAttributes(openingElement, names) { + openingElement.attributes = openingElement.attributes.filter( + (attribute) => + attribute.type !== 'JSXAttribute' || + attribute.name.type !== 'JSXIdentifier' || + !names.includes(attribute.name.name), + ) +} + +function addVariant(j, openingElement, variant) { + removeAttributes(openingElement, ['size', 'weight']) + const levelIndex = openingElement.attributes.findIndex( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === 'level', + ) + const insertionIndex = levelIndex >= 0 ? levelIndex + 1 : 0 + openingElement.attributes.splice( + insertionIndex, + 0, + j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant)), + ) +} + +function toJSXName(j, expression) { + if (expression.type === 'Identifier') return j.jsxIdentifier(expression.name) + if (expression.type === 'MemberExpression' && !expression.computed) { + const object = toJSXName(j, expression.object) + const property = toJSXName(j, expression.property) + return object && property ? j.jsxMemberExpression(object, property) : null + } + return null +} + +function getStaticRenderName(j, attribute) { + if (!attribute?.value) return null + if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') { + return j.jsxIdentifier(String(attribute.value.value)) + } + if (attribute.value.type !== 'JSXExpressionContainer') return null + + const expression = attribute.value.expression + if ( + expression.type === 'StringLiteral' || + (expression.type === 'Literal' && typeof expression.value === 'string') + ) { + return j.jsxIdentifier(String(expression.value)) + } + if (expression.type === 'Identifier' && /^[A-Z]/.test(expression.name)) { + return j.jsxIdentifier(expression.name) + } + if (expression.type === 'MemberExpression' && !expression.computed) { + return toJSXName(j, expression) + } + return null +} + +function hasAttributesOtherThan(openingElement, names) { + return openingElement.attributes.some( + (attribute) => + attribute.type === 'JSXSpreadAttribute' || + (attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + !names.includes(attribute.name.name)), + ) +} + +function replaceAsWithRender(j, openingElement, asAttribute, renderName) { + const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, [], true) + asAttribute.name = j.jsxIdentifier('render') + asAttribute.value = j.jsxExpressionContainer(renderElement) +} + +function markManual(j, api, file, path, reasons) { + const message = ' TODO(reactist-codemod): ' + reasons.join('; ') + ' ' + const alreadyMarked = path.node.children.some( + (child) => + child.type === 'JSXExpressionContainer' && + child.expression.type === 'JSXEmptyExpression' && + child.expression.comments?.some((comment) => + comment.value.includes('TODO(reactist-codemod)'), + ), + ) + + if (!alreadyMarked) { + const emptyExpression = j.jsxEmptyExpression() + emptyExpression.comments = [j.commentBlock(message)] + path.node.children.unshift( + j.jsxText('\n'), + j.jsxExpressionContainer(emptyExpression), + j.jsxText('\n'), + ) + + if (path.node.openingElement.selfClosing) { + const name = path.node.openingElement.name + path.node.openingElement.selfClosing = false + path.node.closingElement = j.jsxClosingElement(j.jsxIdentifier(name.name)) + } + } + + const line = path.node.loc?.start.line ?? 1 + api.report?.(file.path + ':' + line + ' ' + reasons.join('; ')) +} + +function transformTextElement(j, api, file, path) { + const openingElement = path.node.openingElement + const hasVariant = Boolean(getAttribute(openingElement, 'variant')) + const legacySizeAttribute = getAttribute(openingElement, 'size') + const legacyWeightAttribute = getAttribute(openingElement, 'weight') + const reasons = [] + if (hasSpread(openingElement)) + reasons.push('spread props may supply or override typography props') + for (const name of getDuplicateAttributes(openingElement, ['size', 'weight', 'as'])) { + reasons.push('duplicate Text ' + name + ' props') + } + + if (hasVariant && (legacySizeAttribute || legacyWeightAttribute)) { + reasons.push('Text mixes variant with legacy size or weight props') + } + + const sizeAttribute = hasVariant ? undefined : legacySizeAttribute + const weightAttribute = hasVariant ? undefined : legacyWeightAttribute + const asAttribute = getAttribute(openingElement, 'as') + const renderAttribute = getAttribute(openingElement, 'render') + const size = readStaticString(sizeAttribute, 'body') + const weight = readStaticString(weightAttribute, 'regular') + + if (size === DYNAMIC) reasons.push('dynamic Text size') + if (weight === DYNAMIC) reasons.push('dynamic Text weight') + + const variant = + size === DYNAMIC || weight === DYNAMIC ? undefined : TEXT_VARIANTS[size]?.[weight] + if (!hasVariant && (sizeAttribute || weightAttribute) && !variant && reasons.length === 0) { + reasons.push('Text size and weight have no exact variant') + } + + const renderName = asAttribute ? getStaticRenderName(j, asAttribute) : undefined + if (asAttribute && !renderName) { + reasons.push('dynamic Text as target') + } else if ( + asAttribute && + !renderAttribute && + hasAttributesOtherThan(openingElement, ['as', 'size', 'weight']) + ) { + reasons.push('Text as migration requires no props besides size or weight') + } + if (asAttribute && renderAttribute) reasons.push('Text already has render prop') + + if (reasons.length > 0) { + markManual(j, api, file, path, reasons) + return true + } + if (!hasVariant && (sizeAttribute || weightAttribute)) { + addVariant(j, openingElement, variant) + } + if (asAttribute && renderName) { + replaceAsWithRender(j, openingElement, asAttribute, renderName) + } + return ( + Boolean(!hasVariant && (sizeAttribute || weightAttribute)) || + Boolean(asAttribute && renderName) + ) +} + +function transformHeadingElement(j, api, file, path) { + const openingElement = path.node.openingElement + const hasVariant = Boolean(getAttribute(openingElement, 'variant')) + const hasRender = Boolean(getAttribute(openingElement, 'render')) + const hasLegacyVariantProps = ['size', 'weight'].some((name) => + Boolean(getAttribute(openingElement, name)), + ) + const hasLegacyRenderProps = ['level', 'size', 'weight'].some((name) => + Boolean(getAttribute(openingElement, name)), + ) + + if ((hasVariant && hasLegacyVariantProps) || (hasRender && hasLegacyRenderProps)) { + markManual(j, api, file, path, [ + 'Heading mixes variant or render with legacy level, size, or weight props', + ]) + return true + } + if (hasVariant || hasRender) { + return false + } + + const reasons = [] + if (hasSpread(openingElement)) { + reasons.push('spread props may supply or override typography props') + } + for (const name of getDuplicateAttributes(openingElement, ['level', 'size', 'weight'])) { + reasons.push('duplicate Heading ' + name + ' props') + } + + const levelAttribute = getAttribute(openingElement, 'level') + const sizeAttribute = getAttribute(openingElement, 'size') + const weightAttribute = getAttribute(openingElement, 'weight') + const level = readStaticLevel(levelAttribute) + const size = readStaticString(sizeAttribute, 'default') + const weight = readStaticString(weightAttribute, 'regular') + + if (level === DYNAMIC) reasons.push('dynamic Heading level') + if (size === DYNAMIC) reasons.push('dynamic Heading size') + if (weight === DYNAMIC) reasons.push('dynamic Heading weight') + + let variant + if (reasons.length === 0) { + const fontSize = HEADING_SIZES[level]?.[size] + const fontWeight = HEADING_WEIGHTS[weight] + variant = HEADING_VARIANTS[fontSize + ':' + fontWeight] + if (!variant) reasons.push('Heading metrics have no exact variant') + } + + if (reasons.length > 0) { + markManual(j, api, file, path, reasons) + return true + } + + addVariant(j, openingElement, variant) + return true +} + +module.exports = function transform(file, api) { + const j = api.jscodeshift + const root = j(file.source) + const textNames = getImportedNames(root, j, 'Text') + const headingNames = getImportedNames(root, j, 'Heading') + let changed = false + + root.find(j.JSXElement).forEach((path) => { + const openingElement = path.node.openingElement + if (openingElement.name.type !== 'JSXIdentifier') return + + const { name } = openingElement.name + if (textNames.has(name) && isImportedBinding(path, name, 'Text')) { + changed = transformTextElement(j, api, file, path) || changed + } else if (headingNames.has(name) && isImportedBinding(path, name, 'Heading')) { + changed = transformHeadingElement(j, api, file, path) || changed + } + }) + + return changed ? root.toSource({ quote: 'single' }) : null +} + +module.exports.parser = 'tsx' diff --git a/codemods/typography-variants.test.js b/codemods/typography-variants.test.js new file mode 100644 index 000000000..570531681 --- /dev/null +++ b/codemods/typography-variants.test.js @@ -0,0 +1,114 @@ +const path = require('path') +const fs = require('fs') + +const { applyTransform } = require('jscodeshift/dist/testUtils') +const j = require('jscodeshift').withParser('tsx') +const prettier = require('prettier/standalone') +const estree = require('prettier/plugins/estree') +const typescript = require('prettier/plugins/typescript') + +const transform = require('./typography-variants') +const fixturesDirectory = path.join(__dirname, '__testfixtures__') + +function format(source) { + return prettier.format(source, { + parser: 'typescript', + plugins: [typescript, estree], + arrowParens: 'always', + printWidth: 100, + semi: false, + singleQuote: true, + tabWidth: 4, + trailingComma: 'all', + }) +} + +async function transformFixture(name) { + const inputPath = path.join(fixturesDirectory, name + '.input.tsx') + const source = fs.readFileSync(inputPath, 'utf8') + const output = await applyTransform(transform, null, { path: inputPath, source }) + const expected = fs.readFileSync(path.join(fixturesDirectory, name + '.output.tsx'), 'utf8') + + expect(await format(output)).toBe(await format(expected)) + return output +} + +test('transforms exact Text typography variants', async () => { + await transformFixture('typography-variants-text') +}) + +test('transforms exact Heading typography variants', async () => { + await transformFixture('typography-variants-heading') +}) + +test('marks ambiguous Text typography uses for manual migration', async () => { + const output = await transformFixture('typography-variants-manual') + + expect(() => j(output)).not.toThrow() +}) + +test('retains unsafe legacy element and mixed typography props for manual migration', async () => { + const output = await transformFixture('typography-variants-safety') + + expect(() => j(output)).not.toThrow() +}) + +test('reports unsafe legacy element and mixed typography props', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'typography-variants-safety.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + transform({ path: 'src/safety.tsx', source }, { jscodeshift: j, report, stats: jest.fn() }, {}) + + expect(report.mock.calls.map(([message]) => message)).toEqual([ + expect.stringMatching(/Text as migration requires no props besides size or weight$/), + expect.stringMatching(/Text as migration requires no props besides size or weight$/), + expect.stringMatching(/dynamic Text as target$/), + expect.stringMatching(/Text mixes variant with legacy size or weight props$/), + expect.stringMatching( + /Heading mixes variant or render with legacy level, size, or weight props$/, + ), + expect.stringMatching( + /Heading mixes variant or render with legacy level, size, or weight props$/, + ), + ]) +}) + +test('leaves valid Heading level and variant props unchanged on a second transform', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'typography-variants-idempotence.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + const output = transform( + { path: 'src/idempotence.tsx', source }, + { jscodeshift: j, report, stats: jest.fn() }, + {}, + ) + + expect(output).toBeNull() + expect(report).not.toHaveBeenCalled() +}) + +test('reports every manual migration with file and line', () => { + const source = fs.readFileSync( + path.join(fixturesDirectory, 'typography-variants-manual.input.tsx'), + 'utf8', + ) + const report = jest.fn() + + const output = transform( + { path: 'src/manual.tsx', source }, + { jscodeshift: j, report, stats: jest.fn() }, + {}, + ) + + expect(report).toHaveBeenCalledTimes(21) + for (const [message] of report.mock.calls) { + expect(message).toMatch(/^src\/manual\.tsx:\d+ /) + } + expect(() => j(output)).not.toThrow() +}) diff --git a/package-lock.json b/package-lock.json index 408affc37..d700be3b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "jest-axe": "^5.0.1", "jest-environment-jsdom": "^28.1.0", "jest-watch-typeahead": "^1.1.0", + "jscodeshift": "17.3.0", "less": "^3.8.1", "lint-staged": "^10.4.0", "marked": "^4.2.12", @@ -471,9 +472,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -551,9 +552,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -868,6 +869,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", @@ -1373,6 +1390,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", @@ -2183,6 +2217,24 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz", + "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-flow-strip-types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -12506,6 +12558,29 @@ "dev": true, "license": "ISC" }, + "node_modules/flow-estree": { + "version": "0.323.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.323.0.tgz", + "integrity": "sha512-VuCvqb38ueB7u9Xnfuj/uV9xWaqRM1XX8fUbch3Q1perZp1BZHAcGKcSkc57RdVXaDrjsfgNOiSEmlvx8Yhk4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/flow-parser": { + "version": "0.323.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.323.0.tgz", + "integrity": "sha512-uTw5ffLYxQDnSe4Ch5mo1Sp7kFOrhOglz3nqgpQRrYpt4ucQTxrQRCLdC0F1hyhcIK7j5GjdxzV1d4DN68eDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "flow-estree": "0.323.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", @@ -17299,6 +17374,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jscodeshift": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.3.0.tgz", + "integrity": "sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/preset-flow": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@babel/register": "^7.24.6", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.7", + "neo-async": "^2.5.0", + "picocolors": "^1.0.1", + "recast": "^0.23.11", + "tmp": "^0.2.3", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + }, + "peerDependenciesMeta": { + "@babel/preset-env": { + "optional": true + } + } + }, + "node_modules/jscodeshift/node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/jsdom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", @@ -26833,6 +26959,20 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", diff --git a/package.json b/package.json index e9c72c12c..8c5fcf1b8 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "CONTRIBUTING.md", "LICENSE", "README.md", + "codemods/**", "dist/**", "es/**", "lib/**", @@ -129,6 +130,7 @@ "jest-axe": "^5.0.1", "jest-environment-jsdom": "^28.1.0", "jest-watch-typeahead": "^1.1.0", + "jscodeshift": "17.3.0", "less": "^3.8.1", "lint-staged": "^10.4.0", "marked": "^4.2.12", diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index cd88227a7..4561d4787 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -135,9 +135,9 @@ function StorySection({ return ( - {title} + {title} {description ? ( - + {description} ) : null} @@ -152,7 +152,7 @@ function AvatarExample({ label, children }: { label: string; children: React.Rea {children} - + {label} diff --git a/src/base-field/base-field.tsx b/src/base-field/base-field.tsx index 08c253d63..27c66986a 100644 --- a/src/base-field/base-field.tsx +++ b/src/base-field/base-field.tsx @@ -30,7 +30,7 @@ function fieldToneToTextTone(tone: FieldTone) { function FieldMessage({ id, children, tone }: FieldMessageProps) { return ( - + } tone={fieldToneToTextTone(tone)} variant="callout-2" id={id}> {tone === 'loading' ? ( + {children} ) @@ -334,9 +334,8 @@ function BaseField({ alignItems="flexEnd" > } > {label ? ( {label} diff --git a/src/box/box.stories.tsx b/src/box/box.stories.tsx index 8e8399d35..9fe1ca420 100644 --- a/src/box/box.stories.tsx +++ b/src/box/box.stories.tsx @@ -280,7 +280,7 @@ export function OverlayScrollStory() { {Array.from({ length: 20 }, (_, i) => ( Content item {i + 1} - + This is some additional content to make the item taller and ensure scrolling is needed. diff --git a/src/display/display.mdx b/src/display/display.mdx new file mode 100644 index 000000000..07dedd371 --- /dev/null +++ b/src/display/display.mdx @@ -0,0 +1,25 @@ +import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks' +import { Display } from './display' +import * as DisplayStories from './display.stories' + + + +# Display + + + +## Usage + +Use `Display` for large, prominent text and always choose a display variant. It renders a `div` by +default, so it has no heading semantics unless `render` supplies a heading element. + +```tsx +42 completed tasks +}>Annual review +``` + +Choose semantic heading levels from the page structure, not from the display variant number. + +## Props + + diff --git a/src/display/display.module.css b/src/display/display.module.css new file mode 100644 index 000000000..f0f221163 --- /dev/null +++ b/src/display/display.module.css @@ -0,0 +1,44 @@ +:root { + --reactist-display-font-size-1: 96px; + --reactist-display-font-size-2: 88px; + --reactist-display-font-size-3: 72px; + --reactist-display-font-size-4: 56px; + --reactist-display-font-size-5: 42px; + --reactist-display-font-weight: var(--reactist-typography-font-weight-medium); + --reactist-display-line-height: normal; + --reactist-display-letter-spacing-1: 0; + --reactist-display-letter-spacing-2: 0; + --reactist-display-letter-spacing-3: 0.14px; + --reactist-display-letter-spacing-4: 0.3px; + --reactist-display-letter-spacing-5: 0.37px; +} + +.display { + font-weight: var(--reactist-display-font-weight); + line-height: var(--reactist-display-line-height); +} + +.variant-display-1 { + font-size: var(--reactist-display-font-size-1); + letter-spacing: var(--reactist-display-letter-spacing-1); +} + +.variant-display-2 { + font-size: var(--reactist-display-font-size-2); + letter-spacing: var(--reactist-display-letter-spacing-2); +} + +.variant-display-3 { + font-size: var(--reactist-display-font-size-3); + letter-spacing: var(--reactist-display-letter-spacing-3); +} + +.variant-display-4 { + font-size: var(--reactist-display-font-size-4); + letter-spacing: var(--reactist-display-letter-spacing-4); +} + +.variant-display-5 { + font-size: var(--reactist-display-font-size-5); + letter-spacing: var(--reactist-display-letter-spacing-5); +} diff --git a/src/display/display.stories.tsx b/src/display/display.stories.tsx new file mode 100644 index 000000000..8a08936a7 --- /dev/null +++ b/src/display/display.stories.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' + +import { Stack } from '../stack' +import { select, selectWithNone } from '../utils/storybook-helper' + +import { Display } from './display' + +export default { + title: 'Typography/Display', + component: Display, + parameters: { + badges: ['accessible'], + figma: { + path: 'Global > Text Styles > SF *FOR WEB* > Display 1', + url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316', + }, + }, +} + +const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const + +export function DisplayStory() { + return ( +
+ + {displayVariants.map((variant) => ( + + {variant} + + ))} + +
+ ) +} + +DisplayStory.parameters = { chromatic: { disableSnapshot: false } } + +export function DisplayPlaygroundStory(props: React.ComponentProps) { + return ( +
+ +
+ ) +} + +DisplayPlaygroundStory.args = { + variant: 'display-3', + tone: 'normal', + children: 'Display text', +} + +DisplayPlaygroundStory.argTypes = { + variant: select(displayVariants), + lineClamp: selectWithNone([1, 2, 3, 4, 5]), + tone: select(['normal', 'secondary', 'danger', 'positive']), + align: selectWithNone(['start', 'center', 'end', 'justify']), + children: { control: { type: 'text' } }, +} diff --git a/src/display/display.test.tsx b/src/display/display.test.tsx new file mode 100644 index 000000000..1e04e785c --- /dev/null +++ b/src/display/display.test.tsx @@ -0,0 +1,105 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { Display } from './display' + +const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const + +describe('Display', () => { + it.each(displayVariants)('applies the %s variant', (variant) => { + render(Display) + expect(screen.getByText('Display')).toHaveClass( + 'variant-' + variant, + 'font-family-sf-for-web', + 'display', + ) + }) + + it('uses the shared SF for Web CSS-module class', () => { + const source = readFileSync(join(__dirname, 'display.tsx'), 'utf8') + + expect(source).toContain( + "import typographyStyles from '../typography/typography.module.css'", + ) + expect(source).toContain("fontFamilyClassName: typographyStyles['font-family-sf-for-web']!") + }) + + it('renders a div by default', () => { + render(Display) + expect(screen.getByText('Display').tagName).toBe('DIV') + }) + + it('renders through Ariakit Role', () => { + render( + }> + Page title + , + ) + const element = screen.getByRole('heading', { level: 1, name: 'Page title' }) + expect(element).toHaveAttribute('id', 'page-title') + expect(element).toHaveClass('variant-display-2') + }) + + it('does not acknowledge className but accepts exceptionallySetClassName', () => { + render( + + Display + , + ) + expect(screen.getByText('Display')).toHaveClass('right') + expect(screen.getByText('Display')).not.toHaveClass('wrong') + }) + + it('supports tone, responsive alignment, and line clamping', () => { + render( + + Display + , + ) + expect(screen.getByText('Display')).toHaveClass( + 'tone-secondary', + 'textAlign-start', + 'tablet-textAlign-center', + 'desktop-textAlign-end', + 'lineClamp-2', + 'lineClampMultipleLines', + 'paddingRight-xsmall', + ) + }) + + it('forwards its ref', () => { + const ref = React.createRef() + render( + + Display + , + ) + expect(ref.current?.tagName).toBe('DIV') + }) + + it('has no accessibility violations', async () => { + const { container } = render( + <> + Display + }> + Page title + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/src/display/display.tsx b/src/display/display.tsx new file mode 100644 index 000000000..325979448 --- /dev/null +++ b/src/display/display.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' + +import { Role } from '@ariakit/react' + +import { getTypographyClassName } from '../typography/typography' + +import typographyStyles from '../typography/typography.module.css' +import styles from './display.module.css' + +import type { RoleProps } from '@ariakit/react' +import type { TypographyStyleProps } from '../typography/typography' + +type DisplayVariant = 'display-1' | 'display-2' | 'display-3' | 'display-4' | 'display-5' + +/** Renders prominent display text with a required display typography variant. */ +type DisplayProps = Omit, 'children' | 'className'> & + TypographyStyleProps & { + /** Large display text content. */ + children: React.ReactNode + /** Visual display style. */ + variant: DisplayVariant + /** Custom element rendered with display typography; defaults to a div. */ + render?: RoleProps['render'] + } + +/** Renders prominent display text with a required display typography variant. */ +const Display = React.forwardRef(function Display( + { + variant, + tone = 'normal', + align, + lineClamp, + exceptionallySetClassName, + render, + children, + ...props + }, + ref, +) { + return ( + + {children} + + ) +}) + +Display.displayName = 'Display' + +export type { DisplayProps, DisplayVariant } +export { Display } diff --git a/src/display/index.ts b/src/display/index.ts new file mode 100644 index 000000000..41d109997 --- /dev/null +++ b/src/display/index.ts @@ -0,0 +1 @@ +export * from './display' diff --git a/src/expansion-panel/expansion-panel.stories.tsx b/src/expansion-panel/expansion-panel.stories.tsx index a42df5c2e..d3df904e9 100644 --- a/src/expansion-panel/expansion-panel.stories.tsx +++ b/src/expansion-panel/expansion-panel.stories.tsx @@ -26,7 +26,7 @@ export const IconToggle = { alignItems="center" justifyContent="spaceBetween" > - + Fruit diff --git a/src/heading/heading.mdx b/src/heading/heading.mdx new file mode 100644 index 000000000..586123737 --- /dev/null +++ b/src/heading/heading.mdx @@ -0,0 +1,33 @@ +import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks' +import { Heading } from './heading' +import * as HeadingStories from './heading.stories' + + + +# Heading + + + +## Usage + +Use `level` according to the document outline. The matching visual variant is inferred for levels +1-3; levels 4-6 use `heading-4`. Override `variant` only when semantics and visual hierarchy differ. + +```tsx +Page title +Section title +Prominent section title +``` + +Use the custom render path only to apply heading typography to a non-heading control. It does not +add heading semantics. + +```tsx +}> + Edit title + +``` + +## Props + + diff --git a/src/heading/heading.module.css b/src/heading/heading.module.css index aee2b4605..79a4f0994 100644 --- a/src/heading/heading.module.css +++ b/src/heading/heading.module.css @@ -1,114 +1,27 @@ -.heading { - color: var(--reactist-content-primary); +.variant-heading-1 { + font-size: 32px; font-weight: var(--reactist-font-weight-strong); - font-family: var(--reactist-font-family); + letter-spacing: 0.41px; + line-height: normal; } -.weight-medium { - font-weight: var(--reactist-font-weight-medium); -} - -.weight-light { - font-weight: var(--reactist-font-weight-regular); -} - -/* tone */ - -.tone-secondary { - color: var(--reactist-content-secondary); -} -.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; +.variant-heading-2 { + font-size: 26px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0.22px; + line-height: normal; } -.lineClamp-1 { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.variant-heading-3 { + font-size: 20px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0; + line-height: normal; } -.lineClamp-2 { - -webkit-line-clamp: 2; -} -.lineClamp-3 { - -webkit-line-clamp: 3; -} -.lineClamp-4 { - -webkit-line-clamp: 4; -} -.lineClamp-5 { - -webkit-line-clamp: 5; +.variant-heading-4 { + font-size: 18px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: 0; + line-height: normal; } diff --git a/src/heading/heading.stories.tsx b/src/heading/heading.stories.tsx index 3ecf79e4e..53f1d51b4 100644 --- a/src/heading/heading.stories.tsx +++ b/src/heading/heading.stories.tsx @@ -20,55 +20,19 @@ export default { 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 - + + Heading 1 + Heading 2 + Heading 3 + Heading 4 + Heading 5, visually heading-4 + Heading 6, visually heading-4 + + Semantic h2, visual heading-1 + + }> + Button with heading typography +
) @@ -81,7 +45,7 @@ HeadingStory.parameters = { 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. @@ -111,15 +75,13 @@ export function ResponsiveHeadingStory(props: React.ComponentProps { 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( - + it.each([ + [1, 'H1', 'heading-1'], + [2, 'H2', 'heading-2'], + [3, 'H3', 'heading-3'], + [4, 'H4', 'heading-4'], + [5, 'H5', 'heading-4'], + [6, 'H6', 'heading-4'], + ] as const)('renders level %s as %s with %s', (level, tagName, variant) => { + render( + Heading , ) - expect(screen.getByTestId('heading-element').tagName).toBe('H1') + const element = screen.getByTestId('heading-element') - for (const level of [2, 3, 4, 5, 6] as const) { - rerender( - - Heading - , - ) - expect(screen.getByTestId('heading-element').tagName).toBe(`H${level}`) - } + expect(element.tagName).toBe(tagName) + expect(element).toHaveClass('variant-' + variant) }) - it('renders its children as its content', () => { + it('lets semantic level and visual variant differ', () => { render( - - Hello world + + Heading , ) - expect(screen.getByTestId('heading-element').innerHTML).toMatchInlineSnapshot( - `"Hello world"`, - ) + const element = screen.getByTestId('heading-element') + + expect(element.tagName).toBe('H2') + expect(element).toHaveClass('variant-heading-1') }) - 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') + it('requires an explicit variant for custom rendering', () => { + render( + } + > + Edit title + , + ) - for (const size of ['smaller', 'larger', 'largest'] as const) { - rerender( - - Heading - , - ) - expect(textElement).toHaveClass(`size-${size}`) - } - }) + expect(screen.getByRole('button', { name: 'Edit title' })).toHaveClass('variant-heading-2') }) - 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') + it('forwards its semantic heading ref', () => { + const ref = React.createRef() - rerender( - - Heading - , - ) - expect(textElement).toHaveClass('weight-medium') + render( + + Heading + , + ) - rerender( - - Heading - , - ) - expect(textElement).toHaveClass('weight-light') - }) + expect(ref.current?.tagName).toBe('H2') + }) + + it('renders its children as its content', () => { + render( + + Hello world + , + ) + + expect(screen.getByTestId('heading-element').innerHTML).toMatchInlineSnapshot( + `"Hello world"`, + ) }) describe('tone="…"', () => { @@ -109,6 +100,7 @@ describe('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') @@ -132,6 +124,7 @@ describe('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') @@ -158,6 +151,7 @@ describe('Heading', () => { , ) const textElement = screen.getByTestId('heading-element') + expect(textElement).toHaveClass('textAlign-start') expect(textElement).toHaveClass('tablet-textAlign-center') expect(textElement).toHaveClass('desktop-textAlign-end') @@ -172,6 +166,7 @@ describe('Heading', () => { , ) const textElement = screen.getByTestId('heading-element') + expect(textElement.className).not.toMatch(/lineClamp/) expect(textElement).not.toHaveClass('paddingRight-xsmall') @@ -182,7 +177,7 @@ describe('Heading', () => { , ) expect(textElement).toHaveClass(`lineClamp-${lineClamp}`) - expect(textElement).not.toHaveClass(`lineClampMultipleLines`) + expect(textElement).not.toHaveClass('lineClampMultipleLines') expect(textElement).toHaveClass('paddingRight-xsmall') } @@ -193,7 +188,7 @@ describe('Heading', () => { , ) expect(textElement).toHaveClass(`lineClamp-${lineClamp}`) - expect(textElement).toHaveClass(`lineClampMultipleLines`) + expect(textElement).toHaveClass('lineClampMultipleLines') expect(textElement).toHaveClass('paddingRight-xsmall') } }) @@ -209,6 +204,12 @@ describe('Heading', () => { Heading Heading Heading + + Overridden heading + + }> + Button heading + , ) const results = await axe(container) @@ -217,3 +218,19 @@ describe('Heading', () => { }) }) }) + +const invalidHeadingProps = ( + // @ts-expect-error level and render are mutually exclusive + }> + Invalid + +) +// eslint-disable-next-line no-void +void invalidHeadingProps + +const missingRenderVariant = ( + // @ts-expect-error custom render requires a variant + }>Invalid +) +// eslint-disable-next-line no-void +void missingRenderVariant diff --git a/src/heading/heading.tsx b/src/heading/heading.tsx index fb3501e93..ddab2e197 100644 --- a/src/heading/heading.tsx +++ b/src/heading/heading.tsx @@ -1,122 +1,89 @@ import * as React from 'react' -import { Box } from '../box' -import { getClassNames } from '../utils/responsive-props' +import { Role } from '@ariakit/react' + +import { getTypographyClassName } from '../typography/typography' import styles from './heading.module.css' -import type { BoxProps } from '../box' -import type { ObfuscatedClassName, Tone } from '../utils/common-types' +import type { TypographyStyleProps } from '../typography/typography' type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6' -type HeadingElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +type HeadingVariant = 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' + +type HeadingBaseProps = Omit, 'children' | 'className'> & + TypographyStyleProps & { + /** Heading content. */ + children: React.ReactNode + } -type HeadingProps = Omit, 'className' | 'children'> & { - children: React.ReactNode - /** - * The semantic level of the heading. - */ +type SemanticHeadingProps = { + /** Semantic HTML heading level. */ level: HeadingLevel + /** Visual heading style; inferred from level when omitted. */ + variant?: HeadingVariant + /** Custom rendering is unavailable when semantic level is supplied. */ + render?: never +} + +type RenderedHeadingProps = { + /** Semantic level is unavailable when a custom element is supplied. */ + level?: never + /** Visual heading style. */ + variant: HeadingVariant + /** Custom element receiving heading typography. */ + render: React.ReactElement +} - /** - * 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'] +/** Renders a semantic heading or applies heading typography to a custom element. */ +type HeadingProps = HeadingBaseProps & (SemanticHeadingProps | RenderedHeadingProps) + +function getHeadingVariant(level: HeadingLevel | undefined): HeadingVariant { + const numericLevel = Number(level ?? 1) + + return ('heading-' + Math.min(numericLevel, 4)) as HeadingVariant } -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} - - ) +function getHeadingRender(level: HeadingLevel | undefined) { + return level == null ? undefined : React.createElement('h' + level) +} + +/** Renders a semantic heading or applies heading typography to a custom element. */ +const Heading = React.forwardRef(function Heading( + { + level, + variant, + render, + tone = 'normal', + align, + lineClamp, + exceptionallySetClassName, + children, + ...props }, -) + ref, +) { + const resolvedVariant = variant ?? getHeadingVariant(level) + + return ( + + {children} + + ) +}) Heading.displayName = 'Heading' -export type { HeadingLevel, HeadingProps } +export type { HeadingLevel, HeadingProps, HeadingVariant } export { Heading } diff --git a/src/index.ts b/src/index.ts index a27472339..5071a901c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export * from './notice' export * from './toast' // text and typography +export * from './display' export * from './heading' export * from './prose' export * from './text' diff --git a/src/menu/menu.stories.jsx b/src/menu/menu.stories.jsx index 8a582829f..b6f43f438 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 9c82f3428..c02d9da9c 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -137,9 +137,7 @@ export function ModalWithHeaderBodyAndCustomFooter() { - - Do whatever you want down here - + Do whatever you want down here @@ -307,7 +305,7 @@ export function MinimalisticConfirmationModal() { - Are you sure you want to leave? + Are you sure you want to leave? - + By default the `autoFocus` prop is `true`, which shifts the focus onto the first focusable element in the modal. You can further refine this by using the `data-autofocus` attribute if you wish to focus on elements other than the first @@ -438,7 +434,7 @@ export function StackingModals() { - + Modals may be stacked on top of one another, with each of them having their independent states, e.g. `width` and `height`. @@ -449,7 +445,7 @@ export function StackingModals() { - + Modals may be stacked on top of one another, with each of them having their independent states, e.g. `width` and `height`. diff --git a/src/password-field/password-field.stories.jsx b/src/password-field/password-field.stories.jsx index 596f22bcb..5b6a58976 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 44ac73ad2..86beb5955 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 c97bd0700..e30a6f9b2 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 a709e6ff6..4167953a7 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 000000000..05ef46345 --- /dev/null +++ b/src/text/text.mdx @@ -0,0 +1,34 @@ +import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks' +import { Text } from './text' +import * as TextStories from './text.stories' + + + +# Text + + + +## Usage + +Use `Text` for non-heading interface copy. It renders a `div` and uses `body-3` by default. Choose +one named variant for the complete Figma style; do not combine independent size and weight values. + +```tsx +Default body copy +Emphasized body copy +Underlined caption +Metadata +``` + +Use `render` when the copy needs another HTML element. The rendered element owns its semantics. + +```tsx +}> + Project name + + +``` + +## Props + + diff --git a/src/text/text.module.css b/src/text/text.module.css index d78d3aabd..ce2882be6 100644 --- a/src/text/text.module.css +++ b/src/text/text.module.css @@ -1,60 +1,106 @@ -.text { - font-family: var(--reactist-font-family); - font-size: var(--reactist-font-size-body); - font-weight: var(--reactist-font-weight-regular); - color: var(--reactist-content-primary); +.variant-subheader-1 { + font-size: 16px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: 0; + line-height: 23px; } -.size-caption { - font-size: var(--reactist-font-size-caption); -} -.size-copy { - font-size: var(--reactist-font-size-copy); +.variant-subheader-2 { + font-size: 16px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 23px; } -.size-subtitle { - font-size: var(--reactist-font-size-subtitle); + +.variant-body-1 { + font-size: 14px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: -0.15px; + line-height: 21px; } -.weight-semibold { - font-weight: var(--reactist-font-weight-medium); +.variant-body-2 { + font-size: 14px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 21px; } -.weight-bold { - font-weight: var(--reactist-font-weight-strong); + +.variant-body-3 { + font-size: 14px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 21px; } -.tone-secondary { - color: var(--reactist-content-secondary); +.variant-callout-1 { + font-size: 13px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 20px; } -.tone-danger { - color: var(--reactist-content-danger); + +.variant-callout-2 { + font-size: 13px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: -0.15px; + line-height: 20px; } -.tone-positive { - color: var(--reactist-content-positive); + +.variant-caption-1 { + font-size: 12px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 0; + line-height: 20px; } -/* truncated text */ +.variant-caption-2 { + font-size: 12px; + font-weight: var(--reactist-typography-font-weight-semibold); + letter-spacing: -0.15px; + line-height: 15px; +} -.lineClampMultipleLines { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; +.variant-caption-3 { + font-size: 12px; + font-weight: var(--reactist-font-weight-regular); + letter-spacing: 0; + line-height: 20px; } -.lineClamp-1 { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.variant-footnote-1 { + font-size: 10px; + font-weight: var(--reactist-font-weight-strong); + letter-spacing: 1px; + line-height: 13px; } -.lineClamp-2 { - -webkit-line-clamp: 2; +.variant-footnote-2 { + font-size: 10px; + font-weight: var(--reactist-typography-font-weight-medium); + letter-spacing: 1px; + line-height: 13px; } -.lineClamp-3 { - -webkit-line-clamp: 3; + +.variant-subheader-1.decoration-strikethrough, +.variant-subheader-2.decoration-strikethrough, +.variant-body-3.decoration-strikethrough, +.variant-callout-1.decoration-strikethrough, +.variant-callout-2.decoration-strikethrough, +.variant-caption-2.decoration-strikethrough, +.variant-caption-3.decoration-strikethrough { + text-decoration-line: line-through; + text-decoration-skip-ink: none; + text-underline-position: from-font; } -.lineClamp-4 { - -webkit-line-clamp: 4; + +.variant-caption-2.decoration-underline, +.variant-caption-3.decoration-underline { + text-decoration-line: underline; + text-decoration-skip-ink: none; + text-underline-position: from-font; } -.lineClamp-5 { - -webkit-line-clamp: 5; + +.variant-footnote-1.case-uppercase { + text-transform: uppercase; } diff --git a/src/text/text.stories.tsx b/src/text/text.stories.tsx index 8800540af..fbd8ed7e0 100644 --- a/src/text/text.stories.tsx +++ b/src/text/text.stories.tsx @@ -5,6 +5,21 @@ import { ResponsiveWidthRef, select, selectWithNone } from '../utils/storybook-h import { Text } from './text' +const textVariants = [ + 'subheader-1', + 'subheader-2', + 'body-1', + 'body-2', + 'body-3', + 'callout-1', + 'callout-2', + 'caption-1', + 'caption-2', + 'caption-3', + 'footnote-1', + 'footnote-2', +] as const + export default { title: '🔤 Typography/Text', component: Text, @@ -21,80 +36,19 @@ export function TextStory() { return (
- - Subtitle Regular - - - Subtitle Secondary - - - Subtitle Danger - - - Subtitle Positive - - - Subtitle Semibold - - - Subtitle Bold - - - - Body Regular - - - Body Secondary - - - Body Danger - - - Body Positive - - - Body Semibold - - - Body Bold - - - - Copy Regular - - - Copy Secondary - - - Copy Danger - - - Copy Positive - - - Copy Semibold - - - Copy Bold - - - - Caption Regular - - - Caption Secondary - - - Caption Danger - - - Caption Positive + {textVariants.map((variant) => ( + + {variant} + + ))} + + caption-2 underline - - Caption Semibold + + caption-3 strikethrough - - Caption Bold + + footnote-1 uppercase
@@ -153,15 +107,15 @@ export function ResponsiveTextStory(props: React.ComponentProps) { } ResponsiveTextStory.args = { - size: 'body', - weight: 'regular', + variant: 'body-3', tone: 'normal', children: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit', } ResponsiveTextStory.argTypes = { - size: select(['caption', 'copy', 'body', 'subtitle']), - weight: select(['regular', 'semibold', 'bold']), + variant: select(textVariants), + decoration: selectWithNone(['strikethrough', 'underline']), + case: selectWithNone(['uppercase']), lineClamp: selectWithNone([1, 2, 3, 4, 5]), tone: select(['normal', 'secondary', 'danger']), align: { control: false }, @@ -179,15 +133,15 @@ export function TextPlaygroundStory(props: React.ComponentProps) { } TextPlaygroundStory.args = { - size: 'body', - weight: 'regular', + variant: 'body-3', tone: 'normal', children: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit', } TextPlaygroundStory.argTypes = { - size: select(['caption', 'copy', 'body', 'subtitle']), - weight: select(['regular', 'semibold', 'bold']), + variant: select(textVariants), + decoration: selectWithNone(['strikethrough', 'underline']), + case: selectWithNone(['uppercase']), lineClamp: selectWithNone([1, 2, 3, 4, 5]), tone: select(['normal', 'secondary', 'danger']), align: selectWithNone(['start', 'center', 'end', 'justify']), diff --git a/src/text/text.test.tsx b/src/text/text.test.tsx index 0e058516e..6e30fc585 100644 --- a/src/text/text.test.tsx +++ b/src/text/text.test.tsx @@ -1,9 +1,41 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' import { Text } from './text' +import type { TextProps } from './text' + +const textVariants = [ + 'subheader-1', + 'subheader-2', + 'body-1', + 'body-2', + 'body-3', + 'callout-1', + 'callout-2', + 'caption-1', + 'caption-2', + 'caption-3', + 'footnote-1', + 'footnote-2', +] as const + +const decoratedTextProps = [ + { variant: 'subheader-1', decoration: 'strikethrough' }, + { variant: 'subheader-2', decoration: 'strikethrough' }, + { variant: 'body-3', decoration: 'strikethrough' }, + { variant: 'callout-1', decoration: 'strikethrough' }, + { variant: 'callout-2', decoration: 'strikethrough' }, + { variant: 'caption-2', decoration: 'strikethrough' }, + { variant: 'caption-2', decoration: 'underline' }, + { variant: 'caption-3', decoration: 'strikethrough' }, + { variant: 'caption-3', decoration: 'underline' }, +] as const satisfies ReadonlyArray< + Omit, 'children'> +> + describe('Text', () => { it('does not acknowledge the className prop, but exceptionallySetClassName instead', () => { render( @@ -20,13 +52,35 @@ describe('Text', () => { expect(screen.getByTestId('text-element')).not.toHaveClass('wrong') }) - it('can be rendered as any HTML element', () => { + it('defaults to body-3', () => { + render(Text) + expect(screen.getByTestId('text-element')).toHaveClass('variant-body-3') + }) + + it.each(textVariants)('applies the %s variant', (variant) => { render( - + Text , ) - expect(screen.getByTestId('text-element').tagName).toBe('NAV') + expect(screen.getByTestId('text-element')).toHaveClass('variant-' + variant) + }) + + it('renders through Ariakit Role', () => { + render( + }> + Name + , + ) + const element = screen.getByTestId('text-element') + expect(element.tagName).toBe('LABEL') + expect(element).toHaveAttribute('for', 'name') + }) + + it('forwards its ref', () => { + const ref = React.createRef() + render(Text) + expect(ref.current?.tagName).toBe('DIV') }) it('renders its children as its content', () => { @@ -40,53 +94,6 @@ describe('Text', () => { ) }) - describe('size="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Text - , - ) - const textElement = screen.getByTestId('text-element') - expect(textElement).not.toHaveClass('size-body') - expect(textElement).not.toHaveClass('size-caption') - expect(textElement).not.toHaveClass('size-copy') - expect(textElement).not.toHaveClass('size-subtitle') - - for (const size of ['caption', 'copy', 'subtitle'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`size-${size}`) - } - }) - }) - - describe('weight="…"', () => { - it('adds the appropriate class names', () => { - const { rerender } = render( - - Text - , - ) - const textElement = screen.getByTestId('text-element') - expect(textElement).not.toHaveClass('weight-regular') - expect(textElement).not.toHaveClass('weight-semibold') - expect(textElement).not.toHaveClass('weight-bold') - - for (const weight of ['semibold', 'bold'] as const) { - rerender( - - Text - , - ) - expect(textElement).toHaveClass(`weight-${weight}`) - } - }) - }) - describe('tone="…"', () => { it('adds the appropriate class names', () => { const { rerender } = render( @@ -175,4 +182,48 @@ describe('Text', () => { } }) }) + + it.each(decoratedTextProps)('supports $variant with $decoration', (textProps) => { + render( + + Text + , + ) + expect(screen.getByTestId('text-element')).toHaveClass('decoration-' + textProps.decoration) + }) + + it('supports uppercase only for footnote-1', () => { + render( + + Text + , + ) + expect(screen.getByTestId('text-element')).toHaveClass('case-uppercase') + }) + + it('rejects invalid modifiers at type level', () => { + const invalidTextModifier = ( + // @ts-expect-error body-1 does not support decoration + + Invalid + + ) + expect(invalidTextModifier).toBeDefined() + }) + + it('has no accessibility violations', async () => { + const { container } = render( + <> + Default text + + Caption + + }> + Name + + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) }) diff --git a/src/text/text.tsx b/src/text/text.tsx index b0bf36427..08c0da215 100644 --- a/src/text/text.tsx +++ b/src/text/text.tsx @@ -1,98 +1,119 @@ import * as React from 'react' -import { Box } from '../box' -import { polymorphicComponent } from '../utils/polymorphism' -import { getClassNames } from '../utils/responsive-props' +import { Role } from '@ariakit/react' + +import { getTypographyClassName } from '../typography/typography' import styles from './text.module.css' -import type { BoxProps } from '../box' -import type { Tone } from '../utils/common-types' +import type { RoleProps } from '@ariakit/react' +import type { TypographyStyleProps } from '../typography/typography' + +type TextVariant = + | 'subheader-1' + | 'subheader-2' + | 'body-1' + | 'body-2' + | 'body-3' + | 'callout-1' + | 'callout-2' + | 'caption-1' + | 'caption-2' + | 'caption-3' + | 'footnote-1' + | 'footnote-2' + +type StrikethroughTextProps = { + /** Visual text style supporting strikethrough. */ + variant: + | 'subheader-1' + | 'subheader-2' + | 'body-3' + | 'callout-1' + | 'callout-2' + | 'caption-2' + | 'caption-3' + /** Figma-supported strikethrough decoration. */ + decoration: 'strikethrough' + /** Uppercase presentation is unavailable with strikethrough. */ + case?: never +} + +type UnderlinedTextProps = { + /** Visual caption style supporting underline. */ + variant: 'caption-2' | 'caption-3' + /** Figma-supported underline decoration. */ + decoration: 'underline' + /** Uppercase presentation is unavailable with underline. */ + case?: never +} + +type UnmodifiedTextProps = { + /** Visual text style; defaults to body-3. */ + variant?: TextVariant + /** Decoration is omitted for the base variant. */ + decoration?: never + /** Case override is omitted for the base variant. */ + case?: never +} -type TextProps = { - children: React.ReactNode - /** - * The size of the text. - * - * The supported values, from smaller size to larger size, are: - * 'caption', 'copy', 'body', and 'subtitle' - * - * @default 'body' - */ - size?: 'caption' | 'copy' | 'body' | 'subtitle' - /** - * The weight of the text font. - * - * @default 'regular' - */ - weight?: 'regular' | 'semibold' | 'bold' - /** - * The tone (semantic color) of the text. - * - * @default 'normal' - */ - tone?: Tone - /** - * Used to truncate the text to a given number of lines. - * - * It will add an ellipsis (`…`) to the text at the end of the last line, only if the text was - * truncated. If the text fits without it being truncated, no ellipsis is added. - * - * By default, the text is not truncated at all, no matter how many lines it takes to render it. - * - * @default undefined - */ - lineClamp?: 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5' - /** - * How to align the text horizontally. - * - * @default 'start' - */ - align?: BoxProps['textAlign'] +type UppercaseTextProps = { + /** Visual footnote style supporting uppercase. */ + variant: 'footnote-1' + /** Decoration is unavailable with uppercase presentation. */ + decoration?: never + /** Figma-supported uppercase presentation. */ + case: 'uppercase' } -const Text = polymorphicComponent<'div', TextProps>(function Text( +/** Renders non-heading interface copy with a named typography variant. */ +type TextProps = Omit, 'children' | 'className'> & + TypographyStyleProps & { + /** Text content. */ + children: React.ReactNode + /** Custom element rendered with text typography; defaults to a div. */ + render?: RoleProps['render'] + } & (StrikethroughTextProps | UnderlinedTextProps | UppercaseTextProps | UnmodifiedTextProps) + +/** Renders non-heading interface copy with a named typography variant. */ +const Text = React.forwardRef(function Text( { - as, - size = 'body', - weight = 'regular', + variant = 'body-3', + decoration, + case: textCase, tone = 'normal', align, - children, lineClamp, exceptionallySetClassName, + render, + children, ...props }, ref, ) { - const lineClampMultipleLines = - typeof lineClamp === 'string' ? Number(lineClamp) > 1 : (lineClamp ?? 1) > 1 - return ( - {children} - + ) }) Text.displayName = 'Text' -export type { TextProps } +export type { TextProps, TextVariant } export { Text } diff --git a/src/toast/static-toast.tsx b/src/toast/static-toast.tsx index 39f8b6809..02c081a8b 100644 --- a/src/toast/static-toast.tsx +++ b/src/toast/static-toast.tsx @@ -96,7 +96,7 @@ const StaticToast = React.forwardRef(function {description ? ( - {message} + {message} {description} ) : ( diff --git a/src/toast/toast.stories.tsx b/src/toast/toast.stories.tsx index fec548ee9..b4f40a281 100644 --- a/src/toast/toast.stories.tsx +++ b/src/toast/toast.stories.tsx @@ -65,7 +65,7 @@ export function NotificationToastsStory() { return ( - + Toasts @@ -172,7 +172,7 @@ export function StaticToastStory() { return ( - + Statically-rendered toasts diff --git a/src/tooltip/tooltip.stories.tsx b/src/tooltip/tooltip.stories.tsx index 17f70e504..148c38c6c 100644 --- a/src/tooltip/tooltip.stories.tsx +++ b/src/tooltip/tooltip.stories.tsx @@ -116,9 +116,7 @@ export function TooltipRichContent({ hideTimeout={hideTimeout} content={ - - Upgrade to Pro - + Upgrade to Pro
  • 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. diff --git a/src/typography/typography.module.css b/src/typography/typography.module.css new file mode 100644 index 000000000..d915f38a0 --- /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(--reactist-content-primary); +} + +.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(--reactist-content-secondary); +} + +.tone-danger { + color: var(--reactist-content-danger); +} + +.tone-positive { + color: var(--reactist-content-positive); +} + +.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.stories.module.css b/src/typography/typography.stories.module.css new file mode 100644 index 000000000..bd846d271 --- /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 000000000..3b9e988c9 --- /dev/null +++ b/src/typography/typography.stories.tsx @@ -0,0 +1,151 @@ +import * as React from 'react' + +import { Display } from '../display' +import { Heading } from '../heading' +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 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 new file mode 100644 index 000000000..16820dc7f --- /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.fontFamilyDefault, + 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 } diff --git a/stories/components/color.stories.tsx b/stories/components/color.stories.tsx index 764336408..3b757c937 100644 --- a/stories/components/color.stories.tsx +++ b/stories/components/color.stories.tsx @@ -47,7 +47,7 @@ function Swatch({ color }: { color: string }) { export function Colors() { return ( - + Framework @@ -65,7 +65,7 @@ export function Colors() { ))} - + Content