@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
@@ -27,8 +27,30 @@ interface SearchFieldOwnProps
27
27
  * decision, not a default: a filter you can type into and not untype is the
28
28
  * complaint that produced the button on three of these four screens. */
29
29
  clearLabel?: string;
30
+ /**
31
+ * `"field"` (default): the bordered filter box that sits on a page.
32
+ *
33
+ * `"inline"`: no box at all — no border, no fill, no padding of its own — and no
34
+ * type size of its own either, so it takes the size of the header it sits in.
35
+ * For a search that IS the header: a command-palette sheet, a picker's top line
36
+ * (Keksdose's transaction search, which lost its `text-base` headline to a
37
+ * bordered `text-sm` box when it moved onto this component). The container is
38
+ * what frames it, so give the container the focus cue if it needs one
39
+ * (`focus-within:`); the caret is the field's own.
40
+ */
41
+ variant?: "field" | "inline";
42
+ /** Classes for the `<input>` itself. `className` styles the wrapper — the box the
43
+ * icon and the clear button are positioned against — so it could set a width
44
+ * and nothing else. See {@link Input}'s `inputClassName`. */
45
+ inputClassName?: string;
30
46
  }
31
47
 
48
+ // The inline look: FIELD_DISPLAY's idea (the chrome gone, the value the thing
49
+ // itself) without even its baseline, because the header it sits in already draws
50
+ // the line. No `text-*` size, on purpose — the input inherits the caller's.
51
+ const SEARCH_INLINE =
52
+ "block w-full min-w-0 border-0 bg-transparent py-1 text-[var(--text-primary)] shadow-none placeholder:text-[var(--text-placeholder)] focus:outline-none focus:ring-0";
53
+
32
54
  /**
33
55
  * One of the two spellings is REQUIRED, and the union is how that survives the
34
56
  * deprecation. Making `label` merely optional would have been the easy change and the
@@ -70,7 +92,18 @@ export type SearchFieldProps = SearchFieldOwnProps &
70
92
  * the next query can be typed straight away.
71
93
  */
