@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 +241 -106
- package/money/normalize-money.d.ts +20 -0
- package/money/normalize-money.js +49 -0
- package/money/normalize-money.test.d.ts +1 -0
- package/money/normalize-money.test.js +50 -0
- package/package.json +1 -1
- package/utils/normalize-selectable-ids.d.ts +11 -0
- package/utils/normalize-selectable-ids.js +16 -0
- package/utils/normalize-selectable-ids.test.d.ts +1 -0
- package/utils/normalize-selectable-ids.test.js +25 -0
- package/utils/safe-storage.d.ts +10 -0
- package/utils/safe-storage.js +108 -0
- package/utils/safe-storage.test.d.ts +1 -0
- package/utils/safe-storage.test.js +57 -0
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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 = [
|
|
289
|
-
|
|
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 = [
|
|
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);
|
|
328
|
-
humanIndex(9);
|
|
329
|
-
humanIndex(-1);
|
|
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 });
|
|
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);
|
|
448
|
-
isEven(4);
|
|
449
|
-
isEven(1);
|
|
450
|
-
isEven(3);
|
|
451
|
-
isEven(0);
|
|
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);
|
|
463
|
-
isOdd(3);
|
|
464
|
-
isOdd(2);
|
|
465
|
-
isOdd(4);
|
|
466
|
-
isOdd(0);
|
|
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);
|
|
574
|
+
isNil(null); /* returns true */
|
|
486
575
|
isNil(undefined); /* returns true */
|
|
487
|
-
isNil(true);
|
|
488
|
-
isNil(1);
|
|
489
|
-
isNil(0);
|
|
490
|
-
isNil([]);
|
|
491
|
-
isNil("string");
|
|
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);
|
|
588
|
+
isNotNil(null); /* returns false */
|
|
500
589
|
isNotNil(undefined); /* returns false */
|
|
501
|
-
isNotNil(true);
|
|
502
|
-
isNotNil(1);
|
|
503
|
-
isNotNil(0);
|
|
504
|
-
isNotNil([]);
|
|
505
|
-
isNotNil("string");
|
|
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({});
|
|
533
|
-
isPlainObject({ a: 1 });
|
|
534
|
-
isPlainObject([]);
|
|
535
|
-
isPlainObject([1, 2, 3]);
|
|
536
|
-
isPlainObject(new Date());
|
|
537
|
-
isPlainObject(new Map());
|
|
538
|
-
isPlainObject(new Set());
|
|
539
|
-
isPlainObject(/regex/);
|
|
540
|
-
isPlainObject(null);
|
|
541
|
-
isPlainObject(undefined);
|
|
542
|
-
isPlainObject("string");
|
|
543
|
-
isPlainObject(123);
|
|
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([]);
|
|
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([]);
|
|
563
|
-
nonEmptyArrayOrNull(null);
|
|
564
|
-
nonEmptyArrayOrNull(undefined);
|
|
565
|
-
nonEmptyArrayOrNull([1, 2, 3]);
|
|
566
|
-
nonEmptyArrayOrNull(["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("");
|
|
577
|
-
nonEmptyStringOrNull(undefined);
|
|
578
|
-
nonEmptyStringOrNull(null);
|
|
579
|
-
nonEmptyStringOrNull("test");
|
|
580
|
-
nonEmptyStringOrNull(" ");
|
|
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);
|
|
591
|
-
nullishToEmptyString(undefined);
|
|
592
|
-
nullishToEmptyString("");
|
|
593
|
-
nullishToEmptyString("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);
|
|
604
|
-
numberOrNull(42);
|
|
605
|
-
numberOrNull(-1);
|
|
606
|
-
numberOrNull(3.14);
|
|
607
|
-
numberOrNull(NaN);
|
|
608
|
-
numberOrNull(null);
|
|
609
|
-
numberOrNull(undefined);
|
|
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");
|
|
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
|
@@ -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
|
+
});
|