diff --git a/packages/propel/AGENTS.md b/packages/propel/AGENTS.md index 1e390335..c6bbcfa4 100644 --- a/packages/propel/AGENTS.md +++ b/packages/propel/AGENTS.md @@ -40,8 +40,10 @@ from tiers below it, never above. `internal/` is shared implementation usable by `base` primitive, or an intrinsic element via Base UI's `useRender` (`useRender({ defaultTagName, render, props: mergeProps(defaults, props) })`, defaults first). No `Context.Provider` wrap, no second element/frame, no baked default child (a slot renders - `{children}`, never `children ?? `). If you need more structure, add a NEW named - `ui` part and compose the parts in `components`. + `{children}`, never `children ?? `), and no authored `render={}` that injects + another component/behavior — forwarding the consumer's own `render` (the `useRender` mechanism) + IS the render-capability and is fine; baking a specific render target is composition. If you + need more structure, add a NEW named `ui` part and compose the parts in `components`. 2. **All composition lives in `components`** (and `patterns`). Providers, multi-element frames, defaults, and wiring belong here — never in `ui`. @@ -53,6 +55,11 @@ imports `lucide-react`, rendering an icon as `{children}` (a slot) and sizing it carries no `className`, so its size comes from the `ui` cva). `lucide-react` may be imported **only** in `components` source and in stories — never in `ui`, `base`, or `internal` source. +2b. **`ui`/`base` stories stay in-tier.** A `ui` (or `base`) story imports only from `ui`/`base` +(+ Base UI and external libs like `lucide-react`) — NEVER from `components`. To show what a +`components` ready-made composes (e.g. a toolbar toggle = `ToolbarButton` + Base UI `Toggle`), build +it from the `ui` atoms inline in the story. + 3. **`cva`/`cx` live only in `ui`; `className`/`style` exposure stops at `base`.** `base` follows Base UI exactly and **exposes `className`/`style`** (it is unstyled — `ui` is what styles it). `ui` and `components` do **not** expose `className`/`style`: `ui` bakes its styling into a cva @@ -162,9 +169,11 @@ govern this: `variant` is too vague (6c), and native HTML/CSS attribute names ar | `presentation` | **shape** of a repeated entry | NavigationMenuLink `item·card` | | `mode` | **behavior mode** of one component | Table `table·spreadsheet` | | `surface` | host background it adapts to | `background·fill` | -| `density` | compactness | `short·default·auto` | +| `density` | compactness | `comfortable·compact` | | `elevation` | draws its own raised surface vs flat | `raised·flat` | | `orientation` | layout axis | `horizontal·vertical` | +| `side` | which edge a panel attaches to | Drawer `start·end` | +| `visibility` | when a transient affordance shows | ScrollArea `auto·always` | **Why these names — decided on merit, not on what already shipped:** diff --git a/packages/propel/src/components/autocomplete-field/autocomplete-field.stories.tsx b/packages/propel/src/components/autocomplete-field/autocomplete-field.stories.tsx index 4dc5abaa..3d5ebcad 100644 --- a/packages/propel/src/components/autocomplete-field/autocomplete-field.stories.tsx +++ b/packages/propel/src/components/autocomplete-field/autocomplete-field.stories.tsx @@ -32,3 +32,15 @@ export const RendersInput: Story = { await expect(canvas.getByRole("combobox", { name: "Container image" })).toBeInTheDocument(); }, }; + +/** Setting `error` marks the field invalid, which recolors the input group border to danger. */ +export const Invalid: Story = { + args: { error: "Enter a container image." }, + play: async ({ canvas }) => { + const input = canvas.getByRole("combobox", { name: "Container image" }); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(input).toHaveAttribute("data-invalid"); + const group = input.closest(":has([data-invalid])"); + await expect(group).toHaveClass("has-[[data-invalid]]:border-danger-strong"); + }, +}; diff --git a/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx b/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx index 1f938e0c..05039dde 100644 --- a/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx +++ b/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx @@ -59,7 +59,9 @@ export function AutocompleteField({ return ( - {label} + + {label} + diff --git a/packages/propel/src/components/autocomplete/autocomplete.stories.tsx b/packages/propel/src/components/autocomplete/autocomplete.stories.tsx index bd20cbef..9776cff1 100644 --- a/packages/propel/src/components/autocomplete/autocomplete.stories.tsx +++ b/packages/propel/src/components/autocomplete/autocomplete.stories.tsx @@ -43,7 +43,9 @@ export const Default: Story = { render: (args) => ( - Container image + + Container image + diff --git a/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx b/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx index 1c25478c..22fc8e40 100644 --- a/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx +++ b/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx @@ -11,7 +11,6 @@ const meta = { name: "emailUpdates", label: "Email updates", magnitude: "md", - tone: "neutral", value: "enabled", }, } satisfies Meta; @@ -27,6 +26,39 @@ export const Default: Story = { }, }; +/** + * Setting `error` marks the field invalid. Base UI's `Field.Root` propagates that validity to the + * checkbox box as `data-invalid`, and the box recolors its border to `danger` — no `tone` prop. A + * resting field is shown alongside so the danger border is visibly (and assertably) different. + */ +export const Invalid: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ + +
+ ), + play: async ({ canvas }) => { + const [resting, invalid] = canvas.getAllByRole("checkbox"); + // The error-free field leaves the box in its resting (non-invalid) state. + await expect(resting).not.toHaveAttribute("data-invalid"); + // The invalid field propagates `data-invalid` onto the box (Base UI Field -> Checkbox.Root). + await expect(invalid).toHaveAttribute("data-invalid"); + await expect(invalid).toHaveClass("data-invalid:border-danger-strong"); + // ...and the danger border actually renders: its color differs from the resting box's border. + await expect(getComputedStyle(invalid).borderColor).not.toBe( + getComputedStyle(resting).borderColor, + ); + }, +}; + export const RendersCheckbox: Story = { tags: ["!dev", "!autodocs", "!manifest"], play: async ({ canvas }) => { diff --git a/packages/propel/src/components/checkbox-field/checkbox-field.tsx b/packages/propel/src/components/checkbox-field/checkbox-field.tsx index 22cd5cb4..bb1c0f24 100644 --- a/packages/propel/src/components/checkbox-field/checkbox-field.tsx +++ b/packages/propel/src/components/checkbox-field/checkbox-field.tsx @@ -4,16 +4,15 @@ import { CheckboxFieldControl, type CheckboxFieldControlProps, } from "../../internal/checkbox-field-control"; -import type { CheckboxTone } from "../../ui/checkbox/index"; import { Field } from "../../ui/field/field"; import { FieldItem } from "../../ui/field/field-item"; -import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldItemContent } from "../field"; import { FieldHelperText } from "../field/field-helper-text"; export type CheckboxFieldProps = Omit< CheckboxFieldControlProps, - "aria-label" | "label" | "inlineStartNode" | "tone" + "aria-label" | "label" | "inlineStartNode" > & { /** Helper text shown below the control. Replaced by `error` when an error is set. */ hint?: React.ReactNode; @@ -25,8 +24,6 @@ export type CheckboxFieldProps = Omit< magnitude: FieldMagnitude; /** Optional supporting text announced as the checkbox description. */ description?: React.ReactNode; - /** Resting color of the box. */ - tone: CheckboxTone; }; /** Ready-to-use single checkbox field with label, description, and helper/error text. */ @@ -36,19 +33,14 @@ export function CheckboxField({ hint, error, magnitude, - tone, name, disabled, ...controlProps }: CheckboxFieldProps) { return ( - + - + {label} diff --git a/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx b/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx index e9ad7a7a..1509f459 100644 --- a/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx +++ b/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx @@ -5,14 +5,13 @@ import { type CheckboxFieldControlProps, } from "../../internal/checkbox-field-control"; import { useFieldOptionMagnitude } from "../../internal/field-option-magnitude"; -import type { CheckboxTone } from "../../ui/checkbox/index"; import { FieldItem } from "../../ui/field/field-item"; -import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldItemContent } from "../field"; export type CheckboxGroupFieldOptionProps = Omit< CheckboxFieldControlProps, - "aria-label" | "label" | "inlineStartNode" | "tone" + "aria-label" | "label" | "inlineStartNode" > & { /** Visible option label. */ label: React.ReactNode; @@ -20,8 +19,6 @@ export type CheckboxGroupFieldOptionProps = Omit< description?: React.ReactNode; /** Label and description size. Inherited from `CheckboxGroupField` when omitted. */ magnitude?: FieldMagnitude; - /** Resting color of the box. */ - tone: CheckboxTone; }; /** A checkbox option row for use inside `CheckboxGroupField` or a custom `CheckboxGroup`. */ @@ -29,14 +26,13 @@ export function CheckboxGroupFieldOption({ label, description, magnitude: magnitudeProp, - tone, ...props }: CheckboxGroupFieldOptionProps) { const magnitude = useFieldOptionMagnitude(magnitudeProp); return ( - + {label} diff --git a/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx b/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx index a83b105b..4159816d 100644 --- a/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx +++ b/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx @@ -17,13 +17,8 @@ const meta = { defaultValue: ["email"], children: ( <> - - + + ), }, @@ -34,6 +29,21 @@ type Story = StoryObj; export const Default: Story = { args: { hint: "At least one channel is recommended." } }; +/** + * Setting `error` marks the whole group invalid. Base UI's `Field.Root` propagates that validity to + * every checkbox box as `data-invalid`, so each option's border recolors to `danger` automatically + * — no per-option `tone`. + */ +export const Invalid: Story = { + args: { error: "Choose at least one channel." }, + play: async ({ canvas }) => { + for (const box of canvas.getAllByRole("checkbox")) { + await expect(box).toHaveAttribute("data-invalid"); + await expect(box).toHaveClass("data-invalid:border-danger-strong"); + } + }, +}; + export const RendersGroup: Story = { tags: ["!dev", "!autodocs", "!manifest"], play: async ({ canvas }) => { diff --git a/packages/propel/src/components/checkbox-group/checkbox-group.stories.tsx b/packages/propel/src/components/checkbox-group/checkbox-group.stories.tsx index 487e539a..759451c5 100644 --- a/packages/propel/src/components/checkbox-group/checkbox-group.stories.tsx +++ b/packages/propel/src/components/checkbox-group/checkbox-group.stories.tsx @@ -22,9 +22,9 @@ export const Default: Story = { args: { density: "comfortable", defaultValue: ["https"] }, render: (args) => ( - - - + + + ), play: async ({ canvas }) => { @@ -41,12 +41,12 @@ export const Density: Story = { render: () => (
- - + + - - + +
), @@ -58,8 +58,8 @@ export const SelectionBehavior: Story = { args: { density: "comfortable", defaultValue: [] }, render: (args) => ( - - + + ), play: async ({ canvas, userEvent }) => { diff --git a/packages/propel/src/components/checkbox/checkbox.stories.tsx b/packages/propel/src/components/checkbox/checkbox.stories.tsx index 7fd3bedd..e66aa9b1 100644 --- a/packages/propel/src/components/checkbox/checkbox.stories.tsx +++ b/packages/propel/src/components/checkbox/checkbox.stories.tsx @@ -3,6 +3,7 @@ import { Repeat } from "lucide-react"; import * as React from "react"; import { expect, userEvent } from "storybook/test"; +import { Field } from "../../ui/field/field"; import { Checkbox, CheckboxIndeterminateIndicator, @@ -21,7 +22,6 @@ const meta = { CheckboxIndeterminateIndicator, }, args: { - tone: "neutral", "aria-label": "Example", }, parameters: { @@ -55,11 +55,11 @@ export const States: Story = { parameters: { controls: { disable: true } }, render: () => (
- - - - - + + + + +
), play: async ({ canvas }) => { @@ -77,13 +77,13 @@ export const States: Story = { */ export const WithoutLabel: Story = { parameters: { controls: { disable: true } }, - render: () => , + render: () => , }; /** A single labeled checkbox; the whole row is the clickable label. */ export const WithLabel: Story = { parameters: { controls: { disable: true } }, - render: () => , + render: () => , }; /** @@ -94,7 +94,6 @@ export const WithIcon: Story = { parameters: { controls: { disable: true } }, render: () => ( } label="Sync automatically" defaultChecked @@ -105,30 +104,43 @@ export const WithIcon: Story = { /** The mixed state renders `aria-checked="mixed"` and shows a dash. */ export const Indeterminate: Story = { parameters: { controls: { disable: true } }, - render: () => , + render: () => , play: async ({ canvas }) => { await expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "mixed"); }, }; /** - * The Figma "Error" state. The danger tone only colors the _unchecked_ border red; once checked, - * the fill is the same accent blue as every other tone. + * The Figma "Error" state. The danger look is a STATE, not a prop: inside an invalid `Field.Root` + * Base UI propagates `data-invalid` to the box, which recolors its _unchecked_ border red. Once + * checked, the fill is the same accent blue as every other state. A resting checkbox is shown + * alongside for contrast. */ -export const Error: Story = { +export const Invalid: Story = { parameters: { controls: { disable: true } }, render: () => (
- - + + + + + + +
), play: async ({ canvas }) => { - const [unchecked, checked] = canvas.getAllByRole("checkbox"); - // Unchecked danger box: red border, no accent fill. + const [resting, unchecked, checked] = canvas.getAllByRole("checkbox"); + // The resting box has no field-invalid state. + await expect(resting).not.toHaveAttribute("data-invalid"); + // The invalid `Field` propagates `data-invalid` onto the box (Base UI Field -> Checkbox.Root). + await expect(unchecked).toHaveAttribute("data-invalid"); await expect(unchecked).toHaveAttribute("aria-checked", "false"); - await expect(unchecked).toHaveClass("border-danger-strong"); - // Checked danger box: accent-blue fill, like every other tone. + // ...and the danger border actually renders: its border color differs from the resting box. + await expect(getComputedStyle(unchecked).borderColor).not.toBe( + getComputedStyle(resting).borderColor, + ); + // Checked invalid box: accent-blue fill, like every other state. await expect(checked).toHaveAttribute("aria-checked", "true"); await expect(checked).toHaveClass("data-checked:bg-accent-primary"); }, @@ -186,7 +198,7 @@ export const BoxDoesNotShiftOnToggle: Story = { const [indeterminate, setIndeterminate] = React.useState(false); return (
- + diff --git a/packages/propel/src/components/checkbox/checkbox.tsx b/packages/propel/src/components/checkbox/checkbox.tsx index ca32e03d..e5aae4f8 100644 --- a/packages/propel/src/components/checkbox/checkbox.tsx +++ b/packages/propel/src/components/checkbox/checkbox.tsx @@ -10,8 +10,6 @@ import { type CheckboxProps as CheckboxElementProps, } from "../../ui/checkbox"; -export type { CheckboxTone } from "../../ui/checkbox"; - export type CheckboxProps = CheckboxElementProps & { /** * Optional text shown beside the box; the whole row becomes the clickable label. Omit it for a @@ -31,7 +29,7 @@ export type CheckboxProps = CheckboxElementProps & { * The ready-made checkbox: composes the atomic `Checkbox` box with its check and indeterminate * indicators, and optionally wraps the row in a clickable `CheckboxLabel` with an icon slot. */ -export function Checkbox({ tone, label, inlineStartNode, id, ...props }: CheckboxProps) { +export function Checkbox({ label, inlineStartNode, id, ...props }: CheckboxProps) { // Generate a stable id so an explicit `label` can be associated with the box. const generatedId = React.useId(); const checkboxId = id ?? generatedId; @@ -39,7 +37,7 @@ export function Checkbox({ tone, label, inlineStartNode, id, ...props }: Checkbo // Only force the generated id when there's a `label` to associate; without one (e.g. inside a // `Field`, which manages labeling), pass the caller's `id` through untouched. const box = ( - + diff --git a/packages/propel/src/components/combobox-field/combobox-field.stories.tsx b/packages/propel/src/components/combobox-field/combobox-field.stories.tsx index 8e911fdb..31f63f0a 100644 --- a/packages/propel/src/components/combobox-field/combobox-field.stories.tsx +++ b/packages/propel/src/components/combobox-field/combobox-field.stories.tsx @@ -32,3 +32,15 @@ export const RendersInput: Story = { await expect(canvas.getByRole("combobox", { name: "Region" })).toBeInTheDocument(); }, }; + +/** Setting `error` marks the field invalid, which recolors the input group border to danger. */ +export const Invalid: Story = { + args: { error: "Choose a deployment region." }, + play: async ({ canvas }) => { + const input = canvas.getByRole("combobox", { name: "Region" }); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(input).toHaveAttribute("data-invalid"); + const group = input.closest(":has([data-invalid])"); + await expect(group).toHaveClass("has-[[data-invalid]]:border-danger-strong"); + }, +}; diff --git a/packages/propel/src/components/combobox-field/combobox-field.tsx b/packages/propel/src/components/combobox-field/combobox-field.tsx index ef348413..9ade440d 100644 --- a/packages/propel/src/components/combobox-field/combobox-field.tsx +++ b/packages/propel/src/components/combobox-field/combobox-field.tsx @@ -61,7 +61,9 @@ export function ComboboxField({ return ( - {label} + + {label} + diff --git a/packages/propel/src/components/combobox/combobox.stories.tsx b/packages/propel/src/components/combobox/combobox.stories.tsx index b8a9d1e7..0bcf00f2 100644 --- a/packages/propel/src/components/combobox/combobox.stories.tsx +++ b/packages/propel/src/components/combobox/combobox.stories.tsx @@ -47,7 +47,9 @@ export const Default: Story = { render: (args) => ( - Region + + Region + diff --git a/packages/propel/src/components/field/field-item-content.tsx b/packages/propel/src/components/field/field-item-content.tsx new file mode 100644 index 00000000..5038b87f --- /dev/null +++ b/packages/propel/src/components/field/field-item-content.tsx @@ -0,0 +1,28 @@ +import type * as React from "react"; + +import { FieldDescription } from "../../ui/field/field-description"; +import { FieldItemContent as FieldItemContentElement } from "../../ui/field/field-item-content"; +import { FieldLabel } from "../../ui/field/field-label"; +import { type InputMagnitude } from "../../ui/field/variants"; + +/** The label + description column for a single choice option (checkbox/radio/switch row). */ +export function FieldItemContent({ + children, + description, + magnitude, +}: { + children: React.ReactNode; + description?: React.ReactNode; + magnitude: InputMagnitude; +}) { + return ( + + + {children} + + {description != null ? ( + {description} + ) : null} + + ); +} diff --git a/packages/propel/src/components/field/field-label-group.tsx b/packages/propel/src/components/field/field-label-group.tsx index b9ee0ad6..c31f270b 100644 --- a/packages/propel/src/components/field/field-label-group.tsx +++ b/packages/propel/src/components/field/field-label-group.tsx @@ -1,8 +1,9 @@ import type * as React from "react"; -import { FieldDescription, FieldLabel } from "../../ui/field"; +import { FieldDescription } from "../../ui/field"; import { FieldLabelGroup as FieldLabelGroupContainer } from "../../ui/field/field-label-group"; import { type FieldLabelGroupVariantProps, type InputMagnitude } from "../../ui/field/variants"; +import { FieldLabel } from "./field-label"; export type FieldLabelGroupProps = FieldLabelGroupVariantProps & { magnitude: InputMagnitude; diff --git a/packages/propel/src/components/field/field-label.tsx b/packages/propel/src/components/field/field-label.tsx new file mode 100644 index 00000000..ea84254a --- /dev/null +++ b/packages/propel/src/components/field/field-label.tsx @@ -0,0 +1,20 @@ +import { + FieldLabel as FieldLabelElement, + type FieldLabelProps as FieldLabelElementProps, +} from "../../ui/field/field-label"; +import { FieldLabelRequiredMarker } from "../../ui/field/field-label-required-marker"; + +export type FieldLabelProps = FieldLabelElementProps & { required?: boolean }; + +/** + * The ready-made field label: the `FieldLabel` slot, plus a `FieldLabelRequiredMarker` when + * `required`. + */ +export function FieldLabel({ required, children, ...props }: FieldLabelProps) { + return ( + + {children} + {required ? * : null} + + ); +} diff --git a/packages/propel/src/components/field/field.stories.tsx b/packages/propel/src/components/field/field.stories.tsx index 5de04ede..8b4800b6 100644 --- a/packages/propel/src/components/field/field.stories.tsx +++ b/packages/propel/src/components/field/field.stories.tsx @@ -20,7 +20,7 @@ type Story = StoryObj; export const Default: Story = { render: () => ( - + Display name @@ -33,7 +33,9 @@ export const Default: Story = { export const Invalid: Story = { render: () => ( - Workspace slug + + Workspace slug + Choose a different workspace slug. @@ -46,7 +48,7 @@ export const LabelAndDescription: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - + Custom field @@ -65,7 +67,9 @@ export const ErrorAssociation: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - Email + + Email + Enter a valid email address. @@ -83,7 +87,9 @@ export const TypingUpdatesValue: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - Nickname + + Nickname + ), diff --git a/packages/propel/src/components/field/index.tsx b/packages/propel/src/components/field/index.tsx index a2ec8212..c7321688 100644 --- a/packages/propel/src/components/field/index.tsx +++ b/packages/propel/src/components/field/index.tsx @@ -7,8 +7,8 @@ export { type FieldErrorProps, FieldItem, type FieldItemProps, - FieldLabel, - type FieldLabelProps, } from "../../ui/field/index"; +export * from "./field-item-content"; +export * from "./field-label"; export * from "./field-helper-text"; export * from "./field-label-group"; diff --git a/packages/propel/src/components/fieldset/fieldset.stories.tsx b/packages/propel/src/components/fieldset/fieldset.stories.tsx index 13083a57..56047234 100644 --- a/packages/propel/src/components/fieldset/fieldset.stories.tsx +++ b/packages/propel/src/components/fieldset/fieldset.stories.tsx @@ -35,7 +35,6 @@ export const Default: Story = { <> - ), + children: , }, play: async ({ canvas }) => { await expect(canvas.getByRole("group", { name: "Shipping address" })).toBeInTheDocument(); diff --git a/packages/propel/src/components/form/form.stories.tsx b/packages/propel/src/components/form/form.stories.tsx index 1532f5d2..5ec0edae 100644 --- a/packages/propel/src/components/form/form.stories.tsx +++ b/packages/propel/src/components/form/form.stories.tsx @@ -60,7 +60,6 @@ function ExampleForm({ onFormSubmit }: ExampleFormProps) { - - - + + + ; export const Default: Story = { args: { magnitude: "md", - tone: "neutral", orientation: "vertical", label: "Email", placeholder: "you@example.com", @@ -43,7 +42,6 @@ export const Default: Story = { export const Horizontal: Story = { args: { magnitude: "md", - tone: "neutral", orientation: "horizontal", label: "Email", placeholder: "you@example.com", @@ -57,21 +55,14 @@ export const Horizontal: Story = { */ export const HorizontalShowcase: Story = { // Required axes for the args table; the custom `render` ignores them. - args: { magnitude: "md", tone: "neutral", orientation: "horizontal" }, + args: { magnitude: "md", orientation: "horizontal" }, parameters: { controls: { disable: true } }, render: () => (
- + @@ -100,7 +89,6 @@ export const HorizontalShowcase: Story = { export const WithIcons: Story = { args: { magnitude: "md", - tone: "neutral", orientation: "vertical", label: "Search", placeholder: "Search…", @@ -111,39 +99,30 @@ export const WithIcons: Story = { /** * The element-driven states side by side. Hover/focus/filled aren't props — they come from - * interacting with the control; `disabled` and the error treatment (`tone="danger"`) are shown + * interacting with the control; `disabled` and the error treatment (set via `error`) are shown * statically. */ export const States: Story = { // Required axes for the args table; the custom `render` ignores them. - args: { magnitude: "md", tone: "neutral", orientation: "vertical" }, + args: { magnitude: "md", orientation: "vertical" }, parameters: { controls: { disable: true } }, render: () => (
+ - (
@@ -175,12 +153,11 @@ export const Magnitudes: Story = { }; /** The error treatment: danger border, danger helper text, and `aria-invalid`. */ -export const Error: Story = { +export const Invalid: Story = { args: { magnitude: "md", orientation: "vertical", label: "Email", - tone: "danger", defaultValue: "not-an-email", error: "Enter a valid email address.", required: true, @@ -196,13 +173,12 @@ export const Error: Story = { export const RtlVerify: Story = { name: "RTL Verify", tags: ["!autodocs", "!manifest"], - args: { magnitude: "md", tone: "neutral", orientation: "vertical" }, + args: { magnitude: "md", orientation: "vertical" }, parameters: { controls: { disable: true } }, render: () => (
(
( +
+ + +
+ ), + play: async ({ canvas }) => { + const resting = canvas.getByRole("textbox", { name: "Resting" }); + const invalid = canvas.getByRole("textbox", { name: "Invalid" }); + await expect(invalid).toHaveAttribute("aria-invalid", "true"); + // The box is the input's wrapping `div`; danger keys off `:has([data-invalid])` on it. + const restingBox = resting.parentElement; + const invalidBox = invalid.parentElement; + // `Error` is shadowed by this file's `Error` story, so reach for the global constructor. + if (restingBox == null || invalidBox == null) { + throw new Error("expected an InputBox wrapper"); + } + await expect(invalidBox).toHaveClass("has-[[data-invalid]]:border-danger-strong"); + // ...and the danger border actually renders: its color differs from the resting box's border. + await expect(getComputedStyle(invalidBox).borderColor).not.toBe( + getComputedStyle(restingBox).borderColor, + ); + }, +}; diff --git a/packages/propel/src/components/input-field/input-field.tsx b/packages/propel/src/components/input-field/input-field.tsx index 18991c95..4c283cb1 100644 --- a/packages/propel/src/components/input-field/input-field.tsx +++ b/packages/propel/src/components/input-field/input-field.tsx @@ -1,7 +1,7 @@ import type * as React from "react"; import { FieldControlContent } from "../../ui/field/field-control-content"; -import type { InputMagnitude, InputTone } from "../../ui/field/variants"; +import type { InputMagnitude } from "../../ui/field/variants"; import { InputField as InputFieldElement } from "../../ui/input-field/input-field"; import type { InputProps } from "../../ui/input/index"; import { Input } from "../../ui/input/input"; @@ -10,13 +10,11 @@ import { InputIconSlot } from "../../ui/input/input-icon-slot"; import { FieldHelperText } from "../field/field-helper-text"; import { FieldLabelGroup } from "../field/field-label-group"; -export type { InputMagnitude, InputTone } from "../../ui/field/variants"; +export type { InputMagnitude } from "../../ui/field/variants"; export type InputFieldProps = Omit & { /** Magnitude scale. `md` | `lg` | `xl`. */ magnitude: InputMagnitude; - /** Resting treatment. `neutral` | `danger` (the Figma "error" state). */ - tone: InputTone; /** Label text shown above (or beside) the control. */ label?: React.ReactNode; /** Marks the field required: adds a `*` asterisk and sets `required`. */ @@ -25,7 +23,10 @@ export type InputFieldProps = Omit & { description?: React.ReactNode; /** Helper text shown below the control. Replaced by `error` when an error is set. */ hint?: React.ReactNode; - /** Error text shown below the control. Overrides `hint`; pair with `tone="danger"`. */ + /** + * Error text shown below the control. Overrides `hint` and marks the field invalid (danger + * border). + */ error?: React.ReactNode; /** Label placement: `vertical` (label above) | `horizontal` (label beside). */ orientation: "vertical" | "horizontal"; @@ -41,7 +42,6 @@ export type InputFieldProps = Omit & { */ export function InputField({ magnitude, - tone, orientation, name, label, @@ -58,7 +58,7 @@ export function InputField({ - + {inlineStartNode ? {inlineStartNode} : null} {inlineEndNode ? {inlineEndNode} : null} diff --git a/packages/propel/src/components/input/input.stories.tsx b/packages/propel/src/components/input/input.stories.tsx index 85af300e..2476ccec 100644 --- a/packages/propel/src/components/input/input.stories.tsx +++ b/packages/propel/src/components/input/input.stories.tsx @@ -5,7 +5,7 @@ import { expect, userEvent } from "storybook/test"; import { InputBox, InputIconSlot } from "../../ui/input/index"; import { Field, FieldError, FieldLabel } from "../field/index"; -import { type InputMagnitude, type InputTone } from "./index"; +import { type InputMagnitude } from "./index"; import { Input } from "./index"; const MAGNITUDES: InputMagnitude[] = ["md", "lg", "xl"]; @@ -22,17 +22,13 @@ type Story = StoryObj; function InputSurface({ children, magnitude = "md", - tone = "neutral", }: { children: React.ReactNode; magnitude?: InputMagnitude; - tone?: InputTone; }) { return (
- - {children} - + {children}
); } @@ -96,7 +92,9 @@ export const FieldComposition: Story = { args: { magnitude: "md" }, render: (args) => ( - Display name + + Display name + @@ -115,8 +113,10 @@ export const FieldErrorAssociation: Story = { args: { magnitude: "md" }, render: (args) => ( - Email - + + Email + + diff --git a/packages/propel/src/components/linear-progress/linear-progress.tsx b/packages/propel/src/components/linear-progress/linear-progress.tsx index 192e9dae..7281bba8 100644 --- a/packages/propel/src/components/linear-progress/linear-progress.tsx +++ b/packages/propel/src/components/linear-progress/linear-progress.tsx @@ -41,7 +41,7 @@ export function LinearProgress({ ...props }: LinearProgressProps) { return ( - + diff --git a/packages/propel/src/components/menu/menu-content.shared.tsx b/packages/propel/src/components/menu/menu-content.shared.tsx index 9bbe7903..1abae2fb 100644 --- a/packages/propel/src/components/menu/menu-content.shared.tsx +++ b/packages/propel/src/components/menu/menu-content.shared.tsx @@ -38,7 +38,7 @@ export function MenuContentSurface({ - + diff --git a/packages/propel/src/components/meter/index.tsx b/packages/propel/src/components/meter/index.tsx index 9e30981c..2d75788b 100644 --- a/packages/propel/src/components/meter/index.tsx +++ b/packages/propel/src/components/meter/index.tsx @@ -4,7 +4,7 @@ export { type MeterHeaderProps, MeterIndicator, type MeterIndicatorProps, - type MeterIndicatorTone, + type MeterIndicatorLevel, MeterLabel, type MeterLabelProps, MeterTrack, diff --git a/packages/propel/src/components/meter/meter.tsx b/packages/propel/src/components/meter/meter.tsx index dc064e35..b1cbf756 100644 --- a/packages/propel/src/components/meter/meter.tsx +++ b/packages/propel/src/components/meter/meter.tsx @@ -7,7 +7,7 @@ import { MeterLabel, MeterTrack, MeterValue, - type MeterIndicatorTone, + type MeterIndicatorLevel, type MeterProps as MeterElementProps, } from "../../ui/meter"; @@ -45,7 +45,7 @@ export type MeterProps = MeterElementProps & { }; /** - * Derives the indicator tone from the current value and the low/high/optimum thresholds, following + * Derives the indicator level from the current value and the low/high/optimum thresholds, following * the same color-assignment logic as the native HTML `` element: * * - The range is divided into three segments: [min, low), [low, high], (high, max]. @@ -60,7 +60,7 @@ export type MeterProps = MeterElementProps & { * For simplicity we use a three-bucket model: below `low` → warning, above `high` → success, * between → accent. When `optimum` shifts what's "best", we invert the outer buckets accordingly. */ -function deriveIndicatorTone({ +function deriveIndicatorLevel({ value, low, high, @@ -70,7 +70,7 @@ function deriveIndicatorTone({ low: number; high: number; optimum: number | undefined; -}): MeterIndicatorTone { +}): MeterIndicatorLevel { const inLow = value < low; const inHigh = value > high; const inMiddle = !inLow && !inHigh; @@ -123,7 +123,7 @@ export function Meter({ const resolvedLow = low ?? min; const resolvedHigh = high ?? max; - const tone = deriveIndicatorTone({ + const level = deriveIndicatorLevel({ value: props.value, low: resolvedLow, high: resolvedHigh, @@ -142,7 +142,7 @@ export function Meter({ ) : null} - + ); diff --git a/packages/propel/src/components/otp-field/index.tsx b/packages/propel/src/components/otp-field/index.tsx index b7cd40bd..e1561fd0 100644 --- a/packages/propel/src/components/otp-field/index.tsx +++ b/packages/propel/src/components/otp-field/index.tsx @@ -3,7 +3,6 @@ export { OTPFieldInput, type OTPFieldInputProps, type OTPFieldInputMagnitude, - type OTPFieldInputTone, OTPFieldLabel, type OTPFieldLabelProps, OTPFieldSeparator, diff --git a/packages/propel/src/components/otp-field/otp-field.stories.tsx b/packages/propel/src/components/otp-field/otp-field.stories.tsx index b0e1dc60..c6d09118 100644 --- a/packages/propel/src/components/otp-field/otp-field.stories.tsx +++ b/packages/propel/src/components/otp-field/otp-field.stories.tsx @@ -10,7 +10,7 @@ import { OTPField } from "./index"; const meta = { title: "Components/OTPField", component: OTPField, - args: { length: 6, magnitude: "md", tone: "neutral", "aria-label": "Verification code" }, + args: { length: 6, magnitude: "md", "aria-label": "Verification code" }, } satisfies Meta; export default meta; @@ -43,7 +43,7 @@ export const Masked: Story = { args: { length: 6, mask: true, defaultValue: "123456" }, }; -/** `tone="danger"` shows error borders on all boxes. */ -export const Error: Story = { - args: { tone: "danger", defaultValue: "12" }, +/** An invalid field shows danger borders on all boxes. */ +export const Invalid: Story = { + args: { error: "Code is invalid", defaultValue: "12" }, }; diff --git a/packages/propel/src/components/otp-field/otp-field.tsx b/packages/propel/src/components/otp-field/otp-field.tsx index 770f83ab..131246e5 100644 --- a/packages/propel/src/components/otp-field/otp-field.tsx +++ b/packages/propel/src/components/otp-field/otp-field.tsx @@ -1,59 +1,69 @@ import * as React from "react"; +import { Field } from "../../ui/field/field"; +import { FieldError } from "../../ui/field/field-error"; +import type { FieldMagnitude } from "../../ui/field/variants"; import { OTPField as OTPFieldElement, OTPFieldInput, type OTPFieldInputMagnitude, - type OTPFieldInputTone, OTPFieldLabel, type OTPFieldProps as OTPFieldElementProps, } from "../../ui/otp-field"; +// OTP slots size sm/md/lg; the error line maps onto the field text scale (md/lg/xl). +const OTP_HELPER_MAGNITUDE: Record = { + sm: "md", + md: "md", + lg: "lg", +}; + export type OTPFieldProps = OTPFieldElementProps & { /** Box size passed to every slot. */ magnitude: OTPFieldInputMagnitude; - /** Visual state passed to every slot: neutral for default, danger when the code is invalid. */ - tone: OTPFieldInputTone; + /** Error text shown below the slots; its presence flips every slot to the danger state. */ + error?: React.ReactNode; }; /** * The ready-made one-time-password / verification-code field: a row of `length` character slots. * Drive it with `value`/`defaultValue` + `onValueChange`; the root owns focus movement, paste, and * completion across the slots. Pass `mask` to obscure entered characters, `magnitude` to set the - * box size, and `tone="danger"` to show an error border on all boxes. + * box size, and `error` to show an error message — `Field.Root invalid` then propagates + * `data-invalid` to every slot, which recolors its border (and focus ring) to danger off that + * state. * * Composed from the `ui/otp-field` parts (`OTPField` root + `OTPFieldInput`), which are built on * Base UI `OTPFieldPreview`. Each `OTPFieldInput` resolves its slot index from the root context, so * the ready-made simply renders one per slot. For grouped layouts with separators (e.g. `123-456`), * compose the `ui/otp-field` parts directly. */ -export function OTPField({ length, magnitude, tone, ...props }: OTPFieldProps) { +export function OTPField({ length, magnitude, error, ...props }: OTPFieldProps) { // Base UI ignores `aria-label` on the first slot and names it from the root's `aria-labelledby` // instead, so a visually-hidden label backs the first slot while the rest carry an `aria-label`. // Without this the slot inputs fail axe's "Form elements must have labels". const firstSlotLabelId = React.useId(); return ( - - Character 1 - {Array.from({ length }, (_, index) => - // Base UI ignores `aria-label` on the first slot, so name it via `aria-labelledby` - // (pointing at the hidden label); the rest carry an `aria-label`. - index === 0 ? ( - - ) : ( - - ), - )} - + + + Character 1 + {Array.from({ length }, (_, index) => + // Base UI ignores `aria-label` on the first slot, so name it via `aria-labelledby` + // (pointing at the hidden label); the rest carry an `aria-label`. + index === 0 ? ( + + ) : ( + + ), + )} + + + {error} + + ); } diff --git a/packages/propel/src/components/popover/popover.stories.tsx b/packages/propel/src/components/popover/popover.stories.tsx index b16ba963..042b1d9a 100644 --- a/packages/propel/src/components/popover/popover.stories.tsx +++ b/packages/propel/src/components/popover/popover.stories.tsx @@ -58,13 +58,11 @@ function ToggleFooter({ defaultToggles = {} }: { defaultToggles?: Record setToggles((t) => ({ ...t, sub: Boolean(next) }))} label={Show sub-work items} /> setToggles((t) => ({ ...t, empty: Boolean(next) }))} label={Show empty groups} diff --git a/packages/propel/src/components/radio-group-field/radio-group-field-option.tsx b/packages/propel/src/components/radio-group-field/radio-group-field-option.tsx index 32a35304..5d56294b 100644 --- a/packages/propel/src/components/radio-group-field/radio-group-field-option.tsx +++ b/packages/propel/src/components/radio-group-field/radio-group-field-option.tsx @@ -2,8 +2,8 @@ import type * as React from "react"; import { useFieldOptionMagnitude } from "../../internal/field-option-magnitude"; import { FieldItem } from "../../ui/field/field-item"; -import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldItemContent } from "../field"; import { Radio, type RadioProps } from "../radio/radio"; export type RadioGroupFieldOptionProps = RadioProps & { diff --git a/packages/propel/src/components/switch-field/switch-field.tsx b/packages/propel/src/components/switch-field/switch-field.tsx index e2d00a0b..6eb7dbf5 100644 --- a/packages/propel/src/components/switch-field/switch-field.tsx +++ b/packages/propel/src/components/switch-field/switch-field.tsx @@ -2,8 +2,8 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; import { FieldItem } from "../../ui/field/field-item"; -import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldItemContent } from "../field"; import { FieldHelperText } from "../field/field-helper-text"; import { Switch, type SwitchMagnitude, type SwitchProps } from "../switch/index"; diff --git a/packages/propel/src/components/table/table-action-cell.tsx b/packages/propel/src/components/table/table-action-cell.tsx index cf21d322..9d958e5a 100644 --- a/packages/propel/src/components/table/table-action-cell.tsx +++ b/packages/propel/src/components/table/table-action-cell.tsx @@ -10,7 +10,10 @@ import { } from "../../ui/table/index"; import { useTableMode } from "./table-context"; -export type TableActionCellProps = Omit & { +export type TableActionCellProps = Omit< + TableCellProps, + "padding" | "pinned" | "children" | "mode" +> & { /** The menu of row actions. */ children: React.ReactNode; /** Accessible name for the trigger (e.g. "Row options"). Required (icon-only). */ @@ -40,7 +43,7 @@ export function TableActionCell({ }: TableActionCellProps) { const mode = useTableMode(); return ( - + & { +export type TableCellProps = Omit & { /** Leading content beside the cell text — an icon or an `Avatar`. */ inlineStartNode?: React.ReactNode; /** Trailing content beside the cell text — an icon or an `Avatar`. */ diff --git a/packages/propel/src/components/table/table-editable-cell.tsx b/packages/propel/src/components/table/table-editable-cell.tsx index e37f5e70..b3e3be21 100644 --- a/packages/propel/src/components/table/table-editable-cell.tsx +++ b/packages/propel/src/components/table/table-editable-cell.tsx @@ -11,7 +11,10 @@ import { } from "../../ui/table/index"; import { useTableMode } from "./table-context"; -export type TableEditableCellProps = Omit & { +export type TableEditableCellProps = Omit< + TableCellProps, + "padding" | "pinned" | "children" | "mode" +> & { /** The current value shown in the cell. */ value: React.ReactNode; /** The menu shown when the cell is clicked. */ @@ -44,7 +47,7 @@ export function TableEditableCell({ }: TableEditableCellProps) { const mode = useTableMode(); return ( - + {COLUMNS.map((c) => ( - {c} + + {c} + ))} @@ -97,16 +99,26 @@ export const Default: Story = { {PEOPLE.map((person) => ( } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -131,7 +143,9 @@ export const Spreadsheet: Story = { {COLUMNS.map((c) => ( - {c} + + {c} + ))} @@ -139,16 +153,26 @@ export const Spreadsheet: Story = { {PEOPLE.map((person) => ( } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -173,25 +197,31 @@ export const Sortable: Story = { - + Name - Account type - Email + Account type + Email {PEOPLE.map((person) => ( } > {person.name} - {person.role} - {person.email} + + {person.role} + + + {person.email} + ))} @@ -228,22 +258,26 @@ export const EditableCells: Story = {
- Name - Email - Account type + Name + Email + Account type {people.map((person) => ( } > {person.name} - {person.email} + + {person.email} + - Name - Email - Account type - Billing status + Name + Email + Account type + Billing status {rows.map((person) => ( } > {person.name} - {person.email} - {person.role} - {person.billing} + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -384,10 +426,10 @@ export const RichRows: Story = {
- Name - Email - Account type - + Name + Email + Account type + Actions @@ -396,13 +438,17 @@ export const RichRows: Story = { {people.map((person) => ( } > {person.name} - {person.email} + + {person.email} + {ROLES.map((role) => ( @@ -454,10 +500,10 @@ export const StickyHeaderAndColumns: Story = { Name - Display name - Email - Account type - Billing status + Display name + Email + Account type + Billing status @@ -465,16 +511,25 @@ export const StickyHeaderAndColumns: Story = { } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -507,23 +562,27 @@ export const SortableKeyboard: Story = {
- + Name - Email + Email {PEOPLE.map((person) => ( } > {person.name} - {person.email} + + {person.email} + ))} @@ -573,13 +632,15 @@ export const EditableCellKeyboard: Story = {
- Name - Account type + Name + Account type - Chargers + + Chargers + {ROLES.map((r) => ( diff --git a/packages/propel/src/components/tabs/tab.tsx b/packages/propel/src/components/tabs/tab.tsx index 2c01dd77..125771c6 100644 --- a/packages/propel/src/components/tabs/tab.tsx +++ b/packages/propel/src/components/tabs/tab.tsx @@ -5,6 +5,7 @@ import { Tab as TabElement, type TabProps as TabElementProps, TabUnderlineBar, + TabUnderlineBarTrack, TabUnderlineLabel, } from "../../ui/tabs"; import { TabsAppearanceContext } from "./tabs-context"; @@ -33,7 +34,9 @@ export function Tab({ inlineStartNode, children, ...props }: TabProps) { {iconNode} {children} - + + + ); } diff --git a/packages/propel/src/components/text-area-field/text-area-field.stories.tsx b/packages/propel/src/components/text-area-field/text-area-field.stories.tsx index 5ebf976e..346ed636 100644 --- a/packages/propel/src/components/text-area-field/text-area-field.stories.tsx +++ b/packages/propel/src/components/text-area-field/text-area-field.stories.tsx @@ -23,7 +23,6 @@ type Story = StoryObj; export const Default: Story = { args: { magnitude: "md", - tone: "neutral", resize: "vertical", label: "Comment", placeholder: "Leave a comment...", @@ -44,7 +43,6 @@ export const Default: Story = { export const Magnitudes: Story = { args: { magnitude: "md", - tone: "neutral", resize: "vertical", label: "Comment", placeholder: "Leave a comment...", @@ -56,7 +54,6 @@ export const Magnitudes: Story = { (
`Line ${i + 1} of a long comment.`).join( @@ -89,10 +85,9 @@ export const Scroll: Story = { }; /** The error treatment: danger border, danger helper text, and `aria-invalid`. */ -export const Error: Story = { +export const Invalid: Story = { args: { magnitude: "md", - tone: "danger", resize: "vertical", label: "Comment", defaultValue: "No", @@ -111,7 +106,6 @@ export const TypingUpdatesValue: Story = { tags: ["!dev", "!autodocs", "!manifest"], args: { magnitude: "md", - tone: "neutral", resize: "vertical", label: "Comment", placeholder: "Leave a comment...", @@ -130,12 +124,11 @@ export const TypingUpdatesValue: Story = { }, }; -/** `tone="danger"` sets `aria-invalid` and renders the announced error text. */ +/** Setting `error` marks the field invalid (`aria-invalid`) and renders the announced error text. */ export const ErrorAnnouncesInvalid: Story = { tags: ["!dev", "!autodocs", "!manifest"], args: { magnitude: "md", - tone: "danger", resize: "vertical", label: "Comment", defaultValue: "No", @@ -152,3 +145,44 @@ export const ErrorAnnouncesInvalid: Story = { await expect(textarea).toHaveAccessibleDescription("Add a little more detail."); }, }; + +/** + * Setting `error` marks the field invalid; Base UI's `Field.Root` propagates that validity to the + * control as `data-invalid`, and the wrapping `TextAreaBox` recolors its border to `danger` via + * `:has([data-invalid])` — no `tone` prop. A resting field is shown alongside so the danger border + * is assertably different. + */ +export const InvalidShowsDangerBorder: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + parameters: { controls: { disable: true } }, + args: { magnitude: "md", resize: "vertical", label: "Comment" }, + render: () => ( +
+ + +
+ ), + play: async ({ canvas }) => { + const resting = canvas.getByRole("textbox", { name: "Resting" }); + const invalid = canvas.getByRole("textbox", { name: "Invalid" }); + await expect(invalid).toHaveAttribute("aria-invalid", "true"); + // The box is the textarea's wrapping `div`; danger keys off `:has([data-invalid])` on it. + const restingBox = resting.parentElement; + const invalidBox = invalid.parentElement; + // `Error` is shadowed by this file's `Error` story, so reach for the global constructor. + if (restingBox == null || invalidBox == null) { + throw new Error("expected a TextAreaBox wrapper"); + } + await expect(invalidBox).toHaveClass("has-[[data-invalid]]:border-danger-strong"); + // ...and the danger border actually renders: its color differs from the resting box's border. + await expect(getComputedStyle(invalidBox).borderColor).not.toBe( + getComputedStyle(restingBox).borderColor, + ); + }, +}; diff --git a/packages/propel/src/components/text-area-field/text-area-field.tsx b/packages/propel/src/components/text-area-field/text-area-field.tsx index 9de46a47..87d1ee5a 100644 --- a/packages/propel/src/components/text-area-field/text-area-field.tsx +++ b/packages/propel/src/components/text-area-field/text-area-field.tsx @@ -2,10 +2,10 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; import { FieldControlContent } from "../../ui/field/field-control-content"; -import type { InputMagnitude, InputTone } from "../../ui/field/variants"; +import type { InputMagnitude } from "../../ui/field/variants"; import { TextArea, type TextAreaProps } from "../../ui/text-area/text-area"; -export type { InputMagnitude, InputTone }; +export type { InputMagnitude }; import { TextAreaBox } from "../../ui/text-area/text-area-box"; import { FieldHelperText } from "../field/field-helper-text"; import { FieldLabelGroup } from "../field/field-label-group"; @@ -13,8 +13,6 @@ import { FieldLabelGroup } from "../field/field-label-group"; export type TextAreaFieldProps = Omit & { /** Magnitude scale. `md` | `lg` | `xl`. */ magnitude: InputMagnitude; - /** Resting treatment. `neutral` | `danger` (the Figma "error" state). */ - tone: InputTone; /** Label text shown above the control. */ label?: React.ReactNode; /** Marks the field required: adds a `*` asterisk and sets `required`. */ @@ -23,7 +21,10 @@ export type TextAreaFieldProps = Omit & description?: React.ReactNode; /** Helper text shown below the control. Replaced by `error` when an error is set. */ hint?: React.ReactNode; - /** Error text shown below the control. Overrides `hint`; pair with `tone="danger"`. */ + /** + * Error text shown below the control. Overrides `hint` and marks the field invalid (danger + * border). + */ error?: React.ReactNode; }; @@ -33,7 +34,6 @@ export type TextAreaFieldProps = Omit & */ export function TextAreaField({ magnitude, - tone, name, label, required, @@ -44,7 +44,7 @@ export function TextAreaField({ ...controlProps }: TextAreaFieldProps) { return ( - + - +