@pie-players/pie-players-shared 0.3.67 → 0.3.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/formative/index.d.ts +16 -0
- package/dist/formative/index.js +5 -0
- package/dist/formative/mastery.d.ts +16 -0
- package/dist/formative/mastery.js +43 -0
- package/dist/formative/outcome.d.ts +19 -0
- package/dist/formative/outcome.js +90 -0
- package/dist/formative/policy.d.ts +37 -0
- package/dist/formative/policy.js +86 -0
- package/dist/formative/session.d.ts +19 -0
- package/dist/formative/session.js +113 -0
- package/dist/formative/state.d.ts +60 -0
- package/dist/formative/state.js +140 -0
- package/dist/formative/types.d.ts +164 -0
- package/dist/formative/types.js +13 -0
- package/dist/i18n/catalogs.d.ts +38 -0
- package/dist/i18n/catalogs.js +47 -0
- package/dist/i18n/index.d.ts +28 -4
- package/dist/i18n/index.js +34 -3
- package/dist/i18n/language-tags.d.ts +51 -0
- package/dist/i18n/language-tags.js +84 -0
- package/dist/i18n/messages/en-US.d.ts +601 -0
- package/dist/i18n/messages/en-US.js +617 -0
- package/dist/i18n/messages/nl-NL.d.ts +516 -0
- package/dist/i18n/messages/nl-NL.js +516 -0
- package/dist/i18n/provider.d.ts +168 -0
- package/dist/i18n/provider.js +472 -0
- package/dist/i18n/types.d.ts +177 -28
- package/dist/i18n/types.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/pie/custom-element-define.d.ts +24 -0
- package/dist/pie/custom-element-define.js +36 -6
- package/dist/pie/index.d.ts +1 -1
- package/dist/pie/index.js +1 -1
- package/dist/pie/versioned-tag.js +3 -2
- package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
- package/dist/security/sanitize-forbidden-lists.js +41 -0
- package/dist/security/sanitize-item-markup.js +3 -36
- package/dist/security/sanitize-svg-icon.js +3 -25
- package/dist/security/wrap-overwide-images.d.ts +5 -10
- package/dist/security/wrap-overwide-images.js +13 -62
- package/dist/security/wrap-overwide-tables.d.ts +5 -10
- package/dist/security/wrap-overwide-tables.js +13 -62
- package/dist/security/wrap-overwide.d.ts +51 -0
- package/dist/security/wrap-overwide.js +91 -0
- package/dist/server/npm-auth-env.d.ts +13 -0
- package/dist/server/npm-auth-env.js +64 -0
- package/dist/timed-media/index.d.ts +17 -0
- package/dist/timed-media/index.js +16 -0
- package/dist/timed-media/media-element-source.d.ts +36 -0
- package/dist/timed-media/media-element-source.js +121 -0
- package/dist/timed-media/policy.d.ts +36 -0
- package/dist/timed-media/policy.js +297 -0
- package/dist/timed-media/session.d.ts +21 -0
- package/dist/timed-media/session.js +85 -0
- package/dist/timed-media/state.d.ts +66 -0
- package/dist/timed-media/state.js +357 -0
- package/dist/timed-media/types.d.ts +343 -0
- package/dist/timed-media/types.js +12 -0
- package/dist/tools/term-lookup.d.ts +189 -0
- package/dist/tools/term-lookup.js +284 -0
- package/dist/types/index.d.ts +105 -1
- package/dist/ui/attribute-coercion.d.ts +1 -0
- package/dist/ui/attribute-coercion.js +19 -0
- package/dist/ui/first-focusable.d.ts +22 -0
- package/dist/ui/first-focusable.js +47 -0
- package/dist/ui/focus-trap.js +7 -2
- package/dist/ui/pointer-drag.d.ts +30 -0
- package/dist/ui/pointer-drag.js +34 -0
- package/package.json +38 -6
- package/dist/i18n/loader.d.ts +0 -35
- package/dist/i18n/loader.js +0 -132
- package/dist/i18n/simple-i18n.d.ts +0 -68
- package/dist/i18n/simple-i18n.js +0 -198
- package/dist/i18n/translations/ar/common.json +0 -36
- package/dist/i18n/translations/ar/toolkit.json +0 -48
- package/dist/i18n/translations/ar/tools.json +0 -103
- package/dist/i18n/translations/en/common.json +0 -36
- package/dist/i18n/translations/en/toolkit.json +0 -48
- package/dist/i18n/translations/en/tools.json +0 -103
- package/dist/i18n/translations/es/common.json +0 -36
- package/dist/i18n/translations/es/toolkit.json +0 -48
- package/dist/i18n/translations/es/tools.json +0 -103
- package/dist/i18n/translations/zh/common.json +0 -36
- package/dist/i18n/translations/zh/toolkit.json +0 -48
- package/dist/i18n/translations/zh/tools.json +0 -103
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timed-media section vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A **Cue** is a window on a media timeline that activates one or more of the
|
|
5
|
+
* section's item refs. A **Media Time Source** is the port the section reaches
|
|
6
|
+
* media through — never a library API, so a host can supply its own media
|
|
7
|
+
* element without shipping a PIE element.
|
|
8
|
+
*
|
|
9
|
+
* See `docs/prds/timed-media-section-contract.md` for the ratified contract and
|
|
10
|
+
* `docs/architecture/timed-media-section.md` for the layer-ownership record.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Term lookup, shared by the dictionary tools.
|
|
3
|
+
*
|
|
4
|
+
* A dictionary and a picture dictionary differ only in what a result carries: one
|
|
5
|
+
* returns definitions, the other pictures. Everything up to that point is the same
|
|
6
|
+
* concern — normalising what a text selection dragged in, deciding whether a term is
|
|
7
|
+
* worth a request, telling "no entry for this word" apart from "the service did not
|
|
8
|
+
* answer", and not letting a superseded lookup overwrite a newer one's state. That is
|
|
9
|
+
* the part worth getting right once and testing without a browser, so it lives here
|
|
10
|
+
* and each tool supplies only its item reader.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here names a capability or a service. PIE ships no dictionary endpoint: the
|
|
13
|
+
* corpus behind one is licensed per programme, so a host supplies either an endpoint
|
|
14
|
+
* or a resolver of its own.
|
|
15
|
+
*/
|
|
16
|
+
/** What a host's term service is asked for. */
|
|
17
|
+
export interface TermLookupRequest {
|
|
18
|
+
/** The term, already normalised by {@link normalizeTerm}. */
|
|
19
|
+
keyword: string;
|
|
20
|
+
/** BCP-47 tag the learner is reading in, when the host declares one. */
|
|
21
|
+
language?: string;
|
|
22
|
+
/** Upper bound on items, so a host cannot be made to return an unbounded page. */
|
|
23
|
+
max?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A lookup outcome, as a discriminated union.
|
|
27
|
+
*
|
|
28
|
+
* `empty` and `error` are separate states because they need different words in front
|
|
29
|
+
* of a learner: one means the service has no entry for what they selected, the other
|
|
30
|
+
* means it did not answer. Collapsing them tells a learner their word is not real
|
|
31
|
+
* when the network is down.
|
|
32
|
+
*/
|
|
33
|
+
export type TermLookupResult<TItem> = {
|
|
34
|
+
status: "ok";
|
|
35
|
+
items: TItem[];
|
|
36
|
+
} | {
|
|
37
|
+
status: "empty";
|
|
38
|
+
} | {
|
|
39
|
+
status: "error";
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
export type TermLookup<TItem> = (request: TermLookupRequest, signal?: AbortSignal) => Promise<TermLookupResult<TItem>>;
|
|
43
|
+
/** Longest term worth sending. Past this it is prose, not a headword. */
|
|
44
|
+
export declare const MAX_LOOKUP_TERM_LENGTH = 80;
|
|
45
|
+
/** Words a term may span. Generous enough for `carbon dioxide` and `mother in law`. */
|
|
46
|
+
export declare const MAX_LOOKUP_TERM_WORDS = 4;
|
|
47
|
+
/**
|
|
48
|
+
* Collapse whitespace and strip the punctuation a text selection drags along.
|
|
49
|
+
*
|
|
50
|
+
* A learner double-clicking a word at the end of a sentence selects `"reason."`, and
|
|
51
|
+
* a drag across a line break selects `"photo-\nsynthesis"`. Leading and trailing
|
|
52
|
+
* punctuation goes; internal hyphens and apostrophes stay, because `mother-in-law`
|
|
53
|
+
* and `don't` are the entries.
|
|
54
|
+
*/
|
|
55
|
+
export declare function normalizeTerm(raw: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether a normalised term is worth sending.
|
|
58
|
+
*
|
|
59
|
+
* A whole sentence is not a lookup, and sending it wastes a request that will miss.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isLookupableTerm(term: string): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Read a host payload into a result.
|
|
64
|
+
*
|
|
65
|
+
* `keys` are the payload fields to accept the item array under, in order, so a tool
|
|
66
|
+
* can take more than one spelling of the same field. Unknown extra fields are ignored
|
|
67
|
+
* rather than rejected, so a host can extend its payload without this package having
|
|
68
|
+
* to agree first. An item its reader rejects is dropped rather than failing the whole
|
|
69
|
+
* response: one malformed entry should not cost the learner the others.
|
|
70
|
+
*/
|
|
71
|
+
export declare function readTermLookupPayload<TItem>(payload: unknown, args: {
|
|
72
|
+
/** Names the service in messages a learner reads, e.g. `"dictionary"`. */
|
|
73
|
+
serviceLabel: string;
|
|
74
|
+
keys: readonly string[];
|
|
75
|
+
toItem: (value: unknown) => TItem | null;
|
|
76
|
+
}): TermLookupResult<TItem>;
|
|
77
|
+
/**
|
|
78
|
+
* A lookup that POSTs to a host endpoint.
|
|
79
|
+
*
|
|
80
|
+
* `credentials: "same-origin"` by default. The endpoint a host names is expected to
|
|
81
|
+
* sit behind the same session boundary as the assessment itself, so the session
|
|
82
|
+
* cookie is exactly what authorises it and requiring a host to wire a token to get
|
|
83
|
+
* its own already-authenticated route to answer is a configuration step it would
|
|
84
|
+
* reasonably forget. Cross-origin requests still send nothing. A host that authorises
|
|
85
|
+
* some other way passes `headers`, and one that wants no ambient credentials at all
|
|
86
|
+
* passes `credentials: "omit"`.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createEndpointTermLookup<TItem>(args: {
|
|
89
|
+
endpoint: string;
|
|
90
|
+
/** Names the service in messages a learner reads, e.g. `"dictionary"`. */
|
|
91
|
+
serviceLabel: string;
|
|
92
|
+
/** Reads a host payload into a result; usually {@link readTermLookupPayload}. */
|
|
93
|
+
readResponse: (payload: unknown) => TermLookupResult<TItem>;
|
|
94
|
+
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
|
95
|
+
credentials?: RequestCredentials;
|
|
96
|
+
fetchImpl?: typeof fetch;
|
|
97
|
+
}): TermLookup<TItem>;
|
|
98
|
+
/** What a term panel is showing. */
|
|
99
|
+
export type TermPanelState<TItem> = {
|
|
100
|
+
kind: "unconfigured";
|
|
101
|
+
} | {
|
|
102
|
+
kind: "idle";
|
|
103
|
+
} | {
|
|
104
|
+
kind: "searching";
|
|
105
|
+
term: string;
|
|
106
|
+
} | {
|
|
107
|
+
kind: "results";
|
|
108
|
+
term: string;
|
|
109
|
+
items: TItem[];
|
|
110
|
+
} | {
|
|
111
|
+
kind: "empty";
|
|
112
|
+
term: string;
|
|
113
|
+
} | {
|
|
114
|
+
kind: "error";
|
|
115
|
+
term: string;
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
/** Shown when the learner sends something that is not a headword. */
|
|
119
|
+
export declare const NOT_A_TERM_REASON = "Look up a single word or short phrase.";
|
|
120
|
+
export interface TermLookupSessionOptions<TItem> {
|
|
121
|
+
/**
|
|
122
|
+
* Read at call time rather than captured, so a resolver that arrives after mount —
|
|
123
|
+
* a host setting `endpoint` on a live element — is picked up without a remount.
|
|
124
|
+
*/
|
|
125
|
+
resolver: () => TermLookup<TItem> | null;
|
|
126
|
+
max: number;
|
|
127
|
+
language?: () => string | undefined;
|
|
128
|
+
/** Applied on every transition. The component holds the state; this drives it. */
|
|
129
|
+
onState: (state: TermPanelState<TItem>) => void;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The lookup half of a term panel: request sequencing and state transitions, with no
|
|
133
|
+
* framework reactivity of its own.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately not a rune module. Rune modules in this package are source-only and
|
|
136
|
+
* each consumer has to alias them in its own Vite and TypeScript config; a plain
|
|
137
|
+
* class costs a caller one `$state` field instead, and is unit-testable directly.
|
|
138
|
+
*/
|
|
139
|
+
export declare class TermLookupSession<TItem> {
|
|
140
|
+
#private;
|
|
141
|
+
private readonly options;
|
|
142
|
+
constructor(options: TermLookupSessionOptions<TItem>);
|
|
143
|
+
get searchedFor(): string;
|
|
144
|
+
/**
|
|
145
|
+
* Reconcile the unconfigured notice with whether a resolver exists.
|
|
146
|
+
*
|
|
147
|
+
* A tool with nowhere to look words up says so, rather than offering a field that
|
|
148
|
+
* silently fails: the host has misconfigured it and the learner needs to know it is
|
|
149
|
+
* not their typing. Called with the current state because it only ever moves
|
|
150
|
+
* between `idle` and `unconfigured` — a resolver arriving mid-lookup must not
|
|
151
|
+
* discard results the learner is reading.
|
|
152
|
+
*/
|
|
153
|
+
syncConfigured(current: TermPanelState<TItem>): void;
|
|
154
|
+
/**
|
|
155
|
+
* Run a term handed in from outside, at most once per request.
|
|
156
|
+
*
|
|
157
|
+
* Keyed on the request's identity, not on the term or on what was last searched.
|
|
158
|
+
* Both of the alternatives are wrong in a way a learner meets: keying on the term
|
|
159
|
+
* means selecting a word, typing another into the field, then selecting the first
|
|
160
|
+
* word again does nothing, because the incoming term never changed; keying on
|
|
161
|
+
* {@link searchedFor} means every reopen re-issues the stale selection and discards
|
|
162
|
+
* the lookup the learner typed. An id that changes per request says what both are
|
|
163
|
+
* guessing at.
|
|
164
|
+
*
|
|
165
|
+
* A request with no id falls back to the term, which is the best available identity
|
|
166
|
+
* when the caller cannot mint one — a host assigning `term` directly, for instance.
|
|
167
|
+
*
|
|
168
|
+
* Returns whether it issued a lookup, so a caller can mirror the term into its own
|
|
169
|
+
* field only when one ran.
|
|
170
|
+
*/
|
|
171
|
+
syncRequestedTerm(request: {
|
|
172
|
+
term: string;
|
|
173
|
+
requestId?: string | number;
|
|
174
|
+
visible: boolean;
|
|
175
|
+
}): boolean;
|
|
176
|
+
run(raw: string): Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The panel's live-region text.
|
|
180
|
+
*
|
|
181
|
+
* Only the states a learner is waiting on announce. `idle`, `unconfigured` and
|
|
182
|
+
* `error` are already rendered as visible text in the panel body, and repeating them
|
|
183
|
+
* here would announce the same sentence twice.
|
|
184
|
+
*/
|
|
185
|
+
export declare function termPanelStatusMessage<TItem>(state: TermPanelState<TItem>, labels: {
|
|
186
|
+
/** e.g. `2 entries`, `1 picture`. */
|
|
187
|
+
countLabel: (count: number) => string;
|
|
188
|
+
emptyLabel: (term: string) => string;
|
|
189
|
+
}): string;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Term lookup, shared by the dictionary tools.
|
|
3
|
+
*
|
|
4
|
+
* A dictionary and a picture dictionary differ only in what a result carries: one
|
|
5
|
+
* returns definitions, the other pictures. Everything up to that point is the same
|
|
6
|
+
* concern — normalising what a text selection dragged in, deciding whether a term is
|
|
7
|
+
* worth a request, telling "no entry for this word" apart from "the service did not
|
|
8
|
+
* answer", and not letting a superseded lookup overwrite a newer one's state. That is
|
|
9
|
+
* the part worth getting right once and testing without a browser, so it lives here
|
|
10
|
+
* and each tool supplies only its item reader.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here names a capability or a service. PIE ships no dictionary endpoint: the
|
|
13
|
+
* corpus behind one is licensed per programme, so a host supplies either an endpoint
|
|
14
|
+
* or a resolver of its own.
|
|
15
|
+
*/
|
|
16
|
+
/** Longest term worth sending. Past this it is prose, not a headword. */
|
|
17
|
+
export const MAX_LOOKUP_TERM_LENGTH = 80;
|
|
18
|
+
/** Words a term may span. Generous enough for `carbon dioxide` and `mother in law`. */
|
|
19
|
+
export const MAX_LOOKUP_TERM_WORDS = 4;
|
|
20
|
+
/**
|
|
21
|
+
* Collapse whitespace and strip the punctuation a text selection drags along.
|
|
22
|
+
*
|
|
23
|
+
* A learner double-clicking a word at the end of a sentence selects `"reason."`, and
|
|
24
|
+
* a drag across a line break selects `"photo-\nsynthesis"`. Leading and trailing
|
|
25
|
+
* punctuation goes; internal hyphens and apostrophes stay, because `mother-in-law`
|
|
26
|
+
* and `don't` are the entries.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeTerm(raw) {
|
|
29
|
+
return raw
|
|
30
|
+
.replace(/\s+/gu, " ")
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/^[^\p{L}\p{N}]+/u, "")
|
|
33
|
+
.replace(/[^\p{L}\p{N}]+$/u, "");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether a normalised term is worth sending.
|
|
37
|
+
*
|
|
38
|
+
* A whole sentence is not a lookup, and sending it wastes a request that will miss.
|
|
39
|
+
*/
|
|
40
|
+
export function isLookupableTerm(term) {
|
|
41
|
+
if (!term)
|
|
42
|
+
return false;
|
|
43
|
+
if (term.length > MAX_LOOKUP_TERM_LENGTH)
|
|
44
|
+
return false;
|
|
45
|
+
return term.split(" ").length <= MAX_LOOKUP_TERM_WORDS;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read a host payload into a result.
|
|
49
|
+
*
|
|
50
|
+
* `keys` are the payload fields to accept the item array under, in order, so a tool
|
|
51
|
+
* can take more than one spelling of the same field. Unknown extra fields are ignored
|
|
52
|
+
* rather than rejected, so a host can extend its payload without this package having
|
|
53
|
+
* to agree first. An item its reader rejects is dropped rather than failing the whole
|
|
54
|
+
* response: one malformed entry should not cost the learner the others.
|
|
55
|
+
*/
|
|
56
|
+
export function readTermLookupPayload(payload, args) {
|
|
57
|
+
if (!payload || typeof payload !== "object") {
|
|
58
|
+
return {
|
|
59
|
+
status: "error",
|
|
60
|
+
reason: `The ${args.serviceLabel} returned no data.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const record = payload;
|
|
64
|
+
let raw = null;
|
|
65
|
+
for (const key of args.keys) {
|
|
66
|
+
if (Array.isArray(record[key])) {
|
|
67
|
+
raw = record[key];
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!raw) {
|
|
72
|
+
return {
|
|
73
|
+
status: "error",
|
|
74
|
+
reason: `The ${args.serviceLabel} response was unreadable.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const items = raw
|
|
78
|
+
.map(args.toItem)
|
|
79
|
+
.filter((item) => item !== null);
|
|
80
|
+
return items.length > 0 ? { status: "ok", items } : { status: "empty" };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A lookup that POSTs to a host endpoint.
|
|
84
|
+
*
|
|
85
|
+
* `credentials: "same-origin"` by default. The endpoint a host names is expected to
|
|
86
|
+
* sit behind the same session boundary as the assessment itself, so the session
|
|
87
|
+
* cookie is exactly what authorises it and requiring a host to wire a token to get
|
|
88
|
+
* its own already-authenticated route to answer is a configuration step it would
|
|
89
|
+
* reasonably forget. Cross-origin requests still send nothing. A host that authorises
|
|
90
|
+
* some other way passes `headers`, and one that wants no ambient credentials at all
|
|
91
|
+
* passes `credentials: "omit"`.
|
|
92
|
+
*/
|
|
93
|
+
export function createEndpointTermLookup(args) {
|
|
94
|
+
const doFetch = args.fetchImpl ?? globalThis.fetch;
|
|
95
|
+
return async (request, signal) => {
|
|
96
|
+
if (typeof doFetch !== "function") {
|
|
97
|
+
return {
|
|
98
|
+
status: "error",
|
|
99
|
+
reason: "No fetch implementation is available.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
let headers = {
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const extra = await args.headers?.();
|
|
107
|
+
if (extra)
|
|
108
|
+
headers = { ...headers, ...extra };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {
|
|
112
|
+
status: "error",
|
|
113
|
+
reason: `The ${args.serviceLabel} could not be authorized.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const response = await doFetch(args.endpoint, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
credentials: args.credentials ?? "same-origin",
|
|
121
|
+
body: JSON.stringify(request),
|
|
122
|
+
signal,
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
return {
|
|
126
|
+
status: "error",
|
|
127
|
+
reason: `The ${args.serviceLabel} is unavailable (${response.status}).`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return args.readResponse(await response.json());
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
// An aborted request is a newer lookup superseding this one, not a failure
|
|
134
|
+
// the learner should be told about.
|
|
135
|
+
if (cause instanceof DOMException && cause.name === "AbortError") {
|
|
136
|
+
return { status: "empty" };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: "error",
|
|
140
|
+
reason: `The ${args.serviceLabel} could not be reached.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Shown when the learner sends something that is not a headword. */
|
|
146
|
+
export const NOT_A_TERM_REASON = "Look up a single word or short phrase.";
|
|
147
|
+
/**
|
|
148
|
+
* The lookup half of a term panel: request sequencing and state transitions, with no
|
|
149
|
+
* framework reactivity of its own.
|
|
150
|
+
*
|
|
151
|
+
* Deliberately not a rune module. Rune modules in this package are source-only and
|
|
152
|
+
* each consumer has to alias them in its own Vite and TypeScript config; a plain
|
|
153
|
+
* class costs a caller one `$state` field instead, and is unit-testable directly.
|
|
154
|
+
*/
|
|
155
|
+
export class TermLookupSession {
|
|
156
|
+
options;
|
|
157
|
+
/** Cancels a lookup the learner has already superseded. */
|
|
158
|
+
#inFlight = null;
|
|
159
|
+
/** The term the last lookup ran for, so a repeat of it is not re-issued. */
|
|
160
|
+
#searchedFor = "";
|
|
161
|
+
/** Identity of the last handed-in term this session acted on. */
|
|
162
|
+
#handledRequest = null;
|
|
163
|
+
constructor(options) {
|
|
164
|
+
this.options = options;
|
|
165
|
+
}
|
|
166
|
+
get searchedFor() {
|
|
167
|
+
return this.#searchedFor;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reconcile the unconfigured notice with whether a resolver exists.
|
|
171
|
+
*
|
|
172
|
+
* A tool with nowhere to look words up says so, rather than offering a field that
|
|
173
|
+
* silently fails: the host has misconfigured it and the learner needs to know it is
|
|
174
|
+
* not their typing. Called with the current state because it only ever moves
|
|
175
|
+
* between `idle` and `unconfigured` — a resolver arriving mid-lookup must not
|
|
176
|
+
* discard results the learner is reading.
|
|
177
|
+
*/
|
|
178
|
+
syncConfigured(current) {
|
|
179
|
+
const resolver = this.options.resolver();
|
|
180
|
+
if (!resolver && current.kind === "idle") {
|
|
181
|
+
this.options.onState({ kind: "unconfigured" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (resolver && current.kind === "unconfigured") {
|
|
185
|
+
this.options.onState({ kind: "idle" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Run a term handed in from outside, at most once per request.
|
|
190
|
+
*
|
|
191
|
+
* Keyed on the request's identity, not on the term or on what was last searched.
|
|
192
|
+
* Both of the alternatives are wrong in a way a learner meets: keying on the term
|
|
193
|
+
* means selecting a word, typing another into the field, then selecting the first
|
|
194
|
+
* word again does nothing, because the incoming term never changed; keying on
|
|
195
|
+
* {@link searchedFor} means every reopen re-issues the stale selection and discards
|
|
196
|
+
* the lookup the learner typed. An id that changes per request says what both are
|
|
197
|
+
* guessing at.
|
|
198
|
+
*
|
|
199
|
+
* A request with no id falls back to the term, which is the best available identity
|
|
200
|
+
* when the caller cannot mint one — a host assigning `term` directly, for instance.
|
|
201
|
+
*
|
|
202
|
+
* Returns whether it issued a lookup, so a caller can mirror the term into its own
|
|
203
|
+
* field only when one ran.
|
|
204
|
+
*/
|
|
205
|
+
syncRequestedTerm(request) {
|
|
206
|
+
const incoming = normalizeTerm(request.term);
|
|
207
|
+
// Closed panels spend no request: a selection made while the panel is shut is not
|
|
208
|
+
// a question, and answering it would burn a lookup the learner never sees.
|
|
209
|
+
if (!request.visible || !incoming)
|
|
210
|
+
return false;
|
|
211
|
+
const identity = request.requestId === undefined
|
|
212
|
+
? `term:${incoming}`
|
|
213
|
+
: `id:${request.requestId}`;
|
|
214
|
+
if (identity === this.#handledRequest)
|
|
215
|
+
return false;
|
|
216
|
+
this.#handledRequest = identity;
|
|
217
|
+
void this.run(incoming);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
async run(raw) {
|
|
221
|
+
const resolver = this.options.resolver();
|
|
222
|
+
if (!resolver) {
|
|
223
|
+
this.options.onState({ kind: "unconfigured" });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const keyword = normalizeTerm(raw);
|
|
227
|
+
if (!isLookupableTerm(keyword)) {
|
|
228
|
+
this.options.onState({
|
|
229
|
+
kind: "error",
|
|
230
|
+
term: keyword,
|
|
231
|
+
reason: NOT_A_TERM_REASON,
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.#inFlight?.abort();
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
this.#inFlight = controller;
|
|
238
|
+
this.#searchedFor = keyword;
|
|
239
|
+
this.options.onState({ kind: "searching", term: keyword });
|
|
240
|
+
const result = await resolver({
|
|
241
|
+
keyword,
|
|
242
|
+
language: this.options.language?.() || undefined,
|
|
243
|
+
max: this.options.max,
|
|
244
|
+
}, controller.signal);
|
|
245
|
+
// A superseded lookup must not overwrite the newer one's state.
|
|
246
|
+
if (controller !== this.#inFlight)
|
|
247
|
+
return;
|
|
248
|
+
this.#inFlight = null;
|
|
249
|
+
if (result.status === "ok") {
|
|
250
|
+
this.options.onState({
|
|
251
|
+
kind: "results",
|
|
252
|
+
term: keyword,
|
|
253
|
+
items: result.items,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (result.status === "empty") {
|
|
258
|
+
this.options.onState({ kind: "empty", term: keyword });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.options.onState({
|
|
262
|
+
kind: "error",
|
|
263
|
+
term: keyword,
|
|
264
|
+
reason: result.reason,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The panel's live-region text.
|
|
270
|
+
*
|
|
271
|
+
* Only the states a learner is waiting on announce. `idle`, `unconfigured` and
|
|
272
|
+
* `error` are already rendered as visible text in the panel body, and repeating them
|
|
273
|
+
* here would announce the same sentence twice.
|
|
274
|
+
*/
|
|
275
|
+
export function termPanelStatusMessage(state, labels) {
|
|
276
|
+
if (state.kind === "searching")
|
|
277
|
+
return `Looking up ${state.term}`;
|
|
278
|
+
if (state.kind === "results") {
|
|
279
|
+
return `${labels.countLabel(state.items.length)} for ${state.term}`;
|
|
280
|
+
}
|
|
281
|
+
if (state.kind === "empty")
|
|
282
|
+
return labels.emptyLabel(state.term);
|
|
283
|
+
return "";
|
|
284
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* INCLUDING REFERENCES TO THE DATASTORE PACKAGE INSTEAD WILL MAKE PIEONEER THROW 500
|
|
8
8
|
* EVERYWHERE WITHOUT ANY FURTHER EXPLANATION
|
|
9
9
|
*/
|
|
10
|
+
import type { FormativeDeliveryPolicy, FormativeItemPolicy, FormativeSectionSlice } from "../formative/types.js";
|
|
11
|
+
import type { TimedMediaSectionData, TimedMediaSectionSessionSlice } from "../timed-media/types.js";
|
|
12
|
+
export type { FormativeDeliveryPolicy, FormativeItemPolicy };
|
|
13
|
+
export type { TimedMediaSectionData };
|
|
10
14
|
export interface BaseEntity {
|
|
11
15
|
id?: string;
|
|
12
16
|
createdAt?: Date;
|
|
@@ -233,6 +237,14 @@ export interface AssessmentItemRef extends SearchMetaDataEntity {
|
|
|
233
237
|
item?: ItemEntity;
|
|
234
238
|
/** Item-level settings for tool requirements and customization */
|
|
235
239
|
settings?: ItemSettings;
|
|
240
|
+
/**
|
|
241
|
+
* Formative delivery override for this item. Every field overrides the
|
|
242
|
+
* section's `formative` policy independently, which is the order QTI 3 uses
|
|
243
|
+
* for `qti-item-session-control` on an item ref versus its section.
|
|
244
|
+
*
|
|
245
|
+
* See `docs/prds/formative-delivery-contract.md`.
|
|
246
|
+
*/
|
|
247
|
+
formative?: FormativeItemPolicy;
|
|
236
248
|
}
|
|
237
249
|
export interface AssessmentSection extends SearchMetaDataEntity, SettingsMetaDataEntity {
|
|
238
250
|
id?: string;
|
|
@@ -266,6 +278,35 @@ export interface AssessmentSection extends SearchMetaDataEntity, SettingsMetaDat
|
|
|
266
278
|
* delivered without an enclosing assessment.
|
|
267
279
|
*/
|
|
268
280
|
personalNeedsProfile?: PersonalNeedsProfile;
|
|
281
|
+
/**
|
|
282
|
+
* Formative delivery policy for this section: how many Tries a learner gets
|
|
283
|
+
* on each item, when feedback is revealed, and whether the behavior is on at
|
|
284
|
+
* all. Absent or `enabled: false` delivers exactly as a section without this
|
|
285
|
+
* field — no control, no state, no env override.
|
|
286
|
+
*
|
|
287
|
+
* Individual items override it through `AssessmentItemRef.formative`. See
|
|
288
|
+
* `docs/prds/formative-delivery-contract.md`.
|
|
289
|
+
*/
|
|
290
|
+
formative?: FormativeDeliveryPolicy;
|
|
291
|
+
/**
|
|
292
|
+
* The section flavor. Absent for every section authored so far, and the only
|
|
293
|
+
* value is `"timed-media"`, which pairs with `timedMedia` below.
|
|
294
|
+
*
|
|
295
|
+
* A discriminator on data, not a renderer selector: the host still picks the
|
|
296
|
+
* layout tag, as every integration that renders a section already does. See
|
|
297
|
+
* `docs/prds/timed-media-section-contract.md#delivery-attachment`.
|
|
298
|
+
*/
|
|
299
|
+
sectionType?: "timed-media";
|
|
300
|
+
/**
|
|
301
|
+
* Cue timeline and playback policy for a timed-media section. Carries no media
|
|
302
|
+
* payload — `stimulusRef` names the `class: "stimulus"` rubric block whose
|
|
303
|
+
* passage supplies the time source, so the media keeps a Catalog Owner and its
|
|
304
|
+
* captions, transcript and signed alternates resolve through the
|
|
305
|
+
* accessibility-catalog rail.
|
|
306
|
+
*
|
|
307
|
+
* Ignored unless `sectionType` is `"timed-media"`.
|
|
308
|
+
*/
|
|
309
|
+
timedMedia?: TimedMediaSectionData;
|
|
269
310
|
sort?: string;
|
|
270
311
|
}
|
|
271
312
|
export interface TestPart {
|
|
@@ -275,6 +316,70 @@ export interface TestPart {
|
|
|
275
316
|
submissionMode: "individual" | "simultaneous";
|
|
276
317
|
sections: AssessmentSection[];
|
|
277
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* The host-facing persistence snapshot for one section, returned by
|
|
321
|
+
* `SectionControllerHandle.getSession()` and accepted by `applySession()`.
|
|
322
|
+
*
|
|
323
|
+
* Canonical here rather than in a player or the toolkit: the toolkit is
|
|
324
|
+
* complementary to a player rather than an alternative to one and sits beneath
|
|
325
|
+
* both entry paths, so a shape both need belongs alongside `AssessmentSection`.
|
|
326
|
+
* The delivery slices it carries are defined in this package too, which is what
|
|
327
|
+
* previously forced them to reference this shape in prose instead of types.
|
|
328
|
+
*/
|
|
329
|
+
export interface SectionControllerSessionState {
|
|
330
|
+
currentItemIndex?: number;
|
|
331
|
+
visitedItemIdentifiers?: string[];
|
|
332
|
+
itemSessions: Record<string, unknown>;
|
|
333
|
+
/**
|
|
334
|
+
* Formative delivery state — Try counts and reveal state per item.
|
|
335
|
+
*
|
|
336
|
+
* Optional, and its absence is indistinguishable from a snapshot saved
|
|
337
|
+
* before formative delivery existed, which is what keeps existing persisted
|
|
338
|
+
* sessions valid. A slice with an unrecognized `version` is rejected whole
|
|
339
|
+
* and formative state starts clean; `itemSessions` in the same snapshot is
|
|
340
|
+
* unaffected, so a formative version bump never costs a learner responses.
|
|
341
|
+
*/
|
|
342
|
+
formative?: FormativeSectionSlice;
|
|
343
|
+
/**
|
|
344
|
+
* Timed-media delivery state — media progress, cue visits and completions.
|
|
345
|
+
*
|
|
346
|
+
* Same posture as `formative`: optional, absence indistinguishable from a
|
|
347
|
+
* pre-timed-media snapshot, and an unrecognized `version` rejected whole so
|
|
348
|
+
* cue progress restarts while item sessions in the same snapshot are applied
|
|
349
|
+
* untouched.
|
|
350
|
+
*/
|
|
351
|
+
timedMedia?: TimedMediaSectionSessionSlice;
|
|
352
|
+
}
|
|
353
|
+
export interface AssessmentSessionNavigationState {
|
|
354
|
+
currentSectionIndex: number;
|
|
355
|
+
visitedSectionIdentifiers: string[];
|
|
356
|
+
currentSectionIdentifier?: string;
|
|
357
|
+
}
|
|
358
|
+
export interface AssessmentSessionRealization {
|
|
359
|
+
seed: string;
|
|
360
|
+
sectionIdentifiers: string[];
|
|
361
|
+
}
|
|
362
|
+
export interface AssessmentSectionSessionState {
|
|
363
|
+
sectionIdentifier: string;
|
|
364
|
+
updatedAt: string;
|
|
365
|
+
/**
|
|
366
|
+
* The full section snapshot. Typing this as anything narrower drops the
|
|
367
|
+
* delivery slices without a compiler error at the point of loss.
|
|
368
|
+
*/
|
|
369
|
+
session: SectionControllerSessionState | null;
|
|
370
|
+
}
|
|
371
|
+
export interface AssessmentSession {
|
|
372
|
+
version: 1;
|
|
373
|
+
assessmentAttemptSessionIdentifier: string;
|
|
374
|
+
assessmentId: string;
|
|
375
|
+
startedAt: string;
|
|
376
|
+
updatedAt: string;
|
|
377
|
+
completedAt?: string;
|
|
378
|
+
navigationState: AssessmentSessionNavigationState;
|
|
379
|
+
realization: AssessmentSessionRealization;
|
|
380
|
+
sectionSessions: Record<string, AssessmentSectionSessionState>;
|
|
381
|
+
contextVariables?: Record<string, unknown>;
|
|
382
|
+
}
|
|
278
383
|
export interface ContextDeclaration {
|
|
279
384
|
identifier: string;
|
|
280
385
|
baseType: "boolean" | "integer" | "float" | "string" | "identifier" | "point" | "pair" | "directedPair" | "duration" | "file" | "uri";
|
|
@@ -950,4 +1055,3 @@ export interface CmsBlueprintItemEntity extends BaseEntity {
|
|
|
950
1055
|
details?: string;
|
|
951
1056
|
assessmentId?: string;
|
|
952
1057
|
}
|
|
953
|
-
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function coerceBooleanLike(value: boolean | string | null | undefined, defaultValue?: boolean): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function coerceBooleanLike(value, defaultValue = false) {
|
|
2
|
+
if (typeof value === "boolean") {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
if (value === null || value === undefined) {
|
|
6
|
+
return defaultValue;
|
|
7
|
+
}
|
|
8
|
+
const normalizedValue = String(value).trim().toLowerCase();
|
|
9
|
+
if (normalizedValue === "") {
|
|
10
|
+
return defaultValue;
|
|
11
|
+
}
|
|
12
|
+
if (["false", "0", "off", "no"].includes(normalizedValue)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (["true", "1", "on", "yes"].includes(normalizedValue)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return Boolean(normalizedValue);
|
|
19
|
+
}
|
|
@@ -8,3 +8,25 @@ export declare const FOCUSABLE_SELECTOR: string;
|
|
|
8
8
|
* not disabled, not inside `inert`, and has a layout box).
|
|
9
9
|
*/
|
|
10
10
|
export declare function isProgrammaticFocusTarget(el: HTMLElement): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Whether `el` is in the sequential tab order.
|
|
13
|
+
*
|
|
14
|
+
* Stricter than {@link isProgrammaticFocusTarget}, which deliberately accepts
|
|
15
|
+
* `tabindex="-1"`: such an element is a valid `focus()` target — a roving-tabindex
|
|
16
|
+
* control or a landmark focused after a view change — but is explicitly not tabbable.
|
|
17
|
+
* A tab order that included it would stop on elements the browser skips.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isTabbable(el: HTMLElement): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Focusable descendants of `root` in tab order, descending into open shadow roots.
|
|
22
|
+
*
|
|
23
|
+
* `querySelectorAll` stops at a shadow boundary, which makes it the wrong tool for
|
|
24
|
+
* collecting a tab order in this codebase: every tool renders into `shadow: "open"`,
|
|
25
|
+
* so a container holding one has focusable content the selector cannot see. A focus
|
|
26
|
+
* trap built on the flat query traps Tab in the host's own chrome and never reaches
|
|
27
|
+
* the tool — the tool's controls are then unreachable by keyboard entirely.
|
|
28
|
+
*
|
|
29
|
+
* Closed shadow roots are invisible to script and are skipped; nothing can be done
|
|
30
|
+
* for them from outside.
|
|
31
|
+
*/
|
|
32
|
+
export declare function collectFocusable(root: Element | ShadowRoot): HTMLElement[];
|