@eifi1/ui-kit 0.5.1 → 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 (128) 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 +3 -2
  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/danger-confirm.d.ts +91 -0
  20. package/dist/components/danger-confirm.js +181 -0
  21. package/dist/components/danger-confirm.js.map +1 -0
  22. package/dist/components/dialog-frame.d.ts +84 -0
  23. package/dist/components/dialog-frame.js +86 -0
  24. package/dist/components/dialog-frame.js.map +1 -0
  25. package/dist/components/disclosure.d.ts +108 -0
  26. package/dist/components/disclosure.js +127 -0
  27. package/dist/components/disclosure.js.map +1 -0
  28. package/dist/components/entity-combobox.d.ts +17 -3
  29. package/dist/components/entity-combobox.js +25 -5
  30. package/dist/components/entity-combobox.js.map +1 -1
  31. package/dist/components/file-button.d.ts +161 -0
  32. package/dist/components/file-button.js +225 -0
  33. package/dist/components/file-button.js.map +1 -0
  34. package/dist/components/file-dropzone.d.ts +72 -23
  35. package/dist/components/file-dropzone.js +219 -94
  36. package/dist/components/file-dropzone.js.map +1 -1
  37. package/dist/components/icon-picker.d.ts +72 -0
  38. package/dist/components/icon-picker.js +104 -0
  39. package/dist/components/icon-picker.js.map +1 -0
  40. package/dist/components/mini-calendar.d.ts +3 -0
  41. package/dist/components/mini-calendar.js +4 -3
  42. package/dist/components/mini-calendar.js.map +1 -1
  43. package/dist/components/modal.d.ts +8 -1
  44. package/dist/components/modal.js +4 -2
  45. package/dist/components/modal.js.map +1 -1
  46. package/dist/components/multi-entity-combobox.d.ts +16 -3
  47. package/dist/components/multi-entity-combobox.js +25 -5
  48. package/dist/components/multi-entity-combobox.js.map +1 -1
  49. package/dist/components/number-field.d.ts +41 -1
  50. package/dist/components/number-field.js +42 -10
  51. package/dist/components/number-field.js.map +1 -1
  52. package/dist/components/number-input.d.ts +35 -2
  53. package/dist/components/number-input.js +35 -4
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/numpad-sheet.d.ts +7 -0
  56. package/dist/components/search-field.d.ts +16 -0
  57. package/dist/components/search-field.js +29 -7
  58. package/dist/components/search-field.js.map +1 -1
  59. package/dist/components/signature-pad.d.ts +43 -1
  60. package/dist/components/signature-pad.js +74 -2
  61. package/dist/components/signature-pad.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +69 -0
  63. package/dist/components/swatch-picker.js +75 -0
  64. package/dist/components/swatch-picker.js.map +1 -0
  65. package/dist/components/tile-radio.d.ts +50 -0
  66. package/dist/components/tile-radio.js +140 -0
  67. package/dist/components/tile-radio.js.map +1 -0
  68. package/dist/components/toggle-group.d.ts +27 -5
  69. package/dist/components/toggle-group.js +22 -15
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/ui.d.ts +150 -12
  72. package/dist/components/ui.js +196 -22
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/i18n/defaults.d.ts +7 -0
  75. package/dist/i18n/defaults.js +13 -2
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/kit-labels.d.ts +38 -5
  78. package/dist/i18n/kit-labels.js +12 -4
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +12 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/table-text.d.ts +127 -0
  84. package/dist/lib/table-text.js +82 -0
  85. package/dist/lib/table-text.js.map +1 -0
  86. package/dist/rhf/form.d.ts +79 -0
  87. package/dist/rhf/form.js +143 -0
  88. package/dist/rhf/form.js.map +1 -0
  89. package/dist/rhf.d.ts +4 -0
  90. package/dist/rhf.js +3 -0
  91. package/dist/rhf.js.map +1 -0
  92. package/dist/shell/app-shell.js +3 -1
  93. package/dist/shell/app-shell.js.map +1 -1
  94. package/dist/table-text.d.ts +1 -0
  95. package/dist/table-text.js +3 -0
  96. package/dist/table-text.js.map +1 -0
  97. package/package.json +14 -1
  98. package/src/components/autocomplete.tsx +425 -0
  99. package/src/components/chip.tsx +24 -2
  100. package/src/components/choice-card.tsx +305 -0
  101. package/src/components/combobox-core.tsx +228 -58
  102. package/src/components/combobox.tsx +58 -21
  103. package/src/components/danger-confirm.tsx +286 -0
  104. package/src/components/dialog-frame.tsx +179 -0
  105. package/src/components/disclosure.tsx +259 -0
  106. package/src/components/entity-combobox.tsx +41 -6
  107. package/src/components/file-button.tsx +431 -0
  108. package/src/components/file-dropzone.tsx +323 -117
  109. package/src/components/icon-picker.tsx +181 -0
  110. package/src/components/mini-calendar.tsx +7 -3
  111. package/src/components/modal.tsx +10 -2
  112. package/src/components/multi-entity-combobox.tsx +40 -6
  113. package/src/components/number-field.tsx +86 -10
  114. package/src/components/number-input.tsx +79 -2
  115. package/src/components/search-field.tsx +49 -6
  116. package/src/components/signature-pad.tsx +112 -0
  117. package/src/components/swatch-picker.tsx +141 -0
  118. package/src/components/tile-radio.tsx +228 -0
  119. package/src/components/toggle-group.tsx +54 -18
  120. package/src/components/ui.tsx +400 -24
  121. package/src/i18n/defaults.ts +12 -1
  122. package/src/i18n/kit-labels.tsx +45 -5
  123. package/src/index.ts +19 -0
  124. package/src/lib/table-text.ts +265 -0
  125. package/src/rhf/form.tsx +300 -0
  126. package/src/rhf.ts +9 -0
  127. package/src/shell/app-shell.tsx +3 -1
  128. package/src/table-text.ts +8 -0
