@zeyos/zx 2.3.0 → 4.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 +138 -0
- package/DESIGN-SYSTEM.md +96 -0
- package/README.md +29 -39
- package/dist/types/components/account-menu/account-menu.d.ts +144 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +243 -0
- package/dist/types/components/avatar/avatar.d.ts +100 -0
- package/dist/types/components/card/card.d.ts +128 -0
- package/dist/types/components/chart/chart.d.ts +322 -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 +359 -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/select/select.d.ts +51 -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 +223 -15
- 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 +122 -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 +13 -2
- package/dist/types/internal/app-rail.d.ts +265 -0
- package/dist/types/zeyos/icons.d.ts +5 -0
- package/dist/types/zeyos/index.d.ts +2 -1
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +39 -0
- package/dist/zx-zeyos.esm.js +1870 -133
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +2006 -267
- package/dist/zx.esm.js +8987 -2757
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +8987 -2757
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +4396 -2344
- package/docs/llms.md +463 -86
- package/docs/llms.txt +32 -19
- package/package.json +10 -17
- 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-sidebar/app-sidebar.css +80 -0
- package/src/components/app-sidebar/app-sidebar.js +588 -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/card/card.css +149 -0
- package/src/components/card/card.js +285 -0
- package/src/components/chart/chart.css +61 -0
- package/src/components/chart/chart.js +523 -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 +220 -0
- package/src/components/launcher/launcher.css +210 -0
- package/src/components/launcher/launcher.js +765 -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/field-adapter.js +9 -4
- package/src/components/select/select.css +144 -11
- package/src/components/select/select.js +259 -27
- 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 +531 -52
- package/src/components/tooltip/tooltip.js +5 -2
- package/src/components/value-list/value-list.js +6 -3
- package/src/core/dom.js +322 -0
- package/src/core/drag-axis.js +203 -0
- package/src/core/position.js +68 -22
- package/src/index.js +20 -2
- package/src/internal/app-rail.js +718 -0
- package/src/zeyos/icons.js +8 -3
- package/src/zeyos/index.js +7 -1
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +115 -1
- package/styles/tokens/dark.css +2 -3
- package/styles/tokens/global.css +6 -6
- package/styles/tokens/modules.css +1 -0
- package/styles/tokens/semantic.css +5 -3
- package/styles/tokens/themes.css +1 -1
- package/styles/zx.css +11 -1
- package/MIGRATION.md +0 -598
- package/dist/types/compat/base.d.ts +0 -156
- package/dist/types/compat/globals.d.ts +0 -20
- package/dist/types/compat/index.d.ts +0 -128
- package/dist/types/compat/map/basic.d.ts +0 -175
- package/dist/types/compat/map/data.d.ts +0 -102
- package/dist/types/compat/map/date-time.d.ts +0 -80
- package/dist/types/compat/map/factory.d.ts +0 -9
- package/dist/types/compat/map/form.d.ts +0 -98
- package/dist/types/compat/map/helpers.d.ts +0 -12
- package/dist/types/compat/map/http.d.ts +0 -36
- package/dist/types/compat/map/layout.d.ts +0 -106
- package/dist/types/compat/map/options.d.ts +0 -80
- package/dist/types/compat/map/select.d.ts +0 -95
- package/dist/types/compat/map/stubs.d.ts +0 -32
- package/dist/types/compat/map/utilities.d.ts +0 -37
- package/dist/types/compat-entry.d.ts +0 -5
- package/dist/types/components/permission/permission.d.ts +0 -96
- package/dist/zx-compat.esm.js +0 -17484
- package/dist/zx-compat.esm.min.js +0 -1
- package/dist/zx-compat.global.js +0 -23635
- package/dist/zx-compat.global.min.js +0 -3
- package/src/compat/base.js +0 -291
- package/src/compat/globals.js +0 -165
- package/src/compat/index.js +0 -84
- package/src/compat/map/basic.js +0 -464
- package/src/compat/map/data.js +0 -232
- package/src/compat/map/date-time.js +0 -177
- package/src/compat/map/factory.js +0 -45
- package/src/compat/map/form.js +0 -330
- package/src/compat/map/helpers.js +0 -46
- package/src/compat/map/http.js +0 -117
- package/src/compat/map/layout.js +0 -256
- package/src/compat/map/options.js +0 -267
- package/src/compat/map/select.js +0 -198
- package/src/compat/map/stubs.js +0 -26
- package/src/compat/map/utilities.js +0 -113
- package/src/compat-entry.js +0 -11
- package/src/components/permission/permission.css +0 -47
- package/src/components/permission/permission.js +0 -201
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Component } from '../../core/component.js';
|
|
3
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
4
|
+
import { icon as createIcon } from '../../core/icons.js';
|
|
5
|
+
import { uid } from '../../core/util.js';
|
|
6
|
+
import { button } from '../button/button.js';
|
|
7
|
+
import { Field } from '../field/field.js';
|
|
8
|
+
import { ProgressBar } from '../loading/progress-bar.js';
|
|
9
|
+
import {
|
|
10
|
+
applyExclusive, isEmptyAnswer, normalizeItems, progressOf, resolveFlow, resolveNext, validateAnswer
|
|
11
|
+
} from './items.js';
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('./items.js').QuestionnaireItem} QuestionnaireItem */
|
|
14
|
+
/** @typedef {import('./items.js').QuestionnaireChoice} QuestionnaireChoice */
|
|
15
|
+
/** @typedef {import('./items.js').NormalizedItem} NormalizedItem */
|
|
16
|
+
/** @typedef {import('./items.js').QuestionnaireProgress} QuestionnaireProgress */
|
|
17
|
+
|
|
18
|
+
/** @typedef {'manual'|'auto'} QuestionnaireAdvance */
|
|
19
|
+
/** @typedef {'asking'|'review'|'complete'} QuestionnaireStatus */
|
|
20
|
+
|
|
21
|
+
/** How long a picked answer stays visible before `advance: 'auto'` moves on, in milliseconds. */
|
|
22
|
+
const AUTO_ADVANCE_DELAY = 260;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} QuestionnaireOptions
|
|
26
|
+
* @property {QuestionnaireItem[]} [items=[]] Ordered question definitions.
|
|
27
|
+
* @property {Record<string, unknown>} [answers={}] Answers to start from. Together with `active`
|
|
28
|
+
* this is the resume pair: hand back what a previous session collected.
|
|
29
|
+
* @property {string|null} [active=null] Item to open, defaulting to the first visible one.
|
|
30
|
+
* @property {boolean} [progress=true] Whether the progress bar and counter are shown.
|
|
31
|
+
* @property {boolean} [review=false] Whether a summary of every answer is shown before submitting.
|
|
32
|
+
* @property {QuestionnaireAdvance} [advance='manual'] `auto` moves on by itself once a
|
|
33
|
+
* single-choice question is answered.
|
|
34
|
+
* @property {boolean} [shortcuts=true] Whether `1`–`9` and `a`–`z` pick an answer.
|
|
35
|
+
* @property {boolean} [allowBack=true] Whether the Back action is offered.
|
|
36
|
+
* @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
|
|
37
|
+
* @property {(event: CustomEvent<QuestionnaireChangeDetail>) => void} [onchange] Answer listener.
|
|
38
|
+
* @property {(event: CustomEvent<QuestionnaireNavigateDetail>) => void} [onnavigate] Navigation listener.
|
|
39
|
+
* @property {(event: CustomEvent<QuestionnaireSkipDetail>) => void} [onskip] Skip listener.
|
|
40
|
+
* @property {(event: CustomEvent<QuestionnaireInvalidDetail>) => void} [oninvalid] Validation listener.
|
|
41
|
+
* @property {(event: CustomEvent<QuestionnaireSubmitDetail>) => void} [onsubmit] Submit listener.
|
|
42
|
+
* @property {(event: CustomEvent<QuestionnaireCompleteDetail>) => void} [oncomplete] Completion listener.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} QuestionnaireChangeDetail
|
|
47
|
+
* @property {string} name Item whose answer changed.
|
|
48
|
+
* @property {unknown} value The new answer.
|
|
49
|
+
* @property {Record<string, unknown>} answers Every visible answer after the change.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} QuestionnaireNavigateDetail
|
|
54
|
+
* @property {string|null} from Item being left, or null.
|
|
55
|
+
* @property {string|null} to Item being opened, or null when the review screen is being entered.
|
|
56
|
+
* @property {'next'|'previous'|'skip'|'goto'|'review'} reason What triggered the move.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** @typedef {{name: string}} QuestionnaireSkipDetail */
|
|
60
|
+
/** @typedef {{name: string, message: string}} QuestionnaireInvalidDetail */
|
|
61
|
+
/** @typedef {{answers: Record<string, unknown>, path: string[]}} QuestionnaireSubmitDetail */
|
|
62
|
+
/** @typedef {{answers: Record<string, unknown>, path: string[]}} QuestionnaireCompleteDetail */
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A guided, one-question-at-a-time flow: onboarding intake, a service-call checklist, an audit
|
|
66
|
+
* form, a feedback survey. The questionnaire owns the ordered items, the active one, the answers,
|
|
67
|
+
* validation, progress and navigation; the page, card, dialog or drawer around it owns closing,
|
|
68
|
+
* persistence and transport.
|
|
69
|
+
*
|
|
70
|
+
* Three things separate it from a `Form` with one `Fieldset` per question. It **branches**: an item
|
|
71
|
+
* carries a `when` predicate and a `next` target, conditions cascade, and Back retraces the path
|
|
72
|
+
* actually walked rather than an array index. Its answer control is **any registered Zx `Field`
|
|
73
|
+
* type**, so a question can ask for a date, a number, a rating or an upload instead of a choice.
|
|
74
|
+
* And its `validate` may be asynchronous, which is what lets a question be gated on a server check.
|
|
75
|
+
*
|
|
76
|
+
* The root is a real `<form>` and choices are native radios and checkboxes carrying the item name,
|
|
77
|
+
* so `new FormData(questionnaire.toElement())` reads the answers, arrow-key navigation inside a
|
|
78
|
+
* choice group is the browser's rather than ours, and nothing depends on JavaScript for semantics.
|
|
79
|
+
*
|
|
80
|
+
* @fires Questionnaire#change
|
|
81
|
+
* @fires Questionnaire#navigate
|
|
82
|
+
* @fires Questionnaire#skip
|
|
83
|
+
* @fires Questionnaire#invalid
|
|
84
|
+
* @fires Questionnaire#submit
|
|
85
|
+
* @fires Questionnaire#complete
|
|
86
|
+
* @extends {Component<QuestionnaireOptions>}
|
|
87
|
+
*/
|
|
88
|
+
export class Questionnaire extends Component {
|
|
89
|
+
static cssName = 'questionnaire';
|
|
90
|
+
|
|
91
|
+
/** @type {QuestionnaireOptions & {msg: Record<string, string>}} */
|
|
92
|
+
static defaults = {
|
|
93
|
+
items: [],
|
|
94
|
+
answers: {},
|
|
95
|
+
active: null,
|
|
96
|
+
progress: true,
|
|
97
|
+
review: false,
|
|
98
|
+
advance: 'manual',
|
|
99
|
+
shortcuts: true,
|
|
100
|
+
allowBack: true,
|
|
101
|
+
msg: {
|
|
102
|
+
'questionnaire.next': 'Continue',
|
|
103
|
+
'questionnaire.previous': 'Back',
|
|
104
|
+
'questionnaire.skip': 'Skip',
|
|
105
|
+
'questionnaire.submit': 'Submit',
|
|
106
|
+
'questionnaire.review': 'Review answers',
|
|
107
|
+
'questionnaire.reviewTitle': 'Check your answers',
|
|
108
|
+
'questionnaire.edit': 'Edit',
|
|
109
|
+
'questionnaire.counter': 'Question %1 of %2',
|
|
110
|
+
'questionnaire.required': 'Choose an answer to continue.',
|
|
111
|
+
'questionnaire.requiredInput': 'Enter an answer to continue.',
|
|
112
|
+
'questionnaire.noAnswer': 'Skipped',
|
|
113
|
+
'questionnaire.empty': 'There is nothing to answer.'
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds the questionnaire. Runs inside the base constructor, so every piece of instance state
|
|
119
|
+
* is initialized here rather than in class fields.
|
|
120
|
+
* @returns {HTMLElement}
|
|
121
|
+
*/
|
|
122
|
+
render() {
|
|
123
|
+
this._createdRoot = this.el === null;
|
|
124
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(this.el);
|
|
125
|
+
this._destroyed = false;
|
|
126
|
+
/** @type {NormalizedItem[]} */
|
|
127
|
+
this._items = [];
|
|
128
|
+
/** @type {Map<string, Array<string|number>>} */
|
|
129
|
+
this._selected = new Map();
|
|
130
|
+
/** @type {Map<string, string>} */
|
|
131
|
+
this._freeform = new Map();
|
|
132
|
+
/** @type {Map<string, Field>} */
|
|
133
|
+
this._fields = new Map();
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
this._path = [];
|
|
136
|
+
/** @type {QuestionnaireStatus} */
|
|
137
|
+
this._status = 'asking';
|
|
138
|
+
this._returnToReview = false;
|
|
139
|
+
this._busy = false;
|
|
140
|
+
this._error = '';
|
|
141
|
+
this._advanceTimer = 0;
|
|
142
|
+
this._uid = uid('zx-questionnaire');
|
|
143
|
+
|
|
144
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('form'));
|
|
145
|
+
this.el = root;
|
|
146
|
+
|
|
147
|
+
// Radios group by name within their form owner. A questionnaire enhancing a plain element
|
|
148
|
+
// therefore gets an inner form, or two instances showing a same-named question would fight
|
|
149
|
+
// over one selection — and `toFormData()` would have nothing to read.
|
|
150
|
+
this._form = root.localName === 'form' ? root : h('form', { class: 'zx-questionnaire__form' });
|
|
151
|
+
this._form.setAttribute('novalidate', '');
|
|
152
|
+
|
|
153
|
+
this._progressHost = h('div', { class: 'zx-questionnaire__progress', hidden: !this.options.progress });
|
|
154
|
+
this._itemHost = h('fieldset', {
|
|
155
|
+
class: 'zx-questionnaire__item',
|
|
156
|
+
tabIndex: -1
|
|
157
|
+
});
|
|
158
|
+
this._reviewHost = h('div', { class: 'zx-questionnaire__review', tabIndex: -1, hidden: true });
|
|
159
|
+
this._actionsHost = h('div', { class: 'zx-questionnaire__actions' });
|
|
160
|
+
this.refs.item = this._itemHost;
|
|
161
|
+
this.refs.review = this._reviewHost;
|
|
162
|
+
this.refs.actions = this._actionsHost;
|
|
163
|
+
|
|
164
|
+
this._bar = new ProgressBar(null, { max: 100, value: 0, size: 'sm', showValue: false });
|
|
165
|
+
this._progressHost.append(this._bar.el);
|
|
166
|
+
|
|
167
|
+
this._back = this._action('previous', this.msg('questionnaire.previous'), 'chevron-left', 'default');
|
|
168
|
+
this._skip = this._action('skip', this.msg('questionnaire.skip'), null, 'ghost');
|
|
169
|
+
this._forward = this._action('next', this.msg('questionnaire.next'), 'chevron-right', 'primary');
|
|
170
|
+
this._actionsHost.append(this._back, h('span', { class: 'zx-questionnaire__spacer' }), this._skip, this._forward);
|
|
171
|
+
|
|
172
|
+
this._form.replaceChildren(this._progressHost, this._itemHost, this._reviewHost, this._actionsHost);
|
|
173
|
+
if (this._form !== root) root.replaceChildren(this._form);
|
|
174
|
+
|
|
175
|
+
this.listen(this._form, 'submit', (event) => {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
void this.next();
|
|
178
|
+
});
|
|
179
|
+
this.listen(this._itemHost, 'change', (event) => this._onControlChange(event));
|
|
180
|
+
this.listen(this._itemHost, 'input', (event) => this._onFreeformInput(event));
|
|
181
|
+
this.listen(this._actionsHost, 'click', (event) => this._onActionClick(event));
|
|
182
|
+
this.listen(this._reviewHost, 'click', (event) => this._onReviewClick(event));
|
|
183
|
+
this.listen(root, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
184
|
+
|
|
185
|
+
this.setItems(this.options.items);
|
|
186
|
+
if (this.options.answers && typeof this.options.answers === 'object') {
|
|
187
|
+
this.setAnswers(this.options.answers, { silent: true });
|
|
188
|
+
}
|
|
189
|
+
if (this.options.active != null) {
|
|
190
|
+
const start = String(this.options.active);
|
|
191
|
+
const visible = resolveFlow(this._items, this._rawAnswers()).visible;
|
|
192
|
+
if (visible.some((item) => item.name === start)) this._path = [start];
|
|
193
|
+
}
|
|
194
|
+
this._paint();
|
|
195
|
+
return root;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Replaces the whole item list. Answers survive for items that are still present, and so does
|
|
200
|
+
* the active item; when it is gone the first visible item takes over. This is a structural
|
|
201
|
+
* change, so it never emits `navigate`.
|
|
202
|
+
* @param {QuestionnaireItem[]} list Item definitions.
|
|
203
|
+
* @returns {this}
|
|
204
|
+
* @throws {TypeError} When the list, an item, or a name is malformed, or a name repeats.
|
|
205
|
+
*/
|
|
206
|
+
setItems(list) {
|
|
207
|
+
const previous = this.getActive();
|
|
208
|
+
const items = normalizeItems(list);
|
|
209
|
+
const names = new Set(items.map((item) => item.name));
|
|
210
|
+
this._items = items;
|
|
211
|
+
|
|
212
|
+
for (const map of [this._selected, this._freeform]) {
|
|
213
|
+
for (const name of [...map.keys()]) if (!names.has(name)) map.delete(name);
|
|
214
|
+
}
|
|
215
|
+
for (const [name, field] of [...this._fields]) {
|
|
216
|
+
if (names.has(name)) continue;
|
|
217
|
+
field.destroy();
|
|
218
|
+
this._fields.delete(name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const visible = resolveFlow(items, this._rawAnswers()).visible;
|
|
222
|
+
const kept = this._path.filter((name) => visible.some((item) => item.name === name));
|
|
223
|
+
if (previous && kept[kept.length - 1] === previous) this._path = kept;
|
|
224
|
+
else this._path = visible.length > 0 ? [visible[0].name] : [];
|
|
225
|
+
this._status = 'asking';
|
|
226
|
+
this._returnToReview = false;
|
|
227
|
+
this._error = '';
|
|
228
|
+
this._paint();
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Returns the normalized items, including ones currently conditioned out.
|
|
234
|
+
* @returns {NormalizedItem[]}
|
|
235
|
+
*/
|
|
236
|
+
getItems() {
|
|
237
|
+
return this._items.map((item) => ({
|
|
238
|
+
...item,
|
|
239
|
+
choices: item.choices.map((choice) => ({ ...choice })),
|
|
240
|
+
input: item.input ? { ...item.input } : null
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Opens an item without validating the one being left — what the review screen's Edit action
|
|
246
|
+
* does. An item already on the path truncates it, so Back keeps working; a new one is appended.
|
|
247
|
+
* @param {string} name Item name.
|
|
248
|
+
* @returns {boolean} False when the move was vetoed or the item is conditioned out.
|
|
249
|
+
* @throws {RangeError} When no item carries that name.
|
|
250
|
+
* @fires Questionnaire#navigate
|
|
251
|
+
*/
|
|
252
|
+
goTo(name) {
|
|
253
|
+
const item = this._items.find((candidate) => candidate.name === name);
|
|
254
|
+
if (!item) throw new RangeError(`Unknown questionnaire item: ${name}`);
|
|
255
|
+
const visible = resolveFlow(this._items, this._rawAnswers()).visible;
|
|
256
|
+
if (!visible.includes(item)) return false;
|
|
257
|
+
const restore = this._returnToReview;
|
|
258
|
+
// Set before navigating: `_navigate()` paints, and the forward button's label reads this.
|
|
259
|
+
this._returnToReview = restore || this._status === 'review';
|
|
260
|
+
if (this._navigate(name, 'goto')) return true;
|
|
261
|
+
this._returnToReview = restore;
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validates the active item and moves on: to the branch target, then the review screen, then
|
|
267
|
+
* submission. Returns a promise because an item's `validate` may be asynchronous.
|
|
268
|
+
* @returns {Promise<boolean>} False when validation failed or the move was vetoed.
|
|
269
|
+
* @fires Questionnaire#navigate
|
|
270
|
+
* @fires Questionnaire#invalid
|
|
271
|
+
*/
|
|
272
|
+
async next() {
|
|
273
|
+
if (this._busy || this._status === 'complete') return false;
|
|
274
|
+
if (this._status === 'review') return this.submit();
|
|
275
|
+
if (!await this._validateActive()) return false;
|
|
276
|
+
if (this._destroyed) return false;
|
|
277
|
+
|
|
278
|
+
if (this._returnToReview) {
|
|
279
|
+
this._returnToReview = false;
|
|
280
|
+
return this._enterReview();
|
|
281
|
+
}
|
|
282
|
+
const item = this._activeItem();
|
|
283
|
+
const target = item ? resolveNext(this._items, item, this._rawAnswers()) : null;
|
|
284
|
+
if (target) return this._navigate(target, 'next');
|
|
285
|
+
if (this.options.review) return this._enterReview();
|
|
286
|
+
return this.submit();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Steps back along the path actually walked — which is not `index - 1` once a branch has been
|
|
291
|
+
* taken. From the review screen it reopens the last question.
|
|
292
|
+
* @returns {boolean} False at the start of the questionnaire, or when the move was vetoed.
|
|
293
|
+
* @fires Questionnaire#navigate
|
|
294
|
+
*/
|
|
295
|
+
previous() {
|
|
296
|
+
if (this._busy) return false;
|
|
297
|
+
if (this._status === 'review' || this._status === 'complete') {
|
|
298
|
+
const last = this._path[this._path.length - 1];
|
|
299
|
+
if (!last) return false;
|
|
300
|
+
const event = this.emit('navigate', { from: null, to: last, reason: 'previous' });
|
|
301
|
+
if (event.defaultPrevented) return false;
|
|
302
|
+
this._status = 'asking';
|
|
303
|
+
// The last question leads back into review on its own, so nothing needs to be remembered.
|
|
304
|
+
this._returnToReview = false;
|
|
305
|
+
this._error = '';
|
|
306
|
+
this._paint();
|
|
307
|
+
this._focusItem();
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (!this.options.allowBack || this._path.length <= 1) return false;
|
|
311
|
+
const from = this.getActive();
|
|
312
|
+
const to = this._path[this._path.length - 2];
|
|
313
|
+
const event = this.emit('navigate', { from, to, reason: 'previous' });
|
|
314
|
+
if (event.defaultPrevented) return false;
|
|
315
|
+
this._path.pop();
|
|
316
|
+
this._returnToReview = false;
|
|
317
|
+
this._error = '';
|
|
318
|
+
this._paint();
|
|
319
|
+
this._focusItem();
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clears the active item's answer and moves on without validating it. Skipping the last
|
|
325
|
+
* question submits, so this is a promise for the same reason `submit()` is.
|
|
326
|
+
* @returns {Promise<boolean>} False when the item is not skippable or the move was vetoed.
|
|
327
|
+
* @fires Questionnaire#skip
|
|
328
|
+
* @fires Questionnaire#navigate
|
|
329
|
+
*/
|
|
330
|
+
async skip() {
|
|
331
|
+
const item = this._activeItem();
|
|
332
|
+
if (!item || !item.skippable || this._busy) return false;
|
|
333
|
+
this.setAnswer(item.name, null, { silent: true });
|
|
334
|
+
this.emit('skip', { name: item.name });
|
|
335
|
+
if (this._returnToReview) {
|
|
336
|
+
this._returnToReview = false;
|
|
337
|
+
return this._enterReview();
|
|
338
|
+
}
|
|
339
|
+
const target = resolveNext(this._items, item, this._rawAnswers());
|
|
340
|
+
if (target) return this._navigate(target, 'skip');
|
|
341
|
+
if (this.options.review) return this._enterReview();
|
|
342
|
+
return this.submit();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validates the active item (unless already on the review screen) and submits.
|
|
347
|
+
* @returns {Promise<boolean>} False when validation failed or `submit` was prevented.
|
|
348
|
+
* @fires Questionnaire#submit
|
|
349
|
+
* @fires Questionnaire#complete
|
|
350
|
+
*/
|
|
351
|
+
async submit() {
|
|
352
|
+
if (this._busy || this._status === 'complete') return false;
|
|
353
|
+
if (this._status !== 'review' && !await this._validateActive()) return false;
|
|
354
|
+
if (this._destroyed) return false;
|
|
355
|
+
|
|
356
|
+
const detail = { answers: this.getAnswers(), path: this.getPath() };
|
|
357
|
+
if (this.emit('submit', detail).defaultPrevented) return false;
|
|
358
|
+
this._status = 'complete';
|
|
359
|
+
this._paint();
|
|
360
|
+
this.emit('complete', { answers: detail.answers, path: detail.path });
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Returns the answers of every visible question that carries one, in declaration order.
|
|
366
|
+
* Questions conditioned out are left out, so an abandoned branch never submits stale data.
|
|
367
|
+
* @returns {Record<string, unknown>}
|
|
368
|
+
*/
|
|
369
|
+
getAnswers() {
|
|
370
|
+
return resolveFlow(this._items, this._rawAnswers()).answers;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Returns one answer, whether or not its question is currently visible.
|
|
375
|
+
* @param {string} name Item name.
|
|
376
|
+
* @returns {unknown}
|
|
377
|
+
*/
|
|
378
|
+
getAnswer(name) {
|
|
379
|
+
const item = this._items.find((candidate) => candidate.name === name);
|
|
380
|
+
return item ? this._answerOf(item) : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Sets several answers at once.
|
|
385
|
+
* @param {Record<string, unknown>} values Answers keyed by item name.
|
|
386
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `change`.
|
|
387
|
+
* @returns {this}
|
|
388
|
+
* @fires Questionnaire#change
|
|
389
|
+
*/
|
|
390
|
+
setAnswers(values, { silent = false } = {}) {
|
|
391
|
+
if (!values || typeof values !== 'object') return this;
|
|
392
|
+
for (const [name, value] of Object.entries(values)) {
|
|
393
|
+
if (this._items.some((item) => item.name === name)) this.setAnswer(name, value, { silent: true });
|
|
394
|
+
}
|
|
395
|
+
this._paint();
|
|
396
|
+
if (!silent) {
|
|
397
|
+
const answers = this.getAnswers();
|
|
398
|
+
for (const name of Object.keys(values)) {
|
|
399
|
+
if (name in answers) this.emit('change', { name, value: answers[name], answers });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sets one answer. A value matching a choice selects it; anything else lands in the freeform
|
|
407
|
+
* input when the item has one.
|
|
408
|
+
* @param {string} name Item name.
|
|
409
|
+
* @param {unknown} value New answer, or null to clear it.
|
|
410
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `change`.
|
|
411
|
+
* @returns {this}
|
|
412
|
+
* @throws {RangeError} When no item carries that name.
|
|
413
|
+
* @fires Questionnaire#change
|
|
414
|
+
*/
|
|
415
|
+
setAnswer(name, value, { silent = false } = {}) {
|
|
416
|
+
const item = this._items.find((candidate) => candidate.name === name);
|
|
417
|
+
if (!item) throw new RangeError(`Unknown questionnaire item: ${name}`);
|
|
418
|
+
|
|
419
|
+
if (item.field) {
|
|
420
|
+
this._field(item).setValue(value === undefined ? null : value, { silent: true });
|
|
421
|
+
} else {
|
|
422
|
+
const list = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
|
|
423
|
+
const keys = new Set(list.map((entry) => String(entry)));
|
|
424
|
+
const matched = item.choices.filter((choice) => keys.has(String(choice.value)));
|
|
425
|
+
for (const choice of matched) keys.delete(String(choice.value));
|
|
426
|
+
this._selected.set(name, matched.map((choice) => choice.value));
|
|
427
|
+
this._freeform.set(name, item.input ? ([...keys][0] ?? '') : '');
|
|
428
|
+
}
|
|
429
|
+
this._paint();
|
|
430
|
+
if (!silent) this._emitChange(item);
|
|
431
|
+
return this;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Returns the active item name, or null on the review or completion screen.
|
|
436
|
+
* @returns {string|null}
|
|
437
|
+
*/
|
|
438
|
+
getActive() {
|
|
439
|
+
if (this._status !== 'asking') return null;
|
|
440
|
+
return this._path[this._path.length - 1] ?? null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns the item names visited so far, active last. This is the questionnaire's memory of the
|
|
445
|
+
* route through the branches.
|
|
446
|
+
* @returns {string[]}
|
|
447
|
+
*/
|
|
448
|
+
getPath() {
|
|
449
|
+
return [...this._path];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Returns progress along the path walked. `total` is exact while the flow is static and an
|
|
454
|
+
* estimate once a function `next` can jump somewhere the forward walk cannot predict.
|
|
455
|
+
* @returns {QuestionnaireProgress}
|
|
456
|
+
*/
|
|
457
|
+
getProgress() {
|
|
458
|
+
return progressOf(this._items, this._rawAnswers(), this._path);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Returns everything a caller needs to resume this questionnaire later.
|
|
463
|
+
* @returns {{active: string|null, status: QuestionnaireStatus, answers: Record<string, unknown>, path: string[], progress: QuestionnaireProgress}}
|
|
464
|
+
*/
|
|
465
|
+
getState() {
|
|
466
|
+
return {
|
|
467
|
+
active: this.getActive(),
|
|
468
|
+
status: this._status,
|
|
469
|
+
answers: this.getAnswers(),
|
|
470
|
+
path: this.getPath(),
|
|
471
|
+
progress: this.getProgress()
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Returns the visible answers as form data, with one entry per value of a multiple-choice item —
|
|
477
|
+
* the same shape `new FormData(questionnaire.toElement())` produces.
|
|
478
|
+
* @returns {FormData}
|
|
479
|
+
*/
|
|
480
|
+
toFormData() {
|
|
481
|
+
const data = new FormData();
|
|
482
|
+
for (const [name, value] of Object.entries(this.getAnswers())) {
|
|
483
|
+
if (Array.isArray(value)) for (const entry of value) data.append(name, String(entry));
|
|
484
|
+
else if (value instanceof File || value instanceof Blob) data.append(name, value);
|
|
485
|
+
else data.append(name, value == null ? '' : String(value));
|
|
486
|
+
}
|
|
487
|
+
return data;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Clears every answer and returns to the first visible question.
|
|
492
|
+
* @returns {this}
|
|
493
|
+
*/
|
|
494
|
+
reset() {
|
|
495
|
+
this._selected.clear();
|
|
496
|
+
this._freeform.clear();
|
|
497
|
+
for (const [name, field] of this._fields) {
|
|
498
|
+
const item = this._items.find((candidate) => candidate.name === name);
|
|
499
|
+
field.setValue(item?.field?.value ?? null, { silent: true });
|
|
500
|
+
}
|
|
501
|
+
const visible = resolveFlow(this._items, this._rawAnswers()).visible;
|
|
502
|
+
this._path = visible.length > 0 ? [visible[0].name] : [];
|
|
503
|
+
this._status = 'asking';
|
|
504
|
+
this._returnToReview = false;
|
|
505
|
+
this._error = '';
|
|
506
|
+
this._paint();
|
|
507
|
+
return this;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Moves focus to the active question, which is what a screen reader announces after a move.
|
|
512
|
+
* @returns {this}
|
|
513
|
+
*/
|
|
514
|
+
focus() {
|
|
515
|
+
this._focusItem();
|
|
516
|
+
return this;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** @returns {void} */
|
|
520
|
+
destroy() {
|
|
521
|
+
if (this._destroyed) return;
|
|
522
|
+
this._destroyed = true;
|
|
523
|
+
clearTimeout(this._advanceTimer);
|
|
524
|
+
for (const field of this._fields.values()) field.destroy();
|
|
525
|
+
this._fields.clear();
|
|
526
|
+
this._bar?.destroy();
|
|
527
|
+
super.destroy();
|
|
528
|
+
if (!this._createdRoot) restoreTarget(this.el, this._snapshot);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* ---------------------------------------------------------------- state */
|
|
532
|
+
|
|
533
|
+
/** @returns {NormalizedItem|null} */
|
|
534
|
+
_activeItem() {
|
|
535
|
+
const name = this._path[this._path.length - 1];
|
|
536
|
+
return this._items.find((item) => item.name === name) ?? null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Every answer given, including ones on branches that are currently conditioned out — walking
|
|
541
|
+
* back into a branch restores what was typed there. `resolveFlow()` filters this down.
|
|
542
|
+
* @returns {Record<string, unknown>}
|
|
543
|
+
*/
|
|
544
|
+
_rawAnswers() {
|
|
545
|
+
/** @type {Record<string, unknown>} */
|
|
546
|
+
const answers = {};
|
|
547
|
+
for (const item of this._items) {
|
|
548
|
+
const value = this._answerOf(item);
|
|
549
|
+
if (!isEmptyAnswer(value)) answers[item.name] = value;
|
|
550
|
+
}
|
|
551
|
+
return answers;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** @param {NormalizedItem} item @returns {unknown} */
|
|
555
|
+
_answerOf(item) {
|
|
556
|
+
if (item.field) return this._fields.get(item.name)?.getValue() ?? null;
|
|
557
|
+
const selected = this._selected.get(item.name) ?? [];
|
|
558
|
+
const freeform = (this._freeform.get(item.name) ?? '').trim();
|
|
559
|
+
if (item.multiple) return freeform ? [...selected, freeform] : [...selected];
|
|
560
|
+
if (freeform) return freeform;
|
|
561
|
+
return selected.length > 0 ? selected[0] : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Returns the item's Field, building it on first use. Fields are cached rather than rebuilt per
|
|
566
|
+
* paint, so what a reader typed survives walking away from the question and back.
|
|
567
|
+
* @param {NormalizedItem} item Item carrying `field` options.
|
|
568
|
+
* @returns {Field}
|
|
569
|
+
*/
|
|
570
|
+
_field(item) {
|
|
571
|
+
let field = this._fields.get(item.name);
|
|
572
|
+
if (field) return field;
|
|
573
|
+
const options = /** @type {Record<string, any>} */ (item.field ?? {});
|
|
574
|
+
field = new Field(null, {
|
|
575
|
+
...options,
|
|
576
|
+
label: options.label ?? item.prompt,
|
|
577
|
+
required: item.required
|
|
578
|
+
});
|
|
579
|
+
field.el.classList.add('zx-questionnaire__field');
|
|
580
|
+
// The legend already asks the question; repeating it above the control would say it twice.
|
|
581
|
+
if (options.label == null) field.el.setAttribute('data-hide-label', '');
|
|
582
|
+
field.on('change', () => {
|
|
583
|
+
this._error = '';
|
|
584
|
+
this._paintError(item);
|
|
585
|
+
this._emitChange(item);
|
|
586
|
+
});
|
|
587
|
+
this._fields.set(item.name, field);
|
|
588
|
+
return field;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* ----------------------------------------------------------- navigation */
|
|
592
|
+
|
|
593
|
+
/** @param {string} name @param {QuestionnaireNavigateDetail['reason']} reason @returns {boolean} */
|
|
594
|
+
_navigate(name, reason) {
|
|
595
|
+
const from = this.getActive();
|
|
596
|
+
if (from === name && this._status === 'asking') return false;
|
|
597
|
+
if (this.emit('navigate', { from, to: name, reason }).defaultPrevented) return false;
|
|
598
|
+
|
|
599
|
+
const index = this._path.indexOf(name);
|
|
600
|
+
if (index >= 0) this._path = this._path.slice(0, index + 1);
|
|
601
|
+
else this._path.push(name);
|
|
602
|
+
this._status = 'asking';
|
|
603
|
+
this._error = '';
|
|
604
|
+
this._paint();
|
|
605
|
+
this._focusItem();
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** @returns {boolean} */
|
|
610
|
+
_enterReview() {
|
|
611
|
+
if (this.emit('navigate', { from: this.getActive(), to: null, reason: 'review' }).defaultPrevented) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
this._status = 'review';
|
|
615
|
+
this._error = '';
|
|
616
|
+
this._paint();
|
|
617
|
+
this._reviewHost.focus?.();
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** @returns {Promise<boolean>} */
|
|
622
|
+
async _validateActive() {
|
|
623
|
+
const item = this._activeItem();
|
|
624
|
+
if (!item) return true;
|
|
625
|
+
const answer = this._answerOf(item);
|
|
626
|
+
let message = validateAnswer(item, answer, (key) => this.msg(key));
|
|
627
|
+
|
|
628
|
+
if (!message && typeof item.validate === 'function') {
|
|
629
|
+
this._setBusy(true);
|
|
630
|
+
try {
|
|
631
|
+
message = await item.validate(answer, this.getAnswers());
|
|
632
|
+
} catch (error) {
|
|
633
|
+
message = error instanceof Error ? error.message : String(error);
|
|
634
|
+
} finally {
|
|
635
|
+
if (!this._destroyed) this._setBusy(false);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (this._destroyed) return false;
|
|
639
|
+
|
|
640
|
+
this._error = message ? String(message) : '';
|
|
641
|
+
this._paintError(item);
|
|
642
|
+
if (!this._error) return true;
|
|
643
|
+
this.emit('invalid', { name: item.name, message: this._error });
|
|
644
|
+
this._focusControl();
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** @param {boolean} busy @returns {void} */
|
|
649
|
+
_setBusy(busy) {
|
|
650
|
+
this._busy = busy;
|
|
651
|
+
if (busy) this.el.setAttribute('aria-busy', 'true');
|
|
652
|
+
else this.el.removeAttribute('aria-busy');
|
|
653
|
+
this._syncState();
|
|
654
|
+
this._paintActions();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* -------------------------------------------------------------- painting */
|
|
658
|
+
|
|
659
|
+
/** @returns {void} */
|
|
660
|
+
_paint() {
|
|
661
|
+
if (this._destroyed) return;
|
|
662
|
+
this._syncState();
|
|
663
|
+
this._paintProgress();
|
|
664
|
+
if (this._status === 'review') this._paintReview();
|
|
665
|
+
else this._paintItem();
|
|
666
|
+
this._itemHost.hidden = this._status === 'review';
|
|
667
|
+
this._reviewHost.hidden = this._status !== 'review';
|
|
668
|
+
this._paintActions();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** @returns {void} */
|
|
672
|
+
_syncState() {
|
|
673
|
+
/** @type {HTMLElement} */ (this.el).dataset.state = this._busy ? 'checking' : this._status;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** @returns {void} */
|
|
677
|
+
_paintProgress() {
|
|
678
|
+
this._progressHost.hidden = !this.options.progress || this._items.length === 0;
|
|
679
|
+
if (this._progressHost.hidden) return;
|
|
680
|
+
const progress = this.getProgress();
|
|
681
|
+
const counter = this.msg('questionnaire.counter', progress.index + 1, Math.max(progress.total, 1));
|
|
682
|
+
this._bar.setLabel(progress.section ? `${counter} · ${progress.section}` : counter);
|
|
683
|
+
this._bar.set(this._status === 'asking' ? progress.percent : 100, { silent: true });
|
|
684
|
+
this._bar.setStatus(this._status === 'complete' ? 'success' : 'active');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** @returns {void} */
|
|
688
|
+
_paintItem() {
|
|
689
|
+
const item = this._activeItem();
|
|
690
|
+
this._itemHost.toggleAttribute('data-empty', !item);
|
|
691
|
+
if (!item) {
|
|
692
|
+
this._itemHost.replaceChildren(h('p', { class: 'zx-questionnaire__prompt' },
|
|
693
|
+
this.msg('questionnaire.empty')));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const promptId = `${this._uid}-prompt`;
|
|
698
|
+
const descriptionId = `${this._uid}-description`;
|
|
699
|
+
const errorId = `${this._uid}-error`;
|
|
700
|
+
const children = [h('legend', { class: 'zx-questionnaire__prompt', id: promptId }, item.prompt)];
|
|
701
|
+
if (item.description) {
|
|
702
|
+
children.push(h('p', { class: 'zx-questionnaire__description', id: descriptionId }, item.description));
|
|
703
|
+
}
|
|
704
|
+
children.push(item.field
|
|
705
|
+
? /** @type {HTMLElement} */ (this._field(item).el)
|
|
706
|
+
: this._choices(item));
|
|
707
|
+
children.push(h('p', {
|
|
708
|
+
class: 'zx-questionnaire__error',
|
|
709
|
+
id: errorId,
|
|
710
|
+
role: 'alert',
|
|
711
|
+
hidden: true
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
this._itemHost.replaceChildren(...children);
|
|
715
|
+
this._itemHost.dataset.item = item.name;
|
|
716
|
+
this._itemHost.setAttribute('aria-describedby',
|
|
717
|
+
[item.description ? descriptionId : '', errorId].filter(Boolean).join(' '));
|
|
718
|
+
this._paintError(item);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** @param {NormalizedItem} item @returns {HTMLElement} */
|
|
722
|
+
_choices(item) {
|
|
723
|
+
const selected = new Set((this._selected.get(item.name) ?? []).map((value) => String(value)));
|
|
724
|
+
const group = h('div', { class: 'zx-questionnaire__choices' });
|
|
725
|
+
|
|
726
|
+
group.append(...item.choices.map((choice, index) => h('label', {
|
|
727
|
+
class: 'zx-questionnaire__choice',
|
|
728
|
+
dataset: { disabled: choice.disabled ? 'true' : null }
|
|
729
|
+
},
|
|
730
|
+
h('input', {
|
|
731
|
+
type: item.multiple ? 'checkbox' : 'radio',
|
|
732
|
+
class: 'zx-questionnaire__control',
|
|
733
|
+
name: item.name,
|
|
734
|
+
value: String(choice.value),
|
|
735
|
+
checked: selected.has(String(choice.value)),
|
|
736
|
+
disabled: choice.disabled,
|
|
737
|
+
dataset: { index }
|
|
738
|
+
}),
|
|
739
|
+
h('span', { class: 'zx-questionnaire__marker', ariaHidden: 'true' }),
|
|
740
|
+
choice.icon ? createIcon(choice.icon, { size: 16 }) : null,
|
|
741
|
+
h('span', { class: 'zx-questionnaire__text' },
|
|
742
|
+
h('span', { class: 'zx-questionnaire__label' }, choice.label),
|
|
743
|
+
choice.description ? h('span', { class: 'zx-questionnaire__hint' }, choice.description) : null),
|
|
744
|
+
choice.key ? h('kbd', { class: 'zx-questionnaire__key', ariaHidden: 'true' }, choice.key) : null)));
|
|
745
|
+
|
|
746
|
+
if (item.input) {
|
|
747
|
+
const inputId = `${this._uid}-freeform`;
|
|
748
|
+
const value = this._freeform.get(item.name) ?? '';
|
|
749
|
+
const control = h(item.input.multiline ? 'textarea' : 'input', {
|
|
750
|
+
type: item.input.multiline ? undefined : 'text',
|
|
751
|
+
class: 'zx-questionnaire__freeform-input',
|
|
752
|
+
id: inputId,
|
|
753
|
+
name: item.choices.length > 0 ? `${item.name}__other` : item.name,
|
|
754
|
+
placeholder: item.input.placeholder || undefined,
|
|
755
|
+
maxLength: item.input.maxLength > 0 ? item.input.maxLength : undefined,
|
|
756
|
+
rows: item.input.multiline ? 3 : undefined,
|
|
757
|
+
autocomplete: 'off',
|
|
758
|
+
value
|
|
759
|
+
});
|
|
760
|
+
group.append(h('div', { class: 'zx-questionnaire__freeform' },
|
|
761
|
+
h('label', { class: 'zx-questionnaire__freeform-label', htmlFor: inputId }, item.input.label),
|
|
762
|
+
control));
|
|
763
|
+
}
|
|
764
|
+
return group;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** @param {NormalizedItem} item @returns {void} */
|
|
768
|
+
_paintError(item) {
|
|
769
|
+
const error = this._itemHost.querySelector('.zx-questionnaire__error');
|
|
770
|
+
if (error instanceof HTMLElement) {
|
|
771
|
+
error.textContent = this._error;
|
|
772
|
+
error.hidden = !this._error;
|
|
773
|
+
}
|
|
774
|
+
if (this._error) this._itemHost.setAttribute('aria-invalid', 'true');
|
|
775
|
+
else this._itemHost.removeAttribute('aria-invalid');
|
|
776
|
+
const field = item?.field ? this._fields.get(item.name) : null;
|
|
777
|
+
// A Field draws its own highlight; letting it own the message keeps one error, not two.
|
|
778
|
+
if (field) {
|
|
779
|
+
if (this._error) field.setHighlight(this._error, 'danger');
|
|
780
|
+
else field.clearHighlight();
|
|
781
|
+
if (error instanceof HTMLElement) error.hidden = true;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** @returns {void} */
|
|
786
|
+
_paintReview() {
|
|
787
|
+
const { answers, visible } = resolveFlow(this._items, this._rawAnswers());
|
|
788
|
+
const rows = this._path
|
|
789
|
+
.map((name) => visible.find((item) => item.name === name))
|
|
790
|
+
.filter((item) => Boolean(item))
|
|
791
|
+
.map((item) => h('div', { class: 'zx-questionnaire__answer' },
|
|
792
|
+
h('dt', { class: 'zx-questionnaire__answer-prompt' }, item.prompt),
|
|
793
|
+
h('dd', {
|
|
794
|
+
class: 'zx-questionnaire__answer-value',
|
|
795
|
+
dataset: { empty: isEmptyAnswer(answers[item.name]) ? 'true' : null }
|
|
796
|
+
}, this._formatAnswer(item, answers[item.name])),
|
|
797
|
+
button({ label: this.msg('questionnaire.edit'), kind: 'ghost', size: 'sm' })));
|
|
798
|
+
|
|
799
|
+
for (const [index, row] of rows.entries()) {
|
|
800
|
+
const edit = row.querySelector('button');
|
|
801
|
+
if (edit instanceof HTMLElement) edit.dataset.edit = this._path[index];
|
|
802
|
+
}
|
|
803
|
+
this._reviewHost.replaceChildren(
|
|
804
|
+
h('p', { class: 'zx-questionnaire__review-title' }, this.msg('questionnaire.reviewTitle')),
|
|
805
|
+
h('dl', { class: 'zx-questionnaire__answers' }, ...rows));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** @param {NormalizedItem} item @param {unknown} value @returns {string} */
|
|
809
|
+
_formatAnswer(item, value) {
|
|
810
|
+
if (isEmptyAnswer(value)) return this.msg('questionnaire.noAnswer');
|
|
811
|
+
const labels = new Map(item.choices.map((choice) => [String(choice.value), choice.label]));
|
|
812
|
+
const list = Array.isArray(value) ? value : [value];
|
|
813
|
+
return list.map((entry) => labels.get(String(entry)) ?? String(entry)).join(', ');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** @returns {void} */
|
|
817
|
+
_paintActions() {
|
|
818
|
+
const item = this._activeItem();
|
|
819
|
+
const review = this._status === 'review';
|
|
820
|
+
const complete = this._status === 'complete';
|
|
821
|
+
// `next()` lets a bad branch target throw; painting must not, or one typo blanks the UI.
|
|
822
|
+
let hasNext = false;
|
|
823
|
+
try {
|
|
824
|
+
hasNext = Boolean(item) && resolveNext(this._items, /** @type {NormalizedItem} */ (item),
|
|
825
|
+
this._rawAnswers()) !== null;
|
|
826
|
+
} catch {
|
|
827
|
+
hasNext = false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
this._back.hidden = complete || (!review && (!this.options.allowBack || this._path.length <= 1));
|
|
831
|
+
this._back.disabled = this._busy;
|
|
832
|
+
this._skip.hidden = review || complete || !item?.skippable;
|
|
833
|
+
this._skip.disabled = this._busy;
|
|
834
|
+
this._forward.hidden = complete;
|
|
835
|
+
this._forward.disabled = this._busy || (!item && !review);
|
|
836
|
+
|
|
837
|
+
const role = review ? 'submit' : this._returnToReview || (!hasNext && this.options.review) ? 'review'
|
|
838
|
+
: hasNext ? 'next' : 'submit';
|
|
839
|
+
if (this._forward.dataset.role !== role) {
|
|
840
|
+
this._forward.dataset.role = role;
|
|
841
|
+
const glyph = role === 'next' ? 'chevron-right' : role === 'review' ? 'list' : 'check';
|
|
842
|
+
this._forward.replaceChildren(createIcon(glyph, { size: 16 }),
|
|
843
|
+
h('span', { class: 'zx-btn__label' }, this.msg(`questionnaire.${role}`)));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* @param {string} action @param {string} label @param {string|null} glyph
|
|
849
|
+
* @param {'default'|'primary'|'ghost'} kind @returns {HTMLButtonElement}
|
|
850
|
+
*/
|
|
851
|
+
_action(action, label, glyph, kind) {
|
|
852
|
+
const element = button({ label, icon: glyph, kind });
|
|
853
|
+
element.dataset.action = action;
|
|
854
|
+
return element;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/* -------------------------------------------------------------- handlers */
|
|
858
|
+
|
|
859
|
+
/** @param {Event} event @returns {void} */
|
|
860
|
+
_onControlChange(event) {
|
|
861
|
+
const control = event.target;
|
|
862
|
+
if (!(control instanceof HTMLInputElement) || !control.classList.contains('zx-questionnaire__control')) return;
|
|
863
|
+
const item = this._activeItem();
|
|
864
|
+
if (!item) return;
|
|
865
|
+
const choice = item.choices[Number(control.dataset.index)];
|
|
866
|
+
if (!choice) return;
|
|
867
|
+
|
|
868
|
+
if (item.multiple) {
|
|
869
|
+
const checked = [...this._itemHost.querySelectorAll('.zx-questionnaire__control')]
|
|
870
|
+
.filter((input) => /** @type {HTMLInputElement} */ (input).checked)
|
|
871
|
+
.map((input) => item.choices[Number(/** @type {HTMLElement} */ (input).dataset.index)].value);
|
|
872
|
+
const values = applyExclusive(item.choices, checked, control.checked ? choice.value : null);
|
|
873
|
+
this._selected.set(item.name, values);
|
|
874
|
+
const keep = new Set(values.map((value) => String(value)));
|
|
875
|
+
for (const input of this._itemHost.querySelectorAll('.zx-questionnaire__control')) {
|
|
876
|
+
/** @type {HTMLInputElement} */ (input).checked = keep.has(/** @type {HTMLInputElement} */ (input).value);
|
|
877
|
+
}
|
|
878
|
+
} else {
|
|
879
|
+
this._selected.set(item.name, [choice.value]);
|
|
880
|
+
// A fixed answer and a freeform one are alternatives, so picking one drops the other.
|
|
881
|
+
this._freeform.set(item.name, '');
|
|
882
|
+
const freeform = this._itemHost.querySelector('.zx-questionnaire__freeform-input');
|
|
883
|
+
if (freeform instanceof HTMLInputElement || freeform instanceof HTMLTextAreaElement) freeform.value = '';
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
this._error = '';
|
|
887
|
+
this._paintError(item);
|
|
888
|
+
this._paintProgress();
|
|
889
|
+
this._paintActions();
|
|
890
|
+
this._emitChange(item);
|
|
891
|
+
this._maybeAutoAdvance(item);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** @param {Event} event @returns {void} */
|
|
895
|
+
_onFreeformInput(event) {
|
|
896
|
+
const control = event.target;
|
|
897
|
+
const isFreeform = (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)
|
|
898
|
+
&& control.classList.contains('zx-questionnaire__freeform-input');
|
|
899
|
+
if (!isFreeform) return;
|
|
900
|
+
const item = this._activeItem();
|
|
901
|
+
if (!item) return;
|
|
902
|
+
|
|
903
|
+
this._freeform.set(item.name, control.value);
|
|
904
|
+
if (!item.multiple && control.value.trim() !== '') {
|
|
905
|
+
this._selected.set(item.name, []);
|
|
906
|
+
for (const input of this._itemHost.querySelectorAll('.zx-questionnaire__control')) {
|
|
907
|
+
/** @type {HTMLInputElement} */ (input).checked = false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
this._error = '';
|
|
911
|
+
this._paintError(item);
|
|
912
|
+
this._paintProgress();
|
|
913
|
+
this._paintActions();
|
|
914
|
+
this._emitChange(item);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** @param {Event} event @returns {void} */
|
|
918
|
+
_onActionClick(event) {
|
|
919
|
+
const target = event.target instanceof Element ? event.target.closest('[data-action]') : null;
|
|
920
|
+
if (!(target instanceof HTMLElement) || !this._actionsHost.contains(target)) return;
|
|
921
|
+
if (target.dataset.action === 'previous') this.previous();
|
|
922
|
+
else if (target.dataset.action === 'skip') void this.skip();
|
|
923
|
+
else void this.next();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** @param {Event} event @returns {void} */
|
|
927
|
+
_onReviewClick(event) {
|
|
928
|
+
const target = event.target instanceof Element ? event.target.closest('[data-edit]') : null;
|
|
929
|
+
if (!(target instanceof HTMLElement)) return;
|
|
930
|
+
this.goTo(String(target.dataset.edit));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
934
|
+
_onKeydown(event) {
|
|
935
|
+
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
936
|
+
const target = event.target;
|
|
937
|
+
const typing = target instanceof HTMLTextAreaElement
|
|
938
|
+
|| (target instanceof HTMLInputElement && !['radio', 'checkbox'].includes(target.type))
|
|
939
|
+
|| (target instanceof HTMLElement && target.isContentEditable);
|
|
940
|
+
|
|
941
|
+
if (event.key === 'Enter') {
|
|
942
|
+
if (target instanceof HTMLTextAreaElement || target instanceof HTMLButtonElement) return;
|
|
943
|
+
event.preventDefault();
|
|
944
|
+
void this.next();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (!this.options.shortcuts || typing || event.key.length !== 1) return;
|
|
948
|
+
|
|
949
|
+
const item = this._activeItem();
|
|
950
|
+
if (!item || this._status !== 'asking') return;
|
|
951
|
+
const wanted = event.key.toLowerCase();
|
|
952
|
+
const index = item.choices.findIndex((choice) => choice.key === wanted && !choice.disabled);
|
|
953
|
+
if (index < 0) return;
|
|
954
|
+
const control = this._itemHost.querySelector(`.zx-questionnaire__control[data-index="${index}"]`);
|
|
955
|
+
if (!(control instanceof HTMLInputElement)) return;
|
|
956
|
+
event.preventDefault();
|
|
957
|
+
control.checked = item.multiple ? !control.checked : true;
|
|
958
|
+
control.focus();
|
|
959
|
+
control.dispatchEvent(new Event('change', { bubbles: true }));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** @param {NormalizedItem} item @returns {void} */
|
|
963
|
+
_maybeAutoAdvance(item) {
|
|
964
|
+
if (this.options.advance !== 'auto' || item.multiple || item.input) return;
|
|
965
|
+
clearTimeout(this._advanceTimer);
|
|
966
|
+
// A beat of delay so the picked answer is seen to register before the question is replaced.
|
|
967
|
+
this._advanceTimer = setTimeout(() => {
|
|
968
|
+
if (!this._destroyed && this._activeItem() === item) void this.next();
|
|
969
|
+
}, AUTO_ADVANCE_DELAY);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** @param {NormalizedItem} item @returns {void} */
|
|
973
|
+
_emitChange(item) {
|
|
974
|
+
const answers = this.getAnswers();
|
|
975
|
+
this.emit('change', { name: item.name, value: this._answerOf(item), answers });
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** @returns {void} */
|
|
979
|
+
_focusItem() {
|
|
980
|
+
if (this._status === 'asking') this._itemHost.focus?.();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** @returns {void} */
|
|
984
|
+
_focusControl() {
|
|
985
|
+
const item = this._activeItem();
|
|
986
|
+
const field = item?.field ? this._fields.get(item.name) : null;
|
|
987
|
+
if (field) {
|
|
988
|
+
field.focus();
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const control = this._itemHost.querySelector(
|
|
992
|
+
'.zx-questionnaire__control:not(:disabled), .zx-questionnaire__freeform-input');
|
|
993
|
+
if (control instanceof HTMLElement) control.focus();
|
|
994
|
+
else this._focusItem();
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* An answer changed.
|
|
1000
|
+
* @event Questionnaire#change
|
|
1001
|
+
* @type {CustomEvent<QuestionnaireChangeDetail>}
|
|
1002
|
+
*/
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* A move between questions, into the review screen, or back. Preventable: call
|
|
1006
|
+
* `event.preventDefault()` to refuse the move.
|
|
1007
|
+
* @event Questionnaire#navigate
|
|
1008
|
+
* @type {CustomEvent<QuestionnaireNavigateDetail>}
|
|
1009
|
+
*/
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* A skippable question was skipped, clearing its answer.
|
|
1013
|
+
* @event Questionnaire#skip
|
|
1014
|
+
* @type {CustomEvent<QuestionnaireSkipDetail>}
|
|
1015
|
+
*/
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Validation of the active question failed.
|
|
1019
|
+
* @event Questionnaire#invalid
|
|
1020
|
+
* @type {CustomEvent<QuestionnaireInvalidDetail>}
|
|
1021
|
+
*/
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* The questionnaire is being submitted. Preventable.
|
|
1025
|
+
* @event Questionnaire#submit
|
|
1026
|
+
* @type {CustomEvent<QuestionnaireSubmitDetail>}
|
|
1027
|
+
*/
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Submission was not prevented and the questionnaire is finished.
|
|
1031
|
+
* @event Questionnaire#complete
|
|
1032
|
+
* @type {CustomEvent<QuestionnaireCompleteDetail>}
|
|
1033
|
+
*/
|