diff --git a/packages/propel/src/components/field/checkbox-group-field.tsx b/packages/propel/src/components/field/checkbox-group-field.tsx index 3f3dc8ca..501f3035 100644 --- a/packages/propel/src/components/field/checkbox-group-field.tsx +++ b/packages/propel/src/components/field/checkbox-group-field.tsx @@ -42,7 +42,10 @@ export function CheckboxGroupField({ }: CheckboxGroupFieldProps) { return ( -
}> +
} + > {label} {description != null ? ( {description} diff --git a/packages/propel/src/components/field/radio-group-field.tsx b/packages/propel/src/components/field/radio-group-field.tsx index 9b3595e4..d780fc0e 100644 --- a/packages/propel/src/components/field/radio-group-field.tsx +++ b/packages/propel/src/components/field/radio-group-field.tsx @@ -47,6 +47,7 @@ export function RadioGroupField({ return (
density={density} diff --git a/packages/propel/src/components/fieldset/fieldset.stories.tsx b/packages/propel/src/components/fieldset/fieldset.stories.tsx index a02edb38..f484970e 100644 --- a/packages/propel/src/components/fieldset/fieldset.stories.tsx +++ b/packages/propel/src/components/fieldset/fieldset.stories.tsx @@ -8,7 +8,14 @@ import { Fieldset } from "./index"; const meta = { title: "Components/Fieldset", component: Fieldset, - args: { legendMagnitude: "md" }, + args: { legendMagnitude: "md", bordered: false }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], parameters: { design: { type: "figma", @@ -25,7 +32,7 @@ export const Default: Story = { args: { legend: "Billing details", children: ( -
+ <> -
+ + ), + }, +}; + +/** + * A bordered fieldset draws a visible boundary around the group, useful when the group sits among + * other content. + */ +export const Bordered: Story = { + args: { + bordered: true, + legend: "Billing details", + description: "Enter your billing information below.", + children: ( + <> + + + ), }, }; @@ -53,9 +92,7 @@ export const GroupSemantics: Story = { args: { legend: "Shipping address", children: ( -
- -
+ ), }, play: async ({ canvas }) => { diff --git a/packages/propel/src/components/fieldset/fieldset.tsx b/packages/propel/src/components/fieldset/fieldset.tsx index 45f4f6b5..2893ca72 100644 --- a/packages/propel/src/components/fieldset/fieldset.tsx +++ b/packages/propel/src/components/fieldset/fieldset.tsx @@ -3,11 +3,15 @@ import type * as React from "react"; import { Fieldset as FieldsetRoot, type FieldsetProps as FieldsetRootProps, + FieldsetBody, + FieldsetDescription, FieldsetLegend, type FieldsetLegendProps, } from "../../ui/fieldset"; export type FieldsetProps = FieldsetRootProps & { + /** Supporting text shown below the legend. */ + description?: React.ReactNode; /** The legend text labelling the group. */ legend: React.ReactNode; /** Legend text size. */ @@ -18,14 +22,21 @@ export type FieldsetProps = FieldsetRootProps & { /** * The ready-made fieldset: groups a `legend` with its related controls for the 90% case. Pass the - * legend text and `legendMagnitude`; everything else flows through to the underlying fieldset - * root. + * legend text, `legendMagnitude`, and whether the group is `bordered`; everything else flows + * through to the underlying fieldset root. */ -export function Fieldset({ legend, legendMagnitude, children, ...props }: FieldsetProps) { +export function Fieldset({ + description, + legend, + legendMagnitude, + children, + ...props +}: FieldsetProps) { return ( {legend} - {children} + {description != null ? {description} : null} + {children} ); } diff --git a/packages/propel/src/components/fieldset/index.tsx b/packages/propel/src/components/fieldset/index.tsx index 34692cb1..ccc4d721 100644 --- a/packages/propel/src/components/fieldset/index.tsx +++ b/packages/propel/src/components/fieldset/index.tsx @@ -1,3 +1,10 @@ export { Fieldset, type FieldsetProps } from "./fieldset"; -// Re-export the atomic legend so a custom fieldset is importable from this convenience. -export { FieldsetLegend, type FieldsetLegendProps } from "../../ui/fieldset"; +// Re-export the atomic parts so a custom fieldset can be built from this convenience import. +export { + FieldsetBody, + type FieldsetBodyProps, + FieldsetDescription, + type FieldsetDescriptionProps, + FieldsetLegend, + type FieldsetLegendProps, +} from "../../ui/fieldset"; diff --git a/packages/propel/src/components/radio/radio.stories.tsx b/packages/propel/src/components/radio/radio.stories.tsx index 1f066df5..26a9eefe 100644 --- a/packages/propel/src/components/radio/radio.stories.tsx +++ b/packages/propel/src/components/radio/radio.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; +import { Fieldset, FieldsetLegend } from "../../ui/fieldset/index"; import { Field, RadioGroupField, RadioGroupFieldOption } from "../field/index"; -import { Fieldset } from "../fieldset/index"; import { Radio, RadioGroup } from "./index"; const meta = { @@ -27,7 +27,8 @@ export const Default: Story = { args: { density: "comfortable", defaultValue: "low" }, render: (args) => ( -
}> +
}> + Priority @@ -65,21 +66,15 @@ export const Density: Story = { render: () => (
-
} - > +
}> + Comfortable density
-
} - > +
}> + Compact density
diff --git a/packages/propel/src/ui/fieldset/fieldset-body.tsx b/packages/propel/src/ui/fieldset/fieldset-body.tsx new file mode 100644 index 00000000..c7b9679f --- /dev/null +++ b/packages/propel/src/ui/fieldset/fieldset-body.tsx @@ -0,0 +1,13 @@ +import type * as React from "react"; + +import { fieldsetBodyVariants } from "./variants"; + +export type FieldsetBodyProps = Omit, "className" | "style">; + +/** + * The body region of a `Fieldset`: stacks the group's contained fields with the consistent vertical + * gap the design spec calls for. Sits below the legend (and optional description). + */ +export function FieldsetBody(props: FieldsetBodyProps) { + return
; +} diff --git a/packages/propel/src/ui/fieldset/fieldset-description.tsx b/packages/propel/src/ui/fieldset/fieldset-description.tsx new file mode 100644 index 00000000..00da85ee --- /dev/null +++ b/packages/propel/src/ui/fieldset/fieldset-description.tsx @@ -0,0 +1,13 @@ +import type * as React from "react"; + +import { fieldsetDescriptionVariants } from "./variants"; + +export type FieldsetDescriptionProps = Omit< + React.ComponentPropsWithRef<"p">, + "className" | "style" +>; + +/** Supporting text shown below the fieldset legend. */ +export function FieldsetDescription(props: FieldsetDescriptionProps) { + return

; +} diff --git a/packages/propel/src/ui/fieldset/fieldset.stories.tsx b/packages/propel/src/ui/fieldset/fieldset.stories.tsx index 61179fe2..e1cdfb2b 100644 --- a/packages/propel/src/ui/fieldset/fieldset.stories.tsx +++ b/packages/propel/src/ui/fieldset/fieldset.stories.tsx @@ -2,15 +2,22 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; import { Field, FieldLabel, InputFieldControl } from "../field/index"; -import { Fieldset, FieldsetLegend } from "./index"; +import { Fieldset, FieldsetBody, FieldsetDescription, FieldsetLegend } from "./index"; -// UI-tier story: composes the atomic `Fieldset` + `FieldsetLegend` with raw `Field` -// primitives. The ready-made field conveniences (InputField, RadioGroupFieldOption…) +// UI-tier story: composes the atomic `Fieldset` + `FieldsetLegend` + `FieldsetBody` with raw +// `Field` primitives. The ready-made field conveniences (InputField, RadioGroupFieldOption…) // live in the components tier — see the `Components/Fieldset` story. const meta = { title: "UI/Fieldset", component: Fieldset, - subcomponents: { FieldsetLegend }, + subcomponents: { FieldsetLegend, FieldsetDescription, FieldsetBody }, + decorators: [ + (Story) => ( +

+ +
+ ), + ], } satisfies Meta; export default meta; @@ -27,25 +34,42 @@ function TextField({ name, label }: { name: string; label: string }) { /** Fieldset groups related fields under one accessible legend. */ export const Default: Story = { + args: { bordered: false }, render: () => ( -
+
Billing details -
+ -
+ +
+ ), +}; + +/** A bordered fieldset draws a visible boundary around the grouped controls. */ +export const Bordered: Story = { + args: { bordered: true }, + render: () => ( +
+ Billing details + Enter your billing information below. + + + +
), }; export const GroupSemantics: Story = { tags: ["!dev", "!autodocs", "!manifest"], + args: { bordered: false }, render: () => ( -
+
Shipping address -
+ -
+
), play: async ({ canvas }) => { diff --git a/packages/propel/src/ui/fieldset/fieldset.tsx b/packages/propel/src/ui/fieldset/fieldset.tsx index 55d68484..70aae4b2 100644 --- a/packages/propel/src/ui/fieldset/fieldset.tsx +++ b/packages/propel/src/ui/fieldset/fieldset.tsx @@ -1,10 +1,16 @@ import { Fieldset as BaseFieldset } from "@base-ui/react/fieldset"; +import type { VariantProps } from "class-variance-authority"; import { fieldsetVariants } from "./variants"; -export type FieldsetProps = Omit; +type FieldsetVariantProps = VariantProps; + +export type FieldsetProps = Omit & { + /** Whether a visible border wraps the group. */ + bordered: NonNullable; +}; /** Groups a legend with related controls. */ -export function Fieldset(props: FieldsetProps) { - return ; +export function Fieldset({ bordered, ...props }: FieldsetProps) { + return ; } diff --git a/packages/propel/src/ui/fieldset/index.tsx b/packages/propel/src/ui/fieldset/index.tsx index a071092f..eb1b63d9 100644 --- a/packages/propel/src/ui/fieldset/index.tsx +++ b/packages/propel/src/ui/fieldset/index.tsx @@ -1,2 +1,4 @@ export { Fieldset, type FieldsetProps } from "./fieldset"; +export { FieldsetBody, type FieldsetBodyProps } from "./fieldset-body"; +export { FieldsetDescription, type FieldsetDescriptionProps } from "./fieldset-description"; export { FieldsetLegend, type FieldsetLegendProps } from "./fieldset-legend"; diff --git a/packages/propel/src/ui/fieldset/variants.ts b/packages/propel/src/ui/fieldset/variants.ts index 75d0cafb..b0c78207 100644 --- a/packages/propel/src/ui/fieldset/variants.ts +++ b/packages/propel/src/ui/fieldset/variants.ts @@ -1,6 +1,17 @@ import { cva } from "class-variance-authority"; -export const fieldsetVariants = cva("flex min-w-0 flex-col gap-3"); +// The `
` element is the group's vertical stack: legend, optional description, then the +// body of contained fields, separated by a consistent gap. Per the design spec the legend sits at +// the top and the internal spacing is always the same; only the border visibility is adjustable. +export const fieldsetVariants = cva("flex min-w-0 flex-col gap-3", { + variants: { + /** Whether a visible border wraps the group. */ + bordered: { + true: "rounded-md border-sm border-subtle p-4", + false: "", + }, + }, +}); export const fieldsetLegendVariants = cva("font-medium text-primary", { variants: { @@ -11,3 +22,11 @@ export const fieldsetLegendVariants = cva("font-medium text-primary", { }, }, }); + +/** Supporting text shown directly below the legend. */ +export const fieldsetDescriptionVariants = cva("text-13 text-tertiary"); + +// The body region holding the group's contained fields. Per the design spec the vertical gap +// between child fields is always the same, so it is baked in here — callers no longer stack fields +// with an ad-hoc wrapper. `min-w-0` lets fields shrink instead of overflowing the group. +export const fieldsetBodyVariants = cva("flex min-w-0 flex-col gap-4");