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/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} + + ); +} 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 3110e3a1..c07c2068 100644 --- a/packages/propel/src/components/field/text-area-field.tsx +++ b/packages/propel/src/components/field/text-area-field.tsx @@ -1,14 +1,15 @@ 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, 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`. */ @@ -52,7 +53,7 @@ export function TextAreaField({ description={description} orientation="vertical" /> -
+ -
+ ); } 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} - - ); -} 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-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-label-required-marker.tsx b/packages/propel/src/ui/field/field-label-required-marker.tsx new file mode 100644 index 00000000..9c344585 --- /dev/null +++ b/packages/propel/src/ui/field/field-label-required-marker.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { fieldLabelRequiredMarkerVariants } from "./variants"; + +export type FieldLabelRequiredMarkerProps = Omit< + React.ComponentPropsWithoutRef<"span">, + "className" | "style" +>; + +/** + * 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} + + ); +} diff --git a/packages/propel/src/ui/field/field-label.tsx b/packages/propel/src/ui/field/field-label.tsx index d36d52f6..08380b49 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..c97df0b1 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) => (
@@ -24,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: () => ( @@ -42,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: () => ( diff --git a/packages/propel/src/ui/field/field.tsx b/packages/propel/src/ui/field/field.tsx index dce17351..63ddb366 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 { fieldVariants } 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/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/input-field-icon-slot.tsx b/packages/propel/src/ui/field/input-field-icon-slot.tsx index daa453f4..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 { iconSlotClass } 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 7be65f89..f62ecf23 100644 --- a/packages/propel/src/ui/field/variants.ts +++ b/packages/propel/src/ui/field/variants.ts @@ -1,7 +1,11 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; +import { nodeSlotClass } from "../../internal/node-slot"; + export type { InputMagnitude } from "../input/variants"; +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"), { @@ -28,7 +32,14 @@ type FieldLabelVariantProps = VariantProps; export type FieldMagnitude = NonNullable; -const fieldHelperTextVariants = cva("", { +// 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: { md: "text-12", @@ -38,41 +49,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 +77,61 @@ 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", +// 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 inputFieldIconSlotVariants = cva( + cx(nodeSlotClass, "text-icon-secondary [--node-size:1rem]"), ); export const fieldItemVariants = cva( @@ -134,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",