@@ -1,5 +1,5 @@
1
- import { forwardRef, useId, useState } from "react";
2
- import { ChevronDown, Eye, EyeOff, HelpCircle } from "lucide-react";
1
+ import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
+ import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
3
  import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useMediaQuery } from "../hooks/use-media-query";
@@ -71,33 +71,101 @@ export function Button({ variant = "primary", stretch, className, ...rest }: But
71
71
  // Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
72
72
  // gap-2 — so rendering a bare glyph through it gives a small icon in a wide,
73
73
  // text-shaped box. This fixes a square box instead and, crucially, forces the
74
- // child icon to 20px via `[&_svg]:size-5` so a caller cannot under-size it. Use
75
- // it wherever an action is a bare icon (edit/delete/tools) so they all match the
76
- // top-bar icon buttons and can never drift apart again.
74
+ // child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a
75
+ // caller cannot under-size it. Use it wherever an action is a bare icon
76
+ // (edit/delete/tools) so they all match the top-bar icon buttons and can never
77
+ // drift apart again.
77
78
  //
78
79
  // Draws its colours from the same `buttonVariantClasses` map as <Button>, so the
79
80
  // two re-skin together with the palette.
80
81
  const ICON_BUTTON_BASE =
81
- "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";
82
+ "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
83
+
84
+ export type IconButtonSize = "md" | "sm" | "xs" | "2xs";
85
+
86
+ // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
87
+ // that touches its edges. The two small steps are lenkbank's list-row (28px) and
88
+ // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
89
+ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
90
+ md: "size-9 [&_svg]:size-5",
91
+ sm: "size-8 [&_svg]:size-5",
92
+ xs: "size-7 rounded [&_svg]:size-4",
93
+ "2xs": "size-6 rounded [&_svg]:size-3.5",
94
+ };
95
+
96
+ // A tone re-colours the glyph without changing what the variant draws around it.
97
+ // `muted` and `danger` both sit quiet at rest — an action in every row of a list
98
+ // must not shout from every row — and `danger` answers the pointer in the
99
+ // destructive family, so the red arrives only on the one row you are about to
100
+ // act on.
101
+ const ICON_BUTTON_TONES = {
102
+ default: "",
103
+ muted:
104
+ "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
105
+ danger:
106
+ "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
107
+ } as const;
82
108
 
83
109
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
84
110
  variant?: ButtonVariant;
85
- /** Box size: md = 36px (matches the top bar), sm = 32px. The icon stays 20px. */
86
- size?: "sm" | "md";
111
+ /** Box size: md = 36px (matches the top bar), sm = 32px — both with a 20px icon;
112
+ * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
113
+ * icon (an action on a chip or a tab). */
114
+ size?: IconButtonSize;
115
+ /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
116
+ * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
117
+ * remove/delete that repeats down a list. Default: the variant's own colours. */
118
+ tone?: keyof typeof ICON_BUTTON_TONES;
119
+ /** Keep the click (and the Enter/Space that produces it) from reaching an
120
+ * ancestor's handler — for an action inside a clickable table row or card.
121
+ *
122
+ * **Not a licence to put this inside another `<button>`.** HTML forbids any
123
+ * interactive content, and any element with a `tabindex`, inside a button — so
124
+ * the `<span role="button" tabIndex={0}>` workaround is invalid too — and ARIA
125
+ * makes a button's children presentational, so a screen reader flattens the
126
+ * inner one into the outer one's name and it cannot be reached at all. Make the
127
+ * row's main action a button that fills the row and put this one BESIDE it, on
128
+ * top:
129
+ *
130
+ * ```tsx
131
+ * <div className="relative">
132
+ * <button className="w-full pe-16 …" onClick={open}>…row…</button>
133
+ * <div className="absolute inset-y-0 end-2 flex items-center gap-0.5">
134
+ * <IconButton size="xs" tone="danger" aria-label="Delete" onClick={remove}>
135
+ * <Trash2 />
136
+ * </IconButton>
137
+ * </div>
138
+ * </div>
139
+ * ```
140
+ *
141
+ * The pair look exactly like the nested version, click exactly like it, and
142
+ * are two tab stops a screen reader can tell apart. */
143
+ stopPropagation?: boolean;
87
144
  }
