@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
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
3
4
|
import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
|
|
5
|
+
import { pressSort, sortPosition, } from "./sorting.js";
|
|
4
6
|
import { ArrowDown, ArrowUp, ChevronRight, ChevronsUpDown } from "lucide-react";
|
|
5
7
|
import { createContext, useContext, useId } from "react";
|
|
8
|
+
/* ==================================================================== *
|
|
9
|
+
* TABLE — the way this product shows N records.
|
|
10
|
+
*
|
|
11
|
+
* Dense, but readable, and a single split is what makes that possible:
|
|
12
|
+
*
|
|
13
|
+
* the header in PROPORTIONAL is a word
|
|
14
|
+
* a number in TABULAR FIGURES is a value that lines up with the one below
|
|
15
|
+
*
|
|
16
|
+
* That is what makes a table readable at a glance: the eye tells the frame
|
|
17
|
+
* apart from the content without reading either of them. Everything in mono
|
|
18
|
+
* —which is where this product came from— turned the header into just another
|
|
19
|
+
* row of data, and the whole report into a terminal. Mono is now for codes
|
|
20
|
+
* only, and only when a cell asks for it.
|
|
21
|
+
*
|
|
22
|
+
* ── Three sort states, not two ──────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Ascending, descending and **unsorted**. The third one is not a luxury: the
|
|
25
|
+
* natural order of a delivery queue is the order they arrived in, and a table that
|
|
26
|
+
* only toggles asc/desc has no way back to it. Losing the original order and
|
|
27
|
+
* not being able to get it back is the number one complaint about any
|
|
28
|
+
* sortable table.
|
|
29
|
+
*
|
|
30
|
+
* ── And more than one column ────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* Shift+click adds the column as a TIE-BREAKER instead of replacing the order:
|
|
33
|
+
* «by date, then by customer». Each sorted header then carries its place (1,
|
|
34
|
+
* 2, 3) next to the arrow, because two arrows alone do not say which one
|
|
35
|
+
* decides. The model is in `sorting.ts`.
|
|
36
|
+
*
|
|
37
|
+
* ── The header stays pinned ─────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* It is the difference between a long table you can read and one where, by
|
|
40
|
+
* row forty, nobody knows which column they are looking at any more. It needs
|
|
41
|
+
* the box to have a height: without `height` there is no scroll of its own
|
|
42
|
+
* and nothing to stick to.
|
|
43
|
+
* ==================================================================== */
|
|
6
44
|
export function Table({ children,
|
|
7
45
|
/** With a height, the table scrolls on its own and the header sticks.
|
|
8
46
|
* Without a height, the page scrolls and the header goes away. */
|
|
@@ -28,25 +66,35 @@ export function THead({ children, rowIndex, }) {
|
|
|
28
66
|
}
|
|
29
67
|
/** A column. If it gets a `sortKey`, it can be sorted.
|
|
30
68
|
*
|
|
31
|
-
* The icon is ALWAYS there, dimmed when the column is not
|
|
69
|
+
* The icon is ALWAYS there, dimmed when the column is not sorted: an
|
|
32
70
|
* indicator that only shows up once you sort does not say which columns are
|
|
33
71
|
* sortable, and then you have to try them one by one. */
|
|
34
72
|
export function Th({ children, sortKey, sort, onSort, numeric = false, align, width, }) {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
onSort({ key: sortKey, direction: "asc" });
|
|
43
|
-
else if (dir === "asc")
|
|
44
|
-
onSort({ key: sortKey, direction: "desc" });
|
|
45
|
-
else
|
|
46
|
-
onSort(null);
|
|
47
|
-
}
|
|
73
|
+
const copy = useUiCopy();
|
|
74
|
+
const at = sortKey === undefined ? null : sortPosition(sort, sortKey);
|
|
75
|
+
const dir = at?.direction;
|
|
76
|
+
/* The place is drawn only when there is more than one criterion: «1» alone says nothing
|
|
77
|
+
the arrow did not. */
|
|
78
|
+
const ranked = at !== null && (sort?.length ?? 0) > 1;
|
|
79
|
+
const right = (align ?? (numeric ? "right" : "left")) === "right";
|
|
48
80
|
const Icon = dir === "asc" ? ArrowUp : dir === "desc" ? ArrowDown : ChevronsUpDown;
|
|
49
|
-
return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort":
|
|
81
|
+
return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort": sortKey === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: at === null
|
|
84
|
+
? "none"
|
|
85
|
+
: at.index > 0
|
|
86
|
+
? undefined
|
|
87
|
+
: dir === "asc"
|
|
88
|
+
? "ascending"
|
|
89
|
+
: "descending", className: `border-b border-line-strong px-3 py-2 ${HEADER_TEXT} ${right ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", title: copy.sorting.addHint,
|
|
90
|
+
/* Shift+click selects the header's text in some browsers before the click lands. */
|
|
91
|
+
onMouseDown: (event) => {
|
|
92
|
+
if (event.shiftKey)
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
}, onClick: (event) => onSort?.(pressSort(sort ?? [], sortKey, event.shiftKey)), className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) select-none hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${right ? "flex-row-reverse" : ""} ${at === null ? "" : "text-ink"}`, children: [children, _jsxs("span", { className: "inline-flex shrink-0 items-center gap-px", children: [_jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${at === null ? "text-line-strong" : "text-primary"}`, strokeWidth: 2, "aria-hidden": true }), ranked ? (_jsx("span", { "aria-hidden": true, className: "text-micro text-primary tabular-nums", children: at.index + 1 })) : null] }), ranked ? (_jsx("span", { className: "sr-only", children: fillCopy(copy.sorting.place, {
|
|
95
|
+
place: String(at.index + 1),
|
|
96
|
+
direction: dir === "asc" ? copy.sorting.asc : copy.sorting.desc,
|
|
97
|
+
}) })) : null] })) }));
|
|
50
98
|
}
|
|
51
99
|
/** The wash of a row lives on the cells, not on the `<tr>`. A background on the
|
|
52
100
|
* row paints edge to edge; on the cells the ends can round and the table's
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { type Aggregate, type DataColumn, DataTable, type DataTableCreate, type GroupColumn, } from "./DataTable.js";
|
|
2
|
-
export { EditableTable, type EditAdd, type EditBands, type EditColumn, type EditLayout, sectionLines, type StackPlace, stackPlaces, type TabTarget, tabTarget, } from "./EditableTable.js";
|
|
2
|
+
export { EditableTable, type EditAdd, type EditBands, type EditCellState, type EditColumn, type EditFlag, type EditLayout, type EditRowState, sectionLines, type StackPlace, stackPlaces, type TabTarget, tabTarget, } from "./EditableTable.js";
|
|
3
3
|
export { PileFootLine } from "./PileFootLine.js";
|
|
4
4
|
export { PivotTable, type PivotTableProps, } from "./PivotTable.js";
|
|
5
5
|
export { type ReportColumn, type ReportRow, type ReportRowKind, ReportTable, } from "./ReportTable.js";
|
|
6
6
|
export { type RowAction, RowActions } from "./RowActions.js";
|
|
7
7
|
export { type SheetColumn, SheetTable } from "./SheetTable.js";
|
|
8
|
-
export {
|
|
8
|
+
export { compareBy, flipSort, moveSort, pressSort, type SortDirection, sortPosition, type SortState, } from "./sorting.js";
|
|
9
|
+
export { CELL_TONE, Table, Td, Th, THead, Tr } from "./Table.js";
|
|
@@ -7,4 +7,5 @@ export { PivotTable, } from "./PivotTable.js";
|
|
|
7
7
|
export { ReportTable, } from "./ReportTable.js";
|
|
8
8
|
export { RowActions } from "./RowActions.js";
|
|
9
9
|
export { SheetTable } from "./SheetTable.js";
|
|
10
|
-
export {
|
|
10
|
+
export { compareBy, flipSort, moveSort, pressSort, sortPosition, } from "./sorting.js";
|
|
11
|
+
export { CELL_TONE, Table, Td, Th, THead, Tr } from "./Table.js";
|
|
@@ -36,4 +36,7 @@ export declare function useRowWindow({ count, rowsBefore, getKey, }: {
|
|
|
36
36
|
pad: (from: number, to: number, columns: number, key: string) => ReactNode;
|
|
37
37
|
/** The spacer after the last drawn row. */
|
|
38
38
|
tail: (columns: number) => ReactNode;
|
|
39
|
+
/** Scrolls row `index` into the window, so it gets drawn — for a jump past what is drawn
|
|
40
|
+
* (`Home`, `End`, «take me to it»). Nothing when the window is off: every row is there. */
|
|
41
|
+
reveal: (index: number) => void;
|
|
39
42
|
};
|
|
@@ -173,5 +173,9 @@ export function useRowWindow({ count, rowsBefore = 1, getKey, }) {
|
|
|
173
173
|
const end = rows.length === 0 ? 0 : rows[rows.length - 1].end;
|
|
174
174
|
return spacer(total - end, columns, "\u0000tail");
|
|
175
175
|
},
|
|
176
|
+
reveal: (index) => {
|
|
177
|
+
if (on && scroller !== null)
|
|
178
|
+
v.scrollToIndex(index, { align: "auto" });
|
|
179
|
+
},
|
|
176
180
|
};
|
|
177
181
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SortDirection = "asc" | "desc";
|
|
2
|
+
/** One criterion: a field and its direction. */
|
|
3
|
+
export interface SortState {
|
|
4
|
+
key: string;
|
|
5
|
+
direction: SortDirection;
|
|
6
|
+
}
|
|
7
|
+
/** Where a column stands in the order: its place (0 = the primary) and direction. */
|
|
8
|
+
export declare function sortPosition(sort: readonly SortState[] | undefined, key: string): {
|
|
9
|
+
index: number;
|
|
10
|
+
direction: SortDirection;
|
|
11
|
+
} | null;
|
|
12
|
+
/**
|
|
13
|
+
* What a press on a column header does to the order.
|
|
14
|
+
*
|
|
15
|
+
* Plain press — the column ALONE:
|
|
16
|
+
* - the only criterion: asc → desc → nothing. The third press gives the
|
|
17
|
+
* natural order back (see `Table.tsx`).
|
|
18
|
+
* - with others around it: the column alone, flipped if it was there, and
|
|
19
|
+
* never «nothing» — a press that also wipes the tie-breakers someone built
|
|
20
|
+
* in the panel would be a surprise, not a shortcut.
|
|
21
|
+
*
|
|
22
|
+
* `additive` (Shift) — one more tie-breaker, the others untouched:
|
|
23
|
+
* - absent: appended, ascending.
|
|
24
|
+
* - ascending: flipped in its place.
|
|
25
|
+
* - descending: taken out.
|
|
26
|
+
*/
|
|
27
|
+
export declare function pressSort(sort: readonly SortState[], key: string, additive: boolean): SortState[];
|
|
28
|
+
/** The criterion at `index` with its direction flipped. */
|
|
29
|
+
export declare function flipSort(sort: readonly SortState[], index: number): SortState[];
|
|
30
|
+
/** The criteria with the ones in `keys` moved to sit before `target` (or after it). The
|
|
31
|
+
* shape React Aria's drag-and-drop hands over: a set of dragged keys and a drop target. */
|
|
32
|
+
export declare function moveSort(sort: readonly SortState[], keys: ReadonlySet<string>, target: string, position: "before" | "after"): SortState[];
|
|
33
|
+
/**
|
|
34
|
+
* Compares two rows by every criterion, in order: the second one speaks only when the first
|
|
35
|
+
* one ties. `read` gives each row's value for a field; `undefined` there means «this field
|
|
36
|
+
* cannot be sorted here» and the criterion is skipped.
|
|
37
|
+
*
|
|
38
|
+
* Missing values go LAST whatever the direction. A blank is not «the smallest»: it is the
|
|
39
|
+
* absence of an answer, and putting it first when the order is flipped fills the first
|
|
40
|
+
* screen with the rows that say the least.
|
|
41
|
+
*/
|
|
42
|
+
export declare function compareBy<T>(sort: readonly SortState[], read: (key: string) => ((row: T) => string | number | null | undefined) | undefined): (a: T, b: T) => number;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* ==================================================================== *
|
|
2
|
+
* SORTING — an ordered list of criteria, not one column.
|
|
3
|
+
*
|
|
4
|
+
* Context: the order of a set was ONE field and a direction. That answers
|
|
5
|
+
* «the newest first», and not the question people actually asked the list:
|
|
6
|
+
* «by date, and within the same date by customer, and then by product». The
|
|
7
|
+
* second and third fields are tie-breakers — they only decide between rows the
|
|
8
|
+
* first one left equal — and the engine's criteria already takes them
|
|
9
|
+
* (`order: [{ field, descending }, …]`). What could not say it was the screen.
|
|
10
|
+
*
|
|
11
|
+
* So the order is a LIST, outermost first. Empty is «as they came»: the
|
|
12
|
+
* third state of a header, which gives the natural order back.
|
|
13
|
+
*
|
|
14
|
+
* The two doors to it write the same list:
|
|
15
|
+
*
|
|
16
|
+
* header click this column alone — the quick question
|
|
17
|
+
* header Shift+click this column as one more tie-breaker
|
|
18
|
+
* the bar's panel the whole list: add, flip, reorder, remove
|
|
19
|
+
*
|
|
20
|
+
* Pure, and in the table family because the header (`Th`) is its first user;
|
|
21
|
+
* the list's bar reads it from here.
|
|
22
|
+
* ==================================================================== */
|
|
23
|
+
/** Where a column stands in the order: its place (0 = the primary) and direction. */
|
|
24
|
+
export function sortPosition(sort, key) {
|
|
25
|
+
const index = sort?.findIndex((one) => one.key === key) ?? -1;
|
|
26
|
+
if (index === -1 || sort === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
return { index, direction: sort[index]?.direction ?? "asc" };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a press on a column header does to the order.
|
|
32
|
+
*
|
|
33
|
+
* Plain press — the column ALONE:
|
|
34
|
+
* - the only criterion: asc → desc → nothing. The third press gives the
|
|
35
|
+
* natural order back (see `Table.tsx`).
|
|
36
|
+
* - with others around it: the column alone, flipped if it was there, and
|
|
37
|
+
* never «nothing» — a press that also wipes the tie-breakers someone built
|
|
38
|
+
* in the panel would be a surprise, not a shortcut.
|
|
39
|
+
*
|
|
40
|
+
* `additive` (Shift) — one more tie-breaker, the others untouched:
|
|
41
|
+
* - absent: appended, ascending.
|
|
42
|
+
* - ascending: flipped in its place.
|
|
43
|
+
* - descending: taken out.
|
|
44
|
+
*/
|
|
45
|
+
export function pressSort(sort, key, additive) {
|
|
46
|
+
const at = sortPosition(sort, key);
|
|
47
|
+
if (additive) {
|
|
48
|
+
if (at === null)
|
|
49
|
+
return [...sort, { key, direction: "asc" }];
|
|
50
|
+
if (at.direction === "asc") {
|
|
51
|
+
return sort.map((one, i) => (i === at.index ? { key, direction: "desc" } : one));
|
|
52
|
+
}
|
|
53
|
+
return sort.filter((_, i) => i !== at.index);
|
|
54
|
+
}
|
|
55
|
+
if (at === null)
|
|
56
|
+
return [{ key, direction: "asc" }];
|
|
57
|
+
if (sort.length === 1)
|
|
58
|
+
return at.direction === "asc" ? [{ key, direction: "desc" }] : [];
|
|
59
|
+
return [{ key, direction: at.direction === "asc" ? "desc" : "asc" }];
|
|
60
|
+
}
|
|
61
|
+
/** The criterion at `index` with its direction flipped. */
|
|
62
|
+
export function flipSort(sort, index) {
|
|
63
|
+
return sort.map((one, i) => i === index ? { ...one, direction: one.direction === "asc" ? "desc" : "asc" } : one);
|
|
64
|
+
}
|
|
65
|
+
/** The criteria with the ones in `keys` moved to sit before `target` (or after it). The
|
|
66
|
+
* shape React Aria's drag-and-drop hands over: a set of dragged keys and a drop target. */
|
|
67
|
+
export function moveSort(sort, keys, target, position) {
|
|
68
|
+
const moving = sort.filter((one) => keys.has(one.key));
|
|
69
|
+
const rest = sort.filter((one) => !keys.has(one.key));
|
|
70
|
+
const at = rest.findIndex((one) => one.key === target);
|
|
71
|
+
if (at === -1 || moving.length === 0)
|
|
72
|
+
return [...sort];
|
|
73
|
+
const cut = position === "before" ? at : at + 1;
|
|
74
|
+
return [...rest.slice(0, cut), ...moving, ...rest.slice(cut)];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Compares two rows by every criterion, in order: the second one speaks only when the first
|
|
78
|
+
* one ties. `read` gives each row's value for a field; `undefined` there means «this field
|
|
79
|
+
* cannot be sorted here» and the criterion is skipped.
|
|
80
|
+
*
|
|
81
|
+
* Missing values go LAST whatever the direction. A blank is not «the smallest»: it is the
|
|
82
|
+
* absence of an answer, and putting it first when the order is flipped fills the first
|
|
83
|
+
* screen with the rows that say the least.
|
|
84
|
+
*/
|
|
85
|
+
export function compareBy(sort, read) {
|
|
86
|
+
const criteria = sort.flatMap((one) => {
|
|
87
|
+
const value = read(one.key);
|
|
88
|
+
return value === undefined ? [] : [{ value, sign: one.direction === "asc" ? 1 : -1 }];
|
|
89
|
+
});
|
|
90
|
+
return (a, b) => {
|
|
91
|
+
for (const { value, sign } of criteria) {
|
|
92
|
+
const left = value(a);
|
|
93
|
+
const right = value(b);
|
|
94
|
+
const leftMissing = left === null || left === undefined || left === "";
|
|
95
|
+
const rightMissing = right === null || right === undefined || right === "";
|
|
96
|
+
if (leftMissing && rightMissing)
|
|
97
|
+
continue;
|
|
98
|
+
if (leftMissing)
|
|
99
|
+
return 1;
|
|
100
|
+
if (rightMissing)
|
|
101
|
+
return -1;
|
|
102
|
+
const order = typeof left === "number" && typeof right === "number"
|
|
103
|
+
? left - right
|
|
104
|
+
: String(left).localeCompare(String(right));
|
|
105
|
+
if (order !== 0)
|
|
106
|
+
return order * sign;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The HTML with everything that runs taken out, or `null` where there is no DOM to do it. */
|
|
2
|
+
export declare function sanitizeRichText(html: string): string | null;
|
|
3
|
+
/** The words of an HTML, without its tags: what is shown where it cannot be cleaned. */
|
|
4
|
+
export declare function richTextPlain(html: string): string;
|
|
5
|
+
/** Nothing written: no text and no image — «<p></p>» from an emptied editor counts as nothing. */
|
|
6
|
+
export declare function isRichTextEmpty(html: string | null | undefined): boolean;
|
|
7
|
+
/** The HTML of a note, read: cleaned, in the system's text style (`rich-text` in tokens.css). */
|
|
8
|
+
export declare function RichTextValue({ value }: {
|
|
9
|
+
value: string | null | undefined;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import DOMPurify from "dompurify";
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
/* ==================================================================== *
|
|
7
|
+
* RICH TEXT, READ — the HTML of a note shown as what it says.
|
|
8
|
+
*
|
|
9
|
+
* Context: this HTML comes from people (a description pasted from an email)
|
|
10
|
+
* and from engines (an Odoo `html` field), and either can carry a script, an
|
|
11
|
+
* `onerror`, a `javascript:` link. So it is never put on the page as it came:
|
|
12
|
+
* DOMPurify keeps the text and its structure (paragraphs, headings, lists,
|
|
13
|
+
* links, tables, a mention) and drops everything that runs. A link always
|
|
14
|
+
* opens in another tab, without handing that tab our window.
|
|
15
|
+
*
|
|
16
|
+
* ── On the server there is no DOM to clean with ─────────────────────
|
|
17
|
+
*
|
|
18
|
+
* DOMPurify needs a document. Without one (server rendering, a Node test), the
|
|
19
|
+
* safe answer is the TEXT, tags removed and escaped by React — never the HTML
|
|
20
|
+
* unchecked. The browser then upgrades it to the cleaned HTML; the switch goes
|
|
21
|
+
* through `useSyncExternalStore`, so hydration does not see two different trees.
|
|
22
|
+
* ==================================================================== */
|
|
23
|
+
const ALLOWED_TAGS = [
|
|
24
|
+
"p",
|
|
25
|
+
"br",
|
|
26
|
+
"strong",
|
|
27
|
+
"b",
|
|
28
|
+
"em",
|
|
29
|
+
"i",
|
|
30
|
+
"u",
|
|
31
|
+
"s",
|
|
32
|
+
"strike",
|
|
33
|
+
"del",
|
|
34
|
+
"code",
|
|
35
|
+
"pre",
|
|
36
|
+
"h1",
|
|
37
|
+
"h2",
|
|
38
|
+
"h3",
|
|
39
|
+
"h4",
|
|
40
|
+
"ul",
|
|
41
|
+
"ol",
|
|
42
|
+
"li",
|
|
43
|
+
"blockquote",
|
|
44
|
+
"hr",
|
|
45
|
+
"a",
|
|
46
|
+
"span",
|
|
47
|
+
"table",
|
|
48
|
+
"thead",
|
|
49
|
+
"tbody",
|
|
50
|
+
"tr",
|
|
51
|
+
"th",
|
|
52
|
+
"td",
|
|
53
|
+
"colgroup",
|
|
54
|
+
"col",
|
|
55
|
+
"img",
|
|
56
|
+
];
|
|
57
|
+
const ALLOWED_ATTR = [
|
|
58
|
+
"href",
|
|
59
|
+
"title",
|
|
60
|
+
"colspan",
|
|
61
|
+
"rowspan",
|
|
62
|
+
"src",
|
|
63
|
+
"alt",
|
|
64
|
+
"data-type",
|
|
65
|
+
"data-id",
|
|
66
|
+
"data-label",
|
|
67
|
+
];
|
|
68
|
+
let hooked = false;
|
|
69
|
+
/** The HTML with everything that runs taken out, or `null` where there is no DOM to do it. */
|
|
70
|
+
export function sanitizeRichText(html) {
|
|
71
|
+
if (typeof window === "undefined" || !DOMPurify.isSupported)
|
|
72
|
+
return null;
|
|
73
|
+
if (!hooked) {
|
|
74
|
+
hooked = true;
|
|
75
|
+
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
76
|
+
if (node.tagName === "A") {
|
|
77
|
+
node.setAttribute("target", "_blank");
|
|
78
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
79
|
+
}
|
|
80
|
+
if (node.tagName === "IMG") {
|
|
81
|
+
/* An image whose address was not allowed is a broken box: gone. One without a
|
|
82
|
+
description is read as decoration (`alt=""`) instead of as its file name. */
|
|
83
|
+
if (!node.hasAttribute("src"))
|
|
84
|
+
node.remove();
|
|
85
|
+
else if (!node.hasAttribute("alt"))
|
|
86
|
+
node.setAttribute("alt", "");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return DOMPurify.sanitize(html, {
|
|
91
|
+
ALLOWED_TAGS,
|
|
92
|
+
ALLOWED_ATTR,
|
|
93
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|data:image\/(?:png|jpeg|gif|webp);)/i,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** The words of an HTML, without its tags: what is shown where it cannot be cleaned. */
|
|
97
|
+
export function richTextPlain(html) {
|
|
98
|
+
return html
|
|
99
|
+
.replace(/<(?:br|\/p|\/li|\/h[1-6]|\/tr)\s*\/?>/gi, " ")
|
|
100
|
+
.replace(/<[^>]*>/g, "")
|
|
101
|
+
.replace(/ /g, " ")
|
|
102
|
+
.replace(/&/g, "&")
|
|
103
|
+
.replace(/</g, "<")
|
|
104
|
+
.replace(/>/g, ">")
|
|
105
|
+
.replace(/"/g, '"')
|
|
106
|
+
.replace(/'/g, "'")
|
|
107
|
+
.replace(/\s+/g, " ")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
/** Nothing written: no text and no image — «<p></p>» from an emptied editor counts as nothing. */
|
|
111
|
+
export function isRichTextEmpty(html) {
|
|
112
|
+
if (html === null || html === undefined)
|
|
113
|
+
return true;
|
|
114
|
+
return richTextPlain(html) === "" && !/<(?:img|hr|table)\b/i.test(html);
|
|
115
|
+
}
|
|
116
|
+
const subscribe = () => () => { };
|
|
117
|
+
/** The HTML of a note, read: cleaned, in the system's text style (`rich-text` in tokens.css). */
|
|
118
|
+
export function RichTextValue({ value }) {
|
|
119
|
+
const unset = useUiCopy().field.unset;
|
|
120
|
+
const inBrowser = useSyncExternalStore(subscribe, () => true, () => false);
|
|
121
|
+
if (isRichTextEmpty(value)) {
|
|
122
|
+
return _jsx("span", { className: "text-meta text-muted italic", children: unset });
|
|
123
|
+
}
|
|
124
|
+
const html = value ?? "";
|
|
125
|
+
const clean = inBrowser ? sanitizeRichText(html) : null;
|
|
126
|
+
if (clean === null)
|
|
127
|
+
return _jsx("p", { className: "rich-text", children: richTextPlain(html) });
|
|
128
|
+
/* Cleaned by DOMPurify just above, with the tags and URLs listed there. */
|
|
129
|
+
return _jsx("div", { className: "rich-text min-w-0", dangerouslySetInnerHTML: { __html: clean } });
|
|
130
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Rounding } from "./rounding.js";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
3
|
export declare const formatCount: (value: number, locale?: string) => string;
|
|
3
4
|
export declare const formatNumber: (value: number, locale?: string) => string;
|
|
@@ -7,6 +8,10 @@ export declare function KeyValue({ label, value }: {
|
|
|
7
8
|
label: string;
|
|
8
9
|
value: ReactNode;
|
|
9
10
|
}): import("react").JSX.Element;
|
|
11
|
+
/** A number written with any `Intl.NumberFormatOptions` — a currency, a percentage, fixed
|
|
12
|
+
* decimals — in the session locale. The same options `NumberInput` takes, so a value reads
|
|
13
|
+
* the same in its form and in the cell or the figure that shows it elsewhere. */
|
|
14
|
+
export declare const formatWith: (value: number, options: Intl.NumberFormatOptions, locale?: string) => string;
|
|
10
15
|
/**
|
|
11
16
|
* A number with its unit: right aligned, with tabular figures.
|
|
12
17
|
*
|
|
@@ -14,11 +19,24 @@ export declare function KeyValue({ label, value }: {
|
|
|
14
19
|
* the same width, so `32.561` lines up across rows exactly as it did in monospace — without
|
|
15
20
|
* making a business report look like a terminal. The unit stays in the same face so the
|
|
16
21
|
* digits keep their column.
|
|
22
|
+
*
|
|
23
|
+
* `format` is a preset or any `Intl.NumberFormatOptions`: `{ style: "currency", currency:
|
|
24
|
+
* "BOB" }` writes «Bs 1.234,50», `{ style: "percent" }` takes 0–1, `{ minimumFractionDigits:
|
|
25
|
+
* 2, maximumFractionDigits: 2 }` rounds to two. `align="left"` for a figure that is not in a
|
|
26
|
+
* column.
|
|
17
27
|
*/
|
|
18
|
-
export declare function Numeric({ value, unit, format, }: {
|
|
28
|
+
export declare function Numeric({ value, unit, format, align, rounding, signed, }: {
|
|
19
29
|
value: number;
|
|
20
30
|
unit?: string;
|
|
21
|
-
format?: "integer" | "decimal" | "distance";
|
|
31
|
+
format?: "integer" | "decimal" | "distance" | Intl.NumberFormatOptions;
|
|
32
|
+
align?: "right" | "left";
|
|
33
|
+
/** Round the VALUE before writing it — to 0,05 for cash, to the unit's precision. See
|
|
34
|
+
* `rounding.ts`: the format alone only rounds what is drawn. */
|
|
35
|
+
rounding?: Rounding;
|
|
36
|
+
/** A figure whose sign is the point — a balance, a difference, a variation: it carries
|
|
37
|
+
* «+» or «−» always, and a negative is drawn in the danger tone (the sign says it too, so
|
|
38
|
+
* it never rests on the color alone). */
|
|
39
|
+
signed?: boolean;
|
|
22
40
|
}): import("react").JSX.Element;
|
|
23
41
|
/**
|
|
24
42
|
* The content of a data cell, with what the system decides and the view does not.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { currentCopyLocale, useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { roundTo } from "./rounding.js";
|
|
4
5
|
/**
|
|
5
6
|
* Context: the mono/sans rule decides everything in here. The label goes in
|
|
6
7
|
* PROPORTIONAL —«orígenes», «mediana» are words, not data— and the value in
|
|
@@ -31,6 +32,17 @@ export const formatDistance = (value, locale = currentCopyLocale()) => distanceF
|
|
|
31
32
|
export function KeyValue({ label, value }) {
|
|
32
33
|
return (_jsxs("div", { className: "min-w-0", children: [_jsx("dt", { className: "text-micro text-muted", children: label }), _jsx("dd", { className: "mt-0.5 truncate text-lead tabular-nums text-ink", children: value })] }));
|
|
33
34
|
}
|
|
35
|
+
/** The three presets, as the options they are — so `signed` and a custom format share one
|
|
36
|
+
* path. Kept in step with `integerFormat`, `decimalFormat` and `distanceFormat` above. */
|
|
37
|
+
const PRESET = {
|
|
38
|
+
integer: { maximumFractionDigits: 0 },
|
|
39
|
+
decimal: { maximumFractionDigits: 2 },
|
|
40
|
+
distance: { minimumFractionDigits: 4, maximumFractionDigits: 4 },
|
|
41
|
+
};
|
|
42
|
+
/** A number written with any `Intl.NumberFormatOptions` — a currency, a percentage, fixed
|
|
43
|
+
* decimals — in the session locale. The same options `NumberInput` takes, so a value reads
|
|
44
|
+
* the same in its form and in the cell or the figure that shows it elsewhere. */
|
|
45
|
+
export const formatWith = (value, options, locale = currentCopyLocale()) => new Intl.NumberFormat(locale, options).format(value);
|
|
34
46
|
/**
|
|
35
47
|
* A number with its unit: right aligned, with tabular figures.
|
|
36
48
|
*
|
|
@@ -38,14 +50,21 @@ export function KeyValue({ label, value }) {
|
|
|
38
50
|
* the same width, so `32.561` lines up across rows exactly as it did in monospace — without
|
|
39
51
|
* making a business report look like a terminal. The unit stays in the same face so the
|
|
40
52
|
* digits keep their column.
|
|
53
|
+
*
|
|
54
|
+
* `format` is a preset or any `Intl.NumberFormatOptions`: `{ style: "currency", currency:
|
|
55
|
+
* "BOB" }` writes «Bs 1.234,50», `{ style: "percent" }` takes 0–1, `{ minimumFractionDigits:
|
|
56
|
+
* 2, maximumFractionDigits: 2 }` rounds to two. `align="left"` for a figure that is not in a
|
|
57
|
+
* column.
|
|
41
58
|
*/
|
|
42
|
-
export function Numeric({ value, unit, format = "integer", }) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
export function Numeric({ value, unit, format = "integer", align = "right", rounding, signed = false, }) {
|
|
60
|
+
/* The locale of the copy in context, as `NumberInput` reads it — not only the installed
|
|
61
|
+
one: inside a `UiCopyProvider` the cell and the form next to it write the same digits. */
|
|
62
|
+
const { locale } = useUiCopy();
|
|
63
|
+
const shown = rounding === undefined ? value : roundTo(value, rounding.increment, rounding.method);
|
|
64
|
+
const options = typeof format === "object" ? format : PRESET[format];
|
|
65
|
+
const text = formatWith(shown, signed ? { ...options, signDisplay: "exceptZero" } : options, locale);
|
|
66
|
+
const negative = signed && shown < 0;
|
|
67
|
+
return (_jsxs("span", { className: `${align === "left" ? "inline-block text-left" : "block text-right"} text-body tabular-nums whitespace-nowrap ${negative ? "text-danger" : "text-ink"}`, children: [_jsx("span", { className: "tabular-nums", children: text }), unit === undefined ? null : _jsx("span", { className: "ml-1 text-muted", children: unit })] }));
|
|
49
68
|
}
|
|
50
69
|
/**
|
|
51
70
|
* The content of a data cell, with what the system decides and the view does not.
|
|
@@ -5,5 +5,7 @@ export { Metric, type MetricProps, Metrics } from "./Metric.js";
|
|
|
5
5
|
export { Avatar, type AvatarSize, type PersonContact, UserChip, type UserChipProps, UserStack, } from "./Person.js";
|
|
6
6
|
export { ProgressBar, type ProgressBarProps, } from "./ProgressBar.js";
|
|
7
7
|
export { RelationCard, type RelationCardProps, type RelationSummary, RelationValue, type RelationValueProps, } from "./Relation.js";
|
|
8
|
+
export { isRichTextEmpty, richTextPlain, RichTextValue, sanitizeRichText, } from "./RichText.js";
|
|
9
|
+
export { type Rounding, type RoundingMethod, roundTo, } from "./rounding.js";
|
|
8
10
|
export { Totals, type TotalsCredit, type TotalsDeduction, type TotalsFact, type TotalsProps, type TotalsRow, } from "./Totals.js";
|
|
9
|
-
export { Cell, formatCount, formatDistance, formatNumber, KeyValue, Numeric, } from "./Value.js";
|
|
11
|
+
export { Cell, formatCount, formatDistance, formatNumber, formatWith, KeyValue, Numeric, } from "./Value.js";
|
|
@@ -6,5 +6,8 @@ export { Metric, Metrics } from "./Metric.js";
|
|
|
6
6
|
export { Avatar, UserChip, UserStack, } from "./Person.js";
|
|
7
7
|
export { ProgressBar, } from "./ProgressBar.js";
|
|
8
8
|
export { RelationCard, RelationValue, } from "./Relation.js";
|
|
9
|
+
/* The HTML of a note, read — cleaned of anything that runs. */
|
|
10
|
+
export { isRichTextEmpty, richTextPlain, RichTextValue, sanitizeRichText, } from "./RichText.js";
|
|
11
|
+
export { roundTo, } from "./rounding.js";
|
|
9
12
|
export { Totals, } from "./Totals.js";
|
|
10
|
-
export { Cell, formatCount, formatDistance, formatNumber, KeyValue, Numeric, } from "./Value.js";
|
|
13
|
+
export { Cell, formatCount, formatDistance, formatNumber, formatWith, KeyValue, Numeric, } from "./Value.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type RoundingMethod = "half-up" | "half-even" | "up" | "down";
|
|
2
|
+
export type Rounding = {
|
|
3
|
+
/** The multiple: 0.05 rounds to five cents, 1 to a whole unit, 0.001 to grams. */
|
|
4
|
+
increment: number;
|
|
5
|
+
/** `half-up` by default — what a price expects. `up`/`down` are away from/towards zero. */
|
|
6
|
+
method?: RoundingMethod;
|
|
7
|
+
};
|
|
8
|
+
/** `value` rounded to a multiple of `increment`, by `method`. */
|
|
9
|
+
export declare function roundTo(value: number, increment: number, method?: RoundingMethod): number;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* ==================================================================== *
|
|
2
|
+
* ROUNDING — to a multiple, and saying how.
|
|
3
|
+
*
|
|
4
|
+
* Context: `maximumFractionDigits` rounds what is DRAWN; the value underneath
|
|
5
|
+
* keeps its tail, and a total of drawn values then differs from the drawn
|
|
6
|
+
* total by a cent. An ERP rounds the VALUE, and not always to a decimal: cash
|
|
7
|
+
* to 0,05 (Bolivia has no 1-centavo coin in circulation), a unit of measure to
|
|
8
|
+
* its precision (a box is 1, a kilo 0,001), a price to 0,10. And the method
|
|
9
|
+
* is part of the rule: half up for a price, half even for statistics, up for
|
|
10
|
+
* what is charged per started hour, down for what is paid.
|
|
11
|
+
*
|
|
12
|
+
* Pure, in the display tier so a value that shows (`Numeric`) and a control
|
|
13
|
+
* that asks (`NumberInput`) round with the same function.
|
|
14
|
+
* ==================================================================== */
|
|
15
|
+
/** How many decimals a number is written with: 0.05 → 2. Used to cut the binary tail that
|
|
16
|
+
* `0.1 + 0.2` leaves, so 1.15 rounded to 0.05 is 1.15 and not 1.1500000000000001. */
|
|
17
|
+
function decimalsOf(value) {
|
|
18
|
+
const text = String(value);
|
|
19
|
+
if (text.includes("e-"))
|
|
20
|
+
return Number(text.split("e-")[1]);
|
|
21
|
+
return text.includes(".") ? (text.split(".")[1]?.length ?? 0) : 0;
|
|
22
|
+
}
|
|
23
|
+
/** `value` rounded to a multiple of `increment`, by `method`. */
|
|
24
|
+
export function roundTo(value, increment, method = "half-up") {
|
|
25
|
+
if (!Number.isFinite(value) || !(increment > 0))
|
|
26
|
+
return value;
|
|
27
|
+
const decimals = decimalsOf(increment);
|
|
28
|
+
/* The quotient, cleaned of the float tail before deciding: 1.15 / 0.05 is 22.999999… */
|
|
29
|
+
const steps = Number((Math.abs(value) / increment).toFixed(9));
|
|
30
|
+
const whole = Math.floor(steps);
|
|
31
|
+
const fraction = Number((steps - whole).toFixed(9));
|
|
32
|
+
let rounded;
|
|
33
|
+
switch (method) {
|
|
34
|
+
case "up":
|
|
35
|
+
rounded = fraction > 0 ? whole + 1 : whole;
|
|
36
|
+
break;
|
|
37
|
+
case "down":
|
|
38
|
+
rounded = whole;
|
|
39
|
+
break;
|
|
40
|
+
case "half-even":
|
|
41
|
+
rounded = fraction > 0.5 || (fraction === 0.5 && whole % 2 === 1) ? whole + 1 : whole;
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
rounded = fraction >= 0.5 ? whole + 1 : whole;
|
|
45
|
+
}
|
|
46
|
+
const result = Number((rounded * increment).toFixed(decimals));
|
|
47
|
+
return value < 0 ? -result : result;
|
|
48
|
+
}
|