Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export { Label, labelVariants } from "./label.js";
export type { LabelProps, LabelVariantProps } from "./label.types.js";

View File

@@ -0,0 +1,110 @@
# Label
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - label.variants — `labelVariants` is the cva (class-…
- [label.variants](./label.variants.md) — `labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split fro…
- label.variants — `labelVariants` is the cva (class-…
- [label.variants](./label.variants.md) — `labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split fro…
- label.variants — `labelVariants` is the cva (class-…
- [label.variants](./label.variants.md) — `labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split fro…
- label.variants — `labelVariants` is the cva (class-…
- [label.variants](./label.variants.md) — `labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split fro…
- [label.variants](./label.variants.md) — `labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split fro…
## Purpose
`<Label>` is the chassis form-label primitive for `@suaveplan/react-ui`. It wraps `@radix-ui/react-label` so consumers inherit the Radix labelling contract (a click on the label focuses the matched input via `htmlFor`, and synthetic click events originating inside the label are not double-fired on the input) while threading variant resolution through `useThemedVariants("Label")` and surfacing a `data-disabled="true"` mirror so visual disabled state can be represented without needing a sibling form control.
The label children are deliberately NOT translated by the chassis. Labels routinely interleave dynamic inline elements (`<strong>`, `<span>`, `<Trans>`, count badges, asterisks for required fields) and forcing the children through a single string-key lookup would prevent that composition. Consumers compose translation calls in their own JSX, e.g. `<Label htmlFor="email">{t('app.email.label')}</Label>` or `<Label htmlFor="email"><Trans i18nKey="app.email.label" /></Label>`.
## Features
- Wraps `@radix-ui/react-label` for full Radix labelling semantics (focus management on click, no double-fire of synthetic events).
- Forwards refs to the underlying `<label>` element.
- All native `LabelHTMLAttributes` are forwarded as-is via the Radix primitive's props passthrough.
- `disabled` boolean prop writes `data-disabled="true"` on the rendered label so the cva config's `data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-70` rules activate.
- `peer-disabled:` Tailwind variant baked into the cva base so a label adjacent to a `peer`-classed input picks up the disabled styling automatically when the input is disabled.
- Theme variant overrides via `useThemedVariants("Label", labelVariants)`. A `ThemePlugin.variantOverrides.Label` may add brand-new variants (e.g. `variant: "section-heading"`).
- No translation lookup on children — composition is consumer-driven.
## Usage: Basic Example
```tsx
import { Label, Input } from "@suaveplan/react-ui";
export function EmailField() {
return (
<div className="flex flex-col gap-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" />
</div>
);
}
```
## Usage: Advanced Example
```tsx
import { Label, Input } from "@suaveplan/react-ui";
import { useTranslation } from "@suaveplan/react-i18n";
export function PasswordField({ disabled }: { disabled: boolean }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1">
<Label htmlFor="password" disabled={disabled}>
{t("app.password.label")}{" "}
<span aria-hidden="true" className="text-destructive">
*
</span>
</Label>
<Input
id="password"
type="password"
disabled={disabled}
className="peer"
autoComplete="current-password"
/>
</div>
);
}
```
## API Reference
### `<Label>`
`forwardRef<HTMLLabelElement, LabelProps>` — renders a Radix `<Label.Root>` element (which itself renders a native `<label>` with the labelling enhancements applied).
#### `LabelProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `htmlFor` | `string` | _none_ | Required. The id of the form control this label is bound to. |
| `variant` | `"default"` | `"default"` | Visual variant; the chassis ships only `default` so themes carry the variation. |
| `size` | `"default"` | `"default"` | Sizing variant; the chassis ships only `default` so themes carry the variation. |
| `disabled` | `boolean` | `undefined` | When `true`, writes `data-disabled="true"` so the cva opacity decay activates. |
| _all_ `ComponentPropsWithoutRef<typeof LabelPrimitive.Root>` | _native_ | _native_ | Forwarded as-is to Radix. |
#### `labelVariants`
The bare cva resolver. Exported so external consumers (`@suaveplan/react-ui-theme-*`, ejected source under `npx shadcn@latest add @suaveplan/label`) can re-use the variants directly without going through `useThemedVariants`.
#### Errors
The Label component does NOT call `useTranslation()` so it does not interact with the i18n provider at all. Consumers that wrap `<Trans>` or `useTranslation` themselves inherit the upstream provider's no-throw-in-production contract.
## Implementation Notes
- The chassis re-exports Radix's labelling semantics rather than re-implementing them: the click-on-label-focuses-input + click-event-bubble-suppression behaviour is non-trivial to get right, and Radix is the upstream-of-record.
- `data-disabled="true"` is written when the prop is `true`, omitted when `false`/`undefined`. The CVA selector convention is `data-[disabled=true]:…`, so an explicit `data-disabled="false"` would not match but it would clutter the DOM.
- The `peer-disabled:cursor-not-allowed peer-disabled:opacity-70` rules in the cva base let consumers wire a label adjacent to a `peer`-classed input — when the input is disabled, the label picks up the visual decay automatically without requiring the consumer to thread the `disabled` prop through both elements.
- The variants module is intentionally separate (`label.variants.ts`) so an ejected source copy keeps the cva config in a dedicated file, mirroring the upstream shadcn convention.
- `useThemedVariants("Label", labelVariants)` is called unconditionally so the hook order is stable across renders. The hook's memoisation key is `(themeName, "Label")` so a re-render with the same theme returns the same resolver reference.
- Refs forward to the underlying `<label>` (Radix `Label.Root` is implemented as a `<label>` element, not a custom-element wrapper).

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom";
import * as Stories from "./label.stories.js";
const STORY_KEYS = [
"Default",
"Playground",
"Variants",
"States",
"WithI18n",
"WithRTL",
"WithTheme",
"A11y",
"Composition",
] as const;
interface StoryRecord {
readonly args?: Record<string, unknown>;
readonly render?: (args: Record<string, unknown>) => React.ReactNode;
}
describe("label.stories", () => {
it("exports every Foundation story", { timeout: 5000 }, () => {
for (const key of STORY_KEYS) {
expect(
(Stories as unknown as Record<string, unknown>)[key],
`missing story export: ${key}`
).toBeDefined();
}
});
for (const key of STORY_KEYS) {
it(`renders the ${key} story without throwing`, {
timeout: 10000,
}, () => {
const story = (Stories as unknown as Record<string, StoryRecord>)[
key
];
expect(story).toBeDefined();
expect(typeof story.render).toBe("function");
if (story.render === undefined) return;
const tree = story.render(story.args ?? {});
const ui = (
<div data-testid="story-host">{tree as React.ReactNode}</div>
);
const { container } = renderComponent(ui);
expect(
container.querySelector("[data-testid='story-host']")
).not.toBeNull();
});
}
});

View File

@@ -0,0 +1,282 @@
/**
* Storybook stories for `<Label>`.
*
* `@storybook/react` is NOT a dep of `@suaveplan/react-ui`; story-meta
* shapes are inlined.
*/
import {
createI18n as createI18nNarrow,
type I18nInstance,
} from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
// Widen to `I18nInstance<string>` so the literal-locale narrowing from
// the storybook fixture does not collide with `<TranslationProvider>`'s
// expected `I18nInstance<string>` parameterisation.
function createI18n(
options: Parameters<typeof createI18nNarrow>[0]
): I18nInstance {
return createI18nNarrow(options) as unknown as I18nInstance;
}
import type { ReactElement, ReactNode } from "react";
import type { SupportedLocale } from "../../locales/locales.types.js";
import type { ThemePlugin } from "../../theme/theme.types.js";
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
import { darkTheme } from "../../theme/themes/dark.js";
import { highContrastTheme } from "../../theme/themes/high-contrast.js";
import { lightTheme } from "../../theme/themes/light.js";
import { Input } from "../input/input.js";
import { Label } from "./label.js";
import type { LabelProps } from "./label.types.js";
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
readonly parameters?: Record<string, unknown>;
readonly argTypes?: Record<string, unknown>;
}
interface StoryObj<TArgs> {
readonly args?: Partial<TArgs>;
readonly render?: (args: Partial<TArgs>) => ReactNode;
readonly name?: string;
readonly parameters?: Record<string, unknown>;
}
const meta: Meta<typeof Label> = {
component: Label,
title: "react-ui/Foundation/Label",
tags: ["autodocs"],
argTypes: {
disabled: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<LabelProps>;
interface ProviderShellProps {
readonly locale: SupportedLocale;
readonly themes: ReadonlyArray<ThemePlugin>;
readonly defaultTheme: string;
readonly children: ReactNode;
}
function ProviderShellSync(props: ProviderShellProps): ReactElement {
const i18n = createI18n({
defaultLocale: props.locale,
fallbackLocale: "en-GB",
});
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider
themes={props.themes}
defaultTheme={props.defaultTheme}
>
{props.children}
</ThemeProvider>
</TranslationProvider>
);
}
export const Default: Story = {
render: (args) => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<Label {...args} htmlFor="story-default">
Email
</Label>
</ProviderShellSync>
),
};
export const Playground: Story = {
render: (args) => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<Label {...args} htmlFor="story-playground">
{(args.children as ReactNode) ?? "Playground"}
</Label>
</ProviderShellSync>
),
args: {
disabled: false,
},
};
export const Variants: Story = {
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-2">
<Label htmlFor="v1">Default</Label>
<Label htmlFor="v2" disabled>
Disabled
</Label>
</div>
</ProviderShellSync>
),
};
export const States: Story = {
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-2">
<Label htmlFor="s1">Active</Label>
<Label htmlFor="s2" disabled>
Disabled
</Label>
</div>
</ProviderShellSync>
),
};
const LOCALES: ReadonlyArray<SupportedLocale> = [
"en-GB",
"zh-CN",
"ar-AE",
"cy-GB",
"ru-RU",
];
const LOCALE_TEXT: Record<SupportedLocale, string> = {
"en-GB": "Email",
"zh-CN": "电子邮件",
"ar-AE": "البريد الإلكتروني",
"cy-GB": "E-bost",
"ru-RU": "Электронная почта",
};
export const WithI18n: Story = {
render: () => (
<div className="flex flex-col gap-2">
{LOCALES.map((locale) => (
<div
key={locale}
dir={locale === "ar-AE" ? "rtl" : "ltr"}
className="flex items-center gap-2"
>
<span style={{ width: 80 }}>{locale}</span>
<ProviderShellSync
locale={locale}
themes={[lightTheme]}
defaultTheme="light"
>
<Label htmlFor={`i18n-${locale}`}>
{LOCALE_TEXT[locale]}
</Label>
</ProviderShellSync>
</div>
))}
</div>
),
};
export const WithRTL: Story = {
render: () => (
<div dir="rtl">
<ProviderShellSync
locale="ar-AE"
themes={[lightTheme]}
defaultTheme="light"
>
<Label htmlFor="rtl-x">{LOCALE_TEXT["ar-AE"]}</Label>
</ProviderShellSync>
</div>
),
};
const THEMES: ReadonlyArray<{
readonly label: string;
readonly theme: ThemePlugin;
}> = [
{ label: "light", theme: lightTheme },
{ label: "dark", theme: darkTheme },
{ label: "highContrast", theme: highContrastTheme },
];
export const WithTheme: Story = {
render: () => (
<div className="flex flex-col gap-2">
{THEMES.map(({ label, theme }) => (
<div key={label} className="flex items-center gap-2 p-3 border">
<span style={{ width: 120 }}>{label}</span>
<ProviderShellSync
locale="en-GB"
themes={[theme]}
defaultTheme={theme.name}
>
<Label htmlFor={`t-${label}`}>Email</Label>
</ProviderShellSync>
</div>
))}
</div>
),
};
export const A11y: Story = {
parameters: {
a11y: {
element: "#storybook-root",
config: {
rules: [
{ id: "label", enabled: true },
{ id: "color-contrast", enabled: true },
],
},
},
},
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-1">
<Label htmlFor="a11y-input">Email</Label>
<Input id="a11y-input" type="email" />
</div>
</ProviderShellSync>
),
};
export const Composition: Story = {
name: "Composition (with Input)",
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-1">
<Label htmlFor="comp-input">
Email{" "}
<span aria-hidden="true" className="text-destructive">
*
</span>
</Label>
<Input
id="comp-input"
type="email"
placeholder={"react-ui.empty.noResults" as never}
/>
</div>
</ProviderShellSync>
),
};

