@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.
Files changed (101) 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/lines/LineItems.d.ts +14 -3
  14. package/dist/components/lines/LineItems.js +6 -4
  15. package/dist/components/listing/SortMenu.d.ts +24 -20
  16. package/dist/components/listing/SortMenu.js +51 -91
  17. package/dist/components/listing/index.d.ts +2 -1
  18. package/dist/components/listing/index.js +2 -1
  19. package/dist/components/listing/modelCells.d.ts +7 -0
  20. package/dist/components/listing/modelCells.js +56 -0
  21. package/dist/components/listing/modelColumns.d.ts +9 -0
  22. package/dist/components/listing/modelColumns.js +59 -14
  23. package/dist/components/search/index.d.ts +1 -1
  24. package/dist/components/search/index.js +1 -1
  25. package/dist/components/search/model.d.ts +1 -0
  26. package/dist/components/search/model.js +7 -0
  27. package/dist/components/search/query.js +2 -3
  28. package/dist/foundations/copy.d.ts +131 -0
  29. package/dist/foundations/copy.es.js +86 -0
  30. package/dist/foundations/copy.js +86 -0
  31. package/dist/foundations/form.d.ts +18 -1
  32. package/dist/foundations/form.js +6 -3
  33. package/dist/primitives/button/Button.js +3 -1
  34. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  35. package/dist/primitives/field/CardNumberInput.js +117 -0
  36. package/dist/primitives/field/CodeInput.d.ts +18 -0
  37. package/dist/primitives/field/CodeInput.js +37 -0
  38. package/dist/primitives/field/ColorInput.d.ts +21 -0
  39. package/dist/primitives/field/ColorInput.js +42 -0
  40. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  41. package/dist/primitives/field/ContactInputs.js +70 -0
  42. package/dist/primitives/field/DurationInput.d.ts +20 -0
  43. package/dist/primitives/field/DurationInput.js +54 -0
  44. package/dist/primitives/field/Field.d.ts +24 -4
  45. package/dist/primitives/field/Field.js +17 -4
  46. package/dist/primitives/field/ImageInput.d.ts +51 -0
  47. package/dist/primitives/field/ImageInput.js +267 -0
  48. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  49. package/dist/primitives/field/PasswordInput.js +44 -0
  50. package/dist/primitives/field/PatternInput.d.ts +23 -0
  51. package/dist/primitives/field/PatternInput.js +48 -0
  52. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  53. package/dist/primitives/field/RichTextEditor.js +224 -0
  54. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  55. package/dist/primitives/field/RichTextInput.js +38 -0
  56. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  57. package/dist/primitives/field/SignatureInput.js +215 -0
  58. package/dist/primitives/field/card.d.ts +33 -0
  59. package/dist/primitives/field/card.js +173 -0
  60. package/dist/primitives/field/clipboard.js +3 -2
  61. package/dist/primitives/field/contact.d.ts +17 -0
  62. package/dist/primitives/field/contact.js +65 -0
  63. package/dist/primitives/field/duration.d.ts +5 -0
  64. package/dist/primitives/field/duration.js +48 -0
  65. package/dist/primitives/field/index.d.ts +14 -0
  66. package/dist/primitives/field/index.js +20 -0
  67. package/dist/primitives/field/mask.d.ts +33 -0
  68. package/dist/primitives/field/mask.js +56 -0
  69. package/dist/primitives/field/media.d.ts +99 -0
  70. package/dist/primitives/field/media.js +136 -0
  71. package/dist/primitives/field/pattern.d.ts +10 -0
  72. package/dist/primitives/field/pattern.js +68 -0
  73. package/dist/primitives/menu/Menu.d.ts +16 -0
  74. package/dist/primitives/menu/Menu.js +26 -8
  75. package/dist/primitives/menu/index.d.ts +1 -1
  76. package/dist/primitives/menu/index.js +1 -1
  77. package/dist/primitives/table/DataTable.d.ts +5 -3
  78. package/dist/primitives/table/DataTable.js +2 -4
  79. package/dist/primitives/table/EditableTable.d.ts +49 -7
  80. package/dist/primitives/table/EditableTable.js +321 -82
  81. package/dist/primitives/table/Table.d.ts +5 -8
  82. package/dist/primitives/table/Table.js +63 -15
  83. package/dist/primitives/table/index.d.ts +3 -2
  84. package/dist/primitives/table/index.js +2 -1
  85. package/dist/primitives/table/rowWindow.d.ts +3 -0
  86. package/dist/primitives/table/rowWindow.js +4 -0
  87. package/dist/primitives/table/sorting.d.ts +42 -0
  88. package/dist/primitives/table/sorting.js +110 -0
  89. package/dist/primitives/value/RichText.d.ts +10 -0
  90. package/dist/primitives/value/RichText.js +130 -0
  91. package/dist/primitives/value/Value.d.ts +20 -2
  92. package/dist/primitives/value/Value.js +26 -7
  93. package/dist/primitives/value/index.d.ts +3 -1
  94. package/dist/primitives/value/index.js +4 -1
  95. package/dist/primitives/value/rounding.d.ts +9 -0
  96. package/dist/primitives/value/rounding.js +48 -0
  97. package/dist/tokens.css +103 -0
  98. package/dist/views/list/List.d.ts +5 -4
  99. package/dist/views/list/ListView.js +2 -2
  100. package/dist/views/list/types.d.ts +6 -6
  101. 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 the sorted one: an
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 active = sortKey !== undefined && sort?.key === sortKey;
36
- const dir = active ? sort.direction : undefined;
37
- function nextSort() {
38
- if (sortKey === undefined || onSort === undefined)
39
- return;
40
- // asc → desc → unsorted. The third click puts the table back the way it was.
41
- if (!active)
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": active ? (dir === "asc" ? "ascending" : "descending") : "none", className: `border-b border-line-strong px-3 py-2 ${HEADER_TEXT} ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", onClick: nextSort, className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${(align ?? (numeric ? "right" : "left")) === "right" ? "flex-row-reverse" : ""} ${active ? "text-ink" : ""}`, children: [children, _jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${active ? "text-primary" : "text-line-strong"}`, strokeWidth: 2, "aria-hidden": true })] })) }));
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 { CELL_TONE, type SortDirection, type SortState, Table, Td, Th, THead, Tr, } from "./Table.js";
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 { CELL_TONE, Table, Td, Th, THead, Tr, } from "./Table.js";
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(/&nbsp;/g, " ")
102
+ .replace(/&amp;/g, "&")
103
+ .replace(/&lt;/g, "<")
104
+ .replace(/&gt;/g, ">")
105
+ .replace(/&quot;/g, '"')
106
+ .replace(/&#39;/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
- const text = format === "distance"
44
- ? formatDistance(value)
45
- : format === "decimal"
46
- ? formatNumber(value)
47
- : formatCount(value);
48
- return (_jsxs("span", { className: "block text-right text-body tabular-nums whitespace-nowrap text-ink", children: [_jsx("span", { className: "tabular-nums", children: text }), unit === undefined ? null : _jsx("span", { className: "ml-1 text-muted", children: unit })] }));
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
+ }