@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.
- package/README.md +282 -0
- package/adapters/adapter-registry.ts +64 -0
- package/adapters/choice-adapter.ts +50 -0
- package/adapters/ebsr-adapter.ts +61 -0
- package/adapters/inline-dropdown-adapter.ts +46 -0
- package/adapters/multiple-choice-adapter.ts +96 -0
- package/answer-eliminator-core.ts +465 -0
- package/dist/adapters/adapter-registry.d.ts +26 -0
- package/dist/adapters/adapter-registry.d.ts.map +1 -0
- package/dist/adapters/choice-adapter.d.ts +43 -0
- package/dist/adapters/choice-adapter.d.ts.map +1 -0
- package/dist/adapters/ebsr-adapter.d.ts +20 -0
- package/dist/adapters/ebsr-adapter.d.ts.map +1 -0
- package/dist/adapters/inline-dropdown-adapter.d.ts +18 -0
- package/dist/adapters/inline-dropdown-adapter.d.ts.map +1 -0
- package/dist/adapters/multiple-choice-adapter.d.ts +20 -0
- package/dist/adapters/multiple-choice-adapter.d.ts.map +1 -0
- package/dist/answer-eliminator-core.d.ts +98 -0
- package/dist/answer-eliminator-core.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/strategies/elimination-strategy.d.ts +41 -0
- package/dist/strategies/elimination-strategy.d.ts.map +1 -0
- package/dist/strategies/mask-strategy.d.ts +28 -0
- package/dist/strategies/mask-strategy.d.ts.map +1 -0
- package/dist/strategies/strikethrough-strategy.d.ts +31 -0
- package/dist/strategies/strikethrough-strategy.d.ts.map +1 -0
- package/dist/tool-answer-eliminator.js +2838 -0
- package/dist/tool-answer-eliminator.js.map +1 -0
- package/dist/tool-answer-eliminator.svelte.d.ts +1 -0
- package/dist/vite.config.d.ts +3 -0
- package/dist/vite.config.d.ts.map +1 -0
- package/index.ts +11 -0
- package/package.json +69 -0
- package/strategies/elimination-strategy.ts +47 -0
- package/strategies/mask-strategy.ts +180 -0
- package/strategies/strikethrough-strategy.ts +239 -0
- package/tool-answer-eliminator.svelte +250 -0
package/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# @pie-players/pie-tool-answer-eliminator
|
|
2
|
+
|
|
3
|
+
Test-taking strategy tool that allows students to eliminate answer choices they believe are incorrect.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Element-Level State**: Answer eliminations tracked per PIE element (not per item)
|
|
8
|
+
- **Visual Feedback**: Strikethrough styling for eliminated choices
|
|
9
|
+
- **Global Uniqueness**: Uses composite keys for state management across sections
|
|
10
|
+
- **Ephemeral State**: State is client-only, separate from PIE session data
|
|
11
|
+
- **ElementToolStateStore Integration**: Works with Assessment Toolkit's state management
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @pie-players/pie-tool-answer-eliminator
|
|
17
|
+
# or
|
|
18
|
+
bun add @pie-players/pie-tool-answer-eliminator
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### As Web Component
|
|
24
|
+
|
|
25
|
+
The answer eliminator is automatically integrated when using the PIE Section Player with ToolkitCoordinator:
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<script type="module">
|
|
29
|
+
import '@pie-players/pie-section-player';
|
|
30
|
+
import { ToolkitCoordinator } from '@pie-players/pie-assessment-toolkit';
|
|
31
|
+
|
|
32
|
+
const coordinator = new ToolkitCoordinator({
|
|
33
|
+
assessmentId: 'my-assessment',
|
|
34
|
+
tools: {
|
|
35
|
+
answerEliminator: { enabled: true }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const player = document.getElementById('player');
|
|
40
|
+
player.toolkitCoordinator = coordinator;
|
|
41
|
+
player.section = mySection;
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<pie-section-player id="player"></pie-section-player>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The section player automatically:
|
|
48
|
+
- Renders answer eliminator buttons in question toolbars
|
|
49
|
+
- Generates global element IDs
|
|
50
|
+
- Passes ElementToolStateStore to the tool
|
|
51
|
+
- Manages state lifecycle
|
|
52
|
+
|
|
53
|
+
### Manual Integration (Advanced)
|
|
54
|
+
|
|
55
|
+
For custom implementations outside the section player:
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<script type="module">
|
|
59
|
+
import '@pie-players/pie-tool-answer-eliminator';
|
|
60
|
+
import { ElementToolStateStore } from '@pie-players/pie-assessment-toolkit';
|
|
61
|
+
|
|
62
|
+
const store = new ElementToolStateStore();
|
|
63
|
+
const globalElementId = store.getGlobalElementId(
|
|
64
|
+
'my-assessment',
|
|
65
|
+
'section-1',
|
|
66
|
+
'question-1',
|
|
67
|
+
'mc1'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const tool = document.querySelector('pie-tool-answer-eliminator');
|
|
71
|
+
tool.globalElementId = globalElementId;
|
|
72
|
+
tool.elementToolStateStore = store;
|
|
73
|
+
tool.scopeElement = document.querySelector('.question-content');
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<pie-tool-answer-eliminator></pie-tool-answer-eliminator>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Props/Attributes
|
|
80
|
+
|
|
81
|
+
The web component accepts the following properties (set via JavaScript, not HTML attributes):
|
|
82
|
+
|
|
83
|
+
| Property | Type | Required | Description |
|
|
84
|
+
|----------|------|----------|-------------|
|
|
85
|
+
| `globalElementId` | `string` | Yes | Composite key: `assessmentId:sectionId:itemId:elementId` |
|
|
86
|
+
| `elementToolStateStore` | `IElementToolStateStore` | Yes | Store for element-level tool state |
|
|
87
|
+
| `scopeElement` | `HTMLElement` | No | DOM element to scope choice detection (defaults to document) |
|
|
88
|
+
|
|
89
|
+
## Global Element ID Format
|
|
90
|
+
|
|
91
|
+
The tool uses globally unique composite keys for state management:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
${assessmentId}:${sectionId}:${itemId}:${elementId}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Example:**
|
|
98
|
+
```typescript
|
|
99
|
+
"demo-assessment:section-1:question-1:mc1"
|
|
100
|
+
"biology-exam:section-2:genetics-q1:ebsr-part1"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Benefits of Composite Keys
|
|
104
|
+
|
|
105
|
+
- ✅ **Element-Level Granularity**: Each PIE element has independent eliminations
|
|
106
|
+
- ✅ **No Cross-Item Contamination**: Eliminations from question 1 don't appear on question 2
|
|
107
|
+
- ✅ **Cross-Section Persistence**: State persists when navigating between sections
|
|
108
|
+
- ✅ **Global Uniqueness**: No ID collisions across entire assessment
|
|
109
|
+
|
|
110
|
+
### Why Element-Level?
|
|
111
|
+
|
|
112
|
+
Items can contain **multiple interactive elements** (e.g., EBSR with two parts). Each element needs independent state:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// ✅ Correct: Element-level state
|
|
116
|
+
{
|
|
117
|
+
"demo:section-1:question-1:ebsr-part1": {
|
|
118
|
+
"answerEliminator": { "eliminatedChoices": ["choice-a", "choice-c"] }
|
|
119
|
+
},
|
|
120
|
+
"demo:section-1:question-1:ebsr-part2": {
|
|
121
|
+
"answerEliminator": { "eliminatedChoices": ["choice-b"] }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## State Management
|
|
127
|
+
|
|
128
|
+
### Ephemeral vs Persistent State
|
|
129
|
+
|
|
130
|
+
The answer eliminator stores state in **ElementToolStateStore** (ephemeral, client-only):
|
|
131
|
+
|
|
132
|
+
**Tool State (Ephemeral - NOT sent to server):**
|
|
133
|
+
```typescript
|
|
134
|
+
{
|
|
135
|
+
"demo:section-1:question-1:mc1": {
|
|
136
|
+
"answerEliminator": {
|
|
137
|
+
"eliminatedChoices": ["choice-b", "choice-d"]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**PIE Session Data (Persistent - sent to server for scoring):**
|
|
144
|
+
```typescript
|
|
145
|
+
{
|
|
146
|
+
"question-1": {
|
|
147
|
+
"id": "session-123",
|
|
148
|
+
"data": [
|
|
149
|
+
{ "id": "mc1", "element": "multiple-choice", "value": ["choice-a"] }
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Persistence Integration
|
|
156
|
+
|
|
157
|
+
To persist tool state across page refreshes:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
const coordinator = new ToolkitCoordinator({
|
|
161
|
+
assessmentId: 'my-assessment',
|
|
162
|
+
tools: { answerEliminator: { enabled: true } }
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Save to localStorage on change
|
|
166
|
+
const storageKey = `tool-state:${coordinator.assessmentId}`;
|
|
167
|
+
coordinator.elementToolStateStore.setOnStateChange((state) => {
|
|
168
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Load on mount
|
|
172
|
+
const saved = localStorage.getItem(storageKey);
|
|
173
|
+
if (saved) {
|
|
174
|
+
coordinator.elementToolStateStore.loadState(JSON.parse(saved));
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## How It Works
|
|
179
|
+
|
|
180
|
+
### 1. Choice Detection
|
|
181
|
+
|
|
182
|
+
The tool automatically detects answer choices within the scoped element:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// Searches for choice elements with these patterns
|
|
186
|
+
const choiceSelectors = [
|
|
187
|
+
'[data-choice-id]', // PIE standard
|
|
188
|
+
'.choice', // Common class
|
|
189
|
+
'[role="radio"]', // Accessibility
|
|
190
|
+
'[role="checkbox"]', // Accessibility
|
|
191
|
+
'input[type="radio"]', // Native inputs
|
|
192
|
+
'input[type="checkbox"]' // Native inputs
|
|
193
|
+
];
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 2. State Storage
|
|
197
|
+
|
|
198
|
+
Eliminated choices are stored by choice ID:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
{
|
|
202
|
+
"eliminatedChoices": ["choice-a", "choice-c"]
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 3. Visual Feedback
|
|
207
|
+
|
|
208
|
+
Eliminated choices receive the `eliminated` CSS class:
|
|
209
|
+
|
|
210
|
+
```css
|
|
211
|
+
.choice.eliminated {
|
|
212
|
+
text-decoration: line-through;
|
|
213
|
+
opacity: 0.5;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 4. Toggle Behavior
|
|
218
|
+
|
|
219
|
+
Clicking a choice toggles its elimination state:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// First click: eliminate
|
|
223
|
+
choiceElement.classList.add('eliminated');
|
|
224
|
+
|
|
225
|
+
// Second click: restore
|
|
226
|
+
choiceElement.classList.remove('eliminated');
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Cleanup
|
|
230
|
+
|
|
231
|
+
The ElementToolStateStore provides cleanup methods:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// Clear state for a specific element
|
|
235
|
+
store.clearElement('demo:section-1:question-1:mc1');
|
|
236
|
+
|
|
237
|
+
// Clear all answer eliminator state across all elements
|
|
238
|
+
store.clearTool('answerEliminator');
|
|
239
|
+
|
|
240
|
+
// Clear all elements in a section
|
|
241
|
+
store.clearSection('demo', 'section-1');
|
|
242
|
+
|
|
243
|
+
// Clear all state
|
|
244
|
+
store.clearAll();
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## TypeScript Support
|
|
248
|
+
|
|
249
|
+
Full TypeScript definitions included:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import type { IElementToolStateStore } from '@pie-players/pie-assessment-toolkit';
|
|
253
|
+
|
|
254
|
+
interface AnswerEliminatorState {
|
|
255
|
+
eliminatedChoices: string[];
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Browser Support
|
|
260
|
+
|
|
261
|
+
- Chrome/Edge 90+
|
|
262
|
+
- Firefox 88+
|
|
263
|
+
- Safari 14+
|
|
264
|
+
|
|
265
|
+
Requires ES2020+ support (native ES modules, optional chaining, nullish coalescing).
|
|
266
|
+
|
|
267
|
+
## Examples
|
|
268
|
+
|
|
269
|
+
See the [section-demos](../../apps/section-demos/) for complete examples:
|
|
270
|
+
|
|
271
|
+
- **Three Questions Demo**: Element-level answer eliminator with state persistence
|
|
272
|
+
- **Paired Passages Demo**: Multi-section assessment with cross-section state
|
|
273
|
+
|
|
274
|
+
## Related Documentation
|
|
275
|
+
|
|
276
|
+
- [ToolkitCoordinator Architecture](../../docs/architecture/TOOLKIT_COORDINATOR.md) - Element-level state design
|
|
277
|
+
- [Assessment Toolkit README](../assessment-toolkit/README.md) - Toolkit overview
|
|
278
|
+
- [Section Player README](../section-player/README.md) - Integration guide
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ChoiceAdapter } from "./choice-adapter";
|
|
2
|
+
import { EBSRAdapter } from "./ebsr-adapter";
|
|
3
|
+
import { InlineDropdownAdapter } from "./inline-dropdown-adapter";
|
|
4
|
+
import { MultipleChoiceAdapter } from "./multiple-choice-adapter";
|
|
5
|
+
|
|
6
|
+
export interface ChoiceWithAdapter {
|
|
7
|
+
choice: HTMLElement;
|
|
8
|
+
adapter: ChoiceAdapter;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registry for choice adapters
|
|
13
|
+
* Automatically detects which adapter to use for different PIE elements
|
|
14
|
+
*/
|
|
15
|
+
export class AdapterRegistry {
|
|
16
|
+
private adapters: ChoiceAdapter[];
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
// Register adapters in priority order (higher priority first)
|
|
20
|
+
this.adapters = [
|
|
21
|
+
new MultipleChoiceAdapter(),
|
|
22
|
+
new EBSRAdapter(),
|
|
23
|
+
new InlineDropdownAdapter(),
|
|
24
|
+
].sort((a, b) => b.priority - a.priority);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the appropriate adapter for an element
|
|
29
|
+
*/
|
|
30
|
+
findAdapter(element: HTMLElement): ChoiceAdapter | null {
|
|
31
|
+
return this.adapters.find((adapter) => adapter.canHandle(element)) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Find all choices with their adapters within a root element
|
|
36
|
+
*/
|
|
37
|
+
findAllChoicesWithAdapters(root: HTMLElement): ChoiceWithAdapter[] {
|
|
38
|
+
const results: ChoiceWithAdapter[] = [];
|
|
39
|
+
const processedChoices = new Set<HTMLElement>();
|
|
40
|
+
|
|
41
|
+
// Try each adapter
|
|
42
|
+
for (const adapter of this.adapters) {
|
|
43
|
+
const choices = adapter.findChoices(root);
|
|
44
|
+
|
|
45
|
+
for (const choice of choices) {
|
|
46
|
+
// Avoid duplicates (in case adapters overlap)
|
|
47
|
+
if (processedChoices.has(choice)) continue;
|
|
48
|
+
|
|
49
|
+
processedChoices.add(choice);
|
|
50
|
+
results.push({ choice, adapter });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a custom adapter
|
|
59
|
+
*/
|
|
60
|
+
registerAdapter(adapter: ChoiceAdapter): void {
|
|
61
|
+
this.adapters.push(adapter);
|
|
62
|
+
this.adapters.sort((a, b) => b.priority - a.priority);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter interface for detecting and working with choice elements
|
|
3
|
+
* from different PIE element types.
|
|
4
|
+
*
|
|
5
|
+
* Adapters enable the answer eliminator to work generically with
|
|
6
|
+
* multiple-choice, EBSR, inline-dropdown, and future choice elements.
|
|
7
|
+
*/
|
|
8
|
+
export interface ChoiceAdapter {
|
|
9
|
+
/** Element type this adapter handles */
|
|
10
|
+
readonly elementType: string;
|
|
11
|
+
|
|
12
|
+
/** Priority (higher = checked first) */
|
|
13
|
+
readonly priority: number;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if this adapter can handle the given element
|
|
17
|
+
*/
|
|
18
|
+
canHandle(element: HTMLElement): boolean;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find all choice elements within the root
|
|
22
|
+
*/
|
|
23
|
+
findChoices(root: HTMLElement): HTMLElement[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a Range covering the choice content (for CSS Highlight API)
|
|
27
|
+
*/
|
|
28
|
+
createChoiceRange(choice: HTMLElement): Range | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get unique identifier for this choice (for persistence)
|
|
32
|
+
*/
|
|
33
|
+
getChoiceId(choice: HTMLElement): string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get human-readable label (for screen readers)
|
|
37
|
+
*/
|
|
38
|
+
getChoiceLabel(choice: HTMLElement): string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if choice can be eliminated
|
|
42
|
+
* (not already selected, not in evaluate mode, etc.)
|
|
43
|
+
*/
|
|
44
|
+
canEliminate(choice: HTMLElement): boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the container element to attach elimination button
|
|
48
|
+
*/
|
|
49
|
+
getButtonContainer(choice: HTMLElement): HTMLElement | null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ChoiceAdapter } from "./choice-adapter";
|
|
2
|
+
import { MultipleChoiceAdapter } from "./multiple-choice-adapter";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adapter for EBSR (Evidence-Based Selected Response) elements
|
|
6
|
+
*
|
|
7
|
+
* EBSR contains two multiple-choice questions (Part A and Part B),
|
|
8
|
+
* so we delegate to MultipleChoiceAdapter and prefix IDs with part identifier
|
|
9
|
+
*/
|
|
10
|
+
export class EBSRAdapter implements ChoiceAdapter {
|
|
11
|
+
readonly elementType = "ebsr";
|
|
12
|
+
readonly priority = 95;
|
|
13
|
+
|
|
14
|
+
private mcAdapter = new MultipleChoiceAdapter();
|
|
15
|
+
|
|
16
|
+
canHandle(element: HTMLElement): boolean {
|
|
17
|
+
return (
|
|
18
|
+
element.tagName.toLowerCase() === "ebsr" ||
|
|
19
|
+
element.querySelector("ebsr-multiple-choice") !== null
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
findChoices(root: HTMLElement): HTMLElement[] {
|
|
24
|
+
// EBSR contains ebsr-multiple-choice elements
|
|
25
|
+
// Find choices in both Part A and Part B
|
|
26
|
+
const partA = root.querySelector('ebsr-multiple-choice[id="a"]');
|
|
27
|
+
const partB = root.querySelector('ebsr-multiple-choice[id="b"]');
|
|
28
|
+
|
|
29
|
+
const choices: HTMLElement[] = [];
|
|
30
|
+
if (partA)
|
|
31
|
+
choices.push(...this.mcAdapter.findChoices(partA as HTMLElement));
|
|
32
|
+
if (partB)
|
|
33
|
+
choices.push(...this.mcAdapter.findChoices(partB as HTMLElement));
|
|
34
|
+
|
|
35
|
+
return choices;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Delegate all other methods to MultipleChoiceAdapter
|
|
39
|
+
createChoiceRange(choice: HTMLElement): Range | null {
|
|
40
|
+
return this.mcAdapter.createChoiceRange(choice);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getChoiceId(choice: HTMLElement): string {
|
|
44
|
+
// Prefix with part ID (a or b)
|
|
45
|
+
const part = choice.closest("ebsr-multiple-choice")?.id || "unknown";
|
|
46
|
+
const choiceId = this.mcAdapter.getChoiceId(choice);
|
|
47
|
+
return `${part}-${choiceId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getChoiceLabel(choice: HTMLElement): string {
|
|
51
|
+
return this.mcAdapter.getChoiceLabel(choice);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
canEliminate(choice: HTMLElement): boolean {
|
|
55
|
+
return this.mcAdapter.canEliminate(choice);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getButtonContainer(choice: HTMLElement): HTMLElement | null {
|
|
59
|
+
return this.mcAdapter.getButtonContainer(choice);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ChoiceAdapter } from "./choice-adapter";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adapter for PIE inline-dropdown elements
|
|
5
|
+
*
|
|
6
|
+
* Works with dropdown menu items (role="option")
|
|
7
|
+
*/
|
|
8
|
+
export class InlineDropdownAdapter implements ChoiceAdapter {
|
|
9
|
+
readonly elementType = "inline-dropdown";
|
|
10
|
+
readonly priority = 90;
|
|
11
|
+
|
|
12
|
+
canHandle(element: HTMLElement): boolean {
|
|
13
|
+
return (
|
|
14
|
+
element.tagName.toLowerCase() === "inline-dropdown" ||
|
|
15
|
+
element.querySelector('[role="combobox"]') !== null
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
findChoices(root: HTMLElement): HTMLElement[] {
|
|
20
|
+
// Find dropdown menu items
|
|
21
|
+
return Array.from(root.querySelectorAll<HTMLElement>('[role="option"]'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
createChoiceRange(choice: HTMLElement): Range | null {
|
|
25
|
+
const range = document.createRange();
|
|
26
|
+
range.selectNodeContents(choice);
|
|
27
|
+
return range;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getChoiceId(choice: HTMLElement): string {
|
|
31
|
+
return choice.id || choice.getAttribute("data-value") || "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getChoiceLabel(choice: HTMLElement): string {
|
|
35
|
+
return choice.textContent?.trim() || "Unlabeled option";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
canEliminate(choice: HTMLElement): boolean {
|
|
39
|
+
// Can't eliminate if already selected
|
|
40
|
+
return choice.getAttribute("aria-selected") !== "true";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getButtonContainer(choice: HTMLElement): HTMLElement | null {
|
|
44
|
+
return choice;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ChoiceAdapter } from "./choice-adapter";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adapter for PIE multiple-choice elements
|
|
5
|
+
*
|
|
6
|
+
* Works with both single-select (radio) and multiple-select (checkbox) modes
|
|
7
|
+
* Detects PIE's corespring-checkbox and corespring-radio-button classes
|
|
8
|
+
*/
|
|
9
|
+
export class MultipleChoiceAdapter implements ChoiceAdapter {
|
|
10
|
+
readonly elementType = "multiple-choice";
|
|
11
|
+
readonly priority = 100;
|
|
12
|
+
|
|
13
|
+
canHandle(element: HTMLElement): boolean {
|
|
14
|
+
return (
|
|
15
|
+
element.tagName.toLowerCase() === "multiple-choice" ||
|
|
16
|
+
element.classList.contains("multiple-choice")
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
findChoices(root: HTMLElement): HTMLElement[] {
|
|
21
|
+
// Find by PIE's corespring classes
|
|
22
|
+
return Array.from(
|
|
23
|
+
root.querySelectorAll<HTMLElement>(
|
|
24
|
+
".corespring-checkbox, .corespring-radio-button",
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createChoiceRange(choice: HTMLElement): Range | null {
|
|
30
|
+
// Create range covering the label content
|
|
31
|
+
// Try multiple possible selectors for the label
|
|
32
|
+
const labelElement =
|
|
33
|
+
choice.querySelector(".label") ||
|
|
34
|
+
choice.querySelector("label") ||
|
|
35
|
+
choice.querySelector('[class*="label"]') ||
|
|
36
|
+
choice.querySelector("span");
|
|
37
|
+
|
|
38
|
+
if (!labelElement) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const range = document.createRange();
|
|
43
|
+
range.selectNodeContents(labelElement);
|
|
44
|
+
return range;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getChoiceId(choice: HTMLElement): string {
|
|
48
|
+
// Get value from input element
|
|
49
|
+
const input = choice.querySelector(
|
|
50
|
+
'input[type="checkbox"], input[type="radio"]',
|
|
51
|
+
);
|
|
52
|
+
return (
|
|
53
|
+
input?.getAttribute("value") ||
|
|
54
|
+
input?.id ||
|
|
55
|
+
this.generateFallbackId(choice)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getChoiceLabel(choice: HTMLElement): string {
|
|
60
|
+
const label = choice.querySelector(".label");
|
|
61
|
+
return label?.textContent?.trim() || "Unlabeled choice";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
canEliminate(choice: HTMLElement): boolean {
|
|
65
|
+
const input = choice.querySelector(
|
|
66
|
+
'input[type="checkbox"], input[type="radio"]',
|
|
67
|
+
);
|
|
68
|
+
if (!input) return false;
|
|
69
|
+
|
|
70
|
+
// Can't eliminate if:
|
|
71
|
+
// 1. Already selected (checked)
|
|
72
|
+
if (input.getAttribute("aria-checked") === "true") return false;
|
|
73
|
+
|
|
74
|
+
// 2. Disabled
|
|
75
|
+
if ((input as HTMLInputElement).disabled) return false;
|
|
76
|
+
|
|
77
|
+
// 3. In evaluate/view mode (has feedback tick)
|
|
78
|
+
if (choice.closest(".multiple-choice")?.querySelector(".feedback-tick"))
|
|
79
|
+
return false;
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getButtonContainer(choice: HTMLElement): HTMLElement | null {
|
|
85
|
+
// Return the choice-input container
|
|
86
|
+
return choice;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private generateFallbackId(choice: HTMLElement): string {
|
|
90
|
+
// Generate stable ID based on choice position
|
|
91
|
+
const parent = choice.closest(".multiple-choice");
|
|
92
|
+
const choices = parent?.querySelectorAll(".choice-input") || [];
|
|
93
|
+
const index = Array.from(choices).indexOf(choice);
|
|
94
|
+
return `choice-${index}`;
|
|
95
|
+
}
|
|
96
|
+
}
|