From 3335b4855ec88e3cdb9a8b2a737966a31975852a Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Tue, 27 Oct 2026 17:47:22 +0900 Subject: [PATCH 1/3] feat(text): add element components Text.h1 to Text.h6, Text.p, Text.span, and Text.div Each element component always renders its named HTML element with a default typography role, and takes variant, className, the element's own attributes, and a ref typed for the element. It does not take render. Defaults: h1 heading-lg, h2 heading-md, h3-h6 heading-sm, p body-md, span label-md, div body-md. Headings written this way do not trigger jsx-a11y/heading-has-content, so they need no disable comment. Text and its render prop do not change. The decision record gets a "Fourth change" section, and the Text docs page documents the new API. Refs tailor-inc/platform-planning#2225 Co-Authored-By: Claude Opus 5.5 --- .changeset/text-element-components.md | 14 +++ decisions/typography-roles.md | 17 ++- docs-manifest.json | 12 +- docs-src/components/text.docs.examples.tsx | 26 +++-- docs-src/components/text.docs.outline.md | 69 ++++++++--- docs/components/text.md | 109 ++++++++++++------ .../src__components__text__text.test.tsx.snap | 2 + .../core/src/components/text/text.test.tsx | 99 ++++++++++++++++ packages/core/src/components/text/text.tsx | 101 +++++++++++++--- 9 files changed, 359 insertions(+), 90 deletions(-) create mode 100644 .changeset/text-element-components.md diff --git a/.changeset/text-element-components.md b/.changeset/text-element-components.md new file mode 100644 index 000000000..b04202bfb --- /dev/null +++ b/.changeset/text-element-components.md @@ -0,0 +1,14 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add element components to `Text`: `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div`. Each one always renders its named HTML element with a default typography role, and takes `variant`, `className`, the element's own attributes, and a ref. + +```tsx +Purchase orders +Line items +Orders sent this month. +Updated recently +``` + +The defaults are `heading-lg` for `Text.h1`, `heading-md` for `Text.h2`, `heading-sm` for `Text.h3` to `Text.h6`, `body-md` for `Text.p` and `Text.div`, and `label-md` for `Text.span`. Headings written this way do not need a `jsx-a11y/heading-has-content` disable comment. `Text` and its `render` prop do not change; use `render` for other elements and to compose with another component. diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md index c4911e5c2..cd24ef5a5 100644 --- a/decisions/typography-roles.md +++ b/decisions/typography-roles.md @@ -1,6 +1,6 @@ # Decision: typography role tokens -> Status: **Decided — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities, a `Text` component on top of them, and use them in AppShell's own components (see "Third change" at the end).** +> Status: **Decided — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities, a `Text` component on top of them, and use them in AppShell's own components (see "Third change"). "Fourth change" adds element components such as `Text.h1`.** > Scope: role names and values, how they reach Tailwind, the rules for combining them, and the `Text` component (see "Second change" at the end). "Third change" covers applying the roles to AppShell components and moving sizes below 12px to 12px. ## Context @@ -93,7 +93,7 @@ Limit: this does not let a role replace a size that core sets on its own element - **API.** `Text` takes the role as `variant`. The prop is not called `role`, because `role` is an ARIA attribute. The element is set with Base UI's `render` prop, as in `Button`, the sidebar primitives, and the breadcrumb. AppShell has no `as` or `asChild` prop. - **Default elements.** The three heading roles render `h2`. The body roles, including the relaxed variants, render `p`. `label-md`, `label-sm`, and `code-sm` render `span`. All headings share one default so that no heading level is implied by size. `h2` is the default because a page has one `h1` (`Layout.Header`), and `Dialog.Title` from Base UI also renders `h2`. A default is hard to change later, because every call site without `render` would get a different element. -- **Lint.** The `jsx-a11y/heading-has-content` rule reports `render={

}` as an empty heading, and the function form `render={(props) =>

}` too. Apps that enable the rule disable it on that line. A `level` prop would avoid this, but it would add a second way to choose the element next to `render`. The documentation shows the disable comment instead. +- **Lint.** The `jsx-a11y/heading-has-content` rule reports `render={

}` as an empty heading, and the function form `render={(props) =>

}` too. Apps that enable the rule disable it on that line. A `level` prop would avoid this, but it would add a second way to choose the element next to `render`. The documentation shows the disable comment instead. _Updated by the fourth change: headings now use `Text.h1` to `Text.h6`, and the disable comment remains only for other elements in `render`._ - **No color or modifier props.** Color and the four modifiers stay in `className`, as for the utilities. A consumer `leading-none`, `font-semibold`, or `tracking-*` overrides the role even though AppShell's prefixed sheet loads later, because the role reads the shared `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` variables first. `tests/typography-compile.test.ts` checks this. - **No `textVariants` export.** tailor-inc/platform-planning#1580 asked for a variants export. It is descoped, not deferred. The `text-` utilities already apply the same role mappings, so a cva export would be a third way to do the same thing, and it would put `astw:`-prefixed classes into consumer code. Unlike `Button` and `Badge`, `Text` does not export its cva function. - **`code-sm` includes `font-mono`.** The utility cannot set the font family, but the component can. @@ -110,3 +110,16 @@ Limit: this does not let a role replace a size that core sets on its own element - **Value changes.** Text below 12px moves to 12px. Line heights outside the allowed modifiers move to the role's line height, or to a relaxed role for chat messages. The uppercase section labels, the ActivityCard day label, and the menu group label use `label-sm` at weight 500. `code-sm` adds 2px of line height. In key and value pairs, the key is `label-md` with a muted color and the value is `body-md`, the same as numbers in DataTable. An `h1` to `h6` element takes a heading role (chat empty state title, default sidebar app title). `Alert.Title` drops `tracking-tight`. A selected item (sidebar, chat history, DataTable filter panel) uses `font-semibold` with its background, as Primer and Polaris do. - **Element types do not change.** Under the cream and bloom palettes, headings render as before. The open question about their `h1` to `h6` rule stays open. - **Guard.** `tests/typography-usage.test.ts` fails on a raw Tailwind text size in a component, on a role class with an unknown name, and on `text-code-sm` without `font-mono`. + +## Fourth change: element components + +> Status: **Decided.** Tracking issue: tailor-inc/platform-planning#2225 (under the umbrella tailor-inc/platform-planning#2214). Discussed in `#pf-app-shell` on 2026-10-27. + +- **API.** `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div` always render their named element. They take `variant`, `className`, the element's own attributes, and a ref typed for the element. They do not take `render`. `Text` and its `render` prop do not change, and `render` stays the way to compose with another component. The names are lowercase to show that each one is an HTML element with a default role, not a higher-level component. +- **Why this is not a second way to choose the element.** The second change rejected a `level` prop because it would sit next to `render`. The element components split the work instead: a plain HTML element uses `Text.`, and composition uses `render`. An `as` prop was also considered and dropped, because it does the same thing as `Text.h1`. +- **Defaults.** `Text.h1` is `heading-lg`, `Text.h2` is `heading-md`, and `Text.h3` to `Text.h6` are `heading-sm`. `Text.p` is `body-md`. `Text.span` is `label-md`, not `body-md`, to follow the default elements of `Text`: body roles render `p`, and label and code roles render `span`. `Text.div` is `body-md`, the default role of `Text`, because `div` is a block element like `p`. A default is only a starting point; any role works on any element. +- **Lint.** `jsx-a11y/heading-has-content` does not report ``, because the rule sees the element name `Text.h1`, not `h1` (checked with oxlint 1.64). Headings no longer need the disable comment. Note that the rule also does not report an empty ``. +- **Scope.** Only the elements above. Other elements, such as `code`, `small`, `pre`, `blockquote`, `figcaption`, `time`, `kbd`, and `label`, use `Text` with `render`, and the disable comment where a lint rule reports the empty element. More elements can be added if they are used often. +- **No exported props type.** `React.ComponentProps` gives the props type. A named export would be another public name that cannot be removed later without a breaking change, while adding one later is not breaking. +- **Implementation.** The element components render the element directly, without Base UI's `useRender`, because they take no `render` prop. They set `data-slot="text"` and the same classes as `Text`. +- **Follow-up changes** (not in this PR): after the release, change the documentation examples that use `render={

}` (quickstart, styling guide, `app-shell-patterns` skill) to `Text.h1`. AppShell's own components keep `render` for now; changing their headings to element components is a separate change. diff --git a/docs-manifest.json b/docs-manifest.json index 93afba434..3cd6d687b 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1430,11 +1430,11 @@ "claims": [], "symbols": ["Text", "TextProps"], "hashes": { - "typeSurface": "ed54a16ca677dbe3", - "outline": "114c81c557b82a18", - "snapshot": "cb5f569f7dd2ecfc", - "outputMd": "5b6cf767868d6558", - "examples": "4a832708dc79474b" + "typeSurface": "053c8236047c6ec4", + "outline": "1441d9d6e3f5975b", + "snapshot": "83be7198ffac5470", + "outputMd": "24262ae6cb49a138", + "examples": "c31a669057c28ba7" } }, "textarea": { @@ -1811,7 +1811,7 @@ "packages/core/skills/app-shell-patterns/references/components/spinner.md": "e05d94ba9c3d17da", "packages/core/skills/app-shell-patterns/references/components/table.md": "f85ec64ceb7f4acf", "packages/core/skills/app-shell-patterns/references/components/tabs.md": "30bb32e57fc78bd9", - "packages/core/skills/app-shell-patterns/references/components/text.md": "5b6cf767868d6558", + "packages/core/skills/app-shell-patterns/references/components/text.md": "24262ae6cb49a138", "packages/core/skills/app-shell-patterns/references/components/textarea.md": "c940bc01cd573334", "packages/core/skills/app-shell-patterns/references/components/timeline.md": "699249c9452485ab", "packages/core/skills/app-shell-patterns/references/components/toolbar.md": "91d70b140c7bb747", diff --git a/docs-src/components/text.docs.examples.tsx b/docs-src/components/text.docs.examples.tsx index fbf44b839..ed8cd8956 100644 --- a/docs-src/components/text.docs.examples.tsx +++ b/docs-src/components/text.docs.examples.tsx @@ -3,8 +3,8 @@ import { Text } from "@tailor-platform/app-shell"; export function BasicUsage() { return (
- Purchase orders - Orders sent to suppliers this month. + Purchase orders + Orders sent to suppliers this month.
); } @@ -13,15 +13,9 @@ export function Roles() { return (
{/* The headings render a div so that this specimen is not a document heading. */} - }> - heading-lg: Purchase orders - - }> - heading-md: Line items - - }> - heading-sm: Shipping address - + heading-lg: Purchase orders + heading-md: Line items + heading-sm: Shipping address body-md: Orders sent to suppliers this month. body-sm: Updated 2 hours ago label-md: Total amount @@ -31,6 +25,16 @@ export function Roles() { ); } +export function ElementComponents() { + return ( +
+ Shipping address + Orders ship from the main warehouse. + 3 packages +
+ ); +} + export function ColorAndModifiers() { return (
diff --git a/docs-src/components/text.docs.outline.md b/docs-src/components/text.docs.outline.md index b7bdfedbc..70063bbdb 100644 --- a/docs-src/components/text.docs.outline.md +++ b/docs-src/components/text.docs.outline.md @@ -9,7 +9,7 @@ sources: # Text -The `Text` component applies a [typography role](../concepts/styling-theming.md#typography) to its content. A role sets font size, line height, font weight, and letter spacing together. The role does not set the color, and it does not choose the HTML element. Use the `render` prop to choose the element (Base UI render pattern). +The `Text` component applies a [typography role](../concepts/styling-theming.md#typography) to its content. A role sets font size, line height, font weight, and letter spacing together. The role does not set the color, and it does not choose the HTML element. To choose the element, use an element component such as `Text.h1` or `Text.p`, or use the `render` prop (Base UI render pattern) to compose with another component. ## Import @@ -23,7 +23,34 @@ import { Text } from "@tailor-platform/app-shell"; -All standard HTML attributes are also accepted. Put an attribute that belongs to one element, such as `htmlFor` on a `label`, on the `render` element: +## Element Components + +`Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div` always render their named HTML element. Each one applies a default role, so you do not repeat `variant` for ordinary text: + + + +| Component | Default `variant` | +| ------------------- | ----------------- | +| `Text.h1` | `heading-lg` | +| `Text.h2` | `heading-md` | +| `Text.h3`–`Text.h6` | `heading-sm` | +| `Text.p` | `body-md` | +| `Text.span` | `label-md` | +| `Text.div` | `body-md` | + +The element sets the meaning and `variant` sets the appearance. The default is only a starting point, and any role works on any element: + +```tsx +A compact page title +A visually prominent subsection +Updated recently +``` + +The element components take `variant`, `className`, the element's own attributes, and a ref typed for the element (`HTMLHeadingElement` for `Text.h2`). They do not take `render`. For any other element, or to compose with another component, use `Text` with `render`. + +## Using `render` + +All standard HTML attributes are also accepted by `Text`. Put an attribute that belongs to one element, such as `htmlFor` on a `label`, on the `render` element: ```tsx }> @@ -35,7 +62,7 @@ Some lint rules report this example; see [Lint](#lint). ## Roles -`variant` takes one of the ten roles. Each role renders a default element when you do not pass `render`: +`variant` takes one of the ten roles. On `Text`, each role renders a default element when you do not pass `render`: | `variant` | Default element | Use | | ----------------- | --------------- | ----------------------------------------------- | @@ -56,30 +83,28 @@ The sizes and line heights are in the [Typography section of the styling guide]( ## Heading Level -All three heading roles render `h2` by default. The heading level comes from the document structure, not from the visual size, so set it with `render`: +On `Text`, all three heading roles render `h2` by default. The heading level comes from the document structure, not from the visual size, so set it with `Text.h1` to `Text.h6`: ```tsx -}> - Line items - +Line items ``` -To use a heading role for text that is not a heading, render a `div` or a `span`: +To use a heading role for text that is not a heading, use `Text.div` or `Text.span`: ```tsx -}> - 1,240 orders - +1,240 orders ``` ## Lint -Some `jsx-a11y` rules check that an element has text inside it, for example `heading-has-content` for `

` and `label-has-associated-control` for `