@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
@@ -8,16 +8,43 @@ import {
8
8
  } from "react";
9
9
  import { Slot } from "@radix-ui/react-slot";
10
10
  import { cn } from "../../lib/cn";
11
+ import { hasRenderableContent } from "../../lib/has-renderable-content";
11
12
  import { Label } from "../label";
12
13
 
13
14
  export interface FieldRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
14
15
  /** Field label — rendered as a real `<label>`, click-to-focus associated with the control. */
15
16
  label: ReactNode;
16
- /** Optional help/description text, always visible (not gated by an error). */
17
+ /**
18
+ * Optional help/description text, always visible (not gated by an error).
19
+ * Renders nothing — and contributes no id to `aria-describedby` — when
20
+ * `description` is falsy (`false`/`0`/`""`/`null`/`undefined`), an empty
21
+ * array, or an array containing only falsy values (`{list.map(...)}` on an
22
+ * empty list; `[a && "x", b && "y"]` with both false).
23
+ *
24
+ * **Known limit:** a value that is itself a COMPONENT that renders nothing
25
+ * (returns `null`/an empty fragment) is not knowable before render, so it
26
+ * still produces an empty paragraph that `aria-describedby` points at.
27
+ * `FieldDescription` and the wider React ecosystem share this limit — pass
28
+ * a falsy value instead of a component that may render nothing, e.g.
29
+ * `description={hasHint ? hintText : undefined}`, never
30
+ * `description={<MaybeRendersNull />}`.
31
+ */
17
32
  description?: ReactNode;
18
33
  /**
19
34
  * Validation error text. When present: sets `aria-invalid` on the control,
20
35
  * is appended to `aria-describedby`, and is announced via `role="alert"`.
36
+ * Renders nothing — and leaves `aria-invalid="false"` — when `error` is
37
+ * falsy (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
38
+ * containing only falsy values (`{errors.map(...)}` on an empty list — the
39
+ * idiomatic "no errors" shape).
40
+ *
41
+ * **Known limit:** a value that is itself a COMPONENT that renders nothing
42
+ * (returns `null`/an empty fragment) is not knowable before render, so it
43
+ * still produces an empty `role="alert"` element that `aria-describedby`
44
+ * points at. `FieldError` and the wider React ecosystem share this limit —
45
+ * pass a falsy value instead of a component that may render nothing, e.g.
46
+ * `error={hasError ? message : undefined}`, never
47
+ * `error={<MaybeRendersNull />}`.
21
48
  */
22
49
  error?: ReactNode;
