@likerts/web 0.0.3
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/advanced-questions.d.ts +25 -0
- package/dist/advanced-questions.js +31 -0
- package/dist/branching.d.ts +26 -0
- package/dist/branching.js +58 -0
- package/dist/choice-features.d.ts +9 -0
- package/dist/choice-features.js +59 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +591 -0
- package/dist/offline.d.ts +72 -0
- package/dist/offline.js +139 -0
- package/examples/checkout.ts +20 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { choiceAnswer, choiceError } from './choice-features.js';
|
|
2
|
+
import { pageRoute, routedAnswers } from './branching.js';
|
|
3
|
+
import { advancedAnswerError, allocationRemaining, moveRanking, setAllocation, setMatrixChoice } from './advanced-questions.js';
|
|
4
|
+
export { SurveyFlow, pageRoute, routedAnswers } from './branching.js';
|
|
5
|
+
export * from './advanced-questions.js';
|
|
6
|
+
export const LIKERTS_SDK_CAPABILITY = Object.freeze({ target: 'web', sdkVersion: '0.0.3', schemaVersions: [1, 2, 3, 4, 5] });
|
|
7
|
+
export class LikertsError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
response;
|
|
10
|
+
constructor(status, response) {
|
|
11
|
+
super(`Likerts request failed (${status})`);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.response = response;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
17
|
+
const DEFAULT_MESSAGES = Object.freeze({ selectPlaceholder: 'Select an answer', back: 'Back', next: 'Next', progress: 'Page {current} of {total}', submit: 'Submit', submitting: 'Submitting…', submitted: 'Submitted', submissionError: 'Could not confirm submission. Retry without changes to reuse the same submission key.', selectionRange: 'Select between {min} and {max} options.', otherError: 'Enter valid text for the selected Other option.', moveUp: 'Move up', moveDown: 'Move down', remaining: '{remaining} remaining', advancedError: 'Complete this answer.' });
|
|
18
|
+
const DEFAULT_CLASSES = Object.freeze({ form: 'likerts-form', title: 'likerts-title', question: 'likerts-question', label: 'likerts-label', control: 'likerts-control', submit: 'likerts-submit', status: 'likerts-status' });
|
|
19
|
+
function safeBaseURL(value) {
|
|
20
|
+
const url = new URL(value);
|
|
21
|
+
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
|
22
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash)
|
|
23
|
+
throw new TypeError('Likerts base URL must use HTTPS (HTTP is limited to loopback development)');
|
|
24
|
+
return value.replace(/\/$/, '');
|
|
25
|
+
}
|
|
26
|
+
/** Only a public collection credential belongs here. No administrative credentials. */
|
|
27
|
+
export class LikertsClient {
|
|
28
|
+
token;
|
|
29
|
+
defaultTimeoutMs;
|
|
30
|
+
cacheMaxAgeMs;
|
|
31
|
+
maxResponseBytes;
|
|
32
|
+
baseURL;
|
|
33
|
+
transport;
|
|
34
|
+
collectionCache = new Map();
|
|
35
|
+
constructor(baseURL, token, transport = undefined, defaultTimeoutMs = 15000, cacheMaxAgeMs = 300000, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES) {
|
|
36
|
+
this.token = token;
|
|
37
|
+
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
38
|
+
this.cacheMaxAgeMs = cacheMaxAgeMs;
|
|
39
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
40
|
+
this.baseURL = safeBaseURL(baseURL);
|
|
41
|
+
this.transport = transport ?? ((input, init) => globalThis.fetch(input, init));
|
|
42
|
+
if (!Number.isFinite(defaultTimeoutMs) || defaultTimeoutMs <= 0)
|
|
43
|
+
throw new TypeError('timeoutMs must be positive');
|
|
44
|
+
if (!Number.isFinite(cacheMaxAgeMs) || cacheMaxAgeMs < 0)
|
|
45
|
+
throw new TypeError('cacheMaxAgeMs must not be negative');
|
|
46
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
|
|
47
|
+
throw new TypeError('maxResponseBytes must be a positive integer');
|
|
48
|
+
}
|
|
49
|
+
async responseText(response) {
|
|
50
|
+
const declared = Number(response.headers.get('content-length'));
|
|
51
|
+
if (Number.isFinite(declared) && declared > this.maxResponseBytes)
|
|
52
|
+
throw new Error('Likerts response exceeds configured size limit');
|
|
53
|
+
if (!response.body)
|
|
54
|
+
return '';
|
|
55
|
+
const reader = response.body.getReader();
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let total = 0;
|
|
58
|
+
try {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done)
|
|
62
|
+
break;
|
|
63
|
+
total += value.byteLength;
|
|
64
|
+
if (total > this.maxResponseBytes) {
|
|
65
|
+
await reader.cancel();
|
|
66
|
+
throw new Error('Likerts response exceeds configured size limit');
|
|
67
|
+
}
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
reader.releaseLock();
|
|
73
|
+
}
|
|
74
|
+
const body = new Uint8Array(total);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (const chunk of chunks) {
|
|
77
|
+
body.set(chunk, offset);
|
|
78
|
+
offset += chunk.byteLength;
|
|
79
|
+
}
|
|
80
|
+
return new TextDecoder().decode(body);
|
|
81
|
+
}
|
|
82
|
+
async request(path, body, options = {}) {
|
|
83
|
+
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
84
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
85
|
+
throw new TypeError('timeoutMs must be positive');
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
88
|
+
if (options.signal?.aborted)
|
|
89
|
+
abort();
|
|
90
|
+
else
|
|
91
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
92
|
+
const timer = setTimeout(() => controller.abort(new DOMException('Likerts request timed out', 'TimeoutError')), timeoutMs);
|
|
93
|
+
try {
|
|
94
|
+
const response = await this.transport(`${this.baseURL}${path}`, { method: body ? 'POST' : 'GET', redirect: 'error', signal: controller.signal, headers: { Authorization: `Bearer ${this.token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
95
|
+
const text = await this.responseText(response);
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
throw new LikertsError(response.status, text);
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
options.signal?.removeEventListener('abort', abort);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async collection(id, options = {}) {
|
|
106
|
+
const cached = this.collectionCache.get(id);
|
|
107
|
+
if (!options.refresh && cached && Date.now() - cached.storedAt < this.cacheMaxAgeMs)
|
|
108
|
+
return cached.value;
|
|
109
|
+
try {
|
|
110
|
+
const c = await this.request(`/v1/collections/${encodeURIComponent(id)}`, undefined, options);
|
|
111
|
+
if (![1, 2, 3, 4, 5].includes(c.schema.schemaVersion))
|
|
112
|
+
throw new Error('Unsupported survey schema version');
|
|
113
|
+
if (cached && (cached.value.id !== c.id || cached.value.surveyId !== c.surveyId || cached.value.version !== c.version || cached.value.schema.schemaVersion !== c.schema.schemaVersion)) {
|
|
114
|
+
this.collectionCache.delete(id);
|
|
115
|
+
throw new Error('Collection binding changed');
|
|
116
|
+
}
|
|
117
|
+
this.collectionCache.set(id, { value: c, storedAt: Date.now() });
|
|
118
|
+
return c;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.collectionCache.delete(id);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
clearCollectionCache(id) { if (id)
|
|
126
|
+
this.collectionCache.delete(id);
|
|
127
|
+
else
|
|
128
|
+
this.collectionCache.clear(); }
|
|
129
|
+
/** Retrying requires the same submission object, including its idempotencyKey. */
|
|
130
|
+
async submit(id, submission, options) { const receipt = await this.request(`/v1/collections/${encodeURIComponent(id)}/responses`, submission, options); if (receipt.accepted !== true)
|
|
131
|
+
throw new Error('Invalid Likerts receipt'); return receipt; }
|
|
132
|
+
}
|
|
133
|
+
export function conditionMatches(condition, answer) {
|
|
134
|
+
const selected = answer && typeof answer === 'object' && !Array.isArray(answer) && 'selected' in answer && Array.isArray(answer.selected) ? answer.selected : undefined;
|
|
135
|
+
const objectAnswer = answer && typeof answer === 'object' && !Array.isArray(answer);
|
|
136
|
+
const hasAnswer = answer !== undefined && (typeof answer !== 'string' || answer.trim() !== '') && (!Array.isArray(answer) || answer.length > 0) && (!objectAnswer || (selected ? selected.length > 0 : Object.keys(answer).length > 0));
|
|
137
|
+
if (condition.operator === 'answered')
|
|
138
|
+
return hasAnswer;
|
|
139
|
+
if (condition.operator === 'not_answered')
|
|
140
|
+
return !hasAnswer;
|
|
141
|
+
if (answer === undefined || answer === '')
|
|
142
|
+
return false;
|
|
143
|
+
const scalar = selected?.length === 1 ? selected[0] : answer;
|
|
144
|
+
if (condition.operator === 'equals')
|
|
145
|
+
return scalar === condition.value;
|
|
146
|
+
if (condition.operator === 'not_equals')
|
|
147
|
+
return scalar !== condition.value;
|
|
148
|
+
const values = selected ?? (Array.isArray(answer) ? answer : undefined);
|
|
149
|
+
if (!values)
|
|
150
|
+
return false;
|
|
151
|
+
if (condition.operator === 'includes')
|
|
152
|
+
return values.includes(String(condition.value));
|
|
153
|
+
if (condition.operator === 'not_includes')
|
|
154
|
+
return !values.includes(String(condition.value));
|
|
155
|
+
throw new Error('Unsupported visibility operator');
|
|
156
|
+
}
|
|
157
|
+
/** Computes visibility recursively. Hidden source answers are treated as unanswered. */
|
|
158
|
+
export function visibleQuestionIds(questions, answers) {
|
|
159
|
+
const byId = new Map(questions.map(question => [question.id, question]));
|
|
160
|
+
const memo = new Map();
|
|
161
|
+
const active = new Set();
|
|
162
|
+
const visible = (question) => { const prior = memo.get(question.id); if (prior !== undefined)
|
|
163
|
+
return prior; if (active.has(question.id))
|
|
164
|
+
throw new Error('Conditional visibility cycle'); active.add(question.id); const condition = question.visibleWhen; let result = true; if (condition) {
|
|
165
|
+
const source = byId.get(condition.questionId);
|
|
166
|
+
if (!source)
|
|
167
|
+
throw new Error('Conditional visibility references an unknown question');
|
|
168
|
+
const sourceAnswer = visible(source) ? answers[source.id] : undefined;
|
|
169
|
+
result = conditionMatches(condition, sourceAnswer);
|
|
170
|
+
} active.delete(question.id); memo.set(question.id, result); return result; };
|
|
171
|
+
return new Set(questions.filter(visible).map(question => question.id));
|
|
172
|
+
}
|
|
173
|
+
/** Removes answers for hidden questions before validation or submission. */
|
|
174
|
+
export function visibleAnswers(questions, answers) {
|
|
175
|
+
const visible = visibleQuestionIds(questions, answers);
|
|
176
|
+
return Object.fromEntries(Object.entries(answers).filter(([id]) => visible.has(id)));
|
|
177
|
+
}
|
|
178
|
+
/** Customer owns the mount location, trigger, styles and dismissal. Returns cleanup. */
|
|
179
|
+
let mountSequence = 0;
|
|
180
|
+
/** Fixed likerts-* classes and data attributes are stable styling hooks; options add host classes without inline styles. */
|
|
181
|
+
export function mountSurvey(container, collection, client, onComplete, metadata = {}, options = {}) {
|
|
182
|
+
if (![1, 2, 3, 4, 5].includes(collection.schema.schemaVersion))
|
|
183
|
+
throw new Error('Unsupported survey schema version');
|
|
184
|
+
const messages = { ...DEFAULT_MESSAGES };
|
|
185
|
+
for (const key of Object.keys(DEFAULT_MESSAGES)) {
|
|
186
|
+
const value = options.messages?.[key];
|
|
187
|
+
if (typeof value === 'string')
|
|
188
|
+
messages[key] = value;
|
|
189
|
+
}
|
|
190
|
+
const addClasses = (element, base) => { element.classList.add(DEFAULT_CLASSES[base]); const extra = options.classNames?.[base]?.trim(); if (extra)
|
|
191
|
+
element.classList.add(...extra.split(/\s+/)); };
|
|
192
|
+
const prefix = `likerts-${++mountSequence}`;
|
|
193
|
+
const form = document.createElement('form');
|
|
194
|
+
addClasses(form, 'form');
|
|
195
|
+
form.dataset.likertsCollection = collection.id;
|
|
196
|
+
const title = document.createElement('h2');
|
|
197
|
+
addClasses(title, 'title');
|
|
198
|
+
title.id = `${prefix}-title`;
|
|
199
|
+
title.textContent = collection.schema.title;
|
|
200
|
+
form.setAttribute('aria-labelledby', title.id);
|
|
201
|
+
form.append(title);
|
|
202
|
+
const fields = new Map();
|
|
203
|
+
const wrappers = new Map();
|
|
204
|
+
const otherFields = new Map();
|
|
205
|
+
const starFields = new Map();
|
|
206
|
+
const previousSelections = new Map();
|
|
207
|
+
const advancedAnswers = {};
|
|
208
|
+
for (const q of collection.schema.questions) {
|
|
209
|
+
const wrapper = document.createElement('div');
|
|
210
|
+
addClasses(wrapper, 'question');
|
|
211
|
+
wrapper.dataset.likertsQuestion = q.id;
|
|
212
|
+
wrapper.dataset.likertsType = q.type;
|
|
213
|
+
const label = document.createElement('label');
|
|
214
|
+
addClasses(label, 'label');
|
|
215
|
+
label.textContent = q.label;
|
|
216
|
+
let input;
|
|
217
|
+
if (q.type === 'ranking' || q.type === 'matrix' || q.type === 'constant_sum') {
|
|
218
|
+
const field = document.createElement('input');
|
|
219
|
+
field.type = 'hidden';
|
|
220
|
+
input = field;
|
|
221
|
+
if (q.type === 'ranking') {
|
|
222
|
+
const list = document.createElement('ol');
|
|
223
|
+
let order = (q.options ?? []).map(option => option.id);
|
|
224
|
+
const render = () => { list.replaceChildren(); for (const [index, id] of order.entries()) {
|
|
225
|
+
const option = q.options?.find(value => value.id === id);
|
|
226
|
+
const row = document.createElement('li');
|
|
227
|
+
row.textContent = option.label;
|
|
228
|
+
const up = document.createElement('button');
|
|
229
|
+
up.type = 'button';
|
|
230
|
+
up.textContent = messages.moveUp;
|
|
231
|
+
up.disabled = index === 0;
|
|
232
|
+
up.setAttribute('aria-label', `${messages.moveUp}: ${option.label}`);
|
|
233
|
+
up.dataset.likertsMove = 'up';
|
|
234
|
+
const down = document.createElement('button');
|
|
235
|
+
down.type = 'button';
|
|
236
|
+
down.textContent = messages.moveDown;
|
|
237
|
+
down.disabled = index === order.length - 1;
|
|
238
|
+
down.setAttribute('aria-label', `${messages.moveDown}: ${option.label}`);
|
|
239
|
+
down.dataset.likertsMove = 'down';
|
|
240
|
+
up.addEventListener('click', () => { order = moveRanking(q, order, id, -1); advancedAnswers[q.id] = order; render(); edited(); });
|
|
241
|
+
down.addEventListener('click', () => { order = moveRanking(q, order, id, 1); advancedAnswers[q.id] = order; render(); edited(); });
|
|
242
|
+
row.append(up, down);
|
|
243
|
+
list.append(row);
|
|
244
|
+
} };
|
|
245
|
+
render();
|
|
246
|
+
wrapper.append(list);
|
|
247
|
+
}
|
|
248
|
+
else if (q.type === 'matrix') {
|
|
249
|
+
for (const row of q.rows ?? []) {
|
|
250
|
+
const group = document.createElement('fieldset');
|
|
251
|
+
const legend = document.createElement('legend');
|
|
252
|
+
legend.textContent = row.label;
|
|
253
|
+
group.append(legend);
|
|
254
|
+
for (const column of q.columns ?? []) {
|
|
255
|
+
const choice = document.createElement('label'), control = document.createElement('input');
|
|
256
|
+
control.type = q.matrixMode === 'single' ? 'radio' : 'checkbox';
|
|
257
|
+
control.name = q.matrixMode === 'single' ? `${prefix}-${q.id}-${row.id}` : `${prefix}-${q.id}-${row.id}-${column.id}`;
|
|
258
|
+
control.value = column.id;
|
|
259
|
+
control.addEventListener('change', () => { advancedAnswers[q.id] = setMatrixChoice(q, advancedAnswers[q.id], row.id, column.id); edited(); });
|
|
260
|
+
choice.append(control, document.createTextNode(column.label));
|
|
261
|
+
group.append(choice);
|
|
262
|
+
}
|
|
263
|
+
wrapper.append(group);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const remaining = document.createElement('p');
|
|
268
|
+
remaining.setAttribute('role', 'status');
|
|
269
|
+
remaining.setAttribute('aria-live', 'polite');
|
|
270
|
+
const refresh = () => remaining.textContent = messages.remaining.replace('{remaining}', String(allocationRemaining(q, advancedAnswers[q.id])));
|
|
271
|
+
for (const item of q.items ?? []) {
|
|
272
|
+
const itemLabel = document.createElement('label');
|
|
273
|
+
itemLabel.textContent = item.label;
|
|
274
|
+
const control = document.createElement('input');
|
|
275
|
+
control.type = 'number';
|
|
276
|
+
control.min = '0';
|
|
277
|
+
control.max = String(q.total ?? 0);
|
|
278
|
+
control.step = '1';
|
|
279
|
+
control.addEventListener('input', () => { const current = advancedAnswers[q.id] && typeof advancedAnswers[q.id] === 'object' && !Array.isArray(advancedAnswers[q.id]) ? advancedAnswers[q.id] : {}; if (control.value === '') {
|
|
280
|
+
const next = { ...current };
|
|
281
|
+
delete next[item.id];
|
|
282
|
+
advancedAnswers[q.id] = next;
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
advancedAnswers[q.id] = setAllocation(current, item.id, Number(control.value)); refresh(); edited(); });
|
|
286
|
+
itemLabel.append(control);
|
|
287
|
+
wrapper.append(itemLabel);
|
|
288
|
+
}
|
|
289
|
+
refresh();
|
|
290
|
+
wrapper.append(remaining);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else if (q.presentation === 'stars') {
|
|
294
|
+
const field = document.createElement('input');
|
|
295
|
+
field.type = 'hidden';
|
|
296
|
+
input = field;
|
|
297
|
+
const group = document.createElement('div');
|
|
298
|
+
group.setAttribute('role', 'radiogroup');
|
|
299
|
+
group.setAttribute('aria-label', q.label);
|
|
300
|
+
const radios = [];
|
|
301
|
+
for (let value = q.min ?? 1; value <= (q.max ?? 5); value++) {
|
|
302
|
+
const option = document.createElement('label');
|
|
303
|
+
const radio = document.createElement('input');
|
|
304
|
+
radio.type = 'radio';
|
|
305
|
+
radio.name = `${prefix}-stars-${q.id}`;
|
|
306
|
+
radio.value = String(value);
|
|
307
|
+
radio.setAttribute('aria-label', q.labels?.[String(value)] ? `${value} — ${q.labels[String(value)]}` : String(value));
|
|
308
|
+
radio.required = !!q.required;
|
|
309
|
+
radio.addEventListener('change', () => { if (radio.checked)
|
|
310
|
+
field.value = radio.value; });
|
|
311
|
+
option.append(radio, document.createTextNode('★'.repeat(value)));
|
|
312
|
+
group.append(option);
|
|
313
|
+
radios.push(radio);
|
|
314
|
+
}
|
|
315
|
+
starFields.set(q.id, radios);
|
|
316
|
+
wrapper.append(group);
|
|
317
|
+
}
|
|
318
|
+
else if (q.type === 'single_choice' || q.type === 'multiple_choice' || (q.type === 'scale' && (q.labels !== undefined || q.preset === 'nps'))) {
|
|
319
|
+
const select = document.createElement('select');
|
|
320
|
+
select.multiple = q.type === 'multiple_choice';
|
|
321
|
+
if (!select.multiple) {
|
|
322
|
+
const blank = document.createElement('option');
|
|
323
|
+
blank.value = '';
|
|
324
|
+
blank.textContent = messages.selectPlaceholder;
|
|
325
|
+
select.append(blank);
|
|
326
|
+
}
|
|
327
|
+
const choices = q.type === 'scale'
|
|
328
|
+
? Array.from({ length: (q.max ?? 0) - (q.min ?? 0) + 1 }, (_, i) => { const value = (q.min ?? 0) + i; return { id: String(value), label: q.labels?.[String(value)] ? `${value} — ${q.labels[String(value)]}` : String(value) }; })
|
|
329
|
+
: q.options ?? [];
|
|
330
|
+
for (const choice of choices) {
|
|
331
|
+
const option = document.createElement('option');
|
|
332
|
+
option.value = choice.id;
|
|
333
|
+
option.textContent = choice.label;
|
|
334
|
+
select.append(option);
|
|
335
|
+
}
|
|
336
|
+
input = select;
|
|
337
|
+
}
|
|
338
|
+
else if (q.type === 'text') {
|
|
339
|
+
const text = document.createElement('textarea');
|
|
340
|
+
if (q.maxLength !== undefined)
|
|
341
|
+
text.maxLength = q.maxLength;
|
|
342
|
+
input = text;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const field = document.createElement('input');
|
|
346
|
+
field.type = q.type === 'date' ? 'date' : 'number';
|
|
347
|
+
field.step = q.type === 'scale' ? '1' : 'any';
|
|
348
|
+
if (q.min !== undefined)
|
|
349
|
+
field.min = String(q.min);
|
|
350
|
+
if (q.max !== undefined)
|
|
351
|
+
field.max = String(q.max);
|
|
352
|
+
input = field;
|
|
353
|
+
}
|
|
354
|
+
input.name = q.id;
|
|
355
|
+
input.id = `${prefix}-${fields.size}`;
|
|
356
|
+
input.required = !!q.required;
|
|
357
|
+
addClasses(input, 'control');
|
|
358
|
+
label.htmlFor = input.id;
|
|
359
|
+
wrapper.append(label, input);
|
|
360
|
+
form.append(wrapper);
|
|
361
|
+
fields.set(q.id, input);
|
|
362
|
+
wrappers.set(q.id, wrapper);
|
|
363
|
+
const other = q.options?.find(o => o.other !== undefined);
|
|
364
|
+
if (other) {
|
|
365
|
+
const field = document.createElement('input');
|
|
366
|
+
field.type = 'text';
|
|
367
|
+
field.id = `${input.id}-other`;
|
|
368
|
+
field.name = `${q.id}.otherText`;
|
|
369
|
+
field.hidden = true;
|
|
370
|
+
addClasses(field, 'control');
|
|
371
|
+
const otherLabel = document.createElement('label');
|
|
372
|
+
otherLabel.htmlFor = field.id;
|
|
373
|
+
otherLabel.textContent = `${q.label}: ${other.label}`;
|
|
374
|
+
otherLabel.hidden = true;
|
|
375
|
+
wrapper.append(otherLabel, field);
|
|
376
|
+
otherFields.set(q.id, field);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const pages = collection.schema.pages ?? [{ id: 'survey', questionIds: collection.schema.questions.map(q => q.id) }];
|
|
380
|
+
let currentPage = 0;
|
|
381
|
+
let history = [0];
|
|
382
|
+
const progress = document.createElement('p');
|
|
383
|
+
progress.dataset.likertsProgress = '';
|
|
384
|
+
progress.setAttribute('aria-live', 'polite');
|
|
385
|
+
const back = document.createElement('button');
|
|
386
|
+
back.type = 'button';
|
|
387
|
+
back.textContent = messages.back;
|
|
388
|
+
back.dataset.likertsBack = '';
|
|
389
|
+
const next = document.createElement('button');
|
|
390
|
+
next.type = 'button';
|
|
391
|
+
next.textContent = messages.next;
|
|
392
|
+
next.dataset.likertsNext = '';
|
|
393
|
+
const submit = document.createElement('button');
|
|
394
|
+
submit.type = 'submit';
|
|
395
|
+
submit.textContent = messages.submit;
|
|
396
|
+
addClasses(submit, 'submit');
|
|
397
|
+
const status = document.createElement('p');
|
|
398
|
+
status.id = `${prefix}-status`;
|
|
399
|
+
status.setAttribute('role', 'status');
|
|
400
|
+
status.setAttribute('aria-live', 'polite');
|
|
401
|
+
status.tabIndex = -1;
|
|
402
|
+
addClasses(status, 'status');
|
|
403
|
+
form.setAttribute('aria-describedby', status.id);
|
|
404
|
+
if (collection.schema.pages)
|
|
405
|
+
form.append(progress, back, next);
|
|
406
|
+
form.append(submit, status);
|
|
407
|
+
let pending;
|
|
408
|
+
let completed = false;
|
|
409
|
+
let disposed = false;
|
|
410
|
+
let active;
|
|
411
|
+
const readAnswers = () => {
|
|
412
|
+
const answers = {};
|
|
413
|
+
for (const q of collection.schema.questions) {
|
|
414
|
+
const input = fields.get(q.id);
|
|
415
|
+
if (q.type === 'ranking' || q.type === 'matrix' || q.type === 'constant_sum') {
|
|
416
|
+
if (advancedAnswers[q.id] !== undefined)
|
|
417
|
+
answers[q.id] = advancedAnswers[q.id];
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (q.type === 'single_choice' || q.type === 'multiple_choice') {
|
|
421
|
+
const selected = input instanceof HTMLSelectElement && input.multiple ? Array.from(input.selectedOptions, o => o.value) : input.value ? [input.value] : [];
|
|
422
|
+
const other = q.options?.find(o => o.other !== undefined);
|
|
423
|
+
if (selected.length)
|
|
424
|
+
answers[q.id] = choiceAnswer(q, selected, other ? { [other.id]: otherFields.get(q.id)?.value ?? '' } : {});
|
|
425
|
+
}
|
|
426
|
+
else if (input.value !== '')
|
|
427
|
+
answers[q.id] = q.type === 'number' || q.type === 'scale' ? Number(input.value) : input.value;
|
|
428
|
+
}
|
|
429
|
+
return answers;
|
|
430
|
+
};
|
|
431
|
+
const refreshVisibility = () => {
|
|
432
|
+
for (let pass = 0; pass <= collection.schema.questions.length; pass++) {
|
|
433
|
+
const answers = readAnswers(), visible = visibleQuestionIds(collection.schema.questions, answers), route = pageRoute(collection.schema, answers);
|
|
434
|
+
let changed = false;
|
|
435
|
+
if (!route.includes(currentPage)) {
|
|
436
|
+
let shared = route[0] ?? 0;
|
|
437
|
+
for (let i = 0; i < Math.min(history.length, route.length) && history[i] === route[i]; i++)
|
|
438
|
+
shared = route[i];
|
|
439
|
+
currentPage = shared;
|
|
440
|
+
history = route.slice(0, route.indexOf(currentPage) + 1);
|
|
441
|
+
}
|
|
442
|
+
const reached = new Set(route.flatMap(index => pages[index].questionIds));
|
|
443
|
+
const currentQuestions = new Set(pages[currentPage]?.questionIds ?? []);
|
|
444
|
+
for (const q of collection.schema.questions) {
|
|
445
|
+
const input = fields.get(q.id);
|
|
446
|
+
const eligible = visible.has(q.id) && reached.has(q.id), shown = eligible && currentQuestions.has(q.id);
|
|
447
|
+
wrappers.get(q.id).hidden = !shown;
|
|
448
|
+
input.disabled = !shown;
|
|
449
|
+
input.required = shown && !!q.required;
|
|
450
|
+
if (!eligible && advancedAnswers[q.id] !== undefined) {
|
|
451
|
+
delete advancedAnswers[q.id];
|
|
452
|
+
changed = true;
|
|
453
|
+
}
|
|
454
|
+
if (!eligible && (input.value !== '' || (input instanceof HTMLSelectElement && input.selectedOptions.length))) {
|
|
455
|
+
if (input instanceof HTMLSelectElement)
|
|
456
|
+
Array.from(input.options).forEach(option => option.selected = false);
|
|
457
|
+
else
|
|
458
|
+
input.value = '';
|
|
459
|
+
changed = true;
|
|
460
|
+
}
|
|
461
|
+
for (const radio of starFields.get(q.id) ?? []) {
|
|
462
|
+
radio.disabled = !shown;
|
|
463
|
+
radio.required = shown && !!q.required;
|
|
464
|
+
if (!eligible)
|
|
465
|
+
radio.checked = false;
|
|
466
|
+
}
|
|
467
|
+
const other = q.options?.find(o => o.other !== undefined), text = otherFields.get(q.id);
|
|
468
|
+
if (other && text) {
|
|
469
|
+
const selected = input instanceof HTMLSelectElement ? Array.from(input.selectedOptions, o => o.value) : [];
|
|
470
|
+
const selectedOther = eligible && selected.includes(other.id), displayed = shown && selectedOther;
|
|
471
|
+
text.hidden = !displayed;
|
|
472
|
+
text.disabled = !displayed;
|
|
473
|
+
text.required = displayed;
|
|
474
|
+
const otherLabel = text.previousElementSibling;
|
|
475
|
+
if (otherLabel)
|
|
476
|
+
otherLabel.hidden = !displayed;
|
|
477
|
+
if (!selectedOther) {
|
|
478
|
+
text.value = '';
|
|
479
|
+
text.setCustomValidity('');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!changed) {
|
|
484
|
+
const routeIndex = route.indexOf(currentPage);
|
|
485
|
+
progress.textContent = messages.progress.replace('{current}', String(currentPage + 1)).replace('{total}', String(pages.length));
|
|
486
|
+
back.hidden = history.length < 2;
|
|
487
|
+
next.hidden = routeIndex < 0 || routeIndex + 1 >= route.length;
|
|
488
|
+
submit.hidden = !next.hidden;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const validateSelections = () => {
|
|
494
|
+
const answers = readAnswers(), visible = visibleQuestionIds(collection.schema.questions, answers), currentQuestions = new Set(pages[currentPage]?.questionIds ?? []);
|
|
495
|
+
for (const q of collection.schema.questions) {
|
|
496
|
+
if (!visible.has(q.id) || !currentQuestions.has(q.id) || !['ranking', 'matrix', 'constant_sum'].includes(q.type))
|
|
497
|
+
continue;
|
|
498
|
+
if (advancedAnswerError(q, answers[q.id])) {
|
|
499
|
+
status.setAttribute('role', 'alert');
|
|
500
|
+
status.textContent = `${q.label}: ${messages.advancedError}`;
|
|
501
|
+
status.focus();
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const q of collection.schema.questions) {
|
|
506
|
+
if (q.type !== 'single_choice' && q.type !== 'multiple_choice')
|
|
507
|
+
continue;
|
|
508
|
+
const field = fields.get(q.id);
|
|
509
|
+
const code = visible.has(q.id) && currentQuestions.has(q.id) ? choiceError(q, answers[q.id]) : undefined;
|
|
510
|
+
const min = Math.max(q.required ? 1 : 0, q.minSelections ?? 0), max = q.maxSelections ?? q.options?.length ?? 0;
|
|
511
|
+
field.setCustomValidity(code && code !== 'required' ? (code === 'other' ? messages.otherError : messages.selectionRange.replace('{min}', String(min)).replace('{max}', String(max))) : '');
|
|
512
|
+
}
|
|
513
|
+
return true;
|
|
514
|
+
};
|
|
515
|
+
back.addEventListener('click', () => { if (history.length < 2)
|
|
516
|
+
return; history.pop(); currentPage = history[history.length - 1]; refreshVisibility(); });
|
|
517
|
+
next.addEventListener('click', () => { if (!validateSelections() || !form.reportValidity())
|
|
518
|
+
return; const route = pageRoute(collection.schema, readAnswers()), at = route.indexOf(currentPage); if (at >= 0 && at + 1 < route.length) {
|
|
519
|
+
currentPage = route[at + 1];
|
|
520
|
+
history = route.slice(0, at + 2);
|
|
521
|
+
refreshVisibility();
|
|
522
|
+
} });
|
|
523
|
+
const edited = () => {
|
|
524
|
+
pending = undefined;
|
|
525
|
+
for (const q of collection.schema.questions) {
|
|
526
|
+
if (q.type !== 'multiple_choice')
|
|
527
|
+
continue;
|
|
528
|
+
const select = fields.get(q.id);
|
|
529
|
+
let selected = Array.from(select.selectedOptions, o => o.value);
|
|
530
|
+
const prior = previousSelections.get(q.id) ?? [], added = selected.filter(id => !prior.includes(id));
|
|
531
|
+
const exclusive = q.options?.find(o => o.exclusive)?.id;
|
|
532
|
+
if (exclusive && selected.includes(exclusive) && selected.length > 1) {
|
|
533
|
+
selected = added.includes(exclusive) ? [exclusive] : selected.filter(id => id !== exclusive);
|
|
534
|
+
for (const option of Array.from(select.options))
|
|
535
|
+
option.selected = selected.includes(option.value);
|
|
536
|
+
}
|
|
537
|
+
previousSelections.set(q.id, selected);
|
|
538
|
+
}
|
|
539
|
+
refreshVisibility();
|
|
540
|
+
validateSelections();
|
|
541
|
+
};
|
|
542
|
+
const setDisabled = (disabled) => { for (const control of Array.from(form.querySelectorAll('input,select,textarea,button')))
|
|
543
|
+
control.disabled = disabled; };
|
|
544
|
+
form.addEventListener('input', edited);
|
|
545
|
+
form.addEventListener('change', edited);
|
|
546
|
+
form.addEventListener('submit', async (event) => {
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
if (submit.disabled || completed)
|
|
549
|
+
return;
|
|
550
|
+
if (!validateSelections() || !form.reportValidity())
|
|
551
|
+
return;
|
|
552
|
+
if (!pending) {
|
|
553
|
+
const answers = routedAnswers(collection.schema, readAnswers());
|
|
554
|
+
pending = { idempotencyKey: crypto.randomUUID(), answers, metadata };
|
|
555
|
+
}
|
|
556
|
+
submit.disabled = true;
|
|
557
|
+
setDisabled(true);
|
|
558
|
+
submit.textContent = messages.submitting;
|
|
559
|
+
status.textContent = messages.submitting;
|
|
560
|
+
status.setAttribute('role', 'status');
|
|
561
|
+
active = new AbortController();
|
|
562
|
+
try {
|
|
563
|
+
const receipt = await client.submit(collection.id, pending, { signal: active.signal });
|
|
564
|
+
if (disposed)
|
|
565
|
+
return;
|
|
566
|
+
completed = true;
|
|
567
|
+
submit.textContent = messages.submitted;
|
|
568
|
+
status.textContent = messages.submitted;
|
|
569
|
+
onComplete(receipt);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
if (!disposed) {
|
|
573
|
+
status.setAttribute('role', 'alert');
|
|
574
|
+
status.textContent = messages.submissionError;
|
|
575
|
+
status.focus();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
active = undefined;
|
|
580
|
+
if (!disposed && !completed) {
|
|
581
|
+
submit.disabled = false;
|
|
582
|
+
submit.textContent = messages.submit;
|
|
583
|
+
setDisabled(false);
|
|
584
|
+
refreshVisibility();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
refreshVisibility();
|
|
589
|
+
container.append(form);
|
|
590
|
+
return () => { disposed = true; active?.abort(); form.remove(); };
|
|
591
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Receipt, Submission } from './index.js';
|
|
2
|
+
export type OfflineReason = 'invalid' | 'conflict' | 'unauthorized' | 'revoked' | 'deleted' | 'expired';
|
|
3
|
+
export type OfflineState = 'pending' | 'blocked' | 'expired_local';
|
|
4
|
+
export interface OfflineLimits {
|
|
5
|
+
maxRecords: number;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
maxAgeSeconds: number;
|
|
8
|
+
}
|
|
9
|
+
export interface OfflineStatus {
|
|
10
|
+
pending: number;
|
|
11
|
+
blockedByReason: Partial<Record<OfflineReason, number>>;
|
|
12
|
+
expiredLocal: number;
|
|
13
|
+
quarantined: number;
|
|
14
|
+
bytes: number;
|
|
15
|
+
}
|
|
16
|
+
export interface OfflineOutcome {
|
|
17
|
+
recordId: string;
|
|
18
|
+
outcome: 'accepted' | 'retry' | 'blocked' | 'expired_local' | 'quarantined' | 'credential_unavailable';
|
|
19
|
+
reason?: OfflineReason;
|
|
20
|
+
retryAfterSeconds?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface FlushReport {
|
|
23
|
+
attempted: number;
|
|
24
|
+
accepted: number;
|
|
25
|
+
pending: number;
|
|
26
|
+
blocked: number;
|
|
27
|
+
expiredLocal: number;
|
|
28
|
+
quarantined: number;
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
outcomes: OfflineOutcome[];
|
|
31
|
+
}
|
|
32
|
+
export interface OpaqueQueueStore {
|
|
33
|
+
load(): Promise<Uint8Array[]>;
|
|
34
|
+
replace(records: readonly Uint8Array[]): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface QueueCipher {
|
|
37
|
+
seal(cleartext: Uint8Array): Promise<Uint8Array>;
|
|
38
|
+
open(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
export interface OfflineSendResult {
|
|
41
|
+
status: number;
|
|
42
|
+
receipt?: Receipt;
|
|
43
|
+
retryAfterSeconds?: number;
|
|
44
|
+
}
|
|
45
|
+
export type OfflineSender = (collectionId: string, credential: string, submission: Submission, signal?: AbortSignal) => Promise<OfflineSendResult>;
|
|
46
|
+
export declare class AesGcmCipher implements QueueCipher {
|
|
47
|
+
private key;
|
|
48
|
+
private cryptoApi;
|
|
49
|
+
constructor(key: CryptoKey, cryptoApi?: Crypto);
|
|
50
|
+
seal(cleartext: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
|
|
51
|
+
open(ciphertext: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
|
|
52
|
+
static generate(cryptoApi?: Crypto): Promise<AesGcmCipher>;
|
|
53
|
+
}
|
|
54
|
+
/** IndexedDB stores only opaque ciphertext. The non-extractable CryptoKey is in a separate object store. */
|
|
55
|
+
export declare function openIndexedDbOfflineQueue(name: string, sender: OfflineSender, configuration?: Partial<OfflineLimits>): Promise<OfflineQueue>;
|
|
56
|
+
export declare class OfflineQueue {
|
|
57
|
+
private store;
|
|
58
|
+
private cipher;
|
|
59
|
+
private sender;
|
|
60
|
+
private now;
|
|
61
|
+
private readonly configuration;
|
|
62
|
+
private flushing;
|
|
63
|
+
constructor(store: OpaqueQueueStore, cipher: QueueCipher, sender: OfflineSender, configuration?: Partial<OfflineLimits>, now?: () => number);
|
|
64
|
+
private read;
|
|
65
|
+
private write;
|
|
66
|
+
enqueue(collectionId: string, submission: Submission): Promise<string>;
|
|
67
|
+
snapshot(): Promise<OfflineStatus>;
|
|
68
|
+
delete(recordId: string): Promise<void>;
|
|
69
|
+
deleteCollection(collectionId: string): Promise<number>;
|
|
70
|
+
purgeQuarantined(): Promise<number>;
|
|
71
|
+
flush(resolveCredential: (collectionId: string) => Promise<string | undefined>, signal?: AbortSignal): Promise<FlushReport>;
|
|
72
|
+
}
|