@tensor_1/react 2.30.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/components/Attachment.d.ts +55 -0
- package/dist/components/Attachment.js +69 -0
- package/dist/components/EntityPicker.d.ts +56 -0
- package/dist/components/EntityPicker.js +168 -0
- package/dist/components/FileIcon.d.ts +9 -0
- package/dist/components/FileIcon.js +17 -0
- package/dist/components/FileUpload.d.ts +87 -0
- package/dist/components/FileUpload.js +139 -0
- package/dist/components/FilterEditor.d.ts +51 -0
- package/dist/components/FilterEditor.js +175 -0
- package/dist/components/Lightbox.d.ts +45 -0
- package/dist/components/Lightbox.js +102 -0
- package/dist/components/SearchSuggest.d.ts +6 -2
- package/dist/components/SearchSuggest.js +4 -0
- package/dist/file-model.d.ts +37 -0
- package/dist/file-model.js +50 -0
- package/dist/filter-model.d.ts +88 -0
- package/dist/filter-model.js +91 -0
- package/dist/icons/Icon.d.ts +1 -1
- package/dist/index.cjs +2213 -1407
- package/dist/index.d.cts +401 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.js +35 -0
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "../cx.js";
|
|
5
|
+
import { fillLabel } from "../fill-label.js";
|
|
6
|
+
import { formatFileSize, partitionFiles } from "../file-model.js";
|
|
7
|
+
import { Button } from "./Button.js";
|
|
8
|
+
import { Input } from "./Input.js";
|
|
9
|
+
import { Progress } from "./Progress.js";
|
|
10
|
+
import { FileIcon } from "./FileIcon.js";
|
|
11
|
+
const DEFAULT_FILE_UPLOAD_LABELS = {
|
|
12
|
+
title: "Drop files here",
|
|
13
|
+
choose: "Choose files",
|
|
14
|
+
drop: "Release to add the files",
|
|
15
|
+
list: "Files",
|
|
16
|
+
remove: "Remove {name}",
|
|
17
|
+
retry: "Retry {name}",
|
|
18
|
+
retryShort: "Retry",
|
|
19
|
+
queued: "Waiting",
|
|
20
|
+
uploading: "Uploading, {progress}%",
|
|
21
|
+
done: "Uploaded",
|
|
22
|
+
failed: "Upload failed",
|
|
23
|
+
alt: "Description of {name} for screen readers",
|
|
24
|
+
added: "{count} added",
|
|
25
|
+
wrongType: "{name} is not an accepted file type ({accept}).",
|
|
26
|
+
tooLarge: "{name} is larger than {max}.",
|
|
27
|
+
tooMany: "{name} was not added: at most {max} files."
|
|
28
|
+
};
|
|
29
|
+
function FileUpload({
|
|
30
|
+
items,
|
|
31
|
+
onFilesAdded,
|
|
32
|
+
onRemove,
|
|
33
|
+
onRetry,
|
|
34
|
+
onAltChange,
|
|
35
|
+
accept,
|
|
36
|
+
maxSize,
|
|
37
|
+
maxFiles,
|
|
38
|
+
multiple = true,
|
|
39
|
+
capture,
|
|
40
|
+
disabled,
|
|
41
|
+
hint,
|
|
42
|
+
locale,
|
|
43
|
+
labels: labelOverrides,
|
|
44
|
+
className
|
|
45
|
+
}) {
|
|
46
|
+
const labels = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labelOverrides };
|
|
47
|
+
const id = useId();
|
|
48
|
+
const inputRef = useRef(null);
|
|
49
|
+
const [over, setOver] = useState(false);
|
|
50
|
+
const [messages, setMessages] = useState([]);
|
|
51
|
+
const [announce, setAnnounce] = useState("");
|
|
52
|
+
const take = (list) => {
|
|
53
|
+
if (!list || disabled) return;
|
|
54
|
+
const files = Array.from(list);
|
|
55
|
+
const { accepted, rejected } = partitionFiles(files, { accept, maxSize, maxFiles, existing: items.length });
|
|
56
|
+
const max = maxSize !== void 0 ? formatFileSize(maxSize, locale) : "";
|
|
57
|
+
setMessages(rejected.map(({ file, reason }) => fillLabel(
|
|
58
|
+
reason === "type" ? labels.wrongType : reason === "size" ? labels.tooLarge : labels.tooMany,
|
|
59
|
+
{ name: file.name, accept: accept ?? "", max: reason === "count" ? String(maxFiles) : max }
|
|
60
|
+
)));
|
|
61
|
+
setAnnounce(accepted.length ? fillLabel(labels.added, { count: accepted.length }) : "");
|
|
62
|
+
onFilesAdded(accepted, rejected);
|
|
63
|
+
};
|
|
64
|
+
const onDrop = (event) => {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
setOver(false);
|
|
67
|
+
take(event.dataTransfer?.files ?? null);
|
|
68
|
+
};
|
|
69
|
+
return /* @__PURE__ */ jsxs("div", { className: cx("uix-file-upload", className), "data-disabled": disabled || void 0, children: [
|
|
70
|
+
/* @__PURE__ */ jsxs(
|
|
71
|
+
"div",
|
|
72
|
+
{
|
|
73
|
+
className: "uix-dropzone",
|
|
74
|
+
"data-dragover": over || void 0,
|
|
75
|
+
onDragOver: (event) => {
|
|
76
|
+
if (disabled) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
setOver(true);
|
|
79
|
+
},
|
|
80
|
+
onDragLeave: () => setOver(false),
|
|
81
|
+
onDrop,
|
|
82
|
+
onClick: (event) => {
|
|
83
|
+
if (event.target === event.currentTarget) inputRef.current?.click();
|
|
84
|
+
},
|
|
85
|
+
children: [
|
|
86
|
+
/* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M12 15V4M7 9l5-5 5 5M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4" }) }),
|
|
87
|
+
/* @__PURE__ */ jsx("strong", { "aria-hidden": over || void 0, children: over ? labels.drop : labels.title }),
|
|
88
|
+
hint != null && /* @__PURE__ */ jsx("span", { className: "uix-file-upload__hint", id: `${id}-hint`, children: hint }),
|
|
89
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", disabled, "aria-describedby": hint != null ? `${id}-hint` : void 0, onClick: () => inputRef.current?.click(), children: labels.choose }),
|
|
90
|
+
/* @__PURE__ */ jsx(
|
|
91
|
+
"input",
|
|
92
|
+
{
|
|
93
|
+
ref: inputRef,
|
|
94
|
+
type: "file",
|
|
95
|
+
className: "uix-visually-hidden",
|
|
96
|
+
tabIndex: -1,
|
|
97
|
+
"aria-hidden": "true",
|
|
98
|
+
accept,
|
|
99
|
+
multiple,
|
|
100
|
+
capture,
|
|
101
|
+
disabled,
|
|
102
|
+
onChange: (event) => {
|
|
103
|
+
take(event.currentTarget.files);
|
|
104
|
+
event.currentTarget.value = "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
messages.length > 0 && /* @__PURE__ */ jsx("ul", { className: "uix-file-upload__errors", role: "alert", children: messages.map((m, i) => /* @__PURE__ */ jsx("li", { children: m }, i)) }),
|
|
112
|
+
/* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: announce }),
|
|
113
|
+
items.length > 0 && /* @__PURE__ */ jsx("ul", { className: "uix-filelist uix-file-upload__list", "aria-label": labels.list, children: items.map((item) => {
|
|
114
|
+
const statusText = item.status === "uploading" ? fillLabel(labels.uploading, { progress: item.progress ?? 0 }) : item.status === "done" ? labels.done : item.status === "error" ? labels.failed : labels.queued;
|
|
115
|
+
const image = !!item.previewUrl;
|
|
116
|
+
return /* @__PURE__ */ jsxs("li", { className: "uix-filelist__item uix-file-upload__item", "data-status": item.status, children: [
|
|
117
|
+
image ? /* @__PURE__ */ jsx("img", { className: "uix-file-upload__thumb", src: item.previewUrl, alt: "" }) : /* @__PURE__ */ jsx("span", { className: "uix-attachment__icon", children: /* @__PURE__ */ jsx(FileIcon, { name: item.name, type: item.type }) }),
|
|
118
|
+
/* @__PURE__ */ jsxs("div", { className: "uix-file-upload__body", children: [
|
|
119
|
+
/* @__PURE__ */ jsx("span", { className: "uix-file-upload__name", children: item.name }),
|
|
120
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-file-upload__meta", children: [
|
|
121
|
+
/* @__PURE__ */ jsx("span", { children: formatFileSize(item.size, locale) }),
|
|
122
|
+
item.status !== "uploading" && /* @__PURE__ */ jsx("span", { className: "uix-file-upload__status", children: statusText })
|
|
123
|
+
] }),
|
|
124
|
+
item.status === "uploading" && /* @__PURE__ */ jsx(Progress, { value: item.progress, indeterminate: item.progress === void 0, max: 100, label: statusText }),
|
|
125
|
+
item.status === "error" && item.error != null && /* @__PURE__ */ jsx("span", { className: "uix-file-upload__error", children: item.error }),
|
|
126
|
+
image && onAltChange && /* @__PURE__ */ jsx(Input, { size: "sm", "aria-label": fillLabel(labels.alt, { name: item.name }), placeholder: fillLabel(labels.alt, { name: item.name }), value: item.alt ?? "", onChange: (event) => onAltChange(item.id, event.currentTarget.value) })
|
|
127
|
+
] }),
|
|
128
|
+
/* @__PURE__ */ jsxs("div", { className: "uix-file-upload__actions", children: [
|
|
129
|
+
item.status === "error" && onRetry && /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", "aria-label": fillLabel(labels.retry, { name: item.name }), onClick: () => onRetry(item.id), children: labels.retryShort }),
|
|
130
|
+
onRemove && /* @__PURE__ */ jsx(Button, { type: "button", size: "xs", variant: "ghost", icon: true, "aria-label": fillLabel(labels.remove, { name: item.name }), onClick: () => onRemove(item.id), children: /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }) })
|
|
131
|
+
] })
|
|
132
|
+
] }, item.id);
|
|
133
|
+
}) })
|
|
134
|
+
] });
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
DEFAULT_FILE_UPLOAD_LABELS,
|
|
138
|
+
FileUpload
|
|
139
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { TextFilterOperator, NumberFilterOperator, FilterField, FilterValue } from '../filter-model.js';
|
|
3
|
+
|
|
4
|
+
interface FilterEditorLabels {
|
|
5
|
+
apply: string;
|
|
6
|
+
clear: string;
|
|
7
|
+
operator: string;
|
|
8
|
+
value: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
/** Between two numbers: "and". */
|
|
12
|
+
and: string;
|
|
13
|
+
any: string;
|
|
14
|
+
yes: string;
|
|
15
|
+
no: string;
|
|
16
|
+
search: string;
|
|
17
|
+
searching: string;
|
|
18
|
+
noMatches: string;
|
|
19
|
+
searchFailed: string;
|
|
20
|
+
retry: string;
|
|
21
|
+
selectAll: string;
|
|
22
|
+
selectNone: string;
|
|
23
|
+
/** `{count}` chosen. */
|
|
24
|
+
selected: string;
|
|
25
|
+
/** Remove one chosen record. `{label}`. */
|
|
26
|
+
remove: string;
|
|
27
|
+
operators: Record<TextFilterOperator | NumberFilterOperator, string>;
|
|
28
|
+
}
|
|
29
|
+
declare const DEFAULT_FILTER_EDITOR_LABELS: FilterEditorLabels;
|
|
30
|
+
interface FilterEditorProps {
|
|
31
|
+
field: FilterField;
|
|
32
|
+
/** Undefined means "not filtered yet". */
|
|
33
|
+
value: FilterValue | undefined;
|
|
34
|
+
onValueChange: (value: FilterValue) => void;
|
|
35
|
+
onApply: () => void;
|
|
36
|
+
onClear: () => void;
|
|
37
|
+
labels?: Partial<FilterEditorLabels>;
|
|
38
|
+
/** Show an option filter above an enum list once it has more options than this. Default 8. */
|
|
39
|
+
searchThreshold?: number;
|
|
40
|
+
/** Delay before a reference search runs, in ms. Default 250. */
|
|
41
|
+
searchDelay?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Editor content for one typed column filter (HAR-1365; TENSOR C6, MOTUS C-1): an enum
|
|
45
|
+
* multi-select, text or number with a condition, a date range, yes/no, or records found by
|
|
46
|
+
* an async search. The product owns the popover trigger and the query; `summarizeFilter`
|
|
47
|
+
* turns the applied value into the chip text. `FilterPopover` stays for the one-string case.
|
|
48
|
+
*/
|
|
49
|
+
declare function FilterEditor({ field, value: valueProp, onValueChange, onApply, onClear, labels: labelOverrides, searchThreshold, searchDelay }: FilterEditorProps): react.JSX.Element;
|
|
50
|
+
|
|
51
|
+
export { DEFAULT_FILTER_EDITOR_LABELS, FilterEditor, type FilterEditorLabels, type FilterEditorProps };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Button } from "./Button.js";
|
|
5
|
+
import { Checkbox } from "./Checkbox.js";
|
|
6
|
+
import { Input } from "./Input.js";
|
|
7
|
+
import { Radio, RadioGroup } from "./Radio.js";
|
|
8
|
+
import { Select } from "./Select.js";
|
|
9
|
+
import { Chip } from "./Chip.js";
|
|
10
|
+
import { fillLabel } from "../fill-label.js";
|
|
11
|
+
import { foldForSearch } from "../search-suggest-model.js";
|
|
12
|
+
import { NUMBER_FILTER_OPERATORS, TEXT_FILTER_OPERATORS, emptyFilterValue } from "../filter-model.js";
|
|
13
|
+
const DEFAULT_FILTER_EDITOR_LABELS = {
|
|
14
|
+
apply: "Apply",
|
|
15
|
+
clear: "Clear",
|
|
16
|
+
operator: "Condition",
|
|
17
|
+
value: "Value",
|
|
18
|
+
from: "From",
|
|
19
|
+
to: "To",
|
|
20
|
+
and: "and",
|
|
21
|
+
any: "Any",
|
|
22
|
+
yes: "Yes",
|
|
23
|
+
no: "No",
|
|
24
|
+
search: "Search",
|
|
25
|
+
searching: "Searching\u2026",
|
|
26
|
+
noMatches: "No matches.",
|
|
27
|
+
searchFailed: "Search failed.",
|
|
28
|
+
retry: "Try again",
|
|
29
|
+
selectAll: "Select all",
|
|
30
|
+
selectNone: "Clear selection",
|
|
31
|
+
selected: "{count} selected",
|
|
32
|
+
remove: "Remove {label}",
|
|
33
|
+
operators: {
|
|
34
|
+
contains: "contains",
|
|
35
|
+
"not-contains": "does not contain",
|
|
36
|
+
is: "is",
|
|
37
|
+
"is-not": "is not",
|
|
38
|
+
"starts-with": "starts with",
|
|
39
|
+
eq: "equals",
|
|
40
|
+
neq: "does not equal",
|
|
41
|
+
lt: "less than",
|
|
42
|
+
lte: "at most",
|
|
43
|
+
gt: "more than",
|
|
44
|
+
gte: "at least",
|
|
45
|
+
between: "between"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function FilterEditor({ field, value: valueProp, onValueChange, onApply, onClear, labels: labelOverrides, searchThreshold = 8, searchDelay = 250 }) {
|
|
49
|
+
const labels = { ...DEFAULT_FILTER_EDITOR_LABELS, ...labelOverrides, operators: { ...DEFAULT_FILTER_EDITOR_LABELS.operators, ...labelOverrides?.operators } };
|
|
50
|
+
const id = useId();
|
|
51
|
+
const value = valueProp?.kind === field.kind ? valueProp : emptyFilterValue(field.kind);
|
|
52
|
+
let body = null;
|
|
53
|
+
switch (value.kind) {
|
|
54
|
+
case "enum":
|
|
55
|
+
body = /* @__PURE__ */ jsx(EnumBody, { field, value, onChange: onValueChange, labels, threshold: searchThreshold });
|
|
56
|
+
break;
|
|
57
|
+
case "reference":
|
|
58
|
+
body = /* @__PURE__ */ jsx(ReferenceBody, { field, value, onChange: onValueChange, labels, delay: searchDelay });
|
|
59
|
+
break;
|
|
60
|
+
case "text": {
|
|
61
|
+
const ops = field.operators ?? TEXT_FILTER_OPERATORS;
|
|
62
|
+
body = /* @__PURE__ */ jsxs("div", { className: "uix-filter-editor__row", children: [
|
|
63
|
+
ops.length > 1 && /* @__PURE__ */ jsx(Select, { size: "sm", "aria-label": labels.operator, value: value.operator, onChange: (e) => onValueChange({ ...value, operator: e.currentTarget.value }), children: ops.map((op) => /* @__PURE__ */ jsx("option", { value: op, children: labels.operators[op] }, op)) }),
|
|
64
|
+
/* @__PURE__ */ jsx(Input, { "aria-label": labels.value, value: value.text, onChange: (e) => onValueChange({ ...value, text: e.currentTarget.value }), onKeyDown: (e) => {
|
|
65
|
+
if (e.key === "Enter") onApply();
|
|
66
|
+
}, autoFocus: true })
|
|
67
|
+
] });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "number": {
|
|
71
|
+
const ops = field.operators ?? NUMBER_FILTER_OPERATORS;
|
|
72
|
+
const toNumber = (raw) => raw.trim() === "" ? void 0 : Number(raw);
|
|
73
|
+
body = /* @__PURE__ */ jsxs("div", { className: "uix-filter-editor__row", children: [
|
|
74
|
+
ops.length > 1 && /* @__PURE__ */ jsx(Select, { size: "sm", "aria-label": labels.operator, value: value.operator, onChange: (e) => onValueChange({ ...value, operator: e.currentTarget.value }), children: ops.map((op) => /* @__PURE__ */ jsx("option", { value: op, children: labels.operators[op] }, op)) }),
|
|
75
|
+
/* @__PURE__ */ jsx(Input, { type: "number", inputMode: "decimal", "aria-label": value.operator === "between" ? labels.from : labels.value, value: value.value ?? "", onChange: (e) => onValueChange({ ...value, value: toNumber(e.currentTarget.value) }), autoFocus: true }),
|
|
76
|
+
value.operator === "between" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
77
|
+
/* @__PURE__ */ jsx("span", { className: "uix-filter-editor__and", children: labels.and }),
|
|
78
|
+
/* @__PURE__ */ jsx(Input, { type: "number", inputMode: "decimal", "aria-label": labels.to, value: value.to ?? "", onChange: (e) => onValueChange({ ...value, to: toNumber(e.currentTarget.value) }) })
|
|
79
|
+
] }),
|
|
80
|
+
field.unit && /* @__PURE__ */ jsx("span", { className: "uix-filter-editor__unit", children: field.unit })
|
|
81
|
+
] });
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "date-range":
|
|
85
|
+
body = /* @__PURE__ */ jsxs("div", { className: "uix-filter-editor__row", children: [
|
|
86
|
+
/* @__PURE__ */ jsxs("label", { className: "uix-filter-editor__date", children: [
|
|
87
|
+
/* @__PURE__ */ jsx("span", { children: labels.from }),
|
|
88
|
+
/* @__PURE__ */ jsx(Input, { type: "date", value: value.from ?? "", max: value.to, onChange: (e) => onValueChange({ ...value, from: e.currentTarget.value || void 0 }) })
|
|
89
|
+
] }),
|
|
90
|
+
/* @__PURE__ */ jsxs("label", { className: "uix-filter-editor__date", children: [
|
|
91
|
+
/* @__PURE__ */ jsx("span", { children: labels.to }),
|
|
92
|
+
/* @__PURE__ */ jsx(Input, { type: "date", value: value.to ?? "", min: value.from, onChange: (e) => onValueChange({ ...value, to: e.currentTarget.value || void 0 }) })
|
|
93
|
+
] })
|
|
94
|
+
] });
|
|
95
|
+
break;
|
|
96
|
+
case "boolean": {
|
|
97
|
+
const pick = (v) => onValueChange({ kind: "boolean", value: v });
|
|
98
|
+
body = /* @__PURE__ */ jsxs(RadioGroup, { className: "uix-filter-editor__choices", children: [
|
|
99
|
+
/* @__PURE__ */ jsx(Radio, { name: `${id}-bool`, label: labels.any, checked: value.value === void 0, onChange: () => pick(void 0) }),
|
|
100
|
+
/* @__PURE__ */ jsx(Radio, { name: `${id}-bool`, label: field.trueLabel ?? labels.yes, checked: value.value === true, onChange: () => pick(true) }),
|
|
101
|
+
/* @__PURE__ */ jsx(Radio, { name: `${id}-bool`, label: field.falseLabel ?? labels.no, checked: value.value === false, onChange: () => pick(false) })
|
|
102
|
+
] });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return /* @__PURE__ */ jsxs("div", { className: "uix-filter-popover uix-filter-editor", role: "group", "aria-labelledby": `${id}-label`, "data-kind": field.kind, children: [
|
|
107
|
+
/* @__PURE__ */ jsx("div", { id: `${id}-label`, className: "uix-filter-editor__label", children: field.label }),
|
|
108
|
+
body,
|
|
109
|
+
/* @__PURE__ */ jsxs("div", { className: "uix-filter-popover__actions", children: [
|
|
110
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", onClick: onClear, children: labels.clear }),
|
|
111
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "primary", onClick: onApply, children: labels.apply })
|
|
112
|
+
] })
|
|
113
|
+
] });
|
|
114
|
+
}
|
|
115
|
+
function EnumBody({ field, value, onChange, labels, threshold }) {
|
|
116
|
+
const [query, setQuery] = useState("");
|
|
117
|
+
const options = field.options ?? [];
|
|
118
|
+
const shown = useMemo(() => {
|
|
119
|
+
const q = foldForSearch(query.trim()).text;
|
|
120
|
+
return q ? options.filter((o) => foldForSearch(o.label).text.includes(q)) : options;
|
|
121
|
+
}, [options, query]);
|
|
122
|
+
const selected = new Set(value.values);
|
|
123
|
+
const toggle = (v, on) => onChange({ kind: "enum", values: on ? [...value.values, v] : value.values.filter((x) => x !== v) });
|
|
124
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
125
|
+
options.length > threshold && /* @__PURE__ */ jsx(Input, { type: "search", size: "sm", "aria-label": labels.search, placeholder: labels.search, value: query, onChange: (e) => setQuery(e.currentTarget.value), autoFocus: true }),
|
|
126
|
+
/* @__PURE__ */ jsxs("div", { className: "uix-filter-editor__bulk", children: [
|
|
127
|
+
/* @__PURE__ */ jsx("span", { className: "uix-filter-editor__count", "aria-live": "polite", children: fillLabel(labels.selected, { count: value.values.length }) }),
|
|
128
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "link", size: "sm", onClick: () => onChange({ kind: "enum", values: [.../* @__PURE__ */ new Set([...value.values, ...shown.map((o) => o.value)])] }), children: labels.selectAll }),
|
|
129
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "link", size: "sm", onClick: () => onChange({ kind: "enum", values: [] }), children: labels.selectNone })
|
|
130
|
+
] }),
|
|
131
|
+
/* @__PURE__ */ jsx("div", { className: "uix-filter-editor__list", children: shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "uix-filter-editor__note", children: labels.noMatches }) : shown.map((o) => /* @__PURE__ */ jsx(Checkbox, { label: o.label, checked: selected.has(o.value), onChange: (e) => toggle(o.value, e.currentTarget.checked) }, o.value)) })
|
|
132
|
+
] });
|
|
133
|
+
}
|
|
134
|
+
function ReferenceBody({ field, value, onChange, labels, delay }) {
|
|
135
|
+
const [query, setQuery] = useState("");
|
|
136
|
+
const [state, setState] = useState({ status: "idle", results: [] });
|
|
137
|
+
const [attempt, setAttempt] = useState(0);
|
|
138
|
+
const latest = useRef(0);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!field.onSearch) return;
|
|
141
|
+
const run = ++latest.current;
|
|
142
|
+
const timer = window.setTimeout(() => {
|
|
143
|
+
setState((s) => ({ ...s, status: "loading" }));
|
|
144
|
+
field.onSearch(query.trim()).then(
|
|
145
|
+
(results) => {
|
|
146
|
+
if (run === latest.current) setState({ status: "done", results });
|
|
147
|
+
},
|
|
148
|
+
() => {
|
|
149
|
+
if (run === latest.current) setState({ status: "error", results: [] });
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}, delay);
|
|
153
|
+
return () => window.clearTimeout(timer);
|
|
154
|
+
}, [query, attempt, field, delay]);
|
|
155
|
+
const chosen = new Map(value.values.map((v) => [v.value, v]));
|
|
156
|
+
const toggle = (choice, on) => onChange({ kind: "reference", values: on ? [...value.values, choice] : value.values.filter((v) => v.value !== choice.value) });
|
|
157
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
158
|
+
value.values.length > 0 && /* @__PURE__ */ jsx("div", { className: "uix-chip-group", role: "group", "aria-label": fillLabel(labels.selected, { count: value.values.length }), children: value.values.map((v) => /* @__PURE__ */ jsx(Chip, { removeLabel: fillLabel(labels.remove, { label: v.label }), onRemove: () => toggle(v, false), children: v.label }, v.value)) }),
|
|
159
|
+
/* @__PURE__ */ jsx(Input, { type: "search", size: "sm", "aria-label": labels.search, placeholder: labels.search, value: query, onChange: (e) => setQuery(e.currentTarget.value), autoFocus: true }),
|
|
160
|
+
/* @__PURE__ */ jsxs("div", { className: "uix-filter-editor__list", "aria-busy": state.status === "loading" || void 0, children: [
|
|
161
|
+
state.status === "loading" && /* @__PURE__ */ jsx("p", { className: "uix-filter-editor__note", role: "status", children: labels.searching }),
|
|
162
|
+
state.status === "error" && /* @__PURE__ */ jsxs("p", { className: "uix-filter-editor__note", role: "alert", children: [
|
|
163
|
+
labels.searchFailed,
|
|
164
|
+
" ",
|
|
165
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "link", size: "sm", onClick: () => setAttempt((n) => n + 1), children: labels.retry })
|
|
166
|
+
] }),
|
|
167
|
+
state.status === "done" && state.results.length === 0 && /* @__PURE__ */ jsx("p", { className: "uix-filter-editor__note", children: labels.noMatches }),
|
|
168
|
+
state.status === "done" && state.results.map((r) => /* @__PURE__ */ jsx(Checkbox, { label: r.label, checked: chosen.has(r.value), onChange: (e) => toggle(r, e.currentTarget.checked) }, r.value))
|
|
169
|
+
] })
|
|
170
|
+
] });
|
|
171
|
+
}
|
|
172
|
+
export {
|
|
173
|
+
DEFAULT_FILTER_EDITOR_LABELS,
|
|
174
|
+
FilterEditor
|
|
175
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface LightboxItem {
|
|
5
|
+
src: string;
|
|
6
|
+
/** Alternative text for an image; the video's name for a video. */
|
|
7
|
+
alt: string;
|
|
8
|
+
caption?: ReactNode;
|
|
9
|
+
kind?: 'image' | 'video';
|
|
10
|
+
/** Intrinsic size, so an SVG or a slow image keeps its box. */
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
interface LightboxLabels {
|
|
15
|
+
region: string;
|
|
16
|
+
previous: string;
|
|
17
|
+
next: string;
|
|
18
|
+
close: string;
|
|
19
|
+
/** `{index}`, `{total}`. */
|
|
20
|
+
counter: string;
|
|
21
|
+
zoomIn: string;
|
|
22
|
+
zoomOut: string;
|
|
23
|
+
}
|
|
24
|
+
declare const DEFAULT_LIGHTBOX_LABELS: LightboxLabels;
|
|
25
|
+
interface LightboxProps {
|
|
26
|
+
items: readonly LightboxItem[];
|
|
27
|
+
/** The item on show (0-based). */
|
|
28
|
+
index: number;
|
|
29
|
+
onIndexChange: (index: number) => void;
|
|
30
|
+
open: boolean;
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
/** Wrap from the last item to the first. Default false. */
|
|
33
|
+
loop?: boolean;
|
|
34
|
+
labels?: Partial<LightboxLabels>;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A full-screen media viewer over `.uix-lightbox` (HAR-1367; TENSOR C12 `capture-viewer.tsx`,
|
|
39
|
+
* MOTUS C-8 programme gallery): previous/next buttons, ←/→ and Home/End, swipe on touch, an
|
|
40
|
+
* announced "n of total" counter, a caption, and a fit/actual-size toggle. Native `<dialog>`,
|
|
41
|
+
* so focus stays inside and Escape closes; focus returns to the control that opened it.
|
|
42
|
+
*/
|
|
43
|
+
declare function Lightbox({ items, index, onIndexChange, open, onClose, loop, labels: labelOverrides, className }: LightboxProps): react.JSX.Element;
|
|
44
|
+
|
|
45
|
+
export { DEFAULT_LIGHTBOX_LABELS, Lightbox, type LightboxItem, type LightboxLabels, type LightboxProps };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "../cx.js";
|
|
5
|
+
import { fillLabel } from "../fill-label.js";
|
|
6
|
+
import { useDialog } from "../hooks/useDialog.js";
|
|
7
|
+
const DEFAULT_LIGHTBOX_LABELS = {
|
|
8
|
+
region: "Media viewer",
|
|
9
|
+
previous: "Previous",
|
|
10
|
+
next: "Next",
|
|
11
|
+
close: "Close",
|
|
12
|
+
counter: "{index} of {total}",
|
|
13
|
+
zoomIn: "Show actual size",
|
|
14
|
+
zoomOut: "Fit to screen"
|
|
15
|
+
};
|
|
16
|
+
const Chevron = ({ dir }) => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: dir === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6" }) });
|
|
17
|
+
function Lightbox({ items, index, onIndexChange, open, onClose, loop = false, labels: labelOverrides, className }) {
|
|
18
|
+
const labels = { ...DEFAULT_LIGHTBOX_LABELS, ...labelOverrides };
|
|
19
|
+
const ref = useDialog(open, onClose);
|
|
20
|
+
const closeRef = useRef(null);
|
|
21
|
+
const returnTo = useRef(null);
|
|
22
|
+
const swipe = useRef(null);
|
|
23
|
+
const [zoomed, setZoomed] = useState(false);
|
|
24
|
+
const total = items.length;
|
|
25
|
+
const current = items[Math.min(Math.max(index, 0), Math.max(total - 1, 0))];
|
|
26
|
+
const hasPrev = loop ? total > 1 : index > 0;
|
|
27
|
+
const hasNext = loop ? total > 1 : index < total - 1;
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (open) {
|
|
30
|
+
returnTo.current = document.activeElement;
|
|
31
|
+
closeRef.current?.focus();
|
|
32
|
+
} else if (returnTo.current) {
|
|
33
|
+
returnTo.current.focus?.();
|
|
34
|
+
returnTo.current = null;
|
|
35
|
+
}
|
|
36
|
+
}, [open]);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setZoomed(false);
|
|
39
|
+
}, [index]);
|
|
40
|
+
const go = (to) => {
|
|
41
|
+
if (total === 0) return;
|
|
42
|
+
const next = loop ? (to + total) % total : Math.min(Math.max(to, 0), total - 1);
|
|
43
|
+
if (next !== index) onIndexChange(next);
|
|
44
|
+
};
|
|
45
|
+
const onKeyDown = (event) => {
|
|
46
|
+
if (event.target.closest("video")) return;
|
|
47
|
+
if (event.key === "ArrowLeft") {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
go(index - 1);
|
|
50
|
+
} else if (event.key === "ArrowRight") {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
go(index + 1);
|
|
53
|
+
} else if (event.key === "Home") {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
go(0);
|
|
56
|
+
} else if (event.key === "End") {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
go(total - 1);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const onPointerDown = (event) => {
|
|
62
|
+
if (event.pointerType !== "mouse") swipe.current = { x: event.clientX, id: event.pointerId };
|
|
63
|
+
};
|
|
64
|
+
const onPointerUp = (event) => {
|
|
65
|
+
const start = swipe.current;
|
|
66
|
+
swipe.current = null;
|
|
67
|
+
if (!start || start.id !== event.pointerId || zoomed) return;
|
|
68
|
+
const dx = event.clientX - start.x;
|
|
69
|
+
if (Math.abs(dx) > 48) go(index + (dx < 0 ? 1 : -1));
|
|
70
|
+
};
|
|
71
|
+
return /* @__PURE__ */ jsxs(
|
|
72
|
+
"dialog",
|
|
73
|
+
{
|
|
74
|
+
ref,
|
|
75
|
+
className: cx("uix-lightbox", "uix-lightbox--gallery", className),
|
|
76
|
+
"aria-label": labels.region,
|
|
77
|
+
onKeyDown,
|
|
78
|
+
onClick: (event) => {
|
|
79
|
+
if (event.target === event.currentTarget) onClose();
|
|
80
|
+
},
|
|
81
|
+
children: [
|
|
82
|
+
current && /* @__PURE__ */ jsxs("figure", { className: "uix-lightbox__figure", children: [
|
|
83
|
+
/* @__PURE__ */ jsx("div", { className: "uix-lightbox__stage", "data-zoomed": zoomed || void 0, onPointerDown, onPointerUp, children: current.kind === "video" ? /* @__PURE__ */ jsx("video", { className: "uix-lightbox__media", src: current.src, controls: true, "aria-label": current.alt, width: current.width, height: current.height }) : /* @__PURE__ */ jsx("img", { className: "uix-lightbox__media", src: current.src, alt: current.alt, width: current.width, height: current.height }) }),
|
|
84
|
+
/* @__PURE__ */ jsxs("figcaption", { className: "uix-lightbox__bar", children: [
|
|
85
|
+
/* @__PURE__ */ jsx("span", { className: "uix-lightbox__counter", "aria-live": "polite", children: fillLabel(labels.counter, { index: index + 1, total }) }),
|
|
86
|
+
current.caption != null && /* @__PURE__ */ jsx("span", { className: "uix-lightbox__caption", children: current.caption }),
|
|
87
|
+
current.kind !== "video" && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-lightbox__tool", "aria-pressed": zoomed, onClick: () => setZoomed((z) => !z), children: zoomed ? labels.zoomOut : labels.zoomIn })
|
|
88
|
+
] })
|
|
89
|
+
] }),
|
|
90
|
+
total > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
91
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "uix-lightbox__nav uix-lightbox__nav--prev", "aria-label": labels.previous, disabled: !hasPrev, onClick: () => go(index - 1), children: /* @__PURE__ */ jsx(Chevron, { dir: "left" }) }),
|
|
92
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "uix-lightbox__nav uix-lightbox__nav--next", "aria-label": labels.next, disabled: !hasNext, onClick: () => go(index + 1), children: /* @__PURE__ */ jsx(Chevron, { dir: "right" }) })
|
|
93
|
+
] }),
|
|
94
|
+
/* @__PURE__ */ jsx("button", { ref: closeRef, type: "button", className: "uix-lightbox__close", "aria-label": labels.close, onClick: onClose, children: /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }) })
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
DEFAULT_LIGHTBOX_LABELS,
|
|
101
|
+
Lightbox
|
|
102
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ReactNode, HTMLAttributes, Ref } from 'react';
|
|
3
3
|
|
|
4
4
|
/** One result row. */
|
|
5
5
|
interface SearchSuggestOption {
|
|
@@ -74,6 +74,10 @@ interface SearchSuggestProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onCha
|
|
|
74
74
|
autoFocus?: boolean;
|
|
75
75
|
/** Form field name of the input. */
|
|
76
76
|
name?: string;
|
|
77
|
+
/** Id of the input itself (for a `<label for>` / `Field htmlFor`); `id` stays on the wrapper. */
|
|
78
|
+
inputId?: string;
|
|
79
|
+
/** `aria-describedby` of the input (hint and error text from a `Field`). */
|
|
80
|
+
inputDescribedBy?: string;
|
|
77
81
|
}
|
|
78
82
|
/**
|
|
79
83
|
* A search field with a suggestion list underneath: the "search and jump" pattern of Windows
|
|
@@ -82,6 +86,6 @@ interface SearchSuggestProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onCha
|
|
|
82
86
|
* (`aria-activedescendant`), Enter opens it (the first row when none is active), Escape closes the
|
|
83
87
|
* list and a second Escape clears the text.
|
|
84
88
|
*/
|
|
85
|
-
declare function SearchSuggest({ value, onValueChange, options, onSelect, label, placeholder, highlight, heading, headingAction, footer, loading, loadingLabel, empty, error, shortcutHint, clearLabel: clearLabelProp, status, size, open: openProp, strategy, onOpenChange, inputRef, autoFocus, name, className, onBlur, ...props }: SearchSuggestProps): react.JSX.Element;
|
|
89
|
+
declare function SearchSuggest({ value, onValueChange, options, onSelect, label, placeholder, highlight, heading, headingAction, footer, loading, loadingLabel, empty, error, shortcutHint, clearLabel: clearLabelProp, status, size, open: openProp, strategy, onOpenChange, inputRef, autoFocus, name, inputId, inputDescribedBy, className, onBlur, ...props }: SearchSuggestProps): react.JSX.Element;
|
|
86
90
|
|
|
87
91
|
export { SearchSuggest, type SearchSuggestAction, type SearchSuggestOption, type SearchSuggestProps };
|
|
@@ -43,6 +43,8 @@ function SearchSuggest({
|
|
|
43
43
|
inputRef,
|
|
44
44
|
autoFocus,
|
|
45
45
|
name,
|
|
46
|
+
inputId,
|
|
47
|
+
inputDescribedBy,
|
|
46
48
|
className,
|
|
47
49
|
onBlur,
|
|
48
50
|
...props
|
|
@@ -185,6 +187,8 @@ function SearchSuggest({
|
|
|
185
187
|
"input",
|
|
186
188
|
{
|
|
187
189
|
ref: fieldRef,
|
|
190
|
+
id: inputId,
|
|
191
|
+
"aria-describedby": inputDescribedBy,
|
|
188
192
|
className: "uix-input uix-search-suggest__input",
|
|
189
193
|
type: "text",
|
|
190
194
|
role: "combobox",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** File helpers shared by `FileUpload` and `Attachment` (HAR-984/985). Pure. */
|
|
2
|
+
/** "1.2 MB", "340 KB", "12 B" in the locale's number format (base 1000, like file managers). */
|
|
3
|
+
declare function formatFileSize(bytes: number, locale?: string): string;
|
|
4
|
+
/** Whether a file matches an `accept` list (".pdf,image/*,application/zip"). An empty list accepts all. */
|
|
5
|
+
declare function fileMatchesAccept(file: {
|
|
6
|
+
name: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
}, accept?: string): boolean;
|
|
9
|
+
type FileRejectionReason = 'type' | 'size' | 'count';
|
|
10
|
+
interface FileRejection<F = File> {
|
|
11
|
+
file: F;
|
|
12
|
+
reason: FileRejectionReason;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Splits picked files into accepted and rejected (wrong type, over `maxSize` bytes, or beyond
|
|
16
|
+
* `maxFiles` counting the `existing` ones). Order is kept.
|
|
17
|
+
*/
|
|
18
|
+
declare function partitionFiles<F extends {
|
|
19
|
+
name: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}>(files: readonly F[], { accept, maxSize, maxFiles, existing }: {
|
|
23
|
+
accept?: string;
|
|
24
|
+
maxSize?: number;
|
|
25
|
+
maxFiles?: number;
|
|
26
|
+
existing?: number;
|
|
27
|
+
}): {
|
|
28
|
+
accepted: F[];
|
|
29
|
+
rejected: FileRejection<F>[];
|
|
30
|
+
};
|
|
31
|
+
/** A coarse kind for a file icon. */
|
|
32
|
+
declare function fileKind(file: {
|
|
33
|
+
name: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
}): 'image' | 'video' | 'pdf' | 'sheet' | 'archive' | 'text' | 'other';
|
|
36
|
+
|
|
37
|
+
export { type FileRejection, type FileRejectionReason, fileKind, fileMatchesAccept, formatFileSize, partitionFiles };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function formatFileSize(bytes, locale) {
|
|
2
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
3
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
4
|
+
let value = bytes;
|
|
5
|
+
let unit = 0;
|
|
6
|
+
while (value >= 1e3 && unit < units.length - 1) {
|
|
7
|
+
value /= 1e3;
|
|
8
|
+
unit += 1;
|
|
9
|
+
}
|
|
10
|
+
const digits = unit === 0 || value >= 100 ? 0 : 1;
|
|
11
|
+
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(value)} ${units[unit]}`;
|
|
12
|
+
}
|
|
13
|
+
function fileMatchesAccept(file, accept) {
|
|
14
|
+
if (!accept || !accept.trim()) return true;
|
|
15
|
+
const name = file.name.toLowerCase();
|
|
16
|
+
const type = (file.type ?? "").toLowerCase();
|
|
17
|
+
return accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean).some((token) => {
|
|
18
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
19
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
20
|
+
return type === token;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function partitionFiles(files, { accept, maxSize, maxFiles, existing = 0 }) {
|
|
24
|
+
const accepted = [];
|
|
25
|
+
const rejected = [];
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
if (!fileMatchesAccept(file, accept)) rejected.push({ file, reason: "type" });
|
|
28
|
+
else if (maxSize !== void 0 && file.size > maxSize) rejected.push({ file, reason: "size" });
|
|
29
|
+
else if (maxFiles !== void 0 && existing + accepted.length >= maxFiles) rejected.push({ file, reason: "count" });
|
|
30
|
+
else accepted.push(file);
|
|
31
|
+
}
|
|
32
|
+
return { accepted, rejected };
|
|
33
|
+
}
|
|
34
|
+
function fileKind(file) {
|
|
35
|
+
const type = (file.type ?? "").toLowerCase();
|
|
36
|
+
const ext = file.name.toLowerCase().split(".").pop() ?? "";
|
|
37
|
+
if (type.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "webp", "svg", "heic"].includes(ext)) return "image";
|
|
38
|
+
if (type.startsWith("video/") || ["mp4", "mov", "webm"].includes(ext)) return "video";
|
|
39
|
+
if (type === "application/pdf" || ext === "pdf") return "pdf";
|
|
40
|
+
if (["xls", "xlsx", "csv", "ods"].includes(ext) || type.includes("spreadsheet") || type === "text/csv") return "sheet";
|
|
41
|
+
if (["zip", "7z", "gz", "tar", "rar"].includes(ext) || type.includes("zip")) return "archive";
|
|
42
|
+
if (type.startsWith("text/") || ["txt", "md", "log", "json", "xml", "doc", "docx", "odt", "rtf"].includes(ext)) return "text";
|
|
43
|
+
return "other";
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
fileKind,
|
|
47
|
+
fileMatchesAccept,
|
|
48
|
+
formatFileSize,
|
|
49
|
+
partitionFiles
|
|
50
|
+
};
|