@citolab/tspci 2.12.1 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +14 -4
- package/preset/base/AUTHORING.md +171 -0
- package/preset/base/global.d.ts +12 -2
- package/preset/base/package.json +10 -1
- package/preset/base/src/config.json +37 -5
- package/preset/base/src/styles.css +96 -3
- package/preset/javascript/src/index.js +116 -114
- package/preset/javascript/src/pie.js +46 -0
- package/preset/preact/src/index.tsx +117 -114
- package/preset/preact/src/interaction.tsx +47 -34
- package/preset/preact/src/pie.tsx +46 -0
- package/preset/preact/src/store.ts +12 -14
- package/preset/tailwind/src/styles.css +4 -27
- package/preset/typescript/src/index.ts +123 -127
- package/preset/typescript/src/pie.ts +51 -0
- package/public/index.html +196 -51
- package/readme.md +107 -17
- package/scripts/test-init-local.sh +28 -0
- package/src/authoring-frame.js +85 -0
- package/src/authoring.js +114 -0
- package/src/rollup-plugin-authoring.js +32 -0
- package/src/rollup-plugin-postcss-string.js +37 -1
- package/src/rollup.config.dev.js +18 -1
- package/src/rollup.config.js +2 -0
- package/tspci-development.png +0 -0
- package/types/index.d.ts +136 -0
- package/preset/base/src/assets/procenten.png +0 -0
|
@@ -1,45 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { PciProperties } from "@citolab/tspci";
|
|
2
|
+
import { IStore, useStore } from "@citolab/preact-store";
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
3
4
|
import configProps from "./config.json";
|
|
4
|
-
import {
|
|
5
|
+
import { Pie } from "./pie";
|
|
6
|
+
import { Actions, StateModel } from "./store";
|
|
7
|
+
// Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src={logo} />.
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
type PropTypes = PciProperties<typeof configProps>;
|
|
7
10
|
|
|
8
|
-
type
|
|
11
|
+
type Props = {
|
|
12
|
+
config: PropTypes;
|
|
13
|
+
store: IStore<StateModel, Actions>;
|
|
14
|
+
readOnly: boolean; // review, solution, closed: show the answer, but do not change it
|
|
15
|
+
};
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
if (!eventHandled) {
|
|
16
|
-
const input = e.target as HTMLInputElement;
|
|
17
|
-
store.dispatch<{ input: number }>({ type: "SET_INPUT", payload: { input: +input.value } });
|
|
18
|
-
setEventHandled(true);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
17
|
+
/** The interaction itself: Tailwind classes only, styles.css just loads Tailwind (into the shadow root). */
|
|
18
|
+
const Interaction = ({ config, store, readOnly }: Props) => {
|
|
19
|
+
const { value } = useStore(store); // re-renders when the store changes
|
|
20
|
+
const input = useRef<HTMLInputElement>(null);
|
|
21
|
+
const [invalid, setInvalid] = useState(false);
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
// aria-invalid tells assistive technology, and the aria-invalid: classes show it with a red outline.
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
setInvalid(value !== "" && !input.current.checkValidity());
|
|
26
|
+
}, [value]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="mx-auto flex w-fit max-w-full flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-linear-to-b from-white to-slate-50 px-7 py-6 font-sans text-slate-800 shadow-lg">
|
|
30
|
+
<h2 className="text-xl font-semibold tracking-tight">{config.title}</h2>
|
|
31
|
+
<Pie config={config} />
|
|
32
|
+
{/* htmlFor ties the question to the input: a screen reader reads it, a click on it focuses the input */}
|
|
33
|
+
<label className="max-w-[32ch] cursor-pointer text-center text-slate-600" htmlFor="answer">
|
|
34
|
+
{config.question}
|
|
35
|
+
</label>
|
|
36
|
+
<div className="flex items-center gap-1.5 text-lg font-semibold">
|
|
37
|
+
<input
|
|
38
|
+
ref={input}
|
|
39
|
+
id="answer"
|
|
32
40
|
type="number"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
inputMode="numeric"
|
|
42
|
+
min={0}
|
|
43
|
+
max={100}
|
|
44
|
+
step={1}
|
|
45
|
+
autoComplete="off"
|
|
46
|
+
value={value}
|
|
47
|
+
disabled={readOnly}
|
|
48
|
+
aria-invalid={invalid}
|
|
49
|
+
onInput={(e) => store.dispatch({ type: "SET_VALUE", payload: { value: e.currentTarget.value } })}
|
|
50
|
+
className="w-20 rounded-lg border-[1.5px] border-slate-300 bg-white px-2.5 py-2 text-right outline-none transition [appearance:textfield] focus-visible:border-blue-500 focus-visible:ring-3 focus-visible:ring-blue-500/30 aria-invalid:border-rose-600 aria-invalid:ring-3 aria-invalid:ring-rose-600/20 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
51
|
+
/>
|
|
52
|
+
<span>%</span>
|
|
39
53
|
</div>
|
|
40
54
|
</div>
|
|
41
|
-
|
|
55
|
+
);
|
|
42
56
|
};
|
|
43
57
|
|
|
44
|
-
|
|
45
58
|
export default Interaction;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PciProperties } from "@citolab/tspci";
|
|
2
|
+
import configProps from "./config.json";
|
|
3
|
+
|
|
4
|
+
type PropTypes = PciProperties<typeof configProps>;
|
|
5
|
+
|
|
6
|
+
/** Settings arrive as strings (they are data- attributes) and an author may type anything: parse and clamp them. */
|
|
7
|
+
const toInt = (value: string, fallback: number, min: number, max: number) => {
|
|
8
|
+
const n = Number.parseInt(value, 10);
|
|
9
|
+
return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
|
|
13
|
+
export const Pie = ({ config }: { config: PropTypes }) => {
|
|
14
|
+
// the limits match the min/max in config.json
|
|
15
|
+
const segments = toInt(config.segments, 10, 2, 24);
|
|
16
|
+
const colored = toInt(config.colored, 0, 0, segments);
|
|
17
|
+
const size = toInt(config.size, 200, 100, 400);
|
|
18
|
+
const c = size / 2; // centre
|
|
19
|
+
const r = c - 4; // leave room for the outline
|
|
20
|
+
const point = (i: number) => {
|
|
21
|
+
const angle = -Math.PI / 2 + (2 * Math.PI * i) / segments;
|
|
22
|
+
return `${(c + r * Math.cos(angle)).toFixed(2)} ${(c + r * Math.sin(angle)).toFixed(2)}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// role="img" + aria-label: a screen reader reads one description instead of a list of paths.
|
|
26
|
+
// Translate the label when your items are not in English.
|
|
27
|
+
return (
|
|
28
|
+
<svg
|
|
29
|
+
className="h-auto max-w-full drop-shadow-md"
|
|
30
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
31
|
+
width={size}
|
|
32
|
+
height={size}
|
|
33
|
+
role="img"
|
|
34
|
+
aria-label={`A circle in ${segments} equal slices, ${colored} of them coloured`}
|
|
35
|
+
>
|
|
36
|
+
{Array.from({ length: segments }, (_, i) => (
|
|
37
|
+
<path
|
|
38
|
+
key={i}
|
|
39
|
+
className={i < colored ? "stroke-white stroke-2" : "fill-slate-200 stroke-white stroke-2"}
|
|
40
|
+
fill={i < colored ? config.color || "#3b82f6" : undefined}
|
|
41
|
+
d={`M ${c} ${c} L ${point(i)} A ${r} ${r} 0 0 1 ${point(i + 1)} Z`}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { Store } from "@citolab/preact-store";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The state of the PCI. Every change goes through an action (store.dispatch), and the store keeps the list of actions:
|
|
5
|
+
* getState() saves both, so a session can be restored, and even replayed (store.replay), action by action.
|
|
6
|
+
*/
|
|
7
|
+
export type StateModel = { value: string }; // the text in the input, also when it is not a number yet
|
|
8
|
+
/** The actions, as { type: payload }: dispatch and addReducer only accept these. */
|
|
9
|
+
export type Actions = { SET_VALUE: { value: string } };
|
|
5
10
|
|
|
6
|
-
export const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
actions: { type: string; payload: any; timestamp?: number }[];
|
|
12
|
-
}
|
|
13
|
-
) => {
|
|
14
|
-
const store = new Store<StateModel>(initialState, restoreData);
|
|
15
|
-
store.addReducer<{ input: number | undefined }>("SET_INPUT", (state, { input }) => {
|
|
16
|
-
return { ...state, input };
|
|
17
|
-
});
|
|
11
|
+
export const initialState: StateModel = { value: "" };
|
|
12
|
+
|
|
13
|
+
export const initStore = (state: StateModel = initialState) => {
|
|
14
|
+
const store = new Store<StateModel, Actions>(state);
|
|
15
|
+
store.addReducer("SET_VALUE", (current, { value }) => ({ ...current, value }));
|
|
18
16
|
return store;
|
|
19
17
|
};
|
|
@@ -1,30 +1,7 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind 4, compiled into the bundle and added to the PCI's shadow root (index.tsx): its classes style only this PCI.
|
|
3
|
+
* The components use the utility classes directly; @source tells Tailwind where to find them.
|
|
4
|
+
*/
|
|
1
5
|
@import "tailwindcss";
|
|
2
6
|
@source "./**/*.{html,js,ts,jsx,tsx}";
|
|
3
7
|
@source "../*.xml";
|
|
4
|
-
.pci-container {
|
|
5
|
-
@apply mx-auto flex w-full max-w-xl flex-col items-center gap-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-lg;
|
|
6
|
-
}
|
|
7
|
-
.title {
|
|
8
|
-
@apply text-center text-2xl font-semibold tracking-tight text-slate-800;
|
|
9
|
-
}
|
|
10
|
-
.body {
|
|
11
|
-
@apply flex w-full justify-center;
|
|
12
|
-
}
|
|
13
|
-
.body-image {
|
|
14
|
-
@apply rounded-xl border border-slate-200 bg-slate-50 p-2;
|
|
15
|
-
}
|
|
16
|
-
.interaction {
|
|
17
|
-
@apply w-full rounded-xl border border-slate-200 bg-slate-50 p-4;
|
|
18
|
-
}
|
|
19
|
-
.prompt {
|
|
20
|
-
@apply mb-2 block text-sm font-medium text-slate-700;
|
|
21
|
-
}
|
|
22
|
-
.answer-row {
|
|
23
|
-
@apply flex items-center gap-2;
|
|
24
|
-
}
|
|
25
|
-
.answer-input {
|
|
26
|
-
@apply w-28 rounded-lg border border-slate-300 bg-white px-3 py-2 text-lg font-medium text-slate-900 shadow-sm outline-none transition focus:border-amber-500 focus:ring-2 focus:ring-amber-200;
|
|
27
|
-
}
|
|
28
|
-
.answer-suffix {
|
|
29
|
-
@apply text-sm font-semibold uppercase tracking-wide text-slate-500;
|
|
30
|
-
}
|
|
@@ -1,191 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A Portable Custom Interaction (PCI) in plain TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* The delivery engine (the player) loads this bundle, which registers a factory with qtiCustomInteractionContext.
|
|
5
|
+
* For every interaction in an item the engine calls factory.getInstance(dom, config, state); the instance renders
|
|
6
|
+
* into `dom` and reports its answer through getResponse().
|
|
7
|
+
*
|
|
8
|
+
* The demo: "what percentage of the circle is coloured?". The circle is drawn from the settings in src/config.json
|
|
9
|
+
* (pie.ts); the answer is a whole number, reported as { base: { integer: 30 } } (see config.tspci.response in
|
|
10
|
+
* package.json and AUTHORING.md).
|
|
11
|
+
*/
|
|
1
12
|
import {
|
|
2
13
|
ConfigProperties,
|
|
3
14
|
IMSpci,
|
|
4
15
|
IMSpciFactory,
|
|
16
|
+
PciProperties,
|
|
5
17
|
QtiInteractionChangedDetail,
|
|
6
18
|
QtiVariableJSON,
|
|
7
19
|
} from "@citolab/tspci";
|
|
8
|
-
import * as ctx from "qtiCustomInteractionContext";
|
|
9
|
-
import configProps from "./config.json";
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
type PropTypes = typeof configProps
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
|
|
21
|
+
import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
|
|
22
|
+
import style from "./styles.css"; // bundled as a string, added to the shadow root below
|
|
23
|
+
import { escapeHtml, pieSvg } from "./pie";
|
|
24
|
+
// Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
|
|
25
|
+
|
|
26
|
+
// Like every data- attribute, each setting arrives as a string.
|
|
27
|
+
type PropTypes = PciProperties<typeof configProps>;
|
|
28
|
+
|
|
29
|
+
/** What getState() saves and the constructor restores: the text in the input, also when it is not a number yet. */
|
|
30
|
+
type PciState = { value: string };
|
|
31
|
+
|
|
32
|
+
/** The response for the text in the input: a whole number, or undefined while there is no answer. */
|
|
33
|
+
function toResponse(value: string): QtiVariableJSON | undefined {
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (trimmed === "") return undefined; // nothing answered, nothing reported
|
|
36
|
+
const n = Number(trimmed);
|
|
37
|
+
// One answer, one response: "30", "030" and "30.0" all report 30.
|
|
38
|
+
return Number.isFinite(n) ? { base: { integer: Math.round(n) } } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The input text for a response (setResponse, boundTo) or a correct response (status "solution"). */
|
|
42
|
+
function fromResponse(value: unknown): string {
|
|
43
|
+
if (Array.isArray(value)) return fromResponse(value[0]);
|
|
44
|
+
if (value && typeof value === "object") {
|
|
45
|
+
const base = (value as QtiVariableJSON).base;
|
|
46
|
+
return fromResponse(base?.integer ?? base?.float ?? base?.string);
|
|
47
|
+
}
|
|
48
|
+
return value === undefined || value === null ? "" : String(value);
|
|
49
|
+
}
|
|
22
50
|
|
|
23
51
|
class PciInstance implements IMSpci<PropTypes> {
|
|
24
52
|
typeIdentifier = "###___PCI_NAME___###";
|
|
25
53
|
private config: ConfigProperties<PropTypes>;
|
|
26
54
|
private dom: HTMLElement;
|
|
27
55
|
private shadowdom: ShadowRoot;
|
|
28
|
-
private state: PciState;
|
|
29
|
-
private
|
|
56
|
+
private state: PciState = { value: "" };
|
|
57
|
+
private customValidity = "";
|
|
30
58
|
|
|
31
59
|
constructor(dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString?: string) {
|
|
32
60
|
if (!dom) throw new Error("No DOM Element provided");
|
|
33
|
-
|
|
61
|
+
// The item's data- attributes override the defaults from config.json.
|
|
62
|
+
config.properties = { ...(configProps as unknown as PropTypes), ...config.properties };
|
|
34
63
|
this.dom = dom;
|
|
35
64
|
this.config = config;
|
|
65
|
+
// A closed shadow root keeps the item's CSS out and ours in.
|
|
36
66
|
this.shadowdom = dom.attachShadow({ mode: "closed" });
|
|
37
67
|
|
|
38
|
-
|
|
68
|
+
// 1. The state from a previous session (whatever getState() returned).
|
|
39
69
|
if (stateString) {
|
|
40
70
|
try {
|
|
41
71
|
const parsed = JSON.parse(stateString) as Partial<PciState>;
|
|
42
|
-
this.state = {
|
|
43
|
-
value: typeof parsed.value === "string" ? parsed.value : "",
|
|
44
|
-
touched: parsed.touched === true,
|
|
45
|
-
customValidity: typeof parsed.customValidity === "string" ? parsed.customValidity : undefined,
|
|
46
|
-
};
|
|
72
|
+
this.state = { value: typeof parsed?.value === "string" ? parsed.value : "" };
|
|
47
73
|
} catch {
|
|
48
|
-
|
|
74
|
+
// not ours: start empty
|
|
49
75
|
}
|
|
50
76
|
}
|
|
77
|
+
// 2. A response the engine binds to the interaction (boundTo) wins over the state.
|
|
78
|
+
const boundTo = config.boundTo ?? {};
|
|
79
|
+
const bound = boundTo[config.responseIdentifier] ?? (Object.keys(boundTo).length === 1 ? Object.values(boundTo)[0] : undefined);
|
|
80
|
+
if (bound) this.state = { value: fromResponse(bound) };
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
|
|
56
|
-
: undefined);
|
|
57
|
-
if (boundResponse && (boundResponse.base || boundResponse.list || boundResponse.record)) {
|
|
58
|
-
this.setResponse?.(boundResponse);
|
|
59
|
-
}
|
|
82
|
+
this.render();
|
|
83
|
+
// 3. Status "solution": show the correct response of the item instead (AUTHORING.md, "Correct answers").
|
|
84
|
+
if (config.status === "solution") this.showCorrectResponse();
|
|
60
85
|
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
if (!target) return;
|
|
64
|
-
const nextValue = target.value ?? "";
|
|
65
|
-
if (nextValue === this.state.value && this.state.touched) return;
|
|
66
|
-
this.state = { ...this.state, value: nextValue, touched: true };
|
|
67
|
-
this.dispatchInteractionChanged();
|
|
68
|
-
};
|
|
86
|
+
config.onready?.(this, this.getState());
|
|
87
|
+
}
|
|
69
88
|
|
|
70
|
-
|
|
89
|
+
/** Read-only when the candidate can no longer answer. */
|
|
90
|
+
private get readOnly() {
|
|
91
|
+
return ["review", "solution", "closed"].includes(this.config.status ?? "interacting");
|
|
92
|
+
}
|
|
71
93
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
94
|
+
private get input() {
|
|
95
|
+
return this.shadowdom.querySelector<HTMLInputElement>("input");
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
private
|
|
78
|
-
|
|
98
|
+
private render() {
|
|
99
|
+
const p = this.config.properties;
|
|
100
|
+
// <label for> ties the question to the input, so a screen reader reads it and a click on it focuses the input.
|
|
101
|
+
this.shadowdom.innerHTML = `
|
|
102
|
+
<style>${style}</style>
|
|
103
|
+
<div class="pci">
|
|
104
|
+
<h2 class="title">${escapeHtml(p.title)}</h2>
|
|
105
|
+
${pieSvg(p)}
|
|
106
|
+
<label class="question" for="answer">${escapeHtml(p.question)}</label>
|
|
107
|
+
<div class="answer">
|
|
108
|
+
<input id="answer" type="number" inputmode="numeric" min="0" max="100" step="1" autocomplete="off"
|
|
109
|
+
value="${escapeHtml(this.state.value)}" ${this.readOnly ? "disabled" : ""} />
|
|
110
|
+
<span class="unit">%</span>
|
|
111
|
+
</div>
|
|
112
|
+
</div>`;
|
|
113
|
+
|
|
114
|
+
const input = this.input;
|
|
115
|
+
input.setCustomValidity(this.customValidity);
|
|
116
|
+
this.markValidity();
|
|
117
|
+
input.addEventListener("input", this.onInput);
|
|
79
118
|
}
|
|
80
119
|
|
|
81
|
-
private
|
|
120
|
+
private onInput = () => {
|
|
121
|
+
this.state = { value: this.input.value };
|
|
122
|
+
this.markValidity();
|
|
123
|
+
// Tell the engine the response changed; composed lets the event cross the shadow root.
|
|
82
124
|
const detail: QtiInteractionChangedDetail = {
|
|
83
125
|
interaction: this,
|
|
84
126
|
responseIdentifier: this.config.responseIdentifier,
|
|
85
|
-
valid: this.checkValidity
|
|
127
|
+
valid: this.checkValidity(),
|
|
86
128
|
value: this.getResponse(),
|
|
87
129
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
composed: true,
|
|
91
|
-
cancelable: true,
|
|
92
|
-
detail,
|
|
93
|
-
});
|
|
94
|
-
this.dom.dispatchEvent(event);
|
|
95
|
-
}
|
|
130
|
+
this.dom.dispatchEvent(new CustomEvent("qti-interaction-changed", { bubbles: true, composed: true, detail }));
|
|
131
|
+
};
|
|
96
132
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<img width="${+this.config.properties.width}" height="${+this.config
|
|
102
|
-
.properties.height}" src="${procenten}" />
|
|
103
|
-
</div>
|
|
104
|
-
<div class="interaction">
|
|
105
|
-
<label>${this.config.properties.prompt}</label>
|
|
106
|
-
<input type="number" value="${this.state.value}" min="0" max="100">%
|
|
107
|
-
</div>
|
|
108
|
-
</div>`;
|
|
109
|
-
|
|
110
|
-
const css = document.createElement("style");
|
|
111
|
-
css.innerHTML = style;
|
|
112
|
-
this.shadowdom.appendChild(css);
|
|
113
|
-
|
|
114
|
-
const input = this.getInputEl();
|
|
115
|
-
if (input) {
|
|
116
|
-
if (this.state.customValidity) input.setCustomValidity(this.state.customValidity);
|
|
117
|
-
input.addEventListener("input", this.onChange);
|
|
118
|
-
input.addEventListener("change", this.onChange);
|
|
119
|
-
}
|
|
133
|
+
/** aria-invalid tells assistive technology, and styles.css shows it with a red outline. */
|
|
134
|
+
private markValidity() {
|
|
135
|
+
const input = this.input;
|
|
136
|
+
input.setAttribute("aria-invalid", String(input.value !== "" && !input.checkValidity()));
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
: "";
|
|
129
|
-
this.state = { ...this.state, value: next, touched: true };
|
|
130
|
-
const input = this.getInputEl();
|
|
131
|
-
if (input) input.value = next;
|
|
139
|
+
/** Show the correct response the item declares (status "solution"); without one, keep what is there. */
|
|
140
|
+
showCorrectResponse = () => {
|
|
141
|
+
const correct = this.config.responseDeclaration?.correctResponse?.value;
|
|
142
|
+
if (correct === undefined) return;
|
|
143
|
+
this.state = { value: fromResponse(correct) };
|
|
144
|
+
this.render();
|
|
132
145
|
};
|
|
133
146
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!trimmed) return undefined;
|
|
138
|
-
const asNumber = Number(trimmed);
|
|
139
|
-
if (!Number.isFinite(asNumber)) return undefined;
|
|
140
|
-
return { base: { integer: Math.trunc(asNumber) } } as QtiVariableJSON;
|
|
141
|
-
};
|
|
147
|
+
// ---- the IMS PCI API ----
|
|
148
|
+
|
|
149
|
+
getResponse = () => toResponse(this.state.value);
|
|
142
150
|
|
|
143
151
|
getState = () => JSON.stringify(this.state);
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return input.checkValidity();
|
|
153
|
+
setResponse = (response: QtiVariableJSON) => {
|
|
154
|
+
this.state = { value: fromResponse(response) };
|
|
155
|
+
this.render();
|
|
149
156
|
};
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return input.reportValidity();
|
|
155
|
-
};
|
|
158
|
+
checkValidity = () => this.input?.checkValidity() ?? true;
|
|
159
|
+
|
|
160
|
+
reportValidity = () => this.input?.reportValidity() ?? true;
|
|
156
161
|
|
|
157
162
|
setCustomValidity = (message: string) => {
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
if (input) input.setCustomValidity(message);
|
|
163
|
+
this.customValidity = message ?? "";
|
|
164
|
+
this.input?.setCustomValidity(this.customValidity);
|
|
161
165
|
};
|
|
162
166
|
|
|
163
|
-
getCustomValidity = () => this.
|
|
167
|
+
getCustomValidity = () => this.customValidity;
|
|
164
168
|
|
|
169
|
+
/** The engine is done with this instance: release what you hold (listeners on window, timers, ...). */
|
|
165
170
|
oncompleted = () => {
|
|
166
|
-
|
|
167
|
-
if (input) {
|
|
168
|
-
input.removeEventListener("input", this.onChange);
|
|
169
|
-
input.removeEventListener("change", this.onChange);
|
|
170
|
-
}
|
|
171
|
+
this.input?.removeEventListener("input", this.onInput);
|
|
171
172
|
};
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
const factory: IMSpciFactory<PropTypes> = {
|
|
175
|
-
typeIdentifier: "###___PCI_NAME___###",
|
|
176
|
+
typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
|
|
176
177
|
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
177
178
|
};
|
|
178
179
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
: undefined;
|
|
186
|
-
|
|
187
|
-
if (engineContext) {
|
|
188
|
-
engineContext.register(factory);
|
|
189
|
-
}
|
|
180
|
+
// Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
|
|
181
|
+
type Context = { register: (factory: IMSpciFactory<PropTypes>) => void };
|
|
182
|
+
const engineContext = [ctx as unknown as Context, (globalThis as { qtiCustomInteractionContext?: Context }).qtiCustomInteractionContext].find(
|
|
183
|
+
(c) => typeof c?.register === "function"
|
|
184
|
+
);
|
|
185
|
+
engineContext?.register(factory);
|
|
190
186
|
|
|
191
187
|
export default factory;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PciProperties } from "@citolab/tspci";
|
|
2
|
+
import configProps from "./config.json";
|
|
3
|
+
|
|
4
|
+
type PropTypes = PciProperties<typeof configProps>;
|
|
5
|
+
|
|
6
|
+
/** Settings arrive as strings (they are data- attributes) and an author may type anything: parse and clamp them. */
|
|
7
|
+
const toInt = (value: string, fallback: number, min: number, max: number) => {
|
|
8
|
+
const n = Number.parseInt(value, 10);
|
|
9
|
+
return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** The pie the settings describe; the limits match the min/max in config.json. */
|
|
13
|
+
export function pieSettings(p: PropTypes) {
|
|
14
|
+
const segments = toInt(p.segments, 10, 2, 24);
|
|
15
|
+
return {
|
|
16
|
+
segments,
|
|
17
|
+
colored: toInt(p.colored, 0, 0, segments),
|
|
18
|
+
size: toInt(p.size, 200, 100, 400),
|
|
19
|
+
color: p.color || "#3b82f6",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An SVG circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
|
|
24
|
+
export function pieSvg(p: PropTypes): string {
|
|
25
|
+
const { segments, colored, size, color } = pieSettings(p);
|
|
26
|
+
const c = size / 2; // centre
|
|
27
|
+
const r = c - 4; // leave room for the outline
|
|
28
|
+
const point = (i: number) => {
|
|
29
|
+
const angle = -Math.PI / 2 + (2 * Math.PI * i) / segments;
|
|
30
|
+
return `${(c + r * Math.cos(angle)).toFixed(2)} ${(c + r * Math.sin(angle)).toFixed(2)}`;
|
|
31
|
+
};
|
|
32
|
+
const slices = Array.from(
|
|
33
|
+
{ length: segments },
|
|
34
|
+
(_, i) =>
|
|
35
|
+
`<path class="slice" d="M ${c} ${c} L ${point(i)} A ${r} ${r} 0 0 1 ${point(i + 1)} Z"${
|
|
36
|
+
i < colored ? ` fill="${escapeHtml(color)}"` : ""
|
|
37
|
+
}/>`
|
|
38
|
+
).join("");
|
|
39
|
+
// role="img" + aria-label: a screen reader reads one description instead of a list of paths.
|
|
40
|
+
// Translate the label when your items are not in English.
|
|
41
|
+
return `<svg class="pie" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"
|
|
42
|
+
role="img" aria-label="A circle in ${segments} equal slices, ${colored} of them coloured">${slices}</svg>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Settings and answers end up in innerHTML: escape them, or an author's "<" breaks the markup. */
|
|
46
|
+
export const escapeHtml = (value: unknown) =>
|
|
47
|
+
String(value ?? "")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, """);
|