From 19f354887515cce1aefead72515f457bb2e06a63 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:07:36 +0700 Subject: [PATCH 1/6] Align field to architecture goals - Convert fieldDescriptionVariants and fieldErrorVariants from plain cx-wrapper functions to proper cva instances, so className composition lives entirely in cva - Convert inputFieldBoxVariants and textAreaFieldBoxVariants from cx-wrapper functions to standalone cva instances (tone + magnitude variants inlined); remove the now-dead fieldBoxVariants base - Extract fieldRootVariants cva for Field.Root base class; call it from field.tsx instead of the bare inline string - Rename iconSlotClass (plain cx constant) to iconSlotVariants (cva) and update the import in InputFieldIconSlot - Replace the anonymous inline-className div in TextAreaField with InputFieldContent orientation="vertical", eliminating the last raw className in the components tier Closes #128 --- .../src/components/field/text-area-field.tsx | 5 +- packages/propel/src/ui/field/field.tsx | 4 +- .../src/ui/field/input-field-icon-slot.tsx | 4 +- packages/propel/src/ui/field/variants.ts | 117 +++++++++--------- 4 files changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/propel/src/components/field/text-area-field.tsx b/packages/propel/src/components/field/text-area-field.tsx index 3110e3a1..374a25f1 100644 --- a/packages/propel/src/components/field/text-area-field.tsx +++ b/packages/propel/src/components/field/text-area-field.tsx @@ -3,6 +3,7 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldLabelGroup } from "../../ui/field/field-label-group"; +import { InputFieldContent } from "../../ui/field/input-field-content"; import { TextAreaFieldBox } from "../../ui/field/text-area-field-box"; import { TextAreaFieldControl, @@ -52,7 +53,7 @@ export function TextAreaField({ description={description} orientation="vertical" /> -
+ -
+ ); } diff --git a/packages/propel/src/ui/field/field.tsx b/packages/propel/src/ui/field/field.tsx index dce17351..5cc77980 100644 --- a/packages/propel/src/ui/field/field.tsx +++ b/packages/propel/src/ui/field/field.tsx @@ -1,5 +1,7 @@ import { Field as BaseField } from "@base-ui/react/field"; +import { fieldRootVariants } from "./variants"; + export type FieldProps = Omit; /** @@ -7,5 +9,5 @@ export type FieldProps = Omit; * `TextAreaFieldControl`, `FieldDescription`, and `FieldError`. */ export function Field(props: FieldProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/field/input-field-icon-slot.tsx b/packages/propel/src/ui/field/input-field-icon-slot.tsx index daa453f4..508b5e15 100644 --- a/packages/propel/src/ui/field/input-field-icon-slot.tsx +++ b/packages/propel/src/ui/field/input-field-icon-slot.tsx @@ -1,10 +1,10 @@ import type * as React from "react"; -import { iconSlotClass } from "./variants"; +import { iconSlotVariants } from "./variants"; export type InputFieldIconSlotProps = Omit, "className" | "style">; /** A 16px decorative slot rendered at the inline start/end of the `InputField` control. */ export function InputFieldIconSlot(props: InputFieldIconSlotProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/field/variants.ts b/packages/propel/src/ui/field/variants.ts index 7be65f89..e00b5a4a 100644 --- a/packages/propel/src/ui/field/variants.ts +++ b/packages/propel/src/ui/field/variants.ts @@ -2,6 +2,8 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; export type { InputMagnitude } from "../input/variants"; +export const fieldRootVariants = cva("flex flex-col gap-1.5"); + export const fieldLabelVariants = cva( cx("inline-flex items-center gap-0.5", "font-medium text-primary"), { @@ -28,7 +30,7 @@ type FieldLabelVariantProps = VariantProps; export type FieldMagnitude = NonNullable; -const fieldHelperTextVariants = cva("", { +export const fieldDescriptionVariants = cva("text-tertiary", { variants: { magnitude: { md: "text-12", @@ -38,41 +40,15 @@ const fieldHelperTextVariants = cva("", { }, }); -type FieldHelperTextVariantProps = VariantProps; - -type FieldHelperTextVariantsProps = { - magnitude: NonNullable; -}; - -export const fieldDescriptionVariants = ({ magnitude }: FieldHelperTextVariantsProps) => - cx("text-tertiary", fieldHelperTextVariants({ magnitude })); - -export const fieldErrorVariants = ({ magnitude }: FieldHelperTextVariantsProps) => - cx("text-danger-primary", fieldHelperTextVariants({ magnitude })); - -export const fieldBoxVariants = cva( - cx( - "flex w-full items-center gap-1.5 bg-layer-2 transition-[color,background-color,border-color,box-shadow]", - "border-sm", - "has-[:disabled]:cursor-not-allowed has-[:disabled]:border-subtle has-[:disabled]:bg-layer-2 has-[:disabled]:ring-0 has-[:disabled]:hover:border-subtle", - ), - { - variants: { - tone: { - neutral: cx( - "border-subtle hover:border-subtle-1 hover:bg-layer-2-hover", - "focus-within:border-accent-strong focus-within:bg-layer-2 focus-within:ring-2 focus-within:ring-accent-strong/20", - "focus-within:hover:border-accent-strong focus-within:hover:bg-layer-2", - ), - danger: "border-danger-strong", - }, +export const fieldErrorVariants = cva("text-danger-primary", { + variants: { + magnitude: { + md: "text-12", + lg: "text-13", + xl: "text-13", }, }, -); - -type FieldBoxVariantProps = VariantProps; - -export type InputTone = NonNullable; +}); export const inputFieldRootVariants = cva("flex gap-2", { variants: { @@ -92,38 +68,59 @@ export const inputFieldContentVariants = cva("flex flex-col", { }, }); -const inputFieldBoxFrameVariants = cva("rounded-md px-3", { - variants: { - magnitude: { - md: "py-1.5", - lg: "py-2", - xl: "py-3", +export const inputFieldBoxVariants = cva( + cx( + "flex w-full items-center gap-1.5 bg-layer-2 transition-[color,background-color,border-color,box-shadow]", + "border-sm", + "has-[:disabled]:cursor-not-allowed has-[:disabled]:border-subtle has-[:disabled]:bg-layer-2 has-[:disabled]:ring-0 has-[:disabled]:hover:border-subtle", + "rounded-md px-3", + ), + { + variants: { + tone: { + neutral: cx( + "border-subtle hover:border-subtle-1 hover:bg-layer-2-hover", + "focus-within:border-accent-strong focus-within:bg-layer-2 focus-within:ring-2 focus-within:ring-accent-strong/20", + "focus-within:hover:border-accent-strong focus-within:hover:bg-layer-2", + ), + danger: "border-danger-strong", + }, + magnitude: { + md: "py-1.5", + lg: "py-2", + xl: "py-3", + }, }, }, -}); - -type InputFieldBoxFrameVariantProps = VariantProps; - -type InputFieldBoxVariantsProps = { - magnitude: NonNullable; - tone: InputTone; -}; - -export const inputFieldBoxVariants = ({ magnitude, tone }: InputFieldBoxVariantsProps) => - cx(fieldBoxVariants({ tone }), inputFieldBoxFrameVariants({ magnitude })); +); -const textAreaFieldBoxFrameVariants = cva("items-stretch rounded-lg py-2"); +type InputFieldBoxVariantProps = VariantProps; -type TextAreaFieldBoxVariantsProps = { - tone: InputTone; -}; +export type InputTone = NonNullable; -export const textAreaFieldBoxVariants = ({ tone }: TextAreaFieldBoxVariantsProps) => - cx(fieldBoxVariants({ tone }), textAreaFieldBoxFrameVariants()); +export const textAreaFieldBoxVariants = cva( + cx( + "flex w-full items-center gap-1.5 bg-layer-2 transition-[color,background-color,border-color,box-shadow]", + "border-sm", + "has-[:disabled]:cursor-not-allowed has-[:disabled]:border-subtle has-[:disabled]:bg-layer-2 has-[:disabled]:ring-0 has-[:disabled]:hover:border-subtle", + "items-stretch rounded-lg py-2", + ), + { + variants: { + tone: { + neutral: cx( + "border-subtle hover:border-subtle-1 hover:bg-layer-2-hover", + "focus-within:border-accent-strong focus-within:bg-layer-2 focus-within:ring-2 focus-within:ring-accent-strong/20", + "focus-within:hover:border-accent-strong focus-within:hover:bg-layer-2", + ), + danger: "border-danger-strong", + }, + }, + }, +); -export const iconSlotClass = cx( - "flex shrink-0 items-center justify-center text-icon-secondary", - "[&_svg]:size-4", +export const iconSlotVariants = cva( + cx("flex shrink-0 items-center justify-center text-icon-secondary", "[&_svg]:size-4"), ); export const fieldItemVariants = cva( From 8071220ece39a4da82af5b77c21083aa652c65d3 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:39:53 +0700 Subject: [PATCH 2/6] Extract field required marker as its own anatomy part FieldLabel was rendering its required asterisk as a bare second element with an inline className, so the part owned two elements and baked styling in. Pull the asterisk into a FieldLabelRequiredMarker part (single span, its own cva), keeping FieldLabel a single Base UI Label that composes the marker when required. Move the remaining inline classNames in the field ui tier into cva: the choice-option label/description column (fieldItemContentVariants) and the inline icon slot, which now reuses the shared node-slot class instead of baking a child size. --- .../src/ui/field/field-item-content.tsx | 4 ++-- .../ui/field/field-label-required-marker.tsx | 20 +++++++++++++++++++ packages/propel/src/ui/field/field-label.tsx | 9 +++------ .../propel/src/ui/field/field.stories.tsx | 17 ++++++++++++++-- packages/propel/src/ui/field/index.tsx | 4 ++++ packages/propel/src/ui/field/variants.ts | 15 +++++++++++--- 6 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 packages/propel/src/ui/field/field-label-required-marker.tsx diff --git a/packages/propel/src/ui/field/field-item-content.tsx b/packages/propel/src/ui/field/field-item-content.tsx index 731d2ffb..e83cb9b0 100644 --- a/packages/propel/src/ui/field/field-item-content.tsx +++ b/packages/propel/src/ui/field/field-item-content.tsx @@ -2,7 +2,7 @@ import type * as React from "react"; import { FieldDescription } from "./field-description"; import { FieldLabel } from "./field-label"; -import type { InputMagnitude } from "./variants"; +import { fieldItemContentVariants, type InputMagnitude } from "./variants"; export function FieldItemContent({ children, @@ -14,7 +14,7 @@ export function FieldItemContent({ magnitude: InputMagnitude; }) { return ( -
+
{children} {description != null ? ( {description} diff --git a/packages/propel/src/ui/field/field-label-required-marker.tsx b/packages/propel/src/ui/field/field-label-required-marker.tsx new file mode 100644 index 00000000..bb8814f9 --- /dev/null +++ b/packages/propel/src/ui/field/field-label-required-marker.tsx @@ -0,0 +1,20 @@ +import type * as React from "react"; + +import { fieldLabelRequiredMarkerVariants } from "./variants"; + +export type FieldLabelRequiredMarkerProps = Omit< + React.ComponentPropsWithoutRef<"span">, + "className" | "style" +>; + +/** + * The required `*` marker shown after a `FieldLabel`'s text. Decorative (the control's `required` + * attribute carries the semantics), so it is `aria-hidden`; defaults to an asterisk glyph. + */ +export function FieldLabelRequiredMarker({ children, ...props }: FieldLabelRequiredMarkerProps) { + return ( + + {children ?? "*"} + + ); +} diff --git a/packages/propel/src/ui/field/field-label.tsx b/packages/propel/src/ui/field/field-label.tsx index d36d52f6..b45af8a7 100644 --- a/packages/propel/src/ui/field/field-label.tsx +++ b/packages/propel/src/ui/field/field-label.tsx @@ -1,6 +1,7 @@ import { Field as BaseField } from "@base-ui/react/field"; import type * as React from "react"; +import { FieldLabelRequiredMarker } from "./field-label-required-marker"; import { fieldLabelVariants, type InputMagnitude } from "./variants"; export type FieldLabelProps = { @@ -10,16 +11,12 @@ export type FieldLabelProps = { inset?: boolean; }; -/** The label row: the label text and the required `*` asterisk in danger. */ +/** The label row: the label text, plus a `FieldLabelRequiredMarker` when `required`. */ export function FieldLabel({ children, magnitude, required, inset }: FieldLabelProps) { return ( {children} - {required ? ( - - * - - ) : null} + {required ? : null} ); } diff --git a/packages/propel/src/ui/field/field.stories.tsx b/packages/propel/src/ui/field/field.stories.tsx index df3f8b8e..85662db6 100644 --- a/packages/propel/src/ui/field/field.stories.tsx +++ b/packages/propel/src/ui/field/field.stories.tsx @@ -1,7 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; -import { Field, FieldDescription, FieldError, FieldLabel, InputFieldControl } from "./index"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, + FieldLabelRequiredMarker, + InputFieldControl, +} from "./index"; // UI-tier story: composes the ATOMIC field parts. `Field` (Base UI `Field.Root`) is the // labeling/validation shell; `FieldLabel` names the control, `InputFieldControl` is the @@ -11,7 +18,13 @@ import { Field, FieldDescription, FieldError, FieldLabel, InputFieldControl } fr const meta = { title: "UI/Field", component: Field, - subcomponents: { FieldLabel, InputFieldControl, FieldDescription, FieldError }, + subcomponents: { + FieldLabel, + FieldLabelRequiredMarker, + InputFieldControl, + FieldDescription, + FieldError, + }, decorators: [ (Story) => (
diff --git a/packages/propel/src/ui/field/index.tsx b/packages/propel/src/ui/field/index.tsx index 1e45016b..03e5feb4 100644 --- a/packages/propel/src/ui/field/index.tsx +++ b/packages/propel/src/ui/field/index.tsx @@ -4,6 +4,10 @@ export { FieldDescription, type FieldDescriptionProps } from "./field-descriptio export { FieldError, type FieldErrorProps } from "./field-error"; export { FieldItem, type FieldItemProps } from "./field-item"; export { FieldLabel, type FieldLabelProps } from "./field-label"; +export { + FieldLabelRequiredMarker, + type FieldLabelRequiredMarkerProps, +} from "./field-label-required-marker"; export { InputFieldBox, type InputFieldBoxProps } from "./input-field-box"; export { InputFieldContent, type InputFieldContentProps } from "./input-field-content"; export { InputFieldControl, type InputFieldControlProps } from "./input-field-control"; diff --git a/packages/propel/src/ui/field/variants.ts b/packages/propel/src/ui/field/variants.ts index e00b5a4a..ed127c56 100644 --- a/packages/propel/src/ui/field/variants.ts +++ b/packages/propel/src/ui/field/variants.ts @@ -1,5 +1,7 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; +import { nodeSlotClass } from "../../internal/node-slot"; + export type { InputMagnitude } from "../input/variants"; export const fieldRootVariants = cva("flex flex-col gap-1.5"); @@ -30,6 +32,13 @@ type FieldLabelVariantProps = VariantProps; export type FieldMagnitude = NonNullable; +// The required `*` marker rendered after the label text. Decorative (the control's +// `required` carries the semantics), tinted danger per the Figma spec. +export const fieldLabelRequiredMarkerVariants = cva("text-danger-primary"); + +// The label + description column for a single choice option (checkbox/radio/switch row). +export const fieldItemContentVariants = cva("flex min-w-0 flex-col gap-1"); + export const fieldDescriptionVariants = cva("text-tertiary", { variants: { magnitude: { @@ -119,9 +128,9 @@ export const textAreaFieldBoxVariants = cva( }, ); -export const iconSlotVariants = cva( - cx("flex shrink-0 items-center justify-center text-icon-secondary", "[&_svg]:size-4"), -); +// The decorative 16px node at the control's inline start/end. Sizes its single child +// to `--node-size` (via the shared node-slot class) and tints it. +export const iconSlotVariants = cva(cx(nodeSlotClass, "text-icon-secondary [--node-size:1rem]")); export const fieldItemVariants = cva( cx( From 18d069f72a7e9a214b9703b18788c30124dbf4d6 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:59:30 +0700 Subject: [PATCH 3/6] Rename field cva functions to match their part names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fieldRootVariants → fieldVariants (part: Field) iconSlotVariants → inputFieldIconSlotVariants (part: InputFieldIconSlot) labelGroupVariants → fieldLabelGroupVariants (part: FieldLabelGroup) --- packages/propel/src/ui/field/field-label-group.tsx | 6 +++--- packages/propel/src/ui/field/field.tsx | 4 ++-- packages/propel/src/ui/field/input-field-icon-slot.tsx | 4 ++-- packages/propel/src/ui/field/variants.ts | 8 +++++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/propel/src/ui/field/field-label-group.tsx b/packages/propel/src/ui/field/field-label-group.tsx index de7f17bc..4d1167a6 100644 --- a/packages/propel/src/ui/field/field-label-group.tsx +++ b/packages/propel/src/ui/field/field-label-group.tsx @@ -3,7 +3,7 @@ import type * as React from "react"; import { FieldDescription } from "./field-description"; import { FieldLabel } from "./field-label"; -import { type InputMagnitude, labelGroupVariants } from "./variants"; +import { type InputMagnitude, fieldLabelGroupVariants } from "./variants"; export function FieldLabelGroup({ magnitude, @@ -16,7 +16,7 @@ export function FieldLabelGroup({ required?: boolean; label?: React.ReactNode; description?: React.ReactNode; - orientation: NonNullable["orientation"]>; + orientation: NonNullable["orientation"]>; }) { if (label == null && description == null) { return null; @@ -24,7 +24,7 @@ export function FieldLabelGroup({ const inset = orientation === "horizontal" && description == null; return ( -
+
{label != null ? ( {label} diff --git a/packages/propel/src/ui/field/field.tsx b/packages/propel/src/ui/field/field.tsx index 5cc77980..63ddb366 100644 --- a/packages/propel/src/ui/field/field.tsx +++ b/packages/propel/src/ui/field/field.tsx @@ -1,6 +1,6 @@ import { Field as BaseField } from "@base-ui/react/field"; -import { fieldRootVariants } from "./variants"; +import { fieldVariants } from "./variants"; export type FieldProps = Omit; @@ -9,5 +9,5 @@ export type FieldProps = Omit; * `TextAreaFieldControl`, `FieldDescription`, and `FieldError`. */ export function Field(props: FieldProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/field/input-field-icon-slot.tsx b/packages/propel/src/ui/field/input-field-icon-slot.tsx index 508b5e15..6f3d5ed7 100644 --- a/packages/propel/src/ui/field/input-field-icon-slot.tsx +++ b/packages/propel/src/ui/field/input-field-icon-slot.tsx @@ -1,10 +1,10 @@ import type * as React from "react"; -import { iconSlotVariants } from "./variants"; +import { inputFieldIconSlotVariants } from "./variants"; export type InputFieldIconSlotProps = Omit, "className" | "style">; /** A 16px decorative slot rendered at the inline start/end of the `InputField` control. */ export function InputFieldIconSlot(props: InputFieldIconSlotProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/field/variants.ts b/packages/propel/src/ui/field/variants.ts index ed127c56..f62ecf23 100644 --- a/packages/propel/src/ui/field/variants.ts +++ b/packages/propel/src/ui/field/variants.ts @@ -4,7 +4,7 @@ import { nodeSlotClass } from "../../internal/node-slot"; export type { InputMagnitude } from "../input/variants"; -export const fieldRootVariants = cva("flex flex-col gap-1.5"); +export const fieldVariants = cva("flex flex-col gap-1.5"); export const fieldLabelVariants = cva( cx("inline-flex items-center gap-0.5", "font-medium text-primary"), @@ -130,7 +130,9 @@ export const textAreaFieldBoxVariants = cva( // The decorative 16px node at the control's inline start/end. Sizes its single child // to `--node-size` (via the shared node-slot class) and tints it. -export const iconSlotVariants = cva(cx(nodeSlotClass, "text-icon-secondary [--node-size:1rem]")); +export const inputFieldIconSlotVariants = cva( + cx(nodeSlotClass, "text-icon-secondary [--node-size:1rem]"), +); export const fieldItemVariants = cva( cx( @@ -140,7 +142,7 @@ export const fieldItemVariants = cva( ), ); -export const labelGroupVariants = cva("flex flex-col gap-1", { +export const fieldLabelGroupVariants = cva("flex flex-col gap-1", { variants: { orientation: { vertical: "w-full", From a63444495e7c635b67b3b4432fd6d440d6c0f12f Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 17:27:19 +0700 Subject: [PATCH 4/6] Make the field required marker a single-element slot FieldLabelRequiredMarker no longer bakes the asterisk via children ?? "*"; it renders only its children. FieldLabel now passes the * glyph in when required, and the UI story demonstrates the bare slot with an explicit glyph. --- .../ui/field/field-label-required-marker.tsx | 7 ++++--- packages/propel/src/ui/field/field-label.tsx | 2 +- .../propel/src/ui/field/field.stories.tsx | 21 ++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/propel/src/ui/field/field-label-required-marker.tsx b/packages/propel/src/ui/field/field-label-required-marker.tsx index bb8814f9..9c344585 100644 --- a/packages/propel/src/ui/field/field-label-required-marker.tsx +++ b/packages/propel/src/ui/field/field-label-required-marker.tsx @@ -8,13 +8,14 @@ export type FieldLabelRequiredMarkerProps = Omit< >; /** - * The required `*` marker shown after a `FieldLabel`'s text. Decorative (the control's `required` - * attribute carries the semantics), so it is `aria-hidden`; defaults to an asterisk glyph. + * The required marker slot shown after a `FieldLabel`'s text. Decorative (the control's `required` + * attribute carries the semantics), so it is `aria-hidden`. Bakes no glyph: pass the marker (e.g. + * an asterisk) as `children`. */ export function FieldLabelRequiredMarker({ children, ...props }: FieldLabelRequiredMarkerProps) { return ( - {children ?? "*"} + {children} ); } diff --git a/packages/propel/src/ui/field/field-label.tsx b/packages/propel/src/ui/field/field-label.tsx index b45af8a7..08380b49 100644 --- a/packages/propel/src/ui/field/field-label.tsx +++ b/packages/propel/src/ui/field/field-label.tsx @@ -16,7 +16,7 @@ export function FieldLabel({ children, magnitude, required, inset }: FieldLabelP return ( {children} - {required ? : null} + {required ? * : null} ); } diff --git a/packages/propel/src/ui/field/field.stories.tsx b/packages/propel/src/ui/field/field.stories.tsx index 85662db6..c97df0b1 100644 --- a/packages/propel/src/ui/field/field.stories.tsx +++ b/packages/propel/src/ui/field/field.stories.tsx @@ -37,7 +37,10 @@ const meta = { export default meta; type Story = StoryObj; -/** A labeled input with helper text, assembled from the atomic parts. */ +/** + * A labeled input with helper text, assembled from the atomic parts. `FieldLabel required` appends + * the `FieldLabelRequiredMarker` slot, passing the asterisk glyph in as its child. + */ export const Default: Story = { render: () => ( @@ -55,6 +58,22 @@ export const Default: Story = { }, }; +/** + * The required marker is a bare slot that bakes no glyph: the consumer passes the marker (here, the + * conventional asterisk) as `children`. + */ +export const RequiredMarker: Story = { + render: () => ( + + + Display name + * + + + + ), +}; + /** An invalid field: the control exposes `aria-invalid` and announces the `FieldError` text. */ export const Invalid: Story = { render: () => ( From 313b782c375efb0f31fb835d56a762c776bf4fc7 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 16:12:16 +0700 Subject: [PATCH 5/6] field: move FieldHelperText composition to the components tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldHelperText composes two ui parts (FieldError + FieldDescription) with the error-XOR-hint rule — it is a composition, not a single-element ui part, so it belongs in components/field/ (where its 9 consumers already live), not ui/field/. --- .../components/field/autocomplete-field.tsx | 2 +- .../src/components/field/checkbox-field.tsx | 2 +- .../components/field/checkbox-group-field.tsx | 2 +- .../src/components/field/combobox-field.tsx | 2 +- .../src/components/field/input-field.tsx | 2 +- .../components/field/radio-group-field.tsx | 2 +- .../src/components/field/select-field.tsx | 2 +- .../src/components/field/switch-field.tsx | 2 +- .../src/components/field/text-area-field.tsx | 2 +- .../propel/src/ui/field/field-helper-text.tsx | 26 ------------------- 10 files changed, 9 insertions(+), 35 deletions(-) delete mode 100644 packages/propel/src/ui/field/field-helper-text.tsx diff --git a/packages/propel/src/components/field/autocomplete-field.tsx b/packages/propel/src/components/field/autocomplete-field.tsx index 32d63935..c5927054 100644 --- a/packages/propel/src/components/field/autocomplete-field.tsx +++ b/packages/propel/src/components/field/autocomplete-field.tsx @@ -16,9 +16,9 @@ import { } from "../../ui/autocomplete/index"; import { Field } from "../../ui/field/field"; import { FieldDescription } from "../../ui/field/field-description"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldLabel } from "../../ui/field/field-label"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldHelperText } from "./field-helper-text"; export type AutocompleteFieldProps = Omit, "children" | "items"> & { /** Supporting text shown below the input. */ diff --git a/packages/propel/src/components/field/checkbox-field.tsx b/packages/propel/src/components/field/checkbox-field.tsx index a18ed706..05c35ade 100644 --- a/packages/propel/src/components/field/checkbox-field.tsx +++ b/packages/propel/src/components/field/checkbox-field.tsx @@ -6,10 +6,10 @@ import { type CheckboxFieldControlProps, } from "../../ui/field/checkbox-field-control"; import { Field } from "../../ui/field/field"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldItem } from "../../ui/field/field-item"; import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldHelperText } from "./field-helper-text"; export type CheckboxFieldProps = Omit< CheckboxFieldControlProps, diff --git a/packages/propel/src/components/field/checkbox-group-field.tsx b/packages/propel/src/components/field/checkbox-group-field.tsx index 3f3dc8ca..59026dd1 100644 --- a/packages/propel/src/components/field/checkbox-group-field.tsx +++ b/packages/propel/src/components/field/checkbox-group-field.tsx @@ -3,10 +3,10 @@ import type * as React from "react"; import { CheckboxGroup, type CheckboxGroupProps } from "../../ui/checkbox-group/index"; import { Field } from "../../ui/field/field"; import { FieldDescription } from "../../ui/field/field-description"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldOptionMagnitudeProvider } from "../../ui/field/field-option-magnitude-provider"; import type { FieldMagnitude } from "../../ui/field/variants"; import { Fieldset, FieldsetLegend } from "../../ui/fieldset/index"; +import { FieldHelperText } from "./field-helper-text"; export type CheckboxGroupFieldProps = Omit & { /** Checkbox option rows, usually `CheckboxGroupFieldOption`. */ diff --git a/packages/propel/src/components/field/combobox-field.tsx b/packages/propel/src/components/field/combobox-field.tsx index 993137d9..a6772a52 100644 --- a/packages/propel/src/components/field/combobox-field.tsx +++ b/packages/propel/src/components/field/combobox-field.tsx @@ -17,9 +17,9 @@ import { } from "../../ui/combobox/index"; import { Field } from "../../ui/field/field"; import { FieldDescription } from "../../ui/field/field-description"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldLabel } from "../../ui/field/field-label"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldHelperText } from "./field-helper-text"; export type ComboboxFieldProps = Omit, "children" | "items"> & { /** Supporting text shown below the input. */ diff --git a/packages/propel/src/components/field/input-field.tsx b/packages/propel/src/components/field/input-field.tsx index 2f85b7fd..6d351cea 100644 --- a/packages/propel/src/components/field/input-field.tsx +++ b/packages/propel/src/components/field/input-field.tsx @@ -1,6 +1,5 @@ import type * as React from "react"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldLabelGroup } from "../../ui/field/field-label-group"; import { InputFieldBox } from "../../ui/field/input-field-box"; import { InputFieldContent } from "../../ui/field/input-field-content"; @@ -9,6 +8,7 @@ import { InputFieldIconSlot } from "../../ui/field/input-field-icon-slot"; import { InputFieldRoot } from "../../ui/field/input-field-root"; import type { InputMagnitude, InputTone } from "../../ui/field/variants"; import type { InputProps } from "../../ui/input/index"; +import { FieldHelperText } from "./field-helper-text"; export type { InputMagnitude, InputTone } from "../../ui/field/variants"; diff --git a/packages/propel/src/components/field/radio-group-field.tsx b/packages/propel/src/components/field/radio-group-field.tsx index 9b3595e4..9db7c9f2 100644 --- a/packages/propel/src/components/field/radio-group-field.tsx +++ b/packages/propel/src/components/field/radio-group-field.tsx @@ -2,11 +2,11 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; import { FieldDescription } from "../../ui/field/field-description"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldOptionMagnitudeProvider } from "../../ui/field/field-option-magnitude-provider"; import type { FieldMagnitude } from "../../ui/field/variants"; import { Fieldset, FieldsetLegend } from "../../ui/fieldset/index"; import { RadioGroup, type RadioGroupProps } from "../../ui/radio/index"; +import { FieldHelperText } from "./field-helper-text"; export type RadioGroupFieldProps = Omit< RadioGroupProps, diff --git a/packages/propel/src/components/field/select-field.tsx b/packages/propel/src/components/field/select-field.tsx index b3e8f1be..e91cbc40 100644 --- a/packages/propel/src/components/field/select-field.tsx +++ b/packages/propel/src/components/field/select-field.tsx @@ -2,7 +2,6 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; import { FieldDescription } from "../../ui/field/field-description"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import type { FieldMagnitude } from "../../ui/field/variants"; import { Select, @@ -19,6 +18,7 @@ import { SelectTrigger, SelectValue, } from "../../ui/select/index"; +import { FieldHelperText } from "./field-helper-text"; export type SelectFieldOption = { label: React.ReactNode; diff --git a/packages/propel/src/components/field/switch-field.tsx b/packages/propel/src/components/field/switch-field.tsx index b9c5dc8e..ae8ff498 100644 --- a/packages/propel/src/components/field/switch-field.tsx +++ b/packages/propel/src/components/field/switch-field.tsx @@ -1,10 +1,10 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldItem } from "../../ui/field/field-item"; import { FieldItemContent } from "../../ui/field/field-item-content"; import type { FieldMagnitude } from "../../ui/field/variants"; +import { FieldHelperText } from "./field-helper-text"; import { SwitchFieldControl, type SwitchFieldControlMagnitude, diff --git a/packages/propel/src/components/field/text-area-field.tsx b/packages/propel/src/components/field/text-area-field.tsx index 374a25f1..c07c2068 100644 --- a/packages/propel/src/components/field/text-area-field.tsx +++ b/packages/propel/src/components/field/text-area-field.tsx @@ -1,7 +1,6 @@ import type * as React from "react"; import { Field } from "../../ui/field/field"; -import { FieldHelperText } from "../../ui/field/field-helper-text"; import { FieldLabelGroup } from "../../ui/field/field-label-group"; import { InputFieldContent } from "../../ui/field/input-field-content"; import { TextAreaFieldBox } from "../../ui/field/text-area-field-box"; @@ -10,6 +9,7 @@ import { type TextAreaFieldControlProps, } from "../../ui/field/text-area-field-control"; import type { InputMagnitude, InputTone } from "../../ui/field/variants"; +import { FieldHelperText } from "./field-helper-text"; export type TextAreaFieldProps = Omit & { /** Magnitude scale. `md` | `lg` | `xl`. */ diff --git a/packages/propel/src/ui/field/field-helper-text.tsx b/packages/propel/src/ui/field/field-helper-text.tsx deleted file mode 100644 index fc12b674..00000000 --- a/packages/propel/src/ui/field/field-helper-text.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type * as React from "react"; - -import { FieldDescription } from "./field-description"; -import { FieldError } from "./field-error"; -import type { InputMagnitude } from "./variants"; - -export function FieldHelperText({ - magnitude, - hint, - error, -}: { - magnitude: InputMagnitude; - hint?: React.ReactNode; - error?: React.ReactNode; -}) { - return ( - <> - - {error} - - {error == null && hint != null ? ( - {hint} - ) : null} - - ); -} From cc8edcc596346b1fd11d170f736866d209dcea42 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 16:16:10 +0700 Subject: [PATCH 6/6] field: add the moved FieldHelperText file (was untracked, dropped by commit -a) --- .../components/field/field-helper-text.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/propel/src/components/field/field-helper-text.tsx diff --git a/packages/propel/src/components/field/field-helper-text.tsx b/packages/propel/src/components/field/field-helper-text.tsx new file mode 100644 index 00000000..e61721d5 --- /dev/null +++ b/packages/propel/src/components/field/field-helper-text.tsx @@ -0,0 +1,31 @@ +import type * as React from "react"; + +import { FieldDescription } from "../../ui/field/field-description"; +import { FieldError } from "../../ui/field/field-error"; +import type { InputMagnitude } from "../../ui/field/variants"; + +/** + * The error-or-hint line beneath a field: shows the `FieldError` when there is an error, otherwise + * the `FieldDescription` hint. A components-tier composition of the two ui parts (each a single + * element); shared by the ready-made field types so the error-XOR-hint rule lives in one place. + */ +export function FieldHelperText({ + magnitude, + hint, + error, +}: { + magnitude: InputMagnitude; + hint?: React.ReactNode; + error?: React.ReactNode; +}) { + return ( + <> + + {error} + + {error == null && hint != null ? ( + {hint} + ) : null} + + ); +}