88
145
 
89
146
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
90
- { variant = "ghost", size = "md", className, ...rest },
147
+ { variant = "ghost", size = "md", tone = "default", stopPropagation, className, onClick, onKeyDown, ...rest },
91
148
  ref,
92
149
  ) {
93
150
  return (
94
151
  <button
95
152
  ref={ref}
96
153
  {...rest}
154
+ onClick={(e) => {
155
+ if (stopPropagation) e.stopPropagation();
156
+ onClick?.(e);
157
+ }}
158
+ onKeyDown={(e) => {
159
+ // A button turns Enter/Space into a click of its own, but the KEY still
160
+ // bubbles — and a clickable row listening for Enter would act on it too.
161
+ if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
162
+ onKeyDown?.(e);
163
+ }}
97
164
  className={cn(
98
165
  ICON_BUTTON_BASE,
99
- size === "sm" ? "size-8" : "size-9",
166
+ ICON_BUTTON_SIZES[size],
100
167
  buttonVariantClasses[variant],
168
+ ICON_BUTTON_TONES[tone],
101
169
  className,
102
170
  )}
103
171
  />
@@ -451,6 +519,54 @@ export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
451
519
  );
452
520
  }
453
521
 
522
+ export interface LabelProps extends ComponentPropsWithoutRef<"label"> {
523
+ /** Draw the required mark after the text. The mark is `aria-hidden`: what a
524
+ * screen reader hears is the control's own `required` / `aria-required`, which
525
+ * the caller still sets — a "star" read out as the last word of every name is
526
+ * noise, and a control that is not marked required is not required however its
527
+ * label looks. */
528
+ required?: boolean;
529
+ /** `sm` for a toolbar or a dense filter row (12px), `md` otherwise (14px). */
530
+ size?: "sm" | "md";
531
+ /** Dim the label with its control. A label ABOVE its field is not the field's
532
+ * `peer`, so `peer-disabled:` cannot reach it the way it reaches a floating one. */
533
+ disabled?: boolean;
534
+ }
535
+
536
+ /**
537
+ * The label ABOVE a field, for the places a floating label does not fit: a control
538
+ * the kit did not render (a third-party picker, a range slider, a group of radios),
539
+ * a filter bar whose fields are unlabelled {@link Select}s, a form that sets labels
540
+ * above its fields throughout.
541
+ *
542
+ * A new component rather than a mode of {@link FieldLabel}, because the two share
543
+ * nothing but the word. `FieldLabel` is an absolutely positioned `<span>` inside a
544
+ * trigger's `relative` box and has to stay exactly that for every dropdown that
545
+ * already lines up with it; this is a real `<label>` in normal flow, with `htmlFor`,
546
+ * that a click focuses the control through. Spacing below it belongs to the caller's
547
+ * layout (`space-y-1.5` on the pair, typically), as it would for any block.
548
+ */
549
+ export function Label({ required, size = "md", disabled, className, children, ...rest }: LabelProps) {
550
+ return (
551
+ <label
552
+ {...rest}
553
+ className={cn(
554
+ "inline-flex items-center gap-0.5 font-medium leading-none text-[var(--text-primary)]",
555
+ size === "sm" ? "text-xs" : "text-sm",
556
+ disabled && "cursor-default opacity-50",
557
+ className,
558
+ )}
559
+ >
560
+ {children}
561
+ {required && (
562
+ <span aria-hidden className="text-[var(--danger)]">
563
+ *
564
+ </span>
565
+ )}
566
+ </label>
567
+ );
568
+ }
569
+
454
570
  /**
455
571
  * The message under a field that is wrong, and the wiring that attaches it.
456
572
  *
@@ -482,6 +598,10 @@ function useFieldError(
482
598
  error: ReactNode,
483
599
  invalid: boolean | undefined,
484
600
  describedBy: string | undefined,
601
+ /** The caller's own `aria-invalid`. A form library sets THIS (see `@eifi1/ui-kit/rhf`
602
+ * `FormControl`), not our `invalid` prop — and a field that announces invalid should
603
+ * look it too, as NumberField and the pickers already did. */
604
+ ariaInvalid?: unknown,
485
605
  ) {
486
606
  const errorId = useId();
487
607
  // `null`, `false` and `""` are what a caller's `touched && errors.iban` evaluates to
@@ -490,7 +610,7 @@ function useFieldError(
490
610
  const hasError = error !== undefined && error !== null && error !== false && error !== "";
491
611
  return {
492
612
  /** A field carrying a message that says what is wrong with it IS wrong. */
493
- isInvalid: Boolean(invalid) || hasError,
613
+ isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
494
614
  describedBy: hasError ? (describedBy ? `${describedBy} ${errorId}` : errorId) : describedBy,
495
615
  errorEl: hasError ? (
496
616
  <p id={errorId} className={FIELD_ERROR_CLASS}>
@@ -610,6 +730,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
610
730
  error,
611
731
  invalid,
612
732
  rest["aria-describedby"],
733
+ rest["aria-invalid"],
613
734
  );
614
735
  const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
615
736
  // Password fields get a reveal toggle so users can check what they typed.
@@ -722,7 +843,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
722
843
  });
723
844
  Input.displayName = "Input";
724
845
 
725
- export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
846
+ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
726
847
  label?: ReactNode;
727
848
  /** See {@link Input}'s `invalid`. */
728
849
  invalid?: boolean;
@@ -730,25 +851,50 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
730
851
  error?: ReactNode;
731
852
  /** A {@link FieldHint} for the label line — see {@link FloatingField}. */
732
853
  hint?: ReactNode;
854
+ /**
855
+ * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
856
+ * 36px field stands a head taller than the buttons beside it. `"md"` (default)
857
+ * is the field every form uses. Unlabelled selects only — a floating label needs
858
+ * the tall box to float in, so a labelled Select ignores `"sm"`.
859
+ *
860
+ * A NUMBER is still the native attribute (the rows of a list box) and is passed
861
+ * straight through, so the one HTML meaning of `size` keeps working.
862
+ */
863
+ size?: "sm" | "md" | number;
864
+ /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
865
+ * the chevron is positioned against — so it could set a width and nothing
866
+ * else; this is the way to the element. See {@link Input}'s `inputClassName`. */
867
+ selectClassName?: string;
733
868
  }
