@seekalfred/primitives 0.2.0 → 0.4.0

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.
Files changed (157) hide show
  1. package/README.md +975 -13
  2. package/dist/accordion/accordion.d.ts +107 -0
  3. package/dist/accordion/accordion.js +84 -0
  4. package/dist/accordion/accordion.js.map +1 -0
  5. package/dist/accordion/index.d.ts +2 -0
  6. package/dist/accordion/index.js +2 -0
  7. package/dist/alert/alert.d.ts +68 -0
  8. package/dist/alert/alert.js +67 -0
  9. package/dist/alert/alert.js.map +1 -0
  10. package/dist/alert/index.d.ts +2 -0
  11. package/dist/alert/index.js +2 -0
  12. package/dist/alert-dialog/alert-dialog.d.ts +53 -0
  13. package/dist/alert-dialog/alert-dialog.js +16 -0
  14. package/dist/alert-dialog/alert-dialog.js.map +1 -0
  15. package/dist/alert-dialog/index.d.ts +2 -0
  16. package/dist/alert-dialog/index.js +2 -0
  17. package/dist/avatar/avatar.d.ts +50 -0
  18. package/dist/avatar/avatar.js +91 -0
  19. package/dist/avatar/avatar.js.map +1 -0
  20. package/dist/avatar/index.d.ts +2 -0
  21. package/dist/avatar/index.js +2 -0
  22. package/dist/badge/badge.d.ts +47 -0
  23. package/dist/badge/badge.js +189 -0
  24. package/dist/badge/badge.js.map +1 -0
  25. package/dist/badge/index.d.ts +2 -0
  26. package/dist/badge/index.js +2 -0
  27. package/dist/breadcrumb/breadcrumb.d.ts +98 -0
  28. package/dist/breadcrumb/breadcrumb.js +93 -0
  29. package/dist/breadcrumb/breadcrumb.js.map +1 -0
  30. package/dist/breadcrumb/index.d.ts +2 -0
  31. package/dist/breadcrumb/index.js +2 -0
  32. package/dist/button/button.d.ts +1 -1
  33. package/dist/button/button.js +7 -0
  34. package/dist/button/button.js.map +1 -1
  35. package/dist/calendar/calendar.d.ts +154 -0
  36. package/dist/calendar/calendar.js +282 -0
  37. package/dist/calendar/calendar.js.map +1 -0
  38. package/dist/calendar/index.d.ts +2 -0
  39. package/dist/calendar/index.js +2 -0
  40. package/dist/card/card.d.ts +93 -0
  41. package/dist/card/card.js +105 -0
  42. package/dist/card/card.js.map +1 -0
  43. package/dist/card/index.d.ts +2 -0
  44. package/dist/card/index.js +2 -0
  45. package/dist/checkbox/checkbox.d.ts +63 -0
  46. package/dist/checkbox/checkbox.js +94 -0
  47. package/dist/checkbox/checkbox.js.map +1 -0
  48. package/dist/checkbox/index.d.ts +2 -0
  49. package/dist/checkbox/index.js +2 -0
  50. package/dist/dialog/dialog.d.ts +140 -0
  51. package/dist/dialog/dialog.js +127 -0
  52. package/dist/dialog/dialog.js.map +1 -0
  53. package/dist/dialog/index.d.ts +2 -0
  54. package/dist/dialog/index.js +2 -0
  55. package/dist/index.d.ts +30 -1
  56. package/dist/index.js +30 -1
  57. package/dist/input/index.d.ts +2 -0
  58. package/dist/input/index.js +2 -0
  59. package/dist/input/input.d.ts +80 -0
  60. package/dist/input/input.js +195 -0
  61. package/dist/input/input.js.map +1 -0
  62. package/dist/label/index.d.ts +2 -0
  63. package/dist/label/index.js +2 -0
  64. package/dist/label/label.d.ts +41 -0
  65. package/dist/label/label.js +44 -0
  66. package/dist/label/label.js.map +1 -0
  67. package/dist/lib/field-surface.js +40 -0
  68. package/dist/lib/field-surface.js.map +1 -0
  69. package/dist/lib/selection-control.js +57 -0
  70. package/dist/lib/selection-control.js.map +1 -0
  71. package/dist/menu/index.d.ts +2 -0
  72. package/dist/menu/index.js +2 -0
  73. package/dist/menu/menu.d.ts +98 -0
  74. package/dist/menu/menu.js +99 -0
  75. package/dist/menu/menu.js.map +1 -0
  76. package/dist/popover/index.d.ts +2 -0
  77. package/dist/popover/index.js +2 -0
  78. package/dist/popover/popover.d.ts +50 -0
  79. package/dist/popover/popover.js +46 -0
  80. package/dist/popover/popover.js.map +1 -0
  81. package/dist/progress/index.d.ts +2 -0
  82. package/dist/progress/index.js +2 -0
  83. package/dist/progress/progress.d.ts +82 -0
  84. package/dist/progress/progress.js +82 -0
  85. package/dist/progress/progress.js.map +1 -0
  86. package/dist/radio-group/index.d.ts +2 -0
  87. package/dist/radio-group/index.js +2 -0
  88. package/dist/radio-group/radio-group.d.ts +53 -0
  89. package/dist/radio-group/radio-group.js +65 -0
  90. package/dist/radio-group/radio-group.js.map +1 -0
  91. package/dist/scroll-area/index.d.ts +2 -0
  92. package/dist/scroll-area/index.js +2 -0
  93. package/dist/scroll-area/scroll-area.d.ts +74 -0
  94. package/dist/scroll-area/scroll-area.js +63 -0
  95. package/dist/scroll-area/scroll-area.js.map +1 -0
  96. package/dist/select/index.d.ts +2 -0
  97. package/dist/select/index.js +2 -0
  98. package/dist/select/select.d.ts +105 -0
  99. package/dist/select/select.js +149 -0
  100. package/dist/select/select.js.map +1 -0
  101. package/dist/separator/index.d.ts +2 -0
  102. package/dist/separator/index.js +2 -0
  103. package/dist/separator/separator.d.ts +42 -0
  104. package/dist/separator/separator.js +49 -0
  105. package/dist/separator/separator.js.map +1 -0
  106. package/dist/sheet/index.d.ts +2 -0
  107. package/dist/sheet/index.js +2 -0
  108. package/dist/sheet/sheet.d.ts +96 -0
  109. package/dist/sheet/sheet.js +98 -0
  110. package/dist/sheet/sheet.js.map +1 -0
  111. package/dist/skeleton/index.d.ts +2 -0
  112. package/dist/skeleton/index.js +2 -0
  113. package/dist/skeleton/skeleton.d.ts +33 -0
  114. package/dist/skeleton/skeleton.js +42 -0
  115. package/dist/skeleton/skeleton.js.map +1 -0
  116. package/dist/slider/index.d.ts +2 -0
  117. package/dist/slider/index.js +2 -0
  118. package/dist/slider/slider.d.ts +70 -0
  119. package/dist/slider/slider.js +92 -0
  120. package/dist/slider/slider.js.map +1 -0
  121. package/dist/spinner/index.d.ts +2 -0
  122. package/dist/spinner/index.js +2 -0
  123. package/dist/spinner/spinner.d.ts +75 -0
  124. package/dist/spinner/spinner.js +68 -0
  125. package/dist/spinner/spinner.js.map +1 -0
  126. package/dist/styles.css +1 -1
  127. package/dist/switch/index.d.ts +2 -0
  128. package/dist/switch/index.js +2 -0
  129. package/dist/switch/switch.d.ts +50 -0
  130. package/dist/switch/switch.js +66 -0
  131. package/dist/switch/switch.js.map +1 -0
  132. package/dist/table/index.d.ts +2 -0
  133. package/dist/table/index.js +2 -0
  134. package/dist/table/table.d.ts +288 -0
  135. package/dist/table/table.js +513 -0
  136. package/dist/table/table.js.map +1 -0
  137. package/dist/tabs/index.d.ts +2 -0
  138. package/dist/tabs/index.js +2 -0
  139. package/dist/tabs/tabs.d.ts +79 -0
  140. package/dist/tabs/tabs.js +103 -0
  141. package/dist/tabs/tabs.js.map +1 -0
  142. package/dist/textarea/index.d.ts +2 -0
  143. package/dist/textarea/index.js +2 -0
  144. package/dist/textarea/textarea.d.ts +27 -0
  145. package/dist/textarea/textarea.js +37 -0
  146. package/dist/textarea/textarea.js.map +1 -0
  147. package/dist/toast/index.d.ts +2 -0
  148. package/dist/toast/index.js +2 -0
  149. package/dist/toast/toast.d.ts +93 -0
  150. package/dist/toast/toast.js +145 -0
  151. package/dist/toast/toast.js.map +1 -0
  152. package/dist/tooltip/index.d.ts +2 -0
  153. package/dist/tooltip/index.js +2 -0
  154. package/dist/tooltip/tooltip.d.ts +70 -0
  155. package/dist/tooltip/tooltip.js +65 -0
  156. package/dist/tooltip/tooltip.js.map +1 -0
  157. package/package.json +125 -7
