diff --git a/.changeset/text-element-components.md b/.changeset/text-element-components.md
new file mode 100644
index 00000000..b04202bf
--- /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 c68cdc32..5c09c414 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/browser/theme.test.tsx` checks the computed styles in Chromium; `tests/typography-compile.test.ts` retains the full role-declaration checks.
- **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,17 @@ 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`.
+- **Documentation.** The heading examples in the quickstart, the styling guide, and the `app-shell-patterns` skill use the element components.
+- **Follow-up change** (not in this PR): 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 f66ad3a4..de1e4925 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -1160,9 +1160,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "9b52f8cdbf18c8ac",
+ "outline": "82b05468b438d199",
"snapshot": null,
- "outputMd": "f2ad75a76a1d5206",
+ "outputMd": "2261883b6d0069d3",
"examples": null
}
},
@@ -1381,9 +1381,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "9f6a42c42dd3f54f",
+ "outline": "4081996c98e029b0",
"snapshot": null,
- "outputMd": "7ee0130a20b88310",
+ "outputMd": "1ed3e6d92e582e26",
"examples": "4d19f4f688e0f0ac"
}
},
@@ -1431,11 +1431,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": {
@@ -1770,7 +1770,7 @@
"packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165",
"packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "8034c7f885994b29",
"packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "5a3c8bac248061f9",
- "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "7ee0130a20b88310",
+ "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "1ed3e6d92e582e26",
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af",
"packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0",
"packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194",
@@ -1812,7 +1812,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",
@@ -1856,6 +1856,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "59c93617dc135033",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "5a6612349cb50ef1",
"packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769",
- "packages/core/skills/app-shell-patterns/SKILL.md": "b5aacf04ae361852"
+ "packages/core/skills/app-shell-patterns/SKILL.md": "1aa14970eee9bce0"
}
}
diff --git a/docs-src/components/text.docs.examples.tsx b/docs-src/components/text.docs.examples.tsx
index fbf44b83..ed8cd895 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.
{/* 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 addressbody-md: Orders sent to suppliers this month.body-sm: Updated 2 hours agolabel-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 b7bdfedb..70063bbd 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 ``. These rules report the element in `render` as empty, because they do not see that `Text` passes its children to that element. If your app enables them, disable the rule on that line. ESLint and oxlint both read this comment:
+Some `jsx-a11y` rules check that an element has text inside it, for example `heading-has-content` for `` and `label-has-associated-control` for ``. These rules report the element in `render` as empty, because they do not see that `Text` passes its children to that element.
+
+For headings, use `Text.h1` to `Text.h6`. The rules do not report them. For other elements, such as `label`, use `render`, and if your app enables the rule, disable it on that line. ESLint and oxlint both read this comment:
```tsx
-// eslint-disable-next-line jsx-a11y/heading-has-content -- Text renders the children
-}>
- Line items
+// eslint-disable-next-line jsx-a11y/label-has-associated-control -- Text renders the children
+}>
+ Quantity
```
@@ -93,9 +118,9 @@ Set the color with a color token in `className`. Four modifiers may also be comb
Orders sent this month.
-} className="leading-none">
+
Card title
-
+
1,240.00
@@ -117,11 +142,17 @@ Each role is also a plain utility, for example `text-heading-md`. Use the utilit
import { type TextProps } from "@tailor-platform/app-shell";
```
+The element components have no exported props type. Read it from the component:
+
+```tsx
+type SectionTitleProps = React.ComponentProps;
+```
+
## Accessibility
-- A heading role renders a real heading (`h2`) by default, so screen readers list it with the other headings. Set the level with `render` so the levels follow the page structure.
+- A heading role on `Text` renders a real heading (`h2`) by default, so screen readers list it with the other headings. Set the level with `Text.h1` to `Text.h6` so the levels follow the page structure.
- Do not choose a heading level for its size. `Text` keeps the size and the level separate.
-- `body-*` roles render `p`. Inside another `p`, render a `span` instead (`}>`): the browser closes the outer `p` before the inner one, so React warns and server rendering fails to hydrate. Inside inline elements such as `button`, `label` or `span`, a `p` is invalid HTML too, so render a `span` there as well.
+- `body-*` roles render `p` on `Text`. Inside another `p`, use `Text.span` instead (``): the browser closes the outer `p` before the inner one, so React warns and server rendering fails to hydrate. Inside inline elements such as `button`, `label` or `span`, a `p` is invalid HTML too, so render a `span` there as well.
## Palettes
diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md
index f28f9d2b..db5b22b6 100644
--- a/docs-src/concepts/styling-theming.docs.outline.md
+++ b/docs-src/concepts/styling-theming.docs.outline.md
@@ -296,10 +296,10 @@ Pair a role with a color token from the Color section above. These pairings are
-For headings, body copy, labels, and captions, use the [`Text`](../components/text.md) component. Do not style a bare `p`, `h1` to `h6`, or `span` with `text-sm`, `text-xs`, or `font-*`. A form field label is [`Field.Label`](../components/form.md), which already has the `label-md` role. `Text` applies the same roles with a `variant` prop. It renders a default element for each role (`h2` for headings, `p` for body text, `span` for the others), and its `render` prop sets the element, such as the heading level:
+For headings, body copy, labels, and captions, use the [`Text`](../components/text.md) component. Do not style a bare `p`, `h1` to `h6`, or `span` with `text-sm`, `text-xs`, or `font-*`. A form field label is [`Field.Label`](../components/form.md), which already has the `label-md` role. `Text` applies the same roles with a `variant` prop. It renders a default element for each role (`h2` for headings, `p` for body text, `span` for the others). To set the element, such as the heading level, use an element component (`Text.h1` to `Text.h6`, `Text.p`, `Text.span`, `Text.div`):
```tsx
-}>Line items
+Line itemsDescription copyUpdated 2h ago
```
diff --git a/docs-src/guides/quickstart.docs.outline.md b/docs-src/guides/quickstart.docs.outline.md
index 8414c9af..64952533 100644
--- a/docs-src/guides/quickstart.docs.outline.md
+++ b/docs-src/guides/quickstart.docs.outline.md
@@ -80,9 +80,7 @@ import { Text } from "@tailor-platform/app-shell";
const HomePage = () => {
return (
- }>
- Welcome to AppShell
-
+ Welcome to AppShell
);
};
@@ -120,9 +118,7 @@ import { Text } from "@tailor-platform/app-shell";
const DashboardPage = () => {
return (
);
};
diff --git a/docs-src/skill/SKILL.template.md b/docs-src/skill/SKILL.template.md
index 5bb746c9..2e7a327b 100644
--- a/docs-src/skill/SKILL.template.md
+++ b/docs-src/skill/SKILL.template.md
@@ -67,7 +67,7 @@ Full rationale in [`styling-theming`](references/concepts/styling-theming.md)
- **One primary action per view:** at most one primary/filled `Button`; everything else is `outline`/`secondary`/`ghost`.
- **Status badges by semantic color:** a **filled** semantic variant for the record's primary/lifecycle status, **`outline-*`** for secondary statuses, **`subtle-*`** for tags; reserve brand `default` for non-status emphasis.
-- **Text uses `Text` and typography roles:** render headings, body copy, labels, and captions with [`Text`](references/components/text.md) and a `variant` (`heading-md`, `body-md`, `body-sm`, `label-md`, …) — never a bare `
`, ``, or `` styled with size and weight utilities such as `text-lg font-semibold` or `text-sm`. Set the heading level with `render` (`}>`), not by its size, and the color with a token in `className`. On an element you style yourself, such as a `th`, use the role utility (`text-label-md`). Pick the size with `variant`: a size class such as `text-sm` on `Text` has no effect. `Layout.Header` already renders the page title, and a form field label is [`Field.Label`](references/components/form.md), which already has the label role. Role table and color pairings: [`styling-theming`](references/concepts/styling-theming.md) → Typography.
+- **Text uses `Text` and typography roles:** render headings, body copy, labels, and captions with [`Text`](references/components/text.md) and a `variant` (`heading-md`, `body-md`, `body-sm`, `label-md`, …) — never a bare `
`, ``, or `` styled with size and weight utilities such as `text-lg font-semibold` or `text-sm`. Set the heading level with `Text.h1` to `Text.h6` (``), not by its size, and the color with a token in `className`. For other plain elements use `Text.p`, `Text.span` or `Text.div`; use `render` only to compose with another component. On an element you style yourself, such as a `th`, use the role utility (`text-label-md`). Pick the size with `variant`: a size class such as `text-sm` on `Text` has no effect. `Layout.Header` already renders the page title, and a form field label is [`Field.Label`](references/components/form.md), which already has the label role. Role table and color pairings: [`styling-theming`](references/concepts/styling-theming.md) → Typography.
- **Status and feedback color by semantic role:** use a component variant first (`Badge`, `Alert`, `Button variant="destructive"`). On your own element, use the roles `{info|success|warning|danger|neutral}-{surface|border|solid|text|contrast|indicator}`, for example `bg-warning-surface text-warning-text border-warning-border`. Error text is `text-danger-text`. Never write the deprecated `--status-*`, `--alert-*` or `--destructive` tokens or their utilities (`bg-status-*`, `text-alert-*`, `bg-destructive`, `text-destructive`) in new code; the next major removes them. When you edit a screen that still uses them, replace them: `text-destructive` → `text-danger-text`, `bg-status-completed` → `bg-success-solid`, `bg-alert-warning-background` → `bg-warning-surface`. Full mapping: [`styling-theming`](references/concepts/styling-theming.md) → Semantic color roles.
- **No duplicate actions:** an action lives in exactly one place — never repeat the same action in both `Layout.Header` and `ActionPanel`.
- **Action placement:** primary CTA + status in `Layout.Header`; workflow actions in `ActionPanel`; back/navigation in the breadcrumb — never in `ActionPanel`.
diff --git a/docs/components/text.md b/docs/components/text.md
index 39f2f649..61136d49 100644
--- a/docs/components/text.md
+++ b/docs/components/text.md
@@ -7,7 +7,7 @@ description: Text with a typography role, rendered as an element you choose
# 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,8 +23,8 @@ import { Text } from "@tailor-platform/app-shell";
function BasicUsage() {
return (
- Purchase orders
- Orders sent to suppliers this month.
+ Purchase orders
+ Orders sent to suppliers this month.
);
}
@@ -36,18 +36,57 @@ _Generated from the type surface — first-party props only._
### TextProps
-| Prop | Type | Default | Description |
-| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ |
-| `ref?` | `React.Ref` | - | Ref to the rendered element. |
-| `render?` | `React.ReactElement` | - | Custom element to render as (Base UI render prop). Use it to set the heading level, for example `render={}`. |
-| `variant?` | `"heading-lg" \| "heading-md" \| "heading-sm" \| "body-md" \| "body-sm" \| "label-md" \| "label-sm" \| "code-sm" \| "body-md-relaxed" \| "body-sm-relaxed" \| null` | `"body-md"` | Typography role. Sets font size, line height, font weight, and letter spacing together. |
+| Prop | Type | Default | Description |
+| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `ref?` | `React.Ref` | - | Ref to the rendered element. |
+| `render?` | `React.ReactElement` | - | Custom element to render as (Base UI render prop). Use it to compose with another component. For a plain HTML element, use `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, or `Text.div`. |
+| `variant?` | `"heading-lg" \| "heading-md" \| "heading-sm" \| "body-md" \| "body-sm" \| "label-md" \| "label-sm" \| "code-sm" \| "body-md-relaxed" \| "body-sm-relaxed" \| null` | `"body-md"` | Typography role. Sets font size, line height, font weight, and letter spacing together. |
## Exports
-- `Text` — function
+- `Text` — const
- `TextProps` — type
-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:
+
+
+
+```tsx
+function ElementComponents() {
+ return (
+
+ Shipping address
+ Orders ship from the main warehouse.
+ 3 packages
+
+ );
+}
+```
+
+| 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
}>
@@ -59,7 +98,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 |
| ----------------- | --------------- | ----------------------------------------------- |
@@ -81,15 +120,9 @@ 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 addressbody-md: Orders sent to suppliers this month.body-sm: Updated 2 hours agolabel-md: Total amount
@@ -104,30 +137,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 ``. These rules report the element in `render` as empty, because they do not see that `Text` passes its children to that element. If your app enables them, disable the rule on that line. ESLint and oxlint both read this comment:
+Some `jsx-a11y` rules check that an element has text inside it, for example `heading-has-content` for `` and `label-has-associated-control` for ``. These rules report the element in `render` as empty, because they do not see that `Text` passes its children to that element.
+
+For headings, use `Text.h1` to `Text.h6`. The rules do not report them. For other elements, such as `label`, use `render`, and if your app enables the rule, disable it on that line. ESLint and oxlint both read this comment:
```tsx
-// eslint-disable-next-line jsx-a11y/heading-has-content -- Text renders the children
-}>
- Line items
+// eslint-disable-next-line jsx-a11y/label-has-associated-control -- Text renders the children
+}>
+ Quantity
```
@@ -156,9 +187,9 @@ function ColorAndModifiers() {
Orders sent this month.
-} className="leading-none">
+
Card title
-
+
1,240.00
@@ -180,11 +211,17 @@ Each role is also a plain utility, for example `text-heading-md`. Use the utilit
import { type TextProps } from "@tailor-platform/app-shell";
```
+The element components have no exported props type. Read it from the component:
+
+```tsx
+type SectionTitleProps = React.ComponentProps;
+```
+
## Accessibility
-- A heading role renders a real heading (`h2`) by default, so screen readers list it with the other headings. Set the level with `render` so the levels follow the page structure.
+- A heading role on `Text` renders a real heading (`h2`) by default, so screen readers list it with the other headings. Set the level with `Text.h1` to `Text.h6` so the levels follow the page structure.
- Do not choose a heading level for its size. `Text` keeps the size and the level separate.
-- `body-*` roles render `p`. Inside another `p`, render a `span` instead (`}>`): the browser closes the outer `p` before the inner one, so React warns and server rendering fails to hydrate. Inside inline elements such as `button`, `label` or `span`, a `p` is invalid HTML too, so render a `span` there as well.
+- `body-*` roles render `p` on `Text`. Inside another `p`, use `Text.span` instead (``): the browser closes the outer `p` before the inner one, so React warns and server rendering fails to hydrate. Inside inline elements such as `button`, `label` or `span`, a `p` is invalid HTML too, so render a `span` there as well.
## Palettes
diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md
index d4a70594..cab965f8 100644
--- a/docs/concepts/styling-theming.md
+++ b/docs/concepts/styling-theming.md
@@ -411,10 +411,10 @@ function TypographyRoles() {
}
```
-For headings, body copy, labels, and captions, use the [`Text`](../components/text.md) component. Do not style a bare `p`, `h1` to `h6`, or `span` with `text-sm`, `text-xs`, or `font-*`. A form field label is [`Field.Label`](../components/form.md), which already has the `label-md` role. `Text` applies the same roles with a `variant` prop. It renders a default element for each role (`h2` for headings, `p` for body text, `span` for the others), and its `render` prop sets the element, such as the heading level:
+For headings, body copy, labels, and captions, use the [`Text`](../components/text.md) component. Do not style a bare `p`, `h1` to `h6`, or `span` with `text-sm`, `text-xs`, or `font-*`. A form field label is [`Field.Label`](../components/form.md), which already has the `label-md` role. `Text` applies the same roles with a `variant` prop. It renders a default element for each role (`h2` for headings, `p` for body text, `span` for the others). To set the element, such as the heading level, use an element component (`Text.h1` to `Text.h6`, `Text.p`, `Text.span`, `Text.div`):
```tsx
-}>Line items
+Line itemsDescription copyUpdated 2h ago
```
diff --git a/docs/quickstart.md b/docs/quickstart.md
index fb98d3b4..78c4178d 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -80,9 +80,7 @@ import { Text } from "@tailor-platform/app-shell";
const HomePage = () => {
return (
- }>
- Welcome to AppShell
-
+ Welcome to AppShell
);
};
@@ -120,9 +118,7 @@ import { Text } from "@tailor-platform/app-shell";
const DashboardPage = () => {
return (
"`;
diff --git a/packages/core/src/components/text/text.test.tsx b/packages/core/src/components/text/text.test.tsx
index c5c7d48a..ec1e4fe1 100644
--- a/packages/core/src/components/text/text.test.tsx
+++ b/packages/core/src/components/text/text.test.tsx
@@ -50,6 +50,11 @@ describe("Text", () => {
);
expect(container.innerHTML).toMatchSnapshot();
});
+
+ it("element component", () => {
+ const { container } = render(Purchase orders);
+ expect(container.innerHTML).toMatchSnapshot();
+ });
});
it("has a variant for every role in the default palette", () => {
@@ -145,6 +150,101 @@ describe("Text", () => {
expect(ref.current?.tagName).toBe("H2");
});
+ describe("element components", () => {
+ it.each([
+ [Text.h1, "H1", "heading-lg"],
+ [Text.h2, "H2", "heading-md"],
+ [Text.h3, "H3", "heading-sm"],
+ [Text.h4, "H4", "heading-sm"],
+ [Text.h5, "H5", "heading-sm"],
+ [Text.h6, "H6", "heading-sm"],
+ [Text.p, "P", "body-md"],
+ [Text.span, "SPAN", "label-md"],
+ [Text.div, "DIV", "body-md"],
+ ] as const)("%o renders %s with %s by default", (Component, tagName, role) => {
+ const { container } = render(x);
+ const element = container.firstElementChild;
+ expect(element?.tagName).toBe(tagName);
+ expect(element?.className).toBe(`astw:text-${role}`);
+ expect(element?.getAttribute("data-slot")).toBe("text");
+ });
+
+ it("renders the heading level of the element, not of the role", () => {
+ render(
+ <>
+ A compact page title
+ A visually prominent subsection
+ >,
+ );
+ const h1 = screen.getByRole("heading", { level: 1 });
+ const h3 = screen.getByRole("heading", { level: 3 });
+ expect(h1.className).toBe("astw:text-heading-sm");
+ expect(h3.className).toBe("astw:text-heading-lg");
+ });
+
+ it("treats a null variant as the default role of the element", () => {
+ const { container } = render(x);
+ expect(container.firstElementChild?.className).toBe("astw:text-label-md");
+ });
+
+ it("adds font-mono to code-sm", () => {
+ const { container } = render(PO-0001);
+ expect(container.firstElementChild?.className).toContain("astw:font-mono");
+ });
+
+ it("keeps the role next to color and modifier classes", () => {
+ const { container } = render(
+
+ Updated recently
+ ,
+ );
+ expect(container.firstElementChild?.className.split(" ")).toEqual([
+ "astw:text-body-sm",
+ "text-muted-foreground",
+ "tabular-nums",
+ ]);
+ });
+
+ it("passes the element's own attributes through", () => {
+ render(
+
+ Sent
+ ,
+ );
+ const element = document.getElementById("status");
+ expect(element?.getAttribute("title")).toBe("Sent on 27 October");
+ expect(element?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("forwards a ref typed for the element", () => {
+ const heading = React.createRef();
+ const paragraph = React.createRef();
+ render(
+ <>
+ Line items
+ Orders sent this month.
+ >,
+ );
+ expect(heading.current?.tagName).toBe("H2");
+ expect(paragraph.current?.tagName).toBe("P");
+ });
+
+ it("names each component after its element", () => {
+ expect(Text.displayName).toBe("Text");
+ expect(Text.h1.displayName).toBe("Text.h1");
+ expect(Text.div.displayName).toBe("Text.div");
+ });
+
+ it("does not accept render or as", () => {
+ // @ts-expect-error -- the element component always renders its own element
+ const withRender = }>x;
+ // @ts-expect-error -- the element component always renders its own element
+ const withAs = x;
+ expect(withRender).toBeDefined();
+ expect(withAs).toBeDefined();
+ });
+ });
+
it("keeps the message a Base UI part supplies when no children are given", async () => {
render(
"Enter a quantity."} validationMode="onBlur">
diff --git a/packages/core/src/components/text/text.tsx b/packages/core/src/components/text/text.tsx
index c08fcd9f..e2c35882 100644
--- a/packages/core/src/components/text/text.tsx
+++ b/packages/core/src/components/text/text.tsx
@@ -30,7 +30,7 @@ type TextVariant = NonNullable["variant"]>;
/**
* The element each role renders when no `render` prop is given. All heading
* roles render `h2`: the heading level comes from the document structure, not
- * from the visual size, so pick the level with `render`.
+ * from the visual size, so pick the level with `Text.h1` to `Text.h6`.
*/
const defaultTagNames = {
"heading-lg": "h2",
@@ -49,24 +49,11 @@ type TextProps = React.HTMLAttributes &
VariantProps & {
/** Ref to the rendered element. */
ref?: React.Ref;
- /** Custom element to render as (Base UI render prop). Use it to set the heading level, for example `render={}`. */
+ /** Custom element to render as (Base UI render prop). Use it to compose with another component. For a plain HTML element, use `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, or `Text.div`. */
render?: React.ReactElement;
};
-/**
- * Text with a typography role. The role sets the size, line height, weight,
- * and letter spacing. It does not set the color or choose the heading level.
- *
- * @example
- * ```tsx
- * import { Text } from "@tailor-platform/app-shell";
- *
- * }>Line items
- * Orders sent this month.
- * 1,240
- * ```
- */
-function Text({ className, variant, render, children, ref, ...props }: TextProps) {
+function TextRoot({ className, variant, render, children, ref, ...props }: TextProps) {
// cva returns no class for an explicit `null`, so fall back to the default role here too.
const role = variant ?? "body-md";
return useRender({
@@ -84,4 +71,88 @@ function Text({ className, variant, render, children, ref, ...props }: TextProps
});
}
+TextRoot.displayName = "Text";
+
+/**
+ * The role each element component applies when no `variant` is given. They
+ * follow `defaultTagNames`: body roles render `p`, label roles render `span`.
+ * `div` is a block element like `p`, so it gets the default role of `Text`.
+ */
+const elementDefaultVariants = {
+ h1: "heading-lg",
+ h2: "heading-md",
+ h3: "heading-sm",
+ h4: "heading-sm",
+ h5: "heading-sm",
+ h6: "heading-sm",
+ p: "body-md",
+ span: "label-md",
+ div: "body-md",
+} as const satisfies Record;
+
+type TextTagName = keyof typeof elementDefaultVariants;
+
+type TextElementProps = React.ComponentProps &
+ VariantProps;
+
+type TextElementComponent = {
+ (props: TextElementProps): React.JSX.Element;
+ displayName?: string;
+};
+
+function createTextElement(tagName: T): TextElementComponent {
+ const defaultVariant: TextVariant = elementDefaultVariants[tagName];
+ const Element: React.ElementType = tagName;
+ function TextElement({ className, variant, ...props }: TextElementProps) {
+ return (
+
+ );
+ }
+ TextElement.displayName = `Text.${tagName}`;
+ return TextElement;
+}
+
+/**
+ * Keep the exported static API type explicit instead of relying on
+ * `Object.assign(...)` inference, as `Layout` and `Autocomplete` do.
+ */
+type TextComponent = typeof TextRoot & {
+ [T in TextTagName]: TextElementComponent;
+};
+
+/**
+ * Text with a typography role. The role sets the size, line height, weight,
+ * and letter spacing. It does not set the color or choose the heading level.
+ *
+ * `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div` always render
+ * their named element, with a default role for that element. They take
+ * `variant`, the element's own attributes, and a ref, but not `render`.
+ *
+ * @example
+ * ```tsx
+ * import { Text } from "@tailor-platform/app-shell";
+ *
+ * Purchase orders
+ * Line items
+ * Orders sent this month.
+ * 1,240
+ * }>Quantity
+ * ```
+ */
+const Text: TextComponent = Object.assign(TextRoot, {
+ h1: createTextElement("h1"),
+ h2: createTextElement("h2"),
+ h3: createTextElement("h3"),
+ h4: createTextElement("h4"),
+ h5: createTextElement("h5"),
+ h6: createTextElement("h6"),
+ p: createTextElement("p"),
+ span: createTextElement("span"),
+ div: createTextElement("div"),
+});
+
export { Text, type TextProps };