23
50
  /**
@@ -62,11 +89,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
62
89
  const childProps = control.props;
63
90
  const controlId = childProps.id ?? `${id}-control`;
64
91
 
92
+ // Same predicate `FieldDescription`/`FieldError` use (`../field/field.tsx`)
93
+ // — bare truthiness (`description ? … : null`) treats an empty/all-falsy
94
+ // ARRAY as content, since every array is truthy in JS. `errors.map(...)`
95
+ // on an empty list is the idiomatic "no errors" shape, so that gap is a
96
+ // real defect here, not a hypothetical (#93).
97
+ const hasDescription = hasRenderableContent(description);
98
+ const hasError = hasRenderableContent(error);
99
+
65
100
  const describedBy =
66
101
  [
67
102
  childProps["aria-describedby"] ?? null,
68
- description ? descriptionId : null,
69
- error ? errorId : null,
103
+ hasDescription ? descriptionId : null,
104
+ hasError ? errorId : null,
70
105
  ]
71
106
  .filter(Boolean)
72
107
  .join(" ") || undefined;
@@ -86,12 +121,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
86
121
  <Slot
87
122
  id={controlId}
88
123
  aria-describedby={describedBy}
89
- aria-invalid={!!error}
124
+ // `FieldRoot.invalid` (`../field/field.tsx`) is intentionally
125
+ // INDEPENDENT of whether a `FieldError` with content is mounted — a
126
+ // caller can mark the control invalid before its message settles.
127
+ // `FieldRow` has no such prop: `error` is its only invalidity
128
+ // signal, so "no error content" must mean "not invalid" — deriving
129
+ // this from raw `!!error` marked `error={[]}` invalid with nothing
130
+ // to explain why (#93).
131
+ aria-invalid={hasError}
90
132
  data-slot="field-row-control"
91
133
  >
92
134
  {cloneElement(control, { id: controlId, "aria-describedby": describedBy })}
93
135
  </Slot>
94
- {description ? (
136
+ {hasDescription ? (
95
137
  <p
96
138
  id={descriptionId}
97
139
  data-slot="field-row-description"
@@ -100,7 +142,7 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
100
142
  {description}
101
143
  </p>
102
144
  ) : null}
103
- {error ? (
145
+ {hasError ? (
104
146
  <p
105
147
  id={errorId}
106
148
  role="alert"
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent, within } from "storybook/test";
3
4
  import {
4
5
  FileUpload,
5
6
  FileUploadDropzone,
@@ -106,6 +107,21 @@ export const Default: Story = {
106
107
  </FileUpload>
107
108
  </div>
108
109
  ),
110
+ play: async ({ canvasElement }) => {
111
+ const canvas = within(canvasElement);
112
+ // #322 — the dropzone (`focus-ring-within`) is the sole compound focus
113
+ // indicator for this composite control; the inner "Browse files" button
114
+ // must delegate its own indicator to it, never paint a second one.
115
+ const browseButton = canvas.getByRole("button", { name: "Browse files" });
116
+ await userEvent.tab();
117
+ await expect(browseButton).toHaveFocus();
118
+ const dropzone = browseButton.closest('[data-slot="file-upload-dropzone"]');
119
+ await expect(dropzone).not.toBeNull();
120
+ const dropzoneStyle = getComputedStyle(dropzone as HTMLElement);
121
+ const buttonStyle = getComputedStyle(browseButton);
122
+ await expect(dropzoneStyle.outlineStyle).toBe("solid");
123
+ await expect(buttonStyle.outlineStyle).not.toBe("solid");
124
+ },
109
125
  };
110
126
 
111
127
  export const Uncontrolled: Story = {
@@ -88,7 +88,11 @@ describe("FileUpload", () => {
88
88
  expect(onFilesChange.mock.calls[0]![0]).toHaveLength(0);
89
89
  });
90
90
 
91
- it("FileUploadList renders role=status and aria-live=polite", () => {
91
+ it("FileUploadList announces via aria-live while keeping its native list role", () => {
92
+ // NOT role="status" — that would override the <ul>'s implicit `list`
93
+ // role and strip `listitem` from every <FileUploadItem> inside it
94
+ // (an axe `listitem` violation). `aria-live="polite"` alone still
95
+ // announces additions/removals to assistive tech.
92
96
  const f = makeUploadFile();
93
97
  render(
94
98
  <FileUpload files={[f]} onFilesChange={() => {}}>
@@ -97,8 +101,9 @@ describe("FileUpload", () => {
97
101
  </FileUploadList>
98
102
  </FileUpload>,
99
103
  );
100
- const list = screen.getByRole("status");
104
+ const list = screen.getByRole("list");
101
105
  expect(list).toHaveAttribute("aria-live", "polite");
106
+ expect(list).not.toHaveAttribute("role");
102
107
  });
103
108
 
104
109
  it("FileUploadList returns null when no files and no children", () => {
@@ -289,7 +289,7 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
289
289
  "hover:border-ring hover:bg-accent/50",
290
290
  "data-[dragging]:border-primary data-[dragging]:bg-primary/5",
291
291
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
292
- "focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
292
+ "focus-ring-within focus-within:ring-offset-2 focus-within:ring-offset-background",
293
293
  className,
294
294
  )}
295
295
  {...props}
@@ -312,7 +312,11 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
312
312
  }}
313
313
  className={cn(
314
314
  // #399 — inline text trigger inside a sentence → `-text` rung.
315
- "text-primary-text underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
315
+ "text-primary-text underline-offset-2 hover:underline focus-ring rounded-sm",
316
+ // #322 — the dropzone's `focus-ring-within` (above) is the
317
+ // one compound indicator for this composite control; this
318
+ // button delegates rather than painting a second one.
319
+ "focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
316
320
  "disabled:pointer-events-none disabled:opacity-50",
317
321
  )}
318
322
  >
@@ -343,9 +347,13 @@ export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
343
347
  if (files.length === 0 && !children) return null;
344
348
 
345
349
  return (
350
+ // `aria-live="polite"` alone is enough to announce additions/removals —
351
+ // it does NOT require `role="status"`. Adding that role here used to
352
+ // override the `<ul>`'s implicit `list` role, which strips the
353
+ // `listitem` role from every `<FileUploadItem>` (`<li>`) inside it and
354
+ // fails axe's `listitem` rule the moment the list is non-empty.
346
355
  <ul
347
356
  ref={ref}
348
- role="status"
349
357
  aria-live="polite"
350
358
  aria-label="Selected files"
351
359
  data-slot="file-upload-list"
@@ -434,7 +442,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
434
442
  <Progress value={resolvedProgress} className="h-1" />
435
443
  )}
436
444
  {isError && resolvedError && (
437
- <p role="alert" className="text-xs text-destructive">
445
+ // #124: this is running text (the rejection reason), so it takes the
446
+ // >=4.5:1 ink rung `text-destructive-text` — not the 3:1 mark rung the
447
+ // `statusIcon.error` glyph above correctly keeps.
448
+ <p role="alert" className="text-xs text-destructive-text">
438
449
  {resolvedError}
439
450
  </p>
440
451
  )}
@@ -80,7 +80,8 @@ export const FormLabel = forwardRef<
80
80
  htmlFor={formItemId}
81
81
  className={cn(
82
82
  "transition-colors duration-fast ease-standard",
83
- error && "text-destructive",
83
+ // #124: the label's own error-state text, not a mark — ink rung.
84
+ error && "text-destructive-text",
84
85
  className,
85
86
  )}
86
87
  {...props}
@@ -129,7 +130,9 @@ export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLP
129
130
  ref={ref}
130
131
  id={formMessageId}
131
132
  className={cn(
132
- "text-sm font-medium text-destructive animate-in fade-in slide-in-from-top-1 ease-entrance",
133
+ // #124: the error message body is running text ink rung, not the
134
+ // 3:1 mark rung `text-destructive` guarantees.
135
+ "text-sm font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
133
136
  className,
134
137
  )}
135
138
  {...props}
@@ -59,7 +59,7 @@ describe("IconButton", () => {
59
59
  );
60
60
  const wrapper = document.querySelector('[data-slot="icon-button"]')!;
61
61
  expect(wrapper).toHaveAttribute("tabindex", "0");
62
- expect(wrapper.className).toContain("focus-visible:ring-ring");
62
+ expect(wrapper.className).toContain("focus-ring");
63
63
  await userEvent.tab();
64
64
  expect(wrapper).toHaveFocus();
65
65
  });
@@ -101,8 +101,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
101
101
  // While disabled this span IS the focus target (the button is out
102
102
  // of the tab order), so it must carry the system's focus ring —
103
103
  // without this it falls back to the UA default outline, which is
104
- // a hardcoded browser blue identical in all three themes.
105
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
104
+ // a hardcoded browser blue identical in every theme.
105
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
106
106
  disabled && "cursor-not-allowed",
107
107
  )}
108
108
  >
@@ -1,10 +1,19 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Input } from "./input";
3
4
 
4
5
  const meta = {
5
6
  title: "Core/Input",
6
7
  component: Input,
7
8
  tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The plain text field. A search box with a leading icon and a clear button is `Data/SearchInput`, which is built on this one; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
8
17
  args: { placeholder: "you@example.com" },
9
18
  argTypes: {
10
19
  type: {
@@ -42,3 +51,115 @@ type Story = StoryObj<typeof meta>;
42
51
  export const Default: Story = {};
43
52
  export const Invalid: Story = { args: { "aria-invalid": true, defaultValue: "not-an-email" } };
44
53
  export const Disabled: Story = { args: { disabled: true, value: "Disabled" } };
54
+
55
+ /**
56
+ * The element whose `data-theme` actually GOVERNS `el` — the nearest ancestor
57
+ * carrying the attribute, falling back to the document element.
58
+ *
59
+ * The earlier version of this helper guessed between `document.body` and
60
+ * `document.documentElement`. When the decorator wrote `data-theme` onto a
61
+ * wrapper closer to the story than either of those, flipping the far ancestor
62
+ * was a silent no-op: the nearer attribute still won the cascade, so BOTH
63
+ * branches of the loop below asserted in whatever theme the run had already
64
+ * chosen, and the docblock's "checked in both themes" claim was false. That
65
+ * stayed invisible until the suite was run under `STORYBOOK_THEME=dark`, where
66
+ * the same two branches both asserted in dark and the guard fired. Resolving
67
+ * the governing element from the input itself cannot drift that way.
68
+ */
69
+ function themeHost(el: Element): HTMLElement {
70
+ return (el.closest("[data-theme]") as HTMLElement | null) ?? document.documentElement;
71
+ }
72
+
73
+ /**
74
+ * Regression lock for #49: `Input` must set its OWN text colour rather than
75
+ * inherit whatever ambient ink its container declares. Nested inside a
76
+ * `text-sidebar-foreground` wrapper (the `sidebar-04` mail search field's real
77
+ * shape), the input's resolved `color` must still match an un-nested `Input`'s
78
+ * — not the wrapper's near-invisible-on-`--background` sidebar ink. Runs in
79
+ * the Storybook browser project so `getComputedStyle` reflects the real
80
+ * Tailwind/token cascade (a jsdom unit test has none — `css: false` in
81
+ * `vitest.config.ts` — so it can only assert the class list, which would stay
82
+ * green even if a later change kept the class string but lost the cascade).
83
+ * Checked in BOTH themes via an in-place `data-theme` flip (no remount), so
84
+ * the fix is verified theme-safe rather than merely assumed to be from the
85
+ * class being additive.
86
+ */
87
+ export const InAmbientTextContainer: Story = {
88
+ render: () => (
89
+ <div className="flex flex-col gap-4">
90
+ <Input aria-label="reference input" placeholder="reference (no ambient wrapper)" />
91
+ <div className="text-sidebar-foreground">
92
+ <Input aria-label="nested input" placeholder="nested under text-sidebar-foreground" />
93
+ </div>
94
+ <div className="text-muted-foreground">
95
+ <Input aria-label="muted input" placeholder="nested under text-muted-foreground" />
96
+ </div>
97
+ </div>
98
+ ),
99
+ play: async ({ canvas, userEvent }) => {
100
+ const reference = canvas.getByLabelText<HTMLInputElement>("reference input");
101
+ const nested = canvas.getByLabelText<HTMLInputElement>("nested input");
102
+ const muted = canvas.getByLabelText<HTMLInputElement>("muted input");
103
+ await userEvent.type(nested, "search query");
104
+
105
+ const assertMatchesReference = async () => {
106
+ await waitFor(() => {
107
+ expect(getComputedStyle(nested).color).toBe(getComputedStyle(reference).color);
108
+ expect(getComputedStyle(muted).color).toBe(getComputedStyle(reference).color);
109
+ });
110
+ /*
111
+ * Guard against a vacuous pass: an ambient wrapper's ink must actually
112
+ * differ from the input's own, or this lock could never fail.
113
+ *
114
+ * The guard rides on the `text-muted-foreground` wrapper, NOT the
115
+ * sidebar one. `--sidebar-foreground` and `--foreground` are separate
116
+ * roles that happen to carry the same literal in the dark reference
117
+ * theme (both `oklch(0.95 0.004 257)`) — a legitimate coincidence, not
118
+ * an undeclared alias: light's sidebar ink is near-white because that
119
+ * theme's sidebar ground is DARK, and dark's is near-white because
120
+ * everything is. So in dark the sidebar wrapper cannot express a
121
+ * difference, and asserting one there is unsatisfiable.
122
+ * `--muted-foreground` differs from `--foreground` in both reference
123
+ * themes (light 0.5 vs 0.3, dark 0.72 vs 0.95), so it can carry the
124
+ * guard in either. The sidebar wrapper stays because it is the real
125
+ * shape this locks (the `sidebar-04` mail search field).
126
+ */
127
+ await expect(getComputedStyle(muted).color).not.toBe(
128
+ getComputedStyle(muted.parentElement!).color,
129
+ );
130
+ };
131
+
132
+ const host = themeHost(nested);
133
+ const original = host.getAttribute("data-theme");
134
+ /*
135
+ * Prove the flip actually took, rather than assuming it. A flip that lands
136
+ * on an element the cascade ignores turns the whole cross-theme claim into
137
+ * a no-op, which is precisely how this story passed for two themes while
138
+ * only ever measuring one.
139
+ */
140
+ const assertThemeApplied = async (expected: string) => {
141
+ await waitFor(() => {
142
+ expect(getComputedStyle(host).getPropertyValue("--foreground").trim()).not.toBe("");
143
+ });
144
+ expect(host.getAttribute("data-theme")).toBe(expected);
145
+ expect(nested.closest("[data-theme]")).toBe(host);
146
+ };
147
+
148
+ try {
149
+ host.setAttribute("data-theme", "light");
150
+ await assertThemeApplied("light");
151
+ const lightInk = getComputedStyle(nested).color;
152
+ await assertMatchesReference();
153
+
154
+ host.setAttribute("data-theme", "dark");
155
+ await assertThemeApplied("dark");
156
+ await assertMatchesReference();
157
+ // The two themes must actually resolve to different ink, or "checked in
158
+ // both themes" is a claim about an attribute nobody read.
159
+ expect(getComputedStyle(nested).color).not.toBe(lightInk);
160
+ } finally {
161
+ if (original === null) host.removeAttribute("data-theme");
162
+ else host.setAttribute("data-theme", original);
163
+ }
164
+ },
165
+ };
@@ -16,4 +16,17 @@ describe("Input", () => {
16
16
  const el = screen.getByLabelText<HTMLInputElement>("email");
17
17
  expect(el).toBeDisabled();
18
18
  });