72
94
  export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(
73
- { value, onChange, label, clearLabel, className, placeholder, "aria-label": ariaLabel, ...rest },
95
+ {
96
+ value,
97
+ onChange,
98
+ label,
99
+ clearLabel,
100
+ className,
101
+ inputClassName,
102
+ placeholder,
103
+ variant = "field",
104
+ "aria-label": ariaLabel,
105
+ ...rest
106
+ },
74
107
  ref,
75
108
  ) {
76
109
  const innerRef = useRef<HTMLInputElement>(null);
@@ -78,11 +111,17 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
78
111
  // fallback, so a box named only by `aria-label` still says what it is before anyone
79
112
  // has typed in it — the same deal `label` has always had.
80
113
  const name = ariaLabel ?? label;
114
+ const inline = variant === "inline";
81
115
  return (
82
116
  <div className={cn("relative", className)}>
83
117
  <Search
84
118
  aria-hidden
85
- className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]"
119
+ className={cn(
120
+ "pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
121
+ // Logical sides throughout, so a right-to-left page gets the icon at the
122
+ // start of the line and the clear button at its end.
123
+ inline ? "start-0" : "start-3",
124
+ )}
86
125
  />
87
126
  <input
88
127
  ref={(node) => {
@@ -117,12 +156,13 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
117
156
  spellCheck={false}
118
157
  {...rest}
119
158
  className={cn(
120
- FIELD_BASE,
121
- "pl-9",
159
+ inline ? SEARCH_INLINE : FIELD_BASE,
160
+ inline ? "ps-7" : "ps-9",
122
161
  // Room for our own "×" only when there is one; a field that can't be
123
162
  // cleared has no reason to reserve the space.
124
- clearLabel === undefined ? "pr-3" : "pr-9",
163
+ clearLabel === undefined ? (inline ? "pe-0" : "pe-3") : inline ? "pe-8" : "pe-9",
125
164
  "[&::-webkit-search-cancel-button]:appearance-none",
165
+ inputClassName,
126
166
  )}
127
167
  />
128
168
  {clearLabel !== undefined && value !== "" && (
@@ -135,7 +175,10 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
135
175
  innerRef.current?.focus();
136
176
  }}
137
177
  aria-label={clearLabel}
138
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
178
+ className={cn(
179
+ "absolute top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]",
180
+ inline ? "end-0" : "end-2",
181
+ )}
139
182
  >
140
183
  <X className="size-4" />
141
184
  </button>
@@ -72,6 +72,12 @@ export interface SignaturePadLabels {
72
72
  /** Live-region messages after the matching button. */
73
73
  cleared: string;
74
74
  undone: string;
75
+ /** {@link SignatureView}: what it says when there is no signature to show. */
76
+ viewEmpty: string;
77
+ /** {@link SignatureView}: the saved PNG's alternative text. */
78
+ viewDrawn: string;
79
+ /** {@link SignatureView}: the accessible name of a typed-name signature. */
80
+ viewTyped: (name: string) => string;
75
81
  }
76
82
 
77
83
  export const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {
@@ -88,6 +94,9 @@ export const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {
88
94
  typedName: "Full name",
89
95
  cleared: "Signature cleared",
90
96
  undone: "Last stroke removed",
97
+ viewEmpty: "Not signed",
98
+ viewDrawn: "Handwritten signature",
99
+ viewTyped: (name) => `Signed with the typed name ${name}`,
91
100
  };
92
101
 
93
102
  /* ── Types ───────────────────────────────────────────────────────────────── */
@@ -628,3 +637,106 @@ export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(fu
628
637
  );
629
638
  });
630
639
  SignaturePad.displayName = "SignaturePad";
640
+
641
+ /* ── Read-only ───────────────────────────────────────────────────────────── */
642
+
643
+ export interface SignatureViewProps extends ComponentPropsWithoutRef<"figure"> {
644
+ /** The saved PNG — what `SignaturePad` handed to `onChange`/`onSave`, as stored. */
645
+ value?: string | null;
646
+ /**
647
+ * A signature given as a typed name (`detail.typedName`), for a host that stores the
648
+ * text rather than its PNG. Rendered in the same script face the pad draws it in.
649
+ * Ignored while `value` is set.
650
+ */
651
+ typedName?: string | null;
652
+ /** Visible caption above the signature; defaults to `labels.label`. `null` hides it
653
+ * (the image keeps its alternative text) — for a signature block whose own heading
654
+ * already says whose signature it is. */
655
+ label?: ReactNode;
656
+ /**
657
+ * The exported PNG is dark ink on a transparent ground (`exportInk`), which is
658
+ * invisible on the dark theme's surface. By default the image is colour-inverted
659
+ * under `.dark`, so the ink reads light; `false` keeps the pixels as stored — for a
660
+ * PNG exported with its own `exportBackground`, which inverting would turn black.
661
+ */
662
+ adaptInk?: boolean;
663
+ /** Classes for the frame — its height, chiefly (default `h-40`, the pad's). */
664
+ frameClassName?: string;
665
+ /** User-facing strings; see {@link SignaturePadLabels} (`view*`). */
666
+ labels?: Partial<SignaturePadLabels>;
667
+ }
668
+
669
+ /**
670
+ * A saved signature, shown — the read side of {@link SignaturePad}: the same frame and
671
+ * signing line, none of the drawing chrome (no canvas, buttons, instructions or live
672
+ * region). A separate component rather than a `readOnly` pad because there is nothing
673
+ * of the pad to reuse: the pad's scene is STROKES, and a stored signature is a PNG
674
+ * (or a name) that cannot be turned back into them.
675
+ *
676
+ * Kastlan's handover protocol, reopened after signing, is the case: two signatures
677
+ * that must be visible, and must not look editable.
678
+ */
679
+ export function SignatureView({
680
+ value,
681
+ typedName,
682
+ label,
683
+ adaptInk = true,
684
+ frameClassName,
685
+ labels: labelsProp,
686
+ className,
687
+ ...rest
688
+ }: SignatureViewProps) {
689
+ const labels = useKitLabels("signaturePad", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);
690
+ const name = typedName?.trim() ?? "";
691
+ const kind = value ? "drawn" : name ? "typed" : "empty";
692
+ const caption = label === undefined ? labels.label : label;
693
+ const captionId = useId();
694
+ const captioned = caption !== null && caption !== false;
695
+ return (
696
+ <figure
697
+ // Named explicitly: the figcaption-to-figure name is not computed everywhere.
698
+ aria-labelledby={captioned ? captionId : undefined}
699
+ {...rest}
700
+ className={cn("m-0 space-y-1.5", className)}
701
+ data-empty={kind === "empty" || undefined}
702
+ >
703
+ {captioned && (
704
+ <figcaption id={captionId} className="block text-sm font-medium text-[var(--text-primary)]">{caption}</figcaption>
705
+ )}
706
+ <div
707
+ className={cn(
708
+ "relative flex h-40 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
709
+ frameClassName,
710
+ )}
711
+ >
712
+ {kind === "drawn" && (
713
+ <img
714
+ src={value as string}
715
+ alt={labels.viewDrawn}
716
+ className={cn("relative max-h-full max-w-full object-contain", adaptInk && "dark:invert")}
717
+ />
718
+ )}
719
+ {kind === "typed" && (
720
+ // One name for the whole thing: the visible text alone would be read as a bare
721
+ // name, with nothing saying it IS the signature.
722
+ <span
723
+ role="img"
724
+ aria-label={labels.viewTyped(name)}
725
+ className="relative max-w-[85%] truncate px-2 text-4xl italic text-[var(--text-primary)]"
726
+ style={{ fontFamily: SCRIPT_FONT }}
727
+ >
728
+ {name}
729
+ </span>
730
+ )}
731
+ {kind === "empty" && (
732
+ <span className="relative text-xs text-[var(--text-muted)]">{labels.viewEmpty}</span>
733
+ )}
734
+ {/* The signing line, as on the pad. Decoration only. */}
735
+ <div
736
+ aria-hidden="true"
737
+ className="pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]"
738
+ />
739
+ </div>
740
+ </figure>
741
+ );
742
+ }
@@ -0,0 +1,141 @@
1
+ import { useId, useMemo } from "react";
2
+ import type { ComponentPropsWithoutRef } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
6
+ import type { TileItem, TileSize } from "./tile-radio";
7
+
8
+ export interface SwatchPickerLabels {
9
+ /** The "no colour" tile's name, when `allowNone` is set. */
10
+ none: string;
11
+ /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
12
+ mixed: string;
13
+ }
14
+
15
+ export const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {
16
+ none: "No colour",
17
+ mixed: "Mixed: the selected items have different colours",
18
+ };
19
+
20
+ export interface SwatchOption<T extends string> {
21
+ value: T;
22
+ /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an
23
+ * inline background, so it does not have to be a class the kit's CSS knows.
24
+ * Leave it out when `swatchClassName` paints the dot instead. */
25
+ color?: string;
26
+ /** The colour's name, in the user's language. Shown in the bubble and read out;
27
+ * required, because a swatch with no name is a colour only some people can read. */
28
+ label: string;
29
+ /** Classes for the swatch dot, for a palette that lives in classes rather than
30
+ * values (a light/dark pair). An inline `color` would win over a class, so pass
31
+ * one or the other. */
32
+ swatchClassName?: string;
33
+ /** A remark read after the name and shown under it in the bubble ("used by
34
+ * Groceries"). The tile wears a dot while it has one. */
35
+ note?: string;
36
+ disabled?: boolean;
37
+ }
38
+
39
+ /**
40
+ * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's
41
+ * is omitted rather than shadowed, as on {@link ToggleGroup}.
42
+ */
43
+ export interface SwatchPickerProps<T extends string>
44
+ extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
45
+ options: SwatchOption<T>[];
46
+ /** The chosen colour, or `null` for none. */
47
+ value: T | null;
48
+ onChange: (value: T | null) => void;
49
+ /** Lead with a "no colour" tile whose value is `null`. Clearing a colour has to be
50
+ * as reachable as setting one, and a radio cannot be unchecked by clicking it. */
51
+ allowNone?: boolean;
52
+ /**
53
+ * "Some of each" — a bulk edit over rows whose colours differ. Nothing is checked
54
+ * (not even "none", whatever `value` says), and the group is described as mixed.
55
+ * Distinct from `value={null}`, which is an answer: "no colour".
56
+ */
57
+ mixed?: boolean;
58
+ /** See {@link IconPickerProps.activation}. Default "automatic". */
59
+ activation?: "automatic" | "manual";
60
+ /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
61
+ size?: TileSize;
62
+ disabled?: boolean;
63
+ labels?: Partial<SwatchPickerLabels>;
64
+ }
65
+
66
+ /**
67
+ * A row of colour swatches that is one radio group.
68
+ *
69
+ * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading
70
+ * above it. Each swatch is named by its `label` and shows it in a bubble on hover and
71
+ * focus, so the name is not the screen reader's alone.
72
+ *
73
+ * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one
74
+ * while nothing is checked; the arrow keys move between swatches and, by default,
75
+ * choose as they go; Home and End jump to the ends. Left and right follow the page's
76
+ * direction.
77
+ */
78
+ export function SwatchPicker<T extends string>({
79
+ options,
80
+ value,
81
+ onChange,
82
+ allowNone = false,
83
+ mixed = false,
84
+ activation = "automatic",
85
+ size = "md",
86
+ disabled = false,
87
+ labels,
88
+ className,
89
+ ...rest
90
+ }: SwatchPickerProps<T>) {
91
+ const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
92
+ const mixedId = useId();
93
+ const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
94
+ const items: TileItem<T>[] = [
95
+ ...(allowNone ? [{ key: null, label: text.none }] : []),
96
+ ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
97
+ ];
98
+ return (
99
+ <div
100
+ {...rest}
101
+ role="radiogroup"
102
+ aria-disabled={disabled || undefined}
103
+ aria-describedby={
104
+ [rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || undefined
105
+ }
106
+ className={cn("flex flex-wrap items-center gap-1.5", className)}
107
+ >
108
+ <TileRadioGroup
109
+ items={items}
110
+ checked={mixed ? undefined : value}
111
+ onSelect={onChange}
112
+ activation={activation}
113
+ disabled={disabled}
114
+ size={size}
115
+ renderTile={(item) => {
116
+ const opt = item.key === null ? undefined : byValue.get(item.key);
117
+ return (
118
+ <span
119
+ aria-hidden
120
+ // A hairline round the dot, drawn over whatever colour it is: a pale
121
+ // yellow swatch on a white surface is otherwise a hole in the row.
122
+ className={cn(
123
+ "rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
124
+ TILE_SIZE[size].swatch,
125
+ opt?.swatchClassName,
126
+ )}
127
+ style={{ background: opt?.color }}
128
+ />
129
+ );
130
+ }}
131
+ />
132
+ {/* `hidden`, not `sr-only`: a description is read from hidden text, and this
133
+ way it takes no room in the row. */}
134
+ {mixed && (
135
+ <span id={mixedId} hidden>
136
+ {text.mixed}
137
+ </span>
138
+ )}
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,228 @@
1
+ import { useId, useRef } from "react";
2
+ import type { KeyboardEvent, ReactNode } from "react";
3
+ import { Ban, Check } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import { Tooltip } from "./tooltip";
6
+
7
+ /**
8
+ * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square
9
+ * tiles that behaves as ONE radio group. Private to the package — not re-exported
10
+ * from the barrel.
11
+ *
12
+ * Keksdose had three of these (the category colours, the category symbols, the
13
+ * transaction flags) and all three were rows of `aria-pressed` buttons: a separate
14
+ * tab stop per tile, and "pressed" on a choice that is really one-of-n. The APG radio
15
+ * group is the shape a screen reader expects for that ("Red, radio, 2 of 7, checked")
16
+ * and the one a keyboard expects too — a single tab stop, arrows to move.
17
+ */
18
+
19
+ export type TileSize = "sm" | "md" | "lg";
20
+
21
+ /** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */
22
+ export const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {
23
+ sm: { tile: "size-7", glyph: "size-3.5", swatch: "size-4" },
24
+ md: { tile: "size-8", glyph: "size-4", swatch: "size-5" },
25
+ lg: { tile: "size-11", glyph: "size-5", swatch: "size-6" },
26
+ };
27
+
28
+ /** One tile, whatever it shows. `key` is `null` for the "none" entry. */
29
+ export interface TileItem<T extends string> {
30
+ key: T | null;
31
+ label: string;
32
+ /** Said after the name, and shown in the bubble under it. Marks the tile with a
33
+ * dot, so the note is visible without hovering every tile to find it. */
34
+ note?: string;
35
+ disabled?: boolean;
36
+ }
37
+
38
+ export interface TileRadioGroupProps<T extends string> {
39
+ items: TileItem<T>[];
40
+ /** The checked key, or `undefined` for "nothing is checked" (the mixed state, or
41
+ * a value that is not among the options). */
42
+ checked: T | null | undefined;
43
+ onSelect: (key: T | null) => void;
44
+ /** "automatic": an arrow key moves AND checks, the APG default. "manual": arrows
45
+ * move focus only, Space or Enter checks — for a picker whose change is expensive
46
+ * (a bulk edit that saves on every change). */
47
+ activation: "automatic" | "manual";
48
+ disabled: boolean;
49
+ size: TileSize;
50
+ /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */
51
+ renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;
52
+ /** Extra classes for a tile, per item. */
53
+ tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;
54
+ }
55
+
56
+ /**
57
+ * The tile's frame. Selection is a thicker, darker frame AND a tick in the corner —
58
+ * never a colour change alone,
59
+ * because the thing inside a swatch tile IS a colour, and a red frame round a red dot
60
+ * says nothing to a reader who cannot see red.
61
+ *
62
+ * `focus-visible` with an offset in the surface colour, as the checkbox does: the ring
63
+ * must not be painted in the colour of the frame it touches.
64
+ */
65
+ const TILE_BASE =
66
+ "relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors " +
67
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] " +
68
+ "disabled:cursor-not-allowed disabled:opacity-50";
69
+ const TILE_IDLE =
70
+ "border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]";
71
+ const TILE_SELECTED =
72
+ "border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]";
73
+
74
+ export function TileRadioGroup<T extends string>({
75
+ items,
76
+ checked,
77
+ onSelect,
78
+ activation,
79
+ disabled,
80
+ size,
81
+ renderTile,
82
+ tileClassName,
83
+ }: TileRadioGroupProps<T>) {
84
+ const refs = useRef<Array<HTMLButtonElement | null>>([]);
85
+ const noteId = useId();
86
+ const s = TILE_SIZE[size];
87
+ const checkedIndex =
88
+ checked === undefined ? -1 : items.findIndex((it) => it.key === checked);
89
+ // The ONE tab stop: the checked tile, else the first that can be chosen — the APG
90
+ // rule, so Tab into a group with nothing checked still lands somewhere.
91
+ const tabStop =
92
+ checkedIndex >= 0 && !items[checkedIndex].disabled
93
+ ? checkedIndex
94
+ : items.findIndex((it) => !it.disabled);
95
+
96
+ const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
97
+ // Left and right are VISUAL directions: in a right-to-left page the row runs the
98
+ // other way. Up and down follow reading order either way — the APG's radio group
99
+ // treats them as previous/next, and the tiles wrap, so there is no column to keep.
100
+ const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
101
+ let step: number | "first" | "last";
102
+ switch (e.key) {
103
+ case "ArrowRight":
104
+ step = rtl ? -1 : 1;
105
+ break;
106
+ case "ArrowLeft":
107
+ step = rtl ? 1 : -1;
108
+ break;
109
+ case "ArrowDown":
110
+ step = 1;
111
+ break;
112
+ case "ArrowUp":
113
+ step = -1;
114
+ break;
115
+ case "Home":
116
+ step = "first";
117
+ break;
118
+ case "End":
119
+ step = "last";
120
+ break;
121
+ default:
122
+ return;
123
+ }
124
+ e.preventDefault();
125
+ const enabled = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);
126
+ if (!enabled.length) return;
127
+ const at = enabled.indexOf(index);
128
+ let target: number;
129
+ if (step === "first") target = enabled[0];
130
+ else if (step === "last") target = enabled[enabled.length - 1];
131
+ // Wrapping, as a native radio group does: the last tile's "next" is the first.
132
+ else target = enabled[(at + step + enabled.length) % enabled.length];
133
+ refs.current[target]?.focus();
134
+ if (activation === "automatic") onSelect(items[target].key);
135
+ };
136
+
137
+ return (
138
+ <>
139
+ {items.map((item, i) => {
140
+ const selected = i === checkedIndex;
141
+ const button = (
142
+ <button
143
+ ref={(el) => {
144
+ refs.current[i] = el;
145
+ }}
146
+ type="button"
147
+ role="radio"
148
+ aria-checked={selected}
149
+ aria-label={item.label}
150
+ // The note is a DESCRIPTION, read after the name and the state, not glued
151
+ // into the name — the punctuation between the two would be English's.
152
+ aria-describedby={item.note ? `${noteId}-${i}` : undefined}
153
+ tabIndex={i === tabStop ? 0 : -1}
154
+ disabled={disabled || item.disabled}
155
+ // Clicking the checked tile again does nothing: a radio is not a toggle.
156
+ // "None" is the way back to nothing, and it is a tile of its own.
157
+ onClick={() => !selected && onSelect(item.key)}
158
+ onKeyDown={(e) => onKeyDown(e, i)}
159
+ className={cn(
160
+ TILE_BASE,
161
+ s.tile,
162
+ selected ? TILE_SELECTED : TILE_IDLE,
163
+ tileClassName?.(item, selected),
164
+ )}
165
+ >
166
+ {item.key === null ? (
167
+ <Ban aria-hidden className={cn(s.glyph, "text-[var(--text-muted)]")} />
168
+ ) : (
169
+ renderTile(item, selected)
170
+ )}
171
+ {item.note && (
172
+ <>
173
+ {/* A dot rather than a dimmed tile: dimming reads as "disabled", and a
174
+ tile with a note is entirely available. */}
175
+ <span
176
+ aria-hidden
177
+ className="absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]"
178
+ />
179
+ {/* `hidden` still serves as a description: aria-describedby reads
180
+ hidden text, which is what keeps it out of the layout. */}
181
+ <span id={`${noteId}-${i}`} hidden>
182
+ {item.note}
183
+ </span>
184
+ </>
185
+ )}
186
+ {selected && <SelectedTick />}
187
+ </button>
188
+ );
189
+ return (
190
+ // The bubble is the SIGHTED user's name for the tile — eight unlabelled
191
+ // squares are the complaint that put a bubble on Keksdose's (live #289).
192
+ // It wraps a span rather than the button, because a Tooltip hands its
193
+ // child an `aria-describedby`, and a tile named "Red" and described as
194
+ // "Red" is read out twice.
195
+ <Tooltip
196
+ key={item.key ?? "\u0000none"}
197
+ label={
198
+ item.note ? (
199
+ <>
200
+ <span className="block">{item.label}</span>
201
+ <span className="block font-normal text-[var(--text-muted)]">{item.note}</span>
202
+ </>
203
+ ) : (
204
+ item.label
205
+ )
206
+ }
207
+ portal
208
+ >
209
+ <span className="inline-flex">{button}</span>
210
+ </Tooltip>
211
+ );
212
+ })}
213
+ </>
214
+ );
215
+ }
216
+
217
+ /** The tick on a selected tile — a small badge in the corner, in the text colour on
218
+ * the surface colour, so it reads the same over every swatch colour. */
219
+ function SelectedTick() {
220
+ return (
221
+ <span
222
+ aria-hidden
223
+ className="absolute -bottom-1 -end-1 inline-flex size-3.5 items-center justify-center rounded-full bg-[var(--text-primary)] text-[var(--bg-surface)]"
224
+ >
225
+ <Check strokeWidth={3} className="size-2.5" />
226
+ </span>
227
+ );
228
+ }