@pie-players/pie-assessment-player 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-assessment-player",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Assessment player web component that orchestrates PIE item players and assessment-toolkit services",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  }
31
31
  },
32
32
  "files": [
33
- "dist"
33
+ "dist",
34
+ "src/components"
34
35
  ],
35
36
  "scripts": {
36
37
  "build": "vite build",
@@ -40,19 +41,19 @@
40
41
  "lint": "biome check ."
41
42
  },
42
43
  "peerDependencies": {
43
- "svelte": "^5.0.0"
44
+ "svelte": "^5.51.0"
44
45
  },
45
46
  "dependencies": {
46
- "@pie-players/pie-assessment-toolkit": "workspace:*",
47
- "@pie-players/pie-iife-player": "workspace:*",
48
- "@pie-players/pie-players-shared": "workspace:*",
49
- "@pie-players/pie-tool-toolbar": "workspace:*"
47
+ "@pie-players/pie-assessment-toolkit": "0.2.4",
48
+ "@pie-players/pie-iife-player": "0.2.2",
49
+ "@pie-players/pie-players-shared": "0.2.2",
50
+ "@pie-players/pie-tool-toolbar": "0.1.6"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@biomejs/biome": "^2.3.10",
53
54
  "@sveltejs/vite-plugin-svelte": "^6.1.4",
54
55
  "@types/node": "^22.12.7",
55
- "svelte": "^5.16.1",
56
+ "svelte": "^5.51.0",
56
57
  "svelte-check": "^4.1.1",
57
58
  "typescript": "^5.7.0",
58
59
  "vite": "^7.0.8",
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ import type { QuestionEntity } from '@pie-players/pie-players-shared';
3
+
4
+ let {
5
+ questions = [] as QuestionEntity[],
6
+ currentIndex = 0,
7
+ onItemSelected,
8
+ onItemRemove,
9
+ onItemAdd,
10
+ onItemMove,
11
+ readOnly = false
12
+ }: {
13
+ questions?: QuestionEntity[];
14
+ currentIndex?: number;
15
+ onItemSelected?: (index: number) => void;
16
+ onItemRemove?: (itemId: string, index: number) => void;
17
+ onItemAdd?: () => void;
18
+ onItemMove?: (fromIndex: number, toIndex: number) => void;
19
+ readOnly?: boolean;
20
+ } = $props();
21
+
22
+ let draggedIndex = $state<number | null>(null);
23
+
24
+ function handleItemClick(index: number) {
25
+ onItemSelected?.(index);
26
+ }
27
+
28
+ function handleRemove(event: Event, itemId: string, index: number) {
29
+ event.stopPropagation();
30
+ onItemRemove?.(itemId, index);
31
+ }
32
+
33
+ function handleDragStart(event: DragEvent, index: number) {
34
+ if (readOnly) return;
35
+ draggedIndex = index;
36
+ if (event.dataTransfer) {
37
+ event.dataTransfer.effectAllowed = 'move';
38
+ event.dataTransfer.setData('text/plain', index.toString());
39
+ }
40
+ }
41
+
42
+ function handleDragOver(event: DragEvent) {
43
+ if (readOnly || draggedIndex === null) return;
44
+ event.preventDefault();
45
+ if (event.dataTransfer) {
46
+ event.dataTransfer.dropEffect = 'move';
47
+ }
48
+ }
49
+
50
+ function handleDrop(event: DragEvent, targetIndex: number) {
51
+ if (readOnly || draggedIndex === null) return;
52
+ event.preventDefault();
53
+
54
+ if (draggedIndex !== targetIndex) {
55
+ onItemMove?.(draggedIndex, targetIndex);
56
+ }
57
+ draggedIndex = null;
58
+ }
59
+
60
+ function handleDragEnd() {
61
+ draggedIndex = null;
62
+ }
63
+ </script>
64
+
65
+ <div class="assessment-item-list flex flex-col h-full bg-base-100">
66
+ <div class="p-4 border-b border-base-300">
67
+ <h3 class="font-bold text-lg">Assessment Items</h3>
68
+ </div>
69
+
70
+ <div class="flex-1 overflow-y-auto p-2">
71
+ {#each questions as question, i}
72
+ <div
73
+ role="button"
74
+ tabindex="0"
75
+ class="item-card cursor-pointer rounded-lg p-3 mb-2 border transition-all"
76
+ class:border-primary={i === currentIndex}
77
+ class:bg-primary={i === currentIndex}
78
+ class:bg-opacity-10={i === currentIndex}
79
+ class:border-base-300={i !== currentIndex}
80
+ class:hover:bg-base-200={i !== currentIndex}
81
+ draggable={!readOnly}
82
+ ondragstart={(e) => handleDragStart(e, i)}
83
+ ondragover={handleDragOver}
84
+ ondrop={(e) => handleDrop(e, i)}
85
+ ondragend={handleDragEnd}
86
+ onclick={() => handleItemClick(i)}
87
+ onkeydown={(e) => {
88
+ if (e.key === 'Enter' || e.key === ' ') {
89
+ e.preventDefault();
90
+ handleItemClick(i);
91
+ }
92
+ }}
93
+ >
94
+ <div class="flex items-start justify-between gap-2">
95
+ <div class="flex-1 min-w-0">
96
+ <div class="flex items-center gap-2">
97
+ {#if !readOnly}
98
+ <span class="text-xs opacity-50">⋮⋮</span>
99
+ {/if}
100
+ <span class="font-semibold text-sm">Item {i + 1}</span>
101
+ </div>
102
+ {#if question.title}
103
+ <div class="text-xs opacity-70 truncate mt-1">{question.title}</div>
104
+ {/if}
105
+ {#if question.itemVId}
106
+ <div class="text-xs opacity-50 font-mono truncate mt-1">{question.itemVId}</div>
107
+ {/if}
108
+ </div>
109
+ {#if !readOnly && onItemRemove}
110
+ <button
111
+ class="btn btn-ghost btn-xs"
112
+ onclick={(e) => handleRemove(e, question.id || '', i)}
113
+ title="Remove item"
114
+ >
115
+ ✕
116
+ </button>
117
+ {/if}
118
+ </div>
119
+ </div>
120
+ {/each}
121
+ </div>
122
+
123
+ {#if !readOnly && onItemAdd}
124
+ <div class="p-4 border-t border-base-300">
125
+ <button class="btn btn-primary btn-sm w-full" onclick={onItemAdd}>+ Add Item</button>
126
+ </div>
127
+ {/if}
128
+ </div>
129
+
130
+ <style>
131
+ .item-card {
132
+ user-select: none;
133
+ }
134
+
135
+ .item-card:active {
136
+ cursor: grabbing;
137
+ }
138
+ </style>
@@ -0,0 +1,306 @@
1
+ <script lang="ts">
2
+ import type { AssessmentSection, QuestionEntity } from '@pie-players/pie-players-shared';
3
+
4
+ let {
5
+ sections = [] as AssessmentSection[],
6
+ questions = [] as QuestionEntity[],
7
+ currentSectionId = null,
8
+ currentQuestionId = null,
9
+
10
+ // Section operations
11
+ onSectionAdd,
12
+ onSectionRemove,
13
+ onSectionUpdate,
14
+ onSectionMove,
15
+
16
+ // Question operations
17
+ onQuestionMoveToSection,
18
+ onQuestionRemoveFromSection,
19
+
20
+ // Selection
21
+ onSectionSelected,
22
+ onQuestionSelected,
23
+
24
+ readOnly = false
25
+ }: {
26
+ sections?: AssessmentSection[];
27
+ questions?: QuestionEntity[];
28
+ currentSectionId?: string | null;
29
+ currentQuestionId?: string | null;
30
+
31
+ onSectionAdd?: () => void;
32
+ onSectionRemove?: (sectionId: string) => void;
33
+ onSectionUpdate?: (sectionId: string, updates: Partial<AssessmentSection>) => void;
34
+ onSectionMove?: (fromIndex: number, toIndex: number) => void;
35
+
36
+ onQuestionMoveToSection?: (questionId: string, sectionId: string, index: number) => void;
37
+ onQuestionRemoveFromSection?: (questionId: string, sectionId: string) => void;
38
+
39
+ onSectionSelected?: (sectionId: string) => void;
40
+ onQuestionSelected?: (questionId: string) => void;
41
+
42
+ readOnly?: boolean;
43
+ } = $props();
44
+
45
+ // Drag state
46
+ let draggedSectionIndex = $state<number | null>(null);
47
+ let draggedQuestionId = $state<string | null>(null);
48
+ let expandedSections = $state<Set<string>>(new Set());
49
+
50
+ // Derived: Resolve questions for each section
51
+ const questionsMap = $derived(new Map(questions.map((q) => [q.id, q])));
52
+
53
+ function getSectionQuestions(section: AssessmentSection): QuestionEntity[] {
54
+ return section.questions
55
+ .map((ref) => questionsMap.get(ref.questionId))
56
+ .filter((q): q is QuestionEntity => q !== undefined);
57
+ }
58
+
59
+ function getUnassignedQuestions(): QuestionEntity[] {
60
+ const assignedIds = new Set(sections.flatMap((s) => s.questions.map((ref) => ref.questionId)));
61
+ return questions.filter((q) => !assignedIds.has(q.id!));
62
+ }
63
+
64
+ function toggleSection(sectionId: string) {
65
+ const newExpanded = new Set(expandedSections);
66
+ if (newExpanded.has(sectionId)) {
67
+ newExpanded.delete(sectionId);
68
+ } else {
69
+ newExpanded.add(sectionId);
70
+ }
71
+ expandedSections = newExpanded;
72
+ }
73
+
74
+ function handleSectionTitleChange(sectionId: string, title: string) {
75
+ onSectionUpdate?.(sectionId, { title });
76
+ }
77
+
78
+ // Section drag handlers
79
+ function handleSectionDragStart(event: DragEvent, index: number) {
80
+ if (readOnly) return;
81
+ draggedSectionIndex = index;
82
+ if (event.dataTransfer) {
83
+ event.dataTransfer.effectAllowed = 'move';
84
+ event.dataTransfer.setData('text/plain', `section-${index}`);
85
+ }
86
+ }
87
+
88
+ function handleSectionDrop(event: DragEvent, targetIndex: number) {
89
+ if (readOnly || draggedSectionIndex === null) return;
90
+ event.preventDefault();
91
+
92
+ if (draggedSectionIndex !== targetIndex) {
93
+ onSectionMove?.(draggedSectionIndex, targetIndex);
94
+ }
95
+ draggedSectionIndex = null;
96
+ }
97
+
98
+ // Question drag handlers
99
+ function handleQuestionDragStart(event: DragEvent, questionId: string, _fromSectionId: string | null) {
100
+ if (readOnly) return;
101
+ draggedQuestionId = questionId;
102
+ if (event.dataTransfer) {
103
+ event.dataTransfer.effectAllowed = 'move';
104
+ event.dataTransfer.setData('text/plain', questionId);
105
+ }
106
+ }
107
+
108
+ function handleQuestionDrop(event: DragEvent, toSectionId: string, targetIndex: number) {
109
+ if (readOnly || draggedQuestionId === null) return;
110
+ event.preventDefault();
111
+
112
+ onQuestionMoveToSection?.(draggedQuestionId, toSectionId, targetIndex);
113
+ draggedQuestionId = null;
114
+ }
115
+
116
+ function handleDragOver(event: DragEvent) {
117
+ if (readOnly) return;
118
+ event.preventDefault();
119
+ if (event.dataTransfer) {
120
+ event.dataTransfer.dropEffect = 'move';
121
+ }
122
+ }
123
+
124
+ function handleDragEnd() {
125
+ draggedSectionIndex = null;
126
+ draggedQuestionId = null;
127
+ }
128
+ </script>
129
+
130
+ <div class="assessment-section-list flex flex-col h-full bg-base-100">
131
+ <!-- Header with Add Section button -->
132
+ <div class="p-4 border-b border-base-300 flex items-center justify-between">
133
+ <h3 class="font-bold text-lg">Assessment Structure</h3>
134
+ {#if !readOnly && onSectionAdd}
135
+ <button class="btn btn-primary btn-sm" onclick={onSectionAdd}>+ Add Section</button>
136
+ {/if}
137
+ </div>
138
+
139
+ <!-- Scrollable section list -->
140
+ <div class="flex-1 overflow-y-auto p-2">
141
+ {#each sections as section, sectionIndex (section.id)}
142
+ <div
143
+ role="region"
144
+ aria-label="Section: {section.title || 'Untitled Section'}"
145
+ class="section-container mb-3 rounded-lg border transition-all"
146
+ class:border-primary={section.id === currentSectionId}
147
+ class:border-base-300={section.id !== currentSectionId}
148
+ draggable={!readOnly}
149
+ ondragstart={(e) => handleSectionDragStart(e, sectionIndex)}
150
+ ondragover={handleDragOver}
151
+ ondrop={(e) => handleSectionDrop(e, sectionIndex)}
152
+ ondragend={handleDragEnd}
153
+ >
154
+ <!-- Section Header -->
155
+ <div class="section-header p-3 bg-base-200 rounded-t-lg flex items-center justify-between">
156
+ <div class="flex items-center gap-2 flex-1">
157
+ {#if !readOnly}
158
+ <span class="cursor-grab text-sm opacity-50">⋮⋮</span>
159
+ {/if}
160
+
161
+ <button class="btn btn-ghost btn-xs" onclick={() => toggleSection(section.id!)}>
162
+ {expandedSections.has(section.id!) ? '▼' : '▶'}
163
+ </button>
164
+
165
+ <input
166
+ type="text"
167
+ class="input input-ghost input-sm flex-1 font-semibold"
168
+ value={section.title || 'Untitled Section'}
169
+ onchange={(e) => handleSectionTitleChange(section.id!, e.currentTarget.value)}
170
+ readonly={readOnly}
171
+ onclick={(e) => {
172
+ e.stopPropagation();
173
+ onSectionSelected?.(section.id!);
174
+ }}
175
+ />
176
+
177
+ <span class="badge badge-sm">{section.questions.length} items</span>
178
+ </div>
179
+
180
+ {#if !readOnly && onSectionRemove}
181
+ <button
182
+ class="btn btn-ghost btn-xs"
183
+ onclick={(e) => {
184
+ e.stopPropagation();
185
+ onSectionRemove(section.id!);
186
+ }}
187
+ title="Remove section"
188
+ >
189
+ ✕
190
+ </button>
191
+ {/if}
192
+ </div>
193
+
194
+ <!-- Section Questions (Collapsible) -->
195
+ {#if expandedSections.has(section.id!)}
196
+ <div class="section-questions p-2 bg-base-100">
197
+ {#each getSectionQuestions(section) as question, qIndex (question.id)}
198
+ <div
199
+ role="button"
200
+ tabindex="0"
201
+ class="question-card cursor-pointer rounded p-2 mb-2 border transition-all group"
202
+ class:border-primary={question.id === currentQuestionId}
203
+ class:bg-primary={question.id === currentQuestionId}
204
+ class:bg-opacity-5={question.id === currentQuestionId}
205
+ class:border-base-300={question.id !== currentQuestionId}
206
+ class:hover:bg-base-200={question.id !== currentQuestionId}
207
+ draggable={!readOnly}
208
+ ondragstart={(e) => handleQuestionDragStart(e, question.id!, section.id!)}
209
+ ondragover={handleDragOver}
210
+ ondrop={(e) => handleQuestionDrop(e, section.id!, qIndex)}
211
+ ondragend={handleDragEnd}
212
+ onclick={() => onQuestionSelected?.(question.id!)}
213
+ onkeydown={(e) => {
214
+ if (e.key === 'Enter' || e.key === ' ') {
215
+ e.preventDefault();
216
+ onQuestionSelected?.(question.id!);
217
+ }
218
+ }}
219
+ >
220
+ <div class="flex items-center gap-2">
221
+ {#if !readOnly}
222
+ <span class="text-xs opacity-30">⋮⋮</span>
223
+ {/if}
224
+ <div class="flex-1 min-w-0">
225
+ <div class="font-medium text-sm truncate">{question.title || 'Untitled'}</div>
226
+ <div class="text-xs opacity-60 font-mono truncate">{question.itemVId}</div>
227
+ </div>
228
+ {#if !readOnly && onQuestionRemoveFromSection}
229
+ <button
230
+ class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100"
231
+ onclick={(e) => {
232
+ e.stopPropagation();
233
+ onQuestionRemoveFromSection(question.id!, section.id!);
234
+ }}
235
+ title="Remove from section"
236
+ >
237
+ ✕
238
+ </button>
239
+ {/if}
240
+ </div>
241
+ </div>
242
+ {/each}
243
+
244
+ <!-- Drop zone for adding items -->
245
+ {#if !readOnly}
246
+ <div
247
+ role="region"
248
+ aria-label="Drop zone for adding items to section"
249
+ class="drop-zone p-3 border-2 border-dashed border-base-300 rounded text-center text-xs opacity-50 hover:opacity-100 transition-opacity"
250
+ ondragover={handleDragOver}
251
+ ondrop={(e) => handleQuestionDrop(e, section.id!, getSectionQuestions(section).length)}
252
+ >
253
+ Drop items here
254
+ </div>
255
+ {/if}
256
+ </div>
257
+ {/if}
258
+ </div>
259
+ {/each}
260
+
261
+ <!-- Unassigned Questions Section -->
262
+ {#if getUnassignedQuestions().length > 0}
263
+ <div class="unassigned-section mb-3 rounded-lg border border-dashed border-base-300">
264
+ <div class="p-3 bg-base-200 rounded-t-lg">
265
+ <h4 class="font-semibold text-sm opacity-70">Unassigned Items</h4>
266
+ <p class="text-xs opacity-50">Drag these items into sections</p>
267
+ </div>
268
+ <div class="p-2">
269
+ {#each getUnassignedQuestions() as question (question.id)}
270
+ <div
271
+ role="button"
272
+ tabindex="0"
273
+ aria-label="Unassigned question: {question.title || 'Untitled'}"
274
+ class="question-card cursor-move rounded p-2 mb-2 border border-base-300 hover:bg-base-200"
275
+ draggable={!readOnly}
276
+ ondragstart={(e) => handleQuestionDragStart(e, question.id!, null)}
277
+ ondragend={handleDragEnd}
278
+ >
279
+ <div class="flex items-center gap-2">
280
+ <span class="text-xs opacity-30">⋮⋮</span>
281
+ <div class="flex-1 min-w-0">
282
+ <div class="font-medium text-sm truncate">{question.title || 'Untitled'}</div>
283
+ <div class="text-xs opacity-60 font-mono truncate">{question.itemVId}</div>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ {/each}
288
+ </div>
289
+ </div>
290
+ {/if}
291
+ </div>
292
+ </div>
293
+
294
+ <style>
295
+ .question-card {
296
+ user-select: none;
297
+ }
298
+
299
+ .question-card:active {
300
+ cursor: grabbing;
301
+ }
302
+
303
+ .section-container:active {
304
+ cursor: grabbing;
305
+ }
306
+ </style>