@sincpro/web-ui 1.5.0 → 1.7.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/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.d.ts +14 -3
- package/dist/components/lines/LineItems.js +6 -4
- 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 +18 -1
- package/dist/foundations/form.js +6 -3
- package/dist/primitives/button/Button.js +3 -1
- 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 +24 -4
- package/dist/primitives/field/Field.js +17 -4
- 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/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/table/DataTable.d.ts +5 -3
- package/dist/primitives/table/DataTable.js +2 -4
- package/dist/primitives/table/EditableTable.d.ts +49 -7
- package/dist/primitives/table/EditableTable.js +321 -82
- package/dist/primitives/table/Table.d.ts +5 -8
- package/dist/primitives/table/Table.js +63 -15
- package/dist/primitives/table/index.d.ts +3 -2
- package/dist/primitives/table/index.js +2 -1
- package/dist/primitives/table/rowWindow.d.ts +3 -0
- package/dist/primitives/table/rowWindow.js +4 -0
- 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";
|
|
@@ -2,7 +2,7 @@ import { type FormMode } from "../../foundations/form.js";
|
|
|
2
2
|
import { type DialogContext } from "../../primitives/dialog/Dialog.js";
|
|
3
3
|
import type { FlagTone } from "../../primitives/notice/Flag.js";
|
|
4
4
|
import type { DataColumn } from "../../primitives/table/DataTable.js";
|
|
5
|
-
import { type EditColumn, type EditLayout } from "../../primitives/table/EditableTable.js";
|
|
5
|
+
import { type EditCellState, type EditColumn, type EditLayout, type EditRowState } from "../../primitives/table/EditableTable.js";
|
|
6
6
|
import { type TotalsProps, type TotalsRow } from "../../primitives/value/Totals.js";
|
|
7
7
|
import { type ReactNode } from "react";
|
|
8
8
|
/**
|
|
@@ -41,6 +41,8 @@ export type LineEditorState = {
|
|
|
41
41
|
export type LineItemsProps<T> = {
|
|
42
42
|
lines: readonly T[];
|
|
43
43
|
columns: readonly LineItemColumn<T>[];
|
|
44
|
+
/** The line's key on this screen — LOCAL, not its id on the server: a new line gets one
|
|
45
|
+
* when it is made and keeps it after saving. See «A line's key is local» above. */
|
|
44
46
|
getLineId: (line: T) => string;
|
|
45
47
|
/** How a line is named — in the removal notice, the editor's header and for the
|
|
46
48
|
* screen reader next to each row's buttons. Defaults to the first column's text. */
|
|
@@ -52,7 +54,8 @@ export type LineItemsProps<T> = {
|
|
|
52
54
|
onChange?: (id: string, next: T) => void;
|
|
53
55
|
/** A new line, already filled in the editor (or blank, without an editor). */
|
|
54
56
|
onAdd?: (line: T) => void;
|
|
55
|
-
/** The blank line «Agregar una línea» starts from. Required for adding.
|
|
57
|
+
/** The blank line «Agregar una línea» starts from. Required for adding. It carries its
|
|
58
|
+
* local key from here — `{ key: crypto.randomUUID(), … }` — since it has no server id. */
|
|
56
59
|
newLine?: () => T;
|
|
57
60
|
/** A line was removed. It is gone at once; the notice offers to undo it. */
|
|
58
61
|
onRemove?: (id: string) => void;
|
|
@@ -93,6 +96,14 @@ export type LineItemsProps<T> = {
|
|
|
93
96
|
detail?: ReactNode;
|
|
94
97
|
owner?: string;
|
|
95
98
|
} | null;
|
|
99
|
+
/** What each cell is on each line — readonly, required, hidden, its error, its warning
|
|
100
|
+
* («precio bajo el costo» on the price) — as the backend says it per line and field.
|
|
101
|
+
* `EditableTable`'s `cellState`, passed through. The error reaches the cell's control as
|
|
102
|
+
* `aria-invalid` and `aria-describedby`. */
|
|
103
|
+
cellState?: (line: T, columnId: string) => EditCellState | null;
|
|
104
|
+
/** What a whole line is: `readonly` is read in place and offers no pencil (a delivered
|
|
105
|
+
* line). A line that should not be seen is not passed in `lines`. */
|
|
106
|
+
rowState?: (line: T) => EditRowState | null;
|
|
96
107
|
/** Sections and notes among the lines. See the file's header. */
|
|
97
108
|
structure?: LineStructure<T>;
|
|
98
109
|
/** The lines' order is the document's (Odoo's `sequence`): a grip at the start of each line
|
|
@@ -111,4 +122,4 @@ export type LineItemsProps<T> = {
|
|
|
111
122
|
* removing asked first, what an empty document said. One component, built on
|
|
112
123
|
* `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
|
|
113
124
|
*/
|
|
114
|
-
export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, adding, onOpenLine, flag, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }: LineItemsProps<T>): import("react").JSX.Element;
|
|
125
|
+
export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, adding, onOpenLine, flag, cellState, rowState, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }: LineItemsProps<T>): import("react").JSX.Element;
|
|
@@ -38,7 +38,7 @@ function plain(v) {
|
|
|
38
38
|
* removing asked first, what an empty document said. One component, built on
|
|
39
39
|
* `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
|
|
40
40
|
*/
|
|
41
|
-
export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", adding, onOpenLine, flag, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }) {
|
|
41
|
+
export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", adding, onOpenLine, flag, cellState, rowState, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }) {
|
|
42
42
|
const copy = useUiCopy();
|
|
43
43
|
const toast = useOptionalToast();
|
|
44
44
|
const read = useFormMode(mode) === "read";
|
|
@@ -259,7 +259,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
|
|
|
259
259
|
and a trash can lit on each of twenty lines are forty invitations. They
|
|
260
260
|
fade instead of unmounting, so Tab still reaches them. Pressing them does
|
|
261
261
|
not put the row into in-place editing. */
|
|
262
|
-
_jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit && kindOf(line) === "line" ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openLine(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
|
|
262
|
+
_jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit && kindOf(line) === "line" && rowState?.(line)?.readonly !== true ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openLine(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
|
|
263
263
|
},
|
|
264
264
|
});
|
|
265
265
|
}
|
|
@@ -283,7 +283,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
|
|
|
283
283
|
invalid: false,
|
|
284
284
|
}, 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
|
|
285
285
|
? copy.lines.empty
|
|
286
|
-
: 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 }), ...(onReorder === undefined || read ? {} : { onReorder }), ...(inPlace && onRemove !== undefined
|
|
286
|
+
: 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
|
|
287
287
|
? /* A blank row nobody wrote in is taken back without a notice: nobody
|
|
288
288
|
removed anything, so there is nothing to undo. */
|
|
289
289
|
{ onDiscard: onRemove }
|
|
@@ -291,8 +291,10 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
|
|
|
291
291
|
? {
|
|
292
292
|
onOpen: (id) => {
|
|
293
293
|
const line = lines.find((l) => getLineId(l) === id);
|
|
294
|
-
|
|
294
|
+
/* A read-only line opens no form to change it. */
|
|
295
|
+
if (line !== undefined && rowState?.(line)?.readonly !== true) {
|
|
295
296
|
openLine(id, line);
|
|
297
|
+
}
|
|
296
298
|
},
|
|
297
299
|
}
|
|
298
300
|
: {}), ...(canChange && columns.some((c) => c.edit !== undefined)
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import type { Surface } from "../../foundations/types.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type SortState } from "../../primitives/table/sorting.js";
|
|
3
3
|
/**
|
|
4
|
-
* ORDENAR
|
|
4
|
+
* ORDENAR — por uno o por varios campos, desde la barra.
|
|
5
5
|
*
|
|
6
6
|
* Context: ordenar era una capacidad de `DataTable` y de nadie más. Se hacía
|
|
7
7
|
* clic en el encabezado de la columna, que es la forma correcta **cuando hay
|
|
8
8
|
* encabezados**. En filas, en tablero y en la planilla no los hay, así que
|
|
9
9
|
* cambiar de presentación no perdía sólo el orden puesto: perdía la posibilidad
|
|
10
|
-
* de ordenar.
|
|
10
|
+
* de ordenar.
|
|
11
11
|
*
|
|
12
12
|
* El orden es del CONJUNTO y no de la presentación, igual que los niveles y que
|
|
13
13
|
* lo plegado. Por eso el control vive en la barra y no adentro de la tabla, y
|
|
14
14
|
* por eso la tabla sigue teniendo además sus encabezados: son dos puertas a lo
|
|
15
15
|
* mismo, y la de los encabezados es más rápida cuando existe.
|
|
16
16
|
*
|
|
17
|
-
* ──
|
|
17
|
+
* ── Varios criterios, y por eso un panel y no un menú ───────────────
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Se decía que ordenar por dos campos era «un desempate que hace el motor» y
|
|
20
|
+
* que alcanzaba con elegir uno. La gente pidió lo contrario en la pantalla: por
|
|
21
|
+
* fecha, y en la misma fecha por cliente, y después por producto. El motor ya
|
|
22
|
+
* lo aceptaba (`order: [...]`); faltaba poder decirlo. Un orden de N campos
|
|
23
|
+
* tiene ORDEN entre ellos —el segundo sólo decide entre las filas que el
|
|
24
|
+
* primero dejó iguales—, y eso un menú de tildes no lo expresa: hace falta una
|
|
25
|
+
* lista que se reordena. Es la de React Aria (`GridList` + `useDragAndDrop`):
|
|
26
|
+
* se arrastra con el mouse, y con el teclado desde el asa (Enter, flechas,
|
|
27
|
+
* Enter), anunciado.
|
|
23
28
|
*/
|
|
24
29
|
export type SortField<T = unknown> = {
|
|
25
30
|
field: string;
|
|
@@ -33,7 +38,7 @@ export type SortField<T = unknown> = {
|
|
|
33
38
|
* falso— así que ahí el orden viaja en la lectura y esto sólo lo refleja.
|
|
34
39
|
*/
|
|
35
40
|
value?: (item: T) => string | number | null | undefined;
|
|
36
|
-
/** Qué quiere decir «ascendente» para este campo. Sin esto, «
|
|
41
|
+
/** Qué quiere decir «ascendente» para este campo. Sin esto, «ascendente».
|
|
37
42
|
* Una fecha asciende de vieja a nueva y un importe de chico a grande, y
|
|
38
43
|
* decirlo evita el clic de prueba. */
|
|
39
44
|
ascLabel?: string;
|
|
@@ -42,21 +47,20 @@ export type SortField<T = unknown> = {
|
|
|
42
47
|
export type SortMenuProps = {
|
|
43
48
|
/** Por qué campos se puede ordenar. Sin ninguno, el control no se dibuja. */
|
|
44
49
|
fields: readonly SortField<never>[];
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
/** El orden, de afuera hacia adentro. Vacío es «como llegaron». */
|
|
51
|
+
sort: readonly SortState[];
|
|
52
|
+
onSortChange: (next: SortState[]) => void;
|
|
47
53
|
surface?: Surface;
|
|
48
54
|
};
|
|
49
55
|
/**
|
|
50
|
-
* Las filas, ordenadas ac
|
|
56
|
+
* Las filas, ordenadas acá, por cada criterio en su orden.
|
|
51
57
|
*
|
|
52
|
-
* Sólo
|
|
53
|
-
*
|
|
54
|
-
*
|
|
58
|
+
* Sólo por los campos que declaran `value`; los demás se saltean —ordenar una
|
|
59
|
+
* página de un conjunto que se pagina afuera produce un orden que es mentira,
|
|
60
|
+
* porque ordena las doce que llegaron y no las ciento ochenta y siete. Sin
|
|
61
|
+
* ningún criterio legible, se devuelven tal cual.
|
|
55
62
|
*
|
|
56
|
-
* Los faltantes van SIEMPRE al final, suba o baje el orden
|
|
57
|
-
* cualquier planilla, y la razón es que un vacío no es «el más chico»: es la
|
|
58
|
-
* ausencia de respuesta, y ponerlo primero al invertir el orden llena la primera
|
|
59
|
-
* pantalla con las filas que menos dicen.
|
|
63
|
+
* Los faltantes van SIEMPRE al final, suba o baje el orden (ver `compareBy`).
|
|
60
64
|
*/
|
|
61
|
-
export declare function sortItems<T>(items: readonly T[], fields: readonly SortField<T>[], sort: SortState |
|
|
65
|
+
export declare function sortItems<T>(items: readonly T[], fields: readonly SortField<T>[], sort: readonly SortState[] | undefined): readonly T[];
|
|
62
66
|
export declare function SortMenu({ fields, sort, onSortChange, surface }: SortMenuProps): import("react").JSX.Element | null;
|