@exsto/ds 0.0.1-rc.17 → 0.0.1-rc.18
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.
- package/README.md +34 -0
- package/dist/ds/src/file-input.d.ts +9 -0
- package/dist/ds/src/time-field.d.ts +9 -0
- package/dist/file-input.js +2 -0
- package/dist/index.js +4 -3
- package/dist/source-hash.txt +1 -1
- package/dist/styles.css +143 -38
- package/dist/time-field.js +2 -0
- package/dist/ui/src/components/edyn/file-input/file-input.d.ts +90 -0
- package/dist/ui/src/components/edyn/file-input/file-input.js +261 -0
- package/dist/ui/src/components/edyn/file-input/file-input.js.map +1 -0
- package/dist/ui/src/components/edyn/file-input/file-input.test.d.ts +8 -0
- package/dist/ui/src/components/edyn/file-input/index.d.ts +1 -0
- package/dist/ui/src/components/edyn/multi-select/multi-select.d.ts +12 -1
- package/dist/ui/src/components/edyn/multi-select/multi-select.js +11 -7
- package/dist/ui/src/components/edyn/multi-select/multi-select.js.map +1 -1
- package/dist/ui/src/components/edyn/time-field/index.d.ts +1 -0
- package/dist/ui/src/components/edyn/time-field/time-field.d.ts +85 -0
- package/dist/ui/src/components/edyn/time-field/time-field.js +330 -0
- package/dist/ui/src/components/edyn/time-field/time-field.js.map +1 -0
- package/dist/ui/src/components/edyn/time-field/time-field.test.d.ts +8 -0
- package/dist/ui/src/components/ui/combobox/combobox.d.ts +10 -0
- package/dist/ui/src/components/ui/combobox/combobox.js +37 -5
- package/dist/ui/src/components/ui/combobox/combobox.js.map +1 -1
- package/dist/ui/src/components/ui/context-menu/context-menu.d.ts +1 -1
- package/dist/ui/src/components/ui/context-menu/context-menu.js +23 -10
- package/dist/ui/src/components/ui/context-menu/context-menu.js.map +1 -1
- package/dist/ui/src/components/ui/dropdown-menu/dropdown-menu.js +5 -4
- package/dist/ui/src/components/ui/dropdown-menu/dropdown-menu.js.map +1 -1
- package/dist/ui/src/components/ui/menubar/menubar.js +1 -1
- package/dist/ui/src/components/ui/menubar/menubar.js.map +1 -1
- package/dist/ui/src/components/ui/scroll-area/scroll-area.js +1 -1
- package/dist/ui/src/components/ui/scroll-area/scroll-area.js.map +1 -1
- package/dist/ui/src/components/ui/select/select.js +7 -6
- package/dist/ui/src/components/ui/select/select.js.map +1 -1
- package/dist/ui/src/components/ui/sheet/sheet.d.ts +8 -0
- package/dist/ui/src/components/ui/sheet/sheet.js +11 -3
- package/dist/ui/src/components/ui/sheet/sheet.js.map +1 -1
- package/dist/ui/src/components/ui/table/table.d.ts +9 -2
- package/dist/ui/src/components/ui/table/table.js +19 -6
- package/dist/ui/src/components/ui/table/table.js.map +1 -1
- package/dist/ui/src/index.d.ts +1 -0
- package/dist/ui/src/lib/popup-list.d.ts +29 -4
- package/dist/ui/src/lib/popup-list.js +30 -5
- package/dist/ui/src/lib/popup-list.js.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { cn } from "../../../lib/utils.js";
|
|
2
|
+
import { Button } from "../../ui/button/button.js";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { IconFile, IconUpload, IconX } from "@tabler/icons-react";
|
|
6
|
+
//#region ../ui/src/components/edyn/file-input/file-input.tsx
|
|
7
|
+
var DEFAULT_FILE_INPUT_LABELS = {
|
|
8
|
+
dropOne: "Drag a file here or",
|
|
9
|
+
dropMany: "Drag files here or",
|
|
10
|
+
browse: "browse",
|
|
11
|
+
chooseOne: "Choose a file",
|
|
12
|
+
chooseMany: "Choose files",
|
|
13
|
+
list: "Chosen files",
|
|
14
|
+
remove: (name) => `Remove ${name}`,
|
|
15
|
+
added: (count) => count === 1 ? "1 file added" : `${count} files added`,
|
|
16
|
+
removed: (name) => `${name} removed`,
|
|
17
|
+
rejection: (name, reason, limit) => {
|
|
18
|
+
if (reason === "type") return `${name} was not added: this type is not accepted`;
|
|
19
|
+
if (reason === "size") return `${name} was not added: it is larger than ${limit}`;
|
|
20
|
+
return `${name} was not added: ${limit === "1" ? "1 file is" : `${limit} files are`} the most`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var UNITS = [
|
|
24
|
+
"B",
|
|
25
|
+
"KB",
|
|
26
|
+
"MB",
|
|
27
|
+
"GB"
|
|
28
|
+
];
|
|
29
|
+
/** 1,536 bytes as "1.5 KB": the size a person reads next to a file's name. */
|
|
30
|
+
var formatBytes = (bytes) => {
|
|
31
|
+
let size = bytes;
|
|
32
|
+
let unit = 0;
|
|
33
|
+
while (size >= 1024 && unit < UNITS.length - 1) {
|
|
34
|
+
size /= 1024;
|
|
35
|
+
unit += 1;
|
|
36
|
+
}
|
|
37
|
+
return `${unit === 0 || size >= 10 ? String(Math.round(size)) : size.toFixed(1).replace(/\.0$/, "")} ${UNITS[unit]}`;
|
|
38
|
+
};
|
|
39
|
+
/** Whether a file is one `accept` allows: an extension (`.pdf`), a type (`application/pdf`) or a family of types (`image/*`). No `accept` allows all. */
|
|
40
|
+
var matchesAccept = (file, accept) => {
|
|
41
|
+
const tokens = (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
42
|
+
if (tokens.length === 0) return true;
|
|
43
|
+
const name = file.name.toLowerCase();
|
|
44
|
+
const type = file.type.toLowerCase();
|
|
45
|
+
return tokens.some((token) => {
|
|
46
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
47
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
48
|
+
return type === token;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
var sameFile = (a, b) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
52
|
+
/**
|
|
53
|
+
* Which of the files just offered are taken, and which are refused and why. A file already chosen is skipped without a word. With one
|
|
54
|
+
* file allowed, the first acceptable one replaces what was chosen and the rest are refused as too many.
|
|
55
|
+
*/
|
|
56
|
+
var sortFiles = ({ incoming, current, multiple, accept, maxSize, maxFiles }) => {
|
|
57
|
+
const accepted = [];
|
|
58
|
+
const rejected = [];
|
|
59
|
+
const limit = multiple ? maxFiles : 1;
|
|
60
|
+
for (const file of incoming) if (!matchesAccept(file, accept)) rejected.push({
|
|
61
|
+
file,
|
|
62
|
+
reason: "type"
|
|
63
|
+
});
|
|
64
|
+
else if (maxSize !== void 0 && file.size > maxSize) rejected.push({
|
|
65
|
+
file,
|
|
66
|
+
reason: "size"
|
|
67
|
+
});
|
|
68
|
+
else if (current.some((chosen) => sameFile(chosen, file)) || accepted.some((taken) => sameFile(taken, file))) {} else if (limit !== void 0 && (multiple ? current.length : 0) + accepted.length >= limit) rejected.push({
|
|
69
|
+
file,
|
|
70
|
+
reason: "count"
|
|
71
|
+
});
|
|
72
|
+
else accepted.push(file);
|
|
73
|
+
return {
|
|
74
|
+
accepted,
|
|
75
|
+
rejected
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
var limitText = (reason, maxSize, maxFiles, multiple) => {
|
|
79
|
+
if (reason === "size") return formatBytes(maxSize ?? 0);
|
|
80
|
+
return String(multiple ? maxFiles ?? 0 : 1);
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Chooses files by picking them or by dropping them on the field, in the kit's look. It exists so an app does not fall back to
|
|
84
|
+
* `<input type="file">`, which cannot be styled and has no list of what was chosen.
|
|
85
|
+
*
|
|
86
|
+
* The control is a real file input laid over the whole drop area, so Tab, Enter and Space, the system's file dialog, a drop and a
|
|
87
|
+
* screen reader's name for it all come from the browser. What the browser lacks is added around it: the list of chosen files with a
|
|
88
|
+
* button to take each out, the checks against `accept`, `maxSize` and `maxFiles` (a drop skips the dialog's own filter), and a
|
|
89
|
+
* live region that says what was added, removed or refused.
|
|
90
|
+
*
|
|
91
|
+
* Name the field with `aria-label`, `aria-labelledby` or a `<Label htmlFor={id}>`; with none of those it is named "Choose files".
|
|
92
|
+
*/
|
|
93
|
+
var FileInput = ({ value, defaultValue = [], onValueChange, accept, multiple = false, maxSize, maxFiles, onReject, disabled = false, invalid = false, required = false, name, id, hint, labels: labelOverrides, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }) => {
|
|
94
|
+
const labels = useMemo(() => ({
|
|
95
|
+
...DEFAULT_FILE_INPUT_LABELS,
|
|
96
|
+
...labelOverrides
|
|
97
|
+
}), [labelOverrides]);
|
|
98
|
+
const [inner, setInner] = useState(defaultValue);
|
|
99
|
+
const files = value ?? inner;
|
|
100
|
+
const [dragging, setDragging] = useState(false);
|
|
101
|
+
const [rejections, setRejections] = useState([]);
|
|
102
|
+
const [announcement, setAnnouncement] = useState("");
|
|
103
|
+
const refInput = useRef(null);
|
|
104
|
+
const hintId = `${id ?? "file-input"}-hint`;
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const input = refInput.current;
|
|
107
|
+
if (input === null || typeof DataTransfer === "undefined") return;
|
|
108
|
+
const transfer = new DataTransfer();
|
|
109
|
+
for (const file of files) transfer.items.add(file);
|
|
110
|
+
input.files = transfer.files;
|
|
111
|
+
}, [files]);
|
|
112
|
+
const commit = (next) => {
|
|
113
|
+
setInner(next);
|
|
114
|
+
onValueChange?.(next);
|
|
115
|
+
};
|
|
116
|
+
const take = (incoming) => {
|
|
117
|
+
if (incoming.length === 0) return;
|
|
118
|
+
const { accepted, rejected } = sortFiles({
|
|
119
|
+
incoming,
|
|
120
|
+
current: files,
|
|
121
|
+
multiple,
|
|
122
|
+
accept,
|
|
123
|
+
maxSize,
|
|
124
|
+
maxFiles
|
|
125
|
+
});
|
|
126
|
+
setRejections(rejected);
|
|
127
|
+
if (rejected.length > 0) onReject?.(rejected);
|
|
128
|
+
if (accepted.length > 0) {
|
|
129
|
+
commit(multiple ? [...files, ...accepted] : accepted);
|
|
130
|
+
setAnnouncement(labels.added(accepted.length));
|
|
131
|
+
} else setAnnouncement("");
|
|
132
|
+
};
|
|
133
|
+
const remove = (index) => {
|
|
134
|
+
const gone = files[index];
|
|
135
|
+
if (gone === void 0) return;
|
|
136
|
+
setRejections([]);
|
|
137
|
+
commit(files.filter((_, at) => at !== index));
|
|
138
|
+
setAnnouncement(labels.removed(gone.name));
|
|
139
|
+
refInput.current?.focus();
|
|
140
|
+
};
|
|
141
|
+
const onChange = (event) => {
|
|
142
|
+
take([...event.target.files ?? []]);
|
|
143
|
+
};
|
|
144
|
+
const carriesFiles = (event) => event.dataTransfer.types.includes("Files");
|
|
145
|
+
const onDrag = (event) => {
|
|
146
|
+
if (disabled || !carriesFiles(event)) return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
setDragging(true);
|
|
149
|
+
};
|
|
150
|
+
const onDrop = (event) => {
|
|
151
|
+
if (disabled || !carriesFiles(event)) return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
setDragging(false);
|
|
154
|
+
take([...event.dataTransfer.files]);
|
|
155
|
+
};
|
|
156
|
+
const named = ariaLabel !== void 0 || ariaLabelledBy !== void 0 || id !== void 0;
|
|
157
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
158
|
+
"data-slot": "file-input",
|
|
159
|
+
className: cn("flex w-full flex-col gap-2", className),
|
|
160
|
+
...rest,
|
|
161
|
+
children: [
|
|
162
|
+
/* @__PURE__ */ jsxs("div", {
|
|
163
|
+
"data-slot": "file-input-zone",
|
|
164
|
+
role: "presentation",
|
|
165
|
+
"data-dragging": dragging || void 0,
|
|
166
|
+
className: cn("relative flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-input px-4 py-6 text-center text-sm transition-colors dark:bg-input/30", "hover:bg-foreground/4 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50", "data-dragging:border-ring data-dragging:bg-foreground/6", invalid && "border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40", disabled && "pointer-events-none cursor-not-allowed border-border"),
|
|
167
|
+
onDragEnter: onDrag,
|
|
168
|
+
onDragOver: onDrag,
|
|
169
|
+
onDragLeave: (event) => {
|
|
170
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
|
|
171
|
+
},
|
|
172
|
+
onDrop,
|
|
173
|
+
children: [
|
|
174
|
+
/* @__PURE__ */ jsx(IconUpload, {
|
|
175
|
+
"aria-hidden": "true",
|
|
176
|
+
className: "size-5 text-muted-foreground"
|
|
177
|
+
}),
|
|
178
|
+
/* @__PURE__ */ jsxs("p", {
|
|
179
|
+
className: disabled ? "text-muted-foreground" : "text-foreground",
|
|
180
|
+
children: [
|
|
181
|
+
multiple ? labels.dropMany : labels.dropOne,
|
|
182
|
+
" ",
|
|
183
|
+
/* @__PURE__ */ jsx("span", {
|
|
184
|
+
className: "font-medium underline underline-offset-3",
|
|
185
|
+
children: labels.browse
|
|
186
|
+
})
|
|
187
|
+
]
|
|
188
|
+
}),
|
|
189
|
+
hint !== void 0 && /* @__PURE__ */ jsx("p", {
|
|
190
|
+
id: hintId,
|
|
191
|
+
className: "text-xs text-muted-foreground",
|
|
192
|
+
children: hint
|
|
193
|
+
}),
|
|
194
|
+
/* @__PURE__ */ jsx("input", {
|
|
195
|
+
ref: refInput,
|
|
196
|
+
id,
|
|
197
|
+
type: "file",
|
|
198
|
+
name,
|
|
199
|
+
accept,
|
|
200
|
+
multiple,
|
|
201
|
+
disabled,
|
|
202
|
+
required: required && files.length === 0,
|
|
203
|
+
"aria-label": named ? ariaLabel : multiple ? labels.chooseMany : labels.chooseOne,
|
|
204
|
+
"aria-labelledby": ariaLabelledBy,
|
|
205
|
+
"aria-describedby": hint === void 0 ? void 0 : hintId,
|
|
206
|
+
"aria-invalid": invalid || void 0,
|
|
207
|
+
className: "absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed",
|
|
208
|
+
onChange
|
|
209
|
+
})
|
|
210
|
+
]
|
|
211
|
+
}),
|
|
212
|
+
files.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
213
|
+
"data-slot": "file-input-list",
|
|
214
|
+
"aria-label": labels.list,
|
|
215
|
+
className: "flex flex-col gap-1",
|
|
216
|
+
children: files.map((file, index) => /* @__PURE__ */ jsxs("li", {
|
|
217
|
+
className: "flex items-center gap-2 rounded-lg border border-input py-1 pr-1 pl-2 text-sm",
|
|
218
|
+
children: [
|
|
219
|
+
/* @__PURE__ */ jsx(IconFile, {
|
|
220
|
+
"aria-hidden": "true",
|
|
221
|
+
className: "size-4 shrink-0 text-muted-foreground"
|
|
222
|
+
}),
|
|
223
|
+
/* @__PURE__ */ jsx("span", {
|
|
224
|
+
className: "min-w-0 flex-1 truncate",
|
|
225
|
+
children: file.name
|
|
226
|
+
}),
|
|
227
|
+
/* @__PURE__ */ jsx("span", {
|
|
228
|
+
className: "shrink-0 text-xs text-muted-foreground tabular-nums",
|
|
229
|
+
children: formatBytes(file.size)
|
|
230
|
+
}),
|
|
231
|
+
/* @__PURE__ */ jsx(Button, {
|
|
232
|
+
type: "button",
|
|
233
|
+
variant: "ghost",
|
|
234
|
+
size: "icon-xs",
|
|
235
|
+
disabled,
|
|
236
|
+
"aria-label": labels.remove(file.name),
|
|
237
|
+
onClick: () => remove(index),
|
|
238
|
+
children: /* @__PURE__ */ jsx(IconX, { "aria-hidden": "true" })
|
|
239
|
+
})
|
|
240
|
+
]
|
|
241
|
+
}, `${file.name}-${file.size}-${file.lastModified}`))
|
|
242
|
+
}),
|
|
243
|
+
/* @__PURE__ */ jsxs("div", {
|
|
244
|
+
role: "status",
|
|
245
|
+
"data-slot": "file-input-status",
|
|
246
|
+
className: "text-sm empty:hidden",
|
|
247
|
+
children: [rejections.map(({ file, reason }) => /* @__PURE__ */ jsx("p", {
|
|
248
|
+
className: "text-destructive",
|
|
249
|
+
children: labels.rejection(file.name, reason, limitText(reason, maxSize, maxFiles, multiple))
|
|
250
|
+
}, `${file.name}-${reason}`)), announcement !== "" && /* @__PURE__ */ jsx("span", {
|
|
251
|
+
className: "sr-only",
|
|
252
|
+
children: announcement
|
|
253
|
+
})]
|
|
254
|
+
})
|
|
255
|
+
]
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
//#endregion
|
|
259
|
+
export { DEFAULT_FILE_INPUT_LABELS, FileInput, formatBytes, matchesAccept, sortFiles };
|
|
260
|
+
|
|
261
|
+
//# sourceMappingURL=file-input.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-input.js","names":[],"sources":["../../../../../../../ui/src/components/edyn/file-input/file-input.tsx"],"sourcesContent":["import { Button } from '@edyn/ui/components/ui/button';\nimport { cn } from '@edyn/ui/lib/utils';\nimport { IconFile, IconUpload, IconX } from '@tabler/icons-react';\nimport { type ChangeEvent, type ComponentPropsWithoutRef, type DragEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';\n\nexport type TFileRejectReason = 'type' | 'size' | 'count';\n\nexport interface IFileRejection {\n file: File;\n reason: TFileRejectReason;\n}\n\n/** The words a FileInput says and announces. Pass the ones to change. */\nexport interface IFileInputLabels {\n /** The prompt in the drop area when it takes one file, before the \"browse\" link. */\n dropOne: string;\n /** The same, when it takes several. */\n dropMany: string;\n browse: string;\n /** The accessible name of the file chooser itself, when the caller gives it no name of its own. */\n chooseOne: string;\n chooseMany: string;\n /** The accessible name of the list of chosen files. */\n list: string;\n remove: (name: string) => string;\n /** Announced when files are added. */\n added: (count: number) => string;\n /** Announced when a file is taken out. */\n removed: (name: string) => string;\n /** Said, and shown, for a file that was refused. */\n rejection: (name: string, reason: TFileRejectReason, limit: string) => string;\n}\n\nexport const DEFAULT_FILE_INPUT_LABELS: IFileInputLabels = {\n dropOne: 'Drag a file here or',\n dropMany: 'Drag files here or',\n browse: 'browse',\n chooseOne: 'Choose a file',\n chooseMany: 'Choose files',\n list: 'Chosen files',\n remove: (name) => `Remove ${name}`,\n added: (count) => (count === 1 ? '1 file added' : `${count} files added`),\n removed: (name) => `${name} removed`,\n rejection: (name, reason, limit) => {\n if (reason === 'type') {\n return `${name} was not added: this type is not accepted`;\n }\n if (reason === 'size') {\n return `${name} was not added: it is larger than ${limit}`;\n }\n return `${name} was not added: ${limit === '1' ? '1 file is' : `${limit} files are`} the most`;\n },\n};\n\nconst UNITS = ['B', 'KB', 'MB', 'GB'] as const;\n\n/** 1,536 bytes as \"1.5 KB\": the size a person reads next to a file's name. */\nexport const formatBytes = (bytes: number): string => {\n let size = bytes;\n let unit = 0;\n while (size >= 1024 && unit < UNITS.length - 1) {\n size /= 1024;\n unit += 1;\n }\n const text = unit === 0 || size >= 10 ? String(Math.round(size)) : size.toFixed(1).replace(/\\.0$/, '');\n return `${text} ${UNITS[unit]}`;\n};\n\n/** Whether a file is one `accept` allows: an extension (`.pdf`), a type (`application/pdf`) or a family of types (`image/*`). No `accept` allows all. */\nexport const matchesAccept = (file: { name: string; type: string }, accept: string | undefined): boolean => {\n const tokens = (accept ?? '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token !== '');\n if (tokens.length === 0) {\n return true;\n }\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return tokens.some((token) => {\n if (token.startsWith('.')) {\n return name.endsWith(token);\n }\n if (token.endsWith('/*')) {\n return type.startsWith(token.slice(0, -1));\n }\n return type === token;\n });\n};\n\nconst sameFile = (a: File, b: File): boolean => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;\n\nexport interface ISortFilesInput {\n incoming: readonly File[];\n current: readonly File[];\n multiple: boolean;\n accept?: string;\n maxSize?: number;\n maxFiles?: number;\n}\n\n/**\n * Which of the files just offered are taken, and which are refused and why. A file already chosen is skipped without a word. With one\n * file allowed, the first acceptable one replaces what was chosen and the rest are refused as too many.\n */\nexport const sortFiles = ({\n incoming,\n current,\n multiple,\n accept,\n maxSize,\n maxFiles,\n}: ISortFilesInput): { accepted: File[]; rejected: IFileRejection[] } => {\n const accepted: File[] = [];\n const rejected: IFileRejection[] = [];\n const limit = multiple ? maxFiles : 1;\n for (const file of incoming) {\n if (!matchesAccept(file, accept)) {\n rejected.push({ file, reason: 'type' });\n } else if (maxSize !== undefined && file.size > maxSize) {\n rejected.push({ file, reason: 'size' });\n } else if (current.some((chosen) => sameFile(chosen, file)) || accepted.some((taken) => sameFile(taken, file))) {\n } else if (limit !== undefined && (multiple ? current.length : 0) + accepted.length >= limit) {\n rejected.push({ file, reason: 'count' });\n } else {\n accepted.push(file);\n }\n }\n return { accepted, rejected };\n};\n\nexport interface IFileInputProps extends Omit<ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'onChange' | 'children' | 'id'> {\n /** The chosen files. Leave it out and the field keeps them itself. */\n value?: readonly File[];\n defaultValue?: readonly File[];\n onValueChange?: (files: File[]) => void;\n /** What the picker offers, and what a drop is held to: extensions (`.pdf`), types (`application/pdf`) or families (`image/*`). */\n accept?: string;\n /** Several files, added to each other, instead of one that is replaced. */\n multiple?: boolean;\n /** The most a file may weigh, in bytes. */\n maxSize?: number;\n /** The most files that may be chosen. Several-file fields only. */\n maxFiles?: number;\n /** Called with the files that were refused, each with the reason. They are also listed under the field. */\n onReject?: (rejections: IFileRejection[]) => void;\n disabled?: boolean;\n invalid?: boolean;\n required?: boolean;\n /** Submits the chosen files with a form, under this name. */\n name?: string;\n /** For a `<Label htmlFor>` to name the field. */\n id?: string;\n /** What the field accepts, in words (\"PDF or PNG, up to 5 MB\"), read with the field. */\n hint?: ReactNode;\n labels?: Partial<IFileInputLabels>;\n 'aria-label'?: string;\n 'aria-labelledby'?: string;\n}\n\nconst limitText = (reason: TFileRejectReason, maxSize: number | undefined, maxFiles: number | undefined, multiple: boolean): string => {\n if (reason === 'size') {\n return formatBytes(maxSize ?? 0);\n }\n return String(multiple ? (maxFiles ?? 0) : 1);\n};\n\n/**\n * Chooses files by picking them or by dropping them on the field, in the kit's look. It exists so an app does not fall back to\n * `<input type=\"file\">`, which cannot be styled and has no list of what was chosen.\n *\n * The control is a real file input laid over the whole drop area, so Tab, Enter and Space, the system's file dialog, a drop and a\n * screen reader's name for it all come from the browser. What the browser lacks is added around it: the list of chosen files with a\n * button to take each out, the checks against `accept`, `maxSize` and `maxFiles` (a drop skips the dialog's own filter), and a\n * live region that says what was added, removed or refused.\n *\n * Name the field with `aria-label`, `aria-labelledby` or a `<Label htmlFor={id}>`; with none of those it is named \"Choose files\".\n */\nexport const FileInput = ({\n value,\n defaultValue = [],\n onValueChange,\n accept,\n multiple = false,\n maxSize,\n maxFiles,\n onReject,\n disabled = false,\n invalid = false,\n required = false,\n name,\n id,\n hint,\n labels: labelOverrides,\n className,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledBy,\n ...rest\n}: IFileInputProps) => {\n const labels = useMemo(() => ({ ...DEFAULT_FILE_INPUT_LABELS, ...labelOverrides }), [labelOverrides]);\n const [inner, setInner] = useState<readonly File[]>(defaultValue);\n const files = value ?? inner;\n const [dragging, setDragging] = useState(false);\n const [rejections, setRejections] = useState<IFileRejection[]>([]);\n const [announcement, setAnnouncement] = useState('');\n const refInput = useRef<HTMLInputElement | null>(null);\n const hintId = `${id ?? 'file-input'}-hint`;\n\n // The input's own list follows the chosen files, so a form submits exactly what the list shows.\n useEffect(() => {\n const input = refInput.current;\n if (input === null || typeof DataTransfer === 'undefined') {\n return;\n }\n const transfer = new DataTransfer();\n for (const file of files) {\n transfer.items.add(file);\n }\n input.files = transfer.files;\n }, [files]);\n\n const commit = (next: File[]): void => {\n setInner(next);\n onValueChange?.(next);\n };\n\n const take = (incoming: File[]): void => {\n if (incoming.length === 0) {\n return;\n }\n const { accepted, rejected } = sortFiles({ incoming, current: files, multiple, accept, maxSize, maxFiles });\n setRejections(rejected);\n if (rejected.length > 0) {\n onReject?.(rejected);\n }\n if (accepted.length > 0) {\n commit(multiple ? [...files, ...accepted] : accepted);\n setAnnouncement(labels.added(accepted.length));\n } else {\n setAnnouncement('');\n }\n };\n\n const remove = (index: number): void => {\n const gone = files[index];\n if (gone === undefined) {\n return;\n }\n setRejections([]);\n commit(files.filter((_, at) => at !== index));\n setAnnouncement(labels.removed(gone.name));\n refInput.current?.focus();\n };\n\n const onChange = (event: ChangeEvent<HTMLInputElement>): void => {\n take([...(event.target.files ?? [])]);\n };\n\n const carriesFiles = (event: DragEvent): boolean => event.dataTransfer.types.includes('Files');\n\n const onDrag = (event: DragEvent<HTMLDivElement>): void => {\n if (disabled || !carriesFiles(event)) {\n return;\n }\n event.preventDefault();\n setDragging(true);\n };\n\n const onDrop = (event: DragEvent<HTMLDivElement>): void => {\n if (disabled || !carriesFiles(event)) {\n return;\n }\n // The drop is handled here, once, and not again by the input underneath.\n event.preventDefault();\n setDragging(false);\n take([...event.dataTransfer.files]);\n };\n\n const named = ariaLabel !== undefined || ariaLabelledBy !== undefined || id !== undefined;\n\n return (\n <div data-slot=\"file-input\" className={cn('flex w-full flex-col gap-2', className)} {...rest}>\n {/* biome-ignore lint/a11y/noStaticElementInteractions: a drop is only the pointer's way onto the file input laid over this area, which stays the keyboard and screen-reader way. */}\n <div\n data-slot=\"file-input-zone\"\n role=\"presentation\"\n data-dragging={dragging || undefined}\n className={cn(\n 'relative flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-input px-4 py-6 text-center text-sm transition-colors dark:bg-input/30',\n 'hover:bg-foreground/4 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50',\n 'data-dragging:border-ring data-dragging:bg-foreground/6',\n invalid && 'border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40',\n // The prompt stays readable (4.5:1) the way a disabled Button's text does: no opacity on the text, a muted colour instead.\n disabled && 'pointer-events-none cursor-not-allowed border-border',\n )}\n onDragEnter={onDrag}\n onDragOver={onDrag}\n onDragLeave={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n setDragging(false);\n }\n }}\n onDrop={onDrop}\n >\n <IconUpload aria-hidden=\"true\" className=\"size-5 text-muted-foreground\" />\n <p className={disabled ? 'text-muted-foreground' : 'text-foreground'}>\n {multiple ? labels.dropMany : labels.dropOne} <span className=\"font-medium underline underline-offset-3\">{labels.browse}</span>\n </p>\n {hint !== undefined && (\n <p id={hintId} className=\"text-xs text-muted-foreground\">\n {hint}\n </p>\n )}\n <input\n ref={refInput}\n id={id}\n type=\"file\"\n name={name}\n accept={accept}\n multiple={multiple}\n disabled={disabled}\n required={required && files.length === 0}\n aria-label={named ? ariaLabel : multiple ? labels.chooseMany : labels.chooseOne}\n aria-labelledby={ariaLabelledBy}\n aria-describedby={hint === undefined ? undefined : hintId}\n aria-invalid={invalid || undefined}\n className=\"absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed\"\n onChange={onChange}\n />\n </div>\n {files.length > 0 && (\n <ul data-slot=\"file-input-list\" aria-label={labels.list} className=\"flex flex-col gap-1\">\n {files.map((file, index) => (\n <li\n key={`${file.name}-${file.size}-${file.lastModified}`}\n className=\"flex items-center gap-2 rounded-lg border border-input py-1 pr-1 pl-2 text-sm\"\n >\n <IconFile aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate\">{file.name}</span>\n <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">{formatBytes(file.size)}</span>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n disabled={disabled}\n aria-label={labels.remove(file.name)}\n onClick={() => remove(index)}\n >\n <IconX aria-hidden=\"true\" />\n </Button>\n </li>\n ))}\n </ul>\n )}\n {/* One live region: it stays mounted and only its words change, which is what makes a screen reader say them. The refused files are\n the visible part; what was added or removed is for the ear. */}\n <div role=\"status\" data-slot=\"file-input-status\" className=\"text-sm empty:hidden\">\n {rejections.map(({ file, reason }) => (\n <p key={`${file.name}-${reason}`} className=\"text-destructive\">\n {labels.rejection(file.name, reason, limitText(reason, maxSize, maxFiles, multiple))}\n </p>\n ))}\n {announcement !== '' && <span className=\"sr-only\">{announcement}</span>}\n </div>\n </div>\n );\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileInput: which files are taken, what they weigh in words, and the list on a mounted field.
|
|
3
|
+
*
|
|
4
|
+
* PATH TAKEN: the rules (accept, size, count, duplicates) and the labels are plain functions and run in happy-dom, and so does the list
|
|
5
|
+
* of chosen files with its remove buttons. The system's file dialog, a real drop and the focus ring are a browser's, covered by
|
|
6
|
+
* e2e/file-input.spec.ts.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './file-input';
|
|
@@ -21,12 +21,23 @@ export interface IMultiSelectLabels {
|
|
|
21
21
|
export declare const DEFAULT_MULTI_SELECT_LABELS: IMultiSelectLabels;
|
|
22
22
|
/** Above this many options a list draws only the rows on screen. */
|
|
23
23
|
export declare const VIRTUALIZE_FROM = 100;
|
|
24
|
+
/** The number of options up to which a MultiSelect does not show its search field (see `searchFrom`). */
|
|
25
|
+
export declare const SEARCH_FROM = 8;
|
|
24
26
|
/** Anything else a button takes (data-*, aria-*, title) goes to the trigger. */
|
|
25
27
|
export interface IMultiSelectProps extends Omit<ComponentPropsWithoutRef<'button'>, 'placeholder' | 'value' | 'defaultValue' | 'id' | 'disabled' | 'className' | 'children' | 'onChange'> {
|
|
26
28
|
/** The trigger's text with nothing picked, which means no filter ("All dealer groups"). */
|
|
27
29
|
placeholder: string;
|
|
28
30
|
/** The search field's prompt ("Search dealer groups…"). */
|
|
29
31
|
searchPlaceholder: string;
|
|
32
|
+
/**
|
|
33
|
+
* How many options a list must have before it shows its search field: it is there for a list too long to scan, and over a short
|
|
34
|
+
* one it is a box in the way. Above this many (default 8, the rows a popup shows at once) the field shows; at or under, it
|
|
35
|
+
* does not. In async mode it always shows, because the field is how the caller is told what to fetch. `0` always shows it.
|
|
36
|
+
*
|
|
37
|
+
* Hidden, the field is still in the popup, out of sight and not typable, because it is what Base UI moves the highlight
|
|
38
|
+
* from: the arrow keys, Home, End and Enter work as ever, and a screen reader still finds it by its label.
|
|
39
|
+
*/
|
|
40
|
+
searchFrom?: number;
|
|
30
41
|
/** The options to show now: all of a short fixed list, or in async mode the ones for the typed text. */
|
|
31
42
|
options: readonly TMultiSelectOption[];
|
|
32
43
|
value: readonly string[];
|
|
@@ -103,4 +114,4 @@ export declare const fullSelection: (chosen: readonly TMultiSelectOption[], plac
|
|
|
103
114
|
* server: the caller fetches the options for the typed text, and says when they are loading,
|
|
104
115
|
* failed, or have another page.
|
|
105
116
|
*/
|
|
106
|
-
export declare const MultiSelect: ({ placeholder, searchPlaceholder, options, value, onValueChange, id, disabled, className, onSearchChange, searchDelay, selectedOptions, loading, error, onRetry, hasMore, onLoadMore, loadingMore, labels: labelOverrides, summary, ...rest }: IMultiSelectProps) => import('react').JSX.Element;
|
|
117
|
+
export declare const MultiSelect: ({ placeholder, searchPlaceholder, searchFrom, options, value, onValueChange, id, disabled, className, onSearchChange, searchDelay, selectedOptions, loading, error, onRetry, hasMore, onLoadMore, loadingMore, labels: labelOverrides, summary, ...rest }: IMultiSelectProps) => import('react').JSX.Element;
|
|
@@ -2,7 +2,7 @@ import { cn } from "../../../lib/utils.js";
|
|
|
2
2
|
import { useExposedFocusGuards } from "../../../lib/focus-guards.js";
|
|
3
3
|
import { ScrollArea } from "../../ui/scroll-area/scroll-area.js";
|
|
4
4
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip/tooltip.js";
|
|
5
|
-
import { popupItemClass } from "../../../lib/popup-list.js";
|
|
5
|
+
import { POPUP_FRAME_CLASS, POPUP_LIST_HEIGHT_CLASS, POPUP_LIST_WIDTH_CLASS, popupItemClass } from "../../../lib/popup-list.js";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
import { IconCheck, IconChevronDown, IconSearch } from "@tabler/icons-react";
|
|
@@ -21,6 +21,8 @@ var DEFAULT_MULTI_SELECT_LABELS = {
|
|
|
21
21
|
};
|
|
22
22
|
/** Above this many options a list draws only the rows on screen. */
|
|
23
23
|
var VIRTUALIZE_FROM = 100;
|
|
24
|
+
/** The number of options up to which a MultiSelect does not show its search field (see `searchFrom`). */
|
|
25
|
+
var SEARCH_FROM = 8;
|
|
24
26
|
var ITEM_HEIGHT = 32;
|
|
25
27
|
/**
|
|
26
28
|
* The options behind the chosen values, in the order they were chosen. A value with no
|
|
@@ -43,7 +45,7 @@ var summarize = (chosen, placeholder, labels, summary = "selection") => {
|
|
|
43
45
|
};
|
|
44
46
|
/** Every chosen name, for a tooltip on a trigger whose text is cut. With `label` the names are not shown, so it is the text itself. */
|
|
45
47
|
var fullSelection = (chosen, placeholder, labels, summary = "selection") => summary === "selection" && chosen.length > 0 ? chosen.map((option) => option.label).join(", ") : summarize(chosen, placeholder, labels, summary);
|
|
46
|
-
var POPUP = "flex max-h-(--available-height)
|
|
48
|
+
var POPUP = cn("flex max-h-(--available-height) origin-(--transform-origin) flex-col overflow-hidden duration-100", POPUP_FRAME_CLASS, POPUP_LIST_WIDTH_CLASS, "motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95");
|
|
47
49
|
var TRIGGER = "flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm text-foreground transition-colors outline-none focus-visible:border-ring disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive data-popup-open:border-ring dark:bg-input/30";
|
|
48
50
|
var Option = ({ option, index, style, measure, highlighted }) => {
|
|
49
51
|
const [cut, setCut] = useState(false);
|
|
@@ -91,7 +93,7 @@ var Option = ({ option, index, style, measure, highlighted }) => {
|
|
|
91
93
|
})]
|
|
92
94
|
}),
|
|
93
95
|
option.count !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
94
|
-
className: "text-xs text-muted-foreground tabular-nums",
|
|
96
|
+
className: "text-xs text-muted-foreground tabular-nums group-data-highlighted:text-foreground",
|
|
95
97
|
children: option.count
|
|
96
98
|
})
|
|
97
99
|
]
|
|
@@ -164,7 +166,7 @@ var ExposedFocusGuards = () => {
|
|
|
164
166
|
* server: the caller fetches the options for the typed text, and says when they are loading,
|
|
165
167
|
* failed, or have another page.
|
|
166
168
|
*/
|
|
167
|
-
var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChange, id, disabled = false, className, onSearchChange, searchDelay = 250, selectedOptions, loading = false, error = false, onRetry, hasMore = false, onLoadMore, loadingMore = false, labels: labelOverrides, summary = "selection", ...rest }) => {
|
|
169
|
+
var MultiSelect = ({ placeholder, searchPlaceholder, searchFrom = 8, options, value, onValueChange, id, disabled = false, className, onSearchChange, searchDelay = 250, selectedOptions, loading = false, error = false, onRetry, hasMore = false, onLoadMore, loadingMore = false, labels: labelOverrides, summary = "selection", ...rest }) => {
|
|
168
170
|
const labels = useMemo(() => ({
|
|
169
171
|
...DEFAULT_MULTI_SELECT_LABELS,
|
|
170
172
|
...labelOverrides
|
|
@@ -188,6 +190,7 @@ var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChan
|
|
|
188
190
|
const [highlightedValue, setHighlightedValue] = useState(null);
|
|
189
191
|
const refReported = useRef(void 0);
|
|
190
192
|
const refOpen = useRef(false);
|
|
193
|
+
const searchable = isAsync || items.length > searchFrom;
|
|
191
194
|
const errorMessage = typeof error === "string" ? error : labels.error;
|
|
192
195
|
const failed = error !== false;
|
|
193
196
|
useEffect(() => () => window.clearTimeout(refTimer.current), []);
|
|
@@ -265,12 +268,13 @@ var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChan
|
|
|
265
268
|
className: POPUP,
|
|
266
269
|
children: [
|
|
267
270
|
/* @__PURE__ */ jsxs("div", {
|
|
268
|
-
className: "flex shrink-0 items-center gap-2 border-b border-border px-3",
|
|
271
|
+
className: searchable ? "flex shrink-0 items-center gap-2 border-b border-border px-3" : "sr-only",
|
|
269
272
|
children: [/* @__PURE__ */ jsx(IconSearch, {
|
|
270
273
|
"aria-hidden": "true",
|
|
271
274
|
className: "size-4 shrink-0 text-muted-foreground"
|
|
272
275
|
}), /* @__PURE__ */ jsx(Combobox.Input, {
|
|
273
276
|
ref: refSearch,
|
|
277
|
+
readOnly: !searchable,
|
|
274
278
|
placeholder: searchPlaceholder,
|
|
275
279
|
"aria-label": searchPlaceholder.replace(/…$/, ""),
|
|
276
280
|
className: "h-10 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
@@ -297,7 +301,7 @@ var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChan
|
|
|
297
301
|
type: "always",
|
|
298
302
|
fade: true,
|
|
299
303
|
viewportRef: refScroller,
|
|
300
|
-
className:
|
|
304
|
+
className: POPUP_LIST_HEIGHT_CLASS,
|
|
301
305
|
children: /* @__PURE__ */ jsx(Options, {
|
|
302
306
|
scroller: refScroller,
|
|
303
307
|
scrollTo: refScrollTo,
|
|
@@ -333,6 +337,6 @@ var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChan
|
|
|
333
337
|
});
|
|
334
338
|
};
|
|
335
339
|
//#endregion
|
|
336
|
-
export { DEFAULT_MULTI_SELECT_LABELS, MultiSelect, VIRTUALIZE_FROM, fullSelection, resolveChosen, summarize };
|
|
340
|
+
export { DEFAULT_MULTI_SELECT_LABELS, MultiSelect, SEARCH_FROM, VIRTUALIZE_FROM, fullSelection, resolveChosen, summarize };
|
|
337
341
|
|
|
338
342
|
//# sourceMappingURL=multi-select.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"multi-select.js","names":[],"sources":["../../../../../../../ui/src/components/edyn/multi-select/multi-select.tsx"],"sourcesContent":["import { Combobox } from '@base-ui/react/combobox';\nimport { ScrollArea } from '@edyn/ui/components/ui/scroll-area';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@edyn/ui/components/ui/tooltip';\nimport { useExposedFocusGuards } from '@edyn/ui/lib/focus-guards';\nimport { popupItemClass } from '@edyn/ui/lib/popup-list';\nimport { cn } from '@edyn/ui/lib/utils';\nimport { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-react';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport {\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type KeyboardEvent as ReactKeyboardEvent,\n type ReactNode,\n type RefObject,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\n\n/** An option: `leading` goes before its label (an avatar, a status dot), `count` after it (how many it would show). */\nexport type TMultiSelectOption = { value: string; label: string; count?: number; leading?: ReactNode };\n\n/** Every word the component prints, so a product can translate it. */\nexport interface IMultiSelectLabels {\n noMatches: string;\n loading: string;\n /** Shown when `error` is `true`; a string `error` replaces it. */\n error: string;\n retry: string;\n loadMore: string;\n loadingMore: string;\n clear: string;\n selectedCount: (count: number) => string;\n}\n\nexport const DEFAULT_MULTI_SELECT_LABELS: IMultiSelectLabels = {\n noMatches: 'No matches.',\n loading: 'Loading…',\n error: 'Could not load the options.',\n retry: 'Try again',\n loadMore: 'Load more',\n loadingMore: 'Loading…',\n clear: 'Clear',\n selectedCount: (count) => `${count} selected`,\n};\n\n/** Above this many options a list draws only the rows on screen. */\nexport const VIRTUALIZE_FROM = 100;\n\nconst ITEM_HEIGHT = 32;\n\n/** Anything else a button takes (data-*, aria-*, title) goes to the trigger. */\nexport interface IMultiSelectProps\n extends Omit<\n ComponentPropsWithoutRef<'button'>,\n 'placeholder' | 'value' | 'defaultValue' | 'id' | 'disabled' | 'className' | 'children' | 'onChange'\n > {\n /** The trigger's text with nothing picked, which means no filter (\"All dealer groups\"). */\n placeholder: string;\n /** The search field's prompt (\"Search dealer groups…\"). */\n searchPlaceholder: string;\n /** The options to show now: all of a short fixed list, or in async mode the ones for the typed text. */\n options: readonly TMultiSelectOption[];\n value: readonly string[];\n onValueChange: (value: string[]) => void;\n /**\n * What the trigger says once something is chosen. `selection` (default) names the one chosen or counts several;\n * `label` keeps the label and only counts, so a value the page shows elsewhere (as a removable chip, say) is\n * not repeated here: nothing chosen reads `placeholder`, anything chosen reads `labels.selectedCount(n)`.\n */\n summary?: 'selection' | 'label';\n /** For a Label's htmlFor. */\n id?: string;\n 'aria-label'?: string;\n /**\n * Marks the trigger invalid: the kit's destructive border, and a screen reader says so. For a form that does not use\n * Field; inside one, its invalid state already does this.\n */\n 'aria-invalid'?: boolean | 'true' | 'false';\n /**\n * The id of the text that explains it (the error). Added to what a Field names already, never replacing it.\n */\n 'aria-describedby'?: string;\n /** Shown but not changeable (a read-only form). */\n disabled?: boolean;\n /** Layout only (a width); the look is the kit's. */\n className?: string;\n /**\n * Passing this turns on ASYNC MODE, for lists that grow (people, dealer groups) and must\n * be searched by the API rather than loaded whole and filtered in the browser. The\n * component then never filters: it reports the typed text (after `searchDelay`) and shows\n * whatever `options` the caller supplies for it. It is also called with '' each time the\n * list opens, so the first page is fetched on demand, not at page load.\n */\n onSearchChange?: (text: string) => void;\n /** How long typing must pause before `onSearchChange` is called. Async mode only. */\n searchDelay?: number;\n /**\n * The options behind the chosen values, for the ones not in the current `options`. A\n * search that no longer returns a chosen value must not make it vanish from the trigger\n * or the count, so the caller says what it is called. Without a label a value shows as\n * itself.\n */\n selectedOptions?: readonly TMultiSelectOption[];\n /** The options for the typed text are on their way. The old ones stay until they arrive. */\n loading?: boolean;\n /** The request failed: `true` for the default message, or the message itself. */\n error?: boolean | string;\n onRetry?: () => void;\n /** There is another page after `options`. */\n hasMore?: boolean;\n onLoadMore?: () => void;\n /** The next page is on its way. */\n loadingMore?: boolean;\n labels?: Partial<IMultiSelectLabels>;\n}\n\n/**\n * The options behind the chosen values, in the order they were chosen. A value with no\n * known option is kept and shown as itself rather than dropped: a selection must never\n * change just because a list did.\n */\nexport const resolveChosen = (\n value: readonly string[],\n options: readonly TMultiSelectOption[],\n selectedOptions: readonly TMultiSelectOption[] = [],\n): TMultiSelectOption[] => {\n const known = new Map<string, TMultiSelectOption>();\n for (const option of [...selectedOptions, ...options]) {\n known.set(option.value, option);\n }\n return value.map((entry) => known.get(entry) ?? { value: entry, label: entry });\n};\n\n/** What the trigger says: the placeholder, the one picked, or a count. */\nexport const summarize = (\n chosen: readonly TMultiSelectOption[],\n placeholder: string,\n labels: IMultiSelectLabels,\n summary: 'selection' | 'label' = 'selection',\n): string => {\n if (chosen.length === 0) {\n return placeholder;\n }\n if (summary === 'label') {\n return labels.selectedCount(chosen.length);\n }\n return chosen.length === 1 ? (chosen[0]?.label ?? '') : labels.selectedCount(chosen.length);\n};\n\n/** Every chosen name, for a tooltip on a trigger whose text is cut. With `label` the names are not shown, so it is the text itself. */\nexport const fullSelection = (\n chosen: readonly TMultiSelectOption[],\n placeholder: string,\n labels: IMultiSelectLabels,\n summary: 'selection' | 'label' = 'selection',\n): string =>\n summary === 'selection' && chosen.length > 0 ? chosen.map((option) => option.label).join(', ') : summarize(chosen, placeholder, labels, summary);\n\nconst POPUP =\n 'flex max-h-(--available-height) w-(--anchor-width) min-w-64 max-w-[max(24rem,var(--anchor-width))] origin-(--transform-origin) flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md outline-none duration-100 motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95';\n\nconst TRIGGER =\n 'flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm text-foreground transition-colors outline-none focus-visible:border-ring disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive data-popup-open:border-ring dark:bg-input/30';\n\nconst Option = ({\n option,\n index,\n style,\n measure,\n highlighted,\n}: {\n option: TMultiSelectOption;\n index: number;\n style?: CSSProperties;\n measure?: (node: Element | null) => void;\n highlighted: boolean;\n}) => {\n const [cut, setCut] = useState(false);\n // A callback ref, so measuring starts when the label mounts and stops when it goes; the key on the label below mounts a fresh one\n // when the text changes, which a resize observer alone would miss at an unchanged width.\n const measureLabel = useCallback((element: HTMLSpanElement | null) => {\n if (!element) {\n return;\n }\n const measureCut = (): void => setCut(element.scrollWidth > element.clientWidth);\n measureCut();\n if (typeof ResizeObserver === 'undefined') {\n return;\n }\n const observer = new ResizeObserver(measureCut);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n return (\n <Combobox.Item\n value={option}\n index={index}\n ref={measure}\n data-index={index}\n style={style}\n data-slot=\"multi-select-item\"\n className={cn(popupItemClass('base-ui'), 'group')}\n >\n {/* A checkbox, filled when chosen: a set of toggles reads as one. */}\n <span\n data-slot=\"multi-select-item-indicator\"\n aria-hidden=\"true\"\n className=\"flex size-4 shrink-0 items-center justify-center rounded-sm border border-input text-primary-foreground transition-colors group-data-[selected]:border-primary group-data-[selected]:bg-primary\"\n >\n <IconCheck className=\"size-3 opacity-0 group-data-[selected]:opacity-100\" stroke={3} />\n </span>\n {option.leading}\n {/* The option is the highlighted one under the pointer and under the keyboard alike, so one controlled tooltip serves both: the full label while the text is cut. */}\n <Tooltip open={highlighted && cut}>\n <TooltipTrigger\n key={option.label}\n data-slot=\"multi-select-item-label\"\n data-overflowing={cut ? '' : undefined}\n render={<span ref={measureLabel} className=\"min-w-0 flex-1 truncate\" />}\n >\n {option.label}\n </TooltipTrigger>\n <TooltipContent side=\"right\">{option.label}</TooltipContent>\n </Tooltip>\n {option.count !== undefined && <span className=\"text-xs text-muted-foreground tabular-nums\">{option.count}</span>}\n </Combobox.Item>\n );\n};\n\n// The options on offer, every one drawn, or past VIRTUALIZE_FROM only those on screen. The\n// list is the listbox itself (the options its direct children), sized to the whole list\n// when virtualized; the ScrollArea wraps it.\nconst Options = ({\n scroller,\n scrollTo,\n busy,\n highlightedValue,\n}: {\n scroller: RefObject<HTMLDivElement | null>;\n scrollTo: RefObject<(index: number) => void>;\n busy: boolean;\n highlightedValue: string | null;\n}) => {\n const items = Combobox.useFilteredItems<TMultiSelectOption>();\n const virtual = items.length > VIRTUALIZE_FROM;\n // The scroller is an ancestor, so its ref is set only after this list's own effects:\n // one more render once mounted gives the virtualizer its element.\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n const virtualizer = useVirtualizer({\n count: items.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => ITEM_HEIGHT,\n overscan: 12,\n enabled: virtual && mounted,\n });\n useEffect(() => {\n scrollTo.current = virtual ? (index) => virtualizer.scrollToIndex(index, { align: 'auto' }) : () => undefined;\n }, [scrollTo, virtual, virtualizer]);\n if (!virtual) {\n return (\n <Combobox.List aria-busy={busy || undefined} className=\"p-1 outline-none\">\n {items.map((option, index) => (\n <Option key={option.value} option={option} index={index} highlighted={highlightedValue === option.value} />\n ))}\n </Combobox.List>\n );\n }\n return (\n <Combobox.List\n data-virtualized=\"\"\n aria-busy={busy || undefined}\n className=\"relative m-1 outline-none\"\n style={{ height: virtualizer.getTotalSize() }}\n >\n {virtualizer.getVirtualItems().map((row) => {\n const option = items[row.index];\n return option === undefined ? null : (\n <Option\n key={option.value}\n option={option}\n index={row.index}\n highlighted={highlightedValue === option.value}\n measure={virtualizer.measureElement}\n style={{ position: 'absolute', top: 0, left: 0, right: 0, transform: `translateY(${row.start}px)` }}\n />\n );\n })}\n </Combobox.List>\n );\n};\n\n/** Rendered inside the portal, so it exists only while the list is open: the focus guards around the list are not aria-hidden. */\nconst ExposedFocusGuards = () => {\n useExposedFocusGuards();\n return null;\n};\n\n/**\n * A choice of several from a list: a trigger shaped like a Select that says what is picked\n * (the placeholder, the one picked, or a count), and a popup with a search field and checkbox\n * options, with the shared popup list style, the ScrollArea's bar and fades, and virtualization\n * past VIRTUALIZE_FROM options. Nothing chosen means no filter.\n *\n * For a short fixed list pass `options` and nothing else: the component filters them as the\n * user types. For a list that grows, pass `onSearchChange` and it becomes a window onto a\n * server: the caller fetches the options for the typed text, and says when they are loading,\n * failed, or have another page.\n */\nexport const MultiSelect = ({\n placeholder,\n searchPlaceholder,\n options,\n value,\n onValueChange,\n id,\n disabled = false,\n className,\n onSearchChange,\n searchDelay = 250,\n selectedOptions,\n loading = false,\n error = false,\n onRetry,\n hasMore = false,\n onLoadMore,\n loadingMore = false,\n labels: labelOverrides,\n summary = 'selection',\n ...rest\n}: IMultiSelectProps) => {\n const labels = useMemo(() => ({ ...DEFAULT_MULTI_SELECT_LABELS, ...labelOverrides }), [labelOverrides]);\n const isAsync = onSearchChange !== undefined;\n const chosen = useMemo(() => resolveChosen(value, options, selectedOptions), [value, options, selectedOptions]);\n // Chosen options the current page does not contain stay on offer at the top, so they can\n // still be unchecked after a search has moved on.\n const items = useMemo(() => {\n const offered = new Set(options.map((option) => option.value));\n return [...chosen.filter((option) => !offered.has(option.value)), ...options];\n }, [chosen, options]);\n const refScroller = useRef<HTMLDivElement | null>(null);\n const refScrollTo = useRef<(index: number) => void>(() => undefined);\n const refTimer = useRef<number | undefined>(undefined);\n const refTriggerText = useRef<HTMLSpanElement | null>(null);\n const refSearch = useRef<HTMLInputElement | null>(null);\n // The popup and the trigger's tooltip are two hover surfaces on one element, so the tooltip stands down while the popup is open.\n const [popupOpen, setPopupOpen] = useState(false);\n // The option under the pointer or the keyboard, whichever moved last, for the tooltip that shows a cut label in full.\n const [highlightedValue, setHighlightedValue] = useState<string | null>(null);\n // The text last reported through onSearchChange, and whether the popup is open. Base UI\n // reports the input as empty when the popup opens and again when it closes; reporting\n // those would fetch the first page twice on every open, and again while closed.\n const refReported = useRef<string | undefined>(undefined);\n const refOpen = useRef(false);\n const errorMessage = typeof error === 'string' ? error : labels.error;\n const failed = error !== false;\n\n useEffect(() => () => window.clearTimeout(refTimer.current), []);\n\n // Base UI gives the search field a key handler that closes the popup on Escape and keeps the key from the page, but not\n // the trigger: with focus back on the trigger and the popup still open (a click that landed while a Sheet slid in,\n // `focus()`, Shift+Tab) Escape reached the document, where an enclosing Sheet or Dialog closed along with the popup.\n // So the trigger hands Escape to the search field, the path that already works, and the key goes no further.\n const closeFromTrigger = (event: ReactKeyboardEvent<HTMLElement>) => {\n if (event.key !== 'Escape' || !popupOpen || refSearch.current === null) {\n return;\n }\n event.stopPropagation();\n event.preventDefault();\n refSearch.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));\n };\n\n return (\n <Combobox.Root\n multiple\n disabled={disabled}\n items={items}\n filter={isAsync ? null : undefined}\n value={chosen}\n onValueChange={(next: TMultiSelectOption[]) => onValueChange(next.map((option) => option.value))}\n onInputValueChange={(text) => {\n if (onSearchChange === undefined || !refOpen.current) {\n return;\n }\n window.clearTimeout(refTimer.current);\n if (text === refReported.current) {\n return;\n }\n refTimer.current = window.setTimeout(() => {\n refReported.current = text;\n onSearchChange(text);\n }, searchDelay);\n }}\n onOpenChange={(open) => {\n window.clearTimeout(refTimer.current);\n setPopupOpen(open);\n if (!open) {\n setHighlightedValue(null);\n }\n refOpen.current = open;\n refReported.current = open ? '' : undefined;\n if (open && onSearchChange !== undefined) {\n onSearchChange('');\n }\n }}\n itemToStringLabel={(option: TMultiSelectOption) => option.label}\n isItemEqualToValue={(a: TMultiSelectOption, b: TMultiSelectOption) => a.value === b.value}\n virtualized={items.length > VIRTUALIZE_FROM}\n onItemHighlighted={(item, details) => {\n setHighlightedValue(item?.value ?? null);\n if (details.reason === 'keyboard' && details.index >= 0) {\n refScrollTo.current(details.index);\n }\n }}\n >\n {/* A tooltip only when the text is really cut, which is known when it is about to open. */}\n <Tooltip\n disabled={popupOpen}\n onOpenChange={(open, details) => {\n const element = refTriggerText.current;\n if (open && !(element && element.scrollWidth > element.clientWidth)) {\n details.cancel();\n }\n }}\n >\n <TooltipTrigger\n data-slot=\"multi-select-trigger\"\n render={\n <Combobox.Trigger\n {...rest}\n id={id}\n data-set={chosen.length > 0 ? '' : undefined}\n onKeyDown={closeFromTrigger}\n className={cn(TRIGGER, className)}\n />\n }\n >\n <span ref={refTriggerText} className={cn('min-w-0 flex-1 truncate text-left', chosen.length === 0 && 'text-muted-foreground')}>\n {summarize(chosen, placeholder, labels, summary)}\n </span>\n <IconChevronDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </TooltipTrigger>\n <TooltipContent>{fullSelection(chosen, placeholder, labels, summary)}</TooltipContent>\n </Tooltip>\n <Combobox.Portal>\n <ExposedFocusGuards />\n <Combobox.Positioner sideOffset={4} align=\"start\" className=\"z-50\">\n <Combobox.Popup data-slot=\"multi-select-content\" className={POPUP}>\n <div className=\"flex shrink-0 items-center gap-2 border-b border-border px-3\">\n <IconSearch aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <Combobox.Input\n ref={refSearch}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder.replace(/…$/, '')}\n className=\"h-10 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n />\n </div>\n {/* The live region stays mounted and only its words change, which is what makes a\n screen reader announce them. */}\n <Combobox.Status data-slot=\"multi-select-status\" className=\"px-3 py-2 text-sm text-muted-foreground empty:p-0\">\n {failed ? (\n <span className=\"flex items-center justify-between gap-2 text-destructive\">\n {errorMessage}\n {onRetry !== undefined && (\n <button type=\"button\" onClick={onRetry} className=\"rounded-md px-2 py-1 font-medium text-foreground hover:bg-foreground/6\">\n {labels.retry}\n </button>\n )}\n </span>\n ) : loading ? (\n labels.loading\n ) : null}\n </Combobox.Status>\n <Combobox.Empty className=\"px-3 py-6 text-center text-sm text-muted-foreground empty:p-0\">\n {loading || failed ? null : labels.noMatches}\n </Combobox.Empty>\n <ScrollArea type=\"always\" fade viewportRef={refScroller} className=\"max-h-72 min-h-0\">\n <Options scroller={refScroller} scrollTo={refScrollTo} busy={loading} highlightedValue={highlightedValue} />\n </ScrollArea>\n {hasMore && onLoadMore !== undefined && (\n <div className=\"shrink-0 border-t border-border p-1\">\n <button\n type=\"button\"\n disabled={loadingMore}\n onClick={onLoadMore}\n className=\"h-8 w-full rounded-md px-2 text-sm font-medium text-foreground hover:bg-foreground/6 disabled:pointer-events-none disabled:opacity-50\"\n >\n {loadingMore ? labels.loadingMore : labels.loadMore}\n </button>\n </div>\n )}\n {chosen.length > 0 && (\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-t border-border py-1 pr-1 pl-3 text-xs text-muted-foreground\">\n <span className=\"tabular-nums\">{labels.selectedCount(chosen.length)}</span>\n <button\n type=\"button\"\n onClick={() => onValueChange([])}\n className=\"rounded-md px-2 py-1 font-medium text-foreground hover:bg-foreground/6\"\n >\n {labels.clear}\n </button>\n </div>\n )}\n </Combobox.Popup>\n </Combobox.Positioner>\n </Combobox.Portal>\n </Combobox.Root>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"multi-select.js","names":[],"sources":["../../../../../../../ui/src/components/edyn/multi-select/multi-select.tsx"],"sourcesContent":["import { Combobox } from '@base-ui/react/combobox';\nimport { ScrollArea } from '@edyn/ui/components/ui/scroll-area';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@edyn/ui/components/ui/tooltip';\nimport { useExposedFocusGuards } from '@edyn/ui/lib/focus-guards';\nimport { POPUP_FRAME_CLASS, POPUP_LIST_HEIGHT_CLASS, POPUP_LIST_WIDTH_CLASS, popupItemClass } from '@edyn/ui/lib/popup-list';\nimport { cn } from '@edyn/ui/lib/utils';\nimport { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-react';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport {\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type KeyboardEvent as ReactKeyboardEvent,\n type ReactNode,\n type RefObject,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\n\n/** An option: `leading` goes before its label (an avatar, a status dot), `count` after it (how many it would show). */\nexport type TMultiSelectOption = { value: string; label: string; count?: number; leading?: ReactNode };\n\n/** Every word the component prints, so a product can translate it. */\nexport interface IMultiSelectLabels {\n noMatches: string;\n loading: string;\n /** Shown when `error` is `true`; a string `error` replaces it. */\n error: string;\n retry: string;\n loadMore: string;\n loadingMore: string;\n clear: string;\n selectedCount: (count: number) => string;\n}\n\nexport const DEFAULT_MULTI_SELECT_LABELS: IMultiSelectLabels = {\n noMatches: 'No matches.',\n loading: 'Loading…',\n error: 'Could not load the options.',\n retry: 'Try again',\n loadMore: 'Load more',\n loadingMore: 'Loading…',\n clear: 'Clear',\n selectedCount: (count) => `${count} selected`,\n};\n\n/** Above this many options a list draws only the rows on screen. */\nexport const VIRTUALIZE_FROM = 100;\n\n/** The number of options up to which a MultiSelect does not show its search field (see `searchFrom`). */\nexport const SEARCH_FROM = 8;\n\nconst ITEM_HEIGHT = 32;\n\n/** Anything else a button takes (data-*, aria-*, title) goes to the trigger. */\nexport interface IMultiSelectProps\n extends Omit<\n ComponentPropsWithoutRef<'button'>,\n 'placeholder' | 'value' | 'defaultValue' | 'id' | 'disabled' | 'className' | 'children' | 'onChange'\n > {\n /** The trigger's text with nothing picked, which means no filter (\"All dealer groups\"). */\n placeholder: string;\n /** The search field's prompt (\"Search dealer groups…\"). */\n searchPlaceholder: string;\n /**\n * How many options a list must have before it shows its search field: it is there for a list too long to scan, and over a short\n * one it is a box in the way. Above this many (default 8, the rows a popup shows at once) the field shows; at or under, it\n * does not. In async mode it always shows, because the field is how the caller is told what to fetch. `0` always shows it.\n *\n * Hidden, the field is still in the popup, out of sight and not typable, because it is what Base UI moves the highlight\n * from: the arrow keys, Home, End and Enter work as ever, and a screen reader still finds it by its label.\n */\n searchFrom?: number;\n /** The options to show now: all of a short fixed list, or in async mode the ones for the typed text. */\n options: readonly TMultiSelectOption[];\n value: readonly string[];\n onValueChange: (value: string[]) => void;\n /**\n * What the trigger says once something is chosen. `selection` (default) names the one chosen or counts several;\n * `label` keeps the label and only counts, so a value the page shows elsewhere (as a removable chip, say) is\n * not repeated here: nothing chosen reads `placeholder`, anything chosen reads `labels.selectedCount(n)`.\n */\n summary?: 'selection' | 'label';\n /** For a Label's htmlFor. */\n id?: string;\n 'aria-label'?: string;\n /**\n * Marks the trigger invalid: the kit's destructive border, and a screen reader says so. For a form that does not use\n * Field; inside one, its invalid state already does this.\n */\n 'aria-invalid'?: boolean | 'true' | 'false';\n /**\n * The id of the text that explains it (the error). Added to what a Field names already, never replacing it.\n */\n 'aria-describedby'?: string;\n /** Shown but not changeable (a read-only form). */\n disabled?: boolean;\n /** Layout only (a width); the look is the kit's. */\n className?: string;\n /**\n * Passing this turns on ASYNC MODE, for lists that grow (people, dealer groups) and must\n * be searched by the API rather than loaded whole and filtered in the browser. The\n * component then never filters: it reports the typed text (after `searchDelay`) and shows\n * whatever `options` the caller supplies for it. It is also called with '' each time the\n * list opens, so the first page is fetched on demand, not at page load.\n */\n onSearchChange?: (text: string) => void;\n /** How long typing must pause before `onSearchChange` is called. Async mode only. */\n searchDelay?: number;\n /**\n * The options behind the chosen values, for the ones not in the current `options`. A\n * search that no longer returns a chosen value must not make it vanish from the trigger\n * or the count, so the caller says what it is called. Without a label a value shows as\n * itself.\n */\n selectedOptions?: readonly TMultiSelectOption[];\n /** The options for the typed text are on their way. The old ones stay until they arrive. */\n loading?: boolean;\n /** The request failed: `true` for the default message, or the message itself. */\n error?: boolean | string;\n onRetry?: () => void;\n /** There is another page after `options`. */\n hasMore?: boolean;\n onLoadMore?: () => void;\n /** The next page is on its way. */\n loadingMore?: boolean;\n labels?: Partial<IMultiSelectLabels>;\n}\n\n/**\n * The options behind the chosen values, in the order they were chosen. A value with no\n * known option is kept and shown as itself rather than dropped: a selection must never\n * change just because a list did.\n */\nexport const resolveChosen = (\n value: readonly string[],\n options: readonly TMultiSelectOption[],\n selectedOptions: readonly TMultiSelectOption[] = [],\n): TMultiSelectOption[] => {\n const known = new Map<string, TMultiSelectOption>();\n for (const option of [...selectedOptions, ...options]) {\n known.set(option.value, option);\n }\n return value.map((entry) => known.get(entry) ?? { value: entry, label: entry });\n};\n\n/** What the trigger says: the placeholder, the one picked, or a count. */\nexport const summarize = (\n chosen: readonly TMultiSelectOption[],\n placeholder: string,\n labels: IMultiSelectLabels,\n summary: 'selection' | 'label' = 'selection',\n): string => {\n if (chosen.length === 0) {\n return placeholder;\n }\n if (summary === 'label') {\n return labels.selectedCount(chosen.length);\n }\n return chosen.length === 1 ? (chosen[0]?.label ?? '') : labels.selectedCount(chosen.length);\n};\n\n/** Every chosen name, for a tooltip on a trigger whose text is cut. With `label` the names are not shown, so it is the text itself. */\nexport const fullSelection = (\n chosen: readonly TMultiSelectOption[],\n placeholder: string,\n labels: IMultiSelectLabels,\n summary: 'selection' | 'label' = 'selection',\n): string =>\n summary === 'selection' && chosen.length > 0 ? chosen.map((option) => option.label).join(', ') : summarize(chosen, placeholder, labels, summary);\n\nconst POPUP = cn(\n 'flex max-h-(--available-height) origin-(--transform-origin) flex-col overflow-hidden duration-100',\n POPUP_FRAME_CLASS,\n POPUP_LIST_WIDTH_CLASS,\n 'motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n);\n\nconst TRIGGER =\n 'flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm text-foreground transition-colors outline-none focus-visible:border-ring disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive data-popup-open:border-ring dark:bg-input/30';\n\nconst Option = ({\n option,\n index,\n style,\n measure,\n highlighted,\n}: {\n option: TMultiSelectOption;\n index: number;\n style?: CSSProperties;\n measure?: (node: Element | null) => void;\n highlighted: boolean;\n}) => {\n const [cut, setCut] = useState(false);\n // A callback ref, so measuring starts when the label mounts and stops when it goes; the key on the label below mounts a fresh one\n // when the text changes, which a resize observer alone would miss at an unchanged width.\n const measureLabel = useCallback((element: HTMLSpanElement | null) => {\n if (!element) {\n return;\n }\n const measureCut = (): void => setCut(element.scrollWidth > element.clientWidth);\n measureCut();\n if (typeof ResizeObserver === 'undefined') {\n return;\n }\n const observer = new ResizeObserver(measureCut);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n return (\n <Combobox.Item\n value={option}\n index={index}\n ref={measure}\n data-index={index}\n style={style}\n data-slot=\"multi-select-item\"\n className={cn(popupItemClass('base-ui'), 'group')}\n >\n {/* A checkbox, filled when chosen: a set of toggles reads as one. */}\n <span\n data-slot=\"multi-select-item-indicator\"\n aria-hidden=\"true\"\n className=\"flex size-4 shrink-0 items-center justify-center rounded-sm border border-input text-primary-foreground transition-colors group-data-[selected]:border-primary group-data-[selected]:bg-primary\"\n >\n <IconCheck className=\"size-3 opacity-0 group-data-[selected]:opacity-100\" stroke={3} />\n </span>\n {option.leading}\n {/* The option is the highlighted one under the pointer and under the keyboard alike, so one controlled tooltip serves both: the full label while the text is cut. */}\n <Tooltip open={highlighted && cut}>\n <TooltipTrigger\n key={option.label}\n data-slot=\"multi-select-item-label\"\n data-overflowing={cut ? '' : undefined}\n render={<span ref={measureLabel} className=\"min-w-0 flex-1 truncate\" />}\n >\n {option.label}\n </TooltipTrigger>\n <TooltipContent side=\"right\">{option.label}</TooltipContent>\n </Tooltip>\n {/* Muted grey is 4.2:1 on the highlighted row (6% of the foreground), under the 4.5:1 small text needs, so the count turns to the foreground with its row, as a CommandShortcut does. */}\n {option.count !== undefined && (\n <span className=\"text-xs text-muted-foreground tabular-nums group-data-highlighted:text-foreground\">{option.count}</span>\n )}\n </Combobox.Item>\n );\n};\n\n// The options on offer, every one drawn, or past VIRTUALIZE_FROM only those on screen. The\n// list is the listbox itself (the options its direct children), sized to the whole list\n// when virtualized; the ScrollArea wraps it.\nconst Options = ({\n scroller,\n scrollTo,\n busy,\n highlightedValue,\n}: {\n scroller: RefObject<HTMLDivElement | null>;\n scrollTo: RefObject<(index: number) => void>;\n busy: boolean;\n highlightedValue: string | null;\n}) => {\n const items = Combobox.useFilteredItems<TMultiSelectOption>();\n const virtual = items.length > VIRTUALIZE_FROM;\n // The scroller is an ancestor, so its ref is set only after this list's own effects:\n // one more render once mounted gives the virtualizer its element.\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n const virtualizer = useVirtualizer({\n count: items.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => ITEM_HEIGHT,\n overscan: 12,\n enabled: virtual && mounted,\n });\n useEffect(() => {\n scrollTo.current = virtual ? (index) => virtualizer.scrollToIndex(index, { align: 'auto' }) : () => undefined;\n }, [scrollTo, virtual, virtualizer]);\n if (!virtual) {\n return (\n <Combobox.List aria-busy={busy || undefined} className=\"p-1 outline-none\">\n {items.map((option, index) => (\n <Option key={option.value} option={option} index={index} highlighted={highlightedValue === option.value} />\n ))}\n </Combobox.List>\n );\n }\n return (\n <Combobox.List\n data-virtualized=\"\"\n aria-busy={busy || undefined}\n className=\"relative m-1 outline-none\"\n style={{ height: virtualizer.getTotalSize() }}\n >\n {virtualizer.getVirtualItems().map((row) => {\n const option = items[row.index];\n return option === undefined ? null : (\n <Option\n key={option.value}\n option={option}\n index={row.index}\n highlighted={highlightedValue === option.value}\n measure={virtualizer.measureElement}\n style={{ position: 'absolute', top: 0, left: 0, right: 0, transform: `translateY(${row.start}px)` }}\n />\n );\n })}\n </Combobox.List>\n );\n};\n\n/** Rendered inside the portal, so it exists only while the list is open: the focus guards around the list are not aria-hidden. */\nconst ExposedFocusGuards = () => {\n useExposedFocusGuards();\n return null;\n};\n\n/**\n * A choice of several from a list: a trigger shaped like a Select that says what is picked\n * (the placeholder, the one picked, or a count), and a popup with a search field and checkbox\n * options, with the shared popup list style, the ScrollArea's bar and fades, and virtualization\n * past VIRTUALIZE_FROM options. Nothing chosen means no filter.\n *\n * For a short fixed list pass `options` and nothing else: the component filters them as the\n * user types. For a list that grows, pass `onSearchChange` and it becomes a window onto a\n * server: the caller fetches the options for the typed text, and says when they are loading,\n * failed, or have another page.\n */\nexport const MultiSelect = ({\n placeholder,\n searchPlaceholder,\n searchFrom = SEARCH_FROM,\n options,\n value,\n onValueChange,\n id,\n disabled = false,\n className,\n onSearchChange,\n searchDelay = 250,\n selectedOptions,\n loading = false,\n error = false,\n onRetry,\n hasMore = false,\n onLoadMore,\n loadingMore = false,\n labels: labelOverrides,\n summary = 'selection',\n ...rest\n}: IMultiSelectProps) => {\n const labels = useMemo(() => ({ ...DEFAULT_MULTI_SELECT_LABELS, ...labelOverrides }), [labelOverrides]);\n const isAsync = onSearchChange !== undefined;\n const chosen = useMemo(() => resolveChosen(value, options, selectedOptions), [value, options, selectedOptions]);\n // Chosen options the current page does not contain stay on offer at the top, so they can\n // still be unchecked after a search has moved on.\n const items = useMemo(() => {\n const offered = new Set(options.map((option) => option.value));\n return [...chosen.filter((option) => !offered.has(option.value)), ...options];\n }, [chosen, options]);\n const refScroller = useRef<HTMLDivElement | null>(null);\n const refScrollTo = useRef<(index: number) => void>(() => undefined);\n const refTimer = useRef<number | undefined>(undefined);\n const refTriggerText = useRef<HTMLSpanElement | null>(null);\n const refSearch = useRef<HTMLInputElement | null>(null);\n // The popup and the trigger's tooltip are two hover surfaces on one element, so the tooltip stands down while the popup is open.\n const [popupOpen, setPopupOpen] = useState(false);\n // The option under the pointer or the keyboard, whichever moved last, for the tooltip that shows a cut label in full.\n const [highlightedValue, setHighlightedValue] = useState<string | null>(null);\n // The text last reported through onSearchChange, and whether the popup is open. Base UI\n // reports the input as empty when the popup opens and again when it closes; reporting\n // those would fetch the first page twice on every open, and again while closed.\n const refReported = useRef<string | undefined>(undefined);\n const refOpen = useRef(false);\n // Over a short list the search is a box in the way; the list still counts what was offered, so a chosen option the page lacks\n // does not hide the field.\n const searchable = isAsync || items.length > searchFrom;\n const errorMessage = typeof error === 'string' ? error : labels.error;\n const failed = error !== false;\n\n useEffect(() => () => window.clearTimeout(refTimer.current), []);\n\n // Base UI gives the search field a key handler that closes the popup on Escape and keeps the key from the page, but not\n // the trigger: with focus back on the trigger and the popup still open (a click that landed while a Sheet slid in,\n // `focus()`, Shift+Tab) Escape reached the document, where an enclosing Sheet or Dialog closed along with the popup.\n // So the trigger hands Escape to the search field, the path that already works, and the key goes no further.\n const closeFromTrigger = (event: ReactKeyboardEvent<HTMLElement>) => {\n if (event.key !== 'Escape' || !popupOpen || refSearch.current === null) {\n return;\n }\n event.stopPropagation();\n event.preventDefault();\n refSearch.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));\n };\n\n return (\n <Combobox.Root\n multiple\n disabled={disabled}\n items={items}\n filter={isAsync ? null : undefined}\n value={chosen}\n onValueChange={(next: TMultiSelectOption[]) => onValueChange(next.map((option) => option.value))}\n onInputValueChange={(text) => {\n if (onSearchChange === undefined || !refOpen.current) {\n return;\n }\n window.clearTimeout(refTimer.current);\n if (text === refReported.current) {\n return;\n }\n refTimer.current = window.setTimeout(() => {\n refReported.current = text;\n onSearchChange(text);\n }, searchDelay);\n }}\n onOpenChange={(open) => {\n window.clearTimeout(refTimer.current);\n setPopupOpen(open);\n if (!open) {\n setHighlightedValue(null);\n }\n refOpen.current = open;\n refReported.current = open ? '' : undefined;\n if (open && onSearchChange !== undefined) {\n onSearchChange('');\n }\n }}\n itemToStringLabel={(option: TMultiSelectOption) => option.label}\n isItemEqualToValue={(a: TMultiSelectOption, b: TMultiSelectOption) => a.value === b.value}\n virtualized={items.length > VIRTUALIZE_FROM}\n onItemHighlighted={(item, details) => {\n setHighlightedValue(item?.value ?? null);\n if (details.reason === 'keyboard' && details.index >= 0) {\n refScrollTo.current(details.index);\n }\n }}\n >\n {/* A tooltip only when the text is really cut, which is known when it is about to open. */}\n <Tooltip\n disabled={popupOpen}\n onOpenChange={(open, details) => {\n const element = refTriggerText.current;\n if (open && !(element && element.scrollWidth > element.clientWidth)) {\n details.cancel();\n }\n }}\n >\n <TooltipTrigger\n data-slot=\"multi-select-trigger\"\n render={\n <Combobox.Trigger\n {...rest}\n id={id}\n data-set={chosen.length > 0 ? '' : undefined}\n onKeyDown={closeFromTrigger}\n className={cn(TRIGGER, className)}\n />\n }\n >\n <span ref={refTriggerText} className={cn('min-w-0 flex-1 truncate text-left', chosen.length === 0 && 'text-muted-foreground')}>\n {summarize(chosen, placeholder, labels, summary)}\n </span>\n <IconChevronDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </TooltipTrigger>\n <TooltipContent>{fullSelection(chosen, placeholder, labels, summary)}</TooltipContent>\n </Tooltip>\n <Combobox.Portal>\n <ExposedFocusGuards />\n <Combobox.Positioner sideOffset={4} align=\"start\" className=\"z-50\">\n <Combobox.Popup data-slot=\"multi-select-content\" className={POPUP}>\n <div className={searchable ? 'flex shrink-0 items-center gap-2 border-b border-border px-3' : 'sr-only'}>\n <IconSearch aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <Combobox.Input\n ref={refSearch}\n readOnly={!searchable}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder.replace(/…$/, '')}\n className=\"h-10 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n />\n </div>\n {/* The live region stays mounted and only its words change, which is what makes a\n screen reader announce them. */}\n <Combobox.Status data-slot=\"multi-select-status\" className=\"px-3 py-2 text-sm text-muted-foreground empty:p-0\">\n {failed ? (\n <span className=\"flex items-center justify-between gap-2 text-destructive\">\n {errorMessage}\n {onRetry !== undefined && (\n <button type=\"button\" onClick={onRetry} className=\"rounded-md px-2 py-1 font-medium text-foreground hover:bg-foreground/6\">\n {labels.retry}\n </button>\n )}\n </span>\n ) : loading ? (\n labels.loading\n ) : null}\n </Combobox.Status>\n <Combobox.Empty className=\"px-3 py-6 text-center text-sm text-muted-foreground empty:p-0\">\n {loading || failed ? null : labels.noMatches}\n </Combobox.Empty>\n <ScrollArea type=\"always\" fade viewportRef={refScroller} className={POPUP_LIST_HEIGHT_CLASS}>\n <Options scroller={refScroller} scrollTo={refScrollTo} busy={loading} highlightedValue={highlightedValue} />\n </ScrollArea>\n {hasMore && onLoadMore !== undefined && (\n <div className=\"shrink-0 border-t border-border p-1\">\n <button\n type=\"button\"\n disabled={loadingMore}\n onClick={onLoadMore}\n className=\"h-8 w-full rounded-md px-2 text-sm font-medium text-foreground hover:bg-foreground/6 disabled:pointer-events-none disabled:opacity-50\"\n >\n {loadingMore ? labels.loadingMore : labels.loadMore}\n </button>\n </div>\n )}\n {chosen.length > 0 && (\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-t border-border py-1 pr-1 pl-3 text-xs text-muted-foreground\">\n <span className=\"tabular-nums\">{labels.selectedCount(chosen.length)}</span>\n <button\n type=\"button\"\n onClick={() => onValueChange([])}\n className=\"rounded-md px-2 py-1 font-medium text-foreground hover:bg-foreground/6\"\n >\n {labels.clear}\n </button>\n </div>\n )}\n </Combobox.Popup>\n </Combobox.Positioner>\n </Combobox.Portal>\n </Combobox.Root>\n );\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './time-field';
|