@pie-players/pie-tool-answer-eliminator 0.3.43 → 0.3.44

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 (33) hide show
  1. package/dist/adapters/adapter-registry.d.ts +0 -1
  2. package/dist/adapters/choice-adapter.d.ts +0 -1
  3. package/dist/adapters/ebsr-adapter.d.ts +0 -1
  4. package/dist/adapters/inline-dropdown-adapter.d.ts +0 -1
  5. package/dist/adapters/multiple-choice-adapter.d.ts +0 -1
  6. package/dist/answer-eliminator-core.d.ts +0 -1
  7. package/dist/index.d.ts +0 -1
  8. package/dist/strategies/elimination-strategy.d.ts +0 -1
  9. package/dist/strategies/mask-strategy.d.ts +0 -1
  10. package/dist/strategies/strikethrough-strategy.d.ts +0 -1
  11. package/dist/vite.config.d.ts +0 -1
  12. package/package.json +6 -12
  13. package/adapters/adapter-registry.ts +0 -64
  14. package/adapters/choice-adapter.ts +0 -50
  15. package/adapters/ebsr-adapter.ts +0 -61
  16. package/adapters/inline-dropdown-adapter.ts +0 -46
  17. package/adapters/multiple-choice-adapter.ts +0 -144
  18. package/answer-eliminator-core.ts +0 -391
  19. package/dist/adapters/adapter-registry.d.ts.map +0 -1
  20. package/dist/adapters/choice-adapter.d.ts.map +0 -1
  21. package/dist/adapters/ebsr-adapter.d.ts.map +0 -1
  22. package/dist/adapters/inline-dropdown-adapter.d.ts.map +0 -1
  23. package/dist/adapters/multiple-choice-adapter.d.ts.map +0 -1
  24. package/dist/answer-eliminator-core.d.ts.map +0 -1
  25. package/dist/index.d.ts.map +0 -1
  26. package/dist/strategies/elimination-strategy.d.ts.map +0 -1
  27. package/dist/strategies/mask-strategy.d.ts.map +0 -1
  28. package/dist/strategies/strikethrough-strategy.d.ts.map +0 -1
  29. package/dist/vite.config.d.ts.map +0 -1
  30. package/strategies/elimination-strategy.ts +0 -47
  31. package/strategies/mask-strategy.ts +0 -171
  32. package/strategies/strikethrough-strategy.ts +0 -223
  33. package/tool-answer-eliminator.svelte +0 -223
