@exsto/ds 0.0.1-rc.17 → 0.0.1-rc.19
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 +55 -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 +5 -3
- package/dist/source-hash.txt +1 -1
- package/dist/styles.css +186 -82
- package/dist/theme.css +6 -1
- 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 +16 -12
- package/dist/ui/src/components/edyn/multi-select/multi-select.js.map +1 -1
- package/dist/ui/src/components/edyn/rich-text-editor/mention-list.js +1 -1
- package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.js +1 -1
- package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.js.map +1 -1
- package/dist/ui/src/components/edyn/rich-text-editor/toolbar.js +1 -1
- package/dist/ui/src/components/edyn/rich-text-editor/toolbar.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 +337 -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/breadcrumb/breadcrumb.js +1 -1
- package/dist/ui/src/components/ui/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/ui/src/components/ui/button/button.js +2 -2
- package/dist/ui/src/components/ui/button/button.js.map +1 -1
- package/dist/ui/src/components/ui/card/card.js +1 -1
- package/dist/ui/src/components/ui/card/card.js.map +1 -1
- package/dist/ui/src/components/ui/checkbox/checkbox.js +1 -1
- package/dist/ui/src/components/ui/checkbox/checkbox.js.map +1 -1
- package/dist/ui/src/components/ui/combobox/combobox.d.ts +15 -1
- package/dist/ui/src/components/ui/combobox/combobox.js +46 -20
- package/dist/ui/src/components/ui/combobox/combobox.js.map +1 -1
- package/dist/ui/src/components/ui/command/command.js +2 -2
- package/dist/ui/src/components/ui/command/command.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 +20 -11
- 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 +7 -10
- package/dist/ui/src/components/ui/dropdown-menu/dropdown-menu.js.map +1 -1
- package/dist/ui/src/components/ui/empty/empty.js +1 -1
- package/dist/ui/src/components/ui/empty/empty.js.map +1 -1
- package/dist/ui/src/components/ui/field/field.js +2 -2
- package/dist/ui/src/components/ui/field/field.js.map +1 -1
- package/dist/ui/src/components/ui/hover-card/hover-card.js +2 -1
- package/dist/ui/src/components/ui/hover-card/hover-card.js.map +1 -1
- package/dist/ui/src/components/ui/input/input.js +1 -1
- package/dist/ui/src/components/ui/input/input.js.map +1 -1
- package/dist/ui/src/components/ui/input-group/input-group.js +1 -1
- package/dist/ui/src/components/ui/input-group/input-group.js.map +1 -1
- package/dist/ui/src/components/ui/input-otp/input-otp.js +1 -1
- package/dist/ui/src/components/ui/input-otp/input-otp.js.map +1 -1
- package/dist/ui/src/components/ui/label/label.js +1 -1
- package/dist/ui/src/components/ui/label/label.js.map +1 -1
- package/dist/ui/src/components/ui/menubar/menubar.js +8 -5
- package/dist/ui/src/components/ui/menubar/menubar.js.map +1 -1
- package/dist/ui/src/components/ui/native-select/native-select.js +1 -1
- package/dist/ui/src/components/ui/native-select/native-select.js.map +1 -1
- package/dist/ui/src/components/ui/navigation-menu/navigation-menu.js +2 -1
- package/dist/ui/src/components/ui/navigation-menu/navigation-menu.js.map +1 -1
- package/dist/ui/src/components/ui/popover/popover.js +2 -1
- package/dist/ui/src/components/ui/popover/popover.js.map +1 -1
- package/dist/ui/src/components/ui/questionnaire/questionnaire.js +4 -4
- package/dist/ui/src/components/ui/questionnaire/questionnaire.js.map +1 -1
- package/dist/ui/src/components/ui/radio-group/radio-group.js +1 -1
- package/dist/ui/src/components/ui/radio-group/radio-group.js.map +1 -1
- package/dist/ui/src/components/ui/scroll-area/index.d.ts +1 -0
- package/dist/ui/src/components/ui/scroll-area/popup-list-scroll.d.ts +13 -0
- package/dist/ui/src/components/ui/scroll-area/popup-list-scroll.js +69 -0
- package/dist/ui/src/components/ui/scroll-area/popup-list-scroll.js.map +1 -0
- package/dist/ui/src/components/ui/scroll-area/scroll-area.d.ts +2 -4
- package/dist/ui/src/components/ui/scroll-area/scroll-area.js +26 -3
- package/dist/ui/src/components/ui/scroll-area/scroll-area.js.map +1 -1
- package/dist/ui/src/components/ui/select/select.js +12 -16
- 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/components/ui/tabs/tabs.js +1 -1
- package/dist/ui/src/components/ui/tabs/tabs.js.map +1 -1
- package/dist/ui/src/components/ui/textarea/textarea.js +1 -1
- package/dist/ui/src/components/ui/textarea/textarea.js.map +1 -1
- package/dist/ui/src/components/ui/toggle/toggle.js +2 -2
- package/dist/ui/src/components/ui/toggle/toggle.js.map +1 -1
- package/dist/ui/src/components/ui/tooltip/tooltip.js +2 -1
- package/dist/ui/src/components/ui/tooltip/tooltip.js.map +1 -1
- package/dist/ui/src/index.d.ts +1 -0
- package/dist/ui/src/lib/popup-list.d.ts +27 -5
- package/dist/ui/src/lib/popup-list.js +28 -6
- package/dist/ui/src/lib/popup-list.js.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
2
|
+
export type TFileRejectReason = 'type' | 'size' | 'count';
|
|
3
|
+
export interface IFileRejection {
|
|
4
|
+
file: File;
|
|
5
|
+
reason: TFileRejectReason;
|
|
6
|
+
}
|
|
7
|
+
/** The words a FileInput says and announces. Pass the ones to change. */
|
|
8
|
+
export interface IFileInputLabels {
|
|
9
|
+
/** The prompt in the drop area when it takes one file, before the "browse" link. */
|
|
10
|
+
dropOne: string;
|
|
11
|
+
/** The same, when it takes several. */
|
|
12
|
+
dropMany: string;
|
|
13
|
+
browse: string;
|
|
14
|
+
/** The accessible name of the file chooser itself, when the caller gives it no name of its own. */
|
|
15
|
+
chooseOne: string;
|
|
16
|
+
chooseMany: string;
|
|
17
|
+
/** The accessible name of the list of chosen files. */
|
|
18
|
+
list: string;
|
|
19
|
+
remove: (name: string) => string;
|
|
20
|
+
/** Announced when files are added. */
|
|
21
|
+
added: (count: number) => string;
|
|
22
|
+
/** Announced when a file is taken out. */
|
|
23
|
+
removed: (name: string) => string;
|
|
24
|
+
/** Said, and shown, for a file that was refused. */
|
|
25
|
+
rejection: (name: string, reason: TFileRejectReason, limit: string) => string;
|
|
26
|
+
}
|
|
27
|
+
export declare const DEFAULT_FILE_INPUT_LABELS: IFileInputLabels;
|
|
28
|
+
/** 1,536 bytes as "1.5 KB": the size a person reads next to a file's name. */
|
|
29
|
+
export declare const formatBytes: (bytes: number) => string;
|
|
30
|
+
/** Whether a file is one `accept` allows: an extension (`.pdf`), a type (`application/pdf`) or a family of types (`image/*`). No `accept` allows all. */
|
|
31
|
+
export declare const matchesAccept: (file: {
|
|
32
|
+
name: string;
|
|
33
|
+
type: string;
|
|
34
|
+
}, accept: string | undefined) => boolean;
|
|
35
|
+
export interface ISortFilesInput {
|
|
36
|
+
incoming: readonly File[];
|
|
37
|
+
current: readonly File[];
|
|
38
|
+
multiple: boolean;
|
|
39
|
+
accept?: string;
|
|
40
|
+
maxSize?: number;
|
|
41
|
+
maxFiles?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 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
|
|
45
|
+
* file allowed, the first acceptable one replaces what was chosen and the rest are refused as too many.
|
|
46
|
+
*/
|
|
47
|
+
export declare const sortFiles: ({ incoming, current, multiple, accept, maxSize, maxFiles, }: ISortFilesInput) => {
|
|
48
|
+
accepted: File[];
|
|
49
|
+
rejected: IFileRejection[];
|
|
50
|
+
};
|
|
51
|
+
export interface IFileInputProps extends Omit<ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'onChange' | 'children' | 'id'> {
|
|
52
|
+
/** The chosen files. Leave it out and the field keeps them itself. */
|
|
53
|
+
value?: readonly File[];
|
|
54
|
+
defaultValue?: readonly File[];
|
|
55
|
+
onValueChange?: (files: File[]) => void;
|
|
56
|
+
/** What the picker offers, and what a drop is held to: extensions (`.pdf`), types (`application/pdf`) or families (`image/*`). */
|
|
57
|
+
accept?: string;
|
|
58
|
+
/** Several files, added to each other, instead of one that is replaced. */
|
|
59
|
+
multiple?: boolean;
|
|
60
|
+
/** The most a file may weigh, in bytes. */
|
|
61
|
+
maxSize?: number;
|
|
62
|
+
/** The most files that may be chosen. Several-file fields only. */
|
|
63
|
+
maxFiles?: number;
|
|
64
|
+
/** Called with the files that were refused, each with the reason. They are also listed under the field. */
|
|
65
|
+
onReject?: (rejections: IFileRejection[]) => void;
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
invalid?: boolean;
|
|
68
|
+
required?: boolean;
|
|
69
|
+
/** Submits the chosen files with a form, under this name. */
|
|
70
|
+
name?: string;
|
|
71
|
+
/** For a `<Label htmlFor>` to name the field. */
|
|
72
|
+
id?: string;
|
|
73
|
+
/** What the field accepts, in words ("PDF or PNG, up to 5 MB"), read with the field. */
|
|
74
|
+
hint?: ReactNode;
|
|
75
|
+
labels?: Partial<IFileInputLabels>;
|
|
76
|
+
'aria-label'?: string;
|
|
77
|
+
'aria-labelledby'?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 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
|
|
81
|
+
* `<input type="file">`, which cannot be styled and has no list of what was chosen.
|
|
82
|
+
*
|
|
83
|
+
* 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
|
|
84
|
+
* 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
|
|
85
|
+
* button to take each out, the checks against `accept`, `maxSize` and `maxFiles` (a drop skips the dialog's own filter), and a
|
|
86
|
+
* live region that says what was added, removed or refused.
|
|
87
|
+
*
|
|
88
|
+
* Name the field with `aria-label`, `aria-labelledby` or a `<Label htmlFor={id}>`; with none of those it is named "Choose files".
|
|
89
|
+
*/
|
|
90
|
+
export declare const FileInput: ({ value, defaultValue, onValueChange, accept, multiple, maxSize, maxFiles, onReject, disabled, invalid, required, name, id, hint, labels: labelOverrides, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }: IFileInputProps) => import('react').JSX.Element;
|
|
@@ -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-control 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-control 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-control 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-control 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;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { cn } from "../../../lib/utils.js";
|
|
2
2
|
import { useExposedFocusGuards } from "../../../lib/focus-guards.js";
|
|
3
|
+
import { POPUP_FRAME_CLASS, POPUP_LAYER_CLASS, POPUP_LIST_HEIGHT_CLASS, POPUP_LIST_WIDTH_CLASS, popupItemClass } from "../../../lib/popup-list.js";
|
|
3
4
|
import { ScrollArea } from "../../ui/scroll-area/scroll-area.js";
|
|
4
5
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip/tooltip.js";
|
|
5
|
-
import { 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,8 +45,8 @@ 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)
|
|
47
|
-
var TRIGGER = "flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-
|
|
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");
|
|
49
|
+
var TRIGGER = "flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-control 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);
|
|
50
52
|
const measureLabel = useCallback((element) => {
|
|
@@ -68,7 +70,7 @@ var Option = ({ option, index, style, measure, highlighted }) => {
|
|
|
68
70
|
/* @__PURE__ */ jsx("span", {
|
|
69
71
|
"data-slot": "multi-select-item-indicator",
|
|
70
72
|
"aria-hidden": "true",
|
|
71
|
-
className: "flex size-4 shrink-0 items-center justify-center rounded-sm border border-
|
|
73
|
+
className: "flex size-4 shrink-0 items-center justify-center rounded-sm border border-control text-primary-foreground transition-colors group-data-[selected]:border-primary group-data-[selected]:bg-primary",
|
|
72
74
|
children: /* @__PURE__ */ jsx(IconCheck, {
|
|
73
75
|
className: "size-3 opacity-0 group-data-[selected]:opacity-100",
|
|
74
76
|
stroke: 3
|
|
@@ -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
|
]
|
|
@@ -118,7 +120,7 @@ var Options = ({ scroller, scrollTo, busy, highlightedValue }) => {
|
|
|
118
120
|
]);
|
|
119
121
|
if (!virtual) return /* @__PURE__ */ jsx(Combobox.List, {
|
|
120
122
|
"aria-busy": busy || void 0,
|
|
121
|
-
className: "
|
|
123
|
+
className: "px-1 outline-none",
|
|
122
124
|
children: items.map((option, index) => /* @__PURE__ */ jsx(Option, {
|
|
123
125
|
option,
|
|
124
126
|
index,
|
|
@@ -128,7 +130,7 @@ var Options = ({ scroller, scrollTo, busy, highlightedValue }) => {
|
|
|
128
130
|
return /* @__PURE__ */ jsx(Combobox.List, {
|
|
129
131
|
"data-virtualized": "",
|
|
130
132
|
"aria-busy": busy || void 0,
|
|
131
|
-
className: "relative
|
|
133
|
+
className: "relative mx-1 outline-none",
|
|
132
134
|
style: { height: virtualizer.getTotalSize() },
|
|
133
135
|
children: virtualizer.getVirtualItems().map((row) => {
|
|
134
136
|
const option = items[row.index];
|
|
@@ -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), []);
|
|
@@ -259,18 +262,19 @@ var MultiSelect = ({ placeholder, searchPlaceholder, options, value, onValueChan
|
|
|
259
262
|
}), /* @__PURE__ */ jsxs(Combobox.Portal, { children: [/* @__PURE__ */ jsx(ExposedFocusGuards, {}), /* @__PURE__ */ jsx(Combobox.Positioner, {
|
|
260
263
|
sideOffset: 4,
|
|
261
264
|
align: "start",
|
|
262
|
-
className:
|
|
265
|
+
className: POPUP_LAYER_CLASS,
|
|
263
266
|
children: /* @__PURE__ */ jsxs(Combobox.Popup, {
|
|
264
267
|
"data-slot": "multi-select-content",
|
|
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
|