734
869
 
870
+ // The compact select: the field's colours, a toolbar button's height. `py-0` and a
871
+ // fixed height rather than a smaller padding, so the box is 28px whatever line
872
+ // height the caller's type brings with it.
873
+ const SELECT_SM = "h-7 py-0 pl-2 pr-7 text-xs";
874
+
735
875
  export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
736
- { className, label, id, children, invalid, error, hint, ...rest },
876
+ { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
737
877
  ref,
738
878
  ) {
739
879
  const generated = useId();
880
+ // Only a number reaches the DOM; the two words are this component's own.
881
+ const nativeSize = typeof size === "number" ? size : undefined;
882
+ const small = size === "sm" && label === undefined;
740
883
  const fieldId = id ?? generated;
741
884
  const { isInvalid, describedBy, errorEl } = useFieldError(
742
885
  error,
743
886
  invalid,
744
887
  rest["aria-describedby"],
888
+ rest["aria-invalid"],
745
889
  );
746
890
  // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
747
891
  // in from the right border and matches both themes — feedback #223. A DISABLED
748
892
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
749
893
  // thing on the control that promises a choice (Keksdose dev#474, where the
750
894
  // account type became read-only and still looked exactly like a picker).
751
- const chevron = rest.disabled ? null : <FieldChevron />;
895
+ const chevron = rest.disabled ? null : (
896
+ <FieldChevron className={small ? "right-1.5 size-3.5" : undefined} />
897
+ );
752
898
  if (label === undefined) {
753
899
  return (
754
900
  <FieldGroup errorEl={errorEl}>
@@ -762,6 +908,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
762
908
  // when it was reached. Input and Textarea both forward it here; this is
763
909
  // the third one doing the same thing.
764
910
  id={id}
911
+ size={nativeSize}
765
912
  {...rest}
766
913
  // OR-ed with the spread for the reason spelled out on Input's copy: this
767
914
  // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
@@ -770,7 +917,13 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
770
917
  // attribute silently dropped. Input has never done that.
771
918
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
772
919
  aria-describedby={describedBy}
773
- className={cn(FIELD_BASE, "appearance-none pr-9", isInvalid && FIELD_INVALID)}
920
+ className={cn(
921
+ FIELD_BASE,
922
+ "appearance-none pr-9",
923
+ small && SELECT_SM,
924
+ selectClassName,
925
+ isInvalid && FIELD_INVALID,
926
+ )}
774
927
  >
775
928
  {children}
776
929
  </select>
@@ -785,6 +938,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
785
938
  <select
786
939
  ref={ref}
787
940
  id={fieldId}
941
+ size={nativeSize}
788
942
  {...rest}
789
943
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
790
944
  aria-describedby={describedBy}
@@ -792,6 +946,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
792
946
  FIELD_BASE,
793
947
  FIELD_FLOATING_PAD,
794
948
  "peer appearance-none pr-9",
949
+ selectClassName,
795
950
  isInvalid && FIELD_INVALID,
796
951
  )}
797
952
  >
@@ -822,6 +977,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
822
977
  error,
823
978
  invalid,
824
979
  rest["aria-describedby"],
980
+ rest["aria-invalid"],
825
981
  );
