@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.3 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
- Schema 5 adds ranking, single/multiple matrix and exact-total constant-sum questions with bounded answer schemas.
|
|
6
|
+
- The renderer uses explicit rank movement, stacked mobile-safe matrix controls and an announced constant-sum remainder.
|
|
7
|
+
|
|
8
|
+
## 0.0.2 — 2026-09-09
|
|
9
|
+
|
|
10
|
+
- Schema 3 adds conditional visibility, structured Other text, exclusive None choices, stars and dropdown presentations. Existing schema 1/2 answers remain compatible.
|
|
11
|
+
- Schema 4 adds pages, forward routing, page validation, Back/Next navigation, progress and removal of skipped answers.
|
|
12
|
+
- Host-controlled triggers, submission and distribution remain unchanged. No package publication.
|
|
13
|
+
|
|
14
|
+
## 0.0.1 — 2026-09-08
|
|
15
|
+
|
|
16
|
+
- Collection-only HTTPS client, cancellable requests, bounded response bodies and five-minute immutable configuration cache.
|
|
17
|
+
- DOM renderer for six question types, NPS/yes-no presets, scale labels and selection bounds, with host CSS/localization hooks.
|
|
18
|
+
- Customer-controlled checkout trigger example and compiled JavaScript/TypeScript declaration artifact.
|
|
19
|
+
|
|
20
|
+
Local evaluation artifact. Package publication and broader browser/device release checks remain separate.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Likerts contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Likerts Web SDK
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
Install a published version from npm:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @likerts/web
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You can also use the installable tarballs from the [community release](https://github.com/crosstabs/likerts/releases/tag/community-v0.1.0).
|
|
12
|
+
|
|
13
|
+
## Install the current source
|
|
14
|
+
|
|
15
|
+
The free community edition supports schemas 1–5. Clone the repository and run these commands from its root:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm ci --prefix sdks/web
|
|
19
|
+
npm run build --prefix sdks/web
|
|
20
|
+
cd sdks/web
|
|
21
|
+
npm pack
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run `npm install /absolute/path/to/likerts/sdks/web/likerts-web-0.0.3.tgz` in your application. This installs the tarball you just built, containing compiled ESM and TypeScript declarations; it needs no repository-relative imports or build step in the consumer. Use a modern browser with Fetch, AbortController and crypto.randomUUID, and bundle the module with your host application. Frozen archives under `releases/` predate the free edition; build current source rather than installing those historical packages.
|
|
25
|
+
|
|
26
|
+
`examples/checkout.ts` supplies a customer-owned button trigger that loads/mounts feedback only when clicked and returns cleanup for navigation. Call it after your own eligibility/consent checks. The client should receive only a collection token. Build/test commands below refer to a source checkout, not the installed tarball.
|
|
27
|
+
|
|
28
|
+
The Web package fetches a public collection configuration and renders it inside a customer-owned element. The customer decides when to mount it, which first-party page contains it, and when to remove it. Never put a management credential in browser code.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import {LIKERTS_SDK_CAPABILITY, LikertsClient, mountSurvey} from '@likerts/web';
|
|
32
|
+
|
|
33
|
+
const client = new LikertsClient('https://feedback.example.com', publicCollectionToken);
|
|
34
|
+
const collection = await client.collection(collectionId);
|
|
35
|
+
const cleanup = mountSurvey(
|
|
36
|
+
document.querySelector('#feedback')!,
|
|
37
|
+
collection,
|
|
38
|
+
client,
|
|
39
|
+
receipt => console.log(receipt.responseId),
|
|
40
|
+
{orderStage: 'after_payment'},
|
|
41
|
+
{
|
|
42
|
+
messages: {submit: 'Send feedback', submitted: 'Thank you'},
|
|
43
|
+
classNames: {form: 'my-survey', submit: 'my-button'},
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Publish and collection-management requests declare `LIKERTS_SDK_CAPABILITY` for every Web installation group. A collection client caches configuration for five minutes. Use `collection(id, {refresh: true})` before presentation when fresh configuration is required, and `clearCollectionCache()` when ending the client lifecycle.
|
|
49
|
+
|
|
50
|
+
## Styling and localization
|
|
51
|
+
|
|
52
|
+
The renderer emits stable `likerts-*` classes for the form, title, question, label, control, submit button and status. Question wrappers also expose `data-likerts-question` and `data-likerts-type`. Supply extra host classes through `classNames`; the SDK never inserts inline styles. Supply translated UI copy through `messages`. Survey titles, question labels and choices remain customer-authored collection content.
|
|
53
|
+
|
|
54
|
+
The form associates every label and control, exposes title and status relationships, follows native keyboard order, uses native constraint feedback, announces progress, and moves focus to a submission alert after a server failure. `cleanup` aborts an active request, removes the form and suppresses late completion.
|
|
55
|
+
|
|
56
|
+
## Browser deployment
|
|
57
|
+
|
|
58
|
+
The client requires HTTPS except on exact loopback development hosts. Customers may use a same-origin proxy or configure exact HTTPS origins with `collections_security_update` for direct browser calls. The API answers bounded preflight requests and echoes only configured origins; the bearer collection credential, expiry, caps and revocation remain authoritative because an origin is never an authentication boundary.
|
|
59
|
+
|
|
60
|
+
The renderer works under a strict CSP without `unsafe-inline` or `unsafe-eval`. Load the SDK and customer CSS as external same-origin resources and allow the API origin in `connect-src` when it differs. The client rejects redirects, limits each response body to 256 KiB by default, and permits a smaller limit through the final constructor argument.
|
|
61
|
+
|
|
62
|
+
Run `npm run build && node --test test/*.test.mjs` for component checks. From the repository root, `scripts/check-web-browser.sh` builds the real backend and uses Chromium to verify accessible labels, keyboard-only completion, strict CSP, host styling/localization and accepted-response accounting through a same-origin proxy.
|
|
63
|
+
|
|
64
|
+
Schema 3 choice semantics are documented in [CHOICE-FEATURES.md](../../contracts/CHOICE-FEATURES.md): Other answers carry separate selected IDs and text; None is exclusive; stars retain numeric answers and dropdowns retain option IDs.
|
|
65
|
+
|
|
66
|
+
Schema 5 adds the bounded advanced families in [ADVANCED-QUESTIONS.md](../../contracts/ADVANCED-QUESTIONS.md). Ranking answers are complete option-ID permutations and use explicit Move up/down buttons. Matrix answers map each answered row ID to one column ID or a nonempty list of column IDs and render as stacked fieldsets. Constant-sum answers contain every item ID, accept nonnegative integers and must equal the configured total; the remaining amount is announced while editing.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type RankingAnswer = string[];
|
|
2
|
+
export type MatrixAnswer = Record<string, string | string[]>;
|
|
3
|
+
export type ConstantSumAnswer = Record<string, number>;
|
|
4
|
+
export type AdvancedAnswer = RankingAnswer | MatrixAnswer | ConstantSumAnswer;
|
|
5
|
+
export interface AdvancedItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AdvancedQuestion {
|
|
10
|
+
type: string;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
options?: AdvancedItem[];
|
|
13
|
+
rows?: AdvancedItem[];
|
|
14
|
+
columns?: AdvancedItem[];
|
|
15
|
+
matrixMode?: 'single' | 'multiple';
|
|
16
|
+
items?: AdvancedItem[];
|
|
17
|
+
total?: number;
|
|
18
|
+
}
|
|
19
|
+
export type AdvancedAnswerError = 'required' | 'ranking' | 'matrix' | 'constant_sum';
|
|
20
|
+
export declare const rankingOrder: (question: AdvancedQuestion, answer: unknown) => string[];
|
|
21
|
+
export declare function moveRanking(question: AdvancedQuestion, answer: unknown, id: string, offset: -1 | 1): string[];
|
|
22
|
+
export declare function setMatrixChoice(question: AdvancedQuestion, answer: unknown, rowId: string, columnId: string): MatrixAnswer;
|
|
23
|
+
export declare function setAllocation(answer: unknown, itemId: string, value: number): ConstantSumAnswer;
|
|
24
|
+
export declare function allocationRemaining(question: AdvancedQuestion, answer: unknown): number;
|
|
25
|
+
export declare function advancedAnswerError(question: AdvancedQuestion, answer: unknown): AdvancedAnswerError | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const rankingOrder = (question, answer) => Array.isArray(answer) ? answer.filter((id) => typeof id === 'string') : (question.options ?? []).map(option => option.id);
|
|
2
|
+
export function moveRanking(question, answer, id, offset) { const order = rankingOrder(question, answer); const from = order.indexOf(id); if (from < 0)
|
|
3
|
+
return order; const to = Math.max(0, Math.min(order.length - 1, from + offset)); const next = [...order]; next.splice(from, 1); next.splice(to, 0, id); return next; }
|
|
4
|
+
export function setMatrixChoice(question, answer, rowId, columnId) { const current = answer && typeof answer === 'object' && !Array.isArray(answer) ? answer : {}; if (question.matrixMode === 'single')
|
|
5
|
+
return { ...current, [rowId]: columnId }; const selected = Array.isArray(current[rowId]) ? current[rowId] : []; const next = selected.includes(columnId) ? selected.filter(id => id !== columnId) : [...selected, columnId]; const result = { ...current }; if (next.length)
|
|
6
|
+
result[rowId] = next;
|
|
7
|
+
else
|
|
8
|
+
delete result[rowId]; return result; }
|
|
9
|
+
export function setAllocation(answer, itemId, value) { const current = answer && typeof answer === 'object' && !Array.isArray(answer) ? answer : {}; return { ...current, [itemId]: value }; }
|
|
10
|
+
export function allocationRemaining(question, answer) { const values = answer && typeof answer === 'object' && !Array.isArray(answer) ? Object.values(answer) : []; return (question.total ?? 0) - values.reduce((sum, value) => sum + (Number.isSafeInteger(value) ? value : 0), 0); }
|
|
11
|
+
export function advancedAnswerError(question, answer) {
|
|
12
|
+
if (answer === undefined)
|
|
13
|
+
return question.required ? 'required' : undefined;
|
|
14
|
+
if (question.type === 'ranking') {
|
|
15
|
+
const values = Array.isArray(answer) ? answer : [];
|
|
16
|
+
const ids = (question.options ?? []).map(item => item.id);
|
|
17
|
+
return values.length === ids.length && new Set(values).size === values.length && values.every(id => ids.includes(id)) ? undefined : 'ranking';
|
|
18
|
+
}
|
|
19
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer))
|
|
20
|
+
return question.type === 'matrix' ? 'matrix' : 'constant_sum';
|
|
21
|
+
const values = answer;
|
|
22
|
+
if (question.type === 'matrix') {
|
|
23
|
+
const rows = (question.rows ?? []).map(item => item.id), columns = (question.columns ?? []).map(item => item.id), entries = Object.entries(values);
|
|
24
|
+
if (!entries.length || question.required && entries.length !== rows.length || entries.some(([row]) => !rows.includes(row)))
|
|
25
|
+
return 'matrix';
|
|
26
|
+
const valid = entries.every(([, value]) => question.matrixMode === 'single' ? typeof value === 'string' && columns.includes(value) : Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every(id => typeof id === 'string' && columns.includes(id)));
|
|
27
|
+
return valid ? undefined : 'matrix';
|
|
28
|
+
}
|
|
29
|
+
const items = (question.items ?? []).map(item => item.id), entries = Object.entries(values);
|
|
30
|
+
return entries.length === items.length && entries.every(([id, value]) => items.includes(id) && Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= (question.total ?? 0)) && allocationRemaining(question, values) === 0 ? undefined : 'constant_sum';
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Answer, Question, SurveyPage } from './index.js';
|
|
2
|
+
export interface BranchingSchema {
|
|
3
|
+
questions: Question[];
|
|
4
|
+
pages?: SurveyPage[];
|
|
5
|
+
}
|
|
6
|
+
export interface SurveyProgress {
|
|
7
|
+
current: number;
|
|
8
|
+
total: number;
|
|
9
|
+
visited: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function pageRoute(schema: BranchingSchema, answers: Record<string, Answer>): number[];
|
|
12
|
+
export declare function routedAnswers(schema: BranchingSchema, answers: Record<string, Answer>): Record<string, Answer>;
|
|
13
|
+
export declare class SurveyFlow {
|
|
14
|
+
readonly schema: BranchingSchema;
|
|
15
|
+
private route;
|
|
16
|
+
private history;
|
|
17
|
+
private current;
|
|
18
|
+
private values;
|
|
19
|
+
constructor(schema: BranchingSchema, initial?: Record<string, Answer>);
|
|
20
|
+
get page(): SurveyPage;
|
|
21
|
+
get answers(): Record<string, Answer>;
|
|
22
|
+
get progress(): SurveyProgress;
|
|
23
|
+
setAnswer(id: string, value: Answer | undefined): void;
|
|
24
|
+
next(): boolean;
|
|
25
|
+
back(): boolean;
|
|
26
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { conditionMatches, visibleAnswers } from './index.js';
|
|
2
|
+
const effectivePages = (schema) => schema.pages ?? [{ id: 'survey', questionIds: schema.questions.map(q => q.id) }];
|
|
3
|
+
export function pageRoute(schema, answers) {
|
|
4
|
+
const pages = effectivePages(schema), byId = new Map(pages.map((page, index) => [page.id, index]));
|
|
5
|
+
const visible = visibleAnswers(schema.questions, answers);
|
|
6
|
+
const available = new Set();
|
|
7
|
+
const route = [];
|
|
8
|
+
let current = 0;
|
|
9
|
+
while (current < pages.length) {
|
|
10
|
+
route.push(current);
|
|
11
|
+
pages[current].questionIds.forEach(id => available.add(id));
|
|
12
|
+
const branch = pages[current].branches?.find(item => conditionMatches(item.when, available.has(item.when.questionId) ? visible[item.when.questionId] : undefined));
|
|
13
|
+
current = branch ? byId.get(branch.goToPageId) ?? pages.length : current + 1;
|
|
14
|
+
}
|
|
15
|
+
return route;
|
|
16
|
+
}
|
|
17
|
+
export function routedAnswers(schema, answers) {
|
|
18
|
+
let result = visibleAnswers(schema.questions, answers);
|
|
19
|
+
const pages = effectivePages(schema);
|
|
20
|
+
for (let pass = 0; pass <= schema.questions.length; pass++) {
|
|
21
|
+
const reached = new Set(pageRoute(schema, result).flatMap(index => pages[index].questionIds));
|
|
22
|
+
const filtered = visibleAnswers(schema.questions, Object.fromEntries(Object.entries(result).filter(([id]) => reached.has(id))));
|
|
23
|
+
if (Object.keys(filtered).length === Object.keys(result).length)
|
|
24
|
+
return filtered;
|
|
25
|
+
result = filtered;
|
|
26
|
+
}
|
|
27
|
+
throw new Error('Survey route did not stabilize');
|
|
28
|
+
}
|
|
29
|
+
export class SurveyFlow {
|
|
30
|
+
schema;
|
|
31
|
+
route;
|
|
32
|
+
history;
|
|
33
|
+
current;
|
|
34
|
+
values;
|
|
35
|
+
constructor(schema, initial = {}) {
|
|
36
|
+
this.schema = schema;
|
|
37
|
+
this.values = routedAnswers(schema, { ...initial });
|
|
38
|
+
this.route = pageRoute(schema, this.values);
|
|
39
|
+
this.current = this.route[0] ?? 0;
|
|
40
|
+
this.history = [this.current];
|
|
41
|
+
}
|
|
42
|
+
get page() { return effectivePages(this.schema)[this.current]; }
|
|
43
|
+
get answers() { return { ...this.values }; }
|
|
44
|
+
get progress() { return { current: this.current + 1, total: effectivePages(this.schema).length, visited: this.history.length }; }
|
|
45
|
+
setAnswer(id, value) { const old = this.history; if (value === undefined)
|
|
46
|
+
delete this.values[id];
|
|
47
|
+
else
|
|
48
|
+
this.values[id] = value; this.values = routedAnswers(this.schema, this.values); this.route = pageRoute(this.schema, this.values); if (!this.route.includes(this.current)) {
|
|
49
|
+
let shared = this.route[0] ?? 0;
|
|
50
|
+
for (let i = 0; i < Math.min(old.length, this.route.length) && old[i] === this.route[i]; i++)
|
|
51
|
+
shared = this.route[i];
|
|
52
|
+
this.current = shared;
|
|
53
|
+
} this.history = this.route.slice(0, this.route.indexOf(this.current) + 1); }
|
|
54
|
+
next() { const at = this.route.indexOf(this.current); if (at < 0 || at + 1 >= this.route.length)
|
|
55
|
+
return false; this.current = this.route[at + 1]; this.history = this.route.slice(0, at + 2); return true; }
|
|
56
|
+
back() { if (this.history.length < 2)
|
|
57
|
+
return false; this.history.pop(); this.current = this.history[this.history.length - 1]; return true; }
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Answer, Question } from './index.js';
|
|
2
|
+
/** Selected option IDs have the same meaning across legacy and structured answers. */
|
|
3
|
+
export declare function selectedChoices(value: unknown): string[];
|
|
4
|
+
export declare function otherTexts(value: unknown): Record<string, string>;
|
|
5
|
+
export declare function choiceAnswer(q: Question, selected: string[], text?: Record<string, string>): Answer;
|
|
6
|
+
export declare function toggleChoice(q: Question, value: unknown, id: string): Answer;
|
|
7
|
+
export declare function setOtherText(q: Question, value: unknown, id: string, text: string): Answer;
|
|
8
|
+
/** Local feedback only; the server validates every accepted answer again. */
|
|
9
|
+
export declare function choiceError(q: Question, value: unknown): 'required' | 'range' | 'other' | 'invalid' | undefined;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Selected option IDs have the same meaning across legacy and structured answers. */
|
|
2
|
+
export function selectedChoices(value) {
|
|
3
|
+
if (typeof value === 'string')
|
|
4
|
+
return value ? [value] : [];
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value.filter((v) => typeof v === 'string');
|
|
7
|
+
if (value && typeof value === 'object' && 'selected' in value && Array.isArray(value.selected))
|
|
8
|
+
return value.selected.filter((v) => typeof v === 'string');
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
export function otherTexts(value) {
|
|
12
|
+
if (!value || typeof value !== 'object' || !('otherText' in value) || !value.otherText || typeof value.otherText !== 'object' || Array.isArray(value.otherText))
|
|
13
|
+
return {};
|
|
14
|
+
return Object.fromEntries(Object.entries(value.otherText).filter((entry) => typeof entry[1] === 'string'));
|
|
15
|
+
}
|
|
16
|
+
export function choiceAnswer(q, selected, text = {}) {
|
|
17
|
+
if (q.options?.some(o => o.other !== undefined))
|
|
18
|
+
return { selected: [...selected], otherText: Object.fromEntries((q.options ?? []).filter(o => o.other !== undefined && selected.includes(o.id)).map(o => [o.id, text[o.id] ?? ''])) };
|
|
19
|
+
return q.type === 'single_choice' ? selected[0] ?? '' : [...selected];
|
|
20
|
+
}
|
|
21
|
+
export function toggleChoice(q, value, id) {
|
|
22
|
+
const option = q.options?.find(o => o.id === id);
|
|
23
|
+
if (!option)
|
|
24
|
+
throw new TypeError('Unknown choice');
|
|
25
|
+
const current = selectedChoices(value);
|
|
26
|
+
const selected = q.type === 'single_choice' ? [id] : current.includes(id) ? current.filter(v => v !== id) : option.exclusive ? [id] : [...current.filter(v => !q.options?.find(o => o.id === v)?.exclusive), id];
|
|
27
|
+
return choiceAnswer(q, selected, otherTexts(value));
|
|
28
|
+
}
|
|
29
|
+
export function setOtherText(q, value, id, text) {
|
|
30
|
+
return choiceAnswer(q, selectedChoices(value), { ...otherTexts(value), [id]: text });
|
|
31
|
+
}
|
|
32
|
+
/** Local feedback only; the server validates every accepted answer again. */
|
|
33
|
+
export function choiceError(q, value) {
|
|
34
|
+
if (value === undefined || value === '')
|
|
35
|
+
return q.required ? 'required' : undefined;
|
|
36
|
+
const hasOther = q.options?.some(o => o.other !== undefined);
|
|
37
|
+
if (hasOther) {
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 2 || !('selected' in value) || !Array.isArray(value.selected) || !value.selected.every(v => typeof v === 'string') || !('otherText' in value) || !value.otherText || typeof value.otherText !== 'object' || Array.isArray(value.otherText))
|
|
39
|
+
return 'invalid';
|
|
40
|
+
}
|
|
41
|
+
else if (q.type === 'single_choice' ? typeof value !== 'string' : !Array.isArray(value) || !value.every(v => typeof v === 'string'))
|
|
42
|
+
return 'invalid';
|
|
43
|
+
const selected = selectedChoices(value);
|
|
44
|
+
if (new Set(selected).size !== selected.length || selected.some(id => !q.options?.some(o => o.id === id)))
|
|
45
|
+
return 'invalid';
|
|
46
|
+
const exclusive = selected.some(id => q.options?.some(o => o.id === id && o.exclusive));
|
|
47
|
+
if (exclusive && selected.length !== 1)
|
|
48
|
+
return 'invalid';
|
|
49
|
+
if (q.type === 'single_choice' ? selected.length !== 1 : selected.length > (q.maxSelections ?? q.options?.length ?? 0) || (!exclusive && selected.length < Math.max(q.required ? 1 : 0, q.minSelections ?? 0)))
|
|
50
|
+
return 'range';
|
|
51
|
+
if (hasOther) {
|
|
52
|
+
const text = otherTexts(value), expected = (q.options ?? []).filter(o => o.other !== undefined && selected.includes(o.id));
|
|
53
|
+
if (Object.keys(value.otherText).length !== expected.length)
|
|
54
|
+
return 'other';
|
|
55
|
+
if (expected.some(o => typeof text[o.id] !== 'string' || !text[o.id].trim() || Array.from(text[o.id]).length > o.other.maxLength))
|
|
56
|
+
return 'other';
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export { SurveyFlow, pageRoute, routedAnswers } from './branching.js';
|
|
2
|
+
export type { BranchingSchema, SurveyProgress } from './branching.js';
|
|
3
|
+
export * from './advanced-questions.js';
|
|
4
|
+
export interface StructuredChoiceAnswer {
|
|
5
|
+
selected: string[];
|
|
6
|
+
otherText: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export type Answer = string | number | string[] | StructuredChoiceAnswer | Record<string, string | string[]> | Record<string, number>;
|
|
9
|
+
export type QuestionType = 'single_choice' | 'multiple_choice' | 'scale' | 'text' | 'number' | 'date' | 'ranking' | 'matrix' | 'constant_sum';
|
|
10
|
+
export type VisibilityOperator = 'equals' | 'not_equals' | 'includes' | 'not_includes' | 'answered' | 'not_answered';
|
|
11
|
+
export interface VisibilityCondition {
|
|
12
|
+
questionId: string;
|
|
13
|
+
operator: VisibilityOperator;
|
|
14
|
+
value?: string | number;
|
|
15
|
+
}
|
|
16
|
+
export interface PageBranch {
|
|
17
|
+
when: VisibilityCondition;
|
|
18
|
+
goToPageId: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SurveyPage {
|
|
21
|
+
id: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
questionIds: string[];
|
|
24
|
+
branches?: PageBranch[];
|
|
25
|
+
}
|
|
26
|
+
export interface Choice {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
other?: {
|
|
30
|
+
maxLength: number;
|
|
31
|
+
};
|
|
32
|
+
exclusive?: true;
|
|
33
|
+
}
|
|
34
|
+
export interface PromptItem {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
}
|
|
38
|
+
export interface Question {
|
|
39
|
+
id: string;
|
|
40
|
+
type: QuestionType;
|
|
41
|
+
label: string;
|
|
42
|
+
required?: boolean;
|
|
43
|
+
options?: Choice[];
|
|
44
|
+
min?: number;
|
|
45
|
+
max?: number;
|
|
46
|
+
maxLength?: number;
|
|
47
|
+
preset?: 'nps' | 'yes_no';
|
|
48
|
+
labels?: Record<string, string>;
|
|
49
|
+
minSelections?: number;
|
|
50
|
+
maxSelections?: number;
|
|
51
|
+
visibleWhen?: VisibilityCondition;
|
|
52
|
+
presentation?: 'stars' | 'dropdown';
|
|
53
|
+
rows?: PromptItem[];
|
|
54
|
+
columns?: Choice[];
|
|
55
|
+
matrixMode?: 'single' | 'multiple';
|
|
56
|
+
items?: PromptItem[];
|
|
57
|
+
total?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface Collection {
|
|
60
|
+
id: string;
|
|
61
|
+
surveyId: string;
|
|
62
|
+
version: number;
|
|
63
|
+
placement: string;
|
|
64
|
+
schema: {
|
|
65
|
+
schemaVersion: 1 | 2 | 3 | 4 | 5;
|
|
66
|
+
title: string;
|
|
67
|
+
questions: Question[];
|
|
68
|
+
pages?: SurveyPage[];
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface Submission {
|
|
72
|
+
idempotencyKey: string;
|
|
73
|
+
answers: Record<string, Answer>;
|
|
74
|
+
metadata: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
export interface Receipt {
|
|
77
|
+
responseId: string;
|
|
78
|
+
collectionId: string;
|
|
79
|
+
accepted: true;
|
|
80
|
+
}
|
|
81
|
+
export interface RequestOptions {
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
export interface CollectionRequestOptions extends RequestOptions {
|
|
86
|
+
refresh?: boolean;
|
|
87
|
+
}
|
|
88
|
+
export interface SurveyMessages {
|
|
89
|
+
selectPlaceholder: string;
|
|
90
|
+
back: string;
|
|
91
|
+
next: string;
|
|
92
|
+
progress: string;
|
|
93
|
+
submit: string;
|
|
94
|
+
submitting: string;
|
|
95
|
+
submitted: string;
|
|
96
|
+
submissionError: string;
|
|
97
|
+
selectionRange: string;
|
|
98
|
+
otherError: string;
|
|
99
|
+
moveUp: string;
|
|
100
|
+
moveDown: string;
|
|
101
|
+
remaining: string;
|
|
102
|
+
advancedError: string;
|
|
103
|
+
}
|
|
104
|
+
export interface SurveyClassNames {
|
|
105
|
+
form: string;
|
|
106
|
+
title: string;
|
|
107
|
+
question: string;
|
|
108
|
+
label: string;
|
|
109
|
+
control: string;
|
|
110
|
+
submit: string;
|
|
111
|
+
status: string;
|
|
112
|
+
}
|
|
113
|
+
export interface MountSurveyOptions {
|
|
114
|
+
messages?: Partial<SurveyMessages>;
|
|
115
|
+
classNames?: Partial<SurveyClassNames>;
|
|
116
|
+
}
|
|
117
|
+
export declare const LIKERTS_SDK_CAPABILITY: Readonly<{
|
|
118
|
+
target: "web";
|
|
119
|
+
sdkVersion: "0.0.3";
|
|
120
|
+
schemaVersions: readonly [1, 2, 3, 4, 5];
|
|
121
|
+
}>;
|
|
122
|
+
export declare class LikertsError extends Error {
|
|
123
|
+
status: number;
|
|
124
|
+
response: string;
|
|
125
|
+
constructor(status: number, response: string);
|
|
126
|
+
}
|
|
127
|
+
/** Only a public collection credential belongs here. No administrative credentials. */
|
|
128
|
+
export declare class LikertsClient {
|
|
129
|
+
private token;
|
|
130
|
+
private defaultTimeoutMs;
|
|
131
|
+
private cacheMaxAgeMs;
|
|
132
|
+
private maxResponseBytes;
|
|
133
|
+
private baseURL;
|
|
134
|
+
private transport;
|
|
135
|
+
private collectionCache;
|
|
136
|
+
constructor(baseURL: string, token: string, transport?: typeof fetch | undefined, defaultTimeoutMs?: number, cacheMaxAgeMs?: number, maxResponseBytes?: number);
|
|
137
|
+
private responseText;
|
|
138
|
+
private request;
|
|
139
|
+
collection(id: string, options?: CollectionRequestOptions): Promise<Collection>;
|
|
140
|
+
clearCollectionCache(id?: string): void;
|
|
141
|
+
/** Retrying requires the same submission object, including its idempotencyKey. */
|
|
142
|
+
submit(id: string, submission: Submission, options?: RequestOptions): Promise<Receipt>;
|
|
143
|
+
}
|
|
144
|
+
export declare function conditionMatches(condition: VisibilityCondition, answer: Answer | undefined): boolean;
|
|
145
|
+
/** Computes visibility recursively. Hidden source answers are treated as unanswered. */
|
|
146
|
+
export declare function visibleQuestionIds(questions: Question[], answers: Record<string, Answer>): Set<string>;
|
|
147
|
+
/** Removes answers for hidden questions before validation or submission. */
|
|
148
|
+
export declare function visibleAnswers(questions: Question[], answers: Record<string, Answer>): Record<string, Answer>;
|
|
149
|
+
/** Fixed likerts-* classes and data attributes are stable styling hooks; options add host classes without inline styles. */
|
|
150
|
+
export declare function mountSurvey(container: HTMLElement, collection: Collection, client: LikertsClient, onComplete: (receipt: Receipt) => void, metadata?: Record<string, unknown>, options?: MountSurveyOptions): () => void;
|