@eifi1/ui-kit 0.5.1 → 0.6.1

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 (136) 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 +103 -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/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. package/src/table-text.ts +8 -0
@@ -1,124 +1,249 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useRef, useState } from "react";
4
- import { Upload } from "lucide-react";
5
- import { Button } from "./ui.js";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef, useState } from "react";
4
+ import { Upload, X } from "lucide-react";
5
+ import { Button, IconButton } from "./ui.js";
6
6
  import { cn } from "../lib/cn.js";
7
- import { useKitFileLabels } from "../i18n/kit-labels.js";
7
+ import { useAnnounce } from "../hooks/use-announce.js";
8
+ import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
9
+ import { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button.js";
8
10
  function FileDropzone({
9
11
  file,
10
12
  onFileSelected,
13
+ multiple,
14
+ files,
15
+ onFilesSelected,
16
+ onClear,
17
+ onRemove,
11
18
  accept,
12
19
  isValid,
13
20
  invalidMessage,
21
+ maxSize,
22
+ maxFiles,
14
23
  onInvalid,
24
+ onReject,
25
+ rejectionFeedback,
26
+ labels: labelsProp,
15
27
  dropLabel,
16
28
  browseLabel,
17
29
  emptyLabel,
18
30
  hint,
19
31
  className,
20
32
  "aria-label": ariaLabel,
33
+ "aria-describedby": describedByProp,
21
34
  ...rest
22
35
  }) {
23
36
  const [dragOver, setDragOver] = useState(false);
37
+ const [error, setError] = useState(null);
24
38
  const fileInputRef = useRef(null);
39
+ const rootRef = useRef(null);
40
+ const errorId = useId();
25
41
  const fileText = useKitFileLabels();
26
- const acceptFile = (f) => {
27
- if (!f) return;
28
- if (!isValid(f)) {
29
- if (onInvalid) {
30
- onInvalid(f);
42
+ const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
43
+ const status = useAnnounce();
44
+ const alert = useAnnounce({ politeness: "assertive" });
45
+ const feedback = rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
46
+ const acceptFiles = (list) => {
47
+ if (!list || list.length === 0) return;
48
+ const all = Array.from(list);
49
+ const { accepted, rejected } = screenFiles(
50
+ multiple ? all : all.slice(0, 1),
51
+ {
52
+ accept: isValid ? void 0 : accept,
53
+ maxSize,
54
+ maxFiles: multiple ? maxFiles : void 0,
55
+ isValid,
56
+ invalidMessage
57
+ },
58
+ labels,
59
+ fileText.size
60
+ );
61
+ if (accepted.length > 0) {
62
+ setError(null);
63
+ if (multiple) {
64
+ onFilesSelected?.(accepted);
31
65
  } else {
32
- void import("sonner").then(({ toast }) => toast.error(invalidMessage));
66
+ onFileSelected?.(accepted[0]);
67
+ onFilesSelected?.(accepted);
68
+ }
69
+ status.announce(labels.selected(accepted.length, accepted[0].name));
70
+ }
71
+ if (rejected.length > 0) {
72
+ for (const r of rejected) onInvalid?.(r.file);
73
+ onReject?.(rejected);
74
+ const message = summariseRejections(rejected, labels);
75
+ if (feedback === "toast") {
76
+ void import("sonner").then(({ toast }) => toast.error(message));
77
+ } else {
78
+ if (feedback === "inline") setError(message);
79
+ alert.announce(message);
33
80
  }
34
- return;
35
81
  }
36
- onFileSelected(f);
37
82
  };
38
- return /* @__PURE__ */ jsxs(
39
- "div",
40
- {
41
- ...rest,
42
- onDragEnter: (e) => {
43
- e.preventDefault();
44
- e.stopPropagation();
45
- if (e.dataTransfer.types.includes("Files")) setDragOver(true);
46
- },
47
- onDragOver: (e) => {
48
- e.preventDefault();
49
- e.stopPropagation();
50
- e.dataTransfer.dropEffect = "copy";
51
- if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
52
- },
53
- onDragLeave: (e) => {
54
- e.preventDefault();
55
- e.stopPropagation();
56
- if (e.currentTarget.contains(e.relatedTarget)) return;
57
- setDragOver(false);
58
- },
59
- onDrop: (e) => {
60
- e.preventDefault();
61
- e.stopPropagation();
62
- setDragOver(false);
63
- acceptFile(e.dataTransfer.files?.[0]);
64
- },
65
- onClick: () => fileInputRef.current?.click(),
66
- onKeyDown: (e) => {
67
- if (e.key === "Enter" || e.key === " ") {
83
+ const afterRemoval = (message) => {
84
+ rootRef.current?.focus();
85
+ status.announce(message);
86
+ };
87
+ const chosen = multiple ? files ?? [] : file ? [file] : [];
88
+ const showError = feedback === "inline" && error !== null;
89
+ const describedBy = cn(describedByProp, showError && errorId) || void 0;
90
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
91
+ /* @__PURE__ */ jsxs(
92
+ "div",
93
+ {
94
+ ...rest,
95
+ ref: rootRef,
96
+ onDragEnter: (e) => {
68
97
  e.preventDefault();
69
- fileInputRef.current?.click();
70
- }
71
- },
72
- role: "button",
73
- tabIndex: 0,
74
- "aria-label": ariaLabel ?? dropLabel,
75
- className: cn(
76
- // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
77
- // which Tailwind implements as `position: absolute` — so without a positioned
78
- // ancestor its containing block is the INITIAL one, and it is laid out at its
79
- // own offset from the top of the document. Nothing looks wrong, because the
80
- // input is 1x1 and clipped; what breaks is the page height. On a long page
81
- // every escaped sr-only element extends `documentElement.scrollHeight` to its
82
- // own offset, which produces a second, whole-document scrollbar alongside the
83
- // app shell's own — one that scrolls past the end of the content into nothing.
84
- // Measured on the showcase: body 900px, document 47,919px.
85
- "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
86
- // Three states out of two border tokens: the target rests on the plain
87
- // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
88
- // border and adds the `--bg-active` wash on top — so "let go here" still reads
89
- // one step louder than "you are over it".
90
- dragOver ? "border-[var(--border-strong)] bg-[var(--bg-active)]" : "border-[var(--border)] hover:border-[var(--border-strong)]",
91
- className
92
- ),
93
- children: [
94
- /* @__PURE__ */ jsx(Upload, { className: "size-5 text-[var(--text-muted)]" }),
95
- /* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-secondary)]", children: file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel }),
96
- /* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file ? fileText.size(file.size) : hint }),
97
- /* @__PURE__ */ jsx(
98
- Button,
99
- {
100
- type: "button",
101
- variant: "secondary",
102
- onClick: (e) => {
103
- e.stopPropagation();
104
- fileInputRef.current?.click();
105
- },
106
- children: browseLabel
98
+ e.stopPropagation();
99
+ if (e.dataTransfer.types.includes("Files")) setDragOver(true);
100
+ },
101
+ onDragOver: (e) => {
102
+ e.preventDefault();
103
+ e.stopPropagation();
104
+ e.dataTransfer.dropEffect = "copy";
105
+ if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
106
+ },
107
+ onDragLeave: (e) => {
108
+ e.preventDefault();
109
+ e.stopPropagation();
110
+ if (e.currentTarget.contains(e.relatedTarget)) return;
111
+ setDragOver(false);
112
+ },
113
+ onDrop: (e) => {
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ setDragOver(false);
117
+ acceptFiles(e.dataTransfer.files);
118
+ },
119
+ onClick: () => fileInputRef.current?.click(),
120
+ onKeyDown: (e) => {
121
+ if (e.target !== e.currentTarget) return;
122
+ if (e.key === "Enter" || e.key === " ") {
123
+ e.preventDefault();
124
+ fileInputRef.current?.click();
107
125
  }
126
+ },
127
+ role: "button",
128
+ tabIndex: 0,
129
+ "aria-label": ariaLabel ?? dropLabel,
130
+ "aria-describedby": describedBy,
131
+ "data-invalid": showError || void 0,
132
+ className: cn(
133
+ // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
134
+ // which Tailwind implements as `position: absolute` — so without a positioned
135
+ // ancestor its containing block is the INITIAL one, and it is laid out at its
136
+ // own offset from the top of the document. Nothing looks wrong, because the
137
+ // input is 1x1 and clipped; what breaks is the page height. On a long page
138
+ // every escaped sr-only element extends `documentElement.scrollHeight` to its
139
+ // own offset, which produces a second, whole-document scrollbar alongside the
140
+ // app shell's own — one that scrolls past the end of the content into nothing.
141
+ // Measured on the showcase: body 900px, document 47,919px.
142
+ "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
143
+ // Three states out of two border tokens: the target rests on the plain
144
+ // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
145
+ // border and adds the `--bg-active` wash on top — so "let go here" still reads
146
+ // one step louder than "you are over it". A refusal shown inline takes the
147
+ // danger border until the next good pick.
148
+ dragOver ? "border-[var(--border-strong)] bg-[var(--bg-active)]" : showError ? "border-[var(--danger-border)]" : "border-[var(--border)] hover:border-[var(--border-strong)]",
149
+ className
108
150
  ),
109
- /* @__PURE__ */ jsx(
110
- "input",
111
- {
112
- ref: fileInputRef,
113
- type: "file",
114
- accept,
115
- onChange: (e) => acceptFile(e.target.files?.[0]),
116
- className: "sr-only"
117
- }
118
- )
119
- ]
120
- }
121
- );
151
+ children: [
152
+ /* @__PURE__ */ jsx(Upload, { "aria-hidden": true, className: "size-5 text-[var(--text-muted)]" }),
153
+ multiple && chosen.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "flex w-full max-w-sm flex-col gap-1 text-start", children: chosen.map((f, i) => /* @__PURE__ */ jsxs(
154
+ "li",
155
+ {
156
+ className: "flex items-center gap-2 text-sm text-[var(--text-secondary)]",
157
+ children: [
158
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: f.name }),
159
+ /* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs text-[var(--text-muted)]", children: fileText.size(f.size) }),
160
+ onRemove && /* @__PURE__ */ jsx(
161
+ IconButton,
162
+ {
163
+ type: "button",
164
+ size: "sm",
165
+ "aria-label": labels.remove(f.name),
166
+ onClick: (e) => {
167
+ e.stopPropagation();
168
+ onRemove(f, i);
169
+ afterRemoval(labels.removed(f.name));
170
+ },
171
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
172
+ }
173
+ )
174
+ ]
175
+ },
176
+ `${f.name}-${f.size}-${i}`
177
+ )) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
178
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 text-sm text-[var(--text-secondary)]", children: [
179
+ file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel,
180
+ !multiple && file && onClear && /* @__PURE__ */ jsx(
181
+ IconButton,
182
+ {
183
+ type: "button",
184
+ size: "sm",
185
+ "aria-label": labels.remove(file.name),
186
+ onClick: (e) => {
187
+ e.stopPropagation();
188
+ onClear();
189
+ afterRemoval(labels.removed(file.name));
190
+ },
191
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
192
+ }
193
+ )
194
+ ] }),
195
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file && !multiple ? fileText.size(file.size) : hint })
196
+ ] }),
197
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
198
+ /* @__PURE__ */ jsx(
199
+ Button,
200
+ {
201
+ type: "button",
202
+ variant: "secondary",
203
+ onClick: (e) => {
204
+ e.stopPropagation();
205
+ fileInputRef.current?.click();
206
+ },
207
+ children: browseLabel
208
+ }
209
+ ),
210
+ multiple && chosen.length > 0 && onClear && /* @__PURE__ */ jsx(
211
+ Button,
212
+ {
213
+ type: "button",
214
+ variant: "ghost",
215
+ onClick: (e) => {
216
+ e.stopPropagation();
217
+ onClear();
218
+ afterRemoval(labels.cleared);
219
+ },
220
+ children: labels.clearAll
221
+ }
222
+ )
223
+ ] }),
224
+ showError && /* @__PURE__ */ jsx("p", { id: errorId, className: "text-xs text-[var(--danger)]", children: error }),
225
+ /* @__PURE__ */ jsx(
226
+ "input",
227
+ {
228
+ ref: fileInputRef,
229
+ type: "file",
230
+ accept,
231
+ multiple,
232
+ onChange: (e) => {
233
+ const picked = Array.from(e.currentTarget.files ?? []);
234
+ e.currentTarget.value = "";
235
+ acceptFiles(picked);
236
+ },
237
+ onClick: (e) => e.stopPropagation(),
238
+ className: "sr-only"
239
+ }
240
+ )
241
+ ]
242
+ }
243
+ ),
244
+ /* @__PURE__ */ jsx("span", { ...status.regionProps }),
245
+ /* @__PURE__ */ jsx("span", { ...alert.regionProps })
246
+ ] });
122
247
  }
