@zeyos/zx 2.3.0 → 3.0.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/CHANGELOG.md +87 -0
- package/DESIGN-SYSTEM.md +136 -0
- package/MIGRATION.md +155 -4
- package/README.md +24 -13
- package/dist/types/compat/map/data.d.ts +3 -2
- package/dist/types/components/account-menu/account-menu.d.ts +144 -0
- package/dist/types/components/app-rail/app-rail.d.ts +250 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
- package/dist/types/components/avatar/avatar.d.ts +100 -0
- package/dist/types/components/chart/chart.d.ts +321 -0
- package/dist/types/components/date-picker/month-picker.d.ts +6 -0
- package/dist/types/components/dialog/dialog.d.ts +9 -0
- package/dist/types/components/dock/dock.d.ts +495 -0
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/field/field.d.ts +14 -0
- package/dist/types/components/field-upload/field-upload.d.ts +37 -4
- package/dist/types/components/form/form.d.ts +25 -3
- package/dist/types/components/grid/grid.d.ts +172 -0
- package/dist/types/components/launcher/launcher.d.ts +300 -0
- package/dist/types/components/layout/layout.d.ts +4 -4
- package/dist/types/components/menu-button/menu-button.d.ts +10 -2
- package/dist/types/components/modal/modal.d.ts +25 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
- package/dist/types/components/questionnaire/items.d.ts +327 -0
- package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
- package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
- package/dist/types/components/sheet/sheet.d.ts +294 -0
- package/dist/types/components/split-view/split-view.d.ts +2 -21
- package/dist/types/components/table/table.d.ts +219 -12
- package/dist/types/components/tooltip/tooltip.d.ts +2 -2
- package/dist/types/components/value-list/value-list.d.ts +24 -3
- package/dist/types/core/dom.d.ts +114 -0
- package/dist/types/core/drag-axis.d.ts +111 -0
- package/dist/types/core/position.d.ts +4 -2
- package/dist/types/index.d.ts +12 -2
- package/dist/zx-compat.esm.js +641 -277
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +7609 -2236
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +546 -87
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +1452 -101
- package/dist/zx.esm.js +7952 -2592
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +7952 -2592
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +2276 -337
- package/docs/llms.md +426 -30
- package/docs/llms.txt +21 -9
- package/package.json +4 -2
- package/src/compat/globals.js +3 -1
- package/src/compat/map/data.js +31 -9
- package/src/components/account-menu/account-menu.css +70 -0
- package/src/components/account-menu/account-menu.js +208 -0
- package/src/components/app-rail/app-rail.css +107 -0
- package/src/components/app-rail/app-rail.js +668 -0
- package/src/components/app-sidebar/app-sidebar.css +68 -0
- package/src/components/app-sidebar/app-sidebar.js +388 -0
- package/src/components/avatar/avatar.css +61 -0
- package/src/components/avatar/avatar.js +222 -0
- package/src/components/button/button.css +0 -1
- package/src/components/chart/chart.css +61 -0
- package/src/components/chart/chart.js +518 -0
- package/src/components/date-picker/month-picker.js +9 -0
- package/src/components/dialog/dialog.js +11 -4
- package/src/components/dock/dock.css +228 -0
- package/src/components/dock/dock.js +977 -0
- package/src/components/dropdown/dropdown.js +1 -1
- package/src/components/field/field.js +2 -0
- package/src/components/field-upload/field-upload.js +9 -4
- package/src/components/form/form.js +6 -3
- package/src/components/grid/grid.css +6 -0
- package/src/components/grid/grid.js +213 -0
- package/src/components/launcher/launcher.css +100 -0
- package/src/components/launcher/launcher.js +557 -0
- package/src/components/layout/layout.js +2 -2
- package/src/components/menu-button/menu-button.js +27 -2
- package/src/components/modal/modal.css +5 -1
- package/src/components/modal/modal.js +37 -2
- package/src/components/multi-value-editor/multi-value-editor.js +2 -1
- package/src/components/questionnaire/items.js +370 -0
- package/src/components/questionnaire/questionnaire.css +387 -0
- package/src/components/questionnaire/questionnaire.js +1033 -0
- package/src/components/select/select.css +0 -1
- package/src/components/sheet/sheet-stack.js +304 -0
- package/src/components/sheet/sheet.css +351 -0
- package/src/components/sheet/sheet.js +551 -0
- package/src/components/split-view/split-view.js +76 -161
- package/src/components/table/table.css +43 -0
- package/src/components/table/table.js +523 -45
- package/src/components/tooltip/tooltip.js +5 -2
- package/src/components/value-list/value-list.js +6 -3
- package/src/core/dom.js +302 -0
- package/src/core/drag-axis.js +203 -0
- package/src/core/position.js +68 -22
- package/src/index.js +19 -2
- package/styles/tokens/dark.css +2 -3
- package/styles/tokens/global.css +5 -5
- package/styles/tokens/semantic.css +5 -3
- package/styles/tokens/themes.css +1 -1
- package/styles/zx.css +10 -1
- package/dist/types/components/permission/permission.d.ts +0 -96
- package/src/components/permission/permission.css +0 -47
- package/src/components/permission/permission.js +0 -201
|
@@ -52,6 +52,7 @@ export class Modal extends Component {
|
|
|
52
52
|
if (!this.options.closable || cancelEvent.defaultPrevented) event.preventDefault();
|
|
53
53
|
});
|
|
54
54
|
this.listen(this.el, 'close', () => {
|
|
55
|
+
if (!this._isRealClose()) return;
|
|
55
56
|
this.el.dataset.state = 'closed';
|
|
56
57
|
delete this.el.dataset.zxOverlayOrder;
|
|
57
58
|
const result = this.#hasPendingResult ? this.#pendingResult : (this.el.returnValue || undefined);
|
|
@@ -66,6 +67,40 @@ export class Modal extends Component {
|
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Element the owned dialog is appended to. Overridden by subclasses that live inside a host
|
|
72
|
+
* rather than at the document level.
|
|
73
|
+
*
|
|
74
|
+
* Called from `render()`, which the base constructor runs before any subclass field
|
|
75
|
+
* initializers, so an override may read `this.options` but must not touch instance state.
|
|
76
|
+
* @returns {Element}
|
|
77
|
+
*/
|
|
78
|
+
mountTarget() {
|
|
79
|
+
return document.body;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Puts the dialog on screen. Overridden by subclasses that open non-modally.
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
_show() {
|
|
87
|
+
this.el.showModal();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether an incoming `close` event is a real dismissal.
|
|
92
|
+
*
|
|
93
|
+
* A dialog cannot move between the top layer and the page flow while it is open, so a subclass
|
|
94
|
+
* that re-hosts itself has to close and immediately reopen. `close` is dispatched in a queued
|
|
95
|
+
* task, not synchronously, so that stale event arrives once the dialog is already open again —
|
|
96
|
+
* late enough to clobber `data-state`, fire a `close` nobody asked for, and trip
|
|
97
|
+
* `destroyOnClose`. Returning false from an override drops it at the source instead.
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
_isRealClose() {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
/**
|
|
70
105
|
* Creates the owned native dialog.
|
|
71
106
|
* @returns {HTMLDialogElement}
|
|
@@ -75,7 +110,7 @@ export class Modal extends Component {
|
|
|
75
110
|
class: 'zx-modal'
|
|
76
111
|
}, h('div', { class: 'zx-modal__content', ref: 'content' })));
|
|
77
112
|
dialog.dataset.state = 'closed';
|
|
78
|
-
|
|
113
|
+
this.mountTarget().append(dialog);
|
|
79
114
|
return dialog;
|
|
80
115
|
}
|
|
81
116
|
|
|
@@ -89,7 +124,7 @@ export class Modal extends Component {
|
|
|
89
124
|
this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
|
|
90
125
|
this.el.returnValue = '';
|
|
91
126
|
try {
|
|
92
|
-
this.
|
|
127
|
+
this._show();
|
|
93
128
|
} catch (error) {
|
|
94
129
|
delete this.el.dataset.zxOverlayOrder;
|
|
95
130
|
throw error;
|
|
@@ -9,6 +9,7 @@ import { icon } from '../../core/icons.js';
|
|
|
9
9
|
* @property {string} [addLabel='Add value'] Add-row button text.
|
|
10
10
|
* @property {'both'|'buttons'|'drag'|'none'} [reorder='both'] Reordering affordances each row gets:
|
|
11
11
|
* a drag handle, the up/down buttons, both, or neither.
|
|
12
|
+
* @property {(event: CustomEvent<{values: string[]}>) => void} [onchange] Values change listener.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/** Affordances each `reorder` setting turns on. */
|
|
@@ -383,5 +384,5 @@ function normalizeOptions(options) {
|
|
|
383
384
|
/**
|
|
384
385
|
* Ordered values changed event.
|
|
385
386
|
* @event MultiValueEditor#change
|
|
386
|
-
* @type {CustomEvent<{values: string[]}>
|
|
387
|
+
* @type {CustomEvent<{values: string[]}>}
|
|
387
388
|
*/
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Pure questionnaire logic: normalization, visibility, branch resolution, validation, and
|
|
4
|
+
* progress. Nothing here touches the DOM, so the rules a branching questionnaire lives or dies by
|
|
5
|
+
* are testable in Node.
|
|
6
|
+
* @module questionnaire/items
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} QuestionnaireChoice
|
|
11
|
+
* @property {string|number} value Answer value, unique within its item.
|
|
12
|
+
* @property {string} label Visible answer text.
|
|
13
|
+
* @property {string} [description=''] Supporting line under the label.
|
|
14
|
+
* @property {string|null} [icon=null] Icon name drawn before the label.
|
|
15
|
+
* @property {string} [key] Shortcut key. Assigned `1`–`9` then `a`–`z` when omitted.
|
|
16
|
+
* @property {boolean} [disabled=false] Whether the choice can be picked.
|
|
17
|
+
* @property {boolean} [exclusive=false] Whether picking it clears every other choice of a
|
|
18
|
+
* `multiple` item — the "None of the above" behaviour.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} QuestionnaireInput
|
|
23
|
+
* @property {string} [label='Other'] Accessible name of the freeform control.
|
|
24
|
+
* @property {string} [placeholder=''] Placeholder text. Never a substitute for the label.
|
|
25
|
+
* @property {boolean} [multiline=false] Whether to render a `textarea`.
|
|
26
|
+
* @property {number} [maxLength=0] Native maximum length, 0 for none.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} QuestionnaireItem
|
|
31
|
+
* @property {string} name Unique key. Doubles as the native input name.
|
|
32
|
+
* @property {string} prompt The question. Rendered as the item's `<legend>`.
|
|
33
|
+
* @property {string} [description=''] Supporting line, associated via `aria-describedby`.
|
|
34
|
+
* @property {string} [section=''] Grouping label shown in the counter and the review list.
|
|
35
|
+
* @property {boolean} [required=false] Whether an answer is needed to advance.
|
|
36
|
+
* @property {boolean} [multiple=false] Whether several choices can be picked at once.
|
|
37
|
+
* @property {boolean} [skippable=false] Whether the Skip action is offered.
|
|
38
|
+
* @property {QuestionnaireChoice[]} [choices=[]] Fixed answers.
|
|
39
|
+
* @property {QuestionnaireInput|null} [input=null] Freeform answer offered beside the choices.
|
|
40
|
+
* @property {Object|null} [field=null] Any registered Zx `Field` type used as the answer control.
|
|
41
|
+
* @property {((answers: Record<string, unknown>) => boolean)|null} [when=null] Visibility predicate.
|
|
42
|
+
* @property {string|((answer: unknown, answers: Record<string, unknown>) => string|null)|null} [next=null]
|
|
43
|
+
* Branch target: an item name, or a function returning one.
|
|
44
|
+
* @property {((answer: unknown, answers: Record<string, unknown>) => string|null|Promise<string|null>)|null} [validate=null]
|
|
45
|
+
* Extra validation returning an error message, or null when the answer is acceptable.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} NormalizedItem
|
|
50
|
+
* @property {string} name Unique key.
|
|
51
|
+
* @property {string} prompt The question.
|
|
52
|
+
* @property {string|null} description Supporting line, or null.
|
|
53
|
+
* @property {string} section Grouping label, empty when ungrouped.
|
|
54
|
+
* @property {boolean} required Whether an answer is needed.
|
|
55
|
+
* @property {boolean} multiple Whether several choices can be picked.
|
|
56
|
+
* @property {boolean} skippable Whether Skip is offered.
|
|
57
|
+
* @property {NormalizedChoice[]} choices Fixed answers with shortcut keys assigned.
|
|
58
|
+
* @property {Required<QuestionnaireInput>|null} input Freeform answer, or null.
|
|
59
|
+
* @property {Object|null} field Field options, or null.
|
|
60
|
+
* @property {((answers: Record<string, unknown>) => boolean)|null} when Visibility predicate.
|
|
61
|
+
* @property {string|((answer: unknown, answers: Record<string, unknown>) => string|null)|null} next Branch target.
|
|
62
|
+
* @property {((answer: unknown, answers: Record<string, unknown>) => string|null|Promise<string|null>)|null} validate Extra validation.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} NormalizedChoice
|
|
67
|
+
* @property {string|number} value Answer value.
|
|
68
|
+
* @property {string} label Visible answer text.
|
|
69
|
+
* @property {string|null} description Supporting line, or null.
|
|
70
|
+
* @property {string|null} icon Icon name, or null.
|
|
71
|
+
* @property {string} key Shortcut key, a single lowercase character, or empty when the item has
|
|
72
|
+
* more choices than there are keys to hand out.
|
|
73
|
+
* @property {boolean} disabled Whether the choice can be picked.
|
|
74
|
+
* @property {boolean} exclusive Whether picking it clears the other choices.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {Object} ResolvedFlow
|
|
79
|
+
* @property {Record<string, unknown>} answers Answers of visible items only, in declaration order.
|
|
80
|
+
* @property {NormalizedItem[]} visible Items whose conditions currently pass.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {Object} QuestionnaireProgress
|
|
85
|
+
* @property {number} index Zero-based position of the active item along the path walked so far.
|
|
86
|
+
* @property {number} answered How many items on the path carry an answer.
|
|
87
|
+
* @property {number} total Path length plus the items still reachable. An estimate whenever a
|
|
88
|
+
* function `next` is involved, since a dynamic jump is unknowable before the answer exists.
|
|
89
|
+
* @property {number} percent Completion share of `total`, 0–100.
|
|
90
|
+
* @property {string} section Section of the active item, empty when ungrouped.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/** Shortcut keys handed out in order: the digits first, then the letters. */
|
|
94
|
+
const SHORTCUT_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes and validates a list of item definitions. Never mutates its input.
|
|
98
|
+
* @param {QuestionnaireItem[]} list Item definitions.
|
|
99
|
+
* @returns {NormalizedItem[]}
|
|
100
|
+
* @throws {TypeError} When the list, an item, or a name is malformed, or a name repeats.
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeItems(list) {
|
|
103
|
+
if (!Array.isArray(list)) throw new TypeError('Questionnaire items must be an array');
|
|
104
|
+
/** @type {Set<string>} */
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
return list.map((item) => {
|
|
107
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
108
|
+
throw new TypeError('Each questionnaire item must be an object');
|
|
109
|
+
}
|
|
110
|
+
if (typeof item.name !== 'string' || item.name.trim() === '') {
|
|
111
|
+
throw new TypeError('Each questionnaire item needs a non-empty string name');
|
|
112
|
+
}
|
|
113
|
+
const name = item.name;
|
|
114
|
+
if (seen.has(name)) throw new TypeError(`Item already exists: ${name}`);
|
|
115
|
+
seen.add(name);
|
|
116
|
+
|
|
117
|
+
const choices = normalizeChoices(item.choices ?? [], name);
|
|
118
|
+
const field = item.field && typeof item.field === 'object' ? { ...item.field } : null;
|
|
119
|
+
const input = normalizeInput(item.input);
|
|
120
|
+
if (choices.length === 0 && !field && !input) {
|
|
121
|
+
throw new TypeError(`Item "${name}" needs choices, an input, or a field`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
name,
|
|
125
|
+
prompt: String(item.prompt ?? name),
|
|
126
|
+
description: item.description == null || item.description === '' ? null : String(item.description),
|
|
127
|
+
section: item.section == null ? '' : String(item.section),
|
|
128
|
+
required: Boolean(item.required),
|
|
129
|
+
multiple: Boolean(item.multiple),
|
|
130
|
+
skippable: Boolean(item.skippable),
|
|
131
|
+
choices,
|
|
132
|
+
input,
|
|
133
|
+
field,
|
|
134
|
+
when: typeof item.when === 'function' ? item.when : null,
|
|
135
|
+
next: typeof item.next === 'function' || (typeof item.next === 'string' && item.next) ? item.next : null,
|
|
136
|
+
validate: typeof item.validate === 'function' ? item.validate : null
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalizes an item's choices and hands out the shortcut keys. Explicit keys are reserved before
|
|
143
|
+
* any are assigned, so adding one to the third choice never renumbers the others.
|
|
144
|
+
* @param {QuestionnaireChoice[]} list Choice definitions.
|
|
145
|
+
* @param {string} [itemName=''] Owning item name, used in error messages.
|
|
146
|
+
* @returns {NormalizedChoice[]}
|
|
147
|
+
* @throws {TypeError} When the list or a choice is malformed, or a value repeats.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeChoices(list, itemName = '') {
|
|
150
|
+
if (!Array.isArray(list)) throw new TypeError('Questionnaire choices must be an array');
|
|
151
|
+
/** @type {Set<string>} */
|
|
152
|
+
const values = new Set();
|
|
153
|
+
const prepared = list.map((choice) => {
|
|
154
|
+
if (!choice || typeof choice !== 'object' || Array.isArray(choice)) {
|
|
155
|
+
throw new TypeError('Each questionnaire choice must be an object');
|
|
156
|
+
}
|
|
157
|
+
if (choice.value == null || choice.value === '') {
|
|
158
|
+
throw new TypeError(`Each choice of "${itemName}" needs a value`);
|
|
159
|
+
}
|
|
160
|
+
const value = typeof choice.value === 'number' ? choice.value : String(choice.value);
|
|
161
|
+
const key = String(value);
|
|
162
|
+
if (values.has(key)) throw new TypeError(`Choice already exists in "${itemName}": ${key}`);
|
|
163
|
+
values.add(key);
|
|
164
|
+
return {
|
|
165
|
+
value,
|
|
166
|
+
label: String(choice.label ?? value),
|
|
167
|
+
description: choice.description == null || choice.description === '' ? null : String(choice.description),
|
|
168
|
+
icon: choice.icon == null || choice.icon === '' ? null : String(choice.icon),
|
|
169
|
+
key: typeof choice.key === 'string' && choice.key ? choice.key.slice(0, 1).toLowerCase() : '',
|
|
170
|
+
disabled: Boolean(choice.disabled),
|
|
171
|
+
exclusive: Boolean(choice.exclusive)
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const taken = new Set(prepared.map((choice) => choice.key).filter(Boolean));
|
|
176
|
+
let cursor = 0;
|
|
177
|
+
for (const choice of prepared) {
|
|
178
|
+
if (choice.key) continue;
|
|
179
|
+
while (cursor < SHORTCUT_KEYS.length && taken.has(SHORTCUT_KEYS[cursor])) cursor += 1;
|
|
180
|
+
// More choices than keys is legal; the surplus simply has no shortcut.
|
|
181
|
+
if (cursor >= SHORTCUT_KEYS.length) break;
|
|
182
|
+
choice.key = SHORTCUT_KEYS[cursor];
|
|
183
|
+
taken.add(choice.key);
|
|
184
|
+
cursor += 1;
|
|
185
|
+
}
|
|
186
|
+
return prepared;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reports whether an item's `when` predicate passes for a given set of answers. An item without
|
|
191
|
+
* one is always visible, and a predicate that throws hides nothing — a broken condition must not
|
|
192
|
+
* strand the reader mid-questionnaire.
|
|
193
|
+
* @param {NormalizedItem} item Item to test.
|
|
194
|
+
* @param {Record<string, unknown>} answers Answers visible to the predicate.
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
export function isVisible(item, answers) {
|
|
198
|
+
if (typeof item.when !== 'function') return true;
|
|
199
|
+
try {
|
|
200
|
+
return item.when(answers) !== false;
|
|
201
|
+
} catch {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves visibility and the effective answers in one forward pass.
|
|
208
|
+
*
|
|
209
|
+
* Conditions have to cascade: if "VAT id" is conditioned out because the customer turned out to be
|
|
210
|
+
* a private buyer, a question conditioned on the VAT id must disappear with it. Walking the items
|
|
211
|
+
* in declaration order and letting each predicate see only the answers of *visible* items before
|
|
212
|
+
* it gives exactly that, and terminates — a predicate can never depend on its own visibility.
|
|
213
|
+
*
|
|
214
|
+
* The raw store keeps answers to conditioned-out items, so walking back into a branch restores
|
|
215
|
+
* what was typed there; they are simply not part of the resolved answers.
|
|
216
|
+
* @param {NormalizedItem[]} items All items.
|
|
217
|
+
* @param {Record<string, unknown>} raw Every answer given, including hidden ones.
|
|
218
|
+
* @returns {ResolvedFlow}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveFlow(items, raw) {
|
|
221
|
+
/** @type {Record<string, unknown>} */
|
|
222
|
+
const answers = {};
|
|
223
|
+
/** @type {NormalizedItem[]} */
|
|
224
|
+
const visible = [];
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
if (!isVisible(item, answers)) continue;
|
|
227
|
+
visible.push(item);
|
|
228
|
+
if (Object.prototype.hasOwnProperty.call(raw, item.name)) answers[item.name] = raw[item.name];
|
|
229
|
+
}
|
|
230
|
+
return { answers, visible };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns the items whose conditions currently pass, in declaration order.
|
|
235
|
+
* @param {NormalizedItem[]} items All items.
|
|
236
|
+
* @param {Record<string, unknown>} raw Every answer given.
|
|
237
|
+
* @returns {NormalizedItem[]}
|
|
238
|
+
*/
|
|
239
|
+
export function visibleItems(items, raw) {
|
|
240
|
+
return resolveFlow(items, raw).visible;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolves which item follows the given one.
|
|
245
|
+
*
|
|
246
|
+
* A `next` of `null` falls through to the next visible item in declaration order. A string, or a
|
|
247
|
+
* function returning a string, names a target: when that target is currently hidden the walk
|
|
248
|
+
* continues forward from it rather than stranding the reader on an invisible question.
|
|
249
|
+
* @param {NormalizedItem[]} items All items.
|
|
250
|
+
* @param {NormalizedItem} item Item being left.
|
|
251
|
+
* @param {Record<string, unknown>} raw Every answer given.
|
|
252
|
+
* @returns {string|null} Name of the next item, or null at the end of the questionnaire.
|
|
253
|
+
* @throws {RangeError} When `next` names an item that does not exist.
|
|
254
|
+
*/
|
|
255
|
+
export function resolveNext(items, item, raw) {
|
|
256
|
+
const { answers, visible } = resolveFlow(items, raw);
|
|
257
|
+
const shown = new Set(visible.map((candidate) => candidate.name));
|
|
258
|
+
|
|
259
|
+
let target = null;
|
|
260
|
+
if (typeof item.next === 'string') target = item.next;
|
|
261
|
+
else if (typeof item.next === 'function') {
|
|
262
|
+
const resolved = item.next(answers[item.name], answers);
|
|
263
|
+
target = resolved == null || resolved === '' ? null : String(resolved);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let from = items.indexOf(item);
|
|
267
|
+
if (target !== null) {
|
|
268
|
+
const index = items.findIndex((candidate) => candidate.name === target);
|
|
269
|
+
if (index < 0) throw new RangeError(`Unknown questionnaire item: ${target}`);
|
|
270
|
+
if (shown.has(target)) return target;
|
|
271
|
+
// The named target is conditioned out, so continue the walk from where it sits.
|
|
272
|
+
from = index;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (let index = from + 1; index < items.length; index += 1) {
|
|
276
|
+
if (shown.has(items[index].name)) return items[index].name;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Reports whether an answer counts as absent. An empty string, an empty array, null, and undefined
|
|
283
|
+
* are all "no answer"; `false` and `0` are answers.
|
|
284
|
+
* @param {unknown} value Answer value.
|
|
285
|
+
* @returns {boolean}
|
|
286
|
+
*/
|
|
287
|
+
export function isEmptyAnswer(value) {
|
|
288
|
+
if (value == null) return true;
|
|
289
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
290
|
+
if (Array.isArray(value)) return value.every((entry) => isEmptyAnswer(entry));
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Runs the synchronous part of validation: the required check. An item's own `validate` may be
|
|
296
|
+
* asynchronous and is awaited by the component instead.
|
|
297
|
+
* @param {NormalizedItem} item Item to check.
|
|
298
|
+
* @param {unknown} answer Current answer.
|
|
299
|
+
* @param {(key: string) => string} message Message resolver, called with a message key.
|
|
300
|
+
* @returns {string|null} Error message, or null when the answer passes.
|
|
301
|
+
*/
|
|
302
|
+
export function validateAnswer(item, answer, message) {
|
|
303
|
+
if (!item.required || !isEmptyAnswer(answer)) return null;
|
|
304
|
+
return message(item.choices.length > 0 ? 'questionnaire.required' : 'questionnaire.requiredInput');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Applies "None of the above" exclusivity to a multiple-choice selection.
|
|
309
|
+
*
|
|
310
|
+
* Picking an exclusive choice drops everything else; picking anything else drops the exclusive
|
|
311
|
+
* ones. The result is returned in choice order, so the answer never depends on click order.
|
|
312
|
+
* @param {NormalizedChoice[]} choices The item's choices.
|
|
313
|
+
* @param {Array<string|number>} selected Values currently selected.
|
|
314
|
+
* @param {string|number|null} [toggled=null] Value the reader just switched on.
|
|
315
|
+
* @returns {Array<string|number>}
|
|
316
|
+
*/
|
|
317
|
+
export function applyExclusive(choices, selected, toggled = null) {
|
|
318
|
+
const keep = new Set(selected.map((value) => String(value)));
|
|
319
|
+
const exclusive = new Set(choices.filter((choice) => choice.exclusive).map((choice) => String(choice.value)));
|
|
320
|
+
if (exclusive.size > 0 && toggled !== null && keep.has(String(toggled))) {
|
|
321
|
+
if (exclusive.has(String(toggled))) keep.forEach((value) => value !== String(toggled) && keep.delete(value));
|
|
322
|
+
else exclusive.forEach((value) => keep.delete(value));
|
|
323
|
+
}
|
|
324
|
+
return choices.filter((choice) => keep.has(String(choice.value))).map((choice) => choice.value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Computes progress along the path actually walked.
|
|
329
|
+
*
|
|
330
|
+
* The index is the position within `path`, not within `items`: once a branch has been taken, the
|
|
331
|
+
* declaration index says nothing about how far the reader has come. `total` adds the items still
|
|
332
|
+
* reachable under the current answers, which is exact for static flows and an estimate as soon as
|
|
333
|
+
* a function `next` can jump somewhere the walk cannot predict.
|
|
334
|
+
* @param {NormalizedItem[]} items All items.
|
|
335
|
+
* @param {Record<string, unknown>} raw Every answer given.
|
|
336
|
+
* @param {string[]} path Item names visited so far, active item last.
|
|
337
|
+
* @returns {QuestionnaireProgress}
|
|
338
|
+
*/
|
|
339
|
+
export function progressOf(items, raw, path) {
|
|
340
|
+
const { answers, visible } = resolveFlow(items, raw);
|
|
341
|
+
const shown = new Map(visible.map((item) => [item.name, item]));
|
|
342
|
+
const walked = path.filter((name) => shown.has(name));
|
|
343
|
+
const active = walked.length > 0 ? shown.get(walked[walked.length - 1]) : null;
|
|
344
|
+
const answered = walked.filter((name) => !isEmptyAnswer(answers[name])).length;
|
|
345
|
+
|
|
346
|
+
const seen = new Set(walked);
|
|
347
|
+
const from = active ? visible.indexOf(active) : -1;
|
|
348
|
+
const remaining = visible.slice(from + 1).filter((item) => !seen.has(item.name)).length;
|
|
349
|
+
|
|
350
|
+
const total = walked.length + remaining;
|
|
351
|
+
return {
|
|
352
|
+
index: Math.max(0, walked.length - 1),
|
|
353
|
+
answered,
|
|
354
|
+
total,
|
|
355
|
+
percent: total === 0 ? 0 : Math.round((answered / total) * 100),
|
|
356
|
+
section: active?.section ?? ''
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** @param {unknown} input @returns {Required<QuestionnaireInput>|null} */
|
|
361
|
+
function normalizeInput(input) {
|
|
362
|
+
if (!input) return null;
|
|
363
|
+
const source = typeof input === 'object' && !Array.isArray(input) ? /** @type {QuestionnaireInput} */ (input) : {};
|
|
364
|
+
return {
|
|
365
|
+
label: String(source.label ?? 'Other'),
|
|
366
|
+
placeholder: String(source.placeholder ?? ''),
|
|
367
|
+
multiline: Boolean(source.multiline),
|
|
368
|
+
maxLength: Math.max(0, Number(source.maxLength) || 0)
|
|
369
|
+
};
|
|
370
|
+
}
|