@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.
- 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/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/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/Table.d.ts +5 -8
- package/dist/primitives/table/Table.js +63 -15
- package/dist/primitives/table/index.d.ts +2 -1
- package/dist/primitives/table/index.js +2 -1
- package/dist/primitives/table/sorting.d.ts +42 -0
- package/dist/primitives/table/sorting.js +110 -0
- package/dist/primitives/value/RichText.d.ts +10 -0
- package/dist/primitives/value/RichText.js +130 -0
- package/dist/primitives/value/Value.d.ts +20 -2
- package/dist/primitives/value/Value.js +26 -7
- package/dist/primitives/value/index.d.ts +3 -1
- package/dist/primitives/value/index.js +4 -1
- package/dist/primitives/value/rounding.d.ts +9 -0
- package/dist/primitives/value/rounding.js +48 -0
- package/dist/tokens.css +103 -0
- package/dist/views/list/List.d.ts +5 -4
- package/dist/views/list/ListView.js +2 -2
- package/dist/views/list/types.d.ts +6 -6
- package/package.json +9 -1
|
@@ -0,0 +1,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
|
+
}
|
package/dist/tokens.css
CHANGED
|
@@ -1237,3 +1237,106 @@
|
|
|
1237
1237
|
writing-mode: vertical-rl;
|
|
1238
1238
|
transform: rotate(180deg);
|
|
1239
1239
|
}
|
|
1240
|
+
|
|
1241
|
+
/* ==================================================================== *
|
|
1242
|
+
* RICH TEXT — the HTML of a note, a description, an email body.
|
|
1243
|
+
*
|
|
1244
|
+
* The SAME rules for the editor (`RichTextInput`, Tiptap) and for reading it
|
|
1245
|
+
* (`RichTextValue`): what is written is what is read, to the line. HTML that
|
|
1246
|
+
* arrives from an engine (Odoo `html` fields) has no classes, so the look is
|
|
1247
|
+
* given by the element — and only with tokens, so it follows the theme.
|
|
1248
|
+
* ==================================================================== */
|
|
1249
|
+
@utility rich-text {
|
|
1250
|
+
font-size: var(--text-meta);
|
|
1251
|
+
line-height: var(--text-meta--line-height);
|
|
1252
|
+
color: var(--color-ink);
|
|
1253
|
+
overflow-wrap: anywhere;
|
|
1254
|
+
& > * + * {
|
|
1255
|
+
margin-top: calc(var(--spacing) * 2);
|
|
1256
|
+
}
|
|
1257
|
+
& h2 {
|
|
1258
|
+
font-size: var(--text-lead);
|
|
1259
|
+
line-height: var(--text-lead--line-height);
|
|
1260
|
+
font-weight: 600;
|
|
1261
|
+
}
|
|
1262
|
+
& h3 {
|
|
1263
|
+
font-size: var(--text-body);
|
|
1264
|
+
line-height: var(--text-body--line-height);
|
|
1265
|
+
font-weight: 600;
|
|
1266
|
+
}
|
|
1267
|
+
& ul {
|
|
1268
|
+
list-style: disc;
|
|
1269
|
+
padding-left: calc(var(--spacing) * 5);
|
|
1270
|
+
}
|
|
1271
|
+
& ol {
|
|
1272
|
+
list-style: decimal;
|
|
1273
|
+
padding-left: calc(var(--spacing) * 5);
|
|
1274
|
+
}
|
|
1275
|
+
& li > p {
|
|
1276
|
+
margin: 0;
|
|
1277
|
+
}
|
|
1278
|
+
& blockquote {
|
|
1279
|
+
border-left: calc(var(--spacing) * 0.5) solid var(--color-line-strong);
|
|
1280
|
+
padding-left: calc(var(--spacing) * 3);
|
|
1281
|
+
color: var(--color-muted);
|
|
1282
|
+
}
|
|
1283
|
+
& a {
|
|
1284
|
+
color: var(--color-primary);
|
|
1285
|
+
text-decoration: underline;
|
|
1286
|
+
text-underline-offset: calc(var(--spacing) * 0.5);
|
|
1287
|
+
}
|
|
1288
|
+
& code {
|
|
1289
|
+
font-family: var(--font-mono);
|
|
1290
|
+
background: color-mix(in oklab, var(--color-ink) 6%, transparent);
|
|
1291
|
+
border-radius: var(--radius-mark);
|
|
1292
|
+
padding: 0 calc(var(--spacing) * 1);
|
|
1293
|
+
}
|
|
1294
|
+
& pre {
|
|
1295
|
+
font-family: var(--font-mono);
|
|
1296
|
+
background: color-mix(in oklab, var(--color-ink) 6%, transparent);
|
|
1297
|
+
border-radius: var(--radius-control);
|
|
1298
|
+
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
|
1299
|
+
overflow-x: auto;
|
|
1300
|
+
}
|
|
1301
|
+
& pre code {
|
|
1302
|
+
background: none;
|
|
1303
|
+
padding: 0;
|
|
1304
|
+
}
|
|
1305
|
+
& hr {
|
|
1306
|
+
border: none;
|
|
1307
|
+
border-top: calc(var(--spacing) * 0.25) solid var(--color-line);
|
|
1308
|
+
}
|
|
1309
|
+
& table {
|
|
1310
|
+
border-collapse: collapse;
|
|
1311
|
+
width: 100%;
|
|
1312
|
+
}
|
|
1313
|
+
& th,
|
|
1314
|
+
& td {
|
|
1315
|
+
border: calc(var(--spacing) * 0.25) solid var(--color-line-strong);
|
|
1316
|
+
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 2);
|
|
1317
|
+
text-align: left;
|
|
1318
|
+
vertical-align: top;
|
|
1319
|
+
}
|
|
1320
|
+
& th {
|
|
1321
|
+
font-weight: 600;
|
|
1322
|
+
background: var(--color-ground);
|
|
1323
|
+
}
|
|
1324
|
+
/* A person named in the text: the same tint as a link, so it reads as «someone», and a
|
|
1325
|
+
box so it is one thing that is deleted at once. */
|
|
1326
|
+
& [data-type="mention"] {
|
|
1327
|
+
color: var(--color-primary);
|
|
1328
|
+
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
|
1329
|
+
border-radius: var(--radius-mark);
|
|
1330
|
+
padding: 0 calc(var(--spacing) * 0.5);
|
|
1331
|
+
}
|
|
1332
|
+
& p.is-editor-empty:first-child::before {
|
|
1333
|
+
content: attr(data-placeholder);
|
|
1334
|
+
color: var(--color-muted);
|
|
1335
|
+
float: left;
|
|
1336
|
+
height: 0;
|
|
1337
|
+
pointer-events: none;
|
|
1338
|
+
}
|
|
1339
|
+
& .selectedCell {
|
|
1340
|
+
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { ViewIcon } from "../../components/listing/ViewSwitcher.js";
|
|
|
5
5
|
import type { Surface } from "../../foundations/types.js";
|
|
6
6
|
import type { DataColumn, DataTableCreate } from "../../primitives/table/DataTable.js";
|
|
7
7
|
import type { RowAction } from "../../primitives/table/RowActions.js";
|
|
8
|
-
import type { SortState } from "../../primitives/table/
|
|
8
|
+
import type { SortState } from "../../primitives/table/sorting.js";
|
|
9
9
|
import { type CardWays, type ListCalendar, type ListCrumb, type ListEmpty, type ListGantt, type ListGroup, type ListPivot, type ListPresent, type ListReport, type ListSet, type ListSheet, type ListTimeline, type RowOf } from "./types.js";
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
11
|
type BarProps = {
|
|
@@ -97,9 +97,10 @@ export type ListProps<T extends object> = {
|
|
|
97
97
|
onSelect: () => void;
|
|
98
98
|
};
|
|
99
99
|
};
|
|
100
|
-
/** El orden, que también es del conjunto
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
/** El orden, que también es del conjunto: una lista de criterios, el primero manda y los
|
|
101
|
+
* demás desempatan. Vacío es «como llegaron». */
|
|
102
|
+
sort?: readonly SortState[];
|
|
103
|
+
onSortChange?: (next: SortState[]) => void;
|
|
103
104
|
/** Por qué campos se puede ordenar en las caras que NO tienen encabezados
|
|
104
105
|
* donde hacer clic. */
|
|
105
106
|
sortable?: readonly SortField<T>[];
|
|
@@ -147,7 +147,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
|
|
|
147
147
|
const sortMenu = sortable === undefined ||
|
|
148
148
|
sortable.length === 0 ||
|
|
149
149
|
declaredReport ||
|
|
150
|
-
present === "pivot" ? null : (_jsx(SortMenu, { fields: sortable, sort: sort ??
|
|
150
|
+
present === "pivot" ? null : (_jsx(SortMenu, { fields: sortable, sort: sort ?? [], onSortChange: (next) => onSortChange?.(next), ...(surface === undefined ? {} : { surface }) }));
|
|
151
151
|
const rightViews = views ?? switcher;
|
|
152
152
|
const viewsSlot = columnsMenu === null && sortMenu === null && rightViews === undefined ? undefined : (_jsxs(_Fragment, { children: [sortMenu, columnsMenu, rightViews] }));
|
|
153
153
|
/* Embedded, the line is drawn only if it has something to OFFER: a count alone repeats
|
|
@@ -202,7 +202,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
|
|
|
202
202
|
veces no rompe nada, pero es trabajo al pedo sobre cada render. */
|
|
203
203
|
const ordered = useMemo(() => present === "table" || sortable === undefined
|
|
204
204
|
? items
|
|
205
|
-
: sortItems(items, sortable, sort
|
|
205
|
+
: sortItems(items, sortable, sort), [items, present, sortable, sort]);
|
|
206
206
|
/**
|
|
207
207
|
* La grilla que hay que dibujar: la que le pasaron plegada, o la que se arma
|
|
208
208
|
* con las filas en la mano.
|
|
@@ -14,7 +14,7 @@ import type { DataColumn, DataTableCreate } from "../../primitives/table/DataTab
|
|
|
14
14
|
import type { ReportColumn, ReportRow } from "../../primitives/table/ReportTable.js";
|
|
15
15
|
import type { RowAction } from "../../primitives/table/RowActions.js";
|
|
16
16
|
import type { SheetColumn } from "../../primitives/table/SheetTable.js";
|
|
17
|
-
import type { SortState } from "../../primitives/table/
|
|
17
|
+
import type { SortState } from "../../primitives/table/sorting.js";
|
|
18
18
|
import type { LucideIcon } from "lucide-react";
|
|
19
19
|
import type { ReactNode } from "react";
|
|
20
20
|
/**
|
|
@@ -276,11 +276,11 @@ export type ListViewProps<T extends object> = {
|
|
|
276
276
|
* Owned by the view when omitted — catalog filters must not reset them. */
|
|
277
277
|
visibleColumnIds?: readonly string[];
|
|
278
278
|
onVisibleColumnIdsChange?: (ids: readonly string[]) => void;
|
|
279
|
-
/** The
|
|
280
|
-
* and not to the presentation: switching from table to
|
|
281
|
-
* lose it. */
|
|
282
|
-
sort?: SortState
|
|
283
|
-
onSortChange?: (next: SortState
|
|
279
|
+
/** The order of the set, outermost first: «by date, then by customer». It lives outside
|
|
280
|
+
* because it belongs to the SET and not to the presentation: switching from table to
|
|
281
|
+
* rows has no reason to lose it. Empty or absent is «as they came». */
|
|
282
|
+
sort?: readonly SortState[];
|
|
283
|
+
onSortChange?: (next: SortState[]) => void;
|
|
284
284
|
/**
|
|
285
285
|
* Por qué campos se puede ordenar, para las presentaciones que NO tienen
|
|
286
286
|
* encabezados donde hacer clic.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sincpro/web-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "An enterprise web design system for business software — ERPs, back offices, management tools — in React and Tailwind: tokens, primitives, components and the two views every management screen is made of.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,7 +56,15 @@
|
|
|
56
56
|
"@fullcalendar/react": "7.1.3",
|
|
57
57
|
"@internationalized/date": "^3.12.4",
|
|
58
58
|
"@svar-ui/react-gantt": "2.8.2",
|
|
59
|
+
"@tiptap/extension-mention": "^3.32.2",
|
|
60
|
+
"@tiptap/extension-table": "^3.32.2",
|
|
61
|
+
"@tiptap/extensions": "^3.32.2",
|
|
62
|
+
"@tiptap/pm": "^3.32.2",
|
|
63
|
+
"@tiptap/react": "^3.32.2",
|
|
64
|
+
"@tiptap/starter-kit": "^3.32.2",
|
|
65
|
+
"@tiptap/suggestion": "^3.32.2",
|
|
59
66
|
"@uiw/react-codemirror": "^4.25.11",
|
|
67
|
+
"dompurify": "^3.4.17",
|
|
60
68
|
"lucide-react": "^1.45.0",
|
|
61
69
|
"react-aria": "^3.53.1",
|
|
62
70
|
"react-aria-components": "^1.22.1",
|