Add v2 Heading typography primitive
Implement the Heading primitive from the Probo Radix UI Figma: it renders the semantic h1–h6 chosen by a numeric level prop (driving the document outline) while size controls the visual scale independently, defaulting to size 6 / bold weight per the design. Add a paired HeadingSkeleton and a story. Factor the shared size/weight/align/color variants and the color × highContrast compound variants out of the Text tv so Text and Heading differ only in their defaults. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
111
packages/ui/src/v2/typography/Heading.stories.tsx
Normal file
111
packages/ui/src/v2/typography/Heading.stories.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { Heading } from "./Heading";
|
||||||
|
import { HeadingSkeleton } from "./HeadingSkeleton";
|
||||||
|
|
||||||
|
const sample = "The quick brown fox";
|
||||||
|
|
||||||
|
const sizes = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
||||||
|
const weights = ["light", "regular", "medium", "bold"] as const;
|
||||||
|
const colors = ["neutral", "gold", "red", "green", "amber", "sky"] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Typography/Heading",
|
||||||
|
component: Heading,
|
||||||
|
args: {
|
||||||
|
children: sample,
|
||||||
|
level: 1,
|
||||||
|
size: 6,
|
||||||
|
weight: "bold",
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Heading>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Heading>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{sizes.map(size => (
|
||||||
|
<Heading key={size} size={size}>
|
||||||
|
{size}
|
||||||
|
{" — "}
|
||||||
|
{sample}
|
||||||
|
</Heading>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Weights: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{weights.map(weight => (
|
||||||
|
<Heading key={weight} weight={weight}>
|
||||||
|
{weight}
|
||||||
|
{" — "}
|
||||||
|
{sample}
|
||||||
|
</Heading>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Align: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex w-96 flex-col gap-3">
|
||||||
|
<Heading size={4} align="left">Left-aligned</Heading>
|
||||||
|
<Heading size={4} align="center">Center-aligned</Heading>
|
||||||
|
<Heading size={4} align="right">Right-aligned</Heading>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Colors: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{colors.map(color => (
|
||||||
|
<Heading key={color} size={4} color={color}>
|
||||||
|
{color}
|
||||||
|
{" — "}
|
||||||
|
{sample}
|
||||||
|
</Heading>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighContrast: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Heading size={4}>Low contrast (step 11)</Heading>
|
||||||
|
<Heading size={4} highContrast>High contrast (step 12)</Heading>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex w-96 flex-col gap-3">
|
||||||
|
<HeadingSkeleton size={8} className="w-2/3" />
|
||||||
|
<HeadingSkeleton size={6} className="w-1/2" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
41
packages/ui/src/v2/typography/Heading.tsx
Normal file
41
packages/ui/src/v2/typography/Heading.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { heading } from "./variants";
|
||||||
|
|
||||||
|
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
|
||||||
|
export type HeadingProps = ComponentProps<"h1"> & VariantProps<typeof heading> & {
|
||||||
|
// The rendered heading element (h1–h6), driving document outline. Decoupled
|
||||||
|
// from `size`, which controls the visual scale. Defaults to h1.
|
||||||
|
level?: HeadingLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Foundational heading primitive (Radix "Heading"). Renders the semantic
|
||||||
|
// h1–h6 chosen by `level`; tune the visual scale with `size`. See
|
||||||
|
// contrib/claude/ui.md (typography).
|
||||||
|
export function Heading(props: HeadingProps) {
|
||||||
|
const { level = 1, size, weight, align, color, highContrast, className, ...rest } = props;
|
||||||
|
const Tag = `h${level}` as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={heading({ size, weight, align, color, highContrast, className })}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
packages/ui/src/v2/typography/HeadingSkeleton.tsx
Normal file
32
packages/ui/src/v2/typography/HeadingSkeleton.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { headingSkeleton } from "./variants";
|
||||||
|
|
||||||
|
export type HeadingSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof headingSkeleton>;
|
||||||
|
|
||||||
|
// Loading placeholder paired with Heading: a pulse block matching a heading
|
||||||
|
// line at the given size. Defaults to full width; constrain with a width class.
|
||||||
|
export function HeadingSkeleton(props: HeadingSkeletonProps) {
|
||||||
|
const { size, className, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span aria-hidden className={headingSkeleton({ size, className })} {...rest}>
|
||||||
|
{"\u00A0"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Numbered type scale (text-1 … text-9). Each utility carries its paired
|
// Numbered type scale (text-1 … text-9). Each utility carries its paired
|
||||||
// font-size, line-height, and letter-spacing from the v2 theme. Shared between
|
// font-size, line-height, and letter-spacing from the v2 theme. Shared across
|
||||||
// Text and its skeleton so the placeholder matches the real line height.
|
// the typography primitives and their skeletons so placeholders match the real
|
||||||
|
// line height.
|
||||||
const size = {
|
const size = {
|
||||||
1: "text-1",
|
1: "text-1",
|
||||||
2: "text-2",
|
2: "text-2",
|
||||||
@@ -29,49 +30,54 @@ const size = {
|
|||||||
9: "text-9",
|
9: "text-9",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const text = tv({
|
// Shared look across Text and Heading: only their defaults differ.
|
||||||
variants: {
|
const typographyVariants = {
|
||||||
size,
|
size,
|
||||||
weight: {
|
weight: {
|
||||||
light: "font-light",
|
light: "font-light",
|
||||||
regular: "font-normal",
|
regular: "font-normal",
|
||||||
medium: "font-medium",
|
medium: "font-medium",
|
||||||
bold: "font-bold",
|
bold: "font-bold",
|
||||||
},
|
|
||||||
align: {
|
|
||||||
left: "text-left",
|
|
||||||
center: "text-center",
|
|
||||||
right: "text-right",
|
|
||||||
},
|
|
||||||
// Hue only; the resolved text step comes from the color × highContrast
|
|
||||||
// compound variants below (step 11 low-contrast, step 12 high-contrast).
|
|
||||||
color: {
|
|
||||||
neutral: "",
|
|
||||||
gold: "",
|
|
||||||
red: "",
|
|
||||||
green: "",
|
|
||||||
amber: "",
|
|
||||||
sky: "",
|
|
||||||
},
|
|
||||||
highContrast: {
|
|
||||||
true: "",
|
|
||||||
false: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
compoundVariants: [
|
align: {
|
||||||
{ color: "neutral", highContrast: false, class: "text-sand-11" },
|
left: "text-left",
|
||||||
{ color: "neutral", highContrast: true, class: "text-sand-12" },
|
center: "text-center",
|
||||||
{ color: "gold", highContrast: false, class: "text-gold-11" },
|
right: "text-right",
|
||||||
{ color: "gold", highContrast: true, class: "text-gold-12" },
|
},
|
||||||
{ color: "red", highContrast: false, class: "text-red-11" },
|
// Hue only; the resolved text step comes from the color × highContrast
|
||||||
{ color: "red", highContrast: true, class: "text-red-12" },
|
// compound variants below (step 11 low-contrast, step 12 high-contrast).
|
||||||
{ color: "green", highContrast: false, class: "text-green-11" },
|
color: {
|
||||||
{ color: "green", highContrast: true, class: "text-green-12" },
|
neutral: "",
|
||||||
{ color: "amber", highContrast: false, class: "text-amber-11" },
|
gold: "",
|
||||||
{ color: "amber", highContrast: true, class: "text-amber-12" },
|
red: "",
|
||||||
{ color: "sky", highContrast: false, class: "text-sky-11" },
|
green: "",
|
||||||
{ color: "sky", highContrast: true, class: "text-sky-12" },
|
amber: "",
|
||||||
],
|
sky: "",
|
||||||
|
},
|
||||||
|
highContrast: {
|
||||||
|
true: "",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorCompoundVariants = [
|
||||||
|
{ color: "neutral", highContrast: false, class: "text-sand-11" },
|
||||||
|
{ color: "neutral", highContrast: true, class: "text-sand-12" },
|
||||||
|
{ color: "gold", highContrast: false, class: "text-gold-11" },
|
||||||
|
{ color: "gold", highContrast: true, class: "text-gold-12" },
|
||||||
|
{ color: "red", highContrast: false, class: "text-red-11" },
|
||||||
|
{ color: "red", highContrast: true, class: "text-red-12" },
|
||||||
|
{ color: "green", highContrast: false, class: "text-green-11" },
|
||||||
|
{ color: "green", highContrast: true, class: "text-green-12" },
|
||||||
|
{ color: "amber", highContrast: false, class: "text-amber-11" },
|
||||||
|
{ color: "amber", highContrast: true, class: "text-amber-12" },
|
||||||
|
{ color: "sky", highContrast: false, class: "text-sky-11" },
|
||||||
|
{ color: "sky", highContrast: true, class: "text-sky-12" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const text = tv({
|
||||||
|
variants: typographyVariants,
|
||||||
|
compoundVariants: [...colorCompoundVariants],
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: 3,
|
size: 3,
|
||||||
weight: "regular",
|
weight: "regular",
|
||||||
@@ -80,8 +86,21 @@ export const text = tv({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const heading = tv({
|
||||||
|
variants: typographyVariants,
|
||||||
|
compoundVariants: [...colorCompoundVariants],
|
||||||
|
defaultVariants: {
|
||||||
|
size: 6,
|
||||||
|
weight: "bold",
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const skeletonBase = "inline-block w-full animate-pulse select-none rounded-2 bg-sand-3 text-transparent";
|
||||||
|
|
||||||
export const textSkeleton = tv({
|
export const textSkeleton = tv({
|
||||||
base: "inline-block w-full animate-pulse select-none rounded-2 bg-sand-3 text-transparent",
|
base: skeletonBase,
|
||||||
variants: {
|
variants: {
|
||||||
size,
|
size,
|
||||||
},
|
},
|
||||||
@@ -89,3 +108,13 @@ export const textSkeleton = tv({
|
|||||||
size: 3,
|
size: 3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const headingSkeleton = tv({
|
||||||
|
base: skeletonBase,
|
||||||
|
variants: {
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user