19
+
20
+ it("applies text-foreground class to ensure text color is not inherited from parent", () => {
21
+ // Render Input inside a container with a different text color (simulating sidebar)
22
+ render(
23
+ <div className="text-sidebar-foreground">
24
+ <Input aria-label="search" />
25
+ </div>,
26
+ );
27
+ const el = screen.getByLabelText<HTMLInputElement>("search");
28
+ // Verify that the Input element has the text-foreground class
29
+ // This ensures its text color is explicitly set and not inherited
30
+ expect(el.className).toContain("text-foreground");
31
+ });
19
32
  });
@@ -12,9 +12,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
12
12
  ref={ref}
13
13
  type={type}
14
14
  className={cn(
15
- "flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
15
+ "flex h-9 w-full rounded-md border border-input bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
16
16
  "placeholder:text-muted-foreground",
17
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
17
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
18
18
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted disabled:border-border",
19
19
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
20
20
  "file:border-0 file:bg-transparent file:text-sm file:font-medium",
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Search } from "lucide-react";
3
4
  import {
4
5
  InputGroup,
@@ -85,3 +86,76 @@ export const Card: Story = {
85
86
  </div>
86
87
  ),
87
88
  };
89
+
90
+ /**
91
+ * Regression lock for #67 (fix round 2) — the compound focus indicator must be
92
+ * ONE mark on ONE element.
93
+ *
94
+ * `InputGroup` delegates its indicator to the wrapper, because the control is
95
+ * borderless and full-bleed and a ring around IT would be drawn inside the
96
+ * well. Before this lock the delegation was only half done and the indicator
97
+ * split across two elements: the wrapper painted the brand ring and no contour
98
+ * (its own base `outline-none` set `--tw-outline-style: none`, and a following
99
+ * `outline-1` re-declared `outline-style` from that variable), while the inner
100
+ * control painted the contour and no ring, ~10px inside, at a different radius.
101
+ * On light that read as a stray dark rectangle across the composer, and the
102
+ * well kept the 1.23:1 un-contoured lime ring #67 exists to remove.
103
+ *
104
+ * THE ASSERTION IS ON COMPUTED STYLE, NOT ON THE CLASS STRING. The class string
105
+ * was already "correct" while the bug shipped — `has-[…]:focus-ring-static` was
106
+ * present and cancelled by a sibling utility — so a class-name assertion (or a
107
+ * jsdom unit test, which has no CSS at all: `css: false` in `vitest.config.ts`)
108
+ * stays green through the exact regression this locks. It therefore lives in
109
+ * the Storybook browser project, where `getComputedStyle` reflects the real
110
+ * Tailwind cascade.
111
+ */
112
+ export const CompoundFocusIndicator: Story = {
113
+ tags: ["!autodocs"],
114
+ render: () => (
115
+ <InputGroup className="max-w-sm">
116
+ <InputGroupAddon>
117
+ <Search />
118
+ </InputGroupAddon>
119
+ <InputGroupInput aria-label="locked control" placeholder="Search…" />
120
+ </InputGroup>
121
+ ),
122
+ play: async ({ canvas, userEvent }) => {
123
+ const control = canvas.getByLabelText<HTMLInputElement>("locked control");
124
+ const wrapper = control.closest<HTMLElement>("[data-slot=input-group]")!;
125
+ await userEvent.tab();
126
+ await expect(control).toHaveFocus();
127
+
128
+ /*
129
+ * Settle past `transition-colors`: reading immediately after focus catches
130
+ * `outline-color` mid-transition and reports the element's own ink instead
131
+ * of the contour token. That artefact is why the utility now declares
132
+ * `outline-color` at rest (themes.css); the wait keeps this lock honest
133
+ * about which property it is measuring.
134
+ */
135
+ await waitFor(() => {
136
+ const w = getComputedStyle(wrapper);
137
+ // The wrapper owns BOTH layers.
138
+ expect(w.outlineStyle).not.toBe("none");
139
+ expect(w.outlineWidth).toBe("1px");
140
+ expect(ringSpread(w)).toBeGreaterThan(0);
141
+ // …and they are flush: the contour sits exactly on the ring's outer edge.
142
+ expect(parseFloat(w.outlineOffset)).toBe(ringSpread(w));
143
+ });
144
+
145
+ // The control paints NEITHER layer — no second mark inside the well.
146
+ const c = getComputedStyle(control);
147
+ await expect(c.outlineStyle).toBe("none");
148
+ await expect(ringSpread(c)).toBe(0);
149
+ },
150
+ };
151
+
152
+ /**
153
+ * `--tw-ring-shadow` is a `0 0 0 calc(<offset> + <width>) <colour>` string; the
154
+ * painted reach is that `calc`. Reading it (rather than `boxShadow`) keeps the
155
+ * measurement independent of whatever elevation rung the element also carries.
156
+ */
157
+ function ringSpread(style: CSSStyleDeclaration): number {
158
+ const shadow = style.getPropertyValue("--tw-ring-shadow");
159
+ const match = /calc\(\s*([\d.]+)px\s*\+\s*([\d.]+)px\s*\)/.exec(shadow);
160
+ return match ? parseFloat(match[1]) + parseFloat(match[2]) : 0;
161
+ }
@@ -7,12 +7,21 @@ import { Textarea } from "../textarea";
7
7
 
8
8
  export const inputGroupVariants = cva(
9
9
  cn(
10
- "group/input-group relative flex w-full flex-wrap items-center rounded-md outline-none transition-[color,box-shadow] duration-fast ease-standard",
10
+ "group/input-group relative flex w-full flex-wrap items-center rounded-md transition-[color,box-shadow] duration-fast ease-standard",
11
11
  // Grow to fit a textarea even when it's nested (e.g. wrapped in a
12
12
  // `display:contents` body like PromptInputBody). `:has(>textarea)`
13
13
  // misses through that wrapper, so match any descendant textarea.
14
14
  "h-9 has-[textarea]:h-auto",
15
- "has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
15
+ // The compound indicator is the wrapper's, not the control's — the control
16
+ // is borderless and full-bleed, so a ring around IT would draw inside the
17
+ // well. `focus-ring-static` paints BOTH layers; the only geometry knob is
18
+ // `ring-offset-1`, which `outline-offset: calc(--tw-ring-offset-width + 2px)`
19
+ // tracks so ring and contour stay flush at the same radius. Do NOT add an
20
+ // `outline-*` utility here: `outline-1` re-declares
21
+ // `outline-style: var(--tw-outline-style)`, which `outline-none` on this
22
+ // same element used to pin to `none` — that pairing shipped, and it cancelled
23
+ // the contour on every composer, NumberInput and TagInput (#67 fix round 2).
24
+ "has-[[data-slot=input-group-control]:focus-visible]:focus-ring-static has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
16
25
  "has-[[data-slot=input-group-control][aria-invalid=true]]:ring-2 has-[[data-slot=input-group-control][aria-invalid=true]]:ring-destructive",
17
26
  ),
18
27
  {
@@ -144,7 +153,7 @@ export const InputGroupInput = forwardRef<HTMLInputElement, ComponentProps<typeo
144
153
  ref={ref}
145
154
  data-slot="input-group-control"
146
155
  className={cn(
147
- "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
156
+ "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
148
157
  className,
149
158
  )}
150
159
  {...props}
@@ -160,7 +169,7 @@ export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, ComponentProps
160
169
  ref={ref}
161
170
  data-slot="input-group-control"
162
171
  className={cn(
163
- "flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
172
+ "flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
164
173
  className,
165
174
  )}
166
175
  {...props}
@@ -42,7 +42,11 @@ export const InputOTPSlot = forwardRef<
42
42
  ref={ref}
43
43
  className={cn(
44
44
  "relative flex size-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
45
- isActive && "z-10 ring-2 ring-ring",
45
+ // The OTP slot IS the field's focus indicator: the real focused element is
46
+ // InputOTP's visually-hidden <input>, and `isActive` is the caret's slot. It
47
+ // therefore carries the compound indicator (#67) — the state is JS-driven, so
48
+ // it is the `-static` flavour rather than a `:focus-visible` one.
49
+ isActive && "z-10 focus-ring-static",
46
50
  className,
47
51
  )}
48
52
  {...props}
@@ -0,0 +1,6 @@
1
+ export {
2
+ KeyboardShortcuts,
3
+ type KeyboardShortcutsProps,
4
+ type Shortcut,
5
+ type ShortcutGroup,
6
+ } from "./keyboard-shortcuts";