123
248
  export {
124
249
  FileDropzone
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFileLabels } from \"../i18n/kit-labels\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"button\"`, so that is also where an `aria-label` or an `aria-describedby`\n * belongs.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n file: File | null;\n onFileSelected: (file: File) => void;\n accept: string;\n isValid: (file: File) => boolean;\n invalidMessage: string;\n /**\n * How to surface {@link invalidMessage}. Defaults to a `sonner` toast — an\n * optional peer, imported only when a file is actually rejected. Pass your own to\n * route it somewhere else, or a no-op to silence it. The same escape hatch, for\n * the same reason, as `useWizard`'s `onValidationFailed`.\n *\n * It exists so this module does not import `sonner` STATICALLY. `package.json`\n * declares that peer `optional`, and the barrel re-exports this file — so a\n * top-level `import { toast } from \"sonner\"` made the claim false for everybody:\n * in an app that installed `@hb/ui` without sonner, `import { Button } from\n * \"@hb/ui\"` failed to resolve and `tsc --noEmit` failed on the missing types,\n * without a FileDropzone anywhere in it. Both current consumers happen to depend\n * on sonner, which is why nothing broke and why the contradiction survived:\n * `use-wizard.ts` had already answered the identical question the other way, in\n * writing, two modules over.\n */\n onInvalid?: (file: File) => void;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).\n * Validation failures surface `invalidMessage` (a `sonner` toast by default, see\n * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +\n * `hint` describe what to drop. */\nexport function FileDropzone({\n file,\n onFileSelected,\n accept,\n isValid,\n invalidMessage,\n onInvalid,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n\n const acceptFile = (f: File | undefined | null) => {\n if (!f) return;\n if (!isValid(f)) {\n if (onInvalid) {\n onInvalid(f);\n } else {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events.\n void import(\"sonner\").then(({ toast }) => toast.error(invalidMessage));\n }\n return;\n }\n onFileSelected(f);\n };\n\n return (\n <div\n // `...rest` first: every handler below is the drop gesture itself, and the\n // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.\n {...rest}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFile(e.dataTransfer.files?.[0]);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\".\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload className=\"size-5 text-[var(--text-muted)]\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file ? fileText.size(file.size) : hint}\n </div>\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n onChange={(e) => acceptFile(e.target.files?.[0])}\n className=\"sr-only\"\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from \"./file-button\";\nimport type { FilePickerLabels, FileRejection } from \"./file-button\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"button\"`, so that is also where an `aria-label` or an `aria-describedby`\n * belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus returns to the zone, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // Both are needed because nothing inside a `role=\"button\"` is read out — its\n // children are presentational — so the echoed file name never reaches a screen\n // reader on its own.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (!list || list.length === 0) return;\n const all = Array.from(list);\n const { accepted, rejected } = screenFiles(\n multiple ? all : all.slice(0, 1),\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n if (accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n onReject?.(rejected);\n const message = summariseRejections(rejected, labels);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. The zone is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n rootRef.current?.focus();\n status.announce(message);\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n <div\n // `...rest` first: every handler below is the drop gesture itself, and the\n // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.\n {...rest}\n ref={rootRef}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFiles(e.dataTransfer.files);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n // Only the zone's OWN keys. A remove button inside it is a real button, and\n // Enter on it bubbling up here would open the picker instead of removing.\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"button\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"border-[var(--danger-border)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone: a live region inside a `role=\"button\"` is inside\n presentational content, and screen readers are not consistent about\n reading those. `sr-only-fixed`, so they need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,72 @@
1
+ import * as react from 'react';
2
+ import { ComponentType, ComponentPropsWithoutRef } from 'react';
3
+ import { TileSize } from './tile-radio.js';
4
+
5
+ interface IconPickerLabels {
6
+ /** The "no icon" tile's name, when `allowNone` is set. */
7
+ none: string;
8
+ /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
9
+ mixed: string;
10
+ /** The search box's accessible name and placeholder, when `searchable` is set. */
11
+ search: string;
12
+ /** Shown in place of the tiles when the search matches nothing. */
13
+ noResults: string;
14
+ /** Announced as the search narrows the grid. Receives the number of matches. */
15
+ resultCount: (count: number) => string;
16
+ }
17
+ declare const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels;
18
+ interface IconOption<T extends string> {
19
+ value: T;
20
+ /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`
21
+ * — the tile's name is `label`. */
22
+ icon: ComponentType<{
23
+ className?: string;
24
+ "aria-hidden"?: boolean;
25
+ }>;
26
+ /** What the symbol IS, in the user's language ("Game controller") — not the icon
27
+ * library's identifier (`Gamepad2`), which is nobody's word for anything. */
28
+ label: string;
29
+ /** More words the search matches ("games", "play"), besides `label`. */
30
+ keywords?: string[];
31
+ /** A remark read after the name and shown under it in the bubble ("used by
32
+ * Leisure"). The tile wears a dot while it has one. */
33
+ note?: string;
34
+ disabled?: boolean;
35
+ }
36
+ interface IconPickerProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
37
+ options: IconOption<T>[];
38
+ /** The chosen icon, or `null` for none. */
39
+ value: T | null;
40
+ onChange: (value: T | null) => void;
41
+ /** Lead with a "no icon" tile whose value is `null`. */
42
+ allowNone?: boolean;
43
+ /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is
44
+ * described as mixed. */
45
+ mixed?: boolean;
46
+ /**
47
+ * "automatic" (default): an arrow key moves AND chooses, the APG radio group.
48
+ * "manual": arrows move focus only; Space or Enter chooses. For a picker whose every
49
+ * change is expensive — a bulk edit that saves as it goes.
50
+ */
51
+ activation?: "automatic" | "manual";
52
+ /** A search box above the grid, matching `label` and `keywords`. For a set large
53
+ * enough that scanning it is slower than typing. */
54
+ searchable?: boolean;
55
+ /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
56
+ size?: TileSize;
57
+ disabled?: boolean;
58
+ labels?: Partial<IconPickerLabels>;
59
+ }
60
+ /**
61
+ * A grid of symbols that is one radio group, with an optional search.
62
+ *
63
+ * Name the group with `aria-label` or `aria-labelledby`. The name lands on the
64
+ * radiogroup itself, not on the outer box: with `searchable` the outer box also holds
65
+ * the search field, which has a name of its own.
66
+ *
67
+ * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a
68
+ * right-to-left page), Home and End.
69
+ */
70
+ declare function IconPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, searchable, size, disabled, labels, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest }: IconPickerProps<T>): react.JSX.Element;
71
+
72
+ export { DEFAULT_ICON_PICKER_LABELS, type IconOption, IconPicker, type IconPickerLabels, type IconPickerProps };
@@ -0,0 +1,104 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useId, useMemo, useState } from "react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
6
+ import { SearchField } from "./search-field.js";
7
+ import { TILE_SIZE, TileRadioGroup } from "./tile-radio.js";
8
+ const DEFAULT_ICON_PICKER_LABELS = {
9
+ none: "No icon",
10
+ mixed: "Mixed: the selected items have different icons",
11
+ search: "Search icons",
12
+ noResults: "No icons match",
13
+ resultCount: (count) => count === 1 ? "1 icon" : `${count} icons`
14
+ };
15
+ function fold(text, locale) {
16
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase(locale);
17
+ }
18
+ function IconPicker({
19
+ options,
20
+ value,
21
+ onChange,
22
+ allowNone = false,
23
+ mixed = false,
24
+ activation = "automatic",
25
+ searchable = false,
26
+ size = "md",
27
+ disabled = false,
28
+ labels,
29
+ className,
30
+ "aria-label": ariaLabel,
31
+ "aria-labelledby": ariaLabelledBy,
32
+ "aria-describedby": ariaDescribedBy,
33
+ ...rest
34
+ }) {
35
+ const text = useKitLabels("iconPicker", DEFAULT_ICON_PICKER_LABELS, labels);
36
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
37
+ const locale = useKitLocale();
38
+ const mixedId = useId();
39
+ const [query, setQuery] = useState("");
40
+ const needle = fold(query.trim(), locale);
41
+ const visible = useMemo(
42
+ () => needle ? options.filter(
43
+ (o) => [o.label, ...o.keywords ?? []].some((w) => fold(w, locale).includes(needle))
44
+ ) : options,
45
+ [options, needle, locale]
46
+ );
47
+ const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
48
+ const items = [
49
+ // "None" stays through a search: it is the way back to no icon, and it matches
50
+ // no word anybody would type for it.
51
+ ...allowNone ? [{ key: null, label: text.none }] : [],
52
+ ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled }))
53
+ ];
54
+ return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("space-y-2", className), children: [
55
+ searchable && /* @__PURE__ */ jsx(
56
+ SearchField,
57
+ {
58
+ value: query,
59
+ onChange: setQuery,
60
+ "aria-label": text.search,
61
+ clearLabel: common.clear,
62
+ disabled
63
+ }
64
+ ),
65
+ /* @__PURE__ */ jsx(
66
+ "div",
67
+ {
68
+ role: "radiogroup",
69
+ "aria-label": ariaLabel,
70
+ "aria-labelledby": ariaLabelledBy,
71
+ "aria-describedby": [ariaDescribedBy, mixed && mixedId].filter(Boolean).join(" ") || void 0,
72
+ "aria-disabled": disabled || void 0,
73
+ className: "flex flex-wrap items-center gap-1.5",
74
+ children: /* @__PURE__ */ jsx(
75
+ TileRadioGroup,
76
+ {
77
+ items,
78
+ checked: mixed ? void 0 : value,
79
+ onSelect: onChange,
80
+ activation,
81
+ disabled,
82
+ size,
83
+ renderTile: (item) => {
84
+ const Icon = item.key === null ? void 0 : byValue.get(item.key)?.icon;
85
+ return Icon ? /* @__PURE__ */ jsx(Icon, { "aria-hidden": true, className: TILE_SIZE[size].glyph }) : null;
86
+ },
87
+ tileClassName: (_item, selected) => selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
88
+ }
89
+ )
90
+ }
91
+ ),
92
+ needle && visible.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: text.noResults }),
93
+ mixed && /* @__PURE__ */ jsx("span", { id: mixedId, hidden: true, children: text.mixed }),
94
+ searchable && // Typing moves no focus and the grid changes silently, so the count is said.
95
+ // `sr-only-fixed`: this sits inside a consumer's markup and must not extend
96
+ // the document height (see tokens.css).
97
+ /* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", "aria-atomic": true, className: "sr-only-fixed", children: needle ? text.resultCount(visible.length) : "" })
98
+ ] });
99
+ }
100
+ export {
101
+ DEFAULT_ICON_PICKER_LABELS,
102
+ IconPicker
103
+ };
104
+ //# sourceMappingURL=icon-picker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/icon-picker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ComponentType } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { SearchField } from \"./search-field\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface IconPickerLabels {\n /** The \"no icon\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n /** The search box's accessible name and placeholder, when `searchable` is set. */\n search: string;\n /** Shown in place of the tiles when the search matches nothing. */\n noResults: string;\n /** Announced as the search narrows the grid. Receives the number of matches. */\n resultCount: (count: number) => string;\n}\n\nexport const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {\n none: \"No icon\",\n mixed: \"Mixed: the selected items have different icons\",\n search: \"Search icons\",\n noResults: \"No icons match\",\n resultCount: (count) => (count === 1 ? \"1 icon\" : `${count} icons`),\n};\n\nexport interface IconOption<T extends string> {\n value: T;\n /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`\n * — the tile's name is `label`. */\n icon: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** What the symbol IS, in the user's language (\"Game controller\") — not the icon\n * library's identifier (`Gamepad2`), which is nobody's word for anything. */\n label: string;\n /** More words the search matches (\"games\", \"play\"), besides `label`. */\n keywords?: string[];\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Leisure\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface IconPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: IconOption<T>[];\n /** The chosen icon, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no icon\" tile whose value is `null`. */\n allowNone?: boolean;\n /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is\n * described as mixed. */\n mixed?: boolean;\n /**\n * \"automatic\" (default): an arrow key moves AND chooses, the APG radio group.\n * \"manual\": arrows move focus only; Space or Enter chooses. For a picker whose every\n * change is expensive — a bulk edit that saves as it goes.\n */\n activation?: \"automatic\" | \"manual\";\n /** A search box above the grid, matching `label` and `keywords`. For a set large\n * enough that scanning it is slower than typing. */\n searchable?: boolean;\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n disabled?: boolean;\n labels?: Partial<IconPickerLabels>;\n}\n\n/** Case- and accent-insensitive, so \"cafe\" finds \"Café\". */\nfunction fold(text: string, locale: string | undefined): string {\n return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase(locale);\n}\n\n/**\n * A grid of symbols that is one radio group, with an optional search.\n *\n * Name the group with `aria-label` or `aria-labelledby`. The name lands on the\n * radiogroup itself, not on the outer box: with `searchable` the outer box also holds\n * the search field, which has a name of its own.\n *\n * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a\n * right-to-left page), Home and End.\n */\nexport function IconPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n searchable = false,\n size = \"md\",\n disabled = false,\n labels,\n className,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: IconPickerProps<T>) {\n const text = useKitLabels(\"iconPicker\", DEFAULT_ICON_PICKER_LABELS, labels);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale();\n const mixedId = useId();\n const [query, setQuery] = useState(\"\");\n const needle = fold(query.trim(), locale);\n\n const visible = useMemo(\n () =>\n needle\n ? options.filter((o) =>\n [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),\n )\n : options,\n [options, needle, locale],\n );\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n // \"None\" stays through a search: it is the way back to no icon, and it matches\n // no word anybody would type for it.\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n\n return (\n <div {...rest} className={cn(\"space-y-2\", className)}>\n {searchable && (\n <SearchField\n value={query}\n onChange={setQuery}\n aria-label={text.search}\n clearLabel={common.clear}\n disabled={disabled}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(\" \") || undefined}\n aria-disabled={disabled || undefined}\n className=\"flex flex-wrap items-center gap-1.5\"\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;\n return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;\n }}\n tileClassName={(_item, selected) =>\n selected ? \"text-[var(--text-primary)]\" : \"text-[var(--text-secondary)]\"\n }\n />\n </div>\n {needle && visible.length === 0 && (\n <p className=\"text-xs text-[var(--text-muted)]\">{text.noResults}</p>\n )}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n {searchable && (\n // Typing moves no focus and the grid changes silently, so the count is said.\n // `sr-only-fixed`: this sits inside a consumer's markup and must not extend\n // the document height (see tokens.css).\n <span role=\"status\" aria-live=\"polite\" aria-atomic className=\"sr-only-fixed\">\n {needle ? text.resultCount(visible.length) : \"\"}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -68,6 +68,9 @@ interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">, "onSel
68
68
  * Saturday in much of the Arab world — which is what the user's own wall calendar
69
69
  * does. It was hard-wired to Monday before, which is wrong for most of the people
70
70
  * who use a Sunday-first calendar and invisible to the people who wrote this one.
71
+ *
72
+ * Resolved as: this prop, else `<UiKitProvider weekStartsOn>`, else the locale's
73
+ * week info, else Monday.
71
74
  */
72
75
  weekStartsOn?: WeekDay;
73
76
  onSelect: (from: string, to: string) => void;