@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
@@ -0,0 +1,215 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { useFieldIds, useFormMode } from "../../foundations/form.js";
5
+ import { cn } from "../../foundations/variants.js";
6
+ import { Button } from "../button/Button.js";
7
+ import { controlClasses, nameOf, ReadValue, } from "./Field.js";
8
+ import { capturePointer, fitFontSize, strokeSteps, } from "./media.js";
9
+ import { Eraser, PenLine, Type } from "lucide-react";
10
+ import { useEffect, useId, useRef, useState } from "react";
11
+ /** The pen, in CSS pixels: the weight of a ballpoint at the size the pad is drawn. */
12
+ const PEN = 2.25;
13
+ /** Where the line sits, from the top of the pad — the same fraction the drawn line uses
14
+ * (`bottom-1/4`), so a typed name rests on the line the person sees. */
15
+ const BASELINE = 0.75;
16
+ /** The gap between the pad's edge and the start of a typed name — `inset-x-4`. */
17
+ const INSET = 16;
18
+ /** The pen's colour: the INK token of the paper the canvas is on, read when it paints. The
19
+ * pad is a light subtree (see below), so this is the dark ink in either theme. */
20
+ function inkOf(el) {
21
+ const style = getComputedStyle(el);
22
+ return style.getPropertyValue("--color-ink").trim() || style.color;
23
+ }
24
+ function paint(canvas, strokes) {
25
+ const ctx = canvas.getContext("2d");
26
+ if (ctx === null)
27
+ return;
28
+ const dpr = canvas.clientWidth === 0 ? 1 : canvas.width / canvas.clientWidth;
29
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
30
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
31
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32
+ const ink = inkOf(canvas);
33
+ ctx.strokeStyle = ink;
34
+ ctx.fillStyle = ink;
35
+ ctx.lineWidth = PEN;
36
+ ctx.lineCap = "round";
37
+ ctx.lineJoin = "round";
38
+ for (const stroke of strokes) {
39
+ ctx.beginPath();
40
+ for (const step of strokeSteps(stroke)) {
41
+ if (step.kind === "dot") {
42
+ ctx.arc(step.at.x, step.at.y, PEN / 2, 0, Math.PI * 2);
43
+ ctx.fill();
44
+ }
45
+ else if (step.kind === "move")
46
+ ctx.moveTo(step.to.x, step.to.y);
47
+ else if (step.kind === "quad") {
48
+ ctx.quadraticCurveTo(step.control.x, step.control.y, step.to.x, step.to.y);
49
+ }
50
+ else
51
+ ctx.lineTo(step.to.x, step.to.y);
52
+ }
53
+ ctx.stroke();
54
+ }
55
+ }
56
+ /** A typed name, written as a signature: the system's font in italic, on the line, as big as
57
+ * the pad allows and smaller when the name is long. */
58
+ function typedSignature(text, pad) {
59
+ const dpr = window.devicePixelRatio || 1;
60
+ const width = pad.clientWidth || 480;
61
+ const height = pad.clientHeight || 128;
62
+ const canvas = document.createElement("canvas");
63
+ canvas.width = Math.round(width * dpr);
64
+ canvas.height = Math.round(height * dpr);
65
+ const ctx = canvas.getContext("2d");
66
+ if (ctx === null)
67
+ return "";
68
+ ctx.scale(dpr, dpr);
69
+ const family = getComputedStyle(pad).fontFamily;
70
+ const font = (size) => `italic 400 ${size}px ${family}`;
71
+ const base = Math.round(height * 0.42);
72
+ ctx.font = font(base);
73
+ const size = fitFontSize(ctx.measureText(text).width, base, width - INSET * 2);
74
+ ctx.font = font(size);
75
+ ctx.fillStyle = inkOf(pad);
76
+ ctx.textBaseline = "alphabetic";
77
+ ctx.fillText(text, INSET, height * BASELINE - 2);
78
+ return canvas.toDataURL("image/png");
79
+ }
80
+ /**
81
+ * A signature: drawn with the mouse, a pen or a finger — or typed, for whoever cannot draw.
82
+ *
83
+ * Context: the pad is a canvas that follows the pointer (`touch-none`, so a finger draws
84
+ * instead of scrolling the page) and smooths each stroke through the midpoints of its
85
+ * samples (`strokeSteps`) — no pressure, no library, and no corners. When a stroke ends the
86
+ * pad is exported as a PNG data URL and handed to `onChange`; «Limpiar» empties it and
87
+ * hands `null`.
88
+ *
89
+ * **A canvas alone shuts people out.** Whoever works with a keyboard or a screen reader, or
90
+ * whose hand does not hold a pointer steady, cannot draw. «Escribir mi nombre» turns the pad
91
+ * into a text box: the name typed (starting from `name`, when the form knows who signs) is
92
+ * written into the same PNG in the system's font, in italic, on the same line — the value
93
+ * has the same shape whichever way it was made, and the form does not have to know.
94
+ *
95
+ * **The pad is paper.** It is a forced-light subtree (`data-theme="light"`): the pen reads
96
+ * `--color-ink` there, so a signature made in the dark theme is still dark ink on a
97
+ * transparent PNG — the thing a printed contract or a PDF expects — and read mode lays it on
98
+ * the same paper, so it is legible on either theme.
99
+ *
100
+ * A value that did not come from this pad (a signature already saved) is shown as it is,
101
+ * not drawn over: «Limpiar» starts a new one. In read mode it draws the image.
102
+ */
103
+ export function SignatureInput({ id: ownId, value, onChange, name = "", alt, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, mode, "aria-label": ariaLabel, }) {
104
+ const copy = useUiCopy();
105
+ const { id, labelledBy, describedBy, invalid } = useFieldIds({
106
+ id: ownId,
107
+ describedBy: ownDescribedBy,
108
+ invalid: ownInvalid,
109
+ });
110
+ const readMode = useFormMode(mode) === "read";
111
+ const [typing, setTyping] = useState(false);
112
+ const [typed, setTyped] = useState(name);
113
+ const pad = useRef(null);
114
+ const canvas = useRef(null);
115
+ const strokes = useRef([]);
116
+ const drawing = useRef(false);
117
+ /* The last value this pad handed out: a `value` equal to it is the pad's own work, and the
118
+ canvas keeps drawing it; anything else came from outside and is shown as an image. */
119
+ const emitted = useRef(null);
120
+ const own = useId();
121
+ const helpId = `${own}-help`;
122
+ const has = value !== null && value !== "";
123
+ const stored = has && value !== emitted.current;
124
+ const label = alt ?? copy.media.signature;
125
+ /* The canvas is as many device pixels as it is drawn at, or the line blurs on a dense
126
+ screen; a resize repaints the strokes, which are kept in CSS pixels. */
127
+ useEffect(() => {
128
+ const el = canvas.current;
129
+ if (el === null)
130
+ return;
131
+ const fit = () => {
132
+ const dpr = window.devicePixelRatio || 1;
133
+ el.width = Math.max(1, Math.round(el.clientWidth * dpr));
134
+ el.height = Math.max(1, Math.round(el.clientHeight * dpr));
135
+ paint(el, strokes.current);
136
+ };
137
+ fit();
138
+ const observer = new ResizeObserver(fit);
139
+ observer.observe(el);
140
+ return () => observer.disconnect();
141
+ }, [typing, stored, readMode]);
142
+ if (readMode) {
143
+ return has ? (_jsx("span", { "data-theme": "light", className: "inline-flex rounded-control border border-line bg-surface p-1", children: _jsx("img", { src: value, alt: label, className: "h-16 w-auto" }) })) : (_jsx(ReadValue, { value: null }));
144
+ }
145
+ const emit = (next) => {
146
+ emitted.current = next;
147
+ onChange?.(next);
148
+ };
149
+ const pointOf = (event) => {
150
+ const box = event.currentTarget.getBoundingClientRect();
151
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
152
+ };
153
+ function onPointerDown(event) {
154
+ if (disabled || (event.pointerType === "mouse" && event.button !== 0))
155
+ return;
156
+ capturePointer(event);
157
+ drawing.current = true;
158
+ strokes.current.push([pointOf(event)]);
159
+ paint(event.currentTarget, strokes.current);
160
+ }
161
+ function onPointerMove(event) {
162
+ if (!drawing.current)
163
+ return;
164
+ const stroke = strokes.current[strokes.current.length - 1];
165
+ if (stroke === undefined)
166
+ return;
167
+ /* The samples the browser merged into this event: a fast stroke between two frames is
168
+ several points, and drawing only the last one cuts its curves into chords. */
169
+ const native = event.nativeEvent;
170
+ const samples = typeof native.getCoalescedEvents === "function" ? native.getCoalescedEvents() : [];
171
+ const box = event.currentTarget.getBoundingClientRect();
172
+ if (samples.length > 0) {
173
+ for (const one of samples)
174
+ stroke.push({ x: one.clientX - box.left, y: one.clientY - box.top });
175
+ }
176
+ else
177
+ stroke.push(pointOf(event));
178
+ paint(event.currentTarget, strokes.current);
179
+ }
180
+ function onPointerUp(event) {
181
+ if (!drawing.current)
182
+ return;
183
+ drawing.current = false;
184
+ emit(event.currentTarget.toDataURL("image/png"));
185
+ }
186
+ function clear() {
187
+ strokes.current = [];
188
+ if (canvas.current !== null)
189
+ paint(canvas.current, []);
190
+ if (typing)
191
+ setTyped("");
192
+ emit(null);
193
+ }
194
+ function writeTyped(text) {
195
+ setTyped(text);
196
+ emit(text.trim() === "" || pad.current === null ? null : typedSignature(text, pad.current));
197
+ }
198
+ /* One signature, either way: switching starts the other one from scratch, and a typed name
199
+ that is already there is written at once. */
200
+ function switchMode() {
201
+ strokes.current = [];
202
+ if (typing) {
203
+ setTyping(false);
204
+ emit(null);
205
+ }
206
+ else {
207
+ setTyping(true);
208
+ writeTyped(typed);
209
+ }
210
+ }
211
+ const paper = cn("relative h-32 w-full overflow-clip rounded-control border bg-surface font-sans text-ink", invalid ? "border-danger" : "border-line-strong", disabled ? "bg-ground" : "");
212
+ return (_jsxs("div", { id: id, role: "group", "aria-labelledby": labelledBy, "aria-label": nameOf(labelledBy, ariaLabel), "aria-describedby": describedBy, className: "flex w-full min-w-0 flex-col gap-2", children: [_jsxs("div", { ref: pad, "data-theme": "light", className: paper, children: [stored || (typing && has) ? (_jsx("img", { src: value ?? "", alt: label, className: "size-full object-contain object-left" })) : typing ? null : (_jsx("canvas", { ref: canvas, role: "img", "aria-label": copy.media.signaturePad, "aria-describedby": helpId, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, className: cn("absolute inset-0 size-full touch-none", disabled ? "cursor-not-allowed" : "cursor-crosshair") })), _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-4 bottom-1/4 border-t border-line-strong" })] }), typing ? (_jsxs("label", { className: "flex flex-col gap-1 text-micro font-medium text-muted", children: [copy.media.typedName, _jsx("input", {
213
+ /* The person asked for this box by pressing the button: the focus goes there. */
214
+ autoFocus: true, value: typed, disabled: disabled, autoComplete: "name", onChange: (event) => writeTyped(event.target.value), className: `${controlClasses(invalid)} font-normal` })] })) : (_jsx("p", { id: helpId, className: "text-micro text-muted", children: copy.media.signatureHelp })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { size: "sm", icon: typing ? PenLine : Type, disabled: disabled, onClick: switchMode, children: typing ? copy.media.drawInstead : copy.media.typeName }), _jsx(Button, { role: "ghost", size: "sm", icon: Eraser, disabled: disabled, onClick: clear, children: copy.media.clear })] })] }));
215
+ }
@@ -0,0 +1,33 @@
1
+ export type CardBrand = "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | "unionpay" | "maestro";
2
+ /** The longest a card number gets, of any brand. */
3
+ export declare const CARD_MAX_DIGITS = 19;
4
+ /** Only the digits of what was typed or pasted: «4242 4242-4242» → «424242424242». */
5
+ export declare function cardDigits(text: string): string;
6
+ /** The brand the first digits say, or `null` while they do not say one yet. */
7
+ export declare function cardBrand(digits: string): CardBrand | null;
8
+ /** How the brand is written: «American Express». */
9
+ export declare function cardBrandName(brand: CardBrand): string;
10
+ /** The most digits a number of this brand takes — where typing stops. */
11
+ export declare function cardMaxDigits(digits: string, brand?: CardBrand | null): number;
12
+ /** The digits in the brand's groups: «378282246310005» → «3782 822463 10005». */
13
+ export declare function formatCardNumber(digits: string, brand?: CardBrand | null): string;
14
+ /** The Luhn check digit holds: a mistyped digit or two swapped neighbours fail it. */
15
+ export declare function luhnValid(digits: string): boolean;
16
+ /** A whole number: a known brand, one of its lengths, and the check digit holds. */
17
+ export declare function isValidCardNumber(digits: string): boolean;
18
+ /** Only the last four, the way a card is shown once it is stored: «•••• 4242». */
19
+ export declare function maskCardNumber(digits: string): string;
20
+ /** The digits of an expiry, a leading zero put in for a month that can only be one digit:
21
+ * «4» is April, so «04»; «1» may still be October. At most four: MMAA. */
22
+ export declare function expiryDigits(text: string): string;
23
+ /** How the expiry reads: «1228» → «12/28». */
24
+ export declare function formatExpiry(digits: string): string;
25
+ /** The month and the full year of a whole expiry, or `null` while it is not one. */
26
+ export declare function expiryParts(digits: string): {
27
+ month: number;
28
+ year: number;
29
+ } | null;
30
+ /** A real month that has not ended yet: a card is good through the last day of its month. */
31
+ export declare function isExpiryValid(digits: string, now?: Date): boolean;
32
+ /** How many digits the security code has: four on the front of an Amex, three elsewhere. */
33
+ export declare function cardCodeLength(brand: CardBrand | null | undefined): 3 | 4;
@@ -0,0 +1,173 @@
1
+ /* ==================================================================== *
2
+ * A PAYMENT CARD NUMBER — what it is, how it is written, whether it holds.
3
+ *
4
+ * Pure: the control (`CardNumberInput`) draws with it, and an application
5
+ * validates with the same functions before sending, so «the field accepted it»
6
+ * and «the form accepts it» never disagree.
7
+ *
8
+ * ── Digits in, digits out ───────────────────────────────────────────
9
+ *
10
+ * The value is the DIGITS: «4242424242424242». The spaces are how it is read,
11
+ * not what it is — the grouping depends on the brand (Amex is 4-6-5, the rest
12
+ * 4-4-4-4), so storing them would store a presentation.
13
+ *
14
+ * ── The brand from the first digits (IIN) ───────────────────────────
15
+ *
16
+ * The ranges are the published issuer prefixes, most specific first: Discover's
17
+ * `622126–622925` lives inside UnionPay's `62`, so Discover is asked before.
18
+ * The names are proper nouns and are not translated.
19
+ * ==================================================================== */
20
+ const FOUR = [4, 4, 4, 4, 3];
21
+ const RULES = [
22
+ {
23
+ brand: "amex",
24
+ name: "American Express",
25
+ prefix: /^3[47]/,
26
+ lengths: [15],
27
+ groups: [4, 6, 5],
28
+ },
29
+ {
30
+ brand: "diners",
31
+ name: "Diners Club",
32
+ prefix: /^3(?:0[0-5]|[689])/,
33
+ lengths: [14, 15, 16, 17, 18, 19],
34
+ groups: [4, 6, 4, 5],
35
+ },
36
+ { brand: "jcb", name: "JCB", prefix: /^35/, lengths: [16, 17, 18, 19], groups: FOUR },
37
+ { brand: "visa", name: "Visa", prefix: /^4/, lengths: [13, 16, 19], groups: FOUR },
38
+ {
39
+ brand: "mastercard",
40
+ name: "Mastercard",
41
+ prefix: /^(?:5[1-5]|2(?:2[2-9]|[3-6]\d|7[01]|720))/,
42
+ lengths: [16],
43
+ groups: FOUR,
44
+ },
45
+ {
46
+ brand: "discover",
47
+ name: "Discover",
48
+ prefix: /^(?:6011|65|64[4-9]|622(?:12[6-9]|1[3-9]\d|[2-8]\d\d|9[01]\d|92[0-5]))/,
49
+ lengths: [16, 17, 18, 19],
50
+ groups: FOUR,
51
+ },
52
+ {
53
+ brand: "unionpay",
54
+ name: "UnionPay",
55
+ prefix: /^62/,
56
+ lengths: [16, 17, 18, 19],
57
+ groups: FOUR,
58
+ },
59
+ {
60
+ brand: "maestro",
61
+ name: "Maestro",
62
+ prefix: /^(?:5[06-9]|6\d)/,
63
+ lengths: [12, 13, 14, 15, 16, 17, 18, 19],
64
+ groups: FOUR,
65
+ },
66
+ ];
67
+ /** The longest a card number gets, of any brand. */
68
+ export const CARD_MAX_DIGITS = 19;
69
+ /** Only the digits of what was typed or pasted: «4242 4242-4242» → «424242424242». */
70
+ export function cardDigits(text) {
71
+ return text.replace(/\D/g, "");
72
+ }
73
+ function ruleOf(digits, brand) {
74
+ if (brand !== undefined && brand !== null)
75
+ return RULES.find((one) => one.brand === brand);
76
+ return RULES.find((one) => one.prefix.test(digits));
77
+ }
78
+ /** The brand the first digits say, or `null` while they do not say one yet. */
79
+ export function cardBrand(digits) {
80
+ return ruleOf(cardDigits(digits))?.brand ?? null;
81
+ }
82
+ /** How the brand is written: «American Express». */
83
+ export function cardBrandName(brand) {
84
+ return RULES.find((one) => one.brand === brand)?.name ?? brand;
85
+ }
86
+ /** The most digits a number of this brand takes — where typing stops. */
87
+ export function cardMaxDigits(digits, brand) {
88
+ const lengths = ruleOf(cardDigits(digits), brand)?.lengths;
89
+ return lengths === undefined ? CARD_MAX_DIGITS : Math.max(...lengths);
90
+ }
91
+ /** The digits in the brand's groups: «378282246310005» → «3782 822463 10005». */
92
+ export function formatCardNumber(digits, brand) {
93
+ const clean = cardDigits(digits);
94
+ const groups = ruleOf(clean, brand)?.groups ?? FOUR;
95
+ const parts = [];
96
+ let at = 0;
97
+ for (const size of groups) {
98
+ if (at >= clean.length)
99
+ break;
100
+ parts.push(clean.slice(at, at + size));
101
+ at += size;
102
+ }
103
+ if (at < clean.length)
104
+ parts.push(clean.slice(at));
105
+ return parts.join(" ");
106
+ }
107
+ /** The Luhn check digit holds: a mistyped digit or two swapped neighbours fail it. */
108
+ export function luhnValid(digits) {
109
+ const clean = cardDigits(digits);
110
+ if (clean.length === 0)
111
+ return false;
112
+ let sum = 0;
113
+ for (let i = 0; i < clean.length; i++) {
114
+ let digit = Number(clean[clean.length - 1 - i]);
115
+ if (i % 2 === 1) {
116
+ digit *= 2;
117
+ if (digit > 9)
118
+ digit -= 9;
119
+ }
120
+ sum += digit;
121
+ }
122
+ return sum % 10 === 0;
123
+ }
124
+ /** A whole number: a known brand, one of its lengths, and the check digit holds. */
125
+ export function isValidCardNumber(digits) {
126
+ const clean = cardDigits(digits);
127
+ const rule = ruleOf(clean);
128
+ if (rule === undefined)
129
+ return false;
130
+ return rule.lengths.includes(clean.length) && luhnValid(clean);
131
+ }
132
+ /** Only the last four, the way a card is shown once it is stored: «•••• 4242». */
133
+ export function maskCardNumber(digits) {
134
+ const clean = cardDigits(digits);
135
+ if (clean.length === 0)
136
+ return "";
137
+ return `•••• ${clean.slice(-4)}`;
138
+ }
139
+ /* ── The expiry «MM/AA» and the security code ─────────────────────────── */
140
+ /** The digits of an expiry, a leading zero put in for a month that can only be one digit:
141
+ * «4» is April, so «04»; «1» may still be October. At most four: MMAA. */
142
+ export function expiryDigits(text) {
143
+ const clean = cardDigits(text);
144
+ const padded = /^[2-9]/.test(clean) ? `0${clean}` : clean;
145
+ return padded.slice(0, 4);
146
+ }
147
+ /** How the expiry reads: «1228» → «12/28». */
148
+ export function formatExpiry(digits) {
149
+ const clean = cardDigits(digits).slice(0, 4);
150
+ return clean.length <= 2 ? clean : `${clean.slice(0, 2)}/${clean.slice(2)}`;
151
+ }
152
+ /** The month and the full year of a whole expiry, or `null` while it is not one. */
153
+ export function expiryParts(digits) {
154
+ const clean = cardDigits(digits);
155
+ if (clean.length !== 4)
156
+ return null;
157
+ const month = Number(clean.slice(0, 2));
158
+ if (month < 1 || month > 12)
159
+ return null;
160
+ return { month, year: 2000 + Number(clean.slice(2)) };
161
+ }
162
+ /** A real month that has not ended yet: a card is good through the last day of its month. */
163
+ export function isExpiryValid(digits, now = new Date()) {
164
+ const parts = expiryParts(digits);
165
+ if (parts === null)
166
+ return false;
167
+ const current = now.getFullYear() * 12 + now.getMonth();
168
+ return parts.year * 12 + (parts.month - 1) >= current;
169
+ }
170
+ /** How many digits the security code has: four on the front of an Amex, three elsewhere. */
171
+ export function cardCodeLength(brand) {
172
+ return brand === "amex" ? 4 : 3;
173
+ }
@@ -39,8 +39,9 @@ export function FieldCopyProvider({ value, children, }) {
39
39
  export function useFieldCopy() {
40
40
  return useContext(FieldCopyContext);
41
41
  }
42
- /** What skipping means: not seen, or not the value. */
43
- const SKIP = '[hidden], [aria-hidden="true"], [data-placeholder], .sr-only, button[aria-label], [data-copy-skip], select';
42
+ /** What skipping means: not seen, or not the value. A password while it is hidden is not
43
+ * SEEN — the dots are — so it is not copied; revealed, it is text like any other. */
44
+ const SKIP = '[hidden], [aria-hidden="true"], [data-placeholder], .sr-only, button[aria-label], [data-copy-skip], select, input[type="password"]';
44
45
  /** The text a value area shows, inputs included, placeholders and named buttons left out. */
45
46
  export function shownText(root) {
46
47
  const parts = [];
@@ -0,0 +1,17 @@
1
+ /** Something that reads as an email: one `@`, a name before it and a domain with a dot after. */
2
+ export declare function isEmail(text: string): boolean;
3
+ /** The `mailto:` of an email, or `null` when it does not read as one. */
4
+ export declare function emailHref(text: string): string | null;
5
+ /** The `tel:` of a phone as people write it — «+591 7 123-4567» → `tel:+59171234567`. `null`
6
+ * when there are no digits to call. */
7
+ export declare function telHref(text: string): string | null;
8
+ /** The `wa.me` link of a phone with its country code. `wa.me` takes digits only. */
9
+ export declare function whatsappHref(text: string): string | null;
10
+ /** The `href` of a website, `https://` added when it has no scheme; `null` for anything that
11
+ * is not `http(s)` or does not parse — a «javascript:» never becomes a link. */
12
+ export declare function urlHref(text: string): string | null;
13
+ /** Something that reads as a website. */
14
+ export declare function isUrl(text: string): boolean;
15
+ /** How a website is read: without the scheme, the `www.` and the trailing slash —
16
+ * «https://www.sincpro.com.bo/» reads «sincpro.com.bo». */
17
+ export declare function urlLabel(text: string): string;
@@ -0,0 +1,65 @@
1
+ /* ==================================================================== *
2
+ * AN ADDRESS TO REACH SOMEONE — an email, a phone, a website.
3
+ *
4
+ * Pure: the controls (`EmailInput`, `PhoneInput`, `UrlInput`) build their read
5
+ * mode links with it, and a form validates with the same checks.
6
+ *
7
+ * ── A link is built, never trusted ──────────────────────────────────
8
+ *
9
+ * What people type becomes an `href`. A website written «javascript:…» must not
10
+ * turn into a link that runs it, so a URL keeps only `http:` and `https:` —
11
+ * anything else with a scheme is no link at all, and a bare «sincpro.com.bo»
12
+ * gets `https://` in front, the way a browser's address bar reads it.
13
+ * ==================================================================== */
14
+ /** Something that reads as an email: one `@`, a name before it and a domain with a dot after. */
15
+ export function isEmail(text) {
16
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim());
17
+ }
18
+ /** The `mailto:` of an email, or `null` when it does not read as one. */
19
+ export function emailHref(text) {
20
+ const clean = text.trim();
21
+ return isEmail(clean) ? `mailto:${clean}` : null;
22
+ }
23
+ /** The `tel:` of a phone as people write it — «+591 7 123-4567» → `tel:+59171234567`. `null`
24
+ * when there are no digits to call. */
25
+ export function telHref(text) {
26
+ const digits = text.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
27
+ return /\d/.test(digits) ? `tel:${digits}` : null;
28
+ }
29
+ /** The `wa.me` link of a phone with its country code. `wa.me` takes digits only. */
30
+ export function whatsappHref(text) {
31
+ const digits = text.replace(/\D/g, "");
32
+ return digits.length === 0 ? null : `https://wa.me/${digits}`;
33
+ }
34
+ /** The `href` of a website, `https://` added when it has no scheme; `null` for anything that
35
+ * is not `http(s)` or does not parse — a «javascript:» never becomes a link. */
36
+ export function urlHref(text) {
37
+ const clean = text.trim();
38
+ if (clean === "" || /\s/.test(clean))
39
+ return null;
40
+ const withScheme = /^[a-z][a-z\d+.-]*:/i.test(clean) ? clean : `https://${clean}`;
41
+ try {
42
+ const url = new URL(withScheme);
43
+ if (url.protocol !== "http:" && url.protocol !== "https:")
44
+ return null;
45
+ if (!url.hostname.includes(".") && url.hostname !== "localhost")
46
+ return null;
47
+ return url.href;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Something that reads as a website. */
54
+ export function isUrl(text) {
55
+ return urlHref(text) !== null;
56
+ }
57
+ /** How a website is read: without the scheme, the `www.` and the trailing slash —
58
+ * «https://www.sincpro.com.bo/» reads «sincpro.com.bo». */
59
+ export function urlLabel(text) {
60
+ return text
61
+ .trim()
62
+ .replace(/^https?:\/\//i, "")
63
+ .replace(/^www\./i, "")
64
+ .replace(/\/$/, "");
65
+ }
@@ -0,0 +1,5 @@
1
+ /** Hours as «h:mm», rounded to the minute: 8.5 → «8:30», 0.25 → «0:15», -1.25 → «-1:15». */
2
+ export declare function formatDuration(hours: number): string;
3
+ /** What was typed, as hours; `""` for an empty box and `null` for something that is not a
4
+ * duration (minutes past 59 included: «8:75» is a typo, not 9:15). */
5
+ export declare function parseDuration(text: string): number | "" | null;
@@ -0,0 +1,48 @@
1
+ /* ==================================================================== *
2
+ * A DURATION in hours — «8:30» is 8.5, the `float_time` of an ERP.
3
+ *
4
+ * Stored as a number of hours (what a timesheet sums), read as hours and
5
+ * minutes (what a person says). Typed any of the ways people write it:
6
+ *
7
+ * 8:30 8,5 8.5 8h30 8h 30m 45m 2h -1:15
8
+ *
9
+ * Pure: the control writes and reads with it, and a report formats a total the
10
+ * same way.
11
+ * ==================================================================== */
12
+ /** Hours as «h:mm», rounded to the minute: 8.5 → «8:30», 0.25 → «0:15», -1.25 → «-1:15». */
13
+ export function formatDuration(hours) {
14
+ const total = Math.round(Math.abs(hours) * 60);
15
+ const sign = hours < 0 && total > 0 ? "-" : "";
16
+ return `${sign}${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
17
+ }
18
+ /** What was typed, as hours; `""` for an empty box and `null` for something that is not a
19
+ * duration (minutes past 59 included: «8:75» is a typo, not 9:15). */
20
+ export function parseDuration(text) {
21
+ const clean = text.trim().toLowerCase().replace(/\s+/g, "");
22
+ if (clean === "")
23
+ return "";
24
+ const sign = clean.startsWith("-") ? -1 : 1;
25
+ const body = clean.replace(/^[-+]/, "");
26
+ const colon = /^(\d+):(\d{1,2})$/.exec(body);
27
+ if (colon !== null) {
28
+ const minutes = Number(colon[2]);
29
+ return minutes > 59 ? null : sign * (Number(colon[1]) + minutes / 60);
30
+ }
31
+ const decimal = /^\d+(?:[.,]\d+)?$/.exec(body);
32
+ if (decimal !== null)
33
+ return sign * Number(body.replace(",", "."));
34
+ const units = /^(?:(\d+)h)?(?:(\d+)m(?:in)?)?$/.exec(body);
35
+ if (units !== null && (units[1] !== undefined || units[2] !== undefined)) {
36
+ const minutes = Number(units[2] ?? 0);
37
+ if (units[1] !== undefined && minutes > 59)
38
+ return null;
39
+ return sign * (Number(units[1] ?? 0) + minutes / 60);
40
+ }
41
+ /* «8h30» — hours, then bare minutes. */
42
+ const short = /^(\d+)h(\d{1,2})$/.exec(body);
43
+ if (short !== null) {
44
+ const minutes = Number(short[2]);
45
+ return minutes > 59 ? null : sign * (Number(short[1]) + minutes / 60);
46
+ }
47
+ return null;
48
+ }
@@ -1,7 +1,21 @@
1
+ export { CARD_MAX_DIGITS, type CardBrand, cardBrand, cardBrandName, cardCodeLength, cardDigits, expiryDigits, expiryParts, formatCardNumber, formatExpiry, isExpiryValid, isValidCardNumber, luhnValid, maskCardNumber, } from "./card.js";
2
+ export { CardCodeInput, CardExpiryInput, CardNumberInput, } from "./CardNumberInput.js";
1
3
  export { Checkbox, type CheckItem, CheckList } from "./Checks.js";
4
+ export { CodeInput } from "./CodeInput.js";
5
+ export { ColorInput } from "./ColorInput.js";
6
+ export { emailHref, isEmail, isUrl, telHref, urlHref, urlLabel, whatsappHref, } from "./contact.js";
7
+ export { EmailInput, PhoneInput, UrlInput, } from "./ContactInputs.js";
2
8
  export { FieldCopyProvider, shownText, useFieldCopy, } from "./clipboard.js";
3
9
  export { DropZone, type DropZoneProps } from "./DropZone.js";
10
+ export { formatDuration, parseDuration } from "./duration.js";
11
+ export { DurationInput } from "./DurationInput.js";
4
12
  export { controlClasses, DataField, Field, NumberInput, ReadValue, Select, type SelectOption, TextArea, TextInput, } from "./Field.js";
13
+ export { type ImageChange, ImageInput } from "./ImageInput.js";
5
14
  export { type FieldLayout, FieldLayoutProvider, useSheetLayout, } from "./layout.js";
15
+ export { PasswordInput } from "./PasswordInput.js";
16
+ export { type MentionItem, RichTextInput, } from "./RichTextInput.js";
17
+ export { formatPattern, isPatternComplete, patternLength, patternValue, } from "./pattern.js";
18
+ export { PatternInput } from "./PatternInput.js";
19
+ export { SignatureInput } from "./SignatureInput.js";
6
20
  export { Suggest, type SuggestProps } from "./Suggest.js";
7
21
  export { type FieldNote } from "./note.js";
@@ -4,12 +4,32 @@
4
4
  What ARRANGES them —the sheet, the section, the footer— lives in `components/form`: a
5
5
  control does not know a form exists, and is used the same way inside a cell, a filter or
6
6
  a dialog. */
7
+ /* A payment card: the control, and the same checks for the form to validate with. */
8
+ export { CARD_MAX_DIGITS, cardBrand, cardBrandName, cardCodeLength, cardDigits, expiryDigits, expiryParts, formatCardNumber, formatExpiry, isExpiryValid, isValidCardNumber, luhnValid, maskCardNumber, } from "./card.js";
9
+ export { CardCodeInput, CardExpiryInput, CardNumberInput, } from "./CardNumberInput.js";
10
+ /* An address to reach someone: typed with its keyboard, read as a link. */
7
11
  export { Checkbox, CheckList } from "./Checks.js";
12
+ export { CodeInput } from "./CodeInput.js";
13
+ export { ColorInput } from "./ColorInput.js";
14
+ export { emailHref, isEmail, isUrl, telHref, urlHref, urlLabel, whatsappHref, } from "./contact.js";
15
+ export { EmailInput, PhoneInput, UrlInput, } from "./ContactInputs.js";
8
16
  /* The copy button every field carries by its label — on by default, off by field or by form. */
9
17
  export { FieldCopyProvider, shownText, useFieldCopy, } from "./clipboard.js";
10
18
  export { DropZone } from "./DropZone.js";
19
+ /* A duration in hours, read «8:30». */
20
+ export { formatDuration, parseDuration } from "./duration.js";
21
+ export { DurationInput } from "./DurationInput.js";
11
22
  export { controlClasses, DataField, Field, NumberInput, ReadValue, Select, TextArea, TextInput, } from "./Field.js";
23
+ /* An image the record holds (chosen, dropped, pasted, cropped) and a signature (drawn or typed). */
24
+ export { ImageInput } from "./ImageInput.js";
12
25
  export { FieldLayoutProvider, useSheetLayout, } from "./layout.js";
26
+ export { PasswordInput } from "./PasswordInput.js";
27
+ /* Formatted text (an Odoo `html` field): Tiptap, loaded only on editing. */
28
+ export { RichTextInput, } from "./RichTextInput.js";
29
+ /* An identifier with a fixed shape: NIT, CI, account, IBAN. */
30
+ export { formatPattern, isPatternComplete, patternLength, patternValue, } from "./pattern.js";
31
+ export { PatternInput } from "./PatternInput.js";
32
+ export { SignatureInput } from "./SignatureInput.js";
13
33
  export { Suggest } from "./Suggest.js";
14
34
  /* A note that belongs to one field: a card in the record's margin, or a flag by its label. */
15
35
  export {} from "./note.js";