826
982
  if (label === undefined) {
827
983
  return (
@@ -1038,6 +1194,49 @@ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps)
1038
1194
  );
1039
1195
  }
1040
1196
 
1197
+ /** One tab of a {@link Tabs} strip. */
1198
+ export interface TabItem<T extends string> {
1199
+ id: T;
1200
+ /** A ReactNode so a tab can pair an icon with text. */
1201
+ label: ReactNode;
1202
+ /** Optional trailing node (e.g. a count pill). */
1203
+ badge?: ReactNode;
1204
+ /** Marks a tab that IS a route — see {@link TabsProps}. */
1205
+ href?: string;
1206
+ /** Leading decoration: an icon, or the colour swatches of the lines this tab
1207
+ * draws in a chart above the strip. Hidden from assistive tech — it repeats
1208
+ * what the label says, or it says something the label must say instead. */
1209
+ icon?: ReactNode;
1210
+ /** A second, smaller line under the label — the one fact that tells two tabs
1211
+ * with similar labels apart ("80 km/h", "Level 3"). Part of the tab's name. */
1212
+ detail?: ReactNode;
1213
+ /** A tab that exists as a slot with nothing in it yet, drawn with a dashed
1214
+ * outline so a gap in a sequence looks like a gap. Visual only: say "empty" in
1215
+ * the label or `detail` if a screen reader needs to know. */
1216
+ empty?: boolean;
1217
+ /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a
1218
+ * workbook cannot be without. Default: removable. */
1219
+ removable?: boolean;
1220
+ /** Plain-text name for the labels the strip composes about this tab ("Remove
1221
+ * …"), for when `label` is not a string. Default: `label` if it is a string,
1222
+ * else the id. */
1223
+ name?: string;
1224
+ }
1225
+
1226
+ /** The strings the strip renders on its own behalf — only when it can add or
1227
+ * remove tabs. */
1228
+ export interface TabsLabels {
1229
+ /** The "add" button's visible text when the caller gives no `addLabel`. */
1230
+ add: string;
1231
+ /** Accessible name of a tab's × — "Remove <tab>". */
1232
+ remove: (tab: string) => string;
1233
+ }
1234
+
1235
+ export const DEFAULT_TABS_LABELS: TabsLabels = {
1236
+ add: "Add tab",
1237
+ remove: (tab) => `Remove ${tab}`,
1238
+ };
1239
+
1041
1240
  /**
1042
1241
  * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything
1043
1242
  * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an
@@ -1054,7 +1253,7 @@ export interface TabsProps<T extends string>
1054
1253
  * feedback #451), while a plain click still goes through `onChange` and stays
1055
1254
  * client-side. Tabs that only flip local state leave it unset — a link to a URL
1056
1255
  * that does not select the tab would be worse than no link. */
1057
- tabs: { id: T; label: ReactNode; badge?: ReactNode; href?: string }[];
1256
+ tabs: TabItem<T>[];
1058
1257
  active: T;
1059
1258
  onChange: (id: T) => void;
1060
1259
  className?: string;
@@ -1120,6 +1319,34 @@ export interface TabsProps<T extends string>
1120
1319
  * describes a tab as opening something that is not there.
1121
1320
  */
1122
1321
  panelId?: string;
