@pie-players/pie-tool-answer-eliminator 0.2.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.
Files changed (38) hide show
  1. package/README.md +282 -0
  2. package/adapters/adapter-registry.ts +64 -0
  3. package/adapters/choice-adapter.ts +50 -0
  4. package/adapters/ebsr-adapter.ts +61 -0
  5. package/adapters/inline-dropdown-adapter.ts +46 -0
  6. package/adapters/multiple-choice-adapter.ts +96 -0
  7. package/answer-eliminator-core.ts +465 -0
  8. package/dist/adapters/adapter-registry.d.ts +26 -0
  9. package/dist/adapters/adapter-registry.d.ts.map +1 -0
  10. package/dist/adapters/choice-adapter.d.ts +43 -0
  11. package/dist/adapters/choice-adapter.d.ts.map +1 -0
  12. package/dist/adapters/ebsr-adapter.d.ts +20 -0
  13. package/dist/adapters/ebsr-adapter.d.ts.map +1 -0
  14. package/dist/adapters/inline-dropdown-adapter.d.ts +18 -0
  15. package/dist/adapters/inline-dropdown-adapter.d.ts.map +1 -0
  16. package/dist/adapters/multiple-choice-adapter.d.ts +20 -0
  17. package/dist/adapters/multiple-choice-adapter.d.ts.map +1 -0
  18. package/dist/answer-eliminator-core.d.ts +98 -0
  19. package/dist/answer-eliminator-core.d.ts.map +1 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/strategies/elimination-strategy.d.ts +41 -0
  23. package/dist/strategies/elimination-strategy.d.ts.map +1 -0
  24. package/dist/strategies/mask-strategy.d.ts +28 -0
  25. package/dist/strategies/mask-strategy.d.ts.map +1 -0
  26. package/dist/strategies/strikethrough-strategy.d.ts +31 -0
  27. package/dist/strategies/strikethrough-strategy.d.ts.map +1 -0
  28. package/dist/tool-answer-eliminator.js +2838 -0
  29. package/dist/tool-answer-eliminator.js.map +1 -0
  30. package/dist/tool-answer-eliminator.svelte.d.ts +1 -0
  31. package/dist/vite.config.d.ts +3 -0
  32. package/dist/vite.config.d.ts.map +1 -0
  33. package/index.ts +11 -0
  34. package/package.json +69 -0
  35. package/strategies/elimination-strategy.ts +47 -0
  36. package/strategies/mask-strategy.ts +180 -0
  37. package/strategies/strikethrough-strategy.ts +239 -0
  38. package/tool-answer-eliminator.svelte +250 -0
