Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/decouple-text-heading-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@cloudflare/kumo": minor
---

feat(Text): decouple visual heading variants from semantic HTML elements

**Breaking change:** `heading1`, `heading2`, `heading3` variants no longer auto-render `<h1>`, `<h2>`, `<h3>` tags. They now render as `<span>` by default. Use the `as` prop to set the appropriate semantic heading level for your document outline.

Before:

```tsx
<Text variant="heading1">Title</Text> // rendered <h1>
```

After:

```tsx
<Text variant="heading1" as="h1">
Title
</Text> // explicit semantic element
```

The `as` prop is now restricted to valid text elements: `"h1"` through `"h6"`, `"p"`, and `"span"`.
12 changes: 9 additions & 3 deletions packages/kumo-docs-astro/src/components/demos/TextDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ export function TextVariantsDemo() {
return (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col justify-end gap-1 rounded-lg border border-kumo-hairline bg-kumo-base p-4">
<Text variant="heading1">Heading 1</Text>
<Text variant="heading1" as="h1">
Heading 1
</Text>
<Text variant="mono-secondary">text-3xl (30px)</Text>
</div>
<div className="flex flex-col justify-end gap-1 rounded-lg border border-kumo-hairline bg-kumo-base p-4">
<Text variant="heading2">Heading 2</Text>
<Text variant="heading2" as="h2">
Heading 2
</Text>
<Text variant="mono-secondary">text-2xl (24px)</Text>
</div>
<div className="flex flex-col justify-end gap-1 rounded-lg border border-kumo-hairline bg-kumo-base p-4">
<Text variant="heading3">Heading 3</Text>
<Text variant="heading3" as="h3">
Heading 3
</Text>
<Text variant="mono-secondary">text-lg (16px)</Text>
</div>
<div className="flex flex-col justify-end gap-1 rounded-lg border border-kumo-hairline bg-kumo-base p-4">
Expand Down
47 changes: 36 additions & 11 deletions packages/kumo-docs-astro/src/pages/components/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ComponentExample from "~/components/docs/ComponentExample.astro";
import ComponentSection from "~/components/docs/ComponentSection.astro";
import Heading from "~/components/docs/Heading.astro";
import PropsTable from "~/components/docs/PropsTable.astro";
import { Text } from "@cloudflare/kumo";
import {
TextVariantsDemo,
TextTruncateDemo,
Expand Down Expand Up @@ -54,10 +55,33 @@ export default function Example() {
```

<section class="mt-8 space-y-4">
<h3 class="text-lg font-semibold">Restrictions</h3>
<p class="text-kumo-strong">
<Text variant="heading3" as="h3">Semantic HTML</Text>
<Text>
The `variant` prop controls visual styling only—it does not determine the HTML element rendered.
Use the `as` prop to set the appropriate semantic element for your document outline:
</Text>

```tsx
// Heading variants render as <span> by default
<Text variant="heading1">Styled as h1, but renders a span</Text>

// Use `as` to set semantic heading levels
<Text variant="heading1" as="h1">Page Title</Text>
<Text variant="heading2" as="h2">Section Title</Text>
<Text variant="heading1" as="h3">Visually large, but semantically h3</Text>
```

<Text>
The `as` prop accepts: `"h1"` through `"h6"`, `"p"`, and `"span"`.
Body variants default to `"p"`, while heading and mono variants default to `"span"`.
</Text>
</section>

<section class="mt-8 space-y-4">
<Text variant="heading3" as="h3">Restrictions</Text>
<Text>
The `bold` and `size` props are intentionally restricted to the `base`, `secondary`, `success`, and `error` text variants.
</p>
</Text>

```tsx
<Text size="sm" bold>Body</Text>
Expand All @@ -66,23 +90,24 @@ export default function Example() {
<Text variant="error">Error</Text>
```

<p class="text-kumo-strong">
<Text>
Monospace variants (`mono` and `mono-secondary`) can only set `size` to `lg`
and cannot use the `bold` prop:
</p>
</Text>

```tsx
<Text variant="mono">Monospace</Text>
<Text variant="mono" size="lg">Monospace</Text>
<Text variant="mono" bold>Monospace</Text> // Doesn't compile
```

<p class="text-kumo-strong">
Headings (i.e. `h1`, `h2` and `h3` variants) cannot use these props at all:
</p>
<Text>
Headings (i.e. `heading1`, `heading2` and `heading3` variants) cannot use
these props at all:
</Text>

```tsx
<Text variant="h1" bold>
<Text variant="heading1" bold>
Heading 1
</Text> // Doesn't compile
```
Expand All @@ -95,9 +120,9 @@ export default function Example() {

<ComponentSection>
<Heading level={2}>Truncate</Heading>
<p class="text-kumo-strong">
<Text>
Use the `truncate` prop to clip overflowing text with an ellipsis. This adds `truncate min-w-0` classes, which is useful when `Text` is inside a flex or grid container.
</p>
</Text>
<ComponentExample demo="TextTruncateDemo">
<TextTruncateDemo client:visible />
</ComponentExample>
Expand Down
33 changes: 23 additions & 10 deletions packages/kumo/src/components/text/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
type ForwardedRef,
forwardRef,
useMemo,
type ElementType,
} from "react";
import { cn } from "../../utils/cn";

Expand Down Expand Up @@ -140,13 +139,24 @@ type Monospace = "mono" | "mono-secondary";
type TextSize = KumoTextSize;
type TextVariant = KumoTextVariant;

/** Valid HTML elements for the Text component's `as` prop. */
export type TextElement =
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "p"
| "span";

type BaseTextProps = Omit<
ComponentPropsWithoutRef<"span">,
"className" | "style"
> & {
DANGEROUS_className?: string;
DANGEROUS_style?: CSSProperties;
as?: ElementType;
as?: TextElement;
};

type TextPropsInternal<Variant extends TextVariant = "body"> = BaseTextProps &
Expand Down Expand Up @@ -213,20 +223,21 @@ export interface TextProps {
bold?: boolean;
/** Whether to truncate overflowing text with an ellipsis. Adds `truncate min-w-0` classes. */
truncate?: boolean;
/** The HTML element type to render as (e.g. `"span"`, `"p"`, `"h1"`). Auto-selected based on variant if omitted. */
as?: ElementType;
/** The HTML element to render (`"h1"`-`"h6"`, `"p"`, or `"span"`). Defaults to `"p"` for body variants and `"span"` for headings/mono. Use this to set semantic heading levels. */
as?: TextElement;
/** Text content. */
children?: React.ReactNode;
}

/**
* Typography component for rendering text with consistent styling.
* Automatically selects the appropriate HTML element based on variant
* (`h1`/`h2`/`h3` for headings, `p` for body, `span` for mono).
* Renders as `<p>` for body variants and `<span>` for headings/mono.
* Use the `as` prop to set semantic HTML elements for proper document outlines.
*
* @example
* ```tsx
* <Text variant="heading1">Dashboard</Text>
* <Text variant="heading1" as="h1">Page Title</Text>
* <Text variant="heading2" as="h2">Section Title</Text>
* <Text>Default body text</Text>
* ```
*/
Expand All @@ -247,12 +258,14 @@ function _Text<Variant extends TextVariant = "body">(
const isCopy = ["body", "secondary", "success", "error"].includes(variant);
const isMono = ["mono", "mono-secondary"].includes(variant);

// Heading variants no longer auto-select h1/h2/h3 to avoid coupling visual
// presentation to semantic HTML. Use the `as` prop to set the appropriate
// heading level for your document outline (e.g., as="h2").
const Component = useMemo(() => {
if (as) return as;
if (variant === "heading1") return "h1";
if (variant === "heading2") return "h2";
if (variant === "heading3") return "h3";
if (["mono", "mono-secondary"].includes(variant)) return "span";
// Headings and body text default to span; use `as` for semantic elements
if (["heading1", "heading2", "heading3"].includes(variant)) return "span";
return "p";
}, [variant, as]);

Expand Down
Loading