@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,250 @@
1
+ <svelte:options
2
+ customElement={{
3
+ tag: 'pie-tool-answer-eliminator',
4
+ shadow: 'none',
5
+ props: {
6
+ visible: { type: 'Boolean', attribute: 'visible' },
7
+ toolId: { type: 'String', attribute: 'tool-id' },
8
+ strategy: { type: 'String', attribute: 'strategy' },
9
+ alwaysOn: { type: 'Boolean', attribute: 'always-on' },
10
+ buttonAlignment: { type: 'String', attribute: 'button-alignment' },
11
+ coordinator: { type: 'Object' },
12
+ scopeElement: { type: 'Object', reflect: false },
13
+
14
+ // Store integration (JS properties only)
15
+ elementToolStateStore: { type: 'Object', reflect: false },
16
+ globalElementId: { type: 'String', reflect: false }
17
+ }
18
+ }}
19
+ />
20
+
21
+ <!-- Answer Eliminator Tool - Process-of-Elimination Support (Inline Toggle Mode)
22
+
23
+ Allows students to mark answer choices as "eliminated" during test-taking,
24
+ supporting the process-of-elimination strategy.
25
+
26
+ **Interaction Pattern (Industry Standard):**
27
+ - Student toggles tool ON via toolbar button
28
+ - Small elimination buttons (X) appear next to each answer choice
29
+ - Student clicks X buttons to eliminate/restore choices
30
+ - Tool can be toggled OFF to hide all elimination buttons
31
+ - OR can be "always-on" via student profile (alwaysOn prop)
32
+
33
+ **Features:**
34
+ - Modern CSS Custom Highlight API (zero DOM mutation, 10-15x faster)
35
+ - Generic adapter pattern (works with multiple-choice, EBSR, inline-dropdown)
36
+ - Strikethrough visual (WCAG 2.2 AA compliant, best for accessibility)
37
+ - localStorage persistence across question navigation
38
+ - Keyboard accessible with proper ARIA attributes
39
+
40
+ **WCAG 2.2 Level AA Compliant:**
41
+ - 1.3.1 Info and Relationships (maintains structure)
42
+ - 2.4.3 Focus Order (no layout shift)
43
+ - 3.3.1 Error Identification (easy to identify/correct)
44
+ - 4.1.2 Name, Role, Value (proper ARIA)
45
+ -->
46
+
47
+ <script lang="ts">
48
+
49
+ import type { IToolCoordinator } from '@pie-players/pie-assessment-toolkit';
50
+ import { ZIndexLayer } from '@pie-players/pie-assessment-toolkit';
51
+ import { onDestroy, onMount } from 'svelte';
52
+ import { AnswerEliminatorCore } from './answer-eliminator-core';
53
+
54
+ // Props
55
+ let {
56
+ visible = false,
57
+ toolId = 'answerEliminator',
58
+ strategy = 'strikethrough' as 'strikethrough' | 'mask' | 'gray',
59
+ alwaysOn = false, // Set true for profile-based accommodation
60
+ buttonAlignment = 'right' as 'left' | 'right' | 'inline', // Button placement: left, right, or inline with checkbox
61
+ coordinator,
62
+ scopeElement = null, // Container element to limit DOM queries (for multi-item pages)
63
+
64
+ // Store integration
65
+ elementToolStateStore = null, // ElementToolStateStore instance
66
+ globalElementId = '' // Composite key: "assessmentId:sectionId:itemId:elementId"
67
+ }: {
68
+ visible?: boolean;
69
+ toolId?: string;
70
+ strategy?: 'strikethrough' | 'mask' | 'gray';
71
+ alwaysOn?: boolean;
72
+ buttonAlignment?: 'left' | 'right' | 'inline';
73
+ coordinator?: IToolCoordinator;
74
+ scopeElement?: HTMLElement | null;
75
+ elementToolStateStore?: any;
76
+ globalElementId?: string;
77
+ } = $props();
78
+
79
+ // State
80
+ let core = $state<AnswerEliminatorCore | null>(null);
81
+ let eliminatedCount = $state(0);
82
+ let mutationObserver = $state<MutationObserver | null>(null);
83
+
84
+ // Track registration state
85
+ let registered = $state(false);
86
+
87
+ // Determine if tool should be active (either toggled on OR always-on mode)
88
+ let isActive = $derived(alwaysOn || visible);
89
+
90
+ function initializeForCurrentQuestion() {
91
+ if (!isActive || !core) return;
92
+
93
+ // Use scopeElement if provided (for multi-item pages), otherwise search globally
94
+ const searchRoot = scopeElement || document.body;
95
+
96
+ // Find the current question/item within the search scope
97
+ const questionRoot =
98
+ searchRoot.querySelector('pie-player') ||
99
+ searchRoot.querySelector('multiple-choice') ||
100
+ searchRoot.querySelector('ebsr') ||
101
+ searchRoot.querySelector('[data-pie-element]') ||
102
+ searchRoot;
103
+
104
+ if (!questionRoot) {
105
+ console.warn('[AnswerEliminator] Could not find question root within scope');
106
+ return;
107
+ }
108
+
109
+ core.initializeForQuestion(questionRoot as HTMLElement);
110
+ updateEliminatedCount();
111
+ }
112
+
113
+ function waitForPIEElements(callback: () => void, timeout: number = 5000) {
114
+ // Use scopeElement if provided, otherwise search globally
115
+ const searchRoot = scopeElement || document.body;
116
+
117
+ // Check if PIE elements already exist
118
+ const checkElements = () => {
119
+ const questionRoot =
120
+ searchRoot.querySelector('pie-player') ||
121
+ searchRoot.querySelector('multiple-choice') ||
122
+ searchRoot.querySelector('ebsr') ||
123
+ searchRoot.querySelector('[data-pie-element]');
124
+
125
+ if (questionRoot) {
126
+ // Elements found, clean up observer and execute callback
127
+ if (mutationObserver) {
128
+ mutationObserver.disconnect();
129
+ mutationObserver = null;
130
+ }
131
+ callback();
132
+ return true;
133
+ }
134
+ return false;
135
+ };
136
+
137
+ // Try immediately first
138
+ if (checkElements()) return;
139
+
140
+ // Set up MutationObserver to watch for elements within scope
141
+ mutationObserver = new MutationObserver(() => {
142
+ checkElements();
143
+ });
144
+
145
+ // Observe the search root for added nodes
146
+ mutationObserver.observe(searchRoot, {
147
+ childList: true,
148
+ subtree: true
149
+ });
150
+
151
+ // Fallback timeout to prevent infinite observation
152
+ setTimeout(() => {
153
+ if (mutationObserver) {
154
+ mutationObserver.disconnect();
155
+ mutationObserver = null;
156
+ // Try one last time
157
+ checkElements();
158
+ }
159
+ }, timeout);
160
+ }
161
+
162
+ function handleItemChange() {
163
+ // Question changed, wait for PIE elements to be rendered using MutationObserver
164
+ waitForPIEElements(() => {
165
+ initializeForCurrentQuestion();
166
+ });
167
+ }
168
+
169
+ function updateEliminatedCount() {
170
+ eliminatedCount = core?.getEliminatedCount() || 0;
171
+ }
172
+
173
+ // Register with coordinator when it becomes available
174
+ $effect(() => {
175
+ if (coordinator && toolId && !registered) {
176
+ coordinator.registerTool(toolId, 'Answer Eliminator', undefined, ZIndexLayer.MODAL);
177
+ registered = true;
178
+ }
179
+ });
180
+
181
+ // Update store integration when store props change
182
+ $effect(() => {
183
+ if (core && elementToolStateStore && globalElementId) {
184
+ core.setStoreIntegration(elementToolStateStore, globalElementId);
185
+ }
186
+ });
187
+
188
+ onMount(() => {
189
+ // Initialize core engine with configuration
190
+ core = new AnswerEliminatorCore(strategy, buttonAlignment);
191
+
192
+ // Set up store integration if provided
193
+ if (core && elementToolStateStore && globalElementId) {
194
+ core.setStoreIntegration(elementToolStateStore, globalElementId);
195
+ }
196
+
197
+ // Listen for question changes (PIE player emits this)
198
+ document.addEventListener('pie-item-changed', handleItemChange);
199
+
200
+ // Listen for custom events from answer-eliminator-core when state changes
201
+ document.addEventListener('answer-eliminator-state-change', () => {
202
+ updateEliminatedCount();
203
+ });
204
+
205
+ // Initialize for current question if active, otherwise ensure clean state
206
+ if (isActive) {
207
+ // Wait for PIE elements to be mounted using MutationObserver
208
+ waitForPIEElements(() => {
209
+ initializeForCurrentQuestion();
210
+ });
211
+ } else {
212
+ // If not active on mount, clear any leftover visual eliminations
213
+ core.cleanup();
214
+ }
215
+
216
+ return () => {
217
+ // Clean up MutationObserver if still active
218
+ if (mutationObserver) {
219
+ mutationObserver.disconnect();
220
+ mutationObserver = null;
221
+ }
222
+
223
+ core?.destroy();
224
+ core = null;
225
+ if (coordinator && toolId) {
226
+ coordinator.unregisterTool(toolId);
227
+ }
228
+ document.removeEventListener('pie-item-changed', handleItemChange);
229
+ document.removeEventListener('answer-eliminator-state-change', updateEliminatedCount);
230
+ };
231
+ });
232
+
233
+ // Watch for visibility changes to show/hide elimination buttons
234
+ $effect(() => {
235
+ if (core) {
236
+ if (isActive) {
237
+ // Re-enable state restoration when tool is activated
238
+ core.enableStateRestoration();
239
+ initializeForCurrentQuestion();
240
+ } else {
241
+ // Hide all elimination buttons when tool is turned off
242
+ // This also disables state restoration
243
+ core.cleanup();
244
+ }
245
+ }
246
+ });
247
+ </script>
248
+
249
+ <!-- No visible UI - tool operates entirely through injected buttons next to choices -->
250
+ <!-- The toolbar button visibility is managed by tool-toolbar.svelte -->