@devalok/shilp-sutra 0.41.0 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ # Dialog
2
+
3
+ Centered modal overlay for focused tasks that interrupt the page flow.
4
+
5
+ ```tsx
6
+ import {
7
+ Dialog,
8
+ DialogTrigger,
9
+ DialogContent,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogClose,
15
+ } from '@devalok/shilp-sutra/ui/dialog'
16
+ ```
17
+
18
+ ## When to use
19
+
20
+ - Confirmations (destructive actions, accept-terms).
21
+ - Short forms that need full attention (rename, share, invite).
22
+ - Critical announcements requiring acknowledgment.
23
+ - Side-anchored drawer (settings panel, mobile nav)? Use `<Sheet>`.
24
+ - Lightweight rich tooltip with no required interaction? Use `<HoverCard>`.
25
+ - Interactive panel anchored to a trigger (filter, picker)? Use `<Popover>`.
26
+
27
+ On mobile, Dialog auto-promotes to a full-screen sheet — don't rebuild this manually.
28
+
29
+ ## Compound shape
30
+
31
+ ```
32
+ Dialog (root — open, onOpenChange, defaultOpen, modal)
33
+ DialogTrigger ← uses asChild around a Button
34
+ DialogContent ← portalled, traps focus
35
+ DialogHeader
36
+ DialogTitle ← REQUIRED for a11y
37
+ DialogDescription
38
+ [body content]
39
+ DialogFooter
40
+ DialogClose ← uses asChild around a Button
41
+ ```
42
+
43
+ ## Root props (passthrough to Radix)
44
+
45
+ | Prop | Type | Notes |
46
+ |---|---|---|
47
+ | `open` | `boolean` | Controlled mode. |
48
+ | `onOpenChange` | `(open: boolean) => void` | Fires on every state change. |
49
+ | `defaultOpen` | `boolean` | Uncontrolled. |
50
+ | `modal` | `boolean` | Default `true`. Set `false` for non-blocking overlays (rare). |
51
+
52
+ Styling props live on `DialogContent`. Trigger / Close use `asChild` to merge with your Button.
53
+
54
+ ## Examples
55
+
56
+ **Confirmation:**
57
+ ```tsx
58
+ <Dialog>
59
+ <DialogTrigger asChild>
60
+ <Button variant="soft" color="error">Delete project</Button>
61
+ </DialogTrigger>
62
+ <DialogContent>
63
+ <DialogHeader>
64
+ <DialogTitle>Delete this project?</DialogTitle>
65
+ <DialogDescription>
66
+ This permanently deletes all tasks, files, and history. Cannot be undone.
67
+ </DialogDescription>
68
+ </DialogHeader>
69
+ <DialogFooter>
70
+ <DialogClose asChild>
71
+ <Button variant="soft">Cancel</Button>
72
+ </DialogClose>
73
+ <Button variant="solid" color="error" onClick={handleDelete}>
74
+ Delete
75
+ </Button>
76
+ </DialogFooter>
77
+ </DialogContent>
78
+ </Dialog>
79
+ ```
80
+
81
+ **Short form:**
82
+ ```tsx
83
+ <Dialog open={open} onOpenChange={setOpen}>
84
+ <DialogContent>
85
+ <DialogHeader>
86
+ <DialogTitle>Rename workspace</DialogTitle>
87
+ </DialogHeader>
88
+ <Stack gap="ds-04">
89
+ <FormField>
90
+ <Label htmlFor="ws-name">Name</Label>
91
+ <Input id="ws-name" value={name} onChange={(e) => setName(e.target.value)} />
92
+ </FormField>
93
+ </Stack>
94
+ <DialogFooter>
95
+ <DialogClose asChild>
96
+ <Button variant="soft">Cancel</Button>
97
+ </DialogClose>
98
+ <Button onClickAsync={async () => { await api.rename(name); setOpen(false) }}>
99
+ Save
100
+ </Button>
101
+ </DialogFooter>
102
+ </DialogContent>
103
+ </Dialog>
104
+ ```
105
+
106
+ **Visually-hidden title (a11y compliance without showing the heading):**
107
+ ```tsx
108
+ <DialogContent>
109
+ <VisuallyHidden>
110
+ <DialogTitle>Image preview</DialogTitle>
111
+ </VisuallyHidden>
112
+ <img src={src} alt={alt} />
113
+ </DialogContent>
114
+ ```
115
+
116
+ **Programmatic close from a deep child:**
117
+ ```tsx
118
+ <DialogContent>
119
+ <Stack>
120
+ <FancyForm onSubmit={handleSubmit} />
121
+ <DialogClose asChild>
122
+ <Button variant="ghost">Done</Button>
123
+ </DialogClose>
124
+ </Stack>
125
+ </DialogContent>
126
+ ```
127
+
128
+ **Nested Popover inside Dialog:**
129
+ ```tsx
130
+ <Dialog>
131
+ <DialogContent>
132
+ <Popover>
133
+ <PopoverTrigger asChild>
134
+ <Button variant="soft">Pick a date</Button>
135
+ </PopoverTrigger>
136
+ <PopoverContent>
137
+ <Calendar value={date} onChange={setDate} />
138
+ </PopoverContent>
139
+ </Popover>
140
+ </DialogContent>
141
+ </Dialog>
142
+ ```
143
+
144
+ Popover uses `z-popover` (1400) which is above `z-dialog` — nesting stacks correctly without z-index fights.
145
+
146
+ ## Mobile behavior
147
+
148
+ On viewports below the `md` breakpoint, DialogContent auto-fullscreens with a top-anchored close button. Layouts that work on desktop in centered modal usually work on mobile in fullscreen as-is — but verify long forms scroll inside the dialog body, not the page.
149
+
150
+ ## Composability
151
+
152
+ - **Portal rendering:** DialogContent portals to `document.body`. CSS `overflow: hidden`, `transform`, or stacking contexts on ancestors don't clip it.
153
+ - **Focus management:** Focus traps inside while open. First focusable element receives focus. Returns to trigger on close.
154
+ - **Imperative close:** Wrap your own button with `<DialogClose asChild>` — no prop drilling.
155
+ - **z-index:** `z-dialog`. Popovers, DropdownMenus, Tooltips inside stack above using `z-popover`.
156
+
157
+ See `foundations/surfaces.md` for the overlay surface tokens, `foundations/motion.md` for the spring entry animation.
158
+
159
+ ## Rules
160
+
161
+ - Always render a `<DialogTitle>` — screen readers depend on it. If the design hides it visually, wrap it in `<VisuallyHidden>`.
162
+ - Use `<DialogTrigger asChild>` with a Button — don't use Dialog's default injected trigger.
163
+ - For destructive actions, place the destructive Button on the right with `color="error"`. Cancel (`<DialogClose>`) sits left with `variant="soft"`.
164
+ - Don't manipulate `open` from inside the content tree without going through `onOpenChange` or `<DialogClose>` — focus restoration breaks.
165
+ - Don't nest Dialogs. Use a single Dialog with stepped state, or close the first before opening the second.
166
+ - For side-anchored drawers, switch to `<Sheet>`. Don't recreate Sheet behavior with custom Dialog styling.
167
+ - If your Dialog is mostly read-only content (image preview, log viewer), still provide a `<DialogTitle>` for a11y, hidden if needed.
@@ -0,0 +1,205 @@
1
+ # DropdownMenu
2
+
3
+ Click-triggered menu of actions. Use for overflow menus, row actions, account menus.
4
+
5
+ ```tsx
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuTrigger,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuShortcut,
14
+ DropdownMenuCheckboxItem,
15
+ DropdownMenuRadioGroup,
16
+ DropdownMenuRadioItem,
17
+ DropdownMenuGroup,
18
+ DropdownMenuSub,
19
+ DropdownMenuSubTrigger,
20
+ DropdownMenuSubContent,
21
+ } from '@devalok/shilp-sutra/ui/dropdown-menu'
22
+ ```
23
+
24
+ ## When to use
25
+
26
+ - Row / item actions (kebab menu — Edit, Duplicate, Delete).
27
+ - Account menu (Profile, Settings, Logout).
28
+ - Sort / filter pickers with a fixed list.
29
+ - Toolbar overflow ("More" button).
30
+ - Interactive panel (forms, calendars, complex pickers)? Use `<Popover>`.
31
+ - Single-choice from a fixed list inside a form field? Use `<Select>`.
32
+ - Hover-triggered rich preview? Use `<HoverCard>`.
33
+ - Right-click contextual menu? Use `<ContextMenu>` (separate component).
34
+
35
+ ## Compound shape
36
+
37
+ ```
38
+ DropdownMenu (root — open, onOpenChange, defaultOpen, modal)
39
+ DropdownMenuTrigger ← asChild around your button
40
+ DropdownMenuContent
41
+ DropdownMenuLabel ← non-interactive section heading
42
+ DropdownMenuSeparator
43
+ DropdownMenuItem (+ DropdownMenuShortcut) ← standard action
44
+ DropdownMenuCheckboxItem ← multi-select toggle
45
+ DropdownMenuRadioGroup
46
+ DropdownMenuRadioItem ← single-select group
47
+ DropdownMenuGroup ← visual grouping
48
+ DropdownMenuSub
49
+ DropdownMenuSubTrigger ← visible item that opens submenu
50
+ DropdownMenuSubContent ← the nested submenu panel
51
+ ```
52
+
53
+ ## Root state props (Radix passthrough)
54
+
55
+ | Prop | Type | Notes |
56
+ |---|---|---|
57
+ | `open` | `boolean` | Controlled. |
58
+ | `onOpenChange` | `(open: boolean) => void` | |
59
+ | `defaultOpen` | `boolean` | Uncontrolled. |
60
+ | `modal` | `boolean` | Default `true`. |
61
+
62
+ ## Examples
63
+
64
+ **Standard kebab menu:**
65
+ ```tsx
66
+ <DropdownMenu>
67
+ <DropdownMenuTrigger asChild>
68
+ <IconButton icon={<Icon icon={IconDots} />} variant="ghost" aria-label="Actions" />
69
+ </DropdownMenuTrigger>
70
+ <DropdownMenuContent>
71
+ <DropdownMenuItem onSelect={() => edit(item)}>
72
+ <Icon icon={IconEdit} /> Edit
73
+ </DropdownMenuItem>
74
+ <DropdownMenuItem onSelect={() => duplicate(item)}>
75
+ <Icon icon={IconCopy} /> Duplicate
76
+ </DropdownMenuItem>
77
+ <DropdownMenuSeparator />
78
+ <DropdownMenuItem onSelect={() => remove(item)}>
79
+ <Icon icon={IconTrash} /> Delete
80
+ </DropdownMenuItem>
81
+ </DropdownMenuContent>
82
+ </DropdownMenu>
83
+ ```
84
+
85
+ **With keyboard shortcut hints:**
86
+ ```tsx
87
+ <DropdownMenuContent>
88
+ <DropdownMenuItem onSelect={save}>
89
+ Save
90
+ <DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
91
+ </DropdownMenuItem>
92
+ <DropdownMenuItem onSelect={find}>
93
+ Find
94
+ <DropdownMenuShortcut>⌘F</DropdownMenuShortcut>
95
+ </DropdownMenuItem>
96
+ </DropdownMenuContent>
97
+ ```
98
+
99
+ `DropdownMenuShortcut` is decorative — it does NOT bind the shortcut globally. Bind shortcuts separately (e.g. with a `useHotkeys` hook).
100
+
101
+ **Checkbox items (multi-select filters):**
102
+ ```tsx
103
+ <DropdownMenu>
104
+ <DropdownMenuTrigger asChild>
105
+ <Button variant="soft" endIcon={IconChevronDown}>View</Button>
106
+ </DropdownMenuTrigger>
107
+ <DropdownMenuContent>
108
+ <DropdownMenuLabel>Show columns</DropdownMenuLabel>
109
+ <DropdownMenuCheckboxItem
110
+ checked={cols.includes('owner')}
111
+ onCheckedChange={(checked) => toggleCol('owner', checked)}
112
+ onSelect={(e) => e.preventDefault()}
113
+ >
114
+ Owner
115
+ </DropdownMenuCheckboxItem>
116
+ <DropdownMenuCheckboxItem
117
+ checked={cols.includes('status')}
118
+ onCheckedChange={(checked) => toggleCol('status', checked)}
119
+ onSelect={(e) => e.preventDefault()}
120
+ >
121
+ Status
122
+ </DropdownMenuCheckboxItem>
123
+ </DropdownMenuContent>
124
+ </DropdownMenu>
125
+ ```
126
+
127
+ Calling `e.preventDefault()` inside `onSelect` keeps the menu open after toggling — standard for checkbox items.
128
+
129
+ **Radio items (single-select sort):**
130
+ ```tsx
131
+ <DropdownMenu>
132
+ <DropdownMenuTrigger asChild>
133
+ <Button variant="soft">Sort: {sort}</Button>
134
+ </DropdownMenuTrigger>
135
+ <DropdownMenuContent>
136
+ <DropdownMenuLabel>Sort by</DropdownMenuLabel>
137
+ <DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
138
+ <DropdownMenuRadioItem value="recent">Most recent</DropdownMenuRadioItem>
139
+ <DropdownMenuRadioItem value="oldest">Oldest</DropdownMenuRadioItem>
140
+ <DropdownMenuRadioItem value="az">A → Z</DropdownMenuRadioItem>
141
+ </DropdownMenuRadioGroup>
142
+ </DropdownMenuContent>
143
+ </DropdownMenu>
144
+ ```
145
+
146
+ **Submenu:**
147
+ ```tsx
148
+ <DropdownMenuContent>
149
+ <DropdownMenuItem onSelect={() => share(item)}>Share</DropdownMenuItem>
150
+ <DropdownMenuSub>
151
+ <DropdownMenuSubTrigger>
152
+ <Icon icon={IconFolder} /> Move to
153
+ </DropdownMenuSubTrigger>
154
+ <DropdownMenuSubContent>
155
+ <DropdownMenuItem onSelect={() => move('inbox')}>Inbox</DropdownMenuItem>
156
+ <DropdownMenuItem onSelect={() => move('archive')}>Archive</DropdownMenuItem>
157
+ <DropdownMenuItem onSelect={() => move('trash')}>Trash</DropdownMenuItem>
158
+ </DropdownMenuSubContent>
159
+ </DropdownMenuSub>
160
+ </DropdownMenuContent>
161
+ ```
162
+
163
+ Both `SubTrigger` AND `SubContent` are required — missing either silently breaks the hover-open behavior.
164
+
165
+ **Account menu pattern:**
166
+ ```tsx
167
+ <DropdownMenu>
168
+ <DropdownMenuTrigger asChild>
169
+ <IconButton icon={<Avatar size="sm" src={user.avatar} alt={user.name} />} variant="ghost" aria-label="Account" />
170
+ </DropdownMenuTrigger>
171
+ <DropdownMenuContent align="end">
172
+ <DropdownMenuLabel>{user.email}</DropdownMenuLabel>
173
+ <DropdownMenuSeparator />
174
+ <DropdownMenuGroup>
175
+ <DropdownMenuItem onSelect={() => router.push('/profile')}>Profile</DropdownMenuItem>
176
+ <DropdownMenuItem onSelect={() => router.push('/settings')}>Settings</DropdownMenuItem>
177
+ </DropdownMenuGroup>
178
+ <DropdownMenuSeparator />
179
+ <DropdownMenuItem onSelect={signOut}>
180
+ <Icon icon={IconLogout} /> Sign out
181
+ </DropdownMenuItem>
182
+ </DropdownMenuContent>
183
+ </DropdownMenu>
184
+ ```
185
+
186
+ ## Composability
187
+
188
+ - **Radix DropdownMenu** underneath — `open` / `onOpenChange` / `defaultOpen` / `modal` standard state.
189
+ - **Keyboard:** Arrow keys navigate, Enter/Space activates, Esc closes, typeahead jumps to first letter. All pre-wired.
190
+ - **Trigger:** `<DropdownMenuTrigger asChild>` around any button. `IconButton` is the common pairing for kebab menus.
191
+ - **Closing from a handler:** `onSelect` auto-closes the menu. Call `e.preventDefault()` inside the handler to keep it open (checkbox items, multi-step interactions).
192
+ - **z-popover (1400):** Stacks above Dialog. Nesting dropdowns inside dialogs works correctly.
193
+
194
+ See `foundations/surfaces.md` for menu surface tokens, `foundations/motion.md` for the spring open animation.
195
+
196
+ ## Rules
197
+
198
+ - Use `<DropdownMenuTrigger asChild>` around a Button or IconButton. Don't use Dropdown's default injected trigger.
199
+ - For kebab menus, always set `aria-label` on the IconButton — "Actions" or the specific row context.
200
+ - Submenus require BOTH `DropdownMenuSubTrigger` AND `DropdownMenuSubContent`. Missing either silently breaks hover-to-open.
201
+ - `DropdownMenuShortcut` is decorative. Bind the actual shortcut separately.
202
+ - For checkbox items, call `e.preventDefault()` inside `onSelect` to keep the menu open after toggling.
203
+ - For interactive content beyond a list (calendars, forms), use `<Popover>` not DropdownMenu.
204
+ - Don't nest a DropdownMenu inside another DropdownMenu — use `<DropdownMenuSub>` for hierarchy.
205
+ - For right-click menus, use `<ContextMenu>` (separate component). DropdownMenu is click-triggered only.
@@ -0,0 +1,189 @@
1
+ # Form
2
+
3
+ Wrap each form field in a `<FormField>`. It cascades state + a11y wiring to compatible controls automatically.
4
+
5
+ ```tsx
6
+ import { FormField, FormHelperText, useFormField } from '@devalok/shilp-sutra/ui/form'
7
+ import { Label } from '@devalok/shilp-sutra/ui/label'
8
+ ```
9
+
10
+ ## When to use
11
+
12
+ - Every input field in a form. One `<FormField>` per logical field.
13
+ - Provides: validation state, helper text, `aria-describedby`, `aria-invalid`, `aria-required` — wired automatically.
14
+ - Not for layout — for stacking fields, use `<Stack gap="ds-05">`.
15
+
16
+ ## Compound shape
17
+
18
+ ```
19
+ FormField (state, required)
20
+ Label ← htmlFor auto-resolves from FormField inputId
21
+ Input | Textarea | NumberInput | InputOTP | Select | Checkbox | ...
22
+ FormHelperText ← reads state + helperTextId from context
23
+ ```
24
+
25
+ `FormField` generates a shared `inputId`. Both `<Label>` (via `htmlFor`) and `<Input>` (via `id`) read it from context — drop the manual id-matching dance. Explicit `htmlFor` / `id` on a child still wins.
26
+
27
+ ## FormField props
28
+
29
+ | Prop | Type | Notes |
30
+ |---|---|---|
31
+ | `state` | `'helper'\|'error'\|'warning'\|'success'` | Default `helper`. Cascades to compatible controls + FormHelperText. |
32
+ | `helperTextId` | `string` | Auto-generated if omitted. Used by `aria-describedby` wiring. |
33
+ | `inputId` | `string` | Auto-generated if omitted. Shared between `<Label htmlFor>` and `<Input id>`. |
34
+ | `required` | `boolean` | Sets `aria-required` on consuming controls. Does NOT auto-render the asterisk — that comes from `<Label required>`. |
35
+
36
+ ## FormHelperText props
37
+
38
+ | Prop | Type | Notes |
39
+ |---|---|---|
40
+ | `state` | `'helper'\|'error'\|'warning'\|'success'` | Inherits from FormField context. Override per-helper if needed. |
41
+
42
+ When `state="error"`, FormHelperText renders `role="alert"` so screen readers interrupt.
43
+
44
+ ## Label props
45
+
46
+ | Prop | Type | Notes |
47
+ |---|---|---|
48
+ | `htmlFor` | `string` | Optional inside `<FormField>` — falls back to FormField's `inputId`. Required when used outside FormField. |
49
+ | `required` | `boolean` | Renders red asterisk. Does NOT set `aria-required` (that comes from FormField). |
50
+
51
+ ## useFormField hook
52
+
53
+ ```ts
54
+ const field = useFormField()
55
+ // → { state, helperTextId, required } | undefined
56
+ ```
57
+
58
+ Use inside a custom control to consume FormField context.
59
+
60
+ ## What auto-consumes FormField context
61
+
62
+ | Control | Auto-receives |
63
+ |---|---|
64
+ | `<Input>` | `id` (from `inputId`), `state`, `aria-describedby`, `aria-invalid`, `aria-required` |
65
+ | `<Textarea>` | same |
66
+ | `<NumberInput>` | same |
67
+ | `<InputOTP>` | `state`, `aria-describedby`, `aria-required` |
68
+ | `<Label>` | `htmlFor` (from `inputId`) |
69
+
70
+ | Control | Manual wiring needed |
71
+ |---|---|
72
+ | `<Select>` / `<SelectTrigger>` | Set `color="error"` on `SelectTrigger` from your validation state. |
73
+ | `<Checkbox>`, `<Radio>`, `<Switch>` | Pair `<Label htmlFor>` manually. State is visual on the control. |
74
+
75
+ ## Examples
76
+
77
+ **Standard text field with error:**
78
+ ```tsx
79
+ <FormField state={errors.email ? 'error' : 'helper'}>
80
+ <Label htmlFor="email" required>Email</Label>
81
+ <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
82
+ <FormHelperText>
83
+ {errors.email ?? 'We will never share your email.'}
84
+ </FormHelperText>
85
+ </FormField>
86
+ ```
87
+
88
+ The Input receives `aria-describedby` (linked to FormHelperText), `aria-invalid="true"` when state is error, and `aria-required="true"`.
89
+
90
+ **Warning state:**
91
+ ```tsx
92
+ <FormField state="warning">
93
+ <Label htmlFor="password" required>Password</Label>
94
+ <Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
95
+ <FormHelperText>Password strength: weak. Add a number or symbol.</FormHelperText>
96
+ </FormField>
97
+ ```
98
+
99
+ **Textarea:**
100
+ ```tsx
101
+ <FormField state={errors.bio ? 'error' : 'helper'}>
102
+ <Label htmlFor="bio">Bio</Label>
103
+ <Textarea id="bio" rows={4} value={bio} onChange={(e) => setBio(e.target.value)} />
104
+ <FormHelperText>{errors.bio ?? `${bio.length} / 200`}</FormHelperText>
105
+ </FormField>
106
+ ```
107
+
108
+ **Required Checkbox (manual label pairing):**
109
+ ```tsx
110
+ <FormField required state={errors.terms ? 'error' : 'helper'}>
111
+ <Stack direction="horizontal" gap="ds-03" align="center">
112
+ <Checkbox id="terms" checked={agreed} onCheckedChange={setAgreed} />
113
+ <Label htmlFor="terms">I agree to the terms.</Label>
114
+ </Stack>
115
+ {errors.terms && <FormHelperText>{errors.terms}</FormHelperText>}
116
+ </FormField>
117
+ ```
118
+
119
+ **Full form layout:**
120
+ ```tsx
121
+ <form onSubmit={handleSubmit}>
122
+ <Stack gap="ds-05">
123
+ <FormField>
124
+ <Label htmlFor="name" required>Name</Label>
125
+ <Input id="name" />
126
+ </FormField>
127
+
128
+ <FormField>
129
+ <Label htmlFor="email" required>Email</Label>
130
+ <Input id="email" type="email" />
131
+ <FormHelperText>For account recovery only.</FormHelperText>
132
+ </FormField>
133
+
134
+ <FormField state={errors.role ? 'error' : 'helper'}>
135
+ <Label htmlFor="role">Role</Label>
136
+ <Select>
137
+ <SelectTrigger id="role" color={errors.role ? 'error' : 'default'}>
138
+ <SelectValue placeholder="Choose a role" />
139
+ </SelectTrigger>
140
+ <SelectContent>
141
+ <SelectItem value="admin">Admin</SelectItem>
142
+ <SelectItem value="member">Member</SelectItem>
143
+ </SelectContent>
144
+ </Select>
145
+ {errors.role && <FormHelperText>{errors.role}</FormHelperText>}
146
+ </FormField>
147
+
148
+ <Stack direction="horizontal" gap="ds-03" justify="end">
149
+ <Button variant="soft" type="button">Cancel</Button>
150
+ <Button type="submit">Save</Button>
151
+ </Stack>
152
+ </Stack>
153
+ </form>
154
+ ```
155
+
156
+ **Custom control consuming FormField context:**
157
+ ```tsx
158
+ function ColorPickerField(props) {
159
+ const field = useFormField()
160
+ return (
161
+ <ColorPicker
162
+ {...props}
163
+ aria-describedby={field?.helperTextId}
164
+ aria-invalid={field?.state === 'error' || undefined}
165
+ aria-required={field?.required || undefined}
166
+ />
167
+ )
168
+ }
169
+ ```
170
+
171
+ ## Composability
172
+
173
+ - **Explicit props override context** — `<Input state="error">` inside a `<FormField state="helper">` makes only that input look errored. Same for `id` / `htmlFor`.
174
+ - **Don't nest FormFields** — only the outermost context wins. Some a11y wiring silently breaks.
175
+ - **Label-to-control pairing auto-wires inside FormField** via the shared `inputId`. Outside FormField, set `htmlFor` + `id` explicitly.
176
+ - **For non-auto-wired controls** (Checkbox, Radio, Switch) where the visible label sits beside the control, you can still drop the `htmlFor` if you put the control inside FormField with no other Inputs — `inputId` resolves on the Checkbox via its `id` prop the same way.
177
+
178
+ See `foundations/color.md` for state colors, `foundations/spacing.md` for inter-field spacing (`ds-05` default).
179
+
180
+ ## Rules
181
+
182
+ - One FormField per logical field. Never nest FormFields.
183
+ - For error display, drive `FormField state` from your validation state — every consuming control updates together.
184
+ - For Select / Checkbox / Radio / Switch, set the control's error visual manually (`color="error"` on SelectTrigger) — they don't auto-consume FormField state, only ids and a11y attrs.
185
+ - Don't use the removed `getFormFieldA11y()` helper — use the `useFormField()` hook.
186
+ - Inside FormField, `<FormHelperText>` reads `state` + id from context — don't pass them again unless intentionally overriding.
187
+ - Use `<Stack gap="ds-05">` to space fields vertically. Don't reach for `ds-04` or `ds-06` — see `foundations/spacing.md`.
188
+ - `<Label required>` only renders the asterisk. The `aria-required` flag comes from `<FormField required>`.
189
+ - You can omit `id` on `<Input>` and `htmlFor` on `<Label>` inside a `<FormField>` — both resolve from FormField's `inputId`. Set them explicitly only when overriding the auto-generated id.