1322
+ /**
1323
+ * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab.
1324
+ *
1325
+ * The × is on the open tab only — a row of tabs each carrying one is a row of
1326
+ * things to hit by accident, and the tab you can remove should be the one you
1327
+ * are looking at — but its room is reserved on every removable tab, so opening a
1328
+ * tab never widens it and never pushes the rest of the strip along (lenkbank
1329
+ * feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
1330
+ * strip stays one stop, and the keyboard path is Delete (announced through
1331
+ * `aria-keyshortcuts`), which removes whichever tab has focus.
1332
+ *
1333
+ * The strip only ASKS: it calls this, and the tab goes when the caller drops it
1334
+ * from `tabs` — after a confirmation, a request, whatever it needs. When it does,
1335
+ * focus that was on the removed tab (or its ×) moves to the open tab if the
1336
+ * removed one was open, else to its neighbour, instead of falling to the page.
1337
+ */
1338
+ onRemove?: (id: T) => void;
1339
+ /** Renders an "add" button after the last tab, outside the tablist — it is an
1340
+ * action, not a tab, and a tablist may own only tabs. */
1341
+ onAdd?: () => void;
1342
+ /** The add button's text. Say what is added — "Add loop", "Add report" says
1343
+ * more than "Add". Default: `tabs.add` from the {@link UiKitProvider}. */
1344
+ addLabel?: string;
1345
+ /** Overrides for this strip's strings. See {@link TabsLabels}. */
1346
+ labels?: Partial<TabsLabels>;
1347
+ /** An add or a remove is in flight: the × and the add button are disabled and
1348
+ * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
1349
+ busy?: boolean;
1123
1350
  }
1124
1351
 
1125
1352
  // The two shapes are written out as whole strings rather than as one base plus a
@@ -1156,6 +1383,15 @@ const TAB_WRAP_ACTIVE_CLASSES =
1156
1383
  const TAB_WRAP_INACTIVE_CLASSES =
1157
1384
  "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)]";
1158
1385
 
1386
+ // The add/remove chrome. The × reserves its gutter on every removable tab (see
1387
+ // `onRemove`); `md:pe-8` is spelled out because the wrapped chip's `md:px-3` would
1388
+ // otherwise hand the gutter back from 768px up.
1389
+ const TAB_REMOVABLE_CLASSES = "pe-8 md:pe-8";
1390
+ const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--border-strong)] px-1.5";
1391
+ const TAB_ADD_CLASSES =
1392
+ "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";
1393
+ const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
1394
+
1159
1395
  export function Tabs<T extends string>({
1160
1396
  tabs,
1161
1397
  active,
@@ -1164,9 +1400,53 @@ export function Tabs<T extends string>({
1164
1400
  wrap = false,
1165
1401
  label,
1166
1402
  panelId,
1403
+ onRemove,
1404
+ onAdd,
1405
+ addLabel,
1406
+ labels,
1407
+ busy = false,
1167
1408
  "aria-label": ariaLabel,
1168
1409
  ...rest
1169
1410
  }: TabsProps<T>) {
1411
+ const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
1412
+ const stripRef = useRef<HTMLDivElement>(null);
1413
+ const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
1414
+ const nameOf = (tab: TabItem<T>) =>
1415
+ tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
1416
+
1417
+ // Where focus goes once a removal the strip asked for has happened. The caller
1418
+ // owns `tabs` and may take its time (a confirmation, a request), so this waits
1419
+ // for the id to actually leave the list rather than guessing at the moment.
1420
+ const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);
1421
+ const requestRemove = (id: T) => {
1422
+ const index = tabs.findIndex((t) => t.id === id);
1423
+ pendingFocus.current = {
1424
+ removed: id,
1425
+ wasActive: id === active,
1426
+ neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,
1427
+ };
1428
+ onRemove?.(id);
1429
+ };
1430
+ useLayoutEffect(() => {
1431
+ const pending = pendingFocus.current;
1432
+ if (!pending || tabs.some((t) => t.id === pending.removed)) return;
1433
+ pendingFocus.current = null;
1434
+ const strip = stripRef.current;
1435
+ if (!strip) return;
1436
+ // Only focus that fell out of the strip with the removed tab is ours to place.
1437
+ // If the user has moved on — into the panel, into a dialog — leave it there.
1438
+ const focused = document.activeElement;
1439
+ if (focused && focused !== document.body && !strip.contains(focused)) return;
1440
+ // The removed tab was open: focus follows the caller's new selection, which is
1441
+ // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.
1442
+ const target =
1443
+ !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)
1444
+ ? pending.neighbour
1445
+ : active;
1446
+ const index = tabs.findIndex((t) => t.id === target);
1447
+ strip.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
1448
+ }, [tabs, active]);
1449
+
1170
1450
  // Arrow keys walk the strip in DOM order (ARIA tabs pattern), which is what keeps
1171
1451
  // a WRAPPED strip navigable: the rows flow in DOM order too, so Right off the end
1172
1452
  // of row one lands on the first chip of row two rather than nowhere. Home/End jump
@@ -1180,7 +1460,15 @@ export function Tabs<T extends string>({
1180
1460
  // the handler on the elements that are natively focusable and actually receive the
1181
1461
  // keystroke. Those are the tabs, so the warning goes away by being right rather
1182
1462
  // than by being satisfied.
1183
- const onTabKeyDown = (e: KeyboardEvent<HTMLElement>) => {
1463
+ const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
1464
+ // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
1465
+ // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it
1466
+ // rather than letting it fall through to the page.
1467
+ if (e.key === "Delete" && isRemovable(tab)) {
1468
+ e.preventDefault();
1469
+ if (!busy) requestRemove(tab.id);
1470
+ return;
1471
+ }
1184
1472
  const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
1185
1473
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1186
1474
  const strip = e.currentTarget.closest('[role="tablist"]');
@@ -1197,19 +1485,26 @@ export function Tabs<T extends string>({
1197
1485
  : (from + step + items.length) % items.length;
1198
1486
  items[to]?.focus();
1199
1487
  };
1200
- return (
1488
+ const tablist = (
1201
1489
  <div
1202
1490
  // Before the role and the name: a caller's arbitrary attribute is welcome on the
1203
1491
  // strip, but a `role` or an `aria-label` arriving through a spread props object
1204
1492
  // must not be able to unmake the tablist the tabs below are registered against.
1205
1493
  {...rest}
1494
+ ref={stripRef}
1206
1495
  role="tablist"
1207
1496
  // The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
1208
1497
  aria-label={ariaLabel ?? label}
1209
- className={cn(wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES, className)}
1498
+ // With an add button the strip gains an outer box, and `className` goes there
1499
+ // — it is the box a caller's margin or width is meant for.
1500
+ className={cn(
1501
+ wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
1502
+ onAdd ? "min-w-0" : className,
1503
+ )}
1210
1504
  >
1211
1505
  {tabs.map((tab) => {
1212
1506
  const isActive = tab.id === active;
1507
+ const removable = isRemovable(tab);
1213
1508
  // Written once and worn by either tag below, so a routed tab and a
1214
1509
  // state-only tab stay indistinguishable to the eye and to a screen reader.
1215
1510
  const shared = {
@@ -1231,7 +1526,8 @@ export function Tabs<T extends string>({
1231
1526
  // Only the open tab: see `panelId`.
1232
1527
  "aria-controls": isActive ? panelId : undefined,
1233
1528
  id: isActive && panelId ? `${panelId}-tab` : undefined,
1234
- onKeyDown: onTabKeyDown,
1529
+ "aria-keyshortcuts": removable ? "Delete" : undefined,
1530
+ onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
1235
1531
  className: cn(
1236
1532
  wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1237
1533
  wrap
@@ -1241,15 +1537,33 @@ export function Tabs<T extends string>({
1241
1537
  : isActive
1242
1538
  ? TAB_ACTIVE_CLASSES
1243
1539
  : TAB_INACTIVE_CLASSES,
1540
+ removable && TAB_REMOVABLE_CLASSES,
1244
1541
  ),
1245
1542
  };
1246
1543
  const inner = (
1247
- <span className="inline-flex items-center gap-1.5">
1248
- {tab.label}
1544
+ <span className={cn("inline-flex items-center gap-1.5", tab.empty && TAB_EMPTY_CLASSES)}>
1545
+ {tab.icon != null && (
1546
+ <span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
1547
+ {tab.icon}
1548
+ </span>
1549
+ )}
1550
+ {tab.detail != null ? (
1551
+ <span className="flex flex-col items-start text-start">
1552
+ <span>{tab.label}</span>
1553
+ {/* A separator for the NAME only — whitespace between flex items is
1554
+ not drawn, and without it the two lines ran together into one
1555
+ word for a screen reader ("80 km/hloop 2"). */}{" "}
1556
+ <span className="text-[11px] font-normal leading-tight text-[var(--text-muted)]">
1557
+ {tab.detail}
1558
+ </span>
1559
+ </span>
1560
+ ) : (
1561
+ tab.label
1562
+ )}
1249
1563
  {tab.badge != null ? tab.badge : null}
