@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -72,7 +72,7 @@ export const CopyableValue = forwardRef<HTMLButtonElement, CopyableValueProps>(
72
72
  // only resting affordance, so a value inside a heading or a KPI
73
73
  // still looks like a value, not a link.
74
74
  "inline-flex items-baseline gap-1 rounded-sm underline decoration-dotted decoration-muted-foreground underline-offset-4",
75
- "hover:decoration-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
75
+ "hover:decoration-foreground focus-ring",
76
76
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
77
77
  className,
78
78
  )}
@@ -149,7 +149,7 @@ export const DialogContent = forwardRef<
149
149
  {children}
150
150
  <DialogPrimitive.Close
151
151
  data-slot="dialog-close"
152
- className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
152
+ className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
153
153
  aria-label="Close"
154
154
  >
155
155
  <svg
@@ -275,7 +275,7 @@ export const DialogBody = forwardRef<HTMLDivElement, DialogBodyProps>(function D
275
275
  // focusable content.
276
276
  tabIndex={tabIndex ?? 0}
277
277
  className={cn(
278
- "-m-1 min-h-0 overflow-y-auto overscroll-contain p-1 scroll-p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
278
+ "-m-1 min-h-0 overflow-y-auto overscroll-contain p-1 scroll-p-1 focus-ring",
279
279
  className,
280
280
  )}
281
281
  {...props}
@@ -147,7 +147,7 @@ export const SubmenuRadioAndCheckbox: Story = {
147
147
  };
148
148
 
149
149
  /**
150
- * Three-theme sweep (a durable, `test-storybook`-run artifact rather than a
150
+ * Cross-theme sweep (a durable, `test-storybook`-run artifact rather than a
151
151
  * one-off manual `preview.tsx` toggle). `<ThemeProvider>` (not a plain
152
152
  * `data-theme` wrapper `<div>`) because it writes the attribute onto the
153
153
  * DOCUMENT ROOT — this menu's Radix `Portal` mounts its content on
@@ -1,11 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
1
4
  import { describe, expect, it } from "vitest";
2
5
  import { render, screen } from "@testing-library/react";
3
6
  import { EmptyState } from "./empty-state";
4
7
 
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const source = readFileSync(join(__dirname, "empty-state.tsx"), "utf8");
10
+
5
11
  describe("EmptyState", () => {
6
12
  it("renders title and description", () => {
7
13
  render(<EmptyState title="Empty" description="Nothing here" />);
8
14
  expect(screen.getByText("Empty")).toBeInTheDocument();
9
15
  expect(screen.getByText("Nothing here")).toBeInTheDocument();
10
16
  });
17
+
18
+ it("clamps a passed icon to the fixed 40×40 slot, matching its documented behavior (#47)", () => {
19
+ // EmptyState only ever forwards `icon` to StatePanel, whose fixed-size
20
+ // icon slot clamps it via `[&_svg]:size-10` — it never forwards
21
+ // `illustration`, whose slot is unclamped. Lock the REAL behavior so it
22
+ // can't silently drift further from whatever the JSDoc claims.
23
+ render(<EmptyState title="X" icon={<svg data-testid="passed-icon" />} />);
24
+ const svg = screen.getByTestId("passed-icon");
25
+ expect(svg.parentElement?.className ?? "").toContain("size-10");
26
+ });
27
+
28
+ it("JSDoc claims only what `icon` actually does — does not promise an unclamped illustration slot (#47)", () => {
29
+ // #47 finding C: the JSDoc said "icon/illustration" but the component
30
+ // only ever forwards `icon` (still clamped to 40x40). A reader passing a
31
+ // larger illustration through `icon` expecting it to render at full size
32
+ // would be surprised by the clamp. The doc must describe the clamp, not
33
+ // the capability the deprecated wrapper doesn't have.
34
+ expect(source).not.toMatch(/icon\/illustration/i);
35
+ expect(source).toMatch(/clamp/i);
36
+ });
11
37
  });
@@ -8,7 +8,12 @@ import { StatePanel } from "../state-panel/state-panel";
8
8
  export interface EmptyStateProps {
9
9
  title: ReactNode;
10
10
  description?: ReactNode;
11
- /** Optional icon/illustration shown above the title. */
11
+ /**
12
+ * Optional icon shown above the title, clamped to a fixed 40×40 (this
13
+ * wrapper only ever forwards `icon` to `StatePanel`, never `illustration`
14
+ * — for a larger, unclamped illustration use
15
+ * `StatePanel kind="empty" illustration={...}` directly).
16
+ */
12
17
  icon?: ReactNode;
13
18
  /** Primary/secondary actions. */
14
19
  actions?: ReactNode;
@@ -111,8 +111,7 @@ export const expandDialogPanesVariants = cva("grid min-h-0 gap-3 overflow-hidden
111
111
  * and a pane holding only text has no focusable child to scroll from. Pass
112
112
  * `tabIndex={-1}` when the pane always contains its own focusable content.
113
113
  */
114
- const paneClass =
115
- "min-h-0 min-w-0 overflow-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
114
+ const paneClass = "min-h-0 min-w-0 overflow-auto overscroll-contain focus-ring";
116
115
 
117
116
  export interface ExpandDialogPanesProps
118
117
  extends
@@ -0,0 +1,59 @@
1
+ import { createContext, use } from "react";
2
+
3
+ /**
4
+ * Shared, lifted state for the `Field*` compound anatomy (`FieldRoot` +
5
+ * `FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError`). `FieldRoot`
6
+ * is the only place this state is produced; every part reads the INTERFACE
7
+ * below rather than receiving positional props, so parts can be composed in
8
+ * any order/layout (compound-component + lifted-state convention — see
9
+ * `.claude/rules/component-api.md` "Composition patterns").
10
+ */
11
+ export interface FieldContextValue {
12
+ /**
13
+ * The id `FieldLabel`'s `htmlFor` points to — the FIRST `FieldControl`
14
+ * mounted under this `FieldRoot`, by JSX order (self-registered; see
15
+ * `registerControl`). `undefined` until a control has registered.
16
+ */
17
+ labelFor: string | undefined;
18
+ /** Registers a `FieldControl`'s resolved id (called while it is mounted). */
19
+ registerControl: (id: string) => void;
20
+ /** Unregisters a `FieldControl`'s id (called on unmount / when its id changes). */
21
+ unregisterControl: (id: string) => void;
22
+ /** `FieldRoot`'s `invalid` prop — drives `aria-invalid` on every `FieldControl` and error styling on `FieldLabel`. */
23
+ invalid: boolean;
24
+ /** `FieldRoot`'s `required` prop — drives `aria-required` on every `FieldControl`. */
25
+ required: boolean;
26
+ /**
27
+ * The composed `aria-describedby` value from every mounted `FieldDescription`/
28
+ * `FieldError` instance WITH content, descriptions first (in mount order),
29
+ * then errors (in mount order) — a fixed semantic order regardless of DOM
30
+ * position, so more than one of either part (or reordering them relative to
31
+ * `FieldControl`) never scrambles the announced order. `undefined` when
32
+ * nothing is registered.
33
+ */
34
+ describedBy: string | undefined;
35
+ /**
36
+ * Registers one `FieldDescription` INSTANCE's own id (called while it has
37
+ * content). Each instance generates and registers its own id — this is not
38
+ * a single shared slot, so more than one `FieldDescription` under one
39
+ * `FieldRoot` is supported without id collisions.
40
+ */
41
+ registerDescription: (id: string) => void;
42
+ /** Unregisters one `FieldDescription` instance's id (on unmount / when content is cleared). Removes only that instance's id. */
43
+ unregisterDescription: (id: string) => void;
44
+ /** Registers one `FieldError` instance's own id (called while it has content). Same per-instance contract as `registerDescription`. */
45
+ registerError: (id: string) => void;
46
+ /** Unregisters one `FieldError` instance's id (on unmount / when content is cleared). Removes only that instance's id. */
47
+ unregisterError: (id: string) => void;
48
+ }
49
+
50
+ export const FieldContext = createContext<FieldContextValue | null>(null);
51
+
52
+ /** Reads the shared `Field*` context; throws with the calling component's name when used outside a `FieldRoot`. */
53
+ export function useFieldContext(component: string): FieldContextValue {
54
+ const ctx = use(FieldContext);
55
+ if (!ctx) {
56
+ throw new Error(`<${component}> must be rendered inside a <FieldRoot>.`);
57
+ }
58
+ return ctx;
59
+ }
@@ -0,0 +1,142 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
3
+ import { Input } from "../input";
4
+ import { FieldRoot, FieldLabel, FieldControl, FieldDescription, FieldError } from "./field";
5
+
6
+ const meta = {
7
+ title: "Forms/Field",
8
+ component: FieldRoot,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "A compound field anatomy — `FieldRoot` + `FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError` — for callers who need to COMPOSE a field layout `FieldRow` (Forms/FieldRow) can't express: more than one control in one row, or a description placed before the control. Every part reads shared context (`FieldRoot` owns id generation and `aria-describedby` composition), so they can be arranged in any order/layout. `FieldRow` remains the convenience wrapper for the common single-control case and is unaffected (#43).",
15
+ },
16
+ },
17
+ },
18
+ } satisfies Meta<typeof FieldRoot>;
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Default: Story = {
23
+ render: () => (
24
+ <div className="w-72">
25
+ <FieldRoot>
26
+ <FieldLabel>Name</FieldLabel>
27
+ <FieldControl>
28
+ <Input placeholder="Jane Doe" />
29
+ </FieldControl>
30
+ </FieldRoot>
31
+ </div>
32
+ ),
33
+ play: async ({ canvas }) => {
34
+ const input = canvas.getByRole("textbox");
35
+ expect(input).not.toHaveAttribute("aria-describedby");
36
+ expect(input).toHaveAttribute("aria-invalid", "false");
37
+ expect(canvas.getByText("Name")).toHaveAttribute("for", input.id);
38
+ },
39
+ };
40
+
41
+ export const WithDescriptionAndError: Story = {
42
+ name: "With description and error",
43
+ render: () => (
44
+ <div className="w-72">
45
+ <FieldRoot invalid required>
46
+ <FieldLabel>API key</FieldLabel>
47
+ <FieldControl>
48
+ <Input placeholder="sk-…" />
49
+ </FieldControl>
50
+ <FieldDescription>Found in your account settings.</FieldDescription>
51
+ <FieldError>Enter a valid API key.</FieldError>
52
+ </FieldRoot>
53
+ </div>
54
+ ),
55
+ play: async ({ canvas }) => {
56
+ const input = canvas.getByRole("textbox");
57
+ expect(input).toHaveAttribute("aria-invalid", "true");
58
+ expect(input).toHaveAttribute("aria-required", "true");
59
+ const describedBy = input.getAttribute("aria-describedby")?.split(" ") ?? [];
60
+ expect(describedBy).toHaveLength(2);
61
+ expect(canvas.getByText("Found in your account settings.").id).toBe(describedBy[0]);
62
+ const alert = canvas.getByRole("alert");
63
+ expect(alert).toHaveTextContent("Enter a valid API key.");
64
+ expect(alert.id).toBe(describedBy[1]);
65
+ },
66
+ };
67
+
68
+ /**
69
+ * #43's first motivating case: `FieldRow`'s `children: ReactElement` can only
70
+ * ever wrap ONE control, so a first/last-name row (or a control plus an
71
+ * inline unit suffix) can't be expressed. Here two `FieldControl`s share one
72
+ * `FieldRoot` — each gets its own independent `id` (given explicitly, the
73
+ * same way any two form controls would need distinct ids) while both read
74
+ * the SAME `invalid`/`aria-describedby` wiring from context. `FieldLabel`'s
75
+ * `htmlFor` can only point to ONE control (the first), so each `Input` also
76
+ * carries its own `aria-label` — a shared visual label plus a placeholder is
77
+ * not enough of an accessible name for the second control (axe:
78
+ * `label-title-only`).
79
+ */
80
+ export const TwoControlsInOneRow: Story = {
81
+ name: "Two controls in one row",
82
+ render: () => (
83
+ <div className="w-96">
84
+ <FieldRoot invalid>
85
+ <FieldLabel>Name</FieldLabel>
86
+ <div className="flex gap-2">
87
+ <FieldControl>
88
+ <Input id="first-name" aria-label="First name" placeholder="First name" />
89
+ </FieldControl>
90
+ <FieldControl>
91
+ <Input id="last-name" aria-label="Last name" placeholder="Last name" />
92
+ </FieldControl>
93
+ </div>
94
+ <FieldError>Both first and last name are required.</FieldError>
95
+ </FieldRoot>
96
+ </div>
97
+ ),
98
+ play: async ({ canvas }) => {
99
+ const first = canvas.getByPlaceholderText("First name");
100
+ const last = canvas.getByPlaceholderText("Last name");
101
+ expect(first.id).toBe("first-name");
102
+ expect(last.id).toBe("last-name");
103
+ expect(first.id).not.toBe(last.id);
104
+
105
+ const alert = canvas.getByRole("alert");
106
+ expect(first).toHaveAttribute("aria-invalid", "true");
107
+ expect(last).toHaveAttribute("aria-invalid", "true");
108
+ expect(first.getAttribute("aria-describedby")).toBe(alert.id);
109
+ expect(last.getAttribute("aria-describedby")).toBe(alert.id);
110
+ },
111
+ };
112
+
113
+ /**
114
+ * #43's second motivating case: `FieldRow` always renders description AFTER
115
+ * the control. Here `FieldDescription` is placed BEFORE `FieldControl` in
116
+ * JSX — the visible order follows the JSX order, but `aria-describedby`
117
+ * wiring is unaffected either way (`FieldRoot` composes it from context, not
118
+ * DOM position).
119
+ */
120
+ export const DescriptionBeforeControl: Story = {
121
+ name: "Description between label and control",
122
+ render: () => (
123
+ <div className="w-72">
124
+ <FieldRoot>
125
+ <FieldLabel>Bio</FieldLabel>
126
+ <FieldDescription>Shown on your public profile.</FieldDescription>
127
+ <FieldControl>
128
+ <Input placeholder="Tell us about yourself…" />
129
+ </FieldControl>
130
+ </FieldRoot>
131
+ </div>
132
+ ),
133
+ play: async ({ canvas }) => {
134
+ const input = canvas.getByRole("textbox");
135
+ const description = canvas.getByText("Shown on your public profile.");
136
+ expect(input.getAttribute("aria-describedby")).toBe(description.id);
137
+ // The description renders before the control in the DOM, matching JSX order.
138
+ expect(
139
+ description.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING,
140
+ ).toBeTruthy();
141
+ },
142
+ };