@discourser/design-system 0.15.0 → 0.15.1

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 (88) hide show
  1. package/dist/{chunk-QC44JPCA.cjs → chunk-ABC7N32K.cjs} +316 -10
  2. package/dist/chunk-ABC7N32K.cjs.map +1 -0
  3. package/dist/{chunk-F7LHARS4.js → chunk-GD6Q2FUE.js} +446 -6
  4. package/dist/chunk-GD6Q2FUE.js.map +1 -0
  5. package/dist/{chunk-M7J7WKJY.js → chunk-SBKRSXSZ.js} +317 -11
  6. package/dist/chunk-SBKRSXSZ.js.map +1 -0
  7. package/dist/{chunk-QP4EJI3G.cjs → chunk-UNWXE6UB.cjs} +450 -2
  8. package/dist/chunk-UNWXE6UB.cjs.map +1 -0
  9. package/dist/components/Breadcrumb.d.ts +9 -0
  10. package/dist/components/Breadcrumb.d.ts.map +1 -0
  11. package/dist/components/Checkbox.d.ts +6 -6
  12. package/dist/components/Icons/ClockIcon.d.ts +6 -0
  13. package/dist/components/Icons/ClockIcon.d.ts.map +1 -0
  14. package/dist/components/Icons/GripDotsVerticalIcon.d.ts +6 -0
  15. package/dist/components/Icons/GripDotsVerticalIcon.d.ts.map +1 -0
  16. package/dist/components/Icons/index.d.ts +3 -0
  17. package/dist/components/Icons/index.d.ts.map +1 -0
  18. package/dist/components/ScenarioQueue/AddScenarioDialog.d.ts +16 -0
  19. package/dist/components/ScenarioQueue/AddScenarioDialog.d.ts.map +1 -0
  20. package/dist/components/ScenarioQueue/ScenarioCard.d.ts +10 -0
  21. package/dist/components/ScenarioQueue/ScenarioCard.d.ts.map +1 -0
  22. package/dist/components/ScenarioQueue/ScenarioCardDraggable.d.ts +15 -0
  23. package/dist/components/ScenarioQueue/ScenarioCardDraggable.d.ts.map +1 -0
  24. package/dist/components/ScenarioQueue/ScenarioQueue.d.ts +3 -0
  25. package/dist/components/ScenarioQueue/ScenarioQueue.d.ts.map +1 -0
  26. package/dist/components/ScenarioQueue/index.d.ts +6 -0
  27. package/dist/components/ScenarioQueue/index.d.ts.map +1 -0
  28. package/dist/components/ScenarioQueue/types.d.ts +56 -0
  29. package/dist/components/ScenarioQueue/types.d.ts.map +1 -0
  30. package/dist/components/index.cjs +65 -33
  31. package/dist/components/index.d.ts +4 -0
  32. package/dist/components/index.d.ts.map +1 -1
  33. package/dist/components/index.js +1 -1
  34. package/dist/index.cjs +69 -37
  35. package/dist/index.js +2 -2
  36. package/dist/preset/index.cjs +2 -2
  37. package/dist/preset/index.d.ts.map +1 -1
  38. package/dist/preset/index.js +1 -1
  39. package/dist/preset/recipes/avatar.d.ts.map +1 -1
  40. package/dist/preset/recipes/breadcrumb.d.ts +2 -0
  41. package/dist/preset/recipes/breadcrumb.d.ts.map +1 -0
  42. package/dist/preset/recipes/checkbox.d.ts.map +1 -1
  43. package/dist/preset/recipes/field.d.ts.map +1 -1
  44. package/dist/preset/recipes/index.d.ts +3 -0
  45. package/dist/preset/recipes/index.d.ts.map +1 -1
  46. package/dist/preset/recipes/progress.d.ts.map +1 -1
  47. package/dist/preset/recipes/radio-group.d.ts.map +1 -1
  48. package/dist/preset/recipes/scenario-card.d.ts +2 -0
  49. package/dist/preset/recipes/scenario-card.d.ts.map +1 -0
  50. package/dist/preset/recipes/scenario-queue.d.ts +2 -0
  51. package/dist/preset/recipes/scenario-queue.d.ts.map +1 -0
  52. package/dist/preset/recipes/steps.d.ts.map +1 -1
  53. package/dist/preset/recipes/toast.d.ts.map +1 -1
  54. package/dist/preset/recipes/tooltip.d.ts.map +1 -1
  55. package/dist/preset/semantic-tokens.d.ts +12 -0
  56. package/dist/preset/semantic-tokens.d.ts.map +1 -1
  57. package/package.json +10 -1
  58. package/src/components/Breadcrumb.tsx +34 -0
  59. package/src/components/Icons/ClockIcon.tsx +40 -0
  60. package/src/components/Icons/GripDotsVerticalIcon.tsx +26 -0
  61. package/src/components/Icons/index.ts +2 -0
  62. package/src/components/ScenarioQueue/AddScenarioDialog.tsx +137 -0
  63. package/src/components/ScenarioQueue/ScenarioCard.tsx +120 -0
  64. package/src/components/ScenarioQueue/ScenarioCardDraggable.tsx +41 -0
  65. package/src/components/ScenarioQueue/ScenarioQueue.test.tsx +398 -0
  66. package/src/components/ScenarioQueue/ScenarioQueue.tsx +162 -0
  67. package/src/components/ScenarioQueue/index.ts +11 -0
  68. package/src/components/ScenarioQueue/types.ts +86 -0
  69. package/src/components/index.ts +19 -0
  70. package/src/preset/index.ts +9 -0
  71. package/src/preset/recipes/avatar.ts +1 -2
  72. package/src/preset/recipes/breadcrumb.ts +77 -0
  73. package/src/preset/recipes/checkbox.ts +1 -2
  74. package/src/preset/recipes/field.ts +1 -2
  75. package/src/preset/recipes/index.ts +7 -0
  76. package/src/preset/recipes/progress.ts +1 -2
  77. package/src/preset/recipes/radio-group.ts +1 -2
  78. package/src/preset/recipes/scenario-card.ts +151 -0
  79. package/src/preset/recipes/scenario-queue.ts +99 -0
  80. package/src/preset/recipes/steps.ts +1 -2
  81. package/src/preset/recipes/toast.ts +1 -2
  82. package/src/preset/recipes/tooltip.ts +1 -2
  83. package/src/preset/semantic-tokens.ts +4 -0
  84. package/src/test/setup.ts +12 -0
  85. package/dist/chunk-F7LHARS4.js.map +0 -1
  86. package/dist/chunk-M7J7WKJY.js.map +0 -1
  87. package/dist/chunk-QC44JPCA.cjs.map +0 -1
  88. package/dist/chunk-QP4EJI3G.cjs.map +0 -1
