@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
@@ -4,7 +4,7 @@ import { CalculatorButton, type CalculatorButtonLabels } from "./calculator";
4
4
  import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
5
5
  import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui";
6
6
  import { cn } from "../lib/cn";
7
- import { commitExpression, sanitizeLive } from "../lib/calc";
7
+ import { commitExpression, formatResult, sanitizeLive } from "../lib/calc";
8
8
  import { useMediaQuery } from "../hooks/use-media-query";
9
9
 
10
10
  interface NumberInputProps {
@@ -73,6 +73,64 @@ interface NumberInputProps {
73
73
  * every value reads as part of the number.
74
74
  */
75
75
  suffix?: ReactNode;
76
+ /**
77
+ * Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /
78
+ * PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×
79
+ * step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —
80
+ * what a native number input does. Decimal-exact: `0.1 + 0.2` steps to "0.3".
81
+ *
82
+ * A step changes the text through `onChange` like a keystroke would; `onCommit`
83
+ * still fires on blur/Enter. Without `step` the arrow keys move the caret as before.
84
+ */
85
+ step?: number;
86
+ /** Bounds for the step keys only — a step never leaves `[min, max]`. This field's
87
+ * value is a STRING, so typed text is not clamped: {@link NumberField} is the
88
+ * numeric field that clamps what is typed as well. */
89
+ min?: number;
90
+ max?: number;
91
+ }
92
+
93
+ /** Decimal places of `n` as written ("0.25" → 2, "1e-7" → 7), for decimal-exact
94
+ * stepping. */
95
+ function decimalsOf(n: number): number {
96
+ const text = formatResult(n);
97
+ const dot = text.indexOf(".");
98
+ return dot === -1 ? 0 : text.length - dot - 1;
99
+ }
100
+
101
+ /**
102
+ * `current` moved by `count` steps (negative = down), on the grid `origin + k × step`
103
+ * and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in
104
+ * that direction first, so one press is never more than one step.
105
+ *
106
+ * The grid index is found by division and the result rebuilt as `origin + k × step`,
107
+ * then cut to the decimals `step` and `origin` are written with — which is where
108
+ * binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is
109
+ * "0.3". Summing steps would accumulate that noise press by press instead.
110
+ */
111
+ export function stepNumber(
112
+ current: number | null,
113
+ count: number,
114
+ step: number,
115
+ min?: number,
116
+ max?: number,
117
+ ): number {
118
+ const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
119
+ // Nothing to step from: land on the value nearest zero the bounds allow, rather
120
+ // than on ±step — a room count with `min={1}` goes to 1, a rate to 0.
121
+ if (current === null || !Number.isFinite(current)) return clamp(0);
122
+ const origin = min !== undefined && Number.isFinite(min) ? min : 0;
123
+ const raw = (current - origin) / step;
124
+ const nearest = Math.round(raw);
125
+ // "On the grid" within float tolerance: (0.3 - 0) / 0.1 is 2.9999999999999996.
126
+ const onGrid = Math.abs(raw - nearest) < 1e-9;
127
+ const k = onGrid
128
+ ? nearest + count
129
+ : count > 0
130
+ ? Math.ceil(raw) + count - 1
131
+ : Math.floor(raw) + count + 1;
132
+ const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);
133
+ return clamp(Number((origin + k * step).toFixed(places)));
76
134
  }
77
135
 
78
136
  /**
@@ -100,6 +158,9 @@ export function NumberInput({
100
158
  hint,
101
159
  invalid,
102
160
  suffix,
161
+ step,
162
+ min,
163
+ max,
103
164
  }: NumberInputProps) {
104
165
  const generatedId = useId();
105
166
  const fieldId = id ?? generatedId;
@@ -121,8 +182,24 @@ export function NumberInput({
121
182
  onCommit?.(next);
122
183
  };
123
184
 
185
+ const stepBy = (count: number) => {
186
+ // The step starts from the text as a commit would read it, so "12+5" steps from 17.
187
+ const resolved = commitExpression(value).trim();
188
+ const n = resolved === "" ? NaN : Number(resolved);
189
+ const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step as number, min, max));
190
+ if (next !== value) onChange(next);
191
+ };
192
+
124
193
  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
125
- if (e.key === "Enter") commit();
194
+ if (e.key === "Enter") return commit();
195
+ // A zero, negative or non-finite step is no step: the keys stay the caret's.
196
+ if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;
197
+ const count =
198
+ e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : e.key === "PageUp" ? 10 : e.key === "PageDown" ? -10 : 0;
199
+ if (count === 0) return;
200
+ // Otherwise ArrowUp/Down also jump the caret to the start/end of the text.
201
+ e.preventDefault();
202
+ stepBy(count);
126
203
  };
127
204
 
128
205
  return (
@@ -19,7 +19,10 @@ export interface PopoverLabels {
19
19
  // Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete
20
20
  // English reference a translator works from has to be able to name every namespace.
21
21
  export const DEFAULT_POPOVER_LABELS: PopoverLabels = {
22
- panel: "Popover",
22
+ // "Pop-up", not "Popover": the panel's accessible name is read to USERS, and
23
+ // "popover" is a developer's word for it (reported by keksdose, 0.5.0). Only a
24
+ // bare Popover falls back to this — the kit's own pickers name their panels.
25
+ panel: "Pop-up",
23
26
  };
24
27
 
25
28
  /**
@@ -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
+ }