View File

@@ -0,0 +1,130 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom";
import { createRef } from "react";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
import { lightTheme } from "../../theme/themes/light.js";
import { Label } from "./label.js";
import { labelVariants } from "./label.variants.js";
async function withProviders(
node: React.ReactElement
): Promise<React.ReactElement> {
const i18n = await createTestI18n({ locale: "en-GB" });
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
describe("<Label>", () => {
it("renders a <label> element with the consumer's text", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Label htmlFor="x">Email address</Label>
);
const { getByText } = renderComponent(tree);
const label = getByText("Email address");
expect(label.tagName).toBe("LABEL");
});
it("forwards the htmlFor attribute", { timeout: 5000 }, async () => {
const tree = await withProviders(<Label htmlFor="email">Email</Label>);
const { getByText } = renderComponent(tree);
const label = getByText("Email");
expect(label.getAttribute("for")).toBe("email");
});
it("forwards refs to the underlying label", {
timeout: 5000,
}, async () => {
const ref = createRef<HTMLLabelElement>();
const tree = await withProviders(
<Label htmlFor="x" ref={ref}>
With ref
</Label>
);
renderComponent(tree);
expect(ref.current).not.toBeNull();
expect(ref.current?.tagName).toBe("LABEL");
});
it("writes data-disabled when disabled=true", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Label htmlFor="x" disabled>
Disabled label
</Label>
);
const { getByText } = renderComponent(tree);
const label = getByText("Disabled label");
expect(label.getAttribute("data-disabled")).toBe("true");
});
it("does NOT write data-disabled when not disabled", {
timeout: 5000,
}, async () => {
const tree = await withProviders(<Label htmlFor="x">Active</Label>);
const { getByText } = renderComponent(tree);
const label = getByText("Active");
expect(label.hasAttribute("data-disabled")).toBe(false);
});
it("merges custom className with cva variant classes", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Label htmlFor="x" className="custom-extra-class">
Themed
</Label>
);
const { getByText } = renderComponent(tree);
const label = getByText("Themed");
expect(label.className).toContain("custom-extra-class");
expect(label.className).toContain("text-sm");
});
it("focuses the matched input when clicked (Radix behaviour)", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<>
<Label htmlFor="email">Email</Label>
<input id="email" type="email" />
</>
);
const { getByText, user } = renderComponent(tree);
const label = getByText("Email");
await user.click(label);
expect(document.getElementById("email") as HTMLInputElement).toBe(
document.activeElement
);
});
it("renders interleaved children verbatim (no translation lookup)", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Label htmlFor="x">
Email <span data-testid="aster">*</span>
</Label>
);
const { getByTestId } = renderComponent(tree);
const aster = getByTestId("aster");
expect(aster.textContent).toBe("*");
});
it("exposes labelVariants as a callable resolver", {
timeout: 5000,
}, () => {
const cls = labelVariants({ variant: "default", size: "default" });
expect(typeof cls).toBe("string");
expect(cls.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,46 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { type ComponentRef, forwardRef, type ReactElement } from "react";
import { useThemedVariants } from "../../theme/use-themed-variants/use-themed-variants.js";
import { cn } from "../../utils/cn/cn.js";
import type { LabelProps } from "./label.types.js";
import { labelVariants } from "./label.variants.js";
/**
* `<Label>` — chassis form-label primitive built on `@radix-ui/react-label`.
*
* Radix supplies the labelling semantics (clicking the label focuses the
* matched input via `htmlFor`, and forwards click events that originate
* inside the label so the input does not see them twice). The chassis
* layers theme-driven variant resolution and the `data-disabled="true"`
* mirror so consumers can disable the label visually without needing a
* sibling form control.
*
* The chassis deliberately does NOT translate the label children — labels
* routinely interleave dynamic inline elements (`<strong>`, `<span>`,
* `<Trans>`) and forcing a single string-key lookup would prevent that.
* Consumers compose translation calls in their own JSX.
*/
export const Label = forwardRef<
ComponentRef<typeof LabelPrimitive.Root>,
LabelProps
>(function Label(props, ref): ReactElement {
const { className, variant, size, disabled, children, ...rest } = props;
const variants = useThemedVariants("Label", labelVariants);
const classes = cn(variants({ variant, size }), className);
return (
<LabelPrimitive.Root
ref={ref}
className={classes}
data-disabled={disabled === true ? "true" : undefined}
{...rest}
>
{children}
</LabelPrimitive.Root>
);
});
Label.displayName = "Label";
export { labelVariants };

View File

@@ -0,0 +1,37 @@
import type * as LabelPrimitive from "@radix-ui/react-label";
import type { ComponentPropsWithoutRef } from "react";
import type { VariantProps } from "../../variants/variants.js";
import type { labelVariants } from "./label.variants.js";
/**
* Module augmentation registering `Label` into `ThemableVariants` so
* `useThemedVariants("Label")` is type-narrowed and a `ThemePlugin` may
* declare `variantOverrides: { Label: <override> }`.
*/
declare module "../../theme/theme.types.js" {
interface ThemableVariants {
readonly Label: typeof labelVariants;
}
}
export type LabelVariantProps = VariantProps<typeof labelVariants>;
/**
* Public props accepted by `<Label>`.
*
* - `htmlFor` — required so the label is bound to a form control. The
* chassis does not auto-generate this association; consumers must
* pass the input id explicitly.
* - Children carry the visible label text; the chassis does NOT layer a
* translation lookup on `children` (consumers compose `<Trans>` /
* `useTranslation` themselves so a label can interleave dynamic
* inline elements).
* - `disabled` — optional convenience prop that writes
* `data-disabled="true"` on the rendered label so the cva config's
* opacity decay activates without requiring a sibling input.
*/
export interface LabelProps
extends ComponentPropsWithoutRef<typeof LabelPrimitive.Root>,
LabelVariantProps {
readonly disabled?: boolean;
}

View File

@@ -0,0 +1,48 @@
# Label Variants (cva config)
## Purpose
`labelVariants` is the cva (class-variance-authority) configuration that powers every `<Label>` render. It is split from `label.tsx` into its own module so an ejected source copy under `npx shadcn@latest add @suaveplan/label` keeps the cva configuration in a dedicated file — exactly mirroring the upstream shadcn convention. Theme overrides flow through `useThemedVariants("Label", labelVariants)`, so the runtime cva resolver can be replaced or extended by an active `ThemePlugin` without touching the component source.
The module exports a single `labelVariants` callable resolver (`(props?) => string`) tagged with the component name `"Label"` via `defineVariants`. Consumers that want the bare resolver — for example to render the same label styling on a non-`<label>` element such as `<legend>` — import `labelVariants` directly and apply the result via `cn()` themselves.
## Features
- Single `default` cva variant + single `default` size — the shadcn upstream Label recipe is structurally simple; the chassis follows suit so themes carry the variation.
- `peer-disabled:cursor-not-allowed peer-disabled:opacity-70` so a label adjacent to a `peer`-classed disabled input automatically picks up the visual decay without consumer plumbing.
- `data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-70` so the chassis-supplied `disabled` prop drives the same visual decay even without a sibling input.
- `text-sm font-medium leading-none` for the chassis baseline label typography.
## Usage: Basic Example
```ts
import { labelVariants } from "@suaveplan/react-ui";
const className = labelVariants({ variant: "default", size: "default" });
```
## Usage: Advanced Example
```ts
import { labelVariants, cn } from "@suaveplan/react-ui";
export function FieldsetLegend({ children }: { children: React.ReactNode }) {
return (
<legend className={cn(labelVariants({}), "px-2 text-base")}>
{children}
</legend>
);
}
```
## API Reference
### `labelVariants(props?: VariantProps<typeof labelVariants>): string`
Returns the cva-merged class string for the supplied variant + size combination. Both fields are optional; omitted fields fall back to the cva `defaultVariants` (`variant: "default"`, `size: "default"`).
## Implementation Notes
- The resolver is tagged with `componentName: "Label"` (non-enumerable, non-writable) via `defineVariants` so debug tooling can identify the cva instance at runtime.
- The chassis prefers the `peer-disabled` pattern over the chassis `disabled` prop when wiring labels next to inputs — passing `disabled` to both elements is redundant and risks drift between visual and functional state.
- The cva base is intentionally minimal — labels routinely interleave dynamic inline elements (asterisks for required fields, badge counts, helper-text indicators) and the cva config does not attempt to style any of those; consumer composition owns them.

View File

@@ -0,0 +1,32 @@
import { defineVariants } from "../../variants/variants.js";
/**
* `labelVariants` — base cva config for `<Label>`.
*
* Mirrors the shadcn `label` recipe: small, medium-weight text with a
* disabled-state opacity decay tied to the consumer-applied
* `data-disabled="true"` attribute on the SAME element OR a sibling
* (the `peer-disabled` Tailwind variant handles the form-input case).
*/
export const labelVariants = defineVariants(
"Label",
[
"text-sm font-medium leading-none",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
"data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-70",
].join(" "),
{
variants: {
variant: {
default: "",
},
size: {
default: "",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);