@eifi1/ui-kit 0.5.0 → 0.6.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 (144) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +22 -7
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { SelectHTMLAttributes, ReactNode, InputHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, TextareaHTMLAttributes } from 'react';
2
+ import { ButtonHTMLAttributes, SelectHTMLAttributes, ReactNode, InputHTMLAttributes, ComponentPropsWithoutRef, TextareaHTMLAttributes } from 'react';
3
3
 
4
4
  type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
5
5
  /**
@@ -17,10 +17,47 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
17
17
  stretch?: boolean;
18
18
  }
19
19
  declare function Button({ variant, stretch, className, ...rest }: ButtonProps): react.JSX.Element;
20
+ type IconButtonSize = "md" | "sm" | "xs" | "2xs";
21
+ declare const ICON_BUTTON_TONES: {
22
+ readonly default: "";
23
+ readonly muted: "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
24
+ readonly danger: "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]";
25
+ };
20
26
  interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
21
27
  variant?: ButtonVariant;
22
- /** Box size: md = 36px (matches the top bar), sm = 32px. The icon stays 20px. */
23
- size?: "sm" | "md";
28
+ /** Box size: md = 36px (matches the top bar), sm = 32px — both with a 20px icon;
29
+ * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
30
+ * icon (an action on a chip or a tab). */
31
+ size?: IconButtonSize;
32
+ /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
33
+ * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
34
+ * remove/delete that repeats down a list. Default: the variant's own colours. */
35
+ tone?: keyof typeof ICON_BUTTON_TONES;
36
+ /** Keep the click (and the Enter/Space that produces it) from reaching an
37
+ * ancestor's handler — for an action inside a clickable table row or card.
38
+ *
39
+ * **Not a licence to put this inside another `<button>`.** HTML forbids any
40
+ * interactive content, and any element with a `tabindex`, inside a button — so
41
+ * the `<span role="button" tabIndex={0}>` workaround is invalid too — and ARIA
42
+ * makes a button's children presentational, so a screen reader flattens the
43
+ * inner one into the outer one's name and it cannot be reached at all. Make the
44
+ * row's main action a button that fills the row and put this one BESIDE it, on
45
+ * top:
46
+ *
47
+ * ```tsx
48
+ * <div className="relative">
49
+ * <button className="w-full pe-16 …" onClick={open}>…row…</button>
50
+ * <div className="absolute inset-y-0 end-2 flex items-center gap-0.5">
51
+ * <IconButton size="xs" tone="danger" aria-label="Delete" onClick={remove}>
52
+ * <Trash2 />
53
+ * </IconButton>
54
+ * </div>
55
+ * </div>
56
+ * ```
57
+ *
58
+ * The pair look exactly like the nested version, click exactly like it, and
59
+ * are two tab stops a screen reader can tell apart. */
60
+ stopPropagation?: boolean;
24
61
  }
25
62
  declare const IconButton: react.ForwardRefExoticComponent<IconButtonProps & react.RefAttributes<HTMLButtonElement>>;
26
63
  declare const FIELD_BASE: string;
@@ -138,6 +175,33 @@ interface FieldLabelProps extends ComponentPropsWithoutRef<"span"> {
138
175
  children: ReactNode;
139
176
  }
140
177
  declare function FieldLabel({ children, className, ...rest }: FieldLabelProps): react.JSX.Element;
178
+ interface LabelProps extends ComponentPropsWithoutRef<"label"> {
179
+ /** Draw the required mark after the text. The mark is `aria-hidden`: what a
180
+ * screen reader hears is the control's own `required` / `aria-required`, which
181
+ * the caller still sets — a "star" read out as the last word of every name is
182
+ * noise, and a control that is not marked required is not required however its
183
+ * label looks. */
184
+ required?: boolean;
185
+ /** `sm` for a toolbar or a dense filter row (12px), `md` otherwise (14px). */
186
+ size?: "sm" | "md";
187
+ /** Dim the label with its control. A label ABOVE its field is not the field's
188
+ * `peer`, so `peer-disabled:` cannot reach it the way it reaches a floating one. */
189
+ disabled?: boolean;
190
+ }
191
+ /**
192
+ * The label ABOVE a field, for the places a floating label does not fit: a control
193
+ * the kit did not render (a third-party picker, a range slider, a group of radios),
194
+ * a filter bar whose fields are unlabelled {@link Select}s, a form that sets labels
195
+ * above its fields throughout.
196
+ *
197
+ * A new component rather than a mode of {@link FieldLabel}, because the two share
198
+ * nothing but the word. `FieldLabel` is an absolutely positioned `<span>` inside a
199
+ * trigger's `relative` box and has to stay exactly that for every dropdown that
200
+ * already lines up with it; this is a real `<label>` in normal flow, with `htmlFor`,
201
+ * that a click focuses the control through. Spacing below it belongs to the caller's
202
+ * layout (`space-y-1.5` on the pair, typically), as it would for any block.
203
+ */
204
+ declare function Label({ required, size, disabled, className, children, ...rest }: LabelProps): react.JSX.Element;
141
205
  /** The two names the password reveal toggle can wear. See {@link Input}. */
