Skip to content
Merged
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
126 changes: 66 additions & 60 deletions packages/propel/src/components/form/form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Form>;

export default meta;
Expand Down Expand Up @@ -59,64 +61,68 @@ function ExampleForm({ onFormSubmit }: ExampleFormProps) {
onFormSubmit?.(values);
}}
>
<InputField
magnitude="md"
tone={homepageError ? "danger" : "neutral"}
orientation="vertical"
name="homepage"
label="Homepage"
type="url"
required
placeholder="https://plane.so"
value={homepage}
onValueChange={(nextHomepage) => {
setHomepage(nextHomepage);
if (homepageError) {
setErrors({});
}
}}
error={Array.isArray(homepageError) ? homepageError.join(" ") : homepageError}
/>
<SelectField
name="serverType"
label="Server type"
magnitude="md"
options={SERVER_TYPES}
defaultValue="general"
required
description="Select the resource profile for this server."
/>
<RadioGroupField
name="storageType"
label="Storage type"
magnitude="md"
density="comfortable"
defaultValue="ssd"
required
>
<RadioGroupFieldOption value="ssd" label="SSD" />
<RadioGroupFieldOption value="hdd" label="HDD" />
</RadioGroupField>
<CheckboxGroupField
name="allowedProtocols"
label="Allowed protocols"
magnitude="md"
density="comfortable"
defaultValue={["https"]}
>
<CheckboxGroupFieldOption tone="neutral" value="http" label="HTTP" />
<CheckboxGroupFieldOption tone="neutral" value="https" label="HTTPS" />
<CheckboxGroupFieldOption tone="neutral" value="ssh" label="SSH" />
</CheckboxGroupField>
<SwitchField
name="restartOnFailure"
label="Restart on failure"
magnitude="md"
defaultChecked
/>
<Button type="submit" variant="secondary" tone="neutral" magnitude="md">
Submit
</Button>
<FormBody layout="single">
<InputField
magnitude="md"
tone={homepageError ? "danger" : "neutral"}
orientation="vertical"
name="homepage"
label="Homepage"
type="url"
required
placeholder="https://plane.so"
value={homepage}
onValueChange={(nextHomepage) => {
setHomepage(nextHomepage);
if (homepageError) {
setErrors({});
}
}}
error={Array.isArray(homepageError) ? homepageError.join(" ") : homepageError}
/>
<SelectField
name="serverType"
label="Server type"
magnitude="md"
options={SERVER_TYPES}
defaultValue="general"
required
description="Select the resource profile for this server."
/>
<RadioGroupField
name="storageType"
label="Storage type"
magnitude="md"
density="comfortable"
defaultValue="ssd"
required
>
<RadioGroupFieldOption value="ssd" label="SSD" />
<RadioGroupFieldOption value="hdd" label="HDD" />
</RadioGroupField>
<CheckboxGroupField
name="allowedProtocols"
label="Allowed protocols"
magnitude="md"
density="comfortable"
defaultValue={["https"]}
>
<CheckboxGroupFieldOption tone="neutral" value="http" label="HTTP" />
<CheckboxGroupFieldOption tone="neutral" value="https" label="HTTPS" />
<CheckboxGroupFieldOption tone="neutral" value="ssh" label="SSH" />
</CheckboxGroupField>
<SwitchField
name="restartOnFailure"
label="Restart on failure"
magnitude="md"
defaultChecked
/>
</FormBody>
<FormActions variant="inline">
<Button type="submit" variant="secondary" tone="neutral" magnitude="md">
Submit
</Button>
</FormActions>
</Form>
);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/propel/src/ui/form/form-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { VariantProps } from "class-variance-authority";
import type * as React from "react";

import { formActionsVariants } from "./variants";

type FormActionsVariantProps = Required<VariantProps<typeof formActionsVariants>>;

export type FormActionsProps = Omit<React.ComponentPropsWithoutRef<"div">, "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 <div className={formActionsVariants({ variant })} {...props} />;
}
18 changes: 18 additions & 0 deletions packages/propel/src/ui/form/form-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { VariantProps } from "class-variance-authority";
import type * as React from "react";

import { formBodyVariants } from "./variants";

type FormBodyVariantProps = Required<VariantProps<typeof formBodyVariants>>;

export type FormBodyProps = Omit<React.ComponentPropsWithoutRef<"div">, "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 <div className={formBodyVariants({ layout })} {...props} />;
}
32 changes: 23 additions & 9 deletions packages/propel/src/ui/form/form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div className="w-80">
<Story />
</div>
),
],
} satisfies Meta<typeof Form>;

export default meta;
type Story = StoryObj<typeof meta>;

/** A form assembled from `Form` + `Field`/`FieldLabel`/`InputFieldControl`/`FieldError`. */
/** A form assembled from `Form` › `FormBody` (`Field`…) + `FormActions`. */
export const Default: Story = {
render: (args) => (
<Form {...args}>
<div className="flex w-80 flex-col gap-4">
<FormBody layout="single">
<Field name="email">
<FieldLabel magnitude="md">Email</FieldLabel>
<InputFieldControl magnitude="md" type="email" required placeholder="you@example.com" />
<FieldError magnitude="md" />
</Field>
</FormBody>
<FormActions variant="inline">
<Button type="submit" variant="primary" tone="neutral" magnitude="md">
Submit
</Button>
</div>
</FormActions>
</Form>
),
};
Expand All @@ -40,16 +52,18 @@ export const Validation: Story = {
args: { onSubmit: fn((e) => e.preventDefault()) },
render: (args) => (
<Form {...args}>
<div className="flex w-80 flex-col gap-4">
<FormBody layout="single">
<Field name="email">
<FieldLabel magnitude="md">Email</FieldLabel>
<InputFieldControl magnitude="md" type="email" required placeholder="you@example.com" />
<FieldError magnitude="md" />
</Field>
</FormBody>
<FormActions variant="inline">
<Button type="submit" variant="primary" tone="neutral" magnitude="md">
Submit
</Button>
</div>
</FormActions>
</Form>
),
play: async ({ canvas }) => {
Expand Down
6 changes: 5 additions & 1 deletion packages/propel/src/ui/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export type FormProps<FormValues extends Record<string, unknown> = Record<string
"className" | "style"
>;

/** 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<FormValues extends Record<string, unknown> = Record<string, unknown>>(
props: FormProps<FormValues>,
) {
Expand Down
2 changes: 2 additions & 0 deletions packages/propel/src/ui/form/index.tsx
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { Form, type FormProps } from "./form";
export { FormActions, type FormActionsProps } from "./form-actions";
export { FormBody, type FormBodyProps } from "./form-body";
37 changes: 36 additions & 1 deletion packages/propel/src/ui/form/variants.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});