diff --git a/packages/propel/src/components/form/form.stories.tsx b/packages/propel/src/components/form/form.stories.tsx index c1c49987..2c2ed885 100644 --- a/packages/propel/src/components/form/form.stories.tsx +++ b/packages/propel/src/components/form/form.stories.tsx @@ -12,12 +12,14 @@ import { SelectField, SwitchField, } from "../field/index"; -import { Form } from "./index"; +import { Form, FormActions, FormBody } from "./index"; -// Components-tier story: the ready-made `Form` composed with same-tier field controls. +// Components-tier story: the ready-made `Form` composed with same-tier field controls, +// using the `FormBody` (field stack) and `FormActions` (bottom actions bar) parts. const meta = { title: "Components/Form", component: Form, + subcomponents: { FormBody, FormActions }, } satisfies Meta; export default meta; @@ -59,64 +61,68 @@ function ExampleForm({ onFormSubmit }: ExampleFormProps) { onFormSubmit?.(values); }} > - { - setHomepage(nextHomepage); - if (homepageError) { - setErrors({}); - } - }} - error={Array.isArray(homepageError) ? homepageError.join(" ") : homepageError} - /> - - - - - - - - - - - - + + { + setHomepage(nextHomepage); + if (homepageError) { + setErrors({}); + } + }} + error={Array.isArray(homepageError) ? homepageError.join(" ") : homepageError} + /> + + + + + + + + + + + + + + + ); } diff --git a/packages/propel/src/ui/form/form-actions.tsx b/packages/propel/src/ui/form/form-actions.tsx new file mode 100644 index 00000000..2fd7f117 --- /dev/null +++ b/packages/propel/src/ui/form/form-actions.tsx @@ -0,0 +1,18 @@ +import type { VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { formActionsVariants } from "./variants"; + +type FormActionsVariantProps = Required>; + +export type FormActionsProps = Omit, "className" | "style"> & + FormActionsVariantProps; + +/** + * The actions bar at the bottom of a form (submit plus any secondary actions). Its position is + * always the same; the `variant` axis selects right-aligned inline buttons (`"inline"`) or + * full-width stretched buttons (`"stretch"`). + */ +export function FormActions({ variant, ...props }: FormActionsProps) { + return
; +} diff --git a/packages/propel/src/ui/form/form-body.tsx b/packages/propel/src/ui/form/form-body.tsx new file mode 100644 index 00000000..7e506693 --- /dev/null +++ b/packages/propel/src/ui/form/form-body.tsx @@ -0,0 +1,18 @@ +import type { VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { formBodyVariants } from "./variants"; + +type FormBodyVariantProps = Required>; + +export type FormBodyProps = Omit, "className" | "style"> & + FormBodyVariantProps; + +/** + * The field-stacking region of a form. Fields stack with a consistent gap (always the same); the + * `layout` axis selects single-column (`"single"`) or a wrapping multi-column (`"multi"`) + * arrangement. + */ +export function FormBody({ layout, ...props }: FormBodyProps) { + return
; +} diff --git a/packages/propel/src/ui/form/form.stories.tsx b/packages/propel/src/ui/form/form.stories.tsx index b801b88f..f3e395cb 100644 --- a/packages/propel/src/ui/form/form.stories.tsx +++ b/packages/propel/src/ui/form/form.stories.tsx @@ -3,34 +3,46 @@ import { expect, fn, userEvent } from "storybook/test"; import { Button } from "../button/index"; import { Field, FieldError, FieldLabel, InputFieldControl } from "../field/index"; -import { Form } from "./index"; +import { Form, FormActions, FormBody } from "./index"; -// UI-tier story: composes the atomic `Form` with raw `Field` primitives (label + -// control + error). The ready-made labeled-field conveniences (InputField, -// SelectField, …) live in the components tier — see the `Components/Form` story. +// UI-tier story: composes the atomic `Form` parts — `FormBody` (the field stack, with +// its single/multi-column `layout` axis) and `FormActions` (the bottom actions bar) — +// with raw `Field` primitives (label + control + error). The ready-made labeled-field +// conveniences (InputField, SelectField, …) live in the components tier — see the +// `Components/Form` story. const meta = { title: "UI/Form", component: Form, + subcomponents: { FormBody, FormActions }, parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } satisfies Meta; export default meta; type Story = StoryObj; -/** A form assembled from `Form` + `Field`/`FieldLabel`/`InputFieldControl`/`FieldError`. */ +/** A form assembled from `Form` › `FormBody` (`Field`…) + `FormActions`. */ export const Default: Story = { render: (args) => (
-
+ Email + + -
+
), }; @@ -40,16 +52,18 @@ export const Validation: Story = { args: { onSubmit: fn((e) => e.preventDefault()) }, render: (args) => (
-
+ Email + + -
+
), play: async ({ canvas }) => { diff --git a/packages/propel/src/ui/form/form.tsx b/packages/propel/src/ui/form/form.tsx index 3949001b..74bde0d1 100644 --- a/packages/propel/src/ui/form/form.tsx +++ b/packages/propel/src/ui/form/form.tsx @@ -8,7 +8,11 @@ export type FormProps = Record; -/** Native form element with Base UI consolidated error handling. */ +/** + * Native form element with Base UI consolidated error handling. Lays out its regions (`FormBody`, + * `FormActions`) with a consistent vertical rhythm; field spacing and the actions-bar treatment + * live on those parts. + */ export function Form = Record>( props: FormProps, ) { diff --git a/packages/propel/src/ui/form/index.tsx b/packages/propel/src/ui/form/index.tsx index 9111f766..087e7116 100644 --- a/packages/propel/src/ui/form/index.tsx +++ b/packages/propel/src/ui/form/index.tsx @@ -1 +1,3 @@ export { Form, type FormProps } from "./form"; +export { FormActions, type FormActionsProps } from "./form-actions"; +export { FormBody, type FormBodyProps } from "./form-body"; diff --git a/packages/propel/src/ui/form/variants.ts b/packages/propel/src/ui/form/variants.ts index 204ff62d..756d023a 100644 --- a/packages/propel/src/ui/form/variants.ts +++ b/packages/propel/src/ui/form/variants.ts @@ -1,3 +1,38 @@ import { cva } from "class-variance-authority"; -export const formVariants = cva("flex flex-col gap-4"); +// Form is a layout primitive. Per the Figma spec (issue #130) the things that are +// "always the same" are baked here — vertical rhythm between regions, the field +// stack's uniform gap, the bottom-aligned actions bar — while the one adjustable +// axis (single- vs multi-column field arrangement) is a required `layout` variant +// on `FormBody`, and the actions bar's right-aligned-vs-full-width treatment is a +// required `variant` on `FormActions`. No part takes a `className`. + +// The form root only governs the vertical rhythm BETWEEN regions (the field body and +// the actions bar). Field spacing and action layout belong to those regions, not here. +export const formVariants = cva("flex flex-col gap-6"); + +// The field-stacking region. "Vertical field stacking with a consistent gap" is the +// always-the-same baseline; the adjustable axis from the spec is the column layout: +// - `"single"` — one column, fields stack vertically. +// - `"multi"` — a wrapping row for side-by-side field pairs (multi-column). +export const formBodyVariants = cva("flex gap-4", { + variants: { + layout: { + single: "flex-col", + multi: "flex-row flex-wrap", + }, + }, +}); + +// The actions bar at the bottom of the form. Position (bottom of the form) is always +// the same; the adjustable treatment from the spec is right-aligned vs full-width: +// - `"inline"` — buttons sit at the inline-end (right-aligned, mirrored under RTL). +// - `"stretch"` — buttons stretch to fill the row (full-width actions). +export const formActionsVariants = cva("flex gap-3", { + variants: { + variant: { + inline: "flex-row justify-end", + stretch: "flex-col [&>*]:w-full", + }, + }, +});