package/README.md CHANGED
@@ -20,26 +20,230 @@ import { Button } from "@seekalfred/primitives";
20
20
 
21
21
  You do not need Tailwind installed. This package ships compiled CSS.
22
22
 
23
+ ## Accordion
24
+
25
+ ```tsx
26
+ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "@seekalfred/primitives";
27
+
28
+ <Accordion defaultExpandedKeys={["billing"]}>
29
+ <AccordionItem id="billing">
30
+ <AccordionTrigger icon={<ChevronDown aria-hidden />}>Billing</AccordionTrigger>
31
+ <AccordionPanel>Invoices and payment methods.</AccordionPanel>
32
+ </AccordionItem>
33
+ </Accordion>;
34
+ ```
35
+
36
+ `AccordionTrigger` renders inside a heading (level 3 by default, `headingLevel`
37
+ to change it) because screen-reader users navigate an accordion by heading.
38
+
39
+ ### Migrating from the legacy shadcn Accordion
40
+
41
+ Expansion is keyed rather than string-valued. That is the change that touches
42
+ every call site:
43
+
44
+ | legacy | Alfred | |
45
+ | --------------------------- | ----------------------------------- | ----------------------------------------------------- |
46
+ | `type="single"` | omit `allowsMultipleExpanded` | |
47
+ | `type="multiple"` | `allowsMultipleExpanded` | |
48
+ | `value` / `onValueChange` | `expandedKeys` / `onExpandedChange` | the handler receives a **`Set`**, not a string |
49
+ | `defaultValue="x"` | `defaultExpandedKeys={["x"]}` | |
50
+ | `<AccordionItem value="x">` | `<AccordionItem id="x">` | |
51
+ | `collapsible` | _(drop it)_ | React Aria's single-expand mode is always collapsible |
52
+ | `<AccordionContent>` | `<AccordionPanel>` | |
53
+ | bundled chevron | `icon` prop | this library ships no icon set |
54
+
55
+ Two deliberate behaviour differences:
56
+
57
+ - **Arrow keys no longer move between headers.** Radix implemented Home/End and
58
+ the arrows; React Aria does not, and the ARIA APG treats it as optional. Tab
59
+ reaches every trigger.
60
+ - **The panel does not animate.** React Aria hides a collapsed panel with the
61
+ `hidden` attribute (`display: none`), which CSS cannot transition out of.
62
+ The chevron still rotates.
63
+
64
+ ## Alert
65
+
66
+ ```tsx
67
+ <Alert variant="destructive" icon={<WarningIcon aria-hidden />} title="Save failed">
68
+ Your changes were not saved.
69
+ </Alert>
70
+ ```
71
+
72
+ `role="alert"` is announced the moment it renders, so this is the wrong
73
+ component for a notice that is simply present on load.
74
+
75
+ ### Migrating from the legacy shadcn Alert
76
+
77
+ | legacy | Alfred | |
78
+ | ------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
79
+ | `<AlertTitle>Text</AlertTitle>` | `title="Text"` | was always `<h5>`; `headingLevel` now sets it |
80
+ | `<AlertDescription>Text</AlertDescription>` | `children` | |
81
+ | `<AlertCircle />` as a child | `icon={<AlertCircle aria-hidden />}` | the legacy `[&>svg]` selectors only worked when the icon came first, and did not mirror in RTL |
82
+ | `variant="default" \| "destructive"` | unchanged | |
83
+ | native `title` attribute | _(unavailable)_ | the name is used for the heading |
84
+
85
+ No `success` / `warning` / `info` variants yet — one faked call site is below
86
+ the Rule of Two. Until then, a `className` override is the supported route;
87
+ consumer CSS always wins.
88
+
89
+ ## AlertDialog
90
+
91
+ ```tsx
92
+ <AlertDialog isOpen={open} onOpenChange={setOpen}
93
+ title="Delete 3 recordings?"
94
+ description="They will be removed for everyone and cannot be restored.">
95
+ <DialogFooter>
96
+ <Button slot="close" variant="ghost">Cancel</Button>
97
+ <Button variant="destructive" onPress={remove}>Delete</Button>
98
+ </DialogFooter>
99
+ </AlertDialog>
100
+ ```
101
+
102
+ A preset over `Dialog`, not a second implementation. It forces
103
+ `role="alertdialog"`, no close button, and `isDismissable={false}`, and it
104
+ makes `description` **required** — React Aria associates the description only
105
+ for that role, so a confirmation built from a plain `Dialog` loses the
106
+ sentence explaining what is about to happen.
107
+
108
+ ### Migrating from the legacy shadcn AlertDialog
109
+
110
+ | legacy | Alfred | |
111
+ | -------------------------- | ------------------------------- | --------------------------------------------------- |
112
+ | `<AlertDialog open …>` | `<AlertDialog isOpen …>` | |
113
+ | `<AlertDialogContent>` | *delete* | `AlertDialog` **is** the content |
114
+ | `<AlertDialogHeader>` / `Title` / `Description` | `title` / `description` props | `description` is required |
115
+ | `<AlertDialogCancel>` | `<Button slot="close">` | |
116
+ | `<AlertDialogAction>` | `<Button variant="destructive">` | **pick the variant explicitly** |
117
+ | `<AlertDialogPortal>` / `Overlay` / `Trigger` | *no equivalent* | never rendered externally |
118
+
119
+ **`AlertDialogAction` is deliberately gone.** It defaulted to the gradient
120
+ primary button, and both call sites override it back to destructive by hand
121
+ (`data-table.tsx:146`, `calendar-view.tsx:638`). A default every caller fights
122
+ is not a default.
123
+
124
+ ## Avatar
125
+
126
+ ```tsx
127
+ <Avatar src={user.photoUrl} alt={user.name} fallback="JD" size="lg" />
128
+ <Avatar alt="" fallback="JD" />
129
+ ```
130
+
131
+ Sizes: `xs` (20px) · `sm` (24px) · `md` (32px, default) · `lg` (40px) · `xl` (64px).
132
+
133
+ ### Migrating from the legacy shadcn Avatar
134
+
135
+ | legacy | Alfred | |
136
+ | -------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
137
+ | `<Avatar><AvatarImage/><AvatarFallback/></Avatar>` | `<Avatar src alt fallback />` | one component, three fewer imports |
138
+ | `className="h-10 w-10"` | `size="lg"` | six ad-hoc sizes became five named ones |
139
+ | `<AvatarImage src />` with no `alt` | `alt` is **required** | `""` for decorative; 15 of 38 legacy uses had none, which fails axe |
140
+
141
+ ## Badge
142
+
143
+ ```tsx
144
+ <Badge tone="success" appearance="soft">Live</Badge>
145
+ <Badge tone="neutral" appearance="outline" shape="pill" size="sm">Draft</Badge>
146
+ ```
147
+
148
+ Axes: `tone` (`neutral` · `primary` · `success` · `warning` · `danger` · `info`),
149
+ `appearance` (`solid` · `soft` · `outline`), `size` (`sm` · `md` · `lg`) and
150
+ `shape` (`rounded` · `pill`). Defaults: neutral, soft, md, rounded.
151
+
152
+ **This absorbed the product's `Pill`.** Two components shipped side by side for
153
+ one job — 22 consumer files each — and disagreed about shape, size and naming;
154
+ `pill` accepted `blue` *and* `info`, `green` *and* `success`, for one colour.
155
+
156
+ Two axes rather than one, because six tones by three appearances is eighteen
157
+ `variant` names otherwise. `softPrimary` next to `primaryOutline` is that cross
158
+ product written out by hand and abandoned halfway.
159
+
160
+ | legacy | Alfred |
161
+ | --- | --- |
162
+ | `variant="default"` | `tone="primary" appearance="solid"` |
163
+ | `variant="secondary"` | `tone="neutral" appearance="solid"` — **now grey, not lavender**; see below |
164
+ | `variant="outline"` | `tone="neutral" appearance="outline"` |
165
+ | `variant="softPrimary"` | `tone="primary" appearance="soft"` |
166
+ | `variant="primaryOutline"` | `tone="primary" appearance="outline"` |
167
+ | `variant="destructive"` | `tone="danger" appearance="solid"` |
168
+ | `<Pill tone="slate">` | `tone="neutral" shape="pill"` |
169
+ | `<Pill tone="blue">` / `"green"` | `tone="info"` / `tone="success"` |
170
+
171
+ `tone="neutral" appearance="solid"` fills with `muted-foreground`, not
172
+ `secondary`. `--color-secondary` resolves to `periwinkle.400`, so a badge asking
173
+ for *neutral* rendered lavender — caught in a browser, because the story
174
+ compared the fill against the same wrong token it was painted with. The product
175
+ fills its `secondary` badge with that periwinkle, which is the brand's second
176
+ colour rather than an absence of one; those 9 call sites become grey, which is
177
+ what they were asking for.
178
+
179
+ Worth knowing when mapping colours across: our `alfred.periwinkle` ramp **is**
180
+ the product's `--primary-*` scale, value for value — `periwinkle.50` is
181
+ `#F3F3FF`, `.100` is `#E7E7FF`. The app's `--primary` (orange) and its
182
+ `--primary-N` (periwinkle) are two unrelated things sharing a prefix.
183
+
184
+ No `asChild` — zero call sites, and the legacy `[a&]:hover:` rules existed only
185
+ for the anchor it produced.
186
+
187
+ ## Breadcrumbs
188
+
189
+ ```tsx
190
+ <Breadcrumbs>
191
+ <BreadcrumbItem href="/">Home</BreadcrumbItem>
192
+ <BreadcrumbItem href="/design-thinking">Design Thinking</BreadcrumbItem>
193
+ <BreadcrumbItem>User Personas</BreadcrumbItem>
194
+ </Breadcrumbs>
195
+ ```
196
+
197
+ The **last child is the current page**: give it no `href` and React Aria marks
198
+ it `aria-current="page"`, renders it as a `<span role="link">` and takes it out
199
+ of the tab order. Separators are drawn by every non-last crumb, so there is
200
+ nothing to interleave.
201
+
202
+ ### Migrating from the legacy shadcn Breadcrumb
203
+
204
+ Six parts become two. All but the first rename are **deletions**, so a call
205
+ site shrinks rather than moving.
206
+
207
+ | legacy | Alfred | |
208
+ | --------------------------------------- | ------------------------------- | --------------------------------------------------------------------------- |
209
+ | `<Breadcrumb>` | `<Breadcrumbs>` | mechanical rename; still renders the `<nav>` landmark |
210
+ | `<BreadcrumbList>` | *delete* | `Breadcrumbs` **is** the `<ol>` |
211
+ | `<BreadcrumbItem><BreadcrumbLink href>` | `<BreadcrumbItem href>` | the link merges into the item |
212
+ | `<BreadcrumbPage>Foo</BreadcrumbPage>` | `<BreadcrumbItem>Foo` | drop the `href`; current-ness is computed, not declared |
213
+ | `<BreadcrumbSeparator />` | *delete* | drawn automatically between crumbs |
214
+ | `<BreadcrumbEllipsis />` | *no equivalent* | zero call sites |
215
+ | `asChild` | *no equivalent* | zero call sites |
216
+
217
+ **Needs a decision per call site:** an icon-only crumb. Both legacy call sites
218
+ render `<BreadcrumbLink href="/"><Home /></BreadcrumbLink>` with no text and no
219
+ label, which is an axe `link-name` violation. Add `aria-label` to the
220
+ `BreadcrumbItem`.
221
+
222
+ Two smaller deliberate changes: the separator is `/` rather than a lucide
223
+ `ChevronRight` (this package ships no icon dependency, and all four legacy
224
+ separators were the default), and hovering a crumb underlines it as well as
225
+ darkening it — colour alone disappears under forced-colors.
226
+
23
227
  ## Button