1250
1564
  </span>
1251
1565
  );
1252
- return tab.href ? (
1566
+ const element = tab.href ? (
1253
1567
  <a
1254
1568
  key={tab.id}
1255
1569
  {...shared}
@@ -1276,7 +1590,7 @@ export function Tabs<T extends string>({
1276
1590
  // prop overrides the one in `shared`, and an anchor tab that lost the
1277
1591
  // arrows would be a strip that is navigable only where it is not a link.
1278
1592
  onKeyDown={(e) => {
1279
- onTabKeyDown(e);
1593
+ onTabKeyDown(e, tab);
1280
1594
  if (e.defaultPrevented || e.key !== " ") return;
1281
1595
  e.preventDefault();
1282
1596
  onChange(tab.id);
@@ -1289,7 +1603,69 @@ export function Tabs<T extends string>({
1289
1603
  {inner}
1290
1604
  </button>
1291
1605
  );
1606
+ if (!removable) return element;
1607
+ return (
1608
+ // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
1609
+ // a tab's children are presentational, so a reader would flatten it into the
1610
+ // tab's name. It sits beside the tab, over the gutter the tab reserves.
1611
+ <div key={tab.id} className="relative flex shrink-0">
1612
+ {element}
1613
+ {isActive && (
1614
+ <IconButton
1615
+ size="2xs"
1616
+ tone="danger"
1617
+ // Not a tab stop: the strip is one, and Delete is the keyboard's path.
1618
+ // Still a named button, so a screen reader's cursor and a touch
1619
+ // reader's swipe both reach it.
1620
+ tabIndex={-1}
1621
+ type="button"
1622
+ aria-label={text.remove(nameOf(tab))}
1623
+ disabled={busy}
1624
+ onClick={() => requestRemove(tab.id)}
1625
+ className={cn(
1626
+ "absolute inset-y-0 end-1 my-auto",
1627
+ // On a wrapped strip's filled chip the quiet grey would vanish into
1628
+ // the brand fill below `md`.
1629
+ wrap && "text-[var(--brand-contrast)] md:text-[var(--text-placeholder)]",
1630
+ )}
1631
+ >
1632
+ <X />
1633
+ </IconButton>
1634
+ )}
1635
+ </div>
1636
+ );
1292
1637
  })}
1293
1638
  </div>
1294
1639
  );
1640
+ if (!onAdd) return tablist;
1641
+ return (
1642
+ <div
1643
+ className={cn(
1644
+ wrap ? "flex flex-wrap items-end gap-1.5 md:flex-nowrap md:gap-0" : "flex items-end",
1645
+ className,
1646
+ )}
1647
+ >
1648
+ {tablist}
1649
+ {/* Outside the tablist: an action, not a tab — and a tablist may own only
1650
+ tabs. The rule under the strip carries on beneath it. */}
1651
+ <div
1652
+ className={cn(
1653
+ "flex flex-1 items-center",
1654
+ wrap
1655
+ ? "md:self-stretch md:border-b md:border-[var(--border)]"
1656
+ : "self-stretch border-b border-[var(--border)]",
1657
+ )}
1658
+ >
1659
+ <button
1660
+ type="button"
1661
+ onClick={onAdd}
1662
+ disabled={busy}
1663
+ className={cn(TAB_ADD_CLASSES, wrap && TAB_ADD_WRAP_CLASSES)}
1664
+ >
1665
+ <Plus aria-hidden />
1666
+ {addLabel ?? text.add}
1667
+ </button>
1668
+ </div>
1669
+ </div>
1670
+ );
1295
1671
  }
@@ -5,7 +5,7 @@ import { DEFAULT_PAGE_CONTENTS_LABELS } from "../components/page-contents";
5
5
  import { DEFAULT_POPOVER_LABELS } from "../components/popover";
6
6
  import { DEFAULT_CHIP_INPUT_LABELS } from "../components/chip";
7
7
  import { DEFAULT_FIELD_SYNC_LABELS } from "../components/field-sync";
8
- import { DEFAULT_PASSWORD_REVEAL_LABELS } from "../components/ui";
8
+ import { DEFAULT_PASSWORD_REVEAL_LABELS, DEFAULT_TABS_LABELS } from "../components/ui";
9
9
  import { DEFAULT_WIZARD_LABELS } from "../wizard/types";
10
10
  import { DEFAULT_TOUR_LABELS } from "../tour/tour";
11
11
  import { DEFAULT_COMMAND_PALETTE_LABELS } from "../search/command-palette";
@@ -14,6 +14,11 @@ import { DEFAULT_SPARKLINE_LABELS } from "../components/sparkline";
14
14
  import { DEFAULT_STAT_TILE_LABELS } from "../components/stat-tile";
15
15
  import { DEFAULT_SIGNATURE_PAD_LABELS } from "../components/signature-pad";
16
16
  import { DEFAULT_PASSWORD_STRENGTH_LABELS } from "../components/password-strength";
17
+ import { DEFAULT_DANGER_CONFIRM_LABELS } from "../components/danger-confirm";
18
+ import { DEFAULT_SWATCH_PICKER_LABELS } from "../components/swatch-picker";
19
+ import { DEFAULT_ICON_PICKER_LABELS } from "../components/icon-picker";
20
+ import { DEFAULT_DIALOG_FRAME_LABELS } from "../components/dialog-frame";
21
+ import { DEFAULT_FILE_PICKER_LABELS } from "../components/file-button";
17
22
  import {
18
23
  DEFAULT_APP_SHELL_LABELS,
19
24
  DEFAULT_CALCULATOR_LABELS,
@@ -51,6 +56,7 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
51
56
  chipInput: DEFAULT_CHIP_INPUT_LABELS,
52
57
  fieldSync: DEFAULT_FIELD_SYNC_LABELS,
53
58
  passwordReveal: DEFAULT_PASSWORD_REVEAL_LABELS,
59
+ tabs: DEFAULT_TABS_LABELS,
54
60
  appShell: DEFAULT_APP_SHELL_LABELS,
55
61
  pageContents: DEFAULT_PAGE_CONTENTS_LABELS,
56
62
  topBar: DEFAULT_TOP_BAR_LABELS,
@@ -65,4 +71,9 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
65
71
  statTile: DEFAULT_STAT_TILE_LABELS,
66
72
  signaturePad: DEFAULT_SIGNATURE_PAD_LABELS,
67
73
  passwordStrength: DEFAULT_PASSWORD_STRENGTH_LABELS,
74
+ dangerConfirm: DEFAULT_DANGER_CONFIRM_LABELS,
75
+ swatchPicker: DEFAULT_SWATCH_PICKER_LABELS,
76
+ iconPicker: DEFAULT_ICON_PICKER_LABELS,
77
+ dialogFrame: DEFAULT_DIALOG_FRAME_LABELS,
78
+ filePicker: DEFAULT_FILE_PICKER_LABELS,
68
79
  };