@uxf/core 11.124.0 → 11.127.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/README.md CHANGED
@@ -3,24 +3,26 @@
3
3
  ## Constants
4
4
 
5
5
  - common modifier classnames for interactive elements (eg. `CLASSES.IS_HOVERABLE` for is-hoverable classname)
6
- - `focus-visible`
7
- - `is-active`
8
- - `is-busy`
9
- - `is-disabled`
10
- - `is-focused`
11
- - `is-hoverable`
12
- - `is-hovered`
13
- - `is-invalid`
14
- - `is-loading`
15
- - `is-not-hoverable`
16
- - `is-readonly`
17
- - `is-required`
18
- - `is-selected`
6
+ - `focus-visible`
7
+ - `is-active`
8
+ - `is-busy`
9
+ - `is-disabled`
10
+ - `is-focused`
11
+ - `is-hoverable`
12
+ - `is-hovered`
13
+ - `is-invalid`
14
+ - `is-loading`
15
+ - `is-not-hoverable`
16
+ - `is-readonly`
17
+ - `is-required`
18
+ - `is-selected`
19
19
 
20
20
  ## Resizer
21
+
21
22
  !!! Required [@uxf/resizer](https://www.npmjs.com/package/@uxf/resizer) version `>= 2.3.2` which supported `quality` parameter.
22
23
 
23
24
  ### Config
25
+
24
26
  ```json
25
27
  [
26
28
  {
@@ -37,19 +39,19 @@
37
39
  ### Usage for generated images
38
40
 
39
41
  ```tsx
40
- import {resizerImageUrl} from "@uxf/core/utils/resizer";
42
+ import { resizerImageUrl } from "@uxf/core/utils/resizer";
41
43
 
42
- <img src={resizerImageUrl(file, width, height, params)}/>
44
+ <img src={resizerImageUrl(file, width, height, params)} />;
43
45
  ```
44
46
 
45
47
  ### Usage for static images
46
48
 
47
49
  ```tsx
48
- import {resizerImageUrl} from "@uxf/core/utils/resizer";
50
+ import { resizerImageUrl } from "@uxf/core/utils/resizer";
49
51
 
50
52
  import staticImage from "./path/to/static-image.png";
51
53
 
52
- <img src={resizerImageUrl(staticImage, width, height, params)}/>
54
+ <img src={resizerImageUrl(staticImage, width, height, params)} />;
53
55
  ```
54
56
 
55
57
  ## QR code generator
@@ -86,6 +88,78 @@ cookie.set("cookie-name", "value", /* ttl in seconds (optional) */, /* options (
86
88
  cookie.delete("cookie-name", /* options (optional) */);
87
89
  ```
88
90
 
91
+ ## Money
92
+
93
+ `Money` is the monetary value type used across the packages — the amount is a **string**, so it can carry
94
+ more precision than a JS number holds.
95
+
96
+ ```tsx
97
+ import { Currency, Money } from "@uxf/core/money";
98
+ import { currencies } from "@uxf/core/money/currencies";
99
+ import { getCurrencySymbol } from "@uxf/core/money/get-currency-symbol";
100
+
101
+ const price: Money = { amount: "1000", currency: "CZK" };
102
+
103
+ getCurrencySymbol("CZK"); /* returns "Kč" */
104
+ ```
105
+
106
+ ### normalizeMoneyAmount
107
+
108
+ Rewrites an amount into its canonical decimal form. Returns `null` when the input does not describe a
109
+ decimal number.
110
+
111
+ ```tsx
112
+ import { normalizeMoneyAmount } from "@uxf/core/money/normalize-money";
113
+
114
+ normalizeMoneyAmount("1000.00"); /* returns "1000" */
115
+ normalizeMoneyAmount("15.50"); /* returns "15.5" */
116
+ normalizeMoneyAmount("015"); /* returns "15" */
117
+ normalizeMoneyAmount(".5"); /* returns "0.5" */
118
+ normalizeMoneyAmount("+15"); /* returns "15" */
119
+ normalizeMoneyAmount("-0.00"); /* returns "0" */
120
+ normalizeMoneyAmount("abc"); /* returns null */
121
+ normalizeMoneyAmount("1e5"); /* returns null - exponent notation is not a decimal amount */
122
+ ```
123
+
124
+ The rewriting is textual, never a `Number()` round-trip, so an amount a double cannot hold exactly
125
+ survives intact: `"9007199254740993"` and `"100000000000000000000000"` come back unchanged rather than as
126
+ a different number or as `"1e+23"`. This is also why exponent notation is rejected instead of expanded.
127
+
128
+ Related but **not** a substitute: [`trimTrailingZeros`](#trimtrailingzeros) only strips trailing zeros
129
+ from a fractional part and leaves leading zeros, signs and `".5"` alone.
130
+
131
+ ### normalizeMoney
132
+
133
+ Normalizes a whole `Money` value: the amount goes through `normalizeMoneyAmount` and any extra properties
134
+ (a GraphQL `__typename`, for instance) are dropped. Returns `null` for a nullish value or an amount that
135
+ is not a number.
136
+
137
+ ```tsx
138
+ import { normalizeMoney } from "@uxf/core/money/normalize-money";
139
+
140
+ normalizeMoney({ amount: "1000.00", currency: "CZK" }); /* returns { amount: "1000", currency: "CZK" } */
141
+ normalizeMoney({ __typename: "Money", amount: "1000", currency: "CZK" }); /* drops __typename */
142
+ normalizeMoney({ amount: "", currency: "CZK" }); /* returns null */
143
+ normalizeMoney(null); /* returns null */
144
+ ```
145
+
146
+ **Why you need this in a form.** react-hook-form decides dirtiness with its own `deepEqual`, which
147
+ compares `Object.keys().length` first and then every leaf. A `Money` field therefore reads as dirty as
148
+ soon as its _shape_ drifts from the default value — an extra `__typename`, or an amount retyped as
149
+ `"1000.00"` where the default says `"1000"` — even though the visible value is identical, and the
150
+ unsaved-changes bar never goes away.
151
+
152
+ A component can only make what it **emits** canonical; the default values are built by the consuming
153
+ app's mappers, which no component can reach. So run both sides through the same normalizer:
154
+
155
+ ```tsx
156
+ const formApi = useForm<FormData>({
157
+ defaultValues: { price: normalizeMoney(data.price) },
158
+ });
159
+ ```
160
+
161
+ [`@uxf/form/money-input`](../form/money-input/README.md) already normalizes what it emits, on blur.
162
+
89
163
  ## Utils
90
164
 
91
165
  ### adjustTextareaHeight
@@ -93,43 +167,49 @@ cookie.delete("cookie-name", /* options (optional) */);
93
167
  Dynamically adjusts the height of a `<textarea>` based on its content and an optional number of rows.
94
168
 
95
169
  #### Parameters
170
+
96
171
  - **`element`**: The `<textarea>` to adjust.
97
172
  - **`rows`** (optional): Minimum visible rows. Default is `4`.
98
173
 
99
174
  #### Behavior
175
+
100
176
  - Leverages MutationObserver API to measure content height.
101
177
  - Adjusts height to fit content or the minimum height based on the `rows` parameter, calculated using `line-height` and `font-size`.
102
178
 
103
179
  #### Usage
180
+
104
181
  ```typescript
105
182
  adjustTextareaHeight(textarea); // Adjusts height (min 4 rows)
106
183
  adjustTextareaHeight(textarea, 6); // With 6-row minimum
107
- ```
184
+ ```
185
+
108
186
  In React component:
187
+
109
188
  ```tsx
110
189
  import { useIsomorphicLayoutEffect } from "@uxf/core-react/hooks/use-isomorphic-layout-effect";
111
190
  import { isNotNil } from "@uxf/core/utils/is-not-nil";
112
191
 
113
192
  useIsomorphicLayoutEffect(() => {
114
- const textarea = textareaRef.current;
115
-
116
- if (isNotNil(textarea)) {
117
- return;
118
- }
119
-
120
- const cleanup = adjustTextareaHeight(textarea);
121
-
122
- return () => cleanup();
193
+ const textarea = textareaRef.current;
194
+
195
+ if (isNotNil(textarea)) {
196
+ return;
197
+ }
198
+
199
+ const cleanup = adjustTextareaHeight(textarea);
200
+
201
+ return () => cleanup();
123
202
  }, []);
124
- ```
203
+ ```
125
204
 
126
205
  > **Note**: Requires valid `line-height` and `font-size` styles for accurate sizing.
127
206
 
128
207
  ### assertNever
129
208
 
130
209
  Checks that value is always type "never".
210
+
131
211
  ```ts
132
- switch(value) {
212
+ switch (value) {
133
213
  case "a":
134
214
  return "A";
135
215
  case "b":
@@ -209,9 +289,11 @@ const example = <div ref={composeRefs(firstRef, secondRef)} />;
209
289
  ```
210
290
 
211
291
  ### cx, cxa
292
+
212
293
  It is our fork of `clsx` library https://github.com/lukeed/clsx
213
294
 
214
295
  We will mainly use `cx`, which is fork of `clsx/lite` – it accepts **ONLY** string values! Any non-string arguments are ignored!
296
+
215
297
  ```tsx
216
298
  import { cx } from "@uxf/core/utils/cx";
217
299
 
@@ -224,9 +306,10 @@ cx({ foo: true });
224
306
  //=> ""
225
307
  ```
226
308
 
227
- The `cxa` function is full fork of `clsx` and can take *any* number of arguments, each of which can be an Object, Array, Boolean, or String.
309
+ The `cxa` function is full fork of `clsx` and can take _any_ number of arguments, each of which can be an Object, Array, Boolean, or String.
228
310
 
229
311
  **Important**: Any falsy values are discarded! Standalone Boolean values are discarded as well.
312
+
230
313
  ```tsx
231
314
  import { cxa } from "@uxf/core/utils/cxa";
232
315
 
@@ -238,11 +321,11 @@ cxa("foo", true && "bar", "baz");
238
321
  //=> "foo bar baz"
239
322
 
240
323
  // Objects
241
- cxa({ foo:true, bar:false, baz:isTrue() });
324
+ cxa({ foo: true, bar: false, baz: isTrue() });
242
325
  //=> "foo baz"
243
326
 
244
327
  // Objects (variadic)
245
- cxa({ foo:true }, { bar:false }, null, { "--foobar":"hello" });
328
+ cxa({ foo: true }, { bar: false }, null, { "--foobar": "hello" });
246
329
  //=> "foo --foobar"
247
330
 
248
331
  // Arrays
@@ -254,7 +337,7 @@ cxa(["foo"], ["", 0, false, "bar"], [["baz", [["hello"], "there"]]]);
254
337
  //=> "foo bar baz hello there"
255
338
 
256
339
  // Kitchen sink (with nesting)
257
- cxa("foo", [1 && "bar", { baz:false, bat:null }, ["hello", ["world"]]], "cya");
340
+ cxa("foo", [1 && "bar", { baz: false, bat: null }, ["hello", ["world"]]], "cya");
258
341
  //=> "foo bar hello world cya"
259
342
  ```
260
343
 
@@ -285,11 +368,20 @@ const a = { b: 2, a: 1, nested: { y: 2, x: 1 } };
285
368
  const b = { nested: { x: 1, y: 2 }, a: 1, b: 2 };
286
369
  console.log(deepEqualIgnoringKeyOrder(a, b)); // true
287
370
 
288
- const arr1 = [{ a: 1, b: 2 }, { c: 3, d: 4 }];
289
- const arr2 = [{ b: 2, a: 1 }, { d: 4, c: 3 }];
371
+ const arr1 = [
372
+ { a: 1, b: 2 },
373
+ { c: 3, d: 4 },
374
+ ];
375
+ const arr2 = [
376
+ { b: 2, a: 1 },
377
+ { d: 4, c: 3 },
378
+ ];
290
379
  console.log(deepEqualIgnoringKeyOrder(arr1, arr2)); // true (objects equal, same array order)
291
380
 
292
- const arr3 = [{ d: 4, c: 3 }, { b: 2, a: 1 }];
381
+ const arr3 = [
382
+ { d: 4, c: 3 },
383
+ { b: 2, a: 1 },
384
+ ];
293
385
  console.log(deepEqualIgnoringKeyOrder(arr1, arr3)); // false (array order differs)
294
386
  ```
295
387
 
@@ -302,7 +394,7 @@ import { downloadFile } from "@uxf/core/utils/download-file";
302
394
  import { FormEventHandler } from "react";
303
395
 
304
396
  const submitHandler: FormEventHandler<HTMLFormElement> = () => {
305
- downloadFile("https://example.com/file", "file.txt")
397
+ downloadFile("https://example.com/file", "file.txt");
306
398
  };
307
399
  ```
308
400
 
@@ -313,7 +405,7 @@ Escapes all double quotes (`"`) in a string by replacing them with `\"`.
313
405
  ```ts
314
406
  import { escapeQuotes } from "@uxf/core/utils/escape-quotes";
315
407
 
316
- escapeQuotes('The "quick" fox');
408
+ escapeQuotes('The "quick" fox');
317
409
  // Output: The \"quick\" fox
318
410
  ```
319
411
 
@@ -324,9 +416,9 @@ Converts a 0-based index to a 1-based (human-readable) index.
324
416
  ```tsx
325
417
  import { humanIndex } from "@uxf/core/utils/human-index";
326
418
 
327
- humanIndex(0); /* returns 1 */
328
- humanIndex(9); /* returns 10 */
329
- humanIndex(-1); /* throws error */
419
+ humanIndex(0); /* returns 1 */
420
+ humanIndex(9); /* returns 10 */
421
+ humanIndex(-1); /* throws error */
330
422
  ```
331
423
 
332
424
  ## filterNullish
@@ -369,7 +461,7 @@ const props = {
369
461
  const htmlAttrs = filterAriaAndDataAttrs(props);
370
462
  // Result: { "aria-label": "Close button", "data-testid": "close-btn" }
371
463
 
372
- <button {...htmlAttrs}>Close</button>
464
+ <button {...htmlAttrs}>Close</button>;
373
465
  ```
374
466
 
375
467
  ```tsx
@@ -380,16 +472,13 @@ function CustomInput({ label, onChange, ...restProps }) {
380
472
  return <input {...accessibilityAttrs} onChange={onChange} />;
381
473
  }
382
474
 
383
- <CustomInput
384
- aria-describedby="helper-text"
385
- data-analytics="email-input"
386
- customProp="ignored"
387
- />
475
+ <CustomInput aria-describedby="helper-text" data-analytics="email-input" customProp="ignored" />;
388
476
  ```
389
477
 
390
478
  ### formatBytes
391
479
 
392
480
  Appends suitable unit to the byte value of data size.
481
+
393
482
  ```ts
394
483
  formatBytes(17.5 * 1024);
395
484
  //=> "17.5 kB"
@@ -423,7 +512,7 @@ Re-export of [lodash.isequal](https://lodash.com/docs/#isEqual). Performs a deep
423
512
  import { isEqual } from "@uxf/core/utils/is-equal";
424
513
 
425
514
  isEqual({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] }); /* returns true */
426
- isEqual({ a: 1 }, { a: 2 }); /* returns false */
515
+ isEqual({ a: 1 }, { a: 2 }); /* returns false */
427
516
  ```
428
517
 
429
518
  ## isEmpty
@@ -444,11 +533,11 @@ Checks if a number is even.
444
533
  ```tsx
445
534
  import { isEven } from "@uxf/core/utils/is-even";
446
535
 
447
- isEven(2); /* returns true */
448
- isEven(4); /* returns true */
449
- isEven(1); /* returns false */
450
- isEven(3); /* returns false */
451
- isEven(0); /* returns true */
536
+ isEven(2); /* returns true */
537
+ isEven(4); /* returns true */
538
+ isEven(1); /* returns false */
539
+ isEven(3); /* returns false */
540
+ isEven(0); /* returns true */
452
541
  isEven(-2); /* returns true */
453
542
  ```
454
543
 
@@ -459,11 +548,11 @@ Checks if a number is odd.
459
548
  ```tsx
460
549
  import { isOdd } from "@uxf/core/utils/is-odd";
461
550
 
462
- isOdd(1); /* returns true */
463
- isOdd(3); /* returns true */
464
- isOdd(2); /* returns false */
465
- isOdd(4); /* returns false */
466
- isOdd(0); /* returns false */
551
+ isOdd(1); /* returns true */
552
+ isOdd(3); /* returns true */
553
+ isOdd(2); /* returns false */
554
+ isOdd(4); /* returns false */
555
+ isOdd(0); /* returns false */
467
556
  isOdd(-1); /* returns true */
468
557
  ```
469
558
 
@@ -482,13 +571,13 @@ const serverExample = isServer; /* returns true if DOM is NOT available */
482
571
  ```tsx
483
572
  import { isNil } from "@uxf/core/utils/is-nil";
484
573
 
485
- isNil(null); /* returns true */
574
+ isNil(null); /* returns true */
486
575
  isNil(undefined); /* returns true */
487
- isNil(true); /* returns false */
488
- isNil(1); /* returns false */
489
- isNil(0); /* returns false */
490
- isNil([]); /* returns false */
491
- isNil("string"); /* returns false */
576
+ isNil(true); /* returns false */
577
+ isNil(1); /* returns false */
578
+ isNil(0); /* returns false */
579
+ isNil([]); /* returns false */
580
+ isNil("string"); /* returns false */
492
581
  ```
493
582
 
494
583
  ## isNotNil
@@ -496,13 +585,13 @@ isNil("string"); /* returns false */
496
585
  ```tsx
497
586
  import { isNotNil } from "@uxf/core/utils/is-not-nil";
498
587
 
499
- isNotNil(null); /* returns false */
588
+ isNotNil(null); /* returns false */
500
589
  isNotNil(undefined); /* returns false */
501
- isNotNil(true); /* returns true */
502
- isNotNil(1); /* returns true */
503
- isNotNil(0); /* returns true */
504
- isNotNil([]); /* returns true */
505
- isNotNil("string"); /* returns true */
590
+ isNotNil(true); /* returns true */
591
+ isNotNil(1); /* returns true */
592
+ isNotNil(0); /* returns true */
593
+ isNotNil([]); /* returns true */
594
+ isNotNil("string"); /* returns true */
506
595
  ```
507
596
 
508
597
  ## isNotNilNorEmpty
@@ -529,18 +618,18 @@ Type guard that checks if a value is a plain object. Returns `false` for arrays,
529
618
  ```tsx
530
619
  import { isPlainObject } from "@uxf/core/utils/is-plain-object";
531
620
 
532
- isPlainObject({}); /* returns true */
533
- isPlainObject({ a: 1 }); /* returns true */
534
- isPlainObject([]); /* returns false */
535
- isPlainObject([1, 2, 3]); /* returns false */
536
- isPlainObject(new Date()); /* returns false */
537
- isPlainObject(new Map()); /* returns false */
538
- isPlainObject(new Set()); /* returns false */
539
- isPlainObject(/regex/); /* returns false */
540
- isPlainObject(null); /* returns false */
541
- isPlainObject(undefined); /* returns false */
542
- isPlainObject("string"); /* returns false */
543
- isPlainObject(123); /* returns false */
621
+ isPlainObject({}); /* returns true */
622
+ isPlainObject({ a: 1 }); /* returns true */
623
+ isPlainObject([]); /* returns false */
624
+ isPlainObject([1, 2, 3]); /* returns false */
625
+ isPlainObject(new Date()); /* returns false */
626
+ isPlainObject(new Map()); /* returns false */
627
+ isPlainObject(new Set()); /* returns false */
628
+ isPlainObject(/regex/); /* returns false */
629
+ isPlainObject(null); /* returns false */
630
+ isPlainObject(undefined); /* returns false */
631
+ isPlainObject("string"); /* returns false */
632
+ isPlainObject(123); /* returns false */
544
633
  ```
545
634
 
546
635
  ## last
@@ -549,7 +638,7 @@ isPlainObject(123); /* returns false */
549
638
  import { last } from "@uxf/core/utils/last";
550
639
 
551
640
  last([1, 2]); /* returns 2 */
552
- last([]); /* returns undefined */
641
+ last([]); /* returns undefined */
553
642
  ```
554
643
 
555
644
  ## nonEmptyArrayOrNull
@@ -559,11 +648,11 @@ Converts empty arrays, `null`, or `undefined` values to `null`, leaving all non-
559
648
  ```tsx
560
649
  import { nonEmptyArrayOrNull } from "@uxf/core/utils/non-empty-array-or-null";
561
650
 
562
- nonEmptyArrayOrNull([]); /* returns null */
563
- nonEmptyArrayOrNull(null); /* returns null */
564
- nonEmptyArrayOrNull(undefined); /* returns null */
565
- nonEmptyArrayOrNull([1, 2, 3]); /* returns [1, 2, 3] */
566
- nonEmptyArrayOrNull(["a", "b"]); /* returns ["a", "b"] */
651
+ nonEmptyArrayOrNull([]); /* returns null */
652
+ nonEmptyArrayOrNull(null); /* returns null */
653
+ nonEmptyArrayOrNull(undefined); /* returns null */
654
+ nonEmptyArrayOrNull([1, 2, 3]); /* returns [1, 2, 3] */
655
+ nonEmptyArrayOrNull(["a", "b"]); /* returns ["a", "b"] */
567
656
  ```
568
657
 
569
658
  ## nonEmptyStringOrNull
@@ -573,13 +662,40 @@ Converts empty strings and `undefined` values to `null`, leaving all other strin
573
662
  ```tsx
574
663
  import { nonEmptyStringOrNull } from "@uxf/core/utils/non-empty-string-or-null";
575
664
 
576
- nonEmptyStringOrNull(""); /* returns null */
577
- nonEmptyStringOrNull(undefined); /* returns null */
578
- nonEmptyStringOrNull(null); /* returns null */
579
- nonEmptyStringOrNull("test"); /* returns "test" */
580
- nonEmptyStringOrNull(" "); /* returns " " - non-empty string */
665
+ nonEmptyStringOrNull(""); /* returns null */
666
+ nonEmptyStringOrNull(undefined); /* returns null */
667
+ nonEmptyStringOrNull(null); /* returns null */
668
+ nonEmptyStringOrNull("test"); /* returns "test" */
669
+ nonEmptyStringOrNull(" "); /* returns " " - non-empty string */
581
670
  ```
582
671
 
672
+ ## normalizeSelectableIds
673
+
674
+ Sorts a multi-choice value into a canonical order. Returns a new array (the input is never mutated), or
675
+ `null` for a nullish value.
676
+
677
+ ```tsx
678
+ import { normalizeSelectableIds } from "@uxf/core/utils/normalize-selectable-ids";
679
+
680
+ normalizeSelectableIds([3, 1, 2]); /* returns [1, 2, 3] */
681
+ normalizeSelectableIds(["b", "a"]); /* returns ["a", "b"] */
682
+ normalizeSelectableIds(null); /* returns null */
683
+ ```
684
+
685
+ Multi-choice inputs treat their value as a set but emit it as an array whose order follows the order the
686
+ user clicked, and react-hook-form compares arrays index by index — so unticking an option and ticking it
687
+ again leaves a semantically unchanged form reading as dirty. Run the form's `defaultValues` through this
688
+ too, so both sides agree:
689
+
690
+ ```tsx
691
+ const formApi = useForm<FormData>({
692
+ defaultValues: { tags: normalizeSelectableIds(data.tagIds) },
693
+ });
694
+ ```
695
+
696
+ [`@uxf/ui/checkbox-list`](../ui/checkbox-list/README.md) and
697
+ [`@uxf/ui/multi-select`](../ui/multi-select/README.md) already normalize what they emit.
698
+
583
699
  ## nullishToEmptyString
584
700
 
585
701
  Converts `null` or `undefined` values to an empty string, leaving all other strings unchanged. Useful for safely displaying nullable string values in UI components.
@@ -587,10 +703,10 @@ Converts `null` or `undefined` values to an empty string, leaving all other stri
587
703
  ```tsx
588
704
  import { nullishToEmptyString } from "@uxf/core/utils/nullish-to-empty-string";
589
705
 
590
- nullishToEmptyString(null); /* returns "" */
591
- nullishToEmptyString(undefined); /* returns "" */
592
- nullishToEmptyString(""); /* returns "" */
593
- nullishToEmptyString("hello world"); /* returns "hello world" */
706
+ nullishToEmptyString(null); /* returns "" */
707
+ nullishToEmptyString(undefined); /* returns "" */
708
+ nullishToEmptyString(""); /* returns "" */
709
+ nullishToEmptyString("hello world"); /* returns "hello world" */
594
710
  ```
595
711
 
596
712
  ## numberOrNull
@@ -600,13 +716,13 @@ Converts `NaN`, `null`, or `undefined` values to `null`, leaving all valid numbe
600
716
  ```tsx
601
717
  import { numberOrNull } from "@uxf/core/utils/number-or-null";
602
718
 
603
- numberOrNull(0); /* returns 0 */
604
- numberOrNull(42); /* returns 42 */
605
- numberOrNull(-1); /* returns -1 */
606
- numberOrNull(3.14); /* returns 3.14 */
607
- numberOrNull(NaN); /* returns null */
608
- numberOrNull(null); /* returns null */
609
- numberOrNull(undefined); /* returns null */
719
+ numberOrNull(0); /* returns 0 */
720
+ numberOrNull(42); /* returns 42 */
721
+ numberOrNull(-1); /* returns -1 */
722
+ numberOrNull(3.14); /* returns 3.14 */
723
+ numberOrNull(NaN); /* returns null */
724
+ numberOrNull(null); /* returns null */
725
+ numberOrNull(undefined); /* returns null */
610
726
  ```
611
727
 
612
728
  ## plural
@@ -639,7 +755,26 @@ Re-export of [qs](https://github.com/ljharb/qs). A querystring parsing and strin
639
755
  import { stringify, parse } from "@uxf/core/utils/qs";
640
756
 
641
757
  stringify({ a: "b", c: [1, 2] }); /* returns "a=b&c%5B0%5D=1&c%5B1%5D=2" */
642
- parse("a=b&c=1"); /* returns { a: "b", c: "1" } */
758
+ parse("a=b&c=1"); /* returns { a: "b", c: "1" } */
759
+ ```
760
+
761
+ ## safeLocalStorage / safeSessionStorage
762
+
763
+ Web storage that never throws. Safari in private mode, iOS with cookies blocked, sandboxed iframes and several in-app browsers throw `SecurityError: The operation is insecure.` on **reads as well as writes** — and in some browsers even on the bare `window.localStorage` property access — so unguarded storage access during render can take the whole app down. A full quota throws `QuotaExceededError` the same way.
764
+
765
+ A read that cannot happen returns `null`, a write that cannot happen returns `false`. The storage object is resolved per call, so importing this module on the server is safe.
766
+
767
+ ```tsx
768
+ import { safeLocalStorage, safeSessionStorage } from "@uxf/core/utils/safe-storage";
769
+
770
+ safeLocalStorage.setItem("theme", "dark"); /* returns true, or false when storage is unavailable */
771
+ safeLocalStorage.getItem("theme"); /* returns "dark", or null when storage is unavailable */
772
+ safeLocalStorage.removeItem("theme"); /* returns true when it went through */
773
+ safeLocalStorage.clear(); /* returns true when it went through */
774
+ safeLocalStorage.length; /* returns 0 when storage is unavailable */
775
+ safeLocalStorage.key(0); /* returns null when storage is unavailable */
776
+
777
+ safeSessionStorage.getItem("wizard-step"); /* same API for sessionStorage */
643
778
  ```
644
779
 
645
780
  ## slugify
@@ -685,10 +820,10 @@ const example = trimTrailingZeros("120,450"); /* returns "120,45" */
685
820
  ```
686
821
 
687
822
  ## Validators
823
+
688
824
  ```tsx
689
825
  import { Validator } from "@uxf/core";
690
826
 
691
827
  Validator.isEmail("...");
692
828
  Validator.isPhone("...");
693
829
  ```
694
-
@@ -0,0 +1,20 @@
1
+ import { Money } from "@uxf/core/money";
2
+ /**
3
+ * Rewrites an amount into its canonical decimal form: `"1000.00"` and `"015"` both become `"1000"` and
4
+ * `"15"`. Returns `null` when the input does not describe a decimal number.
5
+ *
6
+ * The rewriting is textual on purpose. `Money.amount` is a string so that an amount can carry more
7
+ * precision than a double holds, and a `Number()` round-trip would throw that away — `"9007199254740993"`
8
+ * comes back as a different number and `"100000000000000000000000"` comes back as `"1e+23"`.
9
+ */
10
+ export declare function normalizeMoneyAmount(amount: string): string | null;
11
+ /**
12
+ * Normalizes a money value into a canonical shape: the amount is rewritten by `normalizeMoneyAmount` and
13
+ * any extra properties (a GraphQL `__typename`, for instance) are dropped.
14
+ *
15
+ * `Money` is an object, and react-hook-form compares objects by key count and then leaf by leaf, so a
16
+ * default value carrying an extra key — or an amount typed as `"1000.00"` where the default says
17
+ * `"1000"` — reads as dirty forever. Run both the form's default values and the emitted value through
18
+ * this function and the comparison lines up.
19
+ */
20
+ export declare function normalizeMoney(value: Money | null | undefined): Money | null;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeMoneyAmount = normalizeMoneyAmount;
4
+ exports.normalizeMoney = normalizeMoney;
5
+ const is_empty_1 = require("@uxf/core/utils/is-empty");
6
+ const is_nil_1 = require("@uxf/core/utils/is-nil");
7
+ // An optional sign, then digits and/or a fractional part. Deliberately no exponent notation: an amount
8
+ // is a plain decimal string everywhere in this stack, and accepting `1e+23` would mean expanding it,
9
+ // which is the round-trip through a double that this function exists to avoid.
10
+ const DECIMAL_AMOUNT = /^([+-]?)(\d*)(?:\.(\d*))?$/;
11
+ /**
12
+ * Rewrites an amount into its canonical decimal form: `"1000.00"` and `"015"` both become `"1000"` and
13
+ * `"15"`. Returns `null` when the input does not describe a decimal number.
14
+ *
15
+ * The rewriting is textual on purpose. `Money.amount` is a string so that an amount can carry more
16
+ * precision than a double holds, and a `Number()` round-trip would throw that away — `"9007199254740993"`
17
+ * comes back as a different number and `"100000000000000000000000"` comes back as `"1e+23"`.
18
+ */
19
+ function normalizeMoneyAmount(amount) {
20
+ const match = DECIMAL_AMOUNT.exec(amount.trim());
21
+ if ((0, is_nil_1.isNil)(match)) {
22
+ return null;
23
+ }
24
+ const [, sign, integerDigits, fractionDigits = ""] = match;
25
+ // The pattern matches an empty string and a bare "." as well, neither of which is a number.
26
+ if ((0, is_empty_1.isEmpty)(integerDigits) && (0, is_empty_1.isEmpty)(fractionDigits)) {
27
+ return null;
28
+ }
29
+ const integer = integerDigits.replace(/^0+(?=\d)/, "") || "0";
30
+ const fraction = fractionDigits.replace(/0+$/, "");
31
+ const digits = (0, is_empty_1.isEmpty)(fraction) ? integer : `${integer}.${fraction}`;
32
+ return sign === "-" && digits !== "0" ? `-${digits}` : digits;
33
+ }
34
+ /**
35
+ * Normalizes a money value into a canonical shape: the amount is rewritten by `normalizeMoneyAmount` and
36
+ * any extra properties (a GraphQL `__typename`, for instance) are dropped.
37
+ *
38
+ * `Money` is an object, and react-hook-form compares objects by key count and then leaf by leaf, so a
39
+ * default value carrying an extra key — or an amount typed as `"1000.00"` where the default says
40
+ * `"1000"` — reads as dirty forever. Run both the form's default values and the emitted value through
41
+ * this function and the comparison lines up.
42
+ */
43
+ function normalizeMoney(value) {
44
+ if ((0, is_nil_1.isNil)(value)) {
45
+ return null;
46
+ }
47
+ const amount = normalizeMoneyAmount(value.amount);
48
+ return (0, is_nil_1.isNil)(amount) ? null : { amount, currency: value.currency };
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const normalize_money_1 = require("./normalize-money");
4
+ test("normalizes an amount into its canonical decimal form", () => {
5
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1000")).toBe("1000");
6
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1000.00")).toBe("1000");
7
+ expect((0, normalize_money_1.normalizeMoneyAmount)("15.50")).toBe("15.5");
8
+ expect((0, normalize_money_1.normalizeMoneyAmount)("015")).toBe("15");
9
+ expect((0, normalize_money_1.normalizeMoneyAmount)(".5")).toBe("0.5");
10
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-0")).toBe("0");
11
+ expect((0, normalize_money_1.normalizeMoneyAmount)(" 12 ")).toBe("12");
12
+ });
13
+ test("returns null for an amount that is not a decimal number", () => {
14
+ expect((0, normalize_money_1.normalizeMoneyAmount)("")).toBeNull();
15
+ expect((0, normalize_money_1.normalizeMoneyAmount)(" ")).toBeNull();
16
+ expect((0, normalize_money_1.normalizeMoneyAmount)("abc")).toBeNull();
17
+ expect((0, normalize_money_1.normalizeMoneyAmount)(".")).toBeNull();
18
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1 000")).toBeNull();
19
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1e5")).toBeNull();
20
+ expect((0, normalize_money_1.normalizeMoneyAmount)("Infinity")).toBeNull();
21
+ });
22
+ test("keeps an amount a double cannot hold exactly", () => {
23
+ // The whole reason `Money.amount` is a string: a `Number()` round-trip returns "9007199254740992"
24
+ // for the first one and "1e+23" for the second.
25
+ expect((0, normalize_money_1.normalizeMoneyAmount)("9007199254740993")).toBe("9007199254740993");
26
+ expect((0, normalize_money_1.normalizeMoneyAmount)("100000000000000000000000")).toBe("100000000000000000000000");
27
+ expect((0, normalize_money_1.normalizeMoneyAmount)("0.0000001")).toBe("0.0000001");
28
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1.005000")).toBe("1.005");
29
+ });
30
+ test("drops a redundant sign", () => {
31
+ expect((0, normalize_money_1.normalizeMoneyAmount)("+15")).toBe("15");
32
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-15.50")).toBe("-15.5");
33
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-0.00")).toBe("0");
34
+ });
35
+ test("normalizing an amount is idempotent", () => {
36
+ var _a, _b;
37
+ expect((0, normalize_money_1.normalizeMoneyAmount)((_a = (0, normalize_money_1.normalizeMoneyAmount)("1000.00")) !== null && _a !== void 0 ? _a : "")).toBe("1000");
38
+ expect((0, normalize_money_1.normalizeMoneyAmount)((_b = (0, normalize_money_1.normalizeMoneyAmount)("-000.5000")) !== null && _b !== void 0 ? _b : "")).toBe("-0.5");
39
+ });
40
+ test("keeps the currency and drops extra properties", () => {
41
+ var _a;
42
+ const fromApi = { __typename: "Money", amount: "1000.00", currency: "CZK" };
43
+ expect((0, normalize_money_1.normalizeMoney)(fromApi)).toEqual({ amount: "1000", currency: "CZK" });
44
+ expect(Object.keys((_a = (0, normalize_money_1.normalizeMoney)(fromApi)) !== null && _a !== void 0 ? _a : {})).toEqual(["amount", "currency"]);
45
+ });
46
+ test("returns null for an empty value", () => {
47
+ expect((0, normalize_money_1.normalizeMoney)(null)).toBeNull();
48
+ expect((0, normalize_money_1.normalizeMoney)(undefined)).toBeNull();
49
+ expect((0, normalize_money_1.normalizeMoney)({ amount: "", currency: "CZK" })).toBeNull();
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/core",
3
- "version": "11.124.0",
3
+ "version": "11.127.0",
4
4
  "description": "UXF Core",
5
5
  "author": "Petr Vejvoda <vejvoda@uxf.cz>",
6
6
  "homepage": "https://gitlab.com/uxf-npm/core#readme",
@@ -0,0 +1,11 @@
1
+ import { SelectableId } from "@uxf/core/types";
2
+ /**
3
+ * Sorts a multi-choice value into a canonical order.
4
+ *
5
+ * Multi-choice inputs treat their value as a set, but they emit it as an array whose order depends on
6
+ * the order the user clicked. react-hook-form compares arrays index by index, so a value that is
7
+ * semantically unchanged still reads as dirty once an item has been unticked and ticked again. Run both
8
+ * the form's default values and the emitted value through this function and the comparison lines up.
9
+ */
10
+ export declare function normalizeSelectableIds<T extends SelectableId>(value: T[]): T[];
11
+ export declare function normalizeSelectableIds<T extends SelectableId>(value: T[] | null | undefined): T[] | null;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSelectableIds = normalizeSelectableIds;
4
+ const is_nil_1 = require("@uxf/core/utils/is-nil");
5
+ function compareIds(a, b) {
6
+ if (typeof a === "number" && typeof b === "number") {
7
+ return a - b;
8
+ }
9
+ return String(a) < String(b) ? -1 : 1;
10
+ }
11
+ function normalizeSelectableIds(value) {
12
+ if ((0, is_nil_1.isNil)(value)) {
13
+ return null;
14
+ }
15
+ return [...value].sort(compareIds);
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const normalize_selectable_ids_1 = require("./normalize-selectable-ids");
4
+ test("sorts numeric ids numerically", () => {
5
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([10, 9, 100])).toEqual([9, 10, 100]);
6
+ });
7
+ test("sorts string ids", () => {
8
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(["b", "c", "a"])).toEqual(["a", "b", "c"]);
9
+ });
10
+ test("does not depend on the order the items were picked", () => {
11
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([1, 3, 2])).toEqual((0, normalize_selectable_ids_1.normalizeSelectableIds)([3, 2, 1]));
12
+ });
13
+ test("is idempotent", () => {
14
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)((0, normalize_selectable_ids_1.normalizeSelectableIds)([3, 1, 2]))).toEqual([1, 2, 3]);
15
+ });
16
+ test("does not mutate the input", () => {
17
+ const value = [3, 1, 2];
18
+ (0, normalize_selectable_ids_1.normalizeSelectableIds)(value);
19
+ expect(value).toEqual([3, 1, 2]);
20
+ });
21
+ test("keeps an empty value", () => {
22
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(null)).toBeNull();
23
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(undefined)).toBeNull();
24
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([])).toEqual([]);
25
+ });
@@ -0,0 +1,10 @@
1
+ export interface SafeStorage {
2
+ clear: () => boolean;
3
+ getItem: (key: string) => string | null;
4
+ key: (index: number) => string | null;
5
+ readonly length: number;
6
+ removeItem: (key: string) => boolean;
7
+ setItem: (key: string, value: string) => boolean;
8
+ }
9
+ export declare const safeLocalStorage: SafeStorage;
10
+ export declare const safeSessionStorage: SafeStorage;
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeSessionStorage = exports.safeLocalStorage = void 0;
4
+ const is_browser_1 = require("./is-browser");
5
+ const is_nil_1 = require("./is-nil");
6
+ /**
7
+ * Web storage is not always usable. Safari in private mode, iOS with cookies blocked, sandboxed
8
+ * iframes and several in-app browsers throw `SecurityError: The operation is insecure.` — and a full
9
+ * quota throws `QuotaExceededError` — on **reads as well as writes**, and in some browsers even on
10
+ * the bare `window.localStorage` property access. An unguarded read during render therefore takes
11
+ * the whole app down.
12
+ *
13
+ * These wrappers never throw: a read that cannot happen returns `null`, a write that cannot happen
14
+ * returns `false`. The storage object is resolved per call, so a page that gains (or loses) access
15
+ * mid-session is handled too, and importing this module on the server is safe.
16
+ */
17
+ function getWebStorage(name) {
18
+ if (!is_browser_1.isBrowser) {
19
+ return null;
20
+ }
21
+ try {
22
+ return window[name];
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function createSafeStorage(name) {
29
+ return {
30
+ clear() {
31
+ const storage = getWebStorage(name);
32
+ if ((0, is_nil_1.isNil)(storage)) {
33
+ return false;
34
+ }
35
+ try {
36
+ storage.clear();
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ },
43
+ getItem(key) {
44
+ const storage = getWebStorage(name);
45
+ if ((0, is_nil_1.isNil)(storage)) {
46
+ return null;
47
+ }
48
+ try {
49
+ return storage.getItem(key);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ },
55
+ key(index) {
56
+ const storage = getWebStorage(name);
57
+ if ((0, is_nil_1.isNil)(storage)) {
58
+ return null;
59
+ }
60
+ try {
61
+ return storage.key(index);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ },
67
+ get length() {
68
+ const storage = getWebStorage(name);
69
+ if ((0, is_nil_1.isNil)(storage)) {
70
+ return 0;
71
+ }
72
+ try {
73
+ return storage.length;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ },
79
+ removeItem(key) {
80
+ const storage = getWebStorage(name);
81
+ if ((0, is_nil_1.isNil)(storage)) {
82
+ return false;
83
+ }
84
+ try {
85
+ storage.removeItem(key);
86
+ return true;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ },
92
+ setItem(key, value) {
93
+ const storage = getWebStorage(name);
94
+ if ((0, is_nil_1.isNil)(storage)) {
95
+ return false;
96
+ }
97
+ try {
98
+ storage.setItem(key, value);
99
+ return true;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ },
105
+ };
106
+ }
107
+ exports.safeLocalStorage = createSafeStorage("localStorage");
108
+ exports.safeSessionStorage = createSafeStorage("sessionStorage");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const safe_storage_1 = require("./safe-storage");
4
+ function throwSecurityError() {
5
+ throw new DOMException("The operation is insecure.", "SecurityError");
6
+ }
7
+ describe.each([
8
+ ["safeLocalStorage", safe_storage_1.safeLocalStorage, window.localStorage],
9
+ ["safeSessionStorage", safe_storage_1.safeSessionStorage, window.sessionStorage],
10
+ ])("%s", (_name, safeStorage, storage) => {
11
+ beforeEach(() => {
12
+ jest.restoreAllMocks();
13
+ storage.clear();
14
+ });
15
+ describe("with a working storage", () => {
16
+ it("reads and writes", () => {
17
+ expect(safeStorage.getItem("theme")).toBeNull();
18
+ expect(safeStorage.setItem("theme", "dark")).toBe(true);
19
+ expect(safeStorage.getItem("theme")).toBe("dark");
20
+ });
21
+ it("removes and clears", () => {
22
+ safeStorage.setItem("theme", "dark");
23
+ expect(safeStorage.removeItem("theme")).toBe(true);
24
+ expect(safeStorage.getItem("theme")).toBeNull();
25
+ safeStorage.setItem("theme", "dark");
26
+ expect(safeStorage.clear()).toBe(true);
27
+ expect(safeStorage.length).toBe(0);
28
+ });
29
+ it("enumerates keys", () => {
30
+ safeStorage.setItem("theme", "dark");
31
+ expect(safeStorage.length).toBe(1);
32
+ expect(safeStorage.key(0)).toBe("theme");
33
+ expect(safeStorage.key(1)).toBeNull();
34
+ });
35
+ });
36
+ describe("with a storage that throws (private mode, blocked storage, full quota)", () => {
37
+ it("returns null instead of throwing on a read", () => {
38
+ jest.spyOn(Storage.prototype, "getItem").mockImplementation(throwSecurityError);
39
+ expect(() => safeStorage.getItem("theme")).not.toThrow();
40
+ expect(safeStorage.getItem("theme")).toBeNull();
41
+ });
42
+ it("returns false instead of throwing on a write", () => {
43
+ jest.spyOn(Storage.prototype, "setItem").mockImplementation(throwSecurityError);
44
+ jest.spyOn(Storage.prototype, "removeItem").mockImplementation(throwSecurityError);
45
+ jest.spyOn(Storage.prototype, "clear").mockImplementation(throwSecurityError);
46
+ expect(safeStorage.setItem("theme", "dark")).toBe(false);
47
+ expect(safeStorage.removeItem("theme")).toBe(false);
48
+ expect(safeStorage.clear()).toBe(false);
49
+ });
50
+ it("returns empty values instead of throwing on enumeration", () => {
51
+ jest.spyOn(Storage.prototype, "key").mockImplementation(throwSecurityError);
52
+ jest.spyOn(Storage.prototype, "length", "get").mockImplementation(throwSecurityError);
53
+ expect(safeStorage.key(0)).toBeNull();
54
+ expect(safeStorage.length).toBe(0);
55
+ });
56
+ });
57
+ });