@@ -1,223 +0,0 @@
1
- import type { EliminationStrategy } from "./elimination-strategy.js";
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
- private static readonly HIGHLIGHT_STYLE_PREFIX =
12
- "pie-answer-eliminator-highlight-";
13
- private static readonly HIGHLIGHT_NAME_PREFIX = "pie-answer-eliminated-";
14
- private static readonly FALLBACK_CLASS =
15
- "pie-answer-eliminator-eliminated-fallback";
16
- private static readonly SR_CLASS = "pie-answer-eliminator-sr-announcement";
17
- private static readonly CHOICE_HOOK_ATTR =
18
- "data-pie-answer-eliminator-choice";
19
- private static readonly LABEL_HOOK_ATTR = "data-pie-answer-eliminator-label";
20
- private static readonly ELIMINATED_ATTR = "data-pie-answer-eliminated";
21
- private static readonly ELIMINATED_ID_ATTR = "data-pie-answer-eliminated-id";
22
-
23
- readonly name = "strikethrough";
24
-
25
- private highlights = new Map<string, Highlight>();
26
- private ranges = new Map<string, Range>();
27
- private fallbackContainers = new Map<string, HTMLElement>();
28
-
29
- initialize(): void {
30
- // Check browser support
31
- if (!this.isSupported()) {
32
- console.warn("CSS Custom Highlight API not supported, using fallback");
33
- }
34
- }
35
-
36
- destroy(): void {
37
- this.clearAll();
38
- }
39
-
40
- apply(choiceId: string, range: Range): void {
41
- if (!this.isSupported()) {
42
- this.applyFallback(choiceId, range);
43
- return;
44
- }
45
-
46
- // Inject CSS for this specific highlight
47
- this.injectHighlightCSS(choiceId);
48
-
49
- // Create highlight for this range
50
- const highlight = new Highlight(range);
51
-
52
- // Register in CSS.highlights with unique name
53
- CSS.highlights.set(
54
- `${StrikethroughStrategy.HIGHLIGHT_NAME_PREFIX}${choiceId}`,
55
- highlight,
56
- );
57
-
58
- // Track internally
59
- this.highlights.set(choiceId, highlight);
60
- this.ranges.set(choiceId, range);
61
-
62
- // Add ARIA attributes to the choice element for screen readers
63
- this.addAriaAttributes(range);
64
- }
65
-
66
- remove(choiceId: string): void {
67
- if (!this.isSupported()) {
68
- this.removeFallback(choiceId);
69
- return;
70
- }
71
-
72
- // Remove from CSS.highlights
73
- CSS.highlights.delete(
74
- `${StrikethroughStrategy.HIGHLIGHT_NAME_PREFIX}${choiceId}`,
75
- );
76
-
77
- // Remove CSS for this specific highlight
78
- this.removeHighlightCSS(choiceId);
79
-
80
- // Remove from internal tracking
81
- const range = this.ranges.get(choiceId);
82
- if (range) {
83
- this.removeAriaAttributes(range);
84
- }
85
-
86
- this.highlights.delete(choiceId);
87
- this.ranges.delete(choiceId);
88
- this.fallbackContainers.delete(choiceId);
89
- }
90
-
91
- isEliminated(choiceId: string): boolean {
92
- return this.highlights.has(choiceId);
93
- }
94
-
95
- clearAll(): void {
96
- // Remove all highlights
97
- for (const choiceId of this.highlights.keys()) {
98
- this.remove(choiceId);
99
- }
100
- this.fallbackContainers.clear();
101
- }
102
-
103
- getEliminatedIds(): string[] {
104
- return Array.from(this.highlights.keys());
105
- }
106
-
107
- private isSupported(): boolean {
108
- return typeof CSS !== "undefined" && "highlights" in CSS;
109
- }
110
-
111
- private injectHighlightCSS(choiceId: string): void {
112
- const styleId = `${StrikethroughStrategy.HIGHLIGHT_STYLE_PREFIX}${choiceId}`;
113
- if (document.getElementById(styleId)) return;
114
-
115
- const style = document.createElement("style");
116
- style.id = styleId;
117
- style.textContent = `
118
- ::highlight(pie-answer-eliminated-${choiceId}) {
119
- text-decoration: line-through;
120
- text-decoration-thickness: 2px;
121
- text-decoration-color: var(--pie-incorrect, #ff9800);
122
- opacity: 0.6;
123
- }
124
- `;
125
- document.head.appendChild(style);
126
- }
127
-
128
- private removeHighlightCSS(choiceId: string): void {
129
- document
130
- .getElementById(
131
- `${StrikethroughStrategy.HIGHLIGHT_STYLE_PREFIX}${choiceId}`,
132
- )
133
- ?.remove();
134
- }
135
-
136
- private addAriaAttributes(range: Range): void {
137
- // Find the choice container element
138
- const container = this.findChoiceContainer(range);
139
- if (!container) return;
140
-
141
- container.setAttribute(StrikethroughStrategy.ELIMINATED_ATTR, "true");
142
- container.setAttribute("aria-disabled", "true");
143
-
144
- // Add screen reader announcement
145
- const label = this.resolveLabelElement(container);
146
- if (label && !label.querySelector(`.${StrikethroughStrategy.SR_CLASS}`)) {
147
- const announcement = document.createElement("span");
148
- announcement.className = StrikethroughStrategy.SR_CLASS;
149
- announcement.textContent = " (eliminated)";
150
- label.appendChild(announcement);
151
- }
152
- }
153
-
154
- private removeAriaAttributes(range: Range): void {
155
- const container = this.findChoiceContainer(range);
156
- if (!container) return;
157
-
158
- container.removeAttribute(StrikethroughStrategy.ELIMINATED_ATTR);
159
- container.removeAttribute("aria-disabled");
160
-
161
- // Remove screen reader announcement
162
- const announcement = container.querySelector(
163
- `.${StrikethroughStrategy.SR_CLASS}`,
164
- );
165
- announcement?.remove();
166
- }
167
-
168
- private findChoiceContainer(range: Range): HTMLElement | null {
169
- // Walk up from range start to find the choice container
170
- let element: HTMLElement | null = range.startContainer as HTMLElement;
171
-
172
- // If startContainer is a text node, get its parent
173
- if (element.nodeType === Node.TEXT_NODE) {
174
- element = element.parentElement;
175
- }
176
-
177
- while (element && element !== document.body) {
178
- if (
179
- element.getAttribute(StrikethroughStrategy.CHOICE_HOOK_ATTR) === "true"
180
- ) {
181
- return element;
182
- }
183
- element = element.parentElement;
184
- }
185
-
186
- return null;
187
- }
188
-
189
- // Fallback for browsers without CSS Highlight API
190
- private applyFallback(choiceId: string, range: Range): void {
191
- const container = this.findChoiceContainer(range);
192
- if (!container) return;
193
-
194
- container.classList.add(StrikethroughStrategy.FALLBACK_CLASS);
195
- container.setAttribute(StrikethroughStrategy.ELIMINATED_ATTR, "true");
196
- container.setAttribute(StrikethroughStrategy.ELIMINATED_ID_ATTR, choiceId);
197
- this.fallbackContainers.set(choiceId, container);
198
- this.addAriaAttributes(range);
199
- }
200
-
201
- private removeFallback(choiceId: string): void {
202
- const container = this.fallbackContainers.get(choiceId);
203
- if (!container) return;
204
-
205
- container.classList.remove(StrikethroughStrategy.FALLBACK_CLASS);
206
- container.removeAttribute(StrikethroughStrategy.ELIMINATED_ATTR);
207
- container.removeAttribute(StrikethroughStrategy.ELIMINATED_ID_ATTR);
208
-
209
- // Create fake range for aria removal
210
- const range = document.createRange();
211
- range.selectNodeContents(container);
212
- this.removeAriaAttributes(range);
213
- this.fallbackContainers.delete(choiceId);
214
- }
215
-
216
- private resolveLabelElement(container: HTMLElement): HTMLElement | null {
217
- return (
218
- container.querySelector<HTMLElement>(
219
- `[${StrikethroughStrategy.LABEL_HOOK_ATTR}="true"]`,
220
- ) || container.querySelector<HTMLElement>("label")
221
- );
222
- }
223
- }
@@ -1,223 +0,0 @@
1
- <svelte:options
2
- customElement={{
3
- tag: 'pie-tool-answer-eliminator',
4
- shadow: 'open',
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
- scopeElement: { type: 'Object', reflect: false },
12
-
13
- // Store integration (JS properties only)
14
- elementToolStateStore: { type: 'Object', reflect: false },
15
- globalElementId: { type: 'String', reflect: false }
16
- }
17
- }}
18
- />
19
-
20
- <!-- Answer Eliminator Tool - Process-of-Elimination Support (Inline Toggle Mode)
21
-
22
- Allows students to mark answer choices as "eliminated" during test-taking,
23
- supporting the process-of-elimination strategy.
24
-
25
- **Interaction Pattern (Industry Standard):**
26
- - Student toggles tool ON via toolbar button
27
- - Small elimination buttons (X) appear next to each answer choice
28
- - Student clicks X buttons to eliminate/restore choices
29
- - Tool can be toggled OFF to hide all elimination buttons
30
- - OR can be "always-on" via student profile (alwaysOn prop)
31
-
32
- **Features:**
33
- - Modern CSS Custom Highlight API (zero DOM mutation, 10-15x faster)
34
- - Generic adapter pattern (works with multiple-choice, EBSR, inline-dropdown)
35
- - Strikethrough visual (WCAG 2.2 AA compliant, best for accessibility)
36
- - localStorage persistence across question navigation
37
- - Keyboard accessible with proper ARIA attributes
38
-
39
- **WCAG 2.2 Level AA Compliant:**
40
- - 1.3.1 Info and Relationships (maintains structure)
41
- - 2.4.3 Focus Order (no layout shift)
42
- - 3.3.1 Error Identification (easy to identify/correct)
43
- - 4.1.2 Name, Role, Value (proper ARIA)
44
- -->
45
-
46
- <script lang="ts">
47
-
48
- import {
49
- connectToolRuntimeContext,
50
- connectToolShellContext,
51
- ZIndexLayer,
52
- } from '@pie-players/pie-assessment-toolkit';
53
- import type {
54
- AssessmentToolkitShellContext,
55
- AssessmentToolkitRuntimeContext,
56
- ToolCoordinatorApi,
57
- } from '@pie-players/pie-assessment-toolkit';
58
- import { onMount } from 'svelte';
59
- import { AnswerEliminatorCore } from './answer-eliminator-core.js';
60
-
61
- // Props
62
- let {
63
- visible = false,
64
- toolId = 'answerEliminator',
65
- strategy = 'strikethrough' as 'strikethrough' | 'mask' | 'gray',
66
- alwaysOn = false, // Set true for profile-based accommodation
67
- buttonAlignment = 'right' as 'left' | 'right' | 'inline', // Button placement: left, right, or inline with checkbox
68
- scopeElement = null, // Container element to limit DOM queries (for multi-item pages)
69
-
70
- // Store integration
71
- elementToolStateStore = null, // ElementToolStateStore instance
72
- globalElementId = '' // Composite key: "assessmentId:sectionId:itemId:elementId"
73
- }: {
74
- visible?: boolean;
75
- toolId?: string;
76
- strategy?: 'strikethrough' | 'mask' | 'gray';
77
- alwaysOn?: boolean;
78
- buttonAlignment?: 'left' | 'right' | 'inline';
79
- scopeElement?: HTMLElement | null;
80
- elementToolStateStore?: any;
81
- globalElementId?: string;
82
- } = $props();
83
-
84
- // State
85
- let contextHostElement = $state<HTMLElement | null>(null);
86
- let runtimeContext = $state<AssessmentToolkitRuntimeContext | null>(null);
87
- let shellContext = $state<AssessmentToolkitShellContext | null>(null);
88
- const coordinator = $derived(
89
- runtimeContext?.toolCoordinator as ToolCoordinatorApi | undefined,
90
- );
91
- let core = $state<AnswerEliminatorCore | null>(null);
92
- let lastShellContextVersion = $state<number | null>(null);
93
-
94
- // Track registration state
95
- let registeredToolId = $state<string | null>(null);
96
- let registeredCoordinator = $state<ToolCoordinatorApi | null>(null);
97
-
98
- // Determine if tool should be active (either toggled on OR always-on mode)
99
- let isActive = $derived(alwaysOn || visible);
100
-
101
- $effect(() => {
102
- if (!contextHostElement) return;
103
- return connectToolRuntimeContext(contextHostElement, (value: AssessmentToolkitRuntimeContext) => {
104
- runtimeContext = value;
105
- });
106
- });
107
-
108
- $effect(() => {
109
- if (!contextHostElement) return;
110
- return connectToolShellContext(contextHostElement, (value: AssessmentToolkitShellContext) => {
111
- shellContext = value;
112
- });
113
- });
114
-
115
- function resolveQuestionRoot(): HTMLElement | null {
116
- return scopeElement || shellContext?.scopeElement || null;
117
- }
118
-
119
- function initializeForCurrentQuestion() {
120
- if (!isActive || !core) return;
121
- const questionRoot = resolveQuestionRoot();
122
-
123
- if (!questionRoot) {
124
- console.warn('[AnswerEliminator] Missing shell scope context for question root');
125
- return;
126
- }
127
-
128
- core.initializeForQuestion(questionRoot);
129
- }
130
-
131
- function handleItemChange() {
132
- requestAnimationFrame(() => {
133
- requestAnimationFrame(() => {
134
- initializeForCurrentQuestion();
135
- });
136
- });
137
- }
138
-
139
- // Register with coordinator when it becomes available
140
- $effect(() => {
141
- if (!coordinator || !toolId) return;
142
- if (
143
- registeredCoordinator &&
144
- registeredToolId &&
145
- (registeredCoordinator !== coordinator || registeredToolId !== toolId)
146
- ) {
147
- registeredCoordinator.unregisterTool(registeredToolId);
148
- registeredCoordinator = null;
149
- registeredToolId = null;
150
- }
151
- if (!registeredToolId) {
152
- coordinator.registerTool(toolId, 'Answer Eliminator', undefined, ZIndexLayer.MODAL);
153
- registeredCoordinator = coordinator;
154
- registeredToolId = toolId;
155
- }
156
- });
157
-
158
- // Update store integration when store props change
159
- $effect(() => {
160
- if (core && elementToolStateStore && globalElementId) {
161
- core.setStoreIntegration(elementToolStateStore, globalElementId);
162
- }
163
- });
164
-
165
- onMount(() => {
166
- // Initialize core engine with configuration
167
- core = new AnswerEliminatorCore(strategy, buttonAlignment);
168
-
169
- // Set up store integration if provided
170
- if (core && elementToolStateStore && globalElementId) {
171
- core.setStoreIntegration(elementToolStateStore, globalElementId);
172
- }
173
-
174
- // Initialize for current question if active, otherwise ensure clean state
175
- if (isActive) {
176
- initializeForCurrentQuestion();
177
- } else {
178
- // If not active on mount, clear any leftover visual eliminations
179
- core.cleanup();
180
- }
181
-
182
- return () => {
183
- core?.destroy();
184
- core = null;
185
- if (registeredCoordinator && registeredToolId) {
186
- registeredCoordinator.unregisterTool(registeredToolId);
187
- registeredCoordinator = null;
188
- registeredToolId = null;
189
- }
190
- };
191
- });
192
-
193
- $effect(() => {
194
- const shellVersion = shellContext?.contextVersion ?? null;
195
- if (shellVersion === null) return;
196
- if (lastShellContextVersion === null) {
197
- lastShellContextVersion = shellVersion;
198
- return;
199
- }
200
- if (shellVersion === lastShellContextVersion) return;
201
- lastShellContextVersion = shellVersion;
202
- handleItemChange();
203
- });
204
-
205
- // Watch for visibility changes to show/hide elimination buttons
206
- $effect(() => {
207
- if (core) {
208
- if (isActive) {
209
- // Re-enable state restoration when tool is activated
210
- core.enableStateRestoration();
211
- initializeForCurrentQuestion();
212
- } else {
213
- // Hide all elimination buttons when tool is turned off
214
- // This also disables state restoration
215
- core.cleanup();
216
- }
217
- }
218
- });
219
- </script>
220
-
221
- <!-- No visible UI - tool operates entirely through injected buttons next to choices -->
222
- <!-- The toolbar button visibility is managed by tool-toolbar.svelte -->
223
- <div bind:this={contextHostElement} style="display: none;" aria-hidden="true"></div>