@sincpro/web-ui 1.6.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.
Files changed (93) hide show
  1. package/dist/components/attachments/Attachments.d.ts +56 -0
  2. package/dist/components/attachments/Attachments.js +93 -0
  3. package/dist/components/attachments/files.d.ts +28 -0
  4. package/dist/components/attachments/files.js +100 -0
  5. package/dist/components/attachments/index.d.ts +2 -0
  6. package/dist/components/attachments/index.js +3 -0
  7. package/dist/components/flow/StepFlow.d.ts +37 -0
  8. package/dist/components/flow/StepFlow.js +74 -0
  9. package/dist/components/flow/index.d.ts +1 -0
  10. package/dist/components/flow/index.js +2 -0
  11. package/dist/components/index.d.ts +2 -0
  12. package/dist/components/index.js +2 -0
  13. package/dist/components/listing/SortMenu.d.ts +24 -20
  14. package/dist/components/listing/SortMenu.js +51 -91
  15. package/dist/components/listing/index.d.ts +2 -1
  16. package/dist/components/listing/index.js +2 -1
  17. package/dist/components/listing/modelCells.d.ts +7 -0
  18. package/dist/components/listing/modelCells.js +56 -0
  19. package/dist/components/listing/modelColumns.d.ts +9 -0
  20. package/dist/components/listing/modelColumns.js +59 -14
  21. package/dist/components/search/index.d.ts +1 -1
  22. package/dist/components/search/index.js +1 -1
  23. package/dist/components/search/model.d.ts +1 -0
  24. package/dist/components/search/model.js +7 -0
  25. package/dist/components/search/query.js +2 -3
  26. package/dist/foundations/copy.d.ts +131 -0
  27. package/dist/foundations/copy.es.js +86 -0
  28. package/dist/foundations/copy.js +86 -0
  29. package/dist/primitives/button/Button.js +3 -1
  30. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  31. package/dist/primitives/field/CardNumberInput.js +117 -0
  32. package/dist/primitives/field/CodeInput.d.ts +18 -0
  33. package/dist/primitives/field/CodeInput.js +37 -0
  34. package/dist/primitives/field/ColorInput.d.ts +21 -0
  35. package/dist/primitives/field/ColorInput.js +42 -0
  36. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  37. package/dist/primitives/field/ContactInputs.js +70 -0
  38. package/dist/primitives/field/DurationInput.d.ts +20 -0
  39. package/dist/primitives/field/DurationInput.js +54 -0
  40. package/dist/primitives/field/Field.d.ts +24 -4
  41. package/dist/primitives/field/Field.js +17 -4
  42. package/dist/primitives/field/ImageInput.d.ts +51 -0
  43. package/dist/primitives/field/ImageInput.js +267 -0
  44. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  45. package/dist/primitives/field/PasswordInput.js +44 -0
  46. package/dist/primitives/field/PatternInput.d.ts +23 -0
  47. package/dist/primitives/field/PatternInput.js +48 -0
  48. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  49. package/dist/primitives/field/RichTextEditor.js +224 -0
  50. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  51. package/dist/primitives/field/RichTextInput.js +38 -0
  52. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  53. package/dist/primitives/field/SignatureInput.js +215 -0
  54. package/dist/primitives/field/card.d.ts +33 -0
  55. package/dist/primitives/field/card.js +173 -0
  56. package/dist/primitives/field/clipboard.js +3 -2
  57. package/dist/primitives/field/contact.d.ts +17 -0
  58. package/dist/primitives/field/contact.js +65 -0
  59. package/dist/primitives/field/duration.d.ts +5 -0
  60. package/dist/primitives/field/duration.js +48 -0
  61. package/dist/primitives/field/index.d.ts +14 -0
  62. package/dist/primitives/field/index.js +20 -0
  63. package/dist/primitives/field/mask.d.ts +33 -0
  64. package/dist/primitives/field/mask.js +56 -0
  65. package/dist/primitives/field/media.d.ts +99 -0
  66. package/dist/primitives/field/media.js +136 -0
  67. package/dist/primitives/field/pattern.d.ts +10 -0
  68. package/dist/primitives/field/pattern.js +68 -0
  69. package/dist/primitives/menu/Menu.d.ts +16 -0
  70. package/dist/primitives/menu/Menu.js +26 -8
  71. package/dist/primitives/menu/index.d.ts +1 -1
  72. package/dist/primitives/menu/index.js +1 -1
  73. package/dist/primitives/table/DataTable.d.ts +5 -3
  74. package/dist/primitives/table/DataTable.js +2 -4
  75. package/dist/primitives/table/Table.d.ts +5 -8
  76. package/dist/primitives/table/Table.js +63 -15
  77. package/dist/primitives/table/index.d.ts +2 -1
  78. package/dist/primitives/table/index.js +2 -1
  79. package/dist/primitives/table/sorting.d.ts +42 -0
  80. package/dist/primitives/table/sorting.js +110 -0
  81. package/dist/primitives/value/RichText.d.ts +10 -0
  82. package/dist/primitives/value/RichText.js +130 -0
  83. package/dist/primitives/value/Value.d.ts +20 -2
  84. package/dist/primitives/value/Value.js +26 -7
  85. package/dist/primitives/value/index.d.ts +3 -1
  86. package/dist/primitives/value/index.js +4 -1
  87. package/dist/primitives/value/rounding.d.ts +9 -0
  88. package/dist/primitives/value/rounding.js +48 -0
  89. package/dist/tokens.css +103 -0
  90. package/dist/views/list/List.d.ts +5 -4
  91. package/dist/views/list/ListView.js +2 -2
  92. package/dist/views/list/types.d.ts +6 -6
  93. 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,2 @@