24
228
 
25
- | variant | |
26
- | ------------------- | ------------------------------------------------------ |
27
- | `primary` (default) | solid brand orange, ink label |
28
- | `secondary` | solid brand periwinkle, ink label |
29
- | `outline` | background fill, brand border and label |
30
- | `ghost` | transparent until hovered |
31
- | `destructive` | solid `destructive` |
32
- | `link` | text only, underlines on hover |
229
+ | variant | |
230
+ | ------------------- | --------------------------------------- |
231
+ | `primary` (default) | solid brand orange, ink label |
232
+ | `secondary` | solid brand periwinkle, ink label |
233
+ | `outline` | background fill, brand border and label |
234
+ | `ghost` | transparent until hovered |
235
+ | `destructive` | solid `destructive` |
236
+ | `link` | text only, underlines on hover |
33
237
 
34
238
  The primary label is **ink, not white**. Brand orange carries white at only
35
239
  2.44:1; ink clears it at 8.35:1, which is the brand system's own rule. For the
36
- same reason `link` and `outline` use `primary-strong` — a darkened tone of the
37
- brand hue — because `primary` itself cannot legally be text or a border.
240
+ same reason `link` and `outline` use `primary-strong` — a darkened tone of the
241
+ brand hue — because `primary` itself cannot legally be text or a border.
38
242
 
39
- Sizes: `sm` (32px) · `md` (36px, default) · `lg` (40px) · `icon` (36px square).
243
+ Sizes: `sm` (32px) · `md` (36px, default) · `lg` (40px) · `icon` (36px square).
40
244
 
41
245
  `size="icon"` has no visible text, so it **must** carry an `aria-label`. Without
42
- one it has no accessible name and fails axe's `button-name` rule — the
246
+ one it has no accessible name and fails axe's `button-name` rule — the
43
247
  `IconOnly` story exists to keep that true.
44
248
 
45
249
  `buttonVariants` is exported. Use it when you need button styling on an element
@@ -67,7 +271,7 @@ React Aria Components rather than a bare `<button>`:
67
271
  | `size="default"` | `size="md"` | Same 36px height. |
68
272
  | `<Button asChild><a /></Button>` | `<a className={buttonVariants({ variant })}>` | React Aria's Button never renders an anchor. A real `Link` primitive lands in Phase 1. |
69
273
 
70
- The first four are mechanical. `asChild` is not — each site needs a decision
274
+ The first four are mechanical. `asChild` is not — each site needs a decision
71
275
  about whether it wanted a link or a button.
72
276
 
73
277
  Two things you gain: a `data-[pressed]` state the original never had, and
@@ -82,3 +286,761 @@ Two things that changed on purpose:
82
286
  - **No `dark:` variants.** Theming happens in the tokens, under