142
206
  interface PasswordRevealLabels {
143
207
  /** While the value is hidden — activating the toggle will show it. */
@@ -185,7 +249,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
185
249
  passwordLabels?: Partial<PasswordRevealLabels>;
186
250
  }
187
251
  declare const Input: react.ForwardRefExoticComponent<InputProps & react.RefAttributes<HTMLInputElement>>;
188
- interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
252
+ interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
189
253
  label?: ReactNode;
190
254
  /** See {@link Input}'s `invalid`. */
191
255
  invalid?: boolean;
@@ -193,6 +257,20 @@ interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
193
257
  error?: ReactNode;
194
258
  /** A {@link FieldHint} for the label line — see {@link FloatingField}. */
195
259
  hint?: ReactNode;
260
+ /**
261
+ * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
262
+ * 36px field stands a head taller than the buttons beside it. `"md"` (default)
263
+ * is the field every form uses. Unlabelled selects only — a floating label needs
264
+ * the tall box to float in, so a labelled Select ignores `"sm"`.
265
+ *
266
+ * A NUMBER is still the native attribute (the rows of a list box) and is passed
267
+ * straight through, so the one HTML meaning of `size` keeps working.
268
+ */
269
+ size?: "sm" | "md" | number;
270
+ /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
271
+ * the chevron is positioned against — so it could set a width and nothing
272
+ * else; this is the way to the element. See {@link Input}'s `inputClassName`. */
273
+ selectClassName?: string;
196
274
  }
197
275
  declare const Select: react.ForwardRefExoticComponent<SelectProps & react.RefAttributes<HTMLSelectElement>>;
198
276
  interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
@@ -232,9 +310,17 @@ declare function CardFooter({ className, ...props }: CardFooterProps): react.JSX
232
310
  /** A `<span>`'s props plus the words a reader hears — see {@link CardHeaderProps}.
233
311
  * The ring is drawn with a border, so there is nothing inside it to put children in. */
234
312
  interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
235
- /** What the spinner means, for a screen reader. Default: `common.loading` from the
236
- * {@link UiKitProvider}, else "Loading…". */
237
- label?: string;
313
+ /**
314
+ * What the spinner means, for a screen reader. Default: `common.loading` from the
315
+ * {@link UiKitProvider}, else "Loading…".
316
+ *
317
+ * `null` makes it DECORATIVE — no role, no text, hidden from assistive tech. Use it
318
+ * whenever words are already there: beside visible "Loading…" text (otherwise it is
319
+ * announced twice), inside a labelled button (otherwise its text joins the button's
320
+ * name), or inside a live region of the app's own (otherwise that region and this
321
+ * one both announce).
322
+ */
323
+ label?: string | null;
238
324
  }
239
325
  /**
240
326
  * A spinning ring that also SAYS it is loading.
@@ -247,9 +333,9 @@ interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"
247
333
  * ignore a name on one.
248
334
  *
249
335
  * `relative` so the `sr-only` text has a local containing block (see
250
- * sr-only-containment.test). The role goes BEFORE the spread: a caller showing the
251
- * spinner next to text that already says "Loading" can pass `aria-hidden` or its own
252
- * `role` and have it win.
336
+ * sr-only-containment.test). Where the words are already on screen, pass
337
+ * `label={null}`: announcing is right for a spinner standing alone and wrong for one
338
+ * beside text, inside a button, or inside the app's own live region.
253
339
  */
254
340
  declare function Spinner({ className, label, ...rest }: SpinnerProps): react.JSX.Element;