@@ -0,0 +1 @@
1
+ export { SvelteComponent as default } from 'svelte';
@@ -0,0 +1,3 @@
1
+ declare const _default: import('vite').UserConfig;
2
+ export default _default;
3
+ //# sourceMappingURL=vite.config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["../vite.config.ts"],"names":[],"mappings":";AAKA,wBAmCG"}
package/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * pie-tool-answer-eliminator - PIE Assessment Tool
3
+ *
4
+ * This package exports a web component built from Svelte.
5
+ * Import the built version for CDN usage, or the .svelte source for Svelte projects.
6
+ */
7
+
8
+ // Re-export AdapterRegistry for auto-hide detection
9
+ export { AdapterRegistry } from "./adapters/adapter-registry";
10
+
11
+ // Re-export any TypeScript types defined in the package
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@pie-players/pie-tool-answer-eliminator",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Answer eliminator tool for PIE assessment player - supports process-of-elimination test-taking strategy",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pie-framework/pie-players.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "keywords": [
14
+ "pie",
15
+ "assessment",
16
+ "tool",
17
+ "answer-eliminator",
18
+ "test-strategy",
19
+ "wcag",
20
+ "accessibility"
21
+ ],
22
+ "svelte": "./tool-answer-eliminator.svelte",
23
+ "main": "./dist/tool-answer-eliminator.js",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/tool-answer-eliminator.js",
28
+ "svelte": "./tool-answer-eliminator.svelte"
29
+ },
30
+ "./adapters/adapter-registry": {
31
+ "types": "./adapters/adapter-registry.ts",
32
+ "import": "./adapters/adapter-registry.ts"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "tool-answer-eliminator.svelte",
38
+ "index.ts",
39
+ "package.json",
40
+ "answer-eliminator-core.ts",
41
+ "adapters/",
42
+ "strategies/"
43
+ ],
44
+ "peerDependencies": {
45
+ "svelte": "^5.0.0"
46
+ },
47
+ "license": "MIT",
48
+ "unpkg": "./dist/tool-answer-eliminator.js",
49
+ "jsdelivr": "./dist/tool-answer-eliminator.js",
50
+ "dependencies": {
51
+ "@pie-players/pie-assessment-toolkit": "workspace:*",
52
+ "@pie-players/pie-players-shared": "workspace:*"
53
+ },
54
+ "types": "./dist/index.d.ts",
55
+ "scripts": {
56
+ "build": "vite build",
57
+ "dev": "vite build --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "lint": "biome check ."
60
+ },
61
+ "devDependencies": {
62
+ "@biomejs/biome": "^2.3.10",
63
+ "@sveltejs/vite-plugin-svelte": "^6.1.4",
64
+ "svelte": "^5.16.1",
65
+ "typescript": "^5.7.0",
66
+ "vite": "^7.0.8",
67
+ "vite-plugin-dts": "^4.5.3"
68
+ }
69
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Strategy interface for visual elimination styles
3
+ * Uses CSS Custom Highlight API for zero DOM mutation
4
+ */
5
+ export interface EliminationStrategy {
6
+ /** Strategy name */
7
+ readonly name: string;
8
+
9
+ /**
10
+ * Apply elimination visual to a choice
11
+ * @param choiceId - Unique identifier for this choice
12
+ * @param range - DOM Range covering the choice content
13
+ */
14
+ apply(choiceId: string, range: Range): void;
15
+
16
+ /**
17
+ * Remove elimination visual from a choice
18
+ * @param choiceId - Unique identifier for this choice
19
+ */
20
+ remove(choiceId: string): void;
21
+
22
+ /**
23
+ * Check if a choice is currently eliminated
24
+ * @param choiceId - Unique identifier for this choice
25
+ */
26
+ isEliminated(choiceId: string): boolean;
27
+
28
+ /**
29
+ * Clear all eliminations
30
+ */
31
+ clearAll(): void;
32
+
33
+ /**
34
+ * Get all eliminated choice IDs
35
+ */
36
+ getEliminatedIds(): string[];
37
+
38
+ /**
39
+ * Initialize strategy (inject CSS, etc.)
40
+ */
41
+ initialize(): void;
42
+
43
+ /**
44
+ * Cleanup strategy (remove CSS, etc.)
45
+ */
46
+ destroy(): void;
47
+ }
@@ -0,0 +1,180 @@
1
+ import type { EliminationStrategy } from "./elimination-strategy";
2
+
3
+ /**
4
+ * Mask strategy using CSS Custom Highlight API
5
+ * Partially hides/grays eliminated choices
6
+ */
7
+ export class MaskStrategy implements EliminationStrategy {
8
+ readonly name = "mask";
9
+
10
+ private highlights = new Map<string, Highlight>();
11
+ private ranges = new Map<string, Range>();
12
+
13
+ initialize(): void {
14
+ this.injectCSS();
15
+ }
16
+
17
+ destroy(): void {
18
+ this.clearAll();
19
+ this.removeCSS();
20
+ }
21
+
22
+ apply(choiceId: string, range: Range): void {
23
+ if (!this.isSupported()) {
24
+ this.applyFallback(choiceId, range);
25
+ return;
26
+ }
27
+
28
+ // Inject CSS for this specific highlight
29
+ this.injectHighlightCSS(choiceId);
30
+
31
+ const highlight = new Highlight(range);
32
+ CSS.highlights.set(`answer-masked-${choiceId}`, highlight);
33
+
34
+ this.highlights.set(choiceId, highlight);
35
+ this.ranges.set(choiceId, range);
36
+
37
+ this.addAriaAttributes(range);
38
+ }
39
+
40
+ remove(choiceId: string): void {
41
+ if (!this.isSupported()) {
42
+ this.removeFallback(choiceId);
43
+ return;
44
+ }
45
+
46
+ CSS.highlights.delete(`answer-masked-${choiceId}`);
47
+
48
+ // Remove CSS for this specific highlight
49
+ this.removeHighlightCSS(choiceId);
50
+
51
+ const range = this.ranges.get(choiceId);
52
+ if (range) {
53
+ this.removeAriaAttributes(range);
54
+ }
55
+
56
+ this.highlights.delete(choiceId);
57
+ this.ranges.delete(choiceId);
58
+ }
59
+
60
+ isEliminated(choiceId: string): boolean {
61
+ return this.highlights.has(choiceId);
62
+ }
63
+
64
+ clearAll(): void {
65
+ for (const choiceId of this.highlights.keys()) {
66
+ this.remove(choiceId);
67
+ }
68
+ }
69
+
70
+ getEliminatedIds(): string[] {
71
+ return Array.from(this.highlights.keys());
72
+ }
73
+
74
+ private isSupported(): boolean {
75
+ return typeof CSS !== "undefined" && "highlights" in CSS;
76
+ }
77
+
78
+ private injectCSS(): void {
79
+ const styleId = "answer-eliminator-mask-styles";
80
+ if (document.getElementById(styleId)) return;
81
+
82
+ const style = document.createElement("style");
83
+ style.id = styleId;
84
+ // CSS Custom Highlight API: Each registered highlight gets its own ::highlight() selector
85
+ // We inject choice-specific styles dynamically in injectHighlightCSS()
86
+ style.textContent = `
87
+ /* Fallback */
88
+ .answer-masked-fallback {
89
+ opacity: 0.2;
90
+ filter: blur(2px);
91
+ }
92
+ `;
93
+
94
+ document.head.appendChild(style);
95
+ }
96
+
97
+ private injectHighlightCSS(choiceId: string): void {
98
+ const styleId = `answer-eliminator-mask-highlight-${choiceId}`;
99
+ if (document.getElementById(styleId)) return;
100
+
101
+ const style = document.createElement("style");
102
+ style.id = styleId;
103
+ style.textContent = `
104
+ ::highlight(answer-masked-${choiceId}) {
105
+ opacity: 0.2;
106
+ filter: blur(2px);
107
+ }
108
+ `;
109
+ document.head.appendChild(style);
110
+ }
111
+
112
+ private removeHighlightCSS(choiceId: string): void {
113
+ document
114
+ .getElementById(`answer-eliminator-mask-highlight-${choiceId}`)
115
+ ?.remove();
116
+ }
117
+
118
+ private removeCSS(): void {
119
+ document.getElementById("answer-eliminator-mask-styles")?.remove();
120
+ }
121
+
122
+ private addAriaAttributes(range: Range): void {
123
+ const container = this.findChoiceContainer(range);
124
+ if (!container) return;
125
+
126
+ container.setAttribute("data-eliminated", "true");
127
+ container.setAttribute("aria-hidden", "true");
128
+ }
129
+
130
+ private removeAriaAttributes(range: Range): void {
131
+ const container = this.findChoiceContainer(range);
132
+ if (!container) return;
133
+
134
+ container.removeAttribute("data-eliminated");
135
+ container.removeAttribute("aria-hidden");
136
+ }
137
+
138
+ private findChoiceContainer(range: Range): HTMLElement | null {
139
+ let element: HTMLElement | null = range.startContainer as HTMLElement;
140
+
141
+ // If startContainer is a text node, get its parent
142
+ if (element.nodeType === Node.TEXT_NODE) {
143
+ element = element.parentElement;
144
+ }
145
+
146
+ while (element && element !== document.body) {
147
+ if (element.classList?.contains("choice-input")) {
148
+ return element;
149
+ }
150
+ element = element.parentElement;
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ private applyFallback(choiceId: string, range: Range): void {
157
+ const container = this.findChoiceContainer(range);
158
+ if (!container) return;
159
+
160
+ container.classList.add("answer-masked-fallback");
161
+ container.setAttribute("data-eliminated", "true");
162
+ container.setAttribute("data-eliminated-id", choiceId);
163
+ this.addAriaAttributes(range);
164
+ }
165
+
166
+ private removeFallback(choiceId: string): void {
167
+ const container = document.querySelector(
168
+ `[data-eliminated-id="${choiceId}"]`,
169
+ );
170
+ if (!container) return;
171
+
172
+ container.classList.remove("answer-masked-fallback");
173
+ container.removeAttribute("data-eliminated");
174
+ container.removeAttribute("data-eliminated-id");
175
+
176
+ const range = document.createRange();
177
+ range.selectNodeContents(container);
178
+ this.removeAriaAttributes(range);
179
+ }
180
+ }
@@ -0,0 +1,239 @@
1
+ import type { EliminationStrategy } from "./elimination-strategy";
2
+
3
+ /**
4
+ * Strikethrough strategy using CSS Custom Highlight API
5
+ *
6
+ * Modern approach: Zero DOM mutation, uses browser-native highlighting
7
+ * Accessibility: Best for screen readers (text remains in DOM unchanged)
8
+ * WCAG Compliance: Maintains info structure (1.3.1), no layout shift (2.4.3)
9
+ */
10
+ export class StrikethroughStrategy implements EliminationStrategy {
11
+ readonly name = "strikethrough";
12
+
13
+ private highlights = new Map<string, Highlight>();
14
+ private ranges = new Map<string, Range>();
15
+
16
+ initialize(): void {
17
+ // Check browser support
18
+ if (!this.isSupported()) {
19
+ console.warn("CSS Custom Highlight API not supported, using fallback");
20
+ return;
21
+ }
22
+
23
+ // Inject CSS for highlight styling
24
+ this.injectCSS();
25
+ }
26
+
27
+ destroy(): void {
28
+ this.clearAll();
29
+ this.removeCSS();
30
+ }
31
+
32
+ apply(choiceId: string, range: Range): void {
33
+ if (!this.isSupported()) {
34
+ this.applyFallback(choiceId, range);
35
+ return;
36
+ }
37
+
38
+ // Inject CSS for this specific highlight
39
+ this.injectHighlightCSS(choiceId);
40
+
41
+ // Create highlight for this range
42
+ const highlight = new Highlight(range);
43
+
44
+ // Register in CSS.highlights with unique name
45
+ CSS.highlights.set(`answer-eliminated-${choiceId}`, highlight);
46
+
47
+ // Track internally
48
+ this.highlights.set(choiceId, highlight);
49
+ this.ranges.set(choiceId, range);
50
+
51
+ // Add ARIA attributes to the choice element for screen readers
52
+ this.addAriaAttributes(range);
53
+ }
54
+
55
+ remove(choiceId: string): void {
56
+ if (!this.isSupported()) {
57
+ this.removeFallback(choiceId);
58
+ return;
59
+ }
60
+
61
+ // Remove from CSS.highlights
62
+ CSS.highlights.delete(`answer-eliminated-${choiceId}`);
63
+
64
+ // Remove CSS for this specific highlight
65
+ this.removeHighlightCSS(choiceId);
66
+
67
+ // Remove from internal tracking
68
+ const range = this.ranges.get(choiceId);
69
+ if (range) {
70
+ this.removeAriaAttributes(range);
71
+ }
72
+
73
+ this.highlights.delete(choiceId);
74
+ this.ranges.delete(choiceId);
75
+ }
76
+
77
+ isEliminated(choiceId: string): boolean {
78
+ return this.highlights.has(choiceId);
79
+ }
80
+
81
+ clearAll(): void {
82
+ // Remove all highlights
83
+ for (const choiceId of this.highlights.keys()) {
84
+ this.remove(choiceId);
85
+ }
86
+ }
87
+
88
+ getEliminatedIds(): string[] {
89
+ return Array.from(this.highlights.keys());
90
+ }
91
+
92
+ private isSupported(): boolean {
93
+ return typeof CSS !== "undefined" && "highlights" in CSS;
94
+ }
95
+
96
+ private injectCSS(): void {
97
+ const styleId = "answer-eliminator-strikethrough-styles";
98
+
99
+ // Don't inject if already exists
100
+ if (document.getElementById(styleId)) return;
101
+
102
+ const style = document.createElement("style");
103
+ style.id = styleId;
104
+ // CSS Custom Highlight API: Each registered highlight gets its own ::highlight() selector
105
+ // Since we register highlights with names like 'answer-eliminated-{choiceId}',
106
+ // we need to inject CSS for each one dynamically, OR use a shared attribute approach.
107
+ // For performance, we inject a base style and add choice-specific styles dynamically.
108
+ style.textContent = `
109
+ /* Fallback for browsers without CSS Highlight API */
110
+ .answer-eliminated-fallback {
111
+ text-decoration: line-through;
112
+ text-decoration-thickness: 2px;
113
+ text-decoration-color: var(--pie-incorrect, #ff9800);
114
+ opacity: 0.6;
115
+ }
116
+
117
+ /* Screen reader only text */
118
+ .sr-announcement {
119
+ position: absolute;
120
+ left: -10000px;
121
+ width: 1px;
122
+ height: 1px;
123
+ overflow: hidden;
124
+ }
125
+ `;
126
+
127
+ document.head.appendChild(style);
128
+ }
129
+
130
+ private injectHighlightCSS(choiceId: string): void {
131
+ const styleId = `answer-eliminator-highlight-${choiceId}`;
132
+ if (document.getElementById(styleId)) return;
133
+
134
+ const style = document.createElement("style");
135
+ style.id = styleId;
136
+ style.textContent = `
137
+ ::highlight(answer-eliminated-${choiceId}) {
138
+ text-decoration: line-through;
139
+ text-decoration-thickness: 2px;
140
+ text-decoration-color: var(--pie-incorrect, #ff9800);
141
+ opacity: 0.6;
142
+ }
143
+ `;
144
+ document.head.appendChild(style);
145
+ }
146
+
147
+ private removeHighlightCSS(choiceId: string): void {
148
+ document
149
+ .getElementById(`answer-eliminator-highlight-${choiceId}`)
150
+ ?.remove();
151
+ }
152
+
153
+ private removeCSS(): void {
154
+ const style = document.getElementById(
155
+ "answer-eliminator-strikethrough-styles",
156
+ );
157
+ style?.remove();
158
+ }
159
+
160
+ private addAriaAttributes(range: Range): void {
161
+ // Find the choice container element
162
+ const container = this.findChoiceContainer(range);
163
+ if (!container) return;
164
+
165
+ container.setAttribute("data-eliminated", "true");
166
+ container.setAttribute("aria-disabled", "true");
167
+
168
+ // Add screen reader announcement
169
+ const label = container.querySelector(".label");
170
+ if (label && !label.querySelector(".sr-announcement")) {
171
+ const announcement = document.createElement("span");
172
+ announcement.className = "sr-announcement";
173
+ announcement.textContent = " (eliminated)";
174
+ label.appendChild(announcement);
175
+ }
176
+ }
177
+
178
+ private removeAriaAttributes(range: Range): void {
179
+ const container = this.findChoiceContainer(range);
180
+ if (!container) return;
181
+
182
+ container.removeAttribute("data-eliminated");
183
+ container.removeAttribute("aria-disabled");
184
+
185
+ // Remove screen reader announcement
186
+ const announcement = container.querySelector(".sr-announcement");
187
+ announcement?.remove();
188
+ }
189
+
190
+ private findChoiceContainer(range: Range): HTMLElement | null {
191
+ // Walk up from range start to find the choice container
192
+ let element: HTMLElement | null = range.startContainer as HTMLElement;
193
+
194
+ // If startContainer is a text node, get its parent
195
+ if (element.nodeType === Node.TEXT_NODE) {
196
+ element = element.parentElement;
197
+ }
198
+
199
+ while (element && element !== document.body) {
200
+ if (
201
+ element.classList?.contains("choice-input") ||
202
+ element.classList?.contains("corespring-checkbox") ||
203
+ element.classList?.contains("corespring-radio-button")
204
+ ) {
205
+ return element;
206
+ }
207
+ element = element.parentElement;
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ // Fallback for browsers without CSS Highlight API
214
+ private applyFallback(choiceId: string, range: Range): void {
215
+ const container = this.findChoiceContainer(range);
216
+ if (!container) return;
217
+
218
+ container.classList.add("answer-eliminated-fallback");
219
+ container.setAttribute("data-eliminated", "true");
220
+ container.setAttribute("data-eliminated-id", choiceId);
221
+ this.addAriaAttributes(range);
222
+ }
223
+
224
+ private removeFallback(choiceId: string): void {
225
+ const container = document.querySelector(
226
+ `[data-eliminated-id="${choiceId}"]`,
227
+ );
228
+ if (!container) return;
229
+
230
+ container.classList.remove("answer-eliminated-fallback");
231
+ container.removeAttribute("data-eliminated");
232
+ container.removeAttribute("data-eliminated-id");
233
+
234
+ // Create fake range for aria removal
235
+ const range = document.createRange();
236
+ range.selectNodeContents(container);
237
+ this.removeAriaAttributes(range);
238
+ }
239
+ }