83
287
  `[data-theme="dark"]`. Every `dark:` class in the original is gone, not
84
288
  reimplemented.
289
+
290
+ ## Calendar
291
+
292
+ **Maturity: `experimental`.** The API may still move; `DateRangePicker` is its
293
+ first real consumer and will shape it.
294
+
295
+ ```tsx
296
+ import { parseDate } from "@internationalized/date";
297
+
298
+ <Calendar label="Report date" value={date} onChange={setDate} clearable />
299
+
300
+ <CalendarRange
301
+ label="Period"
302
+ visibleMonths={3}
303
+ selectionAlignment="start"
304
+ defaultValue={{ start: parseDate("2026-09-01"), end: parseDate("2026-09-30") }}
305
+ onChange={setRange}
306
+ />
307
+ ```
308
+
309
+ **Values are `DateValue`, not `Date`.** A calendar day has no time zone, so
310
+ modelling it as a timestamp is what costs you a day at a DST boundary. ADR 0006
311
+ makes `@internationalized/date` the one allowed public type dependency for
312
+ exactly this reason, and it is a `peerDependency` — install it alongside.
313
+ `String(value)` gives you `YYYY-MM-DD` for a query parameter, so serialize at
314
+ your own API edge rather than having the library guess.
315
+
316
+ **`label` is required.** The root is `role="application"`, which has no implicit
317
+ name. Pass `labelHidden` when the surrounding UI already names it.
318
+
319
+ **`clearable` works controlled and uncontrolled.** The Clear button reads React
320
+ Aria's calendar state from context and calls `setValue(null)`, which runs
321
+ through `useControlledState` — so `onChange` fires either way and there is no
322
+ `onClear` to wire up. It inherits `isDisabled` and `isReadOnly` from the
323
+ calendar, and keeps focus rather than dropping it to `<body>` when it clears.
324
+
325
+ **`selectionAlignment` matters for multi-month.** React Aria defaults to
326
+ `"center"`, which for `visibleMonths={3}` puts the focused month in the middle —
327
+ so a September-focused window opens on August. Range pickers usually want
328
+ `"start"`. It is inherited from React Aria rather than defaulted here, because
329
+ either alignment is legitimate.
330
+
331
+ ### Loading the tokens is not optional
332
+
333
+ `@seekalfred/primitives/styles.css` defines **no** token values — not
334
+ `--color-primary`, not `--density-control-h-sm`. It only references them (ADR
335
+ 0003, reference mode), so a consumer that does not load the token layer gets a
336
+ component styled against variables that resolve to nothing:
337
+
338
+ ```tsx
339
+ import "@seekalfred/styles/base.css"; // <- the tokens live here
340
+ import "@seekalfred/primitives/styles.css";
341
+ ```
342
+
343
+ That applies to every component, but Calendar makes it visible fastest. An app
344
+ supplying a *different* token set will find some names coincide and others do
345
+ not — and the two that hurt are `--focus-ring-width` and `--focus-ring-offset`,
346
+ because their absence removes the focus indicator rather than merely shifting a
347
+ colour. `apps/smoke` is the worked example of a correct consumer.
348
+
349
+ ## Card
350
+
351
+ ```tsx
352
+ <Card>
353
+ <CardHeader>
354
+ <CardTitle>Learning Progress</CardTitle>
355
+ <CardDescription>Across all active courses.</CardDescription>
356
+ </CardHeader>
357
+ <CardContent>Twelve of eighteen modules complete.</CardContent>
358
+ <CardFooter>Updated 2 minutes ago</CardFooter>
359
+ </Card>
360
+ ```
361
+
362
+ Padding is **block-only on the shell and inline-only on the sections**, which
363
+ is what lets a divider or a full-bleed image run edge to edge. A single `p-6`
364
+ on the shell would make that impossible.
365
+
366
+ ### Migrating from the legacy shadcn Card
367
+
368
+ | legacy | Alfred | |
369
+ | ------------------------ | -------------------------------- | ----------------------------------------------------------------------- |
370
+ | `<CardTitle>` (a `div`) | `<CardTitle>` (an `<h3>`) | **check your heading order**; pass `level` if `h3` is wrong for the page |
371
+ | `<CardAction>` | *no equivalent* | zero call sites; put the action in the header or footer |
372
+ | `[.border-b]` / `[.border-t]` padding | *no equivalent* | zero call sites; set the padding you want |
373
+ | `@container/card-header` | *no equivalent* | nothing queried it |
374
+
375
+ Everything else is a straight rename — `Card`, `CardHeader`, `CardDescription`,
376
+ `CardContent` and `CardFooter` keep their names, props and slots.
377
+
378
+ **`CardTitle` is the one change that needs a human per call site.** It now
379
+ renders a real heading, because none of the 19 legacy call sites wrapped one
380
+ and a `div` title is invisible to screen-reader heading navigation. `level`
381
+ changes semantics only, never the rendered size.
382
+
383
+ **A clickable card is still your problem.** `Card` spreads native `div` props,
384
+ so `onClick` works exactly as it did — and a `div` with a click handler is not
385
+ focusable and has no role. One legacy call site does this. Use a `Button` or a
386
+ link inside the card instead.
387
+
388
+ ## Checkbox
389
+
390
+ ```tsx
391
+ <Checkbox defaultSelected>Email me about new courses</Checkbox>
392
+ <Checkbox isIndeterminate onChange={setAll}>Select all</Checkbox>
393
+ <Checkbox aria-label="Select row" />
394
+ ```
395
+
396
+ The label is `children`, and that is also how the control gets its accessible
397
+ name. Pass `aria-label` instead only for a genuinely label-less checkbox, such
398
+ as a table row selector.
399
+
400
+ ### Migrating from the legacy Checkbox
401
+
402
+ The legacy component had **zero call sites**, so nothing here breaks. It is
403
+ listed for completeness, because the shape of the change is the same one the
404
+ other form controls will take.
405
+
406
+ | legacy (native input) | Alfred | |
407
+ | --------------------- | ----------------- | ------------------------------------------------ |
408
+ | `checked` | `isSelected` | |
409
+ | `defaultChecked` | `defaultSelected` | |
410
+ | `onChange(event)` | `onChange(boolean)` | you get the value, not an event |
411
+ | `disabled` | `isDisabled` | |
412
+ | `required` | `isRequired` | |
413
+ | *(no way to set it)* | `isIndeterminate` | `indeterminate` is a DOM property, not an attribute |
414
+ | *(no label at all)* | `children` | the legacy control had no accessible name |
415
+
416
+ ## Dialog
417
+
418
+ ```tsx
419
+ <Dialog isOpen={open} onOpenChange={setOpen}
420
+ title="Delete user"
421
+ description="This removes their account and cannot be undone.">
422
+ <DialogFooter>
423
+ <Button slot="close" variant="ghost">Cancel</Button>
424
+ <Button variant="destructive" onPress={remove}>Delete</Button>
425
+ </DialogFooter>
426
+ </Dialog>
427
+ ```
428
+
429
+ `title` is **required**, so a dialog always has an accessible name. React Aria
430
+ portals the overlay to `document.body`, so tests and queries must reach it
431
+ through `document.body` or `screen`, never through the story container.
432
+
433
+ ### Migrating from the legacy shadcn Dialog
434
+
435
+ Four parts become two props.
436
+
437
+ | legacy | Alfred | |
438
+ | ------------------------- | ------------------------- | --------------------------------------------------------- |
439
+ | `<Dialog open onOpenChange>` | `<Dialog isOpen onOpenChange>` | the props move onto the panel itself |
440
+ | `<DialogContent>` | *delete* | `Dialog` **is** the content |
441
+ | `<DialogHeader>` | *delete* | replaced by the two props below |
442
+ | `<DialogTitle>x</DialogTitle>` | `title="x"` | **required** |
443
+ | `<DialogDescription>x</…>` | `description="x"` | |
444
+ | `<DialogClose>` | `<Button slot="close">` | React Aria's own dismiss wiring |
445
+ | `<DialogPortal>` / `<DialogOverlay>` | *no equivalent* | zero call sites; `ModalOverlay` does both |
446
+ | `asChild` | *no equivalent* | `DialogTrigger` takes the trigger element directly |
447
+
448
+ **Needs a decision per call site:** `LiveQAOverlay.tsx` renders a dialog with
449
+ no title. It now needs one — pass a visually hidden node if the design has no
450
+ room for a heading.
451
+
452
+ `isDismissable` defaults to **`true`**, matching Radix. React Aria's own
453
+ default is `false`; taking it would have silently stopped all seven legacy
454
+ call sites closing on an outside click. An `alertdialog` sets it `false`.
455
+
456
+ Two legacy behaviours are deliberately not carried: the enter/exit animation
457
+ (this repo ships no `tailwindcss-animate`; React Aria exposes `data-entering`
458
+ and `data-exiting` for whoever adds it), and the footer's `flex-col-reverse`,
459
+ which put DOM order and visual order in disagreement below `sm` — WCAG 1.3.2.
460
+
461
+ ## Input and Textarea
462
+
463
+ ```tsx
464
+ <Input type="email" placeholder="you@example.com" value={v} onChange={(e) => set(e.target.value)} />
465
+ <Textarea placeholder="Tell us more" value={v} onChange={(e) => set(e.target.value)} />
466
+ ```
467
+
468
+ **Both take native props.** React Aria's `Input` and `TextArea` are the two
469
+ components in this library that use `disabled` / `onChange(event)` rather
470
+ than `isDisabled` / `onChange(value)`, so all 51 legacy `Input` call sites and
471
+ all 3 `Textarea` ones migrate with **no prop changes at all**. They also read
472
+ React Aria's field contexts, so wrapping either in a `TextField` later works
473
+ without touching the call site.
474
+
475
+ They share one surface (`fieldSurfaceVariants`), so they cannot drift the way
476
+ the legacy pair did — different backgrounds, different focus rings, and
477
+ invalid styling on only one of them.
478
+
479
+ ### Migrating from the legacy Input / Textarea
480
+
481
+ | legacy | Alfred | |
482
+ | ---------------------- | ----------------- | ---------------------------------------------------------- |
483
+ | `value` / `onChange` | unchanged | still a real DOM event |
484
+ | `disabled` | unchanged | native |
485
+ | `aria-invalid` | unchanged | drives the invalid border with no prop of our own |
486
+ | `h-9` (36px) | density scale | **46px at the default**, matching Button |
487
+ | `text-base md:text-sm` | `text-base` | one size; 16px avoids iOS Safari zooming on focus |
488
+ | `min-h-[80px]` | `min-h-20` | same 80px, from the spacing scale |
489
+ | `relative` + `absolute left-3` + `pl-9` | `startIcon` / `endIcon` | **delete the wrapper and the padding** — see below |
490
+
491
+ ### Sizing a field
492
+
493
+ ```tsx
494
+ <Input size="sm" aria-label="Filter" />
495
+ <Select size="sm" label="Status">…</Select>
496
+ <Button size="sm">Apply</Button>
497
+ ```
498
+
499
+ `sm` | `md` | `lg`, default `md`, on the same `--density-control-*` tokens as
500
+ `Button` — so a field, a picker and a button on one rung are the same height.
501
+ In the legacy app 36 of 97 `Input` call sites and 14 of 32 `SelectTrigger` ones
502
+ hand-rolled a height, and a `size="sm"` Button (167 call sites) sat 10px off
503
+ the field it belonged to.
504
+
505
+ Two things worth knowing:
506
+
507
+ - **`size` replaces the native attribute.** React Aria inherits `size?: number`
508
+ (a character count) from `InputHTMLAttributes`; `InputProps` omits it. Set a
509
+ width with `className`. No legacy call site used it.
510
+ - **Type does not scale with the box.** The field stays at 16px on every rung.
511
+ Below that, iOS Safari zooms the viewport when the field takes focus.
512
+
513
+ `Textarea` has no `size` — it is `min-h` driven and no call site overrides its
514
+ height.
515
+
516
+ ### Icons in a field
517
+
518
+ ```tsx
519
+ <Input aria-label="Search" startIcon={<SearchIcon aria-hidden />} />
520
+ <Input aria-label="Target" endIcon={<span>days</span>} />
521
+ ```
522
+
523
+ The legacy app has **18 hand-rolled adornments** — a `relative` wrapper, an
524
+ absolutely positioned glyph and a hand-tuned `pl-9` / `pr-16`. All 18 use
525
+ physical properties (`absolute left-*` appears 33 times, `absolute start-*`
526
+ zero), so all 18 put the glyph on the wrong side under RTL and let the text
527
+ run over it. Replacing the wrapper with these props fixes that and puts the
528
+ padding on the density scale, so `data-density` moves the glyph and the text
529
+ together.
530
+
531
+ **Both slots are decorative.** They are `pointer-events: none`, so a click on
532
+ the glyph focuses the field. An interactive adornment — a password reveal, a
533
+ clear button — is not this prop; no call site in the census needed one.
534
+
535
+ Mark the glyph `aria-hidden`: the field's label is its accessible name, and a
536
+ second name source makes it announce twice.
537
+
538
+ **An invalid field needs a message.** The border changes colour, and colour
539
+ alone is not a state cue (WCAG 1.4.1). Pair `aria-invalid` with
540
+ `aria-describedby` pointing at the error text.
541
+
542
+ ## Label
543
+
544
+ ```tsx
545
+ <Label htmlFor="email">Email</Label>
546
+ ```
547
+
548
+ Inside a React Aria field (`Checkbox`, and `TextField` when it lands) the
549
+ association is automatic through context — no `htmlFor` needed.
550
+
551
+ ### Migrating from the legacy Label
552
+
553
+ | legacy | Alfred | |
554
+ | ------------------------------- | --------------- | ----------------------------------------------------- |
555
+ | `htmlFor` | `htmlFor` | unchanged |
556
+ | `peer-disabled:*` | *no equivalent* | needs `peer` on a sibling; nothing here supplies it |
557
+ | `group-data-[disabled=true]:*` | *no equivalent* | comes from the React Aria field instead |
558
+
559
+ ## Menu
560
+
561
+ ```tsx
562
+ <MenuTrigger>
563
+ <Button>Actions</Button>
564
+ <Menu onAction={(key) => run(key)} placement="bottom end">
565
+ <MenuItem id="edit">Edit</MenuItem>
566
+ <MenuSeparator />
567
+ <MenuItem id="delete">Delete</MenuItem>
568
+ </Menu>
569
+ </MenuTrigger>
570
+ ```
571
+
572
+ ### Migrating from the legacy DropdownMenu
573
+
574
+ Fifteen exports become four.
575
+
576
+ | legacy | Alfred | |
577
+ | ------------------------------- | ----------------- | --------------------------------------------------- |
578
+ | `<DropdownMenu>` | *delete* | `MenuTrigger` owns the state |
579
+ | `<DropdownMenuTrigger asChild>` | `<MenuTrigger>` | takes the trigger element directly |
580
+ | `<DropdownMenuContent align>` | `<Menu placement>` | `align="end"` becomes `placement="bottom end"` |
581
+ | `<DropdownMenuItem onClick>` | `<MenuItem id>` plus `onAction` on the Menu | the key comes from `id` |
582
+ | `<DropdownMenuSeparator>` | `<MenuSeparator>` | |
583
+ | Group, CheckboxItem, RadioItem, Label, Shortcut, Sub*, Portal | *no equivalent* | **zero call sites each** |
584
+
585
+ React Aria has `MenuSection`, `Header` and selection modes ready if any of
586
+ the dropped nine turns out to be wanted.
587
+
588
+ **RTL needs an `I18nProvider`.** React Aria's Popover stamps its own `dir`
589
+ from `useLocale()`, so `dir` on the document does **not** reach a portalled
590
+ menu.
591
+
592
+ ## Popover
593
+
594
+ ```tsx
595
+ <PopoverTrigger>
596
+ <Button>Pick a date</Button>
597
+ <Popover placement="bottom start">…</Popover>
598
+ </PopoverTrigger>
599
+ ```
600
+
601
+ **It gains an accessible name.** The legacy renders `role="dialog"` with no
602
+ `aria-label` and no `aria-labelledby`, so both call sites announce an unnamed
603
+ dialog. React Aria labels it from the trigger.
604
+
605
+ | legacy | Alfred | |
606
+ | ----------------------------- | ------------------- | -------------------------------------- |
607
+ | `<PopoverTrigger asChild>` | `<PopoverTrigger>` | takes the trigger element directly |
608
+ | `<PopoverContent align side>` | `<Popover placement>` | `align="start"` → `"bottom start"` |
609
+ | `<PopoverAnchor>` | *no equivalent* | zero call sites |
610
+
611
+ ## Progress
612
+
613
+ ```tsx
614
+ <Progress label="Upload" value={62} />
615
+ <Progress label="Loading results" isIndeterminate />
616
+ ```
617
+
618
+ **`label` is required.** `role="progressbar"` takes no name from content, and
619
+ neither legacy call site supplies one — both are announced as an unnamed
620
+ progressbar. Pass `labelHidden` when the surrounding UI already says it.
621
+
622
+ The value is **clamped**: the legacy's `translateX(-(100 - value)%)` slid the
623
+ indicator off-screen for `value > 100` while announcing the raw number.
624
+
625
+ `tone` (`neutral` · `success` · `warning` · `danger` · `info`) colours the fill.
626
+ The product's bar is a **score** bar, not a loading bar — a score of 30 is not
627
+ "30% loaded", it is bad, and the fill was the only thing saying so.
628
+
629
+ `showValue` prints the value beside the label. React Aria has always formatted
630
+ that text for assistive tech; nothing rendered it, so a sighted reader got a bar
631
+ and no number. `formatOptions` reaches it too, so a currency or "3 of 7" needs no
632
+ second prop. `trackClassName` and `fillClassName` are escape hatches for a colour
633
+ no tone covers.
634
+
635
+ ## RadioGroup
636
+
637
+ ```tsx
638
+ <RadioGroup value={plan} onChange={setPlan} aria-label="Plan">
639
+ <Radio value="free">Free</Radio>
640
+ <Radio value="pro">Pro</Radio>
641
+ </RadioGroup>
642
+ ```
643
+
644
+ ### Migrating from the legacy RadioGroup
645
+
646
+ | legacy | Alfred | |
647
+ | ----------------------------------- | ------------------------- | -------------------------------------- |
648
+ | `onValueChange` | `onChange` | |
649
+ | `<RadioGroupItem value id/>` + `<Label htmlFor>` | `<Radio value>label</Radio>` | the label moves inside; no id needed |
650
+
651
+ **The label moves inside the item.** That is the one rename a call site
652
+ makes, and it removes the id bookkeeping Radix required.
653
+
654
+ ## ScrollArea
655
+
656
+ ```tsx
657
+ <ScrollArea label="Chat messages" className="h-64">…</ScrollArea>
658
+ ```
659
+
660
+ React Aria has no equivalent, so this is a plain overflow container plus the
661
+ standard `scrollbar-width` / `scrollbar-color` properties — no JavaScript.
662
+
663
+ **`label` is required, and it is keyboard-focusable.** The legacy has no
664
+ `tabIndex` anywhere, so all 17 of its scroll areas are unreachable without a
665
+ pointer (WCAG 2.1.1). A focusable element needs a role, and a role needs a
666
+ name — the three go together.
667
+
668
+ `ScrollBar` and `orientation="horizontal"` are not ported: zero call sites.
669
+
670
+ ### What cannot be styled
671
+
672
+ `scrollbar-color` takes a thumb and a track colour and nothing else — no
673
+ pixel width, radius, hover state or margins. Those need `::-webkit-scrollbar`,
674
+ which Firefox has never supported.
675
+
676
+ ## Select
677
+
678
+ ```tsx
679
+ <Select label="Country" placeholder="Pick one"
680
+ selectedKey={code} onSelectionChange={setCode}>
681
+ <SelectItem id="gb">United Kingdom</SelectItem>
682
+ <SelectItem id="us">United States</SelectItem>
683
+ </Select>
684
+ ```
685
+
686
+ Four legacy parts collapse into one. The trigger shares
687
+ `fieldSurfaceVariants` with `Input`, so a select and a text field in the
688
+ same form row are the same box — the legacy had `h-10` here against
689
+ `h-9` on the input.
690
+
691
+ ### Migrating from the legacy Select
692
+
693
+ | legacy | Alfred | |
694
+ | -------------------------- | --------------------- | ---------------------------------------- |
695
+ | `<Select value onValueChange>` | `selectedKey` / `onSelectionChange` | |
696
+ | `<SelectTrigger>` / `<SelectValue placeholder>` / `<SelectContent>` | *delete* | `Select` is all three; `placeholder` is a prop |
697
+ | `<SelectItem value="x">` | `<SelectItem id="x">` | |
698
+ | `SelectGroup`, `SelectLabel`, `SelectSeparator`, scroll buttons | *no equivalent* | **zero call sites** |
699
+
700
+ **`label` is required.** The legacy exports a `SelectLabel` nobody renders
701
+ and passes no `aria-label`, so 12 of the 15 selects are visually labelled
702
+ by a bare `<label>` with no `htmlFor` — programmatically unlabelled.
703
+
704
+ **`isInvalid`, not `aria-invalid`.** A React Aria Select is a *button*, so there
705
+ is no native attribute to derive from — an `aria-invalid` passed to it is
706
+ dropped in silence by `filterDOMProps`, whose allowlist stops at the labelling
707
+ attributes.
708
+
709
+ Pair it with `errorMessage`. React Aria marks the *root* `data-invalid` and
710
+ leaves the button untouched, so without a message the invalid state is colour
711
+ only — WCAG 1.4.1. The message is wired into `aria-describedby` for you.
712
+
713
+ ```tsx
714
+ <Select label="Country" isInvalid errorMessage="Pick a country to continue." />
715
+ ```
716
+
717
+ ## Separator
718
+
719
+ ```tsx
720
+ <Separator />
721
+ <Separator orientation="vertical" />
722
+ ```
723
+
724
+ React Aria renders an `<hr>` horizontally and a `<div role="separator">`
725
+ vertically.
726
+
727
+ ### Migrating from the legacy Separator
728
+
729
+ | legacy | Alfred | |
730
+ | ------------- | --------------- | ------------------------------------------------------------ |
731
+ | `orientation` | `orientation` | unchanged |
732
+ | `decorative` | *no equivalent* | zero call sites; see below |
733
+
734
+ **This changes the semantics of all six existing uses.** The legacy defaulted
735
+ to `decorative: true`, rendering `role="none"` — so every divider in the app
736
+ is currently invisible to assistive technology. A visible line between two
737
+ groups of content is a separator, and that is now what it reports.
738
+
739
+ ## Sheet
740
+
741
+ ```tsx
742
+ <Sheet isOpen={open} onOpenChange={setOpen} title="Filters" side="right">
743
+ <DialogFooter>
744
+ <Button slot="close" variant="ghost">Cancel</Button>
745
+ <Button>Apply</Button>
746
+ </DialogFooter>
747
+ </Sheet>
748
+ ```
749
+
750
+ A preset over `Dialog`, so it inherits the scrim, the focus trap, Escape,
751
+ the close button — and the **required `title`**.
752
+
753
+ ### Migrating from the legacy Sheet
754
+
755
+ | legacy | Alfred | |
756
+ | ------------------------- | ----------------- | --------------------------------------------- |
757
+ | `<Sheet open onOpenChange>` + `<SheetContent side>` | `<Sheet isOpen onOpenChange side>` | one element |
758
+ | `<SheetHeader>` / `Title` / `Description` | `title` / `description` props | **zero call sites** in the legacy |
759
+ | `<SheetClose>` | `<Button slot="close">` | zero call sites |
760
+ | `side="top" \| "bottom"` | *no equivalent* | zero call sites; two lines to add |
761
+
762
+ **All three legacy sheets are unnamed dialogs** — none renders a
763
+ `SheetTitle`. `title` is required now, so that is no longer expressible.
764
+
765
+ **A width bug the port fixes:** the legacy panel is `w-3/4 sm:max-w-sm`,
766
+ and `sm:max-w-sm` names `--container-sm`, which this repo does not define.
767
+ It would compile to nothing and leave the sheet at 75% of the viewport on
768
+ every screen. The width here is a spacing-scale step that resolves.
769
+
770
+ `side` is logical: in RTL the sides swap.
771
+
772
+ `onBack` adds a back control above the heading — for a panel that goes a level
773
+ deeper by *replacing* its contents rather than stacking a second modal, where
774
+ escape and browser-back both close the whole thing instead of returning one
775
+ level. It renders above the heading, not inside it: a button inside the `<h2>`
776
+ React Aria names the dialog from would make the accessible name "Back Filters".
777
+
778
+ The body is a `ScrollArea`, so the heading and back control stay put while
779
+ content moves, and the scrolling region is keyboard-reachable — a plain
780
+ `overflow-y-auto` div fails WCAG 2.1.1, which axe caught here and which the
781
+ product's own `SidePanelBody` still ships. `scrollResetKey` scrolls it back to
782
+ the top when the contents change.
783
+
784
+ ## Skeleton
785
+
786
+ ```tsx
787
+ <Skeleton className="h-4 w-32" />
788
+ ```
789
+
790
+ Sized entirely by the caller, as all 33 legacy call sites do. `aria-hidden`,
791
+ because a placeholder is not content — put `aria-busy` on the region being
792
+ replaced.
793
+
794
+ The pulse is gated behind `motion-safe:`.
795
+
796
+ ## Slider
797
+
798
+ ```tsx
799
+ <Slider label="Input volume" defaultValue={40} />
800
+ ```
801
+
802
+ **`label` is required** — all four legacy call sites are unnamed sliders.
803
+
804
+ The thumb is 24×24 (WCAG 2.5.8); the legacy's was 16. `aria-valuetext` comes
805
+ free, so a scrubber can announce a time instead of a raw second count.
806
+
807
+ ## Switch
808
+
809
+ ```tsx
810
+ <Switch isSelected={on} onChange={setOn}>Email notifications</Switch>
811
+ <Switch aria-label="Mute" isSelected={muted} onChange={setMuted} />
812
+ ```
813
+
814
+ ### Migrating from the legacy Switch
815
+
816
+ | legacy | Alfred | |
817
+ | ------------------ | ------------- | ----------------------------------------------------- |
818
+ | `checked` | `isSelected` | |
819
+ | `onCheckedChange` | `onChange` | still `(boolean) => void` |
820
+ | `disabled` | `isDisabled` | |
821
+ | *(no label slot)* | `children` | a Radix Switch with no `aria-label` had **no name** |
822
+
823
+ **The track colours are not the legacy's, and could not be.** A white thumb
824
+ on `--color-primary` is 2.44:1 and on `--color-muted` is 1.13:1 — both fail
825
+ WCAG 1.4.11. The track is `border` unchecked and `primary-strong` checked,
826
+ which clears 3:1 in both themes.
827
+
828
+ ## Table
829
+
830
+ ```tsx
831
+ import { Table, createColumns } from "@seekalfred/primitives";
832
+
833
+ const { column } = createColumns<Channel>();
834
+
835
+ const columns = useMemo(
836
+ () => [
837
+ column({ id: "name", header: "Channel", accessor: (r) => r.name }),
838
+ column({
839
+ id: "cost",
840
+ header: "Cost",
841
+ accessor: (r) => r.cost,
842
+ format: (v) => currency(v),
843
+ align: "end",
844
+ sortable: true,
845
+ }),
846
+ ],
847
+ [],
848
+ );
849
+
850
+ <Table aria-label="Channels" data={rows} columns={columns} getRowId={(r) => r.id} />;
851
+ ```
852
+
853
+ `aria-label` is required: React Aria's Table renders no `<caption>`, so there is
854
+ no other source for the accessible name.
855
+
856
+ **Sorting works without wiring.** `sortable: true` is the whole setup — the
857
+ table holds the descriptor and reorders `data` itself, and `aria-sort` comes
858
+ from React Aria. Pass `sortDescriptor` to take control instead, which is what
859
+ server-side sorting needs; then you own the row order.
860
+
861
+ **Pagination needs no state either.** `pageSize` renders the bar and holds the
862
+ page. Sorting is applied to the whole set *before* the page is sliced from it,
863
+ so "cost ascending" surfaces the cheapest row in the data rather than the
864
+ cheapest of the rows you happened to be looking at. Its labels are props
865
+ (`pageLabels`) with English defaults — a known debt until `useMessages()` exists.
866
+
867
+ The bar is the product's shape: page numbers on the leading edge — first and
868
+ last always reachable, the gap between them an ellipsis — and rows-per-page on
869
+ the trailing edge as a `Select`, once you pass `pageSizeOptions`. A size the
870
+ reader chooses outranks `pageSize` from then on, and `onPageSizeChange` tells
871
+ you about it. The current page announces itself through its accessible *name*
872
+ rather than `aria-current`, because React Aria's Button runs its props through
873
+ `filterDOMProps` and drops anything outside the labelling set.
874
+
875
+ **Density is not a prop.** Cell padding reads `--density-row-pad-*`, so
876
+ `data-density="compact"` on any ancestor compresses the table along with the
877
+ rest of the screen.
878
+
879
+ **The card is the component.** The table renders inside a rounded, bordered
880
+ container, because that is where the WCAG 1.4.11 boundary obligation lands.
881
+ Row dividers use `--color-separator`, a decorative hairline, NOT `--color-border`
882
+ — drawn in `border` a data table reads as a grid of boxes rather than a list
883
+ of rows. A chevron appears on each row when `onRowAction` is set, and a
884
+ direction caret on the sorted column; both are `aria-hidden` decoration.
885
+
886
+ Row height comes from the density scope, not from the component. For the
887
+ product's taller proportions set `data-density="spacious"` on an ancestor.
888
+
889
+ **Overriding styles.** Every element carries a `data-slot`
890
+ (`table-scroll`, `table`, `table-header`, `table-column`, `table-row`,
891
+ `table-cell`). Consumer CSS is unlayered and ours is layered, so
892
+ `[data-slot="table-cell"] { … }` wins without `!important`. For styling that
893
+ depends on row *data*, use `rowClassName`.
894
+
895
+ | prop | |
896
+ | --- | --- |
897
+ | `variant` | `lined` (default) · `plain` |
898
+ | `Column.align` | `start` · `center` · `end`, applied to the header **and** its cells |
899
+ | `Column.width` | number or `%`; any width switches the table to `table-layout: fixed` |
900
+ | `onRowAction` | receives the **row**, not its key |
901
+ | `rowClassName` | `(row) => string` |
902
+ | `empty` | rendered instead of rows; no default copy |
903
+ | `minWidth` | px; below it the built-in wrapper scrolls |
904
+ | `pageSize` | rows per page; the bar renders itself, and sorting is applied before the slice |
905
+ | `pageSizeOptions` | adds the rows-per-page select; the reader's choice then outranks `pageSize` |
906
+ | `onPageSizeChange` | notified after the table has applied the reader's new size |
907
+ | `isRowHighlighted` | `(row) => boolean`; tints the viewer's own row |
908
+ | `frame` | `card` (default) · `none` — off for a table already inside a card |
909
+ | `headerType` | `sentence` (default) · `caps` — uppercase with tracking |
910
+ | `headerFill` | `subtle` (default) · `none` |
911
+ | `striped` | zebra-stripes alternate rows |
912
+ | `totalRow` | `(column) => ReactNode`; renders a real `<tfoot>` |
913
+ | `Column.numeric` | tabular numerals, end-aligned by default |
914
+
915
+ ### Using it in alfred-ui
916
+
917
+ **No theme override is needed.** Measured against the running product, the two
918
+ already agree on every value that matters:
919
+
920
+ | | alfred-ui | this component |
921
+ | --- | --- | --- |
922
+ | card edge | `#e6e6e6`, radius 12px | same |
923
+ | row divider | `#e6e6e6`, on the row's TOP | same |
924
+ | header label | `#6b6b78`, 14px, 600 | same |
925
+ | cell text | `#02021e`, 16px, 400 | same |
926
+ | cell padding | 16px / 12px | same |
927
+ | typeface | Satoshi | same |
928
+
929
+ The text colours were never a gap: the product's `--muted-foreground` is
930
+ `#6B6B78` and its body ink is `#02021e`, which are this system's
931
+ `--color-muted-foreground` and `--color-foreground` exactly. An earlier draft of
932
+ this section claimed otherwise and prescribed `#0f172b` / `#6b6b6b`; those come
933
+ from `--table-text-*`, which the AI-visibility tables do not use. Do not apply
934
+ them.
935
+
936
+ One value differs and it is deliberate: the product fills its header with
937
+ `--neutral-200` (`#fffcfc`) where this uses `--color-background` (`#ffffff`).
938
+ That is three counts of warmth in two channels, invisible on a white card, and
939
+ not worth a palette entry. If you want it exactly, override
940
+ `--color-background` for the header only, or say so and it becomes a token.
941
+
942
+ ### Migrating from the legacy DataTable
943
+
944
+ | legacy | Alfred | |
945
+ | --- | --- | --- |
946
+ | `<DataTable>{renderProps => …}</DataTable>` | `<Table data columns getRowId />` | the component owns the markup; there is no render prop |
947
+ | `config={{ sorting: { enabled: false }, … }}` | *(omit)* | every feature is opt-in, so there is nothing to disable |
948
+ | `ColumnDef` (TanStack) | `Column<T, V>` | built with `createColumns<T>()` so each column keeps its value type |
949
+ | `meta.align` | `Column.align` | |
950
+ | `meta.width` / `<colgroup>` / width classes | `Column.width` | one mechanism instead of three |
951
+ | `onRowClick(row)` | `onRowAction(row)` | also fires on Enter |
952
+ | `rowClassName` | unchanged | |
953
+ | `rowIdField="keywordId"` | `getRowId={(r) => r.keywordId}` | |
954
+
955
+ Row expansion, sticky headers, loading skeletons, pinned columns, the total
956
+ row, filtering, column visibility and CSV export are **not ported** — each had
957
+ one consumer or none across the six legacy tables, and they belong to a future
958
+ `DataGrid`.
959
+
960
+ ## Tabs
961
+
962
+ ```tsx
963
+ <Tabs defaultSelectedKey="overview">
964
+ <TabList aria-label="Sections">
965
+ <Tab id="overview">Overview</Tab>
966
+ <Tab id="activity">Activity</Tab>
967
+ </TabList>
968
+ <TabPanel id="overview">…</TabPanel>
969
+ <TabPanel id="activity">…</TabPanel>
970
+ </Tabs>
971
+ ```
972
+
973
+ ### Migrating from the legacy Tabs
974
+
975
+ | legacy | Alfred | |
976
+ | ----------------- | ----------------------- | ------------------------------------------ |
977
+ | `value` | `selectedKey` | |
978
+ | `defaultValue` | `defaultSelectedKey` | |
979
+ | `onValueChange` | `onSelectionChange` | |
980
+ | `<TabsTrigger value="x">` | `<Tab id="x">` | paired to the panel by `id` |
981
+ | `<TabsContent value="x">` | `<TabPanel id="x">` | |
982
+ | `disabled` | `isDisabled` | |
983
+
984
+ **Only the selected panel is in the DOM.** Radix kept every mounted panel
985
+ and hid the inactive ones; React Aria renders one. A panel holding
986
+ uncommitted form state, a running video or a scroll position will lose it
987
+ on tab change — pass `shouldForceMount` on that `TabPanel`. With 31 legacy
988
+ panels, some will need it.
989
+
990
+ The selected tab carries a **border**, not just a fill: `bg-background` on
991
+ a `bg-muted` list is 1.13:1, and WCAG 1.4.11 wants 3:1 for the thing
992
+ telling you which tab is active.
993
+
994
+ ## Toast
995
+
996
+ ```tsx
997
+ // once, near the root
998
+ <ToastRegion label="Notifications" />
999
+
1000
+ // anywhere, including outside React
1001
+ toast({ title: "Report saved" });
1002
+ toast({ title: "Upload failed", description: err.message, tone: "danger" });
1003
+ ```
1004
+
1005
+ **Experimental.** React Aria ships this API as `UNSTABLE_Toast*` in 1.21.1, so a
1006
+ minor bump upstream can reshape it. The alternative was hand-rolling the live
1007
+ region, queue, timers and focus behaviour — which is how toast implementations
1008
+ acquire their accessibility bugs.
1009
+
1010
+ The queue is a module singleton, so `toast()` is importable from anywhere: the
1011
+ product calls it from API error handlers and effects with no component to hang a
1012
+ hook off, and a context-based queue would turn 67 call sites into 67 refactors.
1013
+
1014
+ Two legacy defects are **not** ported. `TOAST_LIMIT = 1` meant a second toast
1015
+ *replaced* the first, so a page reporting three failures showed one;
1016
+ `maxVisibleToasts` is 3 here. `TOAST_REMOVE_DELAY = 1000000` — sixteen minutes —
1017
+ meant nothing auto-dismissed; `timeout` defaults to 5s. React Aria refuses a
1018
+ timeout under 5000ms and pauses it while the region is focused, because a message
1019
+ that vanishes before it can be read fails WCAG 2.2.1. Pass `0` for an error the
1020
+ user must act on.
1021
+
1022
+ `tone` matches Badge's vocabulary. The legacy had `default` and `destructive`
1023
+ only, so 38 of 67 calls passed a variant that could not say "this worked".
1024
+
1025
+ ## Tooltip
1026
+
1027
+ ```tsx
1028
+ <TooltipTrigger>
1029
+ <Button aria-label="Mute"><MicOff /></Button>
1030
+ <Tooltip>Mute</Tooltip>
1031
+ </TooltipTrigger>
1032
+ ```
1033
+
1034
+ **`delay` defaults to 0**, matching the legacy provider. React Aria ships
1035
+ 1500ms — taking that default would have put a 1.5-second warm-up on every
1036
+ tooltip in the app. Focus opens it immediately regardless.
1037
+
1038
+ | legacy | Alfred | |
1039
+ | -------------------------- | ----------------- | -------------------------------------------- |
1040
+ | `<TooltipProvider>` | *delete* | 23 call sites; the legacy `Tooltip` already wrapped itself in one |
1041
+ | `<Tooltip>` + `<TooltipTrigger asChild>` | `<TooltipTrigger>` | one wrapper, trigger element direct |
1042
+ | `<TooltipContent side>` | `<Tooltip placement>` | |
1043
+
1044
+ **RTL differs between the two.** Popover stamps its own `dir` from
1045
+ `useLocale()`, so it needs an `I18nProvider`. Tooltip sets no `dir` at all and
1046
+ inherits from the document. Set both if you support RTL.