255
341
  interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
@@ -260,6 +346,43 @@ interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "childre
260
346
  hint?: string;
261
347
  }
262
348
  declare function EmptyState({ title, hint, className, ...rest }: EmptyStateProps): react.JSX.Element;
349
+ /** One tab of a {@link Tabs} strip. */
350
+ interface TabItem<T extends string> {
351
+ id: T;
352
+ /** A ReactNode so a tab can pair an icon with text. */
353
+ label: ReactNode;
354
+ /** Optional trailing node (e.g. a count pill). */
355
+ badge?: ReactNode;
356
+ /** Marks a tab that IS a route — see {@link TabsProps}. */
357
+ href?: string;
358
+ /** Leading decoration: an icon, or the colour swatches of the lines this tab
359
+ * draws in a chart above the strip. Hidden from assistive tech — it repeats
360
+ * what the label says, or it says something the label must say instead. */
361
+ icon?: ReactNode;
362
+ /** A second, smaller line under the label — the one fact that tells two tabs
363
+ * with similar labels apart ("80 km/h", "Level 3"). Part of the tab's name. */
364
+ detail?: ReactNode;
365
+ /** A tab that exists as a slot with nothing in it yet, drawn with a dashed
366
+ * outline so a gap in a sequence looks like a gap. Visual only: say "empty" in
367
+ * the label or `detail` if a screen reader needs to know. */
368
+ empty?: boolean;
369
+ /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a
370
+ * workbook cannot be without. Default: removable. */
371
+ removable?: boolean;
372
+ /** Plain-text name for the labels the strip composes about this tab ("Remove
373
+ * …"), for when `label` is not a string. Default: `label` if it is a string,
374
+ * else the id. */
375
+ name?: string;
376
+ }
377
+ /** The strings the strip renders on its own behalf — only when it can add or
378
+ * remove tabs. */
379
+ interface TabsLabels {
380
+ /** The "add" button's visible text when the caller gives no `addLabel`. */
381
+ add: string;
382
+ /** Accessible name of a tab's × — "Remove <tab>". */
383
+ remove: (tab: string) => string;
384
+ }
385
+ declare const DEFAULT_TABS_LABELS: TabsLabels;
263
386
  /**
264
387
  * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything
265
388
  * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an
@@ -275,12 +398,7 @@ interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div
275
398
  * feedback #451), while a plain click still goes through `onChange` and stays
276
399
  * client-side. Tabs that only flip local state leave it unset — a link to a URL
277
400
  * that does not select the tab would be worse than no link. */
278
- tabs: {
279
- id: T;
280
- label: ReactNode;
281
- badge?: ReactNode;
282
- href?: string;
283
- }[];
401
+ tabs: TabItem<T>[];
284
402
  active: T;
285
403
  onChange: (id: T) => void;
286
404
  className?: string;
@@ -346,7 +464,35 @@ interface TabsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div
346
464
  * describes a tab as opening something that is not there.
347
465
  */
348
466
  panelId?: string;
