@sincpro/web-ui 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/attachments/Attachments.d.ts +56 -0
- package/dist/components/attachments/Attachments.js +93 -0
- package/dist/components/attachments/files.d.ts +28 -0
- package/dist/components/attachments/files.js +100 -0
- package/dist/components/attachments/index.d.ts +2 -0
- package/dist/components/attachments/index.js +3 -0
- package/dist/components/flow/StepFlow.d.ts +37 -0
- package/dist/components/flow/StepFlow.js +74 -0
- package/dist/components/flow/index.d.ts +1 -0
- package/dist/components/flow/index.js +2 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/lines/LineItems.js +55 -45
- package/dist/components/listing/SortMenu.d.ts +24 -20
- package/dist/components/listing/SortMenu.js +51 -91
- package/dist/components/listing/index.d.ts +2 -1
- package/dist/components/listing/index.js +2 -1
- package/dist/components/listing/modelCells.d.ts +7 -0
- package/dist/components/listing/modelCells.js +56 -0
- package/dist/components/listing/modelColumns.d.ts +9 -0
- package/dist/components/listing/modelColumns.js +59 -14
- package/dist/components/search/index.d.ts +1 -1
- package/dist/components/search/index.js +1 -1
- package/dist/components/search/model.d.ts +1 -0
- package/dist/components/search/model.js +7 -0
- package/dist/components/search/query.js +2 -3
- package/dist/foundations/copy.d.ts +131 -0
- package/dist/foundations/copy.es.js +86 -0
- package/dist/foundations/copy.js +86 -0
- package/dist/foundations/form.d.ts +25 -0
- package/dist/foundations/form.js +27 -0
- package/dist/foundations/index.d.ts +1 -1
- package/dist/foundations/index.js +1 -1
- package/dist/primitives/button/Button.js +3 -1
- package/dist/primitives/choice/ComboBox.js +2 -2
- package/dist/primitives/choice/SearchField.js +2 -5
- package/dist/primitives/choice/SegmentedControl.js +2 -2
- package/dist/primitives/choice/Slider.js +2 -2
- package/dist/primitives/date/DatePicker.js +4 -4
- package/dist/primitives/date/DateTime.js +3 -3
- package/dist/primitives/field/CardNumberInput.d.ts +41 -0
- package/dist/primitives/field/CardNumberInput.js +117 -0
- package/dist/primitives/field/CodeInput.d.ts +18 -0
- package/dist/primitives/field/CodeInput.js +37 -0
- package/dist/primitives/field/ColorInput.d.ts +21 -0
- package/dist/primitives/field/ColorInput.js +42 -0
- package/dist/primitives/field/ContactInputs.d.ts +18 -0
- package/dist/primitives/field/ContactInputs.js +70 -0
- package/dist/primitives/field/DurationInput.d.ts +20 -0
- package/dist/primitives/field/DurationInput.js +54 -0
- package/dist/primitives/field/Field.d.ts +26 -5
- package/dist/primitives/field/Field.js +19 -6
- package/dist/primitives/field/ImageInput.d.ts +51 -0
- package/dist/primitives/field/ImageInput.js +267 -0
- package/dist/primitives/field/PasswordInput.d.ts +29 -0
- package/dist/primitives/field/PasswordInput.js +44 -0
- package/dist/primitives/field/PatternInput.d.ts +23 -0
- package/dist/primitives/field/PatternInput.js +48 -0
- package/dist/primitives/field/RichTextEditor.d.ts +18 -0
- package/dist/primitives/field/RichTextEditor.js +224 -0
- package/dist/primitives/field/RichTextInput.d.ts +30 -0
- package/dist/primitives/field/RichTextInput.js +38 -0
- package/dist/primitives/field/SignatureInput.d.ts +34 -0
- package/dist/primitives/field/SignatureInput.js +215 -0
- package/dist/primitives/field/Suggest.js +2 -2
- package/dist/primitives/field/card.d.ts +33 -0
- package/dist/primitives/field/card.js +173 -0
- package/dist/primitives/field/clipboard.js +3 -2
- package/dist/primitives/field/contact.d.ts +17 -0
- package/dist/primitives/field/contact.js +65 -0
- package/dist/primitives/field/duration.d.ts +5 -0
- package/dist/primitives/field/duration.js +48 -0
- package/dist/primitives/field/index.d.ts +14 -0
- package/dist/primitives/field/index.js +20 -0
- package/dist/primitives/field/mask.d.ts +33 -0
- package/dist/primitives/field/mask.js +56 -0
- package/dist/primitives/field/media.d.ts +99 -0
- package/dist/primitives/field/media.js +136 -0
- package/dist/primitives/field/pattern.d.ts +10 -0
- package/dist/primitives/field/pattern.js +68 -0
- package/dist/primitives/menu/Menu.d.ts +16 -0
- package/dist/primitives/menu/Menu.js +26 -8
- package/dist/primitives/menu/index.d.ts +1 -1
- package/dist/primitives/menu/index.js +1 -1
- package/dist/primitives/picker/Picker.js +2 -2
- package/dist/primitives/picker/TagsField.js +2 -2
- package/dist/primitives/table/DataTable.d.ts +6 -4
- package/dist/primitives/table/DataTable.js +12 -8
- package/dist/primitives/table/EditableTable.js +102 -99
- package/dist/primitives/table/Table.d.ts +5 -8
- package/dist/primitives/table/Table.js +63 -15
- package/dist/primitives/table/index.d.ts +2 -1
- package/dist/primitives/table/index.js +2 -1
- package/dist/primitives/table/sorting.d.ts +42 -0
- package/dist/primitives/table/sorting.js +110 -0
- package/dist/primitives/value/RichText.d.ts +10 -0
- package/dist/primitives/value/RichText.js +130 -0
- package/dist/primitives/value/Value.d.ts +20 -2
- package/dist/primitives/value/Value.js +26 -7
- package/dist/primitives/value/index.d.ts +3 -1
- package/dist/primitives/value/index.js +4 -1
- package/dist/primitives/value/rounding.d.ts +9 -0
- package/dist/primitives/value/rounding.js +48 -0
- package/dist/tokens.css +103 -0
- package/dist/views/list/List.d.ts +5 -4
- package/dist/views/list/ListView.js +2 -2
- package/dist/views/list/types.d.ts +6 -6
- package/package.json +9 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type FormMode } from "../../foundations/form.js";
|
|
2
|
+
export type AttachmentStatus = "ready" | "uploading" | "failed";
|
|
3
|
+
export type AttachmentFile = {
|
|
4
|
+
/** The attachment's identity: the server's id, or a local key while it uploads. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The file name as it was saved: «Factura proveedor 0418.pdf». */
|
|
7
|
+
name: string;
|
|
8
|
+
/** In bytes. Written in the copy's locale: «1,2 MB». */
|
|
9
|
+
size?: number;
|
|
10
|
+
/** The MIME type, when the server knows it. With the name, it picks the icon. */
|
|
11
|
+
type?: string;
|
|
12
|
+
/** Where it downloads from. Absent = no download (still uploading, or not allowed). */
|
|
13
|
+
url?: string;
|
|
14
|
+
/** A small image for the thumbnail. Defaults to `url` for an image — pass a smaller one
|
|
15
|
+
* when the server has it, so a list of 4 MB photos does not load 4 MB photos. */
|
|
16
|
+
thumbnail?: string;
|
|
17
|
+
/** Who added it. */
|
|
18
|
+
by?: string;
|
|
19
|
+
/** Their face, for the chip. */
|
|
20
|
+
byAvatar?: string;
|
|
21
|
+
/** When it was added. */
|
|
22
|
+
at?: Date | string | number;
|
|
23
|
+
/** `ready` by default. The application owns it: it is what the upload is really doing. */
|
|
24
|
+
status?: AttachmentStatus;
|
|
25
|
+
/** 0 to 100 while `uploading`. Absent = under way without a figure yet. */
|
|
26
|
+
progress?: number;
|
|
27
|
+
/** Why it `failed`, in the server's words. */
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
export type AttachmentsProps = {
|
|
31
|
+
files: readonly AttachmentFile[];
|
|
32
|
+
/** Files to add: dropped, pasted or chosen. Present = the drop zone is drawn (in edit
|
|
33
|
+
* mode). The application uploads them and puts them in `files` as `uploading`. */
|
|
34
|
+
onAdd?: (files: File[]) => void;
|
|
35
|
+
/** A file was removed. It is gone at once; the notice offers to undo it. */
|
|
36
|
+
onRemove?: (file: AttachmentFile) => void;
|
|
37
|
+
/** Put a removed file back where it was. Without it the notice has no «Deshacer». */
|
|
38
|
+
onRestore?: (file: AttachmentFile, index: number) => void;
|
|
39
|
+
/** Open the file. Present = its name is a button; WHERE it opens is the application's. */
|
|
40
|
+
onOpen?: (file: AttachmentFile) => void;
|
|
41
|
+
/** Try a failed upload again. Present = a failed file offers «Reintentar». */
|
|
42
|
+
onRetry?: (file: AttachmentFile) => void;
|
|
43
|
+
/** The browser's `accept` list for the file dialog (`".pdf,image/*"`). */
|
|
44
|
+
accept?: string;
|
|
45
|
+
/** `read` for a record that can no longer change. By default, the form's mode. */
|
|
46
|
+
mode?: FormMode;
|
|
47
|
+
/** The accessible name of the list. «Adjuntos» by default. */
|
|
48
|
+
label?: string;
|
|
49
|
+
/** What an empty list says, when the screen can say something closer to its domain:
|
|
50
|
+
* «Esta compra todavía no tiene la factura del proveedor». */
|
|
51
|
+
empty?: string;
|
|
52
|
+
/** The «now» the dates are relative to — so a story or a test does not change its
|
|
53
|
+
* text every minute. */
|
|
54
|
+
now?: Date;
|
|
55
|
+
};
|
|
56
|
+
export declare function Attachments({ files, onAdd, onRemove, onRestore, onOpen, onRetry, accept, mode, label, empty, now, }: AttachmentsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { attachmentKind, fileExtension, formatFileSize, } from "./files.js";
|
|
4
|
+
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
5
|
+
import { useFormMode } from "../../foundations/form.js";
|
|
6
|
+
import { Button, IconButton, IconLink } from "../../primitives/button/Button.js";
|
|
7
|
+
import { DropZone } from "../../primitives/field/DropZone.js";
|
|
8
|
+
import { Empty } from "../../primitives/notice/Notice.js";
|
|
9
|
+
import { useToastIfAny } from "../../primitives/notice/Toast.js";
|
|
10
|
+
import { DateValue } from "../../primitives/value/DateValue.js";
|
|
11
|
+
import { UserChip } from "../../primitives/value/Person.js";
|
|
12
|
+
import { ProgressBar } from "../../primitives/value/ProgressBar.js";
|
|
13
|
+
import { Download, File, FileImage, FileSpreadsheet, FileText, FileType, RotateCcw, Trash2, TriangleAlert, X, } from "lucide-react";
|
|
14
|
+
import { useEffect, useId, useRef } from "react";
|
|
15
|
+
const KIND_ICON = {
|
|
16
|
+
image: FileImage,
|
|
17
|
+
pdf: FileText,
|
|
18
|
+
sheet: FileSpreadsheet,
|
|
19
|
+
doc: FileType,
|
|
20
|
+
other: File,
|
|
21
|
+
};
|
|
22
|
+
/** The type, at the size of a row: the image itself when there is one, otherwise the icon
|
|
23
|
+
* of its kind over its extension. The extension is written because three of the five
|
|
24
|
+
* icons are a page with lines on it — «PDF» and «DOCX» are what tell them apart, and a
|
|
25
|
+
* word is never a colour that only some people see. */
|
|
26
|
+
function Thumb({ file, kind }) {
|
|
27
|
+
const picture = kind === "image" && file.status !== "failed" ? (file.thumbnail ?? file.url) : undefined;
|
|
28
|
+
const Icon = KIND_ICON[kind];
|
|
29
|
+
const extension = fileExtension(file.name).slice(0, 4);
|
|
30
|
+
return (
|
|
31
|
+
/* Decorative: the name next to it says which file it is, and the image's `alt` would
|
|
32
|
+
say it twice. */
|
|
33
|
+
_jsx("span", { "aria-hidden": true, className: "flex size-(--size-control-lg) shrink-0 flex-col items-center justify-center gap-0.5 overflow-clip rounded-control border border-line bg-ink/4 text-muted", children: picture === undefined ? (_jsxs(_Fragment, { children: [_jsx(Icon, { className: "size-(--size-icon-md)", strokeWidth: 1.75 }), extension === "" ? null : (_jsx("span", { className: "text-micro leading-none font-medium uppercase", children: extension }))] })) : (_jsx("img", { src: picture, alt: "", loading: "lazy", className: "size-full object-cover" })) }));
|
|
34
|
+
}
|
|
35
|
+
export function Attachments({ files, onAdd, onRemove, onRestore, onOpen, onRetry, accept, mode, label, empty, now, }) {
|
|
36
|
+
const copy = useUiCopy();
|
|
37
|
+
const toast = useToastIfAny();
|
|
38
|
+
const editable = useFormMode(mode) !== "read";
|
|
39
|
+
const base = useId();
|
|
40
|
+
const dropId = `${base}-drop`;
|
|
41
|
+
const listRef = useRef(null);
|
|
42
|
+
/* Where the focus goes once the removed file has left `files`: a file's id, or `null`
|
|
43
|
+
for the drop zone. Set on remove, spent when the list the application answers with
|
|
44
|
+
is drawn — not before, or it would land on the row that is about to disappear. */
|
|
45
|
+
const focusNext = useRef(undefined);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const target = focusNext.current;
|
|
48
|
+
if (target === undefined)
|
|
49
|
+
return;
|
|
50
|
+
focusNext.current = undefined;
|
|
51
|
+
if (target === null) {
|
|
52
|
+
document.getElementById(dropId)?.focus();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const row = [
|
|
56
|
+
...(listRef.current?.querySelectorAll("[data-attachment-id]") ?? []),
|
|
57
|
+
].find((one) => one.dataset["attachmentId"] === target);
|
|
58
|
+
(row?.querySelector("[data-attachment-remove]") ??
|
|
59
|
+
row?.querySelector("[data-attachment-name]"))?.focus();
|
|
60
|
+
}, [files, dropId]);
|
|
61
|
+
const remove = (file, index) => {
|
|
62
|
+
const neighbour = files[index + 1] ?? files[index - 1];
|
|
63
|
+
focusNext.current =
|
|
64
|
+
neighbour === undefined ? (onAdd === undefined ? undefined : null) : neighbour.id;
|
|
65
|
+
onRemove?.(file);
|
|
66
|
+
/* Only what was stored can be undone: a cancelled upload was never there. */
|
|
67
|
+
if ((file.status ?? "ready") !== "ready")
|
|
68
|
+
return;
|
|
69
|
+
toast?.show({
|
|
70
|
+
message: fillCopy(copy.attachments.removed, { name: file.name }),
|
|
71
|
+
...(onRestore === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: {
|
|
74
|
+
action: {
|
|
75
|
+
label: copy.attachments.undo,
|
|
76
|
+
onSelect: () => onRestore(file, index),
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const adding = editable && onAdd !== undefined;
|
|
82
|
+
return (_jsxs("div", { className: "flex min-w-0 flex-col gap-3", children: [files.length === 0 ? (adding ? null : (_jsx(Empty, { sentence: empty ?? copy.attachments.empty, align: "start" }))) : (_jsx("ul", { ref: listRef, "aria-label": label ?? copy.attachments.label, className: "flex min-w-0 flex-col divide-y divide-line", children: files.map((file, index) => {
|
|
83
|
+
const status = file.status ?? "ready";
|
|
84
|
+
const nameId = `${base}-${index}-name`;
|
|
85
|
+
const detailId = `${base}-${index}-detail`;
|
|
86
|
+
const kind = attachmentKind(file.name, file.type);
|
|
87
|
+
return (_jsxs("li", { "data-attachment-id": file.id, "data-status": status, className: "flex min-w-0 items-center gap-3 py-2", children: [_jsx(Thumb, { file: file, kind: kind }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [onOpen === undefined || status !== "ready" ? (_jsx("span", { id: nameId, title: file.name, className: "max-w-full truncate text-body font-medium text-ink", children: file.name })) : (_jsx("button", { type: "button", id: nameId, title: file.name, "data-attachment-name": true, "aria-describedby": detailId, onClick: () => onOpen(file), className: "max-w-full cursor-pointer truncate rounded-control text-left text-body font-medium text-ink outline-primary outline-offset-2 hover:underline hover:underline-offset-4 focus-visible:outline-2 forced-colors:outline-[Highlight]", children: file.name })), status === "uploading" ? (_jsx("div", { className: "w-full", children: _jsx(ProgressBar, { label: fillCopy(copy.attachments.uploading, { name: file.name }), width: "w-full", ...(file.progress === undefined ? {} : { value: file.progress }) }) })) : status === "failed" ? (
|
|
88
|
+
/* The icon AND the words: red alone is a colour some people do not see. */
|
|
89
|
+
_jsxs("p", { id: detailId, role: "alert", className: "flex min-w-0 items-start gap-1.5 text-meta text-bad", children: [_jsx(TriangleAlert, { "aria-hidden": true, className: "mt-px size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "min-w-0", children: file.error ?? copy.attachments.failed })] })) : (_jsxs("p", { id: detailId, className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted", children: [file.size === undefined ? null : (_jsx("span", { className: "tabular-nums whitespace-nowrap", children: formatFileSize(file.size, copy.locale) })), file.by === undefined ? null : (_jsx(UserChip, { name: file.by, size: "sm", ...(file.byAvatar === undefined ? {} : { src: file.byAvatar }) })), file.at === undefined ? null : (_jsx(DateValue, { when: file.at, ...(now === undefined ? {} : { now }) }))] }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [status === "failed" && onRetry !== undefined ? (_jsx(Button, { role: "ghost", size: "sm", icon: RotateCcw, "aria-describedby": nameId, onClick: () => onRetry(file), children: copy.attachments.retry })) : null, status === "ready" && file.url !== undefined ? (_jsx(IconLink, { icon: Download, label: copy.attachments.download, href: file.url, download: file.name, tone: "normal", "aria-describedby": nameId })) : null, editable && onRemove !== undefined ? (_jsx(IconButton, { icon: status === "ready" ? Trash2 : X, label: status === "uploading"
|
|
90
|
+
? copy.attachments.cancelUpload
|
|
91
|
+
: copy.attachments.remove, tone: status === "ready" ? "danger" : "normal", "data-attachment-remove": true, "aria-describedby": nameId, onClick: () => remove(file, index) })) : null] })] }, file.id));
|
|
92
|
+
}) })), adding ? (_jsx(DropZone, { id: dropId, mode: "edit", label: files.length === 0 ? (empty ?? copy.attachments.emptyAdd) : copy.attachments.add, ...(accept === undefined ? {} : { accept }), onFile: (file) => onAdd([file]) })) : null] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The five kinds a list of attachments tells apart. Five and not twenty: the icon is there
|
|
2
|
+
* to say «this one is the spreadsheet» at a glance, and twenty icons are read, not seen. */
|
|
3
|
+
export type AttachmentKind = "image" | "pdf" | "sheet" | "doc" | "other";
|
|
4
|
+
/** The extension of a file name, lower-case, without the dot — `""` when it has none. A
|
|
5
|
+
* leading dot (`.env`) is a hidden file, not an extension. */
|
|
6
|
+
export declare function fileExtension(name: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* The kind of a file, from its MIME type and, failing that, its name.
|
|
9
|
+
*
|
|
10
|
+
* Context: the MIME type wins because it is what the server READ; the extension is what
|
|
11
|
+
* somebody typed. But a server that does not sniff answers `application/octet-stream` for
|
|
12
|
+
* everything, and then the name is all there is — so an unknown or generic type falls
|
|
13
|
+
* through to the extension instead of to «other».
|
|
14
|
+
*/
|
|
15
|
+
export declare function attachmentKind(name: string, type?: string): AttachmentKind;
|
|
16
|
+
/**
|
|
17
|
+
* A size in bytes as a person reads it: «512 B», «34 KB», «1,2 MB» — in the copy's locale.
|
|
18
|
+
*
|
|
19
|
+
* Context: one decimal below ten and none above. «1,2 MB» and «1,9 MB» are different
|
|
20
|
+
* files; «123,4 MB» and «123,9 MB» are the same one, and the decimal is noise. Steps of 1024,
|
|
21
|
+
* the way the operating system that saved the file counts it — a file that says «1 MB» here
|
|
22
|
+
* and «1,05 MB» in the folder it came from reads as a different file.
|
|
23
|
+
*
|
|
24
|
+
* The number is `Intl`'s and the unit is written here: `Intl`'s own byte units change with
|
|
25
|
+
* the engine («byte», «kB», «B») and a column of sizes that writes its unit three ways is
|
|
26
|
+
* worse than any of the three.
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatFileSize(bytes: number, locale: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* ==================================================================== *
|
|
2
|
+
* FILES — what a file IS, read from what the server says about it: its kind (for the icon
|
|
3
|
+
* and the thumbnail) and its size (for a person, not for a disk).
|
|
4
|
+
*
|
|
5
|
+
* Pure on purpose: the list draws with it, and a test checks it without a browser.
|
|
6
|
+
* ==================================================================== */
|
|
7
|
+
const BY_EXTENSION = {
|
|
8
|
+
png: "image",
|
|
9
|
+
jpg: "image",
|
|
10
|
+
jpeg: "image",
|
|
11
|
+
gif: "image",
|
|
12
|
+
webp: "image",
|
|
13
|
+
svg: "image",
|
|
14
|
+
bmp: "image",
|
|
15
|
+
avif: "image",
|
|
16
|
+
heic: "image",
|
|
17
|
+
pdf: "pdf",
|
|
18
|
+
xlsx: "sheet",
|
|
19
|
+
xls: "sheet",
|
|
20
|
+
xlsm: "sheet",
|
|
21
|
+
ods: "sheet",
|
|
22
|
+
csv: "sheet",
|
|
23
|
+
docx: "doc",
|
|
24
|
+
doc: "doc",
|
|
25
|
+
odt: "doc",
|
|
26
|
+
rtf: "doc",
|
|
27
|
+
txt: "doc",
|
|
28
|
+
md: "doc",
|
|
29
|
+
};
|
|
30
|
+
/** The extension of a file name, lower-case, without the dot — `""` when it has none. A
|
|
31
|
+
* leading dot (`.env`) is a hidden file, not an extension. */
|
|
32
|
+
export function fileExtension(name) {
|
|
33
|
+
const dot = name.lastIndexOf(".");
|
|
34
|
+
if (dot <= 0 || dot === name.length - 1)
|
|
35
|
+
return "";
|
|
36
|
+
return name.slice(dot + 1).toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The kind of a file, from its MIME type and, failing that, its name.
|
|
40
|
+
*
|
|
41
|
+
* Context: the MIME type wins because it is what the server READ; the extension is what
|
|
42
|
+
* somebody typed. But a server that does not sniff answers `application/octet-stream` for
|
|
43
|
+
* everything, and then the name is all there is — so an unknown or generic type falls
|
|
44
|
+
* through to the extension instead of to «other».
|
|
45
|
+
*/
|
|
46
|
+
export function attachmentKind(name, type) {
|
|
47
|
+
const mime = (type ?? "").toLowerCase();
|
|
48
|
+
if (mime.startsWith("image/"))
|
|
49
|
+
return "image";
|
|
50
|
+
if (mime === "application/pdf")
|
|
51
|
+
return "pdf";
|
|
52
|
+
if (mime.includes("spreadsheet") || mime.includes("ms-excel") || mime === "text/csv") {
|
|
53
|
+
return "sheet";
|
|
54
|
+
}
|
|
55
|
+
if (mime.includes("wordprocessing") ||
|
|
56
|
+
mime.includes("msword") ||
|
|
57
|
+
mime.includes("opendocument.text") ||
|
|
58
|
+
mime === "text/plain" ||
|
|
59
|
+
mime === "text/markdown") {
|
|
60
|
+
return "doc";
|
|
61
|
+
}
|
|
62
|
+
return BY_EXTENSION[fileExtension(name)] ?? "other";
|
|
63
|
+
}
|
|
64
|
+
const UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
65
|
+
/* A no-break space: «1,2» on one line and «MB» on the next reads as two values. */
|
|
66
|
+
const NO_BREAK = String.fromCharCode(0xa0);
|
|
67
|
+
const STEP = 1024;
|
|
68
|
+
/**
|
|
69
|
+
* A size in bytes as a person reads it: «512 B», «34 KB», «1,2 MB» — in the copy's locale.
|
|
70
|
+
*
|
|
71
|
+
* Context: one decimal below ten and none above. «1,2 MB» and «1,9 MB» are different
|
|
72
|
+
* files; «123,4 MB» and «123,9 MB» are the same one, and the decimal is noise. Steps of 1024,
|
|
73
|
+
* the way the operating system that saved the file counts it — a file that says «1 MB» here
|
|
74
|
+
* and «1,05 MB» in the folder it came from reads as a different file.
|
|
75
|
+
*
|
|
76
|
+
* The number is `Intl`'s and the unit is written here: `Intl`'s own byte units change with
|
|
77
|
+
* the engine («byte», «kB», «B») and a column of sizes that writes its unit three ways is
|
|
78
|
+
* worse than any of the three.
|
|
79
|
+
*/
|
|
80
|
+
export function formatFileSize(bytes, locale) {
|
|
81
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
82
|
+
return "";
|
|
83
|
+
let value = bytes;
|
|
84
|
+
let unit = 0;
|
|
85
|
+
while (value >= STEP && unit < UNITS.length - 1) {
|
|
86
|
+
value /= STEP;
|
|
87
|
+
unit += 1;
|
|
88
|
+
}
|
|
89
|
+
const digits = unit === 0 || value >= 10 ? 0 : 1;
|
|
90
|
+
/* 1023,96 KB rounds to «1.024 KB»: that is the next unit, written in the wrong one. */
|
|
91
|
+
const rounded = Number(value.toFixed(digits));
|
|
92
|
+
if (rounded >= STEP && unit < UNITS.length - 1) {
|
|
93
|
+
return formatFileSize(STEP ** (unit + 1), locale);
|
|
94
|
+
}
|
|
95
|
+
const number = new Intl.NumberFormat(locale, {
|
|
96
|
+
maximumFractionDigits: digits,
|
|
97
|
+
minimumFractionDigits: 0,
|
|
98
|
+
}).format(rounded);
|
|
99
|
+
return `${number}${NO_BREAK}${UNITS[unit]}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type StepFlowStepProps = {
|
|
3
|
+
/** Who the step is: what `current` and `onCurrentChange` speak. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** The step's name: on the strip and as the heading of its contents. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** One sentence under the title: what this step asks for. */
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Why it cannot move on yet. Present = «Siguiente» (or the finish) is off and this is
|
|
10
|
+
* written next to it. Absent = the step is answered. */
|
|
11
|
+
blocked?: string;
|
|
12
|
+
/** What the forward button says from this step. On the last step it names what is going
|
|
13
|
+
* to happen — «Importar 40 contactos» — instead of the copy's «Terminar». */
|
|
14
|
+
nextLabel?: string;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
export type StepFlowProps = {
|
|
18
|
+
/** The current step's id, controlled. With `onCurrentChange`, the screen owns the step:
|
|
19
|
+
* it can keep it in the URL, or jump back to the step that has the problem. */
|
|
20
|
+
current?: string;
|
|
21
|
+
/** Where an uncontrolled flow starts. The first step when absent. */
|
|
22
|
+
defaultCurrent?: string;
|
|
23
|
+
onCurrentChange?: (id: string) => void;
|
|
24
|
+
/** The forward button on the last step: the operation itself. */
|
|
25
|
+
onFinish: () => void;
|
|
26
|
+
/** Leave without doing anything. Without it there is no «Cancelar» — the surface that
|
|
27
|
+
* opened the flow may already have its own way out (the composer modal's ×). */
|
|
28
|
+
onCancel?: () => void;
|
|
29
|
+
/** The operation is running: the primary is busy, the way back is off. */
|
|
30
|
+
busy?: boolean;
|
|
31
|
+
/** `StepFlow.Step`, in order. */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
};
|
|
34
|
+
export declare function StepFlow({ current: controlled, defaultCurrent, onCurrentChange, onFinish, onCancel, busy, children, }: StepFlowProps): import("react").JSX.Element | null;
|
|
35
|
+
export declare namespace StepFlow {
|
|
36
|
+
var Step: ((props: StepFlowStepProps) => ReactNode) & import("../../foundations/slots.js").Slotted<"step">;
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { flatten, propsOf, slot, slotOf } from "../../foundations/slots.js";
|
|
5
|
+
import { Button } from "../../primitives/button/Button.js";
|
|
6
|
+
import { Check, ChevronRight, CircleAlert } from "lucide-react";
|
|
7
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
8
|
+
const Step = slot("step", (_props) => null);
|
|
9
|
+
/** The steps as written, in order. */
|
|
10
|
+
function readSteps(children) {
|
|
11
|
+
return flatten(children)
|
|
12
|
+
.filter((node) => slotOf(node) === "step")
|
|
13
|
+
.map((node) => propsOf(node));
|
|
14
|
+
}
|
|
15
|
+
const DISC = {
|
|
16
|
+
done: "bg-good/12 text-good",
|
|
17
|
+
current: "bg-ink text-surface",
|
|
18
|
+
pending: "bg-ink/8 text-muted",
|
|
19
|
+
};
|
|
20
|
+
export function StepFlow({ current: controlled, defaultCurrent, onCurrentChange, onFinish, onCancel, busy = false, children, }) {
|
|
21
|
+
const words = useUiCopy().stepFlow;
|
|
22
|
+
const steps = readSteps(children);
|
|
23
|
+
const [own, setOwn] = useState(defaultCurrent);
|
|
24
|
+
const wanted = controlled ?? own;
|
|
25
|
+
/* An id that is not (or no longer) a step falls back to the first: a flow that draws
|
|
26
|
+
nothing because a step was renamed is a blank modal with no way out. */
|
|
27
|
+
const found = steps.findIndex((s) => s.id === wanted);
|
|
28
|
+
const at = found === -1 ? 0 : found;
|
|
29
|
+
const step = steps[at];
|
|
30
|
+
const currentId = step?.id;
|
|
31
|
+
const headingRef = useRef(null);
|
|
32
|
+
const shown = useRef(currentId);
|
|
33
|
+
const headingId = useId();
|
|
34
|
+
const blockedId = useId();
|
|
35
|
+
/* The focus follows the step — but not on the first render, and not on StrictMode's
|
|
36
|
+
second run of the same one: `shown` only differs after a real move. */
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (shown.current === currentId)
|
|
39
|
+
return;
|
|
40
|
+
shown.current = currentId;
|
|
41
|
+
headingRef.current?.focus();
|
|
42
|
+
}, [currentId]);
|
|
43
|
+
if (step === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
const last = at === steps.length - 1;
|
|
46
|
+
const total = steps.length;
|
|
47
|
+
const go = (id) => {
|
|
48
|
+
if (controlled === undefined)
|
|
49
|
+
setOwn(id);
|
|
50
|
+
onCurrentChange?.(id);
|
|
51
|
+
};
|
|
52
|
+
const forward = () => {
|
|
53
|
+
if (busy || step.blocked !== undefined)
|
|
54
|
+
return;
|
|
55
|
+
if (last)
|
|
56
|
+
onFinish();
|
|
57
|
+
else
|
|
58
|
+
go(steps[at + 1].id);
|
|
59
|
+
};
|
|
60
|
+
const position = fillCopy(words.position, { index: at + 1, total });
|
|
61
|
+
return (
|
|
62
|
+
/* `h-full`: the composer modal's body is a block with a height, not a flex column —
|
|
63
|
+
filling it is what keeps the footer in sight while the step scrolls. */
|
|
64
|
+
_jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-1 flex-col bg-surface text-ink", children: [_jsx("ol", { "aria-label": words.steps, className: "flex min-w-0 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-line px-4 py-2.5", children: steps.map((s, i) => {
|
|
65
|
+
const state = i < at ? "done" : i === at ? "current" : "pending";
|
|
66
|
+
const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: `inline-flex size-5 shrink-0 items-center justify-center rounded-pill text-micro font-medium tabular-nums ${DISC[state]}`, children: state === "done" ? (_jsx(Check, { className: "size-(--size-icon-sm)", strokeWidth: 2.5 })) : (i + 1) }), _jsx("span", { className: "min-w-0 truncate", children: s.title }), state === "current" ? null : (_jsx("span", { className: "sr-only", children: ` (${state === "done" ? words.done : words.pending})` }))] }));
|
|
67
|
+
return (_jsxs("li", { className: "flex min-w-0 items-center gap-1", children: [i === 0 ? null : (_jsx(ChevronRight, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0 text-muted" })), state === "done" && !busy ? (_jsx("button", { type: "button", onClick: () => go(s.id), className: "inline-flex min-w-0 items-center gap-1.5 rounded-control px-1.5 py-1 text-micro text-ink transition-colors duration-(--duration-instant) hover:bg-ink/6 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: content })) : (_jsx("span", { "aria-current": state === "current" ? "step" : undefined, className: `inline-flex min-w-0 items-center gap-1.5 px-1.5 py-1 text-micro ${state === "current"
|
|
68
|
+
? "font-medium text-ink"
|
|
69
|
+
: state === "done"
|
|
70
|
+
? "text-ink"
|
|
71
|
+
: "text-muted"}`, children: content }))] }, s.id));
|
|
72
|
+
}) }), _jsx("p", { role: "status", className: "sr-only", children: busy ? words.working : fillCopy(words.announce, { position, title: step.title }) }), _jsxs("section", { "aria-labelledby": headingId, className: "min-h-0 min-w-0 flex-1 overflow-y-auto px-4 py-4", children: [_jsx("p", { className: "text-micro text-muted tabular-nums", children: position }), _jsx("h2", { id: headingId, ref: headingRef, tabIndex: -1, className: "text-title font-semibold text-ink outline-0", children: step.title }), step.description === undefined ? null : (_jsx("p", { className: "mt-1 max-w-prose text-meta text-muted", children: step.description })), _jsx("div", { className: "mt-4 flex min-w-0 flex-col gap-3", children: step.children })] }), _jsxs("footer", { className: "flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-2 border-t border-line bg-ground px-4 py-3 max-sm:safe-bottom", children: [step.blocked === undefined ? null : (_jsxs("p", { id: blockedId, className: "mr-auto flex min-w-0 items-center gap-1.5 text-micro text-warn", children: [_jsx(CircleAlert, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0" }), _jsx("span", { className: "min-w-0", children: step.blocked })] })), onCancel === undefined ? null : (_jsx(Button, { role: "ghost", onClick: onCancel, disabled: busy, children: words.cancel })), at === 0 ? null : (_jsx(Button, { role: "secondary", onClick: () => go(steps[at - 1].id), disabled: busy, children: words.back })), _jsx(Button, { role: "primary", onClick: forward, busy: busy, disabled: step.blocked !== undefined, ...(step.blocked === undefined ? {} : { "aria-describedby": blockedId }), children: step.nextLabel ?? (last ? words.finish : words.next) })] })] }));
|
|
73
|
+
}
|
|
74
|
+
StepFlow.Step = Step;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StepFlow, type StepFlowProps, type StepFlowStepProps, } from "./StepFlow.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./activity/index.js";
|
|
2
2
|
export * from "./assist/index.js";
|
|
3
|
+
export * from "./attachments/index.js";
|
|
3
4
|
export * from "./calendar/index.js";
|
|
5
|
+
export * from "./flow/index.js";
|
|
4
6
|
export * from "./form/index.js";
|
|
5
7
|
export * from "./frame/index.js";
|
|
6
8
|
export * from "./gantt/index.js";
|
package/dist/components/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* COMPONENTS — primitives composed to answer ONE question of the product, by family. */
|
|
2
2
|
export * from "./activity/index.js";
|
|
3
3
|
export * from "./assist/index.js";
|
|
4
|
+
export * from "./attachments/index.js";
|
|
4
5
|
export * from "./calendar/index.js";
|
|
6
|
+
export * from "./flow/index.js";
|
|
5
7
|
export * from "./form/index.js";
|
|
6
8
|
export * from "./frame/index.js";
|
|
7
9
|
export * from "./gantt/index.js";
|
|
@@ -4,7 +4,7 @@ import { commitDraft, filterLines, planRemoval, } from "./lines.js";
|
|
|
4
4
|
import { useColumnControl, } from "../listing/columns.js";
|
|
5
5
|
import { ColumnsMenu } from "../listing/ColumnsMenu.js";
|
|
6
6
|
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
7
|
-
import { FieldIdsContext } from "../../foundations/form.js";
|
|
7
|
+
import { FieldBoundary, FieldIdsContext } from "../../foundations/form.js";
|
|
8
8
|
import { useFormMode } from "../../foundations/form.js";
|
|
9
9
|
import { Button, IconButton } from "../../primitives/button/Button.js";
|
|
10
10
|
import { SearchField } from "../../primitives/choice/SearchField.js";
|
|
@@ -228,7 +228,14 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
|
|
|
228
228
|
...(c.stack === undefined ? {} : { stack: c.stack }),
|
|
229
229
|
...(c.brief === undefined ? {} : { brief: c.brief }),
|
|
230
230
|
render: (line) => {
|
|
231
|
-
|
|
231
|
+
/* `cell` draws it; without one, `text` is how it READS — the raw `value` is what the
|
|
232
|
+
search and the sort compare («3» is a unit id; «caja ×12» is what it says). The
|
|
233
|
+
same order `DataTable` follows. */
|
|
234
|
+
const drawn = c.cell !== undefined
|
|
235
|
+
? c.cell(line)
|
|
236
|
+
: c.text !== undefined
|
|
237
|
+
? c.text(line)
|
|
238
|
+
: plain(c.value(line));
|
|
232
239
|
/* An amount never breaks in two: «Bs.» over «12.500,00» reads as two values. */
|
|
233
240
|
return c.numeric === true ? _jsx("span", { className: "whitespace-nowrap", children: drawn }) : drawn;
|
|
234
241
|
},
|
|
@@ -273,47 +280,50 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
|
|
|
273
280
|
shown: shown.length.toLocaleString(copy.locale),
|
|
274
281
|
count: lineCount.toLocaleString(copy.locale),
|
|
275
282
|
});
|
|
276
|
-
return (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
283
|
+
return (
|
|
284
|
+
/* A collection is many controls, each named by its column: no enclosing `Field`
|
|
285
|
+
reaches inside (see `FieldBoundary`). */
|
|
286
|
+
_jsx(FieldBoundary, { children: _jsxs("section", { ref: root, "aria-label": copy.lineItems.table, className: "flex min-w-0 flex-col gap-4", children: [lines.length === 0 && !canAdd ? (
|
|
287
|
+
/* Said, with its way out: an empty document is a question («what is sold
|
|
288
|
+
here?»), not a blank box. In read mode there is no way out to offer. */
|
|
289
|
+
_jsx(Empty, { align: "start", sentence: copy.lines.empty, action: addButton ?? undefined })) : (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 px-1", children: [searchable ? (_jsxs("div", { className: "w-full max-w-(--container-search) min-w-0 flex-1", children: [_jsx("span", { id: searchLabelId, className: "sr-only", children: copy.lines.search }), _jsx(FieldIdsContext.Provider, { value: {
|
|
290
|
+
id: `${searchLabelId}-input`,
|
|
291
|
+
labelId: searchLabelId,
|
|
292
|
+
describedBy: undefined,
|
|
293
|
+
invalid: false,
|
|
294
|
+
}, children: _jsx(SearchField, { value: query, onChange: setQuery, placeholder: copy.lines.search }) })] })) : null, _jsx("span", { className: "text-meta text-muted tabular-nums", "aria-live": "polite", children: count }), columnControl.hasOptional || addButton !== null ? (_jsxs("span", { className: "ml-auto flex items-center gap-2", children: [_jsx(ColumnsMenu, { control: columnControl }), addButton] })) : null] }), _jsx(EditableTable, { rows: shown, columns: tableColumns, getRowId: getLineId, empty: lines.length === 0
|
|
295
|
+
? copy.lines.empty
|
|
296
|
+
: fillCopy(copy.lineItems.noMatch, { query: query.trim() }), layout: layout, ...(inline ? { onAdd: () => addInline(), addLabel: copy.lines.add } : {}), ...(bandAdds.length === 0 ? {} : { adds: bandAdds }), ...(bands === undefined ? {} : { bands }), ...(flag === undefined ? {} : { flag }), ...(cellState === undefined ? {} : { cellState }), ...(rowState === undefined ? {} : { rowState }), ...(onReorder === undefined || read ? {} : { onReorder }), ...(inPlace && onRemove !== undefined
|
|
297
|
+
? /* A blank row nobody wrote in is taken back without a notice: nobody
|
|
298
|
+
removed anything, so there is nothing to undo. */
|
|
299
|
+
{ onDiscard: onRemove }
|
|
300
|
+
: {}), ...(canEdit
|
|
301
|
+
? {
|
|
302
|
+
onOpen: (id) => {
|
|
303
|
+
const line = lines.find((l) => getLineId(l) === id);
|
|
304
|
+
/* A read-only line opens no form to change it. */
|
|
305
|
+
if (line !== undefined && rowState?.(line)?.readonly !== true) {
|
|
306
|
+
openLine(id, line);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: {}), ...(canChange && columns.some((c) => c.edit !== undefined)
|
|
311
|
+
? { onChange: (id, next) => onChange?.(id, next) }
|
|
312
|
+
: {}) })] })), totals === undefined || ("length" in totals && totals.length === 0) ? null : (
|
|
313
|
+
/* Inset like the table's cells, so the last figure lines up with the amounts. */
|
|
314
|
+
_jsx("div", { className: "px-4", children: "length" in totals ? _jsx(Totals, { rows: totals }) : _jsx(Totals, { ...totals }) })), editor === undefined || session === null ? null : (_jsx(Dialog, { open: open, width: "sm", title: session.id === null ? copy.lineItems.newLine : copy.lines.edit, ...(context === undefined ? {} : { context }), ...(session.id === null ? {} : { subtitle: labelOf(session.draft) }), onClose: () => setOpen(false), footer: _jsxs(_Fragment, { children: [session.id === null || !canRemove ? null : (
|
|
315
|
+
/* Apart, at the other end from «Listo»: removing is not the way the
|
|
316
|
+
form is meant to close. */
|
|
317
|
+
_jsx("span", { className: "mr-auto", children: _jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", onClick: () => {
|
|
318
|
+
const line = lines.find((l) => getLineId(l) === session.id);
|
|
319
|
+
setOpen(false);
|
|
320
|
+
if (line !== undefined)
|
|
321
|
+
remove(line);
|
|
322
|
+
} }) })), _jsx(Button, { role: "ghost", size: "sm", onClick: () => setOpen(false), children: copy.dialog.cancel }), _jsx(Button, { role: "primary", size: "sm", type: "submit", form: formId, children: copy.lines.done })] }), children: _jsx("form", { id: formId, noValidate: true, onSubmit: (e) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
save();
|
|
325
|
+
}, className: "flex flex-col gap-3", children: editor(session.draft, setDraft, {
|
|
326
|
+
tried: session.tried,
|
|
327
|
+
isNew: session.id === null,
|
|
328
|
+
}) }) }))] }) }));
|
|
319
329
|
}
|