@@ -0,0 +1,398 @@
1
+ /* global describe, it, expect, vi, beforeEach */
2
+ import React from 'react'
3
+ import { render, screen, act } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { ScenarioQueue } from './ScenarioQueue'
6
+ import type { Scenario } from './types'
7
+
8
+ // ── dnd-kit mock ──────────────────────────────────────────────────────────────
9
+ // @dnd-kit/react's onDragEnd event uses source.initialIndex / source.index,
10
+ // not source.id vs target.id. We capture the callback here so tests can
11
+ // call it directly with the correct event shape.
12
+
13
+ let capturedDragEnd: ((event: {
14
+ canceled: boolean
15
+ operation: { source: { id: unknown; index: number; initialIndex: number } | null }
16
+ }) => void) | undefined
17
+
18
+ vi.mock('@dnd-kit/react', () => ({
19
+ DragDropProvider: ({
20
+ children,
21
+ onDragEnd,
22
+ }: {
23
+ children: React.ReactNode
24
+ onDragEnd: typeof capturedDragEnd
25
+ }) => {
26
+ capturedDragEnd = onDragEnd
27
+ return React.createElement(React.Fragment, null, children)
28
+ },
29
+ }))
30
+
31
+ vi.mock('@dnd-kit/react/sortable', () => ({
32
+ useSortable: () => ({
33
+ ref: () => undefined,
34
+ handleRef: () => undefined,
35
+ isDragging: false,
36
+ }),
37
+ }))
38
+
39
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
40
+ // Mirrors the mock data in ScenarioQueue.stories.tsx
41
+
42
+ const MOCK_QUEUE: Scenario[] = [
43
+ {
44
+ id: 'ux-research',
45
+ title: 'UX Research & Design Interview',
46
+ category: 'Design',
47
+ difficulty: 'beginner',
48
+ duration: '10-15 min',
49
+ status: 'queued',
50
+ },
51
+ {
52
+ id: 'biz-analysis',
53
+ title: 'Business Analysis ROI Design Presentation',
54
+ category: 'Business',
55
+ difficulty: 'intermediate',
56
+ duration: '15-25 min',
57
+ status: 'queued',
58
+ },
59
+ {
60
+ id: 'product-redesign',
61
+ title: 'Product Redesign Challenge',
62
+ category: 'Product',
63
+ difficulty: 'advanced',
64
+ duration: '25-35 min',
65
+ status: 'queued',
66
+ },
67
+ ]
68
+
69
+ const MOCK_COMPLETED: Scenario[] = [
70
+ {
71
+ id: 'stakeholder-mgmt',
72
+ title: 'Stakeholder Management Scenario',
73
+ category: 'Leadership',
74
+ difficulty: 'intermediate',
75
+ duration: '20-30 min',
76
+ status: 'completed',
77
+ },
78
+ {
79
+ id: 'agile-sprint',
80
+ title: 'Agile Sprint Planning Session',
81
+ category: 'Process',
82
+ difficulty: 'beginner',
83
+ duration: '10-15 min',
84
+ status: 'completed',
85
+ },
86
+ ]
87
+
88
+ const ALL_SCENARIOS = [...MOCK_QUEUE, ...MOCK_COMPLETED]
89
+
90
+ // ── Helpers ───────────────────────────────────────────────────────────────────
91
+
92
+ function simulateDrag(initialIndex: number, newIndex: number) {
93
+ act(() => {
94
+ capturedDragEnd?.({
95
+ canceled: false,
96
+ operation: {
97
+ source: { id: MOCK_QUEUE[initialIndex]?.id ?? 'item', initialIndex, index: newIndex },
98
+ },
99
+ })
100
+ })
101
+ }
102
+
103
+ // ── Tests ─────────────────────────────────────────────────────────────────────
104
+
105
+ describe('ScenarioQueue', () => {
106
+ beforeEach(() => {
107
+ capturedDragEnd = undefined
108
+ })
109
+
110
+ // ── Rendering ───────────────────────────────────────────────────────────────
111
+
112
+ describe('Rendering', () => {
113
+ it('queue tab is the default active tab', () => {
114
+ render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
115
+
116
+ const queueTab = screen.getByRole('tab', { name: 'In Queue' })
117
+ expect(queueTab).toHaveAttribute('aria-selected', 'true')
118
+ })
119
+
120
+ it('queue tab renders 3 scenario cards by default', () => {
121
+ render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
122
+
123
+ expect(screen.getByText('UX Research & Design Interview')).toBeInTheDocument()
124
+ expect(
125
+ screen.getByText('Business Analysis ROI Design Presentation'),
126
+ ).toBeInTheDocument()
127
+ expect(screen.getByText('Product Redesign Challenge')).toBeInTheDocument()
128
+ })
129
+
130
+ it('switching to Completed tab makes 2 completed cards visible', async () => {
131
+ const user = userEvent.setup()
132
+ render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
133
+
134
+ await user.click(screen.getByRole('tab', { name: 'Completed' }))
135
+
136
+ expect(screen.getByText('Stakeholder Management Scenario')).toBeVisible()
137
+ expect(screen.getByText('Agile Sprint Planning Session')).toBeVisible()
138
+ })
139
+
140
+ it('header count reflects total number of scenarios', () => {
141
+ render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
142
+ expect(screen.getByText('5 scenarios')).toBeInTheDocument()
143
+ })
144
+
145
+ it('queue tab cards show drag handle, position badge, title, difficulty, duration', () => {
146
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
147
+
148
+ // Drag handles — one per queued card
149
+ expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(3)
150
+
151
+ // Position badges
152
+ expect(screen.getByLabelText('Position 1')).toBeInTheDocument()
153
+ expect(screen.getByLabelText('Position 2')).toBeInTheDocument()
154
+ expect(screen.getByLabelText('Position 3')).toBeInTheDocument()
155
+
156
+ // Titles, difficulty labels, durations
157
+ expect(screen.getByText('UX Research & Design Interview')).toBeInTheDocument()
158
+ expect(screen.getByText('Beginner')).toBeInTheDocument()
159
+ expect(screen.getByText('10-15 min')).toBeInTheDocument()
160
+ })
161
+
162
+ it('completed tab cards show title, difficulty, duration and re-queue toggle — no drag handle, no position badge', async () => {
163
+ const user = userEvent.setup()
164
+ // Render only completed — ensures zero drag handles exist anywhere in DOM
165
+ render(<ScenarioQueue scenarios={MOCK_COMPLETED} />)
166
+
167
+ await user.click(screen.getByRole('tab', { name: 'Completed' }))
168
+
169
+ // Cards are present
170
+ expect(screen.getByText('Stakeholder Management Scenario')).toBeInTheDocument()
171
+ expect(screen.getByText('Agile Sprint Planning Session')).toBeInTheDocument()
172
+
173
+ // Re-queue toggles present
174
+ expect(
175
+ screen.getByLabelText('Re-queue Stakeholder Management Scenario'),
176
+ ).toBeInTheDocument()
177
+ expect(
178
+ screen.getByLabelText('Re-queue Agile Sprint Planning Session'),
179
+ ).toBeInTheDocument()
180
+
181
+ // No drag handles or position badges anywhere
182
+ expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument()
183
+ expect(screen.queryByLabelText(/^Position \d/)).not.toBeInTheDocument()
184
+ })
185
+
186
+ it('empty queue tab shows empty state message', () => {
187
+ render(<ScenarioQueue scenarios={MOCK_COMPLETED} />)
188
+ expect(screen.getByText('No scenarios in queue')).toBeInTheDocument()
189
+ })
190
+
191
+ it('empty completed tab shows empty state message', async () => {
192
+ const user = userEvent.setup()
193
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
194
+
195
+ await user.click(screen.getByRole('tab', { name: 'Completed' }))
196
+
197
+ expect(screen.getByText('No completed scenarios')).toBeInTheDocument()
198
+ })
199
+ })
200
+
201
+ // ── Position Numbering & Active Card Styling ────────────────────────────────
202
+
203
+ describe('Position Numbering', () => {
204
+ it('shows sequential position numbers 1, 2, 3 matching array order', () => {
205
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
206
+
207
+ expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
208
+ expect(screen.getByLabelText('Position 2')).toHaveTextContent('2')
209
+ expect(screen.getByLabelText('Position 3')).toHaveTextContent('3')
210
+ })
211
+
212
+ it('position badges are in the correct DOM order (1 before 2 before 3)', () => {
213
+ const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
214
+
215
+ const badges = container.querySelectorAll('[aria-label^="Position"]')
216
+ expect(badges[0]).toHaveAccessibleName('Position 1')
217
+ expect(badges[1]).toHaveAccessibleName('Position 2')
218
+ expect(badges[2]).toHaveAccessibleName('Position 3')
219
+ })
220
+ })
221
+
222
+ // ── Difficulty Badge Attributes ─────────────────────────────────────────────
223
+
224
+ describe('Difficulty Badge Attributes', () => {
225
+ it('beginner card renders data-difficulty="beginner" on its badge', () => {
226
+ const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
227
+
228
+ // At least one element (difficultyBadge) must carry data-difficulty="beginner"
229
+ expect(
230
+ container.querySelector('[data-difficulty="beginner"]'),
231
+ ).toBeInTheDocument()
232
+ })
233
+
234
+ it('intermediate card renders data-difficulty="intermediate" on its badge', () => {
235
+ const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
236
+
237
+ expect(
238
+ container.querySelector('[data-difficulty="intermediate"]'),
239
+ ).toBeInTheDocument()
240
+ })
241
+
242
+ it('advanced card renders data-difficulty="advanced" on its badge', () => {
243
+ const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
244
+
245
+ expect(
246
+ container.querySelector('[data-difficulty="advanced"]'),
247
+ ).toBeInTheDocument()
248
+ })
249
+
250
+ it('difficulty label text matches the difficulty value', () => {
251
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
252
+
253
+ expect(screen.getByText('Beginner')).toBeInTheDocument()
254
+ expect(screen.getByText('Intermediate')).toBeInTheDocument()
255
+ expect(screen.getByText('Advanced')).toBeInTheDocument()
256
+ })
257
+ })
258
+
259
+ // ── Re-queue Flow ───────────────────────────────────────────────────────────
260
+
261
+ describe('Re-queue Flow', () => {
262
+ it('toggling the re-queue switch calls onRequeue with the scenario ID', async () => {
263
+ const user = userEvent.setup()
264
+ const onRequeue = vi.fn()
265
+ render(<ScenarioQueue scenarios={ALL_SCENARIOS} onRequeue={onRequeue} />)
266
+
267
+ await user.click(screen.getByRole('tab', { name: 'Completed' }))
268
+
269
+ // Switch starts checked (defaultChecked). Click to uncheck → triggers re-queue.
270
+ const switchEl = screen.getByLabelText('Re-queue Stakeholder Management Scenario')
271
+ await user.click(switchEl)
272
+
273
+ expect(onRequeue).toHaveBeenCalledWith('stakeholder-mgmt')
274
+ expect(onRequeue).toHaveBeenCalledTimes(1)
275
+ })
276
+
277
+ it('re-queued card with wasRequeued=true shows Repeat badge in queue tab', () => {
278
+ const requeued: Scenario = {
279
+ ...MOCK_COMPLETED[0],
280
+ status: 'queued',
281
+ wasRequeued: true,
282
+ }
283
+ render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
284
+
285
+ expect(screen.getByText('Repeat')).toBeInTheDocument()
286
+ })
287
+
288
+ it('re-queued card appears as position 4 at end of queue', () => {
289
+ const requeued: Scenario = {
290
+ ...MOCK_COMPLETED[0],
291
+ status: 'queued',
292
+ wasRequeued: true,
293
+ }
294
+ render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
295
+
296
+ expect(screen.getByLabelText('Position 4')).toHaveTextContent('4')
297
+ })
298
+
299
+ it('cards without wasRequeued flag do not show Repeat badge', () => {
300
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
301
+ expect(screen.queryByText('Repeat')).not.toBeInTheDocument()
302
+ })
303
+
304
+ it('only the re-queued card shows the Repeat badge, not other queue cards', () => {
305
+ const requeued: Scenario = {
306
+ ...MOCK_COMPLETED[0],
307
+ status: 'queued',
308
+ wasRequeued: true,
309
+ }
310
+ render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
311
+
312
+ // Exactly one Repeat badge
313
+ expect(screen.getAllByText('Repeat')).toHaveLength(1)
314
+ })
315
+ })
316
+
317
+ // ── Drag-Drop Reorder ───────────────────────────────────────────────────────
318
+
319
+ describe('Drag-Drop Reorder', () => {
320
+ it('simulated reorder calls onReorder with new ID order', () => {
321
+ const onReorder = vi.fn()
322
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
323
+
324
+ // Move card at index 0 (ux-research) to index 1
325
+ simulateDrag(0, 1)
326
+
327
+ expect(onReorder).toHaveBeenCalledWith([
328
+ 'biz-analysis',
329
+ 'ux-research',
330
+ 'product-redesign',
331
+ ])
332
+ })
333
+
334
+ it('position badge numbers update to reflect new array order after reorder', () => {
335
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
336
+
337
+ // Move card 0 (ux-research) to index 2 (last position)
338
+ simulateDrag(0, 2)
339
+
340
+ // The 3 positions should still exist (numbers 1, 2, 3)
341
+ expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
342
+ expect(screen.getByLabelText('Position 2')).toHaveTextContent('2')
343
+ expect(screen.getByLabelText('Position 3')).toHaveTextContent('3')
344
+ })
345
+
346
+ it('after reorder, position badges are in the correct DOM order', () => {
347
+ const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
348
+
349
+ simulateDrag(0, 2)
350
+
351
+ // The DOM order of position badges should be 1 → 2 → 3 top to bottom
352
+ const badges = container.querySelectorAll('[aria-label^="Position"]')
353
+ expect(badges[0]).toHaveAccessibleName('Position 1')
354
+ expect(badges[1]).toHaveAccessibleName('Position 2')
355
+ expect(badges[2]).toHaveAccessibleName('Position 3')
356
+ })
357
+
358
+ it('canceled drag does not update positions or call onReorder', () => {
359
+ const onReorder = vi.fn()
360
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
361
+
362
+ act(() => {
363
+ capturedDragEnd?.({
364
+ canceled: true,
365
+ operation: {
366
+ source: { id: 'ux-research', initialIndex: 0, index: 1 },
367
+ },
368
+ })
369
+ })
370
+
371
+ expect(onReorder).not.toHaveBeenCalled()
372
+ // Positions unchanged
373
+ expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
374
+ })
375
+
376
+ it('drag to same index does not update positions or call onReorder', () => {
377
+ const onReorder = vi.fn()
378
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
379
+
380
+ simulateDrag(1, 1) // same index — no-op
381
+
382
+ expect(onReorder).not.toHaveBeenCalled()
383
+ })
384
+
385
+ it('drag with null source does not throw or call onReorder', () => {
386
+ const onReorder = vi.fn()
387
+ render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
388
+
389
+ expect(() => {
390
+ act(() => {
391
+ capturedDragEnd?.({ canceled: false, operation: { source: null } })
392
+ })
393
+ }).not.toThrow()
394
+
395
+ expect(onReorder).not.toHaveBeenCalled()
396
+ })
397
+ })
398
+ })
@@ -0,0 +1,162 @@
1
+ 'use client'
2
+ import { DragDropProvider } from '@dnd-kit/react'
3
+ import { useState, useEffect, type ComponentProps } from 'react'
4
+ import { css } from 'styled-system/css'
5
+ import { Box, Center } from 'styled-system/jsx'
6
+ import { scenarioQueue } from 'styled-system/recipes'
7
+ import { Button } from '../Button'
8
+ import * as Tabs from '../Tabs'
9
+ import { AddScenarioDialog } from './AddScenarioDialog'
10
+ import { ScenarioCard } from './ScenarioCard'
11
+ import { ScenarioCardDraggable } from './ScenarioCardDraggable'
12
+ import type { ScenarioQueueProps, Scenario } from './types'
13
+
14
+ // ── Helpers ──────────────────────────────────────────────────────────────────
15
+
16
+ function arrayMove<T>(array: T[], from: number, to: number): T[] {
17
+ const next = [...array]
18
+ const [item] = next.splice(from, 1)
19
+ next.splice(to, 0, item)
20
+ return next
21
+ }
22
+
23
+ // ── Component ─────────────────────────────────────────────────────────────────
24
+
25
+ export function ScenarioQueue({
26
+ scenarios,
27
+ onReorder,
28
+ onRequeue,
29
+ onRemove: _onRemove,
30
+ renderAddScenarioContent,
31
+ onBrowseMore,
32
+ onBuildCustom,
33
+ }: ScenarioQueueProps) {
34
+ const queuedRaw = scenarios.filter((s) => s.status === 'queued')
35
+ const completed = scenarios.filter((s) => s.status === 'completed')
36
+
37
+ // Local ordering state — mirrors queuedRaw but supports optimistic drag reorder
38
+ const [localQueue, setLocalQueue] = useState<Scenario[]>(queuedRaw)
39
+
40
+ // Sync when the canonical scenarios list changes externally
41
+ useEffect(() => {
42
+ setLocalQueue(queuedRaw)
43
+ }, [scenarios])
44
+
45
+ const [dialogOpen, setDialogOpen] = useState(false)
46
+ const totalCount = localQueue.length + completed.length
47
+
48
+ // useSortable adds index + initialIndex to the draggable at runtime; cast here
49
+ // since @dnd-kit/react's static Draggable<Data> type doesn't declare them.
50
+ type SortableSource = { index: number; initialIndex: number } & Record<string, unknown>
51
+
52
+ const handleDragEnd: NonNullable<ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
53
+ if (event.canceled) return
54
+ const source = event.operation?.source as SortableSource | null
55
+ if (!source) return
56
+
57
+ const oldIndex = source.initialIndex
58
+ const newIndex = source.index
59
+
60
+ if (oldIndex === newIndex) return
61
+
62
+ const next = arrayMove(localQueue, oldIndex, newIndex)
63
+ setLocalQueue(next)
64
+ onReorder?.(next.map((s) => s.id))
65
+ }
66
+
67
+ const styles = scenarioQueue()
68
+
69
+ return (
70
+ <Box className={styles.root}>
71
+ {/* Header */}
72
+ <Box className={styles.header}>
73
+ <Box className={styles.title}>Scenario Queue</Box>
74
+ <Box className={styles.count}>
75
+ {totalCount} scenario{totalCount !== 1 ? 's' : ''}
76
+ </Box>
77
+ </Box>
78
+
79
+ {/* Tabs.Root gets tabsInner directly — no wrapper div needed.
80
+ flex:1 + minHeight:0 allows content panels to scroll independently
81
+ without the panel expanding indefinitely. */}
82
+ <Tabs.Root
83
+ defaultValue="queue"
84
+ variant="line"
85
+ size="md"
86
+ colorPalette="primary"
87
+ className={styles.tabsInner}
88
+ >
89
+ <Box className={styles.tabList}>
90
+ <Tabs.List className={css({ borderBottomWidth: '0' })}>
91
+ <Tabs.Trigger value="queue">In Queue</Tabs.Trigger>
92
+ <Tabs.Trigger value="completed">Completed</Tabs.Trigger>
93
+ <Tabs.Indicator />
94
+ </Tabs.List>
95
+ </Box>
96
+
97
+ {/* In Queue tab */}
98
+ <Tabs.Content value="queue" className={styles.tabsContent}>
99
+ {localQueue.length === 0 ? (
100
+ <Center className={styles.emptyState}>No scenarios in queue</Center>
101
+ ) : (
102
+ <DragDropProvider onDragEnd={handleDragEnd}>
103
+ <Box className={styles.scrollArea}>
104
+ {localQueue.map((scenario, index) => (
105
+ <ScenarioCardDraggable
106
+ key={scenario.id}
107
+ scenario={scenario}
108
+ index={index}
109
+ position={index + 1}
110
+ isActive={index === 0}
111
+ isRepeat={scenario.wasRequeued ?? false}
112
+ />
113
+ ))}
114
+ </Box>
115
+ </DragDropProvider>
116
+ )}
117
+ </Tabs.Content>
118
+
119
+ {/* Completed tab */}
120
+ <Tabs.Content value="completed" className={styles.tabsContent}>
121
+ {completed.length === 0 ? (
122
+ <Center className={styles.emptyState}>No completed scenarios</Center>
123
+ ) : (
124
+ <Box className={styles.scrollArea}>
125
+ {completed.map((scenario, index) => (
126
+ <ScenarioCard
127
+ key={scenario.id}
128
+ scenario={scenario}
129
+ position={index + 1}
130
+ isActive={false}
131
+ showRequeueSwitch
132
+ onRequeue={onRequeue}
133
+ />
134
+ ))}
135
+ </Box>
136
+ )}
137
+ </Tabs.Content>
138
+ </Tabs.Root>
139
+
140
+ {/* Pinned "Add Scenario" button — always visible, never scrolls */}
141
+ <Box className={styles.addButtonArea}>
142
+ <Button
143
+ variant="outline"
144
+ colorPalette="primary"
145
+ size="md"
146
+ className={styles.addButton}
147
+ onClick={() => setDialogOpen(true)}
148
+ >
149
+ + Add Scenario
150
+ </Button>
151
+ </Box>
152
+
153
+ <AddScenarioDialog
154
+ open={dialogOpen}
155
+ onClose={() => setDialogOpen(false)}
156
+ renderContent={renderAddScenarioContent}
157
+ onBrowseMore={onBrowseMore}
158
+ onBuildCustom={onBuildCustom}
159
+ />
160
+ </Box>
161
+ )
162
+ }
@@ -0,0 +1,11 @@
1
+ export { ScenarioQueue } from './ScenarioQueue'
2
+ export { ScenarioCard } from './ScenarioCard'
3
+ export { AddScenarioDialog } from './AddScenarioDialog'
4
+ export type {
5
+ ScenarioQueueProps,
6
+ ScenarioCardProps,
7
+ Scenario,
8
+ Difficulty,
9
+ ScenarioStatus,
10
+ } from './types'
11
+ export { difficultyColorMap, difficultyLabel } from './types'
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ // ──────────────────────────────────────────────
4
+ // Data Types
5
+ // ──────────────────────────────────────────────
6
+
7
+ export type Difficulty = 'beginner' | 'intermediate' | 'advanced'
8
+
9
+ export type ScenarioStatus = 'queued' | 'completed'
10
+
11
+ export interface Scenario {
12
+ id: string
13
+ title: string
14
+ category: string
15
+ difficulty: Difficulty
16
+ /** Display string, e.g. "10-15 min" */
17
+ duration: string
18
+ status: ScenarioStatus
19
+ /** True when this card was previously completed and re-queued by the user */
20
+ wasRequeued?: boolean
21
+ }
22
+
23
+ // ──────────────────────────────────────────────
24
+ // Difficulty → Badge colorPalette mapping
25
+ // ──────────────────────────────────────────────
26
+
27
+ export const difficultyColorMap: Record<Difficulty, 'primary' | 'secondary' | 'tertiary'> = {
28
+ beginner: 'primary',
29
+ intermediate: 'secondary',
30
+ advanced: 'tertiary',
31
+ }
32
+
33
+ export const difficultyLabel: Record<Difficulty, string> = {
34
+ beginner: 'Beginner',
35
+ intermediate: 'Intermediate',
36
+ advanced: 'Advanced',
37
+ }
38
+
39
+ // ──────────────────────────────────────────────
40
+ // Component Props
41
+ // ──────────────────────────────────────────────
42
+
43
+ export interface ScenarioQueueProps {
44
+ /** All scenarios — the component splits them by status internally */
45
+ scenarios: Scenario[]
46
+
47
+ /** Called when user reorders cards in the "In Queue" tab */
48
+ onReorder?: (reorderedIds: string[]) => void
49
+
50
+ /** Called when user toggles a completed scenario back to the queue */
51
+ onRequeue?: (scenarioId: string) => void
52
+
53
+ /** Called when user removes a scenario from the queue */
54
+ onRemove?: (scenarioId: string) => void
55
+
56
+ /**
57
+ * Render prop / slot for the Add Scenario modal body.
58
+ * The design system provides the Dialog shell; the consumer (discourser.ai)
59
+ * provides the collection content.
60
+ *
61
+ * Receives `onClose` to allow the consumer to close the modal after selection.
62
+ */
63
+ renderAddScenarioContent?: (props: { onClose: () => void }) => ReactNode
64
+
65
+ /** Called when user clicks "Browse More Scenarios" link inside the modal */
66
+ onBrowseMore?: () => void
67
+
68
+ /** Called when user clicks "Build Custom Scenario" link inside the modal */
69
+ onBuildCustom?: () => void
70
+ }
71
+
72
+ export interface ScenarioCardProps {
73
+ scenario: Scenario
74
+ /** 1-based position number shown in the circle badge */
75
+ position: number
76
+ /** Whether this card is the active (first) scenario */
77
+ isActive: boolean
78
+ /** Show the re-queue switch (used in Completed tab) */
79
+ showRequeueSwitch?: boolean
80
+ /** Called when re-queue switch is toggled */
81
+ onRequeue?: (scenarioId: string) => void
82
+ /** Whether this is a repeat scenario */
83
+ isRepeat?: boolean
84
+ /** Visual state while the card is being dragged (applies opacity) */
85
+ isDragging?: boolean
86
+ }
@@ -44,8 +44,27 @@ export * as Tooltip from './Tooltip';
44
44
  // Utility Components (namespace pattern - may have multiple exports)
45
45
  export * as CloseButton from './CloseButton';
46
46
  export * as Icon from './Icon';
47
+
48
+ // Icons (individual icon components)
49
+ export { GripDotsVerticalIcon, type GripDotsVerticalIconProps } from './Icons/GripDotsVerticalIcon';
50
+ export { ClockIcon, type ClockIconProps } from './Icons/ClockIcon';
47
51
  export * as AbsoluteCenter from './AbsoluteCenter';
48
52
  export * as Group from './Group';
49
53
 
50
54
  // Navigation & Progress Components
55
+ export * as Breadcrumb from './Breadcrumb';
51
56
  export { Stepper, type StepperRootProps, type StepItem } from './Stepper';
57
+
58
+ // Composite / Feature Components
59
+ export {
60
+ ScenarioQueue,
61
+ ScenarioCard,
62
+ AddScenarioDialog,
63
+ difficultyColorMap,
64
+ difficultyLabel,
65
+ type ScenarioQueueProps,
66
+ type ScenarioCardProps,
67
+ type Scenario,
68
+ type Difficulty,
69
+ type ScenarioStatus,
70
+ } from './ScenarioQueue';