467
+ /**
468
+ * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab.
469
+ *
470
+ * The × is on the open tab only — a row of tabs each carrying one is a row of
471
+ * things to hit by accident, and the tab you can remove should be the one you
472
+ * are looking at — but its room is reserved on every removable tab, so opening a
473
+ * tab never widens it and never pushes the rest of the strip along (lenkbank
474
+ * feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
475
+ * strip stays one stop, and the keyboard path is Delete (announced through
476
+ * `aria-keyshortcuts`), which removes whichever tab has focus.
477
+ *
478
+ * The strip only ASKS: it calls this, and the tab goes when the caller drops it
479
+ * from `tabs` — after a confirmation, a request, whatever it needs. When it does,
480
+ * focus that was on the removed tab (or its ×) moves to the open tab if the
481
+ * removed one was open, else to its neighbour, instead of falling to the page.
482
+ */
483
+ onRemove?: (id: T) => void;
484
+ /** Renders an "add" button after the last tab, outside the tablist — it is an
485
+ * action, not a tab, and a tablist may own only tabs. */
486
+ onAdd?: () => void;
487
+ /** The add button's text. Say what is added — "Add loop", "Add report" says
488
+ * more than "Add". Default: `tabs.add` from the {@link UiKitProvider}. */
489
+ addLabel?: string;
490
+ /** Overrides for this strip's strings. See {@link TabsLabels}. */
491
+ labels?: Partial<TabsLabels>;
492
+ /** An add or a remove is in flight: the × and the add button are disabled and
493
+ * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
494
+ busy?: boolean;
349
495
  }
350
- declare function Tabs<T extends string>({ tabs, active, onChange, className, wrap, label, panelId, "aria-label": ariaLabel, ...rest }: TabsProps<T>): react.JSX.Element;
496
+ declare function Tabs<T extends string>({ tabs, active, onChange, className, wrap, label, panelId, onRemove, onAdd, addLabel, labels, busy, "aria-label": ariaLabel, ...rest }: TabsProps<T>): react.JSX.Element;
351
497
 
352
- export { Button, type ButtonProps, type ButtonVariant, Card, CardAction, type CardActionProps, CardContent, type CardContentProps, CardDescription, type CardDescriptionProps, CardFooter, type CardFooterProps, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, DEFAULT_PASSWORD_REVEAL_LABELS, EmptyState, type EmptyStateProps, FIELD_BASE, FIELD_DISPLAY, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FIELD_WRITABLE_LOOK, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, FLOATING_LABEL_STATIC, FieldChevron, type FieldChevronProps, FieldHint, type FieldHintProps, FieldLabel, type FieldLabelProps, FloatingField, type FloatingFieldProps, IconButton, type IconButtonProps, Input, type InputProps, PHONE_QUERY, type PasswordRevealLabels, Select, type SelectProps, Spinner, type SpinnerProps, Tabs, type TabsProps, Textarea, type TextareaProps, buttonClasses, resolvePasswordRevealLabels };
498
+ export { Button, type ButtonProps, type ButtonVariant, Card, CardAction, type CardActionProps, CardContent, type CardContentProps, CardDescription, type CardDescriptionProps, CardFooter, type CardFooterProps, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, DEFAULT_PASSWORD_REVEAL_LABELS, DEFAULT_TABS_LABELS, EmptyState, type EmptyStateProps, FIELD_BASE, FIELD_DISPLAY, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FIELD_WRITABLE_LOOK, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, FLOATING_LABEL_STATIC, FieldChevron, type FieldChevronProps, FieldHint, type FieldHintProps, FieldLabel, type FieldLabelProps, FloatingField, type FloatingFieldProps, IconButton, type IconButtonProps, type IconButtonSize, Input, type InputProps, Label, type LabelProps, PHONE_QUERY, type PasswordRevealLabels, Select, type SelectProps, Spinner, type SpinnerProps, type TabItem, Tabs, type TabsLabels, type TabsProps, Textarea, type TextareaProps, buttonClasses, resolvePasswordRevealLabels };
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { forwardRef, useId, useState } from "react";
4
- import { ChevronDown, Eye, EyeOff, HelpCircle } from "lucide-react";
3
+ import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { useMediaQuery } from "../hooks/use-media-query.js";
7
7
  import { Tooltip } from "./tooltip.js";
@@ -34,17 +34,37 @@ function Button({ variant = "primary", stretch, className, ...rest }) {
34
34
  }
35
35
  );
36
36
  }
37
- const ICON_BUTTON_BASE = "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:size-5";
38
- const IconButton = forwardRef(function IconButton2({ variant = "ghost", size = "md", className, ...rest }, ref) {
37
+ const ICON_BUTTON_BASE = "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
38
+ const ICON_BUTTON_SIZES = {
39
+ md: "size-9 [&_svg]:size-5",
40
+ sm: "size-8 [&_svg]:size-5",
41
+ xs: "size-7 rounded [&_svg]:size-4",
42
+ "2xs": "size-6 rounded [&_svg]:size-3.5"
43
+ };
44
+ const ICON_BUTTON_TONES = {
45
+ default: "",
46
+ muted: "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
47
+ danger: "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]"
48
+ };
49
+ const IconButton = forwardRef(function IconButton2({ variant = "ghost", size = "md", tone = "default", stopPropagation, className, onClick, onKeyDown, ...rest }, ref) {
39
50
  return /* @__PURE__ */ jsx(
40
51
  "button",
41
52
  {
42
53
  ref,
43
54
  ...rest,
55
+ onClick: (e) => {
56
+ if (stopPropagation) e.stopPropagation();
57
+ onClick?.(e);
58
+ },
59
+ onKeyDown: (e) => {
60
+ if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
61
+ onKeyDown?.(e);
62
+ },
44
63
  className: cn(
45
64
  ICON_BUTTON_BASE,
46
- size === "sm" ? "size-8" : "size-9",
65
+ ICON_BUTTON_SIZES[size],
47
66
  buttonVariantClasses[variant],
67
+ ICON_BUTTON_TONES[tone],
48
68
  className
49
69
  )
50
70
  }
@@ -167,13 +187,31 @@ function FieldHint({
167
187
  function FieldLabel({ children, className, ...rest }) {
168
188
  return /* @__PURE__ */ jsx("span", { ...rest, className: cn(FLOATING_LABEL_STATIC, "z-10", className), children });
169
189
  }
190
+ function Label({ required, size = "md", disabled, className, children, ...rest }) {
191
+ return /* @__PURE__ */ jsxs(
192
+ "label",
193
+ {
194
+ ...rest,
195
+ className: cn(
196
+ "inline-flex items-center gap-0.5 font-medium leading-none text-[var(--text-primary)]",
197
+ size === "sm" ? "text-xs" : "text-sm",
198
+ disabled && "cursor-default opacity-50",
199
+ className
200
+ ),
201
+ children: [
202
+ children,
203
+ required && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "text-[var(--danger)]", children: "*" })
204
+ ]
205
+ }
206
+ );
207
+ }
170
208
  const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
171
- function useFieldError(error, invalid, describedBy) {
209
+ function useFieldError(error, invalid, describedBy, ariaInvalid) {
172
210
  const errorId = useId();
173
211
  const hasError = error !== void 0 && error !== null && error !== false && error !== "";
174
212
  return {
175
213
  /** A field carrying a message that says what is wrong with it IS wrong. */
176
- isInvalid: Boolean(invalid) || hasError,
214
+ isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
177
215
  describedBy: hasError ? describedBy ? `${describedBy} ${errorId}` : errorId : describedBy,
178
216
  errorEl: hasError ? /* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error }) : null
179
217
  };
@@ -212,7 +250,8 @@ const Input = forwardRef(function Input2({
212
250
  const { isInvalid, describedBy, errorEl } = useFieldError(
213
251
  error,
214
252
  invalid,
215
- rest["aria-describedby"]
253
+ rest["aria-describedby"],
254
+ rest["aria-invalid"]
216
255
  );
217
256
  const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
218
257
  const isPassword = type === "password";
@@ -296,15 +335,19 @@ const Input = forwardRef(function Input2({
296
335
  ] }) });
297
336
  });
298
337
  Input.displayName = "Input";
299
- const Select = forwardRef(function Select2({ className, label, id, children, invalid, error, hint, ...rest }, ref) {
338
+ const SELECT_SM = "h-7 py-0 pl-2 pr-7 text-xs";
339
+ const Select = forwardRef(function Select2({ className, label, id, children, invalid, error, hint, size, selectClassName, ...rest }, ref) {
300
340
  const generated = useId();
341
+ const nativeSize = typeof size === "number" ? size : void 0;
342
+ const small = size === "sm" && label === void 0;
301
343
  const fieldId = id ?? generated;
302
344
  const { isInvalid, describedBy, errorEl } = useFieldError(
303
345
  error,
304
346
  invalid,
305
- rest["aria-describedby"]
347
+ rest["aria-describedby"],
348
+ rest["aria-invalid"]
306
349
  );
307
- const chevron = rest.disabled ? null : /* @__PURE__ */ jsx(FieldChevron, {});
350
+ const chevron = rest.disabled ? null : /* @__PURE__ */ jsx(FieldChevron, { className: small ? "right-1.5 size-3.5" : void 0 });
308
351
  if (label === void 0) {
309
352
  return /* @__PURE__ */ jsx(FieldGroup, { errorEl, children: /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
310
353
  /* @__PURE__ */ jsx(
@@ -312,10 +355,17 @@ const Select = forwardRef(function Select2({ className, label, id, children, inv
312
355
  {
313
356
  ref,
314
357
  id,
358
+ size: nativeSize,
315
359
  ...rest,
316
360
  "aria-invalid": isInvalid || rest["aria-invalid"] || void 0,
317
361
  "aria-describedby": describedBy,
318
- className: cn(FIELD_BASE, "appearance-none pr-9", isInvalid && FIELD_INVALID),
362
+ className: cn(
363
+ FIELD_BASE,
364
+ "appearance-none pr-9",
365
+ small && SELECT_SM,
366
+ selectClassName,
367
+ isInvalid && FIELD_INVALID
368
+ ),
319
369
  children
320
370
  }
321
371
  ),
@@ -328,6 +378,7 @@ const Select = forwardRef(function Select2({ className, label, id, children, inv
328
378
  {
329
379
  ref,
330
380
  id: fieldId,
381
+ size: nativeSize,
331
382
  ...rest,
332
383
  "aria-invalid": isInvalid || rest["aria-invalid"] || void 0,
333
384
  "aria-describedby": describedBy,
@@ -335,6 +386,7 @@ const Select = forwardRef(function Select2({ className, label, id, children, inv
335
386
  FIELD_BASE,
336
387
  FIELD_FLOATING_PAD,
337
388
  "peer appearance-none pr-9",
389
+ selectClassName,
338
390
  isInvalid && FIELD_INVALID
339
391
  ),
340
392
  children
@@ -350,7 +402,8 @@ const Textarea = forwardRef(function Textarea2({ className, label, id, placehold
350
402
  const { isInvalid, describedBy, errorEl } = useFieldError(
351
403
  error,
352
404
  invalid,
353
- rest["aria-describedby"]
405
+ rest["aria-describedby"],
406
+ rest["aria-invalid"]
354
407
  );
355
408
  if (label === void 0) {
356
409
  return /* @__PURE__ */ jsx(FieldGroup, { errorEl, children: /* @__PURE__ */ jsx(
@@ -456,19 +509,18 @@ function CardFooter({ className, ...props }) {
456
509
  );
457
510
  }
458
511
  function Spinner({ className, label, ...rest }) {
459
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS, { loading: label });
460
- return /* @__PURE__ */ jsx(
461
- "span",
462
- {
463
- role: "status",
464
- ...rest,
465
- className: cn(
466
- "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
467
- className
468
- ),
469
- children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: common.loading })
470
- }
512
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
513
+ loading: label ?? void 0
514
+ });
515
+ const ring = cn(
516
+ "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
517
+ className
471
518
  );
519
+ if (label === null) return /* @__PURE__ */ jsx("span", { "aria-hidden": true, ...rest, className: ring });
520
+ return /* @__PURE__ */ jsx("span", { role: "status", ...rest, className: ring, children: /* @__PURE__ */ jsxs("span", { className: "sr-only", children: [
521
+ common.loading,
522
+ " "
523
+ ] }) });
472
524
  }
473
525
  function EmptyState({ title, hint, className, ...rest }) {
474
526
  return /* @__PURE__ */ jsxs(
@@ -486,6 +538,10 @@ function EmptyState({ title, hint, className, ...rest }) {
486
538
  }
487
539
  );
488
540
  }
541
+ const DEFAULT_TABS_LABELS = {
542
+ add: "Add tab",
543
+ remove: (tab) => `Remove ${tab}`
544
+ };
489
545
  const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-[var(--border)]";
490
546
  const TABLIST_WRAP_CLASSES = "flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-[var(--border)]";
491
547
  const TAB_CLASSES = "whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)]";
@@ -494,6 +550,10 @@ const TAB_INACTIVE_CLASSES = "border-transparent text-[var(--text-muted)] hover:
494
550
  const TAB_WRAP_CLASSES = "whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm";
495
551
  const TAB_WRAP_ACTIVE_CLASSES = "bg-[var(--brand)] text-[var(--brand-contrast)] md:border-[var(--text-primary)] md:text-[var(--text-primary)]";
496
552
  const TAB_WRAP_INACTIVE_CLASSES = "bg-[var(--bg-surface)] text-[var(--text-primary)] md:border-transparent md:text-[var(--text-muted)] md:hover:border-[var(--border-strong)] md:hover:text-[var(--text-secondary)]";
553
+ const TAB_REMOVABLE_CLASSES = "pe-8 md:pe-8";
554
+ const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--border-strong)] px-1.5";
555
+ const TAB_ADD_CLASSES = "inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
556
+ const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
497
557
  function Tabs({
498
558
  tabs,
499
559
  active,
@@ -502,10 +562,46 @@ function Tabs({
502
562
  wrap = false,
503
563
  label,
504
564
  panelId,
565
+ onRemove,
566
+ onAdd,
567
+ addLabel,
568
+ labels,
569
+ busy = false,
505
570
  "aria-label": ariaLabel,
506
571
  ...rest
507
572
  }) {
508
- const onTabKeyDown = (e) => {
573
+ const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
574
+ const stripRef = useRef(null);
575
+ const isRemovable = (tab) => onRemove !== void 0 && tab.removable !== false;
576
+ const nameOf = (tab) => tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
577
+ const pendingFocus = useRef(null);
578
+ const requestRemove = (id) => {
579
+ const index = tabs.findIndex((t) => t.id === id);
580
+ pendingFocus.current = {
581
+ removed: id,
582
+ wasActive: id === active,
583
+ neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id
584
+ };
585
+ onRemove?.(id);
586
+ };
587
+ useLayoutEffect(() => {
588
+ const pending = pendingFocus.current;
589
+ if (!pending || tabs.some((t) => t.id === pending.removed)) return;
590
+ pendingFocus.current = null;
591
+ const strip = stripRef.current;
592
+ if (!strip) return;
593
+ const focused = document.activeElement;
594
+ if (focused && focused !== document.body && !strip.contains(focused)) return;
595
+ const target = !pending.wasActive && tabs.some((t) => t.id === pending.neighbour) ? pending.neighbour : active;
596
+ const index = tabs.findIndex((t) => t.id === target);
597
+ strip.querySelectorAll('[role="tab"]')[index]?.focus();
598
+ }, [tabs, active]);
599
+ const onTabKeyDown = (e, tab) => {
600
+ if (e.key === "Delete" && isRemovable(tab)) {
601
+ e.preventDefault();
602
+ if (!busy) requestRemove(tab.id);
603
+ return;
604
+ }
509
605
  const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
510
606
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
511
607
  const strip = e.currentTarget.closest('[role="tablist"]');
@@ -517,15 +613,20 @@ function Tabs({
517
613
  const to = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : (from + step + items.length) % items.length;
518
614
  items[to]?.focus();
519
615
  };
520
- return /* @__PURE__ */ jsx(
616
+ const tablist = /* @__PURE__ */ jsx(
521
617
  "div",
522
618
  {
523
619
  ...rest,
620
+ ref: stripRef,
524
621
  role: "tablist",
525
622
  "aria-label": ariaLabel ?? label,
526
- className: cn(wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES, className),
623
+ className: cn(
624
+ wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
625
+ onAdd ? "min-w-0" : className
626
+ ),
527
627
  children: tabs.map((tab) => {
528
628
  const isActive = tab.id === active;
629
+ const removable = isRemovable(tab);
529
630
  const shared = {
530
631
  role: "tab",
531
632
  "aria-selected": isActive,
@@ -545,17 +646,24 @@ function Tabs({
545
646
  // Only the open tab: see `panelId`.
546
647
  "aria-controls": isActive ? panelId : void 0,
547
648
  id: isActive && panelId ? `${panelId}-tab` : void 0,
548
- onKeyDown: onTabKeyDown,
649
+ "aria-keyshortcuts": removable ? "Delete" : void 0,
650
+ onKeyDown: (e) => onTabKeyDown(e, tab),
549
651
  className: cn(
550
652
  wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
551
- wrap ? isActive ? TAB_WRAP_ACTIVE_CLASSES : TAB_WRAP_INACTIVE_CLASSES : isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES
653
+ wrap ? isActive ? TAB_WRAP_ACTIVE_CLASSES : TAB_WRAP_INACTIVE_CLASSES : isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES,
654
+ removable && TAB_REMOVABLE_CLASSES
552
655
  )
553
656
  };
554
- const inner = /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5", children: [
555
- tab.label,
657
+ const inner = /* @__PURE__ */ jsxs("span", { className: cn("inline-flex items-center gap-1.5", tab.empty && TAB_EMPTY_CLASSES), children: [
658
+ tab.icon != null && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "inline-flex shrink-0 items-center gap-0.5", children: tab.icon }),
659
+ tab.detail != null ? /* @__PURE__ */ jsxs("span", { className: "flex flex-col items-start text-start", children: [
660
+ /* @__PURE__ */ jsx("span", { children: tab.label }),
661
+ " ",
662
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] font-normal leading-tight text-[var(--text-muted)]", children: tab.detail })
663
+ ] }) : tab.label,
556
664
  tab.badge != null ? tab.badge : null
557
665
  ] });
558
- return tab.href ? /* @__PURE__ */ jsx(
666
+ const element = tab.href ? /* @__PURE__ */ jsx(
559
667
  "a",
560
668
  {
561
669
  ...shared,
@@ -567,7 +675,7 @@ function Tabs({
567
675
  onChange(tab.id);
568
676
  },
569
677
  onKeyDown: (e) => {
570
- onTabKeyDown(e);
678
+ onTabKeyDown(e, tab);
571
679
  if (e.defaultPrevented || e.key !== " ") return;
572
680
  e.preventDefault();
573
681
  onChange(tab.id);
@@ -576,9 +684,72 @@ function Tabs({
576
684
  },
577
685
  tab.id
578
686
  ) : /* @__PURE__ */ jsx("button", { ...shared, onClick: () => onChange(tab.id), children: inner }, tab.id);
687
+ if (!removable) return element;
688
+ return (
689
+ // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
690
+ // a tab's children are presentational, so a reader would flatten it into the
691
+ // tab's name. It sits beside the tab, over the gutter the tab reserves.
692
+ /* @__PURE__ */ jsxs("div", { className: "relative flex shrink-0", children: [
693
+ element,
694
+ isActive && /* @__PURE__ */ jsx(
695
+ IconButton,
696
+ {
697
+ size: "2xs",
698
+ tone: "danger",
699
+ tabIndex: -1,
700
+ type: "button",
701
+ "aria-label": text.remove(nameOf(tab)),
702
+ disabled: busy,
703
+ onClick: () => requestRemove(tab.id),
704
+ className: cn(
705
+ "absolute inset-y-0 end-1 my-auto",
706
+ // On a wrapped strip's filled chip the quiet grey would vanish into
707
+ // the brand fill below `md`.
708
+ wrap && "text-[var(--brand-contrast)] md:text-[var(--text-placeholder)]"
709
+ ),
710
+ children: /* @__PURE__ */ jsx(X, {})
711
+ }
712
+ )
713
+ ] }, tab.id)
714
+ );
579
715
  })
580
716
  }
581
717
  );
718
+ if (!onAdd) return tablist;
719
+ return /* @__PURE__ */ jsxs(
720
+ "div",
721
+ {
722
+ className: cn(
723
+ wrap ? "flex flex-wrap items-end gap-1.5 md:flex-nowrap md:gap-0" : "flex items-end",
724
+ className
725
+ ),
726
+ children: [
727
+ tablist,
728
+ /* @__PURE__ */ jsx(
729
+ "div",
730
+ {
731
+ className: cn(
732
+ "flex flex-1 items-center",
733
+ wrap ? "md:self-stretch md:border-b md:border-[var(--border)]" : "self-stretch border-b border-[var(--border)]"
734
+ ),
735
+ children: /* @__PURE__ */ jsxs(
736
+ "button",
737
+ {
738
+ type: "button",
739
+ onClick: onAdd,
740
+ disabled: busy,
741
+ className: cn(TAB_ADD_CLASSES, wrap && TAB_ADD_WRAP_CLASSES),
742
+ children: [
743
+ /* @__PURE__ */ jsx(Plus, { "aria-hidden": true }),
744
+ addLabel ?? text.add
745
+ ]
746
+ }
747
+ )
748
+ }
749
+ )
750
+ ]
751
+ }
752
+ );
582
753
  }
583
754
  export {
584
755
  Button,
@@ -590,6 +761,7 @@ export {
590
761
  CardHeader,
591
762
  CardTitle,
592
763
  DEFAULT_PASSWORD_REVEAL_LABELS,
764
+ DEFAULT_TABS_LABELS,
593
765
  EmptyState,
594
766
  FIELD_BASE,
595
767
  FIELD_DISPLAY,
@@ -606,6 +778,7 @@ export {
606
778
  FloatingField,
607
779
  IconButton,
608
780
  Input,
781
+ Label,
609
782
  PHONE_QUERY,
610
783
  Select,
611
784
  Spinner,