Initial Commit
This commit is contained in:
2
packages/ui/react/react-ui/src/components/label/index.ts
Normal file
2
packages/ui/react/react-ui/src/components/label/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Label, labelVariants } from "./label.js";
|
||||
export type { LabelProps, LabelVariantProps } from "./label.types.js";
|
||||
110
packages/ui/react/react-ui/src/components/label/label.md
Normal file
110
packages/ui/react/react-ui/src/components/label/label.md
Normal 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).
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
130
packages/ui/react/react-ui/src/components/label/label.test.tsx
Normal file
130
packages/ui/react/react-ui/src/components/label/label.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/ui/react/react-ui/src/components/label/label.tsx
Normal file
46
packages/ui/react/react-ui/src/components/label/label.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user