1
+ export { type AttachmentFile, Attachments, type AttachmentsProps, type AttachmentStatus, } from "./Attachments.js";
2
+ export { type AttachmentKind, attachmentKind, fileExtension, formatFileSize, } from "./files.js";
@@ -0,0 +1,3 @@
1
+ /* ATTACHMENTS — the files of a record: what they are, who brought them, and what to do with them. */
2
+ export { Attachments, } from "./Attachments.js";
3
+ export { attachmentKind, fileExtension, formatFileSize, } from "./files.js";
@@ -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";
@@ -0,0 +1,2 @@
1
+ /* One operation in several steps, done in one sitting — import: upload › map › review. */
2
+ export { StepFlow, } 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";
@@ -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";
@@ -1,25 +1,30 @@
1
1
  import type { Surface } from "../../foundations/types.js";
2
- import type { SortState } from "../../primitives/table/Table.js";
2
+ import { type SortState } from "../../primitives/table/sorting.js";
3
3
  /**
4
- * ORDENAR, cuando no hay encabezados donde hacer clic.
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. Es exactamente el mismo drift que tenía la agrupación.
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
- * ── Por qué es un `Menu` y el agrupador no ──────────────────────────
17
+ * ── Varios criterios, y por eso un panel y no un menú ───────────────
18
18
  *
19
- * Porque ordenar SÍ es elegir una opción: un campo y una dirección. No hay orden
20
- * ni jerarquía que expresar —ordenar por dos campos a la vez es un desempate,
21
- * que es otra pregunta y la hace el motor— así que una lista de tildes alcanza y
22
- * no hace falta un editor.
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, «A → Z».
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
- sort: SortState | null;
46
- onSortChange: (next: SortState | null) => void;
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 cuando el campo activo declara `value`; si no, se devuelven tal cual —
53
- * ordenar una página de un conjunto que se pagina afuera produce un orden que es
54
- * mentira, porque ordena las doce que llegaron y no las ciento ochenta y siete.
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. Es lo que hace
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 | null | undefined): readonly T[];
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;