vultus-core 0.4.0 → 0.5.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 +304 -6
- package/dist/ArgStoreContext.d.ts +10 -0
- package/dist/ArgStoreContext.d.ts.map +1 -0
- package/dist/ArgStoreContext.js +15 -0
- package/dist/ArgStoreContext.js.map +1 -0
- package/dist/FieldContext.d.ts +38 -0
- package/dist/FieldContext.d.ts.map +1 -0
- package/dist/FieldContext.js +82 -0
- package/dist/FieldContext.js.map +1 -0
- package/dist/argStore.d.ts +26 -0
- package/dist/argStore.d.ts.map +1 -0
- package/dist/argStore.js +27 -0
- package/dist/argStore.js.map +1 -0
- package/dist/choices.d.ts +43 -0
- package/dist/choices.d.ts.map +1 -0
- package/dist/choices.js +20 -0
- package/dist/choices.js.map +1 -0
- package/dist/field.d.ts +42 -15
- package/dist/field.d.ts.map +1 -1
- package/dist/field.js +20 -7
- package/dist/field.js.map +1 -1
- package/dist/index.d.ts +21 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -3
- package/dist/index.js.map +1 -1
- package/dist/kinds.d.ts +51 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +143 -0
- package/dist/kinds.js.map +1 -0
- package/dist/localStorageStore.d.ts +16 -0
- package/dist/localStorageStore.d.ts.map +1 -0
- package/dist/localStorageStore.js +72 -0
- package/dist/localStorageStore.js.map +1 -0
- package/dist/normalizeText.d.ts +6 -0
- package/dist/normalizeText.d.ts.map +1 -0
- package/dist/normalizeText.js +8 -0
- package/dist/normalizeText.js.map +1 -0
- package/dist/numeric.d.ts +38 -0
- package/dist/numeric.d.ts.map +1 -0
- package/dist/numeric.js +43 -0
- package/dist/numeric.js.map +1 -0
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +25 -0
- package/dist/resolve.js.map +1 -1
- package/dist/tree.d.ts +69 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +151 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +15 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/useArg.d.ts +45 -0
- package/dist/useArg.d.ts.map +1 -0
- package/dist/useArg.js +83 -0
- package/dist/useArg.js.map +1 -0
- package/dist/useBoolField.d.ts +8 -3
- package/dist/useBoolField.d.ts.map +1 -1
- package/dist/useBoolField.js +9 -1
- package/dist/useBoolField.js.map +1 -1
- package/dist/useManyOfField.d.ts +35 -0
- package/dist/useManyOfField.d.ts.map +1 -0
- package/dist/useManyOfField.js +74 -0
- package/dist/useManyOfField.js.map +1 -0
- package/dist/useMixedBoolField.d.ts +8 -3
- package/dist/useMixedBoolField.d.ts.map +1 -1
- package/dist/useMixedBoolField.js +9 -1
- package/dist/useMixedBoolField.js.map +1 -1
- package/dist/useNumericField.d.ts +25 -0
- package/dist/useNumericField.d.ts.map +1 -0
- package/dist/useNumericField.js +52 -0
- package/dist/useNumericField.js.map +1 -0
- package/dist/useOneOfField.d.ts +18 -34
- package/dist/useOneOfField.d.ts.map +1 -1
- package/dist/useOneOfField.js +18 -18
- package/dist/useOneOfField.js.map +1 -1
- package/dist/useRangeField.d.ts +34 -0
- package/dist/useRangeField.d.ts.map +1 -0
- package/dist/useRangeField.js +63 -0
- package/dist/useRangeField.js.map +1 -0
- package/dist/useStoredField.d.ts +61 -0
- package/dist/useStoredField.d.ts.map +1 -0
- package/dist/useStoredField.js +151 -0
- package/dist/useStoredField.js.map +1 -0
- package/dist/useTextField.d.ts +22 -0
- package/dist/useTextField.d.ts.map +1 -0
- package/dist/useTextField.js +21 -0
- package/dist/useTextField.js.map +1 -0
- package/dist/useTreeField.d.ts +39 -0
- package/dist/useTreeField.d.ts.map +1 -0
- package/dist/useTreeField.js +66 -0
- package/dist/useTreeField.js.map +1 -0
- package/dist/valueTypes.d.ts +28 -0
- package/dist/valueTypes.d.ts.map +1 -0
- package/dist/valueTypes.js +65 -0
- package/dist/valueTypes.js.map +1 -0
- package/package.json +1 -1
- package/src/ArgStoreContext.tsx +16 -0
- package/src/FieldContext.tsx +98 -0
- package/src/argStore.ts +49 -0
- package/src/choices.ts +62 -0
- package/src/field.ts +64 -25
- package/src/index.ts +50 -4
- package/src/kinds.ts +197 -0
- package/src/localStorageStore.ts +68 -0
- package/src/normalizeText.ts +7 -0
- package/src/numeric.ts +68 -0
- package/src/resolve.ts +23 -0
- package/src/tree.ts +175 -0
- package/src/types.ts +28 -7
- package/src/useArg.ts +121 -0
- package/src/useBoolField.ts +19 -6
- package/src/useManyOfField.ts +107 -0
- package/src/useMixedBoolField.ts +19 -6
- package/src/useNumericField.ts +69 -0
- package/src/useOneOfField.ts +38 -58
- package/src/useRangeField.ts +86 -0
- package/src/useStoredField.ts +205 -0
- package/src/useTextField.ts +39 -0
- package/src/useTreeField.ts +108 -0
- package/src/valueTypes.ts +77 -0
package/src/argStore.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Whether a write pushes a new history entry or replaces the current one; a store without history ignores it. */
|
|
2
|
+
export type UpdateMethod = 'push' | 'replace';
|
|
3
|
+
|
|
4
|
+
/** One key's new raw value, or `undefined` to remove it (the key reads back as its default). */
|
|
5
|
+
export interface RawChange {
|
|
6
|
+
key: string;
|
|
7
|
+
raw: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A store for argument values kept as strings (moxb's url-arg backends, batched): a URL query,
|
|
12
|
+
* localStorage, or (here) memory. `read` and `write` move raw strings only — parsing, defaults
|
|
13
|
+
* and equality live in the value type and in useArg, not here. The hooks call its methods on
|
|
14
|
+
* the store, never detached, so a store may be a class whose methods use `this`.
|
|
15
|
+
*/
|
|
16
|
+
export interface ArgStore {
|
|
17
|
+
read(key: string): string | undefined;
|
|
18
|
+
/** Applies every change, then notifies subscribers once — several keys changed together are one history entry. */
|
|
19
|
+
write(changes: RawChange[], method: UpdateMethod): void;
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
/** The href a write of `changes` would produce, where the store has one (a URL store); omitted otherwise. */
|
|
22
|
+
hrefFor?(changes: RawChange[]): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An ArgStore over a plain object, for tests and demos. `snapshot()` is a copy, for assertions. */
|
|
26
|
+
export function memoryStore(initial: Record<string, string> = {}): ArgStore & { snapshot(): Record<string, string> } {
|
|
27
|
+
const data: Record<string, string> = { ...initial };
|
|
28
|
+
const listeners = new Set<() => void>();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
read(key) {
|
|
32
|
+
return data[key];
|
|
33
|
+
},
|
|
34
|
+
write(changes, _method) {
|
|
35
|
+
for (const { key, raw } of changes) {
|
|
36
|
+
if (raw === undefined) delete data[key];
|
|
37
|
+
else data[key] = raw;
|
|
38
|
+
}
|
|
39
|
+
listeners.forEach((listener) => listener());
|
|
40
|
+
},
|
|
41
|
+
subscribe(listener) {
|
|
42
|
+
listeners.add(listener);
|
|
43
|
+
return () => listeners.delete(listener);
|
|
44
|
+
},
|
|
45
|
+
snapshot() {
|
|
46
|
+
return { ...data };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/choices.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { calculateEnabled } from './field.js';
|
|
3
|
+
import { getReason, getVerdict } from './decision.js';
|
|
4
|
+
import type { Decision } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One choice of a one-of or many-of field, or a tree node (moxb's
|
|
8
|
+
* BindOneOfChoice). Shared by useOneOfField today; useManyOfField and the
|
|
9
|
+
* tree hook (later tasks) build on the same shape.
|
|
10
|
+
*/
|
|
11
|
+
export interface OneOfChoice<T extends string = string> {
|
|
12
|
+
value: T;
|
|
13
|
+
/** Shown for the choice; default: the value. */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** What choosing it does (moxb's `help`), markdown; also the field's current state when it is chosen. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Shown before the label (or instead of it, for an icon-only widget), like an action's icon. */
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
/** Choosable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
|
|
20
|
+
enabled?: Decision;
|
|
21
|
+
disabled?: Decision;
|
|
22
|
+
/** Shown as the main or a dangerous choice, like an action's variant. Default 'default'. */
|
|
23
|
+
variant?: 'default' | 'primary' | 'danger';
|
|
24
|
+
/** A heading the choice is listed under, after the choices without one (OneOfSelect); default none. */
|
|
25
|
+
group?: string;
|
|
26
|
+
/** Shown after the label in secondary colour, e.g. a count ("12", "12 · 8%"). Default none. */
|
|
27
|
+
note?: string;
|
|
28
|
+
/** Shown faded, e.g. a choice with nothing behind it right now (no matching tasks). Default false. */
|
|
29
|
+
dimmed?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A choice as the widgets get it. */
|
|
33
|
+
export interface OneOfChoiceControls<T extends string = string> {
|
|
34
|
+
value: T;
|
|
35
|
+
label: string;
|
|
36
|
+
description: string | undefined;
|
|
37
|
+
icon: ReactNode | undefined;
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
whyDisabled: string | undefined;
|
|
40
|
+
variant: 'default' | 'primary' | 'danger';
|
|
41
|
+
group: string | undefined;
|
|
42
|
+
note: string | undefined;
|
|
43
|
+
dimmed: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolves a choice's `enabled` / `disabled` and defaults, as useOneOfField does for each of its choices. */
|
|
47
|
+
export function resolveChoice<T extends string>(fieldId: string, choice: OneOfChoice<T>): OneOfChoiceControls<T> {
|
|
48
|
+
const decision = calculateEnabled(`${fieldId}.${choice.value}`, choice.enabled, choice.disabled);
|
|
49
|
+
const enabled = getVerdict(decision, true);
|
|
50
|
+
return {
|
|
51
|
+
value: choice.value,
|
|
52
|
+
label: choice.label ?? choice.value,
|
|
53
|
+
description: choice.description,
|
|
54
|
+
icon: choice.icon,
|
|
55
|
+
enabled,
|
|
56
|
+
whyDisabled: enabled ? undefined : getReason(decision),
|
|
57
|
+
variant: choice.variant ?? 'default',
|
|
58
|
+
group: choice.group,
|
|
59
|
+
note: choice.note,
|
|
60
|
+
dimmed: choice.dimmed ?? false,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/field.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { getReason, getVerdict, invertDecision } from './decision.js';
|
|
3
|
-
import {
|
|
3
|
+
import { useFieldContext } from './FieldContext.js';
|
|
4
|
+
import { resolveIn } from './resolve.js';
|
|
4
5
|
import { useAction } from './useAction.js';
|
|
5
6
|
import type {
|
|
6
7
|
ConfirmationSpec,
|
|
@@ -21,6 +22,12 @@ import type {
|
|
|
21
22
|
* Design notes: unitas docs/2026-10-05-vultus-field-model-design.md.
|
|
22
23
|
*/
|
|
23
24
|
|
|
25
|
+
/** The field kinds (FieldControls.type and a FieldDefinition's `kind`). */
|
|
26
|
+
export type FieldKind = 'boolean' | 'mixed-boolean' | 'oneOf' | 'manyOf' | 'numeric' | 'range' | 'tree' | 'text';
|
|
27
|
+
|
|
28
|
+
/** The widget to render a field with: a name, or a name with display options overriding its defaults. */
|
|
29
|
+
export type WidgetHint = string | { name: string; [option: string]: unknown };
|
|
30
|
+
|
|
24
31
|
/** How an immediately committing field stores a change: an action that receives the requested value. */
|
|
25
32
|
export interface FieldCommit<TRequest, RouteId extends string = string> {
|
|
26
33
|
fire: (next: TRequest, ctx: ExecutionContext) => Promise<void> | void;
|
|
@@ -32,16 +39,30 @@ export interface FieldCommit<TRequest, RouteId extends string = string> {
|
|
|
32
39
|
/** The key of a value in `valueDescriptions`: 'true' / 'false' for booleans, the value itself for strings. */
|
|
33
40
|
export type ValueKey<T> = T extends boolean ? `${T}` : T extends string | number ? `${T}` : never;
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
/**
|
|
43
|
+
* What every field has, whatever its storage. `Ctx` is the field context the options' functions
|
|
44
|
+
* receive (see FieldContextProvider); a function without parameters needs none.
|
|
45
|
+
*/
|
|
46
|
+
export interface FieldBaseOptions<TValue, Ctx = any> {
|
|
36
47
|
id: string;
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The field's kind, as a FieldDefinition carries it (optional here: a hook knows its own
|
|
50
|
+
* kind). Given and different from the hook's own kind, it throws — a defence against passing
|
|
51
|
+
* the wrong definition to a kind-specific hook.
|
|
52
|
+
*/
|
|
53
|
+
kind?: FieldKind;
|
|
54
|
+
label?: ValueOrFn<string, Ctx>;
|
|
55
|
+
description?: ValueOrFn<string | undefined, Ctx>;
|
|
56
|
+
/** Shown while the field is empty (moxb's Value); a widget that has no notion of "empty" ignores it. */
|
|
57
|
+
placeholder?: ValueOrFn<string | undefined, Ctx>;
|
|
58
|
+
/** The widget to render this field with: a name, or a name with its display options. Each kind has a default; vultus-antd's `<Field>` applies render-site overrides on top of this. */
|
|
59
|
+
widget?: WidgetHint;
|
|
39
60
|
/** Shown? Default true. Use `visible` or `hidden`, not contradicting each other. */
|
|
40
|
-
visible?: ValueOrFn<boolean>;
|
|
41
|
-
hidden?: ValueOrFn<boolean>;
|
|
61
|
+
visible?: ValueOrFn<boolean, Ctx>;
|
|
62
|
+
hidden?: ValueOrFn<boolean, Ctx>;
|
|
42
63
|
/** Changeable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
|
|
43
|
-
enabled?: ValueOrFn<Decision>;
|
|
44
|
-
disabled?: ValueOrFn<Decision>;
|
|
64
|
+
enabled?: ValueOrFn<Decision, Ctx>;
|
|
65
|
+
disabled?: ValueOrFn<Decision, Ctx>;
|
|
45
66
|
/**
|
|
46
67
|
* What each value means right now, keyed by value (for booleans `true` / `false`, and `mixed`
|
|
47
68
|
* where the field can show it). Shown with the current value, next to `description`, which
|
|
@@ -55,23 +76,24 @@ interface FieldBaseOptions<TValue> {
|
|
|
55
76
|
* `TValue` and can be asked for a `TRequest`; usually the same type, but an aggregate can show
|
|
56
77
|
* a state that cannot be requested (a boolean field showing 'mixed').
|
|
57
78
|
*/
|
|
58
|
-
export type FieldStorage<TValue, TRequest = TValue, RouteId extends string = string> =
|
|
59
|
-
/** Inside the field: its own state, starting at `initialValue
|
|
60
|
-
| { initialValue?: TValue
|
|
79
|
+
export type FieldStorage<TValue, TRequest = TValue, RouteId extends string = string, Ctx = any> =
|
|
80
|
+
/** Inside the field: its own state, starting at `initialValue` (resolved once, when the field mounts). */
|
|
81
|
+
| { initialValue?: ValueOrFn<TValue, Ctx>; value?: never; onChange?: never; commit?: never }
|
|
61
82
|
/** Outside, changed locally: the caller owns the value and applies `onChange`. */
|
|
62
83
|
| { value: TValue; onChange: (value: TRequest) => void; initialValue?: never; commit?: never }
|
|
63
84
|
/** Outside, committed immediately: `commit.fire` stores the change (pending, messages, confirmation). */
|
|
64
85
|
| { value: TValue; commit: FieldCommit<TRequest, RouteId>; initialValue?: never; onChange?: never };
|
|
65
86
|
|
|
66
|
-
export type FieldOptions<TValue, TRequest = TValue, RouteId extends string = string> =
|
|
67
|
-
FieldBaseOptions<TValue> & FieldStorage<TValue, TRequest, RouteId>;
|
|
87
|
+
export type FieldOptions<TValue, TRequest = TValue, RouteId extends string = string, Ctx = any> =
|
|
88
|
+
FieldBaseOptions<TValue, Ctx> & FieldStorage<TValue, TRequest, RouteId, Ctx>;
|
|
68
89
|
|
|
69
90
|
/** What a field widget receives: everything it shows, and the one way to change the value. */
|
|
70
91
|
export interface FieldControls<TValue, TRequest = TValue> {
|
|
71
|
-
type:
|
|
92
|
+
type: FieldKind;
|
|
72
93
|
id: string;
|
|
73
94
|
label: string;
|
|
74
95
|
description: string | undefined;
|
|
96
|
+
placeholder: string | undefined;
|
|
75
97
|
/** What the current value means (from `valueDescriptions`). */
|
|
76
98
|
valueDescription: string | undefined;
|
|
77
99
|
visible: boolean;
|
|
@@ -82,10 +104,13 @@ export interface FieldControls<TValue, TRequest = TValue> {
|
|
|
82
104
|
setValue(next: TRequest): void;
|
|
83
105
|
/** A commit is running. */
|
|
84
106
|
pending: boolean;
|
|
107
|
+
/** The field stores through `commit` — true exactly in commit storage mode, so a widget can hold back requests while typing. */
|
|
108
|
+
commits: boolean;
|
|
85
109
|
/** Messages of the last commit. */
|
|
86
110
|
messages: Message[];
|
|
87
111
|
/** The confirmation a widget must obtain before requesting `next`, if any. */
|
|
88
112
|
confirmationFor(next: TRequest): ConfirmationSpec | undefined;
|
|
113
|
+
widget: WidgetHint | undefined;
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
export type FieldStorageMode = 'inside' | 'local' | 'commit';
|
|
@@ -122,27 +147,38 @@ export function calculateEnabled(id: string, enabled: Decision | undefined, disa
|
|
|
122
147
|
/**
|
|
123
148
|
* The shared core of every field hook: label and description, visibility and
|
|
124
149
|
* enabled state (with reason), the storage mode, and in commit mode the setter
|
|
125
|
-
* as an action (its pending state and messages become the field's).
|
|
150
|
+
* as an action (its pending state and messages become the field's). Functions
|
|
151
|
+
* among the options are resolved with the field context, on every render;
|
|
152
|
+
* `initialValue` once, when the field mounts. A kind's hook passes its implicit
|
|
153
|
+
* initial value as a zero-argument function, so that too is computed only then.
|
|
126
154
|
*/
|
|
127
|
-
export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId extends string = string>(
|
|
128
|
-
type:
|
|
129
|
-
opts: FieldOptions<TValue, TRequest, RouteId>,
|
|
155
|
+
export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId extends string = string, Ctx = any>(
|
|
156
|
+
type: FieldKind,
|
|
157
|
+
opts: FieldOptions<TValue, TRequest, RouteId, Ctx>,
|
|
130
158
|
registry?: ErrorRoutesRegistry<RouteId>,
|
|
131
159
|
): FieldControls<TValue, TRequest> {
|
|
160
|
+
if (opts.kind !== undefined && opts.kind !== type) {
|
|
161
|
+
throw new Error(`Field '${opts.id}': a definition of kind '${opts.kind}' was passed to the '${type}' hook`);
|
|
162
|
+
}
|
|
132
163
|
const mode = fieldStorageMode(opts.id, opts as FieldStorage<unknown, unknown, string>);
|
|
133
|
-
const
|
|
164
|
+
const ctx = useFieldContext();
|
|
165
|
+
const where = (name: string) => `Field '${opts.id}': option '${name}'`;
|
|
166
|
+
const [inner, setInner] = useState<TValue>(
|
|
167
|
+
() => resolveIn<TValue | undefined>(opts.initialValue, undefined, ctx, where('initialValue')) as TValue,
|
|
168
|
+
);
|
|
134
169
|
|
|
135
|
-
const label =
|
|
136
|
-
const description =
|
|
170
|
+
const label = resolveIn(opts.label, '', ctx, where('label'));
|
|
171
|
+
const description = resolveIn<string | undefined>(opts.description, undefined, ctx, where('description'));
|
|
172
|
+
const placeholder = resolveIn<string | undefined>(opts.placeholder, undefined, ctx, where('placeholder'));
|
|
137
173
|
const visible = calculateVisible(
|
|
138
174
|
opts.id,
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
resolveIn<boolean | undefined>(opts.visible, undefined, ctx, where('visible')),
|
|
176
|
+
resolveIn<boolean | undefined>(opts.hidden, undefined, ctx, where('hidden')),
|
|
141
177
|
);
|
|
142
178
|
const enabledDecision = calculateEnabled(
|
|
143
179
|
opts.id,
|
|
144
|
-
|
|
145
|
-
|
|
180
|
+
resolveIn<Decision | undefined>(opts.enabled, undefined, ctx, where('enabled')),
|
|
181
|
+
resolveIn<Decision | undefined>(opts.disabled, undefined, ctx, where('disabled')),
|
|
146
182
|
);
|
|
147
183
|
const enabled = getVerdict(enabledDecision, true);
|
|
148
184
|
const whyDisabled = enabled ? undefined : getReason(enabledDecision);
|
|
@@ -184,6 +220,7 @@ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId e
|
|
|
184
220
|
id: opts.id,
|
|
185
221
|
label,
|
|
186
222
|
description,
|
|
223
|
+
placeholder,
|
|
187
224
|
valueDescription,
|
|
188
225
|
visible,
|
|
189
226
|
enabled,
|
|
@@ -191,7 +228,9 @@ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId e
|
|
|
191
228
|
value,
|
|
192
229
|
setValue,
|
|
193
230
|
pending,
|
|
231
|
+
commits: mode === 'commit',
|
|
194
232
|
messages: mode === 'commit' ? (setter.messages ?? []) : [],
|
|
195
233
|
confirmationFor,
|
|
234
|
+
widget: opts.widget,
|
|
196
235
|
};
|
|
197
236
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,17 +6,63 @@ export * from './routeApiError.js';
|
|
|
6
6
|
export { ActionErrorContext, useActionErrorCtx } from './useActionErrorCtx.js';
|
|
7
7
|
export { useAction } from './useAction.js';
|
|
8
8
|
export { useActionList } from './useActionList.js';
|
|
9
|
+
export {
|
|
10
|
+
FieldContextProvider, createFieldContext, useFieldContext, NO_FIELD_CONTEXT, type FieldCtx,
|
|
11
|
+
} from './FieldContext.js';
|
|
9
12
|
export {
|
|
10
13
|
useFieldCore, fieldStorageMode, calculateVisible, calculateEnabled,
|
|
11
|
-
type FieldCommit, type FieldControls, type FieldOptions, type FieldStorage, type FieldStorageMode,
|
|
14
|
+
type FieldBaseOptions, type FieldCommit, type FieldControls, type FieldOptions, type FieldStorage, type FieldStorageMode,
|
|
15
|
+
type FieldKind, type WidgetHint, type ValueKey,
|
|
12
16
|
} from './field.js';
|
|
13
|
-
export {
|
|
17
|
+
export { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
|
|
18
|
+
export { normalizeText } from './normalizeText.js';
|
|
19
|
+
export { useBoolField, boolInitialValue, type BoolFieldControls, type BoolFieldOptions } from './useBoolField.js';
|
|
14
20
|
export {
|
|
15
|
-
useMixedBoolField, aggregateBool,
|
|
21
|
+
useMixedBoolField, aggregateBool, mixedBoolInitialValue,
|
|
22
|
+
type MixedBool, type MixedBoolFieldControls, type MixedBoolFieldOptions,
|
|
16
23
|
} from './useMixedBoolField.js';
|
|
17
24
|
export {
|
|
18
|
-
useOneOfField,
|
|
25
|
+
useOneOfField, oneOfInitialValue, type OneOfFieldControls, type OneOfFieldOptions, type OneOfOwnOptions,
|
|
19
26
|
} from './useOneOfField.js';
|
|
27
|
+
export {
|
|
28
|
+
useManyOfField, manyOfInitialValue,
|
|
29
|
+
type ManyOfFieldControls, type ManyOfFieldOptions, type ManyOfOwnOptions, type ManyOfChoiceControls,
|
|
30
|
+
} from './useManyOfField.js';
|
|
31
|
+
export { clamp, resolveNumeric, type NumericBounds, type ResolvedNumeric } from './numeric.js';
|
|
32
|
+
export {
|
|
33
|
+
useNumericField, numericInitialValue,
|
|
34
|
+
type NumericFieldControls, type NumericFieldOptions, type NumericOwnOptions,
|
|
35
|
+
} from './useNumericField.js';
|
|
36
|
+
export {
|
|
37
|
+
useRangeField, rangeInitialValue,
|
|
38
|
+
type NumberRange, type RangeFieldControls, type RangeFieldOptions, type RangeOwnOptions,
|
|
39
|
+
} from './useRangeField.js';
|
|
40
|
+
export {
|
|
41
|
+
normalizeTreeValue, coveredNodes, toggleTreeValue, expandedTreeKeys, filterTree,
|
|
42
|
+
type TreeNode, type TreeNodeControls, type TreeSelection, type TreeExpand,
|
|
43
|
+
} from './tree.js';
|
|
44
|
+
export {
|
|
45
|
+
useTreeField, treeInitialValue,
|
|
46
|
+
type TreeFieldControls, type TreeFieldOptions, type TreeOwnOptions,
|
|
47
|
+
} from './useTreeField.js';
|
|
48
|
+
export {
|
|
49
|
+
useTextField, textInitialValue,
|
|
50
|
+
type TextFieldControls, type TextFieldOptions, type TextOwnOptions,
|
|
51
|
+
} from './useTextField.js';
|
|
52
|
+
export {
|
|
53
|
+
booleanType, stringType, stringSetType, numberType, rangeType, type ValueType,
|
|
54
|
+
} from './valueTypes.js';
|
|
55
|
+
export { initialValueOf, valueTypeOf, sanitizeStored, type FieldDefinition } from './kinds.js';
|
|
56
|
+
export { memoryStore, type ArgStore, type RawChange, type UpdateMethod } from './argStore.js';
|
|
57
|
+
export { localStorageStore } from './localStorageStore.js';
|
|
58
|
+
export { ArgStoreProvider, useArgStore } from './ArgStoreContext.js';
|
|
59
|
+
export {
|
|
60
|
+
useArg, useArgWriter, setArg, resetArg,
|
|
61
|
+
type ArgDefinition, type ArgControls, type ArgChange,
|
|
62
|
+
} from './useArg.js';
|
|
63
|
+
export {
|
|
64
|
+
argOf, useStoredField, useStoredFields, type StoredField, type StoredFields,
|
|
65
|
+
} from './useStoredField.js';
|
|
20
66
|
export { InternalLinkContext, useInternalLink, type InternalLinkComponent } from './LinkContext.js';
|
|
21
67
|
export { MessageSinkContext, useMessageSink, type MessageSink } from './MessageSink.js';
|
|
22
68
|
export {
|
package/src/kinds.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
|
|
2
|
+
import type { FieldBaseOptions, FieldKind } from './field.js';
|
|
3
|
+
import type { FieldCtx } from './FieldContext.js';
|
|
4
|
+
import { clamp, resolveNumeric } from './numeric.js';
|
|
5
|
+
import { resolveIn } from './resolve.js';
|
|
6
|
+
import { normalizeTreeValue, type TreeNode } from './tree.js';
|
|
7
|
+
import type { ValueOrFn } from './types.js';
|
|
8
|
+
import { boolInitialValue, type useBoolField } from './useBoolField.js';
|
|
9
|
+
import { manyOfInitialValue, type ManyOfOwnOptions, type useManyOfField } from './useManyOfField.js';
|
|
10
|
+
import { mixedBoolInitialValue, type MixedBool, type useMixedBoolField } from './useMixedBoolField.js';
|
|
11
|
+
import { numericInitialValue, type NumericOwnOptions, type useNumericField } from './useNumericField.js';
|
|
12
|
+
import { oneOfInitialValue, type OneOfOwnOptions, type useOneOfField } from './useOneOfField.js';
|
|
13
|
+
import { rangeInitialValue, type NumberRange, type RangeOwnOptions, type useRangeField } from './useRangeField.js';
|
|
14
|
+
import { textInitialValue, type TextOwnOptions, type useTextField } from './useTextField.js';
|
|
15
|
+
import { treeInitialValue, type TreeOwnOptions, type useTreeField } from './useTreeField.js';
|
|
16
|
+
import { booleanType, numberType, rangeType, stringSetType, stringType, type ValueType } from './valueTypes.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A definition of kind `K`: the kind's hook options in their inside form, with `kind` required.
|
|
20
|
+
* It has no `value`, `onChange` or `commit`: where the value lives is chosen by whoever runs the
|
|
21
|
+
* field, and a store holds the definition's initial value as its default.
|
|
22
|
+
*/
|
|
23
|
+
type Definition<K extends FieldKind, V, Own, Ctx> =
|
|
24
|
+
{ kind: K } & FieldBaseOptions<V, Ctx> & { initialValue?: ValueOrFn<V, Ctx> } & Own;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A field as plain data (a module's filters, a dialog's options), tagged by its kind. Narrowed
|
|
28
|
+
* by `kind`, a definition is its kind's hook options as they are: `useOneOfField(definition)`.
|
|
29
|
+
* Its functions receive the field context `Ctx` (see FieldContextProvider).
|
|
30
|
+
*/
|
|
31
|
+
export type FieldDefinition<Ctx = any> =
|
|
32
|
+
| Definition<'boolean', boolean, {}, Ctx>
|
|
33
|
+
| Definition<'mixed-boolean', MixedBool, {}, Ctx>
|
|
34
|
+
| Definition<'oneOf', string, OneOfOwnOptions<string, Ctx>, Ctx>
|
|
35
|
+
| Definition<'manyOf', string[], ManyOfOwnOptions<string, Ctx>, Ctx>
|
|
36
|
+
| Definition<'numeric', number, NumericOwnOptions<Ctx>, Ctx>
|
|
37
|
+
| Definition<'range', NumberRange, RangeOwnOptions<Ctx>, Ctx>
|
|
38
|
+
| Definition<'tree', string[], TreeOwnOptions<string, Ctx>, Ctx>
|
|
39
|
+
| Definition<'text', string, TextOwnOptions, Ctx>;
|
|
40
|
+
|
|
41
|
+
/** Compiles only when `A` is assignable to `B`. */
|
|
42
|
+
type Fits<A extends B, B> = A;
|
|
43
|
+
type DefinitionOf<K extends FieldKind> = Extract<FieldDefinition, { kind: K }>;
|
|
44
|
+
type HookOptions<H extends (...args: any[]) => unknown> = Parameters<H>[0];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A compile-time check, the gate for "a definition is its kind's hook options as they are": if a
|
|
48
|
+
* hook's options and the definition drift apart, `tsc` fails here.
|
|
49
|
+
*/
|
|
50
|
+
type DefinitionsFitTheirHooks = [
|
|
51
|
+
Fits<DefinitionOf<'boolean'>, HookOptions<typeof useBoolField>>,
|
|
52
|
+
Fits<DefinitionOf<'mixed-boolean'>, HookOptions<typeof useMixedBoolField>>,
|
|
53
|
+
Fits<DefinitionOf<'oneOf'>, HookOptions<typeof useOneOfField>>,
|
|
54
|
+
Fits<DefinitionOf<'manyOf'>, HookOptions<typeof useManyOfField>>,
|
|
55
|
+
Fits<DefinitionOf<'numeric'>, HookOptions<typeof useNumericField>>,
|
|
56
|
+
Fits<DefinitionOf<'range'>, HookOptions<typeof useRangeField>>,
|
|
57
|
+
Fits<DefinitionOf<'tree'>, HookOptions<typeof useTreeField>>,
|
|
58
|
+
Fits<DefinitionOf<'text'>, HookOptions<typeof useTextField>>,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const NOT_STORABLE = 'a mixed-boolean field is an aggregate of other data and cannot be stored';
|
|
62
|
+
|
|
63
|
+
/** A definition whose kind is none of FieldKind's (plain data can carry anything): fail loudly rather than give nothing. */
|
|
64
|
+
function unknownKind(definition: never): never {
|
|
65
|
+
const { id, kind } = definition as { id: string; kind: unknown };
|
|
66
|
+
throw new Error(`Field '${id}': unknown kind '${String(kind)}'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The choices of a one-of or many-of definition, resolved as the hooks resolve them. */
|
|
70
|
+
function choicesOf(definition: { id: string; choices: ValueOrFn<OneOfChoice[]> }, ctx: FieldCtx): OneOfChoiceControls[] {
|
|
71
|
+
const { id, choices } = definition;
|
|
72
|
+
return resolveIn<OneOfChoice[]>(choices, [], ctx, `Field '${id}': option 'choices'`).map((c) => resolveChoice(id, c));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every node's value, at any depth. */
|
|
76
|
+
function nodeValues(nodes: TreeNode[], into = new Set<string>()): Set<string> {
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
into.add(node.value);
|
|
79
|
+
if (node.children) nodeValues(node.children, into);
|
|
80
|
+
}
|
|
81
|
+
return into;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A tree definition's nodes, resolved as useTreeField resolves them. */
|
|
85
|
+
function nodesOf(definition: { id: string; nodes: ValueOrFn<TreeNode[]> }, ctx: FieldCtx): TreeNode[] {
|
|
86
|
+
return resolveIn<TreeNode[]>(definition.nodes, [], ctx, `Field '${definition.id}': option 'nodes'`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A definition's default: `initialValue` resolved with the field context, else its kind's
|
|
91
|
+
* implicit initial value (false; the first enabled choice; the empty list; `min`, or 0, clamped
|
|
92
|
+
* into the bounds; `[min, max]`; the empty string). A tree's comes normalized under its
|
|
93
|
+
* selection, as the hook shows it, so a value equal to what the field shows by default is equal
|
|
94
|
+
* to the default too. It is what a store falls back to, what a reset restores, and what "is it
|
|
95
|
+
* set?" compares against; the kind's hook starts at the same value.
|
|
96
|
+
*/
|
|
97
|
+
export function initialValueOf(definition: FieldDefinition, ctx: FieldCtx): unknown {
|
|
98
|
+
const { id } = definition;
|
|
99
|
+
switch (definition.kind) {
|
|
100
|
+
case 'boolean': return boolInitialValue(definition, ctx, id);
|
|
101
|
+
case 'mixed-boolean': return mixedBoolInitialValue(definition, ctx, id);
|
|
102
|
+
case 'oneOf': return oneOfInitialValue(definition, ctx, id, choicesOf(definition, ctx));
|
|
103
|
+
case 'manyOf': return manyOfInitialValue(definition, ctx, id);
|
|
104
|
+
case 'numeric': return numericInitialValue(definition, ctx, id);
|
|
105
|
+
case 'range': return rangeInitialValue(definition, ctx, id);
|
|
106
|
+
case 'tree':
|
|
107
|
+
return normalizeTreeValue(nodesOf(definition, ctx), treeInitialValue(definition, ctx, id), definition.selection ?? 'topmost');
|
|
108
|
+
case 'text': return textInitialValue(definition, ctx, id);
|
|
109
|
+
default: return unknownKind(definition);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How a kind's value is kept in a store. A mixed-boolean field shows an aggregate of other data, so it has none and throws. */
|
|
114
|
+
export function valueTypeOf(kind: FieldKind): ValueType<any> {
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case 'boolean': return booleanType;
|
|
117
|
+
case 'oneOf':
|
|
118
|
+
case 'text': return stringType;
|
|
119
|
+
case 'manyOf':
|
|
120
|
+
case 'tree': return stringSetType;
|
|
121
|
+
case 'numeric': return numberType;
|
|
122
|
+
case 'range': return rangeType;
|
|
123
|
+
case 'mixed-boolean': throw new Error(NOT_STORABLE);
|
|
124
|
+
default: throw new Error(`unknown field kind '${String(kind as unknown)}'`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function warnDropped(id: string, among: 'choices' | 'nodes', dropped: string[], warn: (message: string) => void): void {
|
|
129
|
+
if (dropped.length === 0) return;
|
|
130
|
+
const listed = [...new Set(dropped)].map((v) => `'${v}'`).join(', ');
|
|
131
|
+
warn(`Field '${id}': dropping stored values that are not among its ${among}: ${listed}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A stored value (already parsed by the kind's value type) made fit for the definition as it is
|
|
136
|
+
* now: choices, nodes and bounds follow the data, and an old URL may not. A one-of value that is
|
|
137
|
+
* not a choice becomes the initial value; many-of and tree values that are not choices or nodes
|
|
138
|
+
* are dropped; numbers and ranges are clamped into the bounds. Each such change warns, naming
|
|
139
|
+
* the field: the value came from outside, so it is not an error. Reordering is silent: lists
|
|
140
|
+
* come back in choice order or normalized under the tree's selection (a store keeps them
|
|
141
|
+
* sorted, so a warning would come on every read), and a range's ends in order. The warnings go
|
|
142
|
+
* to `warn`, console.warn unless a caller that only summarizes (useStoredFields) silences them.
|
|
143
|
+
*/
|
|
144
|
+
export function sanitizeStored(
|
|
145
|
+
definition: FieldDefinition,
|
|
146
|
+
value: unknown,
|
|
147
|
+
ctx: FieldCtx,
|
|
148
|
+
warn: (message: string) => void = (message) => console.warn(message),
|
|
149
|
+
): unknown {
|
|
150
|
+
const { id } = definition;
|
|
151
|
+
switch (definition.kind) {
|
|
152
|
+
case 'boolean':
|
|
153
|
+
case 'text':
|
|
154
|
+
return value;
|
|
155
|
+
case 'mixed-boolean':
|
|
156
|
+
throw new Error(`Field '${id}': ${NOT_STORABLE}`);
|
|
157
|
+
case 'oneOf': {
|
|
158
|
+
const choices = choicesOf(definition, ctx);
|
|
159
|
+
if (choices.some((c) => c.value === value)) return value;
|
|
160
|
+
const fallback = oneOfInitialValue(definition, ctx, id, choices);
|
|
161
|
+
if (fallback !== value) warn(`Field '${id}': stored value '${value}' is not one of its choices, using '${fallback}'`);
|
|
162
|
+
return fallback;
|
|
163
|
+
}
|
|
164
|
+
case 'manyOf': {
|
|
165
|
+
const stored = value as string[];
|
|
166
|
+
const known = choicesOf(definition, ctx).map((c) => c.value);
|
|
167
|
+
warnDropped(id, 'choices', stored.filter((v) => !known.includes(v)), warn);
|
|
168
|
+
return known.filter((v) => stored.includes(v));
|
|
169
|
+
}
|
|
170
|
+
case 'tree': {
|
|
171
|
+
const stored = value as string[];
|
|
172
|
+
const nodes = nodesOf(definition, ctx);
|
|
173
|
+
const known = nodeValues(nodes);
|
|
174
|
+
warnDropped(id, 'nodes', stored.filter((v) => !known.has(v)), warn);
|
|
175
|
+
return normalizeTreeValue(nodes, stored.filter((v) => known.has(v)), definition.selection ?? 'topmost');
|
|
176
|
+
}
|
|
177
|
+
case 'numeric': {
|
|
178
|
+
const stored = value as number;
|
|
179
|
+
const { min, max } = resolveNumeric(id, definition, ctx);
|
|
180
|
+
const clamped = clamp(stored, min, max);
|
|
181
|
+
if (clamped !== stored) warn(`Field '${id}': stored value ${stored} is outside its bounds, using ${clamped}`);
|
|
182
|
+
return clamped;
|
|
183
|
+
}
|
|
184
|
+
case 'range': {
|
|
185
|
+
const stored = value as NumberRange;
|
|
186
|
+
const { min, max } = resolveNumeric(id, definition, ctx);
|
|
187
|
+
const from = clamp(stored[0], min, max);
|
|
188
|
+
const to = clamp(stored[1], min, max);
|
|
189
|
+
const sanitized: NumberRange = [Math.min(from, to), Math.max(from, to)];
|
|
190
|
+
if (from !== stored[0] || to !== stored[1]) {
|
|
191
|
+
warn(`Field '${id}': stored range ${rangeType.format(stored)} is outside its bounds, using ${rangeType.format(sanitized)}`);
|
|
192
|
+
}
|
|
193
|
+
return sanitized;
|
|
194
|
+
}
|
|
195
|
+
default: return unknownKind(definition);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ArgStore } from './argStore.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An ArgStore over `window.localStorage` (Praeceptor's print options), items kept under
|
|
5
|
+
* `${prefix}.${key}`. Guarded for SSR/Node (`typeof window === 'undefined'`), so creating one
|
|
6
|
+
* outside a browser does not crash: reads then give `undefined` and writes are no-ops. A browser
|
|
7
|
+
* may block storage (a sandboxed frame, blocked site data) or refuse a write (quota), and then
|
|
8
|
+
* reaching or using `localStorage` throws: a read that fails reads as absent and a write that
|
|
9
|
+
* fails is skipped, with one warning per store rather than one per call.
|
|
10
|
+
*
|
|
11
|
+
* A write notifies this store's own subscribers; a `storage` event (another tab changed a key
|
|
12
|
+
* under the prefix) notifies them too. The store listens for that event on `window` only while
|
|
13
|
+
* it has subscribers, so a store that is dropped leaves no listener behind; still, create one
|
|
14
|
+
* once (outside render, or in `useMemo`) rather than on every render.
|
|
15
|
+
*/
|
|
16
|
+
export function localStorageStore(prefix: string): ArgStore {
|
|
17
|
+
const hasWindow = typeof window !== 'undefined';
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
const storageKey = (key: string) => `${prefix}.${key}`;
|
|
20
|
+
// `null` means "another tab cleared the whole storage" (localStorage.clear()): every key under
|
|
21
|
+
// the prefix may have changed, so this counts as ours too.
|
|
22
|
+
const isOwnStorageKey = (key: string | null) => key === null || key.startsWith(`${prefix}.`);
|
|
23
|
+
const onStorage = (event: StorageEvent) => {
|
|
24
|
+
if (isOwnStorageKey(event.key)) listeners.forEach((listener) => listener());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let warned = false;
|
|
28
|
+
/** Runs a use of localStorage; if it throws, warns (the first time for this store) and gives `undefined`. */
|
|
29
|
+
const guarded = <T>(what: string, use: (storage: Storage) => T): T | undefined => {
|
|
30
|
+
try {
|
|
31
|
+
return use(window.localStorage);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!warned) {
|
|
34
|
+
warned = true;
|
|
35
|
+
console.warn(
|
|
36
|
+
`localStorageStore('${prefix}'): cannot ${what} localStorage (${String(error)}); `
|
|
37
|
+
+ 'failing reads read as absent and failing writes are skipped, without further warnings',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
read(key) {
|
|
46
|
+
if (!hasWindow) return undefined;
|
|
47
|
+
return guarded('read', (storage) => storage.getItem(storageKey(key))) ?? undefined;
|
|
48
|
+
},
|
|
49
|
+
write(changes, _method) {
|
|
50
|
+
if (!hasWindow) return;
|
|
51
|
+
for (const { key, raw } of changes) {
|
|
52
|
+
guarded('write', (storage) => {
|
|
53
|
+
if (raw === undefined) storage.removeItem(storageKey(key));
|
|
54
|
+
else storage.setItem(storageKey(key), raw);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
listeners.forEach((listener) => listener());
|
|
58
|
+
},
|
|
59
|
+
subscribe(listener) {
|
|
60
|
+
if (hasWindow && listeners.size === 0) window.addEventListener('storage', onStorage);
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
if (!listeners.delete(listener)) return;
|
|
64
|
+
if (hasWindow && listeners.size === 0) window.removeEventListener('storage', onStorage);
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key for accent- and case-insensitive comparison of labels (the tree hook's search,
|
|
3
|
+
* Taskmaster's): decompose, strip the combining marks, lower-case. No quaesitor dependency.
|
|
4
|
+
*/
|
|
5
|
+
export function normalizeText(text: string): string {
|
|
6
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
7
|
+
}
|