Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export function CheckboxGroupField({
}: CheckboxGroupFieldProps) {
return (
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<Fieldset render={<CheckboxGroup density={density} disabled={disabled} {...groupProps} />}>
<Fieldset
bordered={false}
render={<CheckboxGroup density={density} disabled={disabled} {...groupProps} />}
>
<FieldsetLegend magnitude={magnitude}>{label}</FieldsetLegend>
{description != null ? (
<FieldDescription magnitude={magnitude}>{description}</FieldDescription>
Expand Down
1 change: 1 addition & 0 deletions packages/propel/src/components/field/radio-group-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function RadioGroupField<Value = string>({
return (
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<Fieldset
bordered={false}
render={
<RadioGroup<Value>
density={density}
Expand Down
49 changes: 43 additions & 6 deletions packages/propel/src/components/fieldset/fieldset.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div className="w-80">
<Story />
</div>
),
],
parameters: {
design: {
type: "figma",
Expand All @@ -25,7 +32,7 @@ export const Default: Story = {
args: {
legend: "Billing details",
children: (
<div className="flex w-80 flex-col gap-4">
<>
<InputField
magnitude="md"
tone="neutral"
Expand All @@ -42,7 +49,39 @@ export const Default: Story = {
label="Tax ID"
placeholder="US-123"
/>
</div>
</>
),
},
};

/**
* 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: (
<>
<InputField
magnitude="md"
tone="neutral"
orientation="vertical"
name="company"
label="Company"
placeholder="Acme Inc."
/>
<InputField
magnitude="md"
tone="neutral"
orientation="vertical"
name="taxId"
label="Tax ID"
placeholder="US-123"
/>
</>
),
},
};
Expand All @@ -53,9 +92,7 @@ export const GroupSemantics: Story = {
args: {
legend: "Shipping address",
children: (
<div className="w-80">
<InputField magnitude="md" tone="neutral" orientation="vertical" name="city" label="City" />
</div>
<InputField magnitude="md" tone="neutral" orientation="vertical" name="city" label="City" />
),
},
play: async ({ canvas }) => {
Expand Down
19 changes: 15 additions & 4 deletions packages/propel/src/components/fieldset/fieldset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 (
<FieldsetRoot {...props}>
<FieldsetLegend magnitude={legendMagnitude}>{legend}</FieldsetLegend>
{children}
{description != null ? <FieldsetDescription>{description}</FieldsetDescription> : null}
<FieldsetBody>{children}</FieldsetBody>
</FieldsetRoot>
);
}
11 changes: 9 additions & 2 deletions packages/propel/src/components/fieldset/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
19 changes: 7 additions & 12 deletions packages/propel/src/components/radio/radio.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -27,7 +27,8 @@ export const Default: Story = {
args: { density: "comfortable", defaultValue: "low" },
render: (args) => (
<Field name="priority">
<Fieldset legend="Priority" legendMagnitude="md" render={<RadioGroup {...args} />}>
<Fieldset bordered={false} render={<RadioGroup {...args} />}>
<FieldsetLegend magnitude="md">Priority</FieldsetLegend>
<RadioGroupFieldOption magnitude="md" value="low" label="Low" />
<RadioGroupFieldOption magnitude="md" value="medium" label="Medium" />
<RadioGroupFieldOption magnitude="md" value="high" label="High" />
Expand Down Expand Up @@ -65,21 +66,15 @@ export const Density: Story = {
render: () => (
<div className="flex items-start gap-10">
<Field name="comfortableDensity">
<Fieldset
legend="Comfortable density"
legendMagnitude="md"
render={<RadioGroup density="comfortable" defaultValue="low" />}
>
<Fieldset bordered={false} render={<RadioGroup density="comfortable" defaultValue="low" />}>
<FieldsetLegend magnitude="md">Comfortable density</FieldsetLegend>
<RadioGroupFieldOption magnitude="md" value="low" label="Comfortable" />
<RadioGroupFieldOption magnitude="md" value="medium" label="8px gap" />
</Fieldset>
</Field>
<Field name="compactDensity">
<Fieldset
legend="Compact density"
legendMagnitude="md"
render={<RadioGroup density="compact" defaultValue="low" />}
>
<Fieldset bordered={false} render={<RadioGroup density="compact" defaultValue="low" />}>
<FieldsetLegend magnitude="md">Compact density</FieldsetLegend>
<RadioGroupFieldOption magnitude="md" value="low" label="Compact" />
<RadioGroupFieldOption magnitude="md" value="medium" label="Flush rows" />
</Fieldset>
Expand Down
13 changes: 13 additions & 0 deletions packages/propel/src/ui/fieldset/fieldset-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type * as React from "react";

import { fieldsetBodyVariants } from "./variants";

export type FieldsetBodyProps = Omit<React.ComponentPropsWithRef<"div">, "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 <div className={fieldsetBodyVariants()} {...props} />;
}
13 changes: 13 additions & 0 deletions packages/propel/src/ui/fieldset/fieldset-description.tsx
Original file line number Diff line number Diff line change
@@ -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 <p className={fieldsetDescriptionVariants()} {...props} />;
}
44 changes: 34 additions & 10 deletions packages/propel/src/ui/fieldset/fieldset.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div className="w-80">
<Story />
</div>
),
],
} satisfies Meta<typeof Fieldset>;

export default meta;
Expand All @@ -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: () => (
<Fieldset>
<Fieldset bordered={false}>
<FieldsetLegend magnitude="md">Billing details</FieldsetLegend>
<div className="flex w-80 flex-col gap-4">
<FieldsetBody>
<TextField name="company" label="Company" />
<TextField name="taxId" label="Tax ID" />
</div>
</FieldsetBody>
</Fieldset>
),
};

/** A bordered fieldset draws a visible boundary around the grouped controls. */
export const Bordered: Story = {
args: { bordered: true },
render: () => (
<Fieldset bordered={true}>
<FieldsetLegend magnitude="md">Billing details</FieldsetLegend>
<FieldsetDescription>Enter your billing information below.</FieldsetDescription>
<FieldsetBody>
<TextField name="company" label="Company" />
<TextField name="taxId" label="Tax ID" />
</FieldsetBody>
</Fieldset>
),
};

export const GroupSemantics: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { bordered: false },
render: () => (
<Fieldset>
<Fieldset bordered={false}>
<FieldsetLegend magnitude="md">Shipping address</FieldsetLegend>
<div className="w-80">
<FieldsetBody>
<TextField name="city" label="City" />
</div>
</FieldsetBody>
</Fieldset>
),
play: async ({ canvas }) => {
Expand Down
12 changes: 9 additions & 3 deletions packages/propel/src/ui/fieldset/fieldset.tsx
Original file line number Diff line number Diff line change
@@ -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<BaseFieldset.Root.Props, "className" | "style">;
type FieldsetVariantProps = VariantProps<typeof fieldsetVariants>;

export type FieldsetProps = Omit<BaseFieldset.Root.Props, "className" | "style"> & {
/** Whether a visible border wraps the group. */
bordered: NonNullable<FieldsetVariantProps["bordered"]>;
};

/** Groups a legend with related controls. */
export function Fieldset(props: FieldsetProps) {
return <BaseFieldset.Root className={fieldsetVariants()} {...props} />;
export function Fieldset({ bordered, ...props }: FieldsetProps) {
return <BaseFieldset.Root className={fieldsetVariants({ bordered })} {...props} />;
}
2 changes: 2 additions & 0 deletions packages/propel/src/ui/fieldset/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
21 changes: 20 additions & 1 deletion packages/propel/src/ui/fieldset/variants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { cva } from "class-variance-authority";

export const fieldsetVariants = cva("flex min-w-0 flex-col gap-3");
// The `<fieldset>` 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: {
Expand All @@ -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");