@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,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
|
-
|
|
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";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
export type MaskedChange = {
|
|
3
|
+
next: string;
|
|
4
|
+
caret: number;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* What a keystroke did to a masked value.
|
|
8
|
+
*
|
|
9
|
+
* raw the input's text right after the keystroke
|
|
10
|
+
* at the caret in `raw`
|
|
11
|
+
* current the significant characters before it
|
|
12
|
+
* drawn how `current` was drawn
|
|
13
|
+
* clean the significant characters of any text
|
|
14
|
+
* max the most of them the value takes
|
|
15
|
+
*/
|
|
16
|
+
export declare function maskedChange({ raw, at, current, drawn, clean, max, }: {
|
|
17
|
+
raw: string;
|
|
18
|
+
at: number;
|
|
19
|
+
current: string;
|
|
20
|
+
drawn: string;
|
|
21
|
+
clean: (text: string) => string;
|
|
22
|
+
max: number;
|
|
23
|
+
}): MaskedChange;
|
|
24
|
+
/** Where, in the drawn text, the caret goes to sit after `count` significant characters. */
|
|
25
|
+
export declare function caretAfter(drawn: string, count: number, significant: (char: string) => boolean): number;
|
|
26
|
+
/**
|
|
27
|
+
* The input's ref, and `keep(count)` to call in `onChange`: once the new text is drawn, the
|
|
28
|
+
* caret goes back after `count` significant characters.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useMaskCaret(drawn: string, significant: (char: string) => boolean): {
|
|
31
|
+
input: RefObject<HTMLInputElement | null>;
|
|
32
|
+
keep: (count: number) => void;
|
|
33
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useLayoutEffect, useRef } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* What a keystroke did to a masked value.
|
|
5
|
+
*
|
|
6
|
+
* raw the input's text right after the keystroke
|
|
7
|
+
* at the caret in `raw`
|
|
8
|
+
* current the significant characters before it
|
|
9
|
+
* drawn how `current` was drawn
|
|
10
|
+
* clean the significant characters of any text
|
|
11
|
+
* max the most of them the value takes
|
|
12
|
+
*/
|
|
13
|
+
export function maskedChange({ raw, at, current, drawn, clean, max, }) {
|
|
14
|
+
let next = clean(raw);
|
|
15
|
+
let before = clean(raw.slice(0, at)).length;
|
|
16
|
+
if (next === current && raw.length < drawn.length && before > 0) {
|
|
17
|
+
next = next.slice(0, before - 1) + next.slice(before);
|
|
18
|
+
before -= 1;
|
|
19
|
+
}
|
|
20
|
+
next = next.slice(0, max);
|
|
21
|
+
return { next, caret: Math.min(before, next.length) };
|
|
22
|
+
}
|
|
23
|
+
/** Where, in the drawn text, the caret goes to sit after `count` significant characters. */
|
|
24
|
+
export function caretAfter(drawn, count, significant) {
|
|
25
|
+
let seen = 0;
|
|
26
|
+
let at = 0;
|
|
27
|
+
while (at < drawn.length && seen < count) {
|
|
28
|
+
if (significant(drawn[at] ?? ""))
|
|
29
|
+
seen++;
|
|
30
|
+
at++;
|
|
31
|
+
}
|
|
32
|
+
return at;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The input's ref, and `keep(count)` to call in `onChange`: once the new text is drawn, the
|
|
36
|
+
* caret goes back after `count` significant characters.
|
|
37
|
+
*/
|
|
38
|
+
export function useMaskCaret(drawn, significant) {
|
|
39
|
+
const input = useRef(null);
|
|
40
|
+
const pending = useRef(null);
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const element = input.current;
|
|
43
|
+
const want = pending.current;
|
|
44
|
+
if (element === null || want === null)
|
|
45
|
+
return;
|
|
46
|
+
pending.current = null;
|
|
47
|
+
const at = caretAfter(drawn, want, significant);
|
|
48
|
+
element.setSelectionRange(at, at);
|
|
49
|
+
}, [drawn, significant]);
|
|
50
|
+
return {
|
|
51
|
+
input,
|
|
52
|
+
keep: (count) => {
|
|
53
|
+
pending.current = count;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind the two media controls — the crop of `ImageInput` and the strokes of
|
|
3
|
+
* `SignatureInput` — kept apart from the canvas so it can be checked without a browser.
|
|
4
|
+
*
|
|
5
|
+
* Context: a crop that is off by half a pixel, or a pan that lets the frame slide past the
|
|
6
|
+
* edge of the photo, is invisible in a story and obvious on the avatar a hundred screens
|
|
7
|
+
* later. Here the rules are plain functions over sizes and points; the components only
|
|
8
|
+
* read the pointer and the keys, ask these where things go, and paint.
|
|
9
|
+
*/
|
|
10
|
+
export type Size = {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
export type Rect = {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
export type Point = {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
};
|
|
24
|
+
/** Where the frame sits on the image: how much it is zoomed in (1 = the image just covers
|
|
25
|
+
* the frame) and the CENTRE of the frame, as a fraction of the image's width and height.
|
|
26
|
+
* Fractions and not pixels so the same crop holds whatever size the image is drawn at. */
|
|
27
|
+
export type Crop = {
|
|
28
|
+
zoom: number;
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
};
|
|
32
|
+
/** Past four times the photo is mostly blur: the limit is about what is worth keeping. */
|
|
33
|
+
export declare const CROP_MAX_ZOOM = 4;
|
|
34
|
+
export declare const CROP_CENTERED: Crop;
|
|
35
|
+
/** The crop made valid: zoom within `[1, CROP_MAX_ZOOM]`, and the centre held far enough
|
|
36
|
+
* from the edges that the frame never shows past the image. */
|
|
37
|
+
export declare function clampCrop(image: Size, aspect: number, crop: Crop): Crop;
|
|
38
|
+
/** The part of the image the frame shows, in image pixels — what `drawImage` reads from. */
|
|
39
|
+
export declare function cropRegion(image: Size, aspect: number, crop: Crop): Rect;
|
|
40
|
+
/**
|
|
41
|
+
* Moves the IMAGE under the frame by a fraction of the frame — the way a drag does: dragging
|
|
42
|
+
* right by a quarter of the frame slides the photo right by a quarter, so the frame now shows
|
|
43
|
+
* more of its left side. The arrow keys move it the same way, so the hand and the keyboard
|
|
44
|
+
* never disagree about which way is which.
|
|
45
|
+
*/
|
|
46
|
+
export declare function panCrop(image: Size, aspect: number, crop: Crop, dx: number, dy: number): Crop;
|
|
47
|
+
/** Zooms by `factor` about the frame's centre (the point the person is looking at). */
|
|
48
|
+
export declare function zoomCrop(image: Size, aspect: number, crop: Crop, factor: number): Crop;
|
|
49
|
+
/** The size the cropped image is written at: the region's own pixels, never upscaled (a
|
|
50
|
+
* bigger file of the same blur), and at most `maxSide` on its longer side. */
|
|
51
|
+
export declare function outputSize(region: Size, maxSide: number): Size;
|
|
52
|
+
/** Whether a file is an image the browser can draw. A dropped file is not filtered by
|
|
53
|
+
* `accept`, so the type is checked here — and by its extension when the system did not
|
|
54
|
+
* say its type, which happens for a file dragged from some file managers. */
|
|
55
|
+
export declare function isImageFile(file: {
|
|
56
|
+
type: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}): boolean;
|
|
59
|
+
/** The type a crop is written in: JPEG and WebP stay what they were (a photo as PNG is five
|
|
60
|
+
* times heavier for nothing); everything else becomes PNG, which keeps transparency. */
|
|
61
|
+
export declare function croppedType(type: string): "image/png" | "image/jpeg" | "image/webp";
|
|
62
|
+
/** The file's name with the extension of the type it was written in: «logo.gif» cropped to
|
|
63
|
+
* a PNG is «logo.png», so the server does not trust a name that lies about its content. */
|
|
64
|
+
export declare function croppedName(name: string, type: string): string;
|
|
65
|
+
/** One instruction of a stroke's path, in the canvas's terms. */
|
|
66
|
+
export type PathStep = {
|
|
67
|
+
kind: "dot";
|
|
68
|
+
at: Point;
|
|
69
|
+
} | {
|
|
70
|
+
kind: "move";
|
|
71
|
+
to: Point;
|
|
72
|
+
} | {
|
|
73
|
+
kind: "quad";
|
|
74
|
+
control: Point;
|
|
75
|
+
to: Point;
|
|
76
|
+
} | {
|
|
77
|
+
kind: "line";
|
|
78
|
+
to: Point;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The path of one stroke, smoothed: each sampled point becomes the CONTROL of a quadratic
|
|
82
|
+
* curve that runs between the midpoints around it.
|
|
83
|
+
*
|
|
84
|
+
* Context: joining the pointer's samples with straight lines draws a polygon — at the speed
|
|
85
|
+
* a signature is written the samples are far apart, and every corner shows. Curving through
|
|
86
|
+
* the midpoints passes near every sample without a corner at any of them, and costs nothing:
|
|
87
|
+
* no pressure, no velocity, no library. A single sample is a tap — a dot, the one over an i.
|
|
88
|
+
*/
|
|
89
|
+
export declare function strokeSteps(points: readonly Point[]): PathStep[];
|
|
90
|
+
/** The font size a typed name is written at so it fits `maxWidth`: `size` when it fits, and
|
|
91
|
+
* smaller in proportion when it does not — a long name is still one line, never cut. */
|
|
92
|
+
export declare function fitFontSize(widthAtSize: number, size: number, maxWidth: number, min?: number): number;
|
|
93
|
+
/** Keeps a pointer's moves on the element that started a drag, even when it leaves it — a
|
|
94
|
+
* stroke that runs off the pad is still one stroke. A pointer the browser no longer tracks
|
|
95
|
+
* (a synthetic one, or one already lifted) cannot be captured, and the drag works without. */
|
|
96
|
+
export declare function capturePointer(event: {
|
|
97
|
+
currentTarget: Element;
|
|
98
|
+
pointerId: number;
|
|
99
|
+
}): void;
|