@happyvertical/smrt-svelte 0.40.65 → 0.40.67

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.
@@ -0,0 +1,498 @@
1
+ import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
2
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+ import { fireEvent, render, screen, within } from '@testing-library/svelte';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { createRawSnippet, hydrate, unmount } from 'svelte';
6
+ import { createServer } from 'vite';
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import Board from '../Board.svelte';
9
+ import BoardSsrHarness from './BoardSsrHarness.svelte';
10
+ const columns = [
11
+ { id: 'new', label: 'New' },
12
+ { id: 'assigned', label: 'Assigned' },
13
+ ];
14
+ const initialCards = [
15
+ { id: 'a', subject: 'Password reset', queue: 'new' },
16
+ { id: 'b', subject: 'Billing question', queue: 'new' },
17
+ { id: 'c', subject: 'Reply needed', queue: 'assigned' },
18
+ ];
19
+ function cardSnippet() {
20
+ return createRawSnippet((context) => ({
21
+ render: () => `<strong>${context().card.subject}</strong>`,
22
+ }));
23
+ }
24
+ function props(overrides = {}) {
25
+ return {
26
+ columns,
27
+ defaultCards: initialCards,
28
+ getCardColumnId: (card) => card.queue,
29
+ setCardColumnId: (card, queue) => ({ ...card, queue }),
30
+ getCardLabel: (card) => card.subject,
31
+ card: cardSnippet(),
32
+ label: 'Support queues',
33
+ ...overrides,
34
+ };
35
+ }
36
+ function lane(name) {
37
+ return screen.getByRole('region', { name: new RegExp(name) });
38
+ }
39
+ describe('Board', () => {
40
+ it('moves generic support cards in uncontrolled mode with a typed keyboard intent', async () => {
41
+ const user = userEvent.setup();
42
+ const onmove = vi.fn();
43
+ const onselect = vi.fn();
44
+ render((Board), {
45
+ props: props({ onmove, onselect }),
46
+ });
47
+ const card = screen.getByRole('button', { name: 'Password reset' });
48
+ card.focus();
49
+ await user.keyboard(' ');
50
+ await user.keyboard('{ArrowRight}');
51
+ await user.keyboard(' ');
52
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({
53
+ card: initialCards[0],
54
+ source: { columnId: 'new', index: 0 },
55
+ target: { columnId: 'assigned', index: 0 },
56
+ }));
57
+ expect(within(lane('Assigned')).getByText('Password reset')).toBeInTheDocument();
58
+ expect(screen.getByText('Moved Password reset to Assigned, position 1 of 2.')).toBeInTheDocument();
59
+ await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Password reset' })).toHaveFocus());
60
+ expect(onselect).not.toHaveBeenCalled();
61
+ });
62
+ it('keeps controlled cards authoritative unless optimistic presentation is requested', async () => {
63
+ const user = userEvent.setup();
64
+ const onmove = vi.fn();
65
+ render((Board), {
66
+ props: props({ cards: initialCards, onmove }),
67
+ });
68
+ const card = screen.getByRole('button', { name: 'Password reset' });
69
+ card.focus();
70
+ await user.keyboard(' ');
71
+ await user.keyboard('{ArrowRight}');
72
+ await user.keyboard('{Enter}');
73
+ expect(onmove).toHaveBeenCalledOnce();
74
+ expect(within(lane('New')).getByText('Password reset')).toBeInTheDocument();
75
+ expect(within(lane('Assigned')).queryByText('Password reset')).not.toBeInTheDocument();
76
+ });
77
+ it('treats a controlled board without onmove as read-only', async () => {
78
+ const user = userEvent.setup();
79
+ const onselect = vi.fn();
80
+ render((Board), {
81
+ props: props({ cards: initialCards, onselect }),
82
+ });
83
+ const card = screen.getByRole('button', { name: 'Password reset' });
84
+ expect(card).toHaveAttribute('draggable', 'false');
85
+ expect(card).not.toHaveAttribute('aria-disabled');
86
+ expect(card).not.toHaveClass('smrt-board__card--touch-drag');
87
+ card.focus();
88
+ await user.keyboard(' ');
89
+ expect(screen.queryByText(/Picked up Password reset/)).not.toBeInTheDocument();
90
+ expect(onselect).toHaveBeenCalledWith(initialCards[0]);
91
+ });
92
+ it('updates precomputed lane cards when controlled cards change', async () => {
93
+ const onmove = vi.fn();
94
+ const { rerender } = render((Board), {
95
+ props: props({ cards: initialCards, onmove }),
96
+ });
97
+ expect(lane('New')).toHaveAccessibleName('New, 2 cards');
98
+ const updatedCards = [
99
+ { ...initialCards[0], queue: 'assigned' },
100
+ ...initialCards.slice(1),
101
+ ];
102
+ await rerender(props({ cards: updatedCards, onmove }));
103
+ expect(lane('New')).toHaveAccessibleName('New, 1 cards');
104
+ expect(lane('Assigned')).toHaveAccessibleName('Assigned, 2 cards');
105
+ expect(within(lane('Assigned')).getByText('Password reset')).toBeInTheDocument();
106
+ });
107
+ it('can disable same-column reordering without suppressing cross-column moves', async () => {
108
+ const user = userEvent.setup();
109
+ const onmove = vi.fn();
110
+ render((Board), {
111
+ props: props({ allowSameColumnReorder: false, onmove }),
112
+ });
113
+ const card = screen.getByRole('button', { name: 'Password reset' });
114
+ card.focus();
115
+ await user.keyboard(' ');
116
+ await user.keyboard('{ArrowDown}');
117
+ await user.keyboard('{Enter}');
118
+ expect(onmove).not.toHaveBeenCalled();
119
+ expect(screen.queryByText(/Moved Password reset/)).not.toBeInTheDocument();
120
+ expect(within(lane('New')).getAllByRole('button')[0]).toHaveAccessibleName('Password reset');
121
+ card.focus();
122
+ await user.keyboard(' ');
123
+ await user.keyboard('{ArrowRight}');
124
+ await user.keyboard('{Enter}');
125
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({ target: { columnId: 'assigned', index: 0 } }));
126
+ });
127
+ it('does not emit a pointer same-column move when reordering is disabled', () => {
128
+ const onmove = vi.fn();
129
+ render((Board), {
130
+ props: props({ allowSameColumnReorder: false, onmove }),
131
+ });
132
+ const card = screen.getByRole('button', { name: 'Password reset' });
133
+ const sibling = screen.getByRole('button', { name: 'Billing question' });
134
+ fireEvent.dragStart(card, { dataTransfer: { setData: vi.fn() } });
135
+ fireEvent.drop(sibling, { clientY: 1 });
136
+ expect(onmove).not.toHaveBeenCalled();
137
+ expect(screen.queryByText(/Moved Password reset/)).not.toBeInTheDocument();
138
+ });
139
+ it('uses native pointer drag and drop to emit a move', () => {
140
+ const onmove = vi.fn();
141
+ render((Board), { props: props({ onmove }) });
142
+ const card = screen.getByRole('button', { name: 'Password reset' });
143
+ const destination = screen.getByRole('button', { name: 'Reply needed' });
144
+ fireEvent.dragStart(card, { dataTransfer: { setData: vi.fn() } });
145
+ fireEvent.drop(destination, { clientY: 1 });
146
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({
147
+ source: { columnId: 'new', index: 0 },
148
+ target: { columnId: 'assigned', index: 1 },
149
+ }));
150
+ });
151
+ it('moves a card with touch Pointer Events after a drag threshold', async () => {
152
+ const onmove = vi.fn();
153
+ render((Board), { props: props({ onmove }) });
154
+ const card = screen.getByRole('button', { name: 'Password reset' });
155
+ const destination = screen.getByRole('button', { name: 'Reply needed' });
156
+ Object.defineProperty(destination, 'getBoundingClientRect', {
157
+ value: () => ({ top: 10, height: 20 }),
158
+ });
159
+ const originalElementFromPoint = document.elementFromPoint;
160
+ Object.defineProperty(document, 'elementFromPoint', {
161
+ configurable: true,
162
+ value: vi.fn(() => destination),
163
+ });
164
+ fireEvent.pointerDown(card, {
165
+ button: 0,
166
+ clientX: 0,
167
+ clientY: 0,
168
+ pointerId: 7,
169
+ pointerType: 'touch',
170
+ });
171
+ fireEvent.pointerMove(card, {
172
+ clientX: 10,
173
+ clientY: 10,
174
+ pointerId: 7,
175
+ pointerType: 'touch',
176
+ });
177
+ fireEvent.pointerUp(card, {
178
+ clientX: 10,
179
+ clientY: 10,
180
+ pointerId: 7,
181
+ pointerType: 'touch',
182
+ });
183
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({
184
+ source: { columnId: 'new', index: 0 },
185
+ target: { columnId: 'assigned', index: 0 },
186
+ }));
187
+ Object.defineProperty(document, 'elementFromPoint', {
188
+ configurable: true,
189
+ value: originalElementFromPoint,
190
+ });
191
+ });
192
+ it('moves a card with mouse Pointer Events after a drag threshold', () => {
193
+ const onmove = vi.fn();
194
+ render((Board), { props: props({ onmove }) });
195
+ const card = screen.getByRole('button', { name: 'Password reset' });
196
+ const destination = screen.getByRole('button', { name: 'Reply needed' });
197
+ Object.defineProperty(destination, 'getBoundingClientRect', {
198
+ value: () => ({ top: 10, height: 20 }),
199
+ });
200
+ const originalElementFromPoint = document.elementFromPoint;
201
+ Object.defineProperty(document, 'elementFromPoint', {
202
+ configurable: true,
203
+ value: vi.fn(() => destination),
204
+ });
205
+ fireEvent.pointerDown(card, {
206
+ button: 0,
207
+ clientX: 0,
208
+ clientY: 0,
209
+ pointerId: 9,
210
+ pointerType: 'mouse',
211
+ });
212
+ fireEvent.pointerMove(card, {
213
+ clientX: 10,
214
+ clientY: 10,
215
+ pointerId: 9,
216
+ pointerType: 'mouse',
217
+ });
218
+ fireEvent.pointerUp(card, {
219
+ clientX: 10,
220
+ clientY: 10,
221
+ pointerId: 9,
222
+ pointerType: 'mouse',
223
+ });
224
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({ target: { columnId: 'assigned', index: 0 } }));
225
+ Object.defineProperty(document, 'elementFromPoint', {
226
+ configurable: true,
227
+ value: originalElementFromPoint,
228
+ });
229
+ });
230
+ it('cancels a mouse Pointer Event drag and restores card focus', async () => {
231
+ render((Board), { props: props() });
232
+ const card = screen.getByRole('button', { name: 'Password reset' });
233
+ fireEvent.pointerDown(card, {
234
+ button: 0,
235
+ clientX: 0,
236
+ clientY: 0,
237
+ pointerId: 8,
238
+ pointerType: 'mouse',
239
+ });
240
+ fireEvent.pointerMove(card, {
241
+ clientX: 8,
242
+ clientY: 0,
243
+ pointerId: 8,
244
+ pointerType: 'mouse',
245
+ });
246
+ fireEvent.pointerCancel(card, { pointerId: 8, pointerType: 'mouse' });
247
+ expect(screen.getByText('Cancelled moving Password reset.')).toBeInTheDocument();
248
+ await vi.waitFor(() => expect(card).toHaveFocus());
249
+ });
250
+ it('cancels a pointer drag that is hit-tested over another Board with shared column ids', async () => {
251
+ const firstMove = vi.fn();
252
+ const secondMove = vi.fn();
253
+ render((Board), {
254
+ props: props({ onmove: firstMove }),
255
+ });
256
+ render((Board), {
257
+ props: props({ onmove: secondMove }),
258
+ });
259
+ const boards = screen.getAllByRole('region', { name: 'Support queues' });
260
+ const firstCard = within(boards[0]).getByRole('button', {
261
+ name: 'Password reset',
262
+ });
263
+ const secondDestination = within(boards[1]).getByRole('button', {
264
+ name: 'Reply needed',
265
+ });
266
+ const originalElementFromPoint = document.elementFromPoint;
267
+ Object.defineProperty(document, 'elementFromPoint', {
268
+ configurable: true,
269
+ value: vi.fn(() => secondDestination),
270
+ });
271
+ fireEvent.pointerDown(firstCard, {
272
+ button: 0,
273
+ clientX: 0,
274
+ clientY: 0,
275
+ pointerId: 10,
276
+ pointerType: 'mouse',
277
+ });
278
+ fireEvent.pointerMove(firstCard, {
279
+ clientX: 10,
280
+ clientY: 0,
281
+ pointerId: 10,
282
+ pointerType: 'mouse',
283
+ });
284
+ fireEvent.pointerUp(firstCard, {
285
+ clientX: 10,
286
+ clientY: 0,
287
+ pointerId: 10,
288
+ pointerType: 'mouse',
289
+ });
290
+ expect(firstMove).not.toHaveBeenCalled();
291
+ expect(secondMove).not.toHaveBeenCalled();
292
+ expect(within(boards[0]).getByText('Cancelled moving Password reset.')).toBeInTheDocument();
293
+ await vi.waitFor(() => expect(firstCard).toHaveFocus());
294
+ Object.defineProperty(document, 'elementFromPoint', {
295
+ configurable: true,
296
+ value: originalElementFromPoint,
297
+ });
298
+ });
299
+ it('rejects a pointer drop into a disabled lane, announces it, and restores focus', async () => {
300
+ const onmove = vi.fn();
301
+ render((Board), {
302
+ props: props({
303
+ columns: [columns[0], { ...columns[1], disabled: true }],
304
+ onmove,
305
+ }),
306
+ });
307
+ const card = screen.getByRole('button', { name: 'Password reset' });
308
+ const destination = screen.getByRole('button', { name: 'Reply needed' });
309
+ fireEvent.dragStart(card, { dataTransfer: { setData: vi.fn() } });
310
+ fireEvent.drop(destination, { clientY: 1 });
311
+ expect(onmove).not.toHaveBeenCalled();
312
+ expect(screen.getByText('Assigned is unavailable.')).toBeInTheDocument();
313
+ await vi.waitFor(() => expect(card).toHaveFocus());
314
+ });
315
+ it('restores an optimistic controlled move when persistence rejects', async () => {
316
+ const user = userEvent.setup();
317
+ const onmove = vi.fn().mockRejectedValue(new Error('offline'));
318
+ render((Board), {
319
+ props: props({ cards: initialCards, optimistic: true, onmove }),
320
+ });
321
+ const card = screen.getByRole('button', { name: 'Password reset' });
322
+ card.focus();
323
+ await user.keyboard(' ');
324
+ await user.keyboard('{ArrowRight}');
325
+ await user.keyboard('{Enter}');
326
+ await vi.waitFor(() => expect(screen.getByText('Could not move Password reset. The board was restored.')).toBeInTheDocument());
327
+ expect(within(lane('New')).getByText('Password reset')).toBeInTheDocument();
328
+ expect(within(lane('Assigned')).queryByText('Password reset')).not.toBeInTheDocument();
329
+ await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Password reset' })).toHaveFocus());
330
+ });
331
+ it('announces pickup, supports cancellation, collapse controls, and is axe-clean', async () => {
332
+ const user = userEvent.setup();
333
+ const { container } = render((Board), {
334
+ props: props({ collapsible: true }),
335
+ });
336
+ const card = screen.getByRole('button', { name: 'Password reset' });
337
+ card.focus();
338
+ await user.keyboard(' ');
339
+ expect(screen.getByText(/Picked up Password reset\. Available destinations: New, Assigned\./)).toBeInTheDocument();
340
+ await user.keyboard('{Escape}');
341
+ expect(screen.getByText('Cancelled moving Password reset.')).toBeInTheDocument();
342
+ const collapse = screen.getByRole('button', { name: 'Collapse New' });
343
+ await user.click(collapse);
344
+ expect(collapse).toHaveAttribute('aria-expanded', 'false');
345
+ expect(screen.getByRole('button', { name: 'Expand New' })).toBeInTheDocument();
346
+ await expectNoA11yViolations(container);
347
+ });
348
+ it('expands a collapsed keyboard destination before committing so the moved card remains visible', async () => {
349
+ const user = userEvent.setup();
350
+ render((Board), {
351
+ props: props({ collapsible: true }),
352
+ });
353
+ await user.click(screen.getByRole('button', { name: 'Collapse Assigned' }));
354
+ const card = screen.getByRole('button', { name: 'Password reset' });
355
+ card.focus();
356
+ await user.keyboard(' ');
357
+ await user.keyboard('{ArrowRight}');
358
+ await user.keyboard('{Enter}');
359
+ expect(screen.getByRole('button', { name: 'Collapse Assigned' })).toHaveAttribute('aria-expanded', 'true');
360
+ expect(within(lane('Assigned')).getByText('Password reset')).toBeInTheDocument();
361
+ await vi.waitFor(() => expect(within(lane('Assigned')).getByRole('button', {
362
+ name: 'Password reset',
363
+ })).toHaveFocus());
364
+ });
365
+ it('allows ordinary selection after a cancelled keyboard pickup', async () => {
366
+ const user = userEvent.setup();
367
+ const onselect = vi.fn();
368
+ render((Board), { props: props({ onselect }) });
369
+ const card = screen.getByRole('button', { name: 'Password reset' });
370
+ card.focus();
371
+ await user.keyboard(' ');
372
+ await user.keyboard('{Escape}');
373
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
374
+ await user.click(card);
375
+ expect(onselect).toHaveBeenCalledWith(initialCards[0]);
376
+ });
377
+ it('skips disabled lanes and announces the unavailable destination', async () => {
378
+ const user = userEvent.setup();
379
+ render((Board), {
380
+ props: props({
381
+ columns: [columns[0], { ...columns[1], disabled: true }],
382
+ }),
383
+ });
384
+ const card = screen.getByRole('button', { name: 'Password reset' });
385
+ card.focus();
386
+ await user.keyboard(' ');
387
+ await user.keyboard('{ArrowRight}');
388
+ expect(screen.getByText('Assigned is unavailable.')).toBeInTheDocument();
389
+ await user.keyboard('{Escape}');
390
+ });
391
+ it('allows a card to leave a disabled source lane', async () => {
392
+ const user = userEvent.setup();
393
+ const disabledSourceCards = [{ ...initialCards[0], queue: 'assigned' }];
394
+ const onmove = vi.fn();
395
+ render((Board), {
396
+ props: props({
397
+ columns: [{ ...columns[0] }, { ...columns[1], disabled: true }],
398
+ defaultCards: disabledSourceCards,
399
+ onmove,
400
+ }),
401
+ });
402
+ const card = screen.getByRole('button', { name: 'Password reset' });
403
+ expect(card).toHaveAttribute('draggable', 'true');
404
+ card.focus();
405
+ await user.keyboard(' ');
406
+ await user.keyboard('{ArrowLeft}');
407
+ await user.keyboard('{Enter}');
408
+ expect(onmove).toHaveBeenCalledWith(expect.objectContaining({ target: { columnId: 'new', index: 0 } }));
409
+ });
410
+ it('serializes deferred move persistence and permits the next move only after the first settles', async () => {
411
+ const user = userEvent.setup();
412
+ let resolveFirst;
413
+ let resolveSecond;
414
+ const onmove = vi
415
+ .fn()
416
+ .mockImplementationOnce(() => new Promise((resolve) => {
417
+ resolveFirst = resolve;
418
+ }))
419
+ .mockImplementationOnce(() => new Promise((resolve) => {
420
+ resolveSecond = resolve;
421
+ }));
422
+ render((Board), { props: props({ onmove }) });
423
+ const first = screen.getByRole('button', { name: 'Password reset' });
424
+ first.focus();
425
+ await user.keyboard(' ');
426
+ await user.keyboard('{ArrowRight}');
427
+ await user.keyboard('{Enter}');
428
+ await vi.waitFor(() => expect(onmove).toHaveBeenCalledTimes(1));
429
+ const second = screen.getByRole('button', { name: 'Billing question' });
430
+ second.focus();
431
+ await user.keyboard(' ');
432
+ await user.keyboard('{ArrowRight}');
433
+ await user.keyboard('{Enter}');
434
+ expect(onmove).toHaveBeenCalledTimes(1);
435
+ resolveFirst();
436
+ await vi.waitFor(() => expect(within(lane('Assigned')).getByText('Password reset')).toBeInTheDocument());
437
+ second.focus();
438
+ await user.keyboard(' ');
439
+ await user.keyboard('{ArrowRight}');
440
+ await user.keyboard('{Enter}');
441
+ await vi.waitFor(() => expect(onmove).toHaveBeenCalledTimes(2));
442
+ resolveSecond();
443
+ await vi.waitFor(() => expect(within(lane('Assigned')).getByText('Billing question')).toBeInTheDocument());
444
+ });
445
+ it('renders stable board structure on the server without domain imports', async () => {
446
+ const vite = await createServer({
447
+ appType: 'custom',
448
+ configFile: false,
449
+ plugins: [svelte()],
450
+ root: process.cwd(),
451
+ server: { middlewareMode: true },
452
+ });
453
+ const { default: SsrBoard } = await vite.ssrLoadModule('/src/components/board/__tests__/BoardSsrHarness.svelte');
454
+ const { render: renderSsr } = await vite.ssrLoadModule('svelte/server');
455
+ const result = renderSsr(SsrBoard);
456
+ expect(result.body).toContain('Sales pipeline');
457
+ expect(result.body).toContain('Acme');
458
+ expect(result.body).toContain('lane-0');
459
+ await vite.close();
460
+ const host = document.createElement('div');
461
+ host.innerHTML = result.body;
462
+ document.body.append(host);
463
+ const instance = hydrate(BoardSsrHarness, {
464
+ target: host,
465
+ });
466
+ expect(within(host).getByRole('region', { name: 'Lead, 1 cards' })).toBeInTheDocument();
467
+ expect(within(host).getByRole('button', { name: 'Acme' })).toBeInTheDocument();
468
+ await unmount(instance);
469
+ host.remove();
470
+ });
471
+ it('scopes live descriptions when board instances share card ids', async () => {
472
+ const user = userEvent.setup();
473
+ const { container } = render((Board), {
474
+ props: props(),
475
+ });
476
+ render((Board), { props: props() });
477
+ const boards = screen.getAllByRole('region', { name: 'Support queues' });
478
+ expect(boards).toHaveLength(2);
479
+ const firstCard = within(boards[0]).getByRole('button', {
480
+ name: 'Password reset',
481
+ });
482
+ const secondCard = within(boards[1]).getByRole('button', {
483
+ name: 'Password reset',
484
+ });
485
+ firstCard.focus();
486
+ await user.keyboard(' ');
487
+ const firstLiveId = firstCard.getAttribute('aria-describedby');
488
+ await user.keyboard('{Escape}');
489
+ secondCard.focus();
490
+ await user.keyboard(' ');
491
+ const secondLiveId = secondCard.getAttribute('aria-describedby');
492
+ expect(firstLiveId).toBeTruthy();
493
+ expect(secondLiveId).toBeTruthy();
494
+ expect(firstLiveId).not.toBe(secondLiveId);
495
+ expect(within(boards[0]).getByText('Cancelled moving Password reset.')).toHaveAttribute('id', firstLiveId);
496
+ expect(within(boards[1]).getByText(/Picked up Password reset/)).toHaveAttribute('id', secondLiveId);
497
+ });
498
+ });
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import Board from '../Board.svelte';
3
+ import type { BoardColumn } from '../types.js';
4
+
5
+ interface SalesCard {
6
+ id: string;
7
+ company: string;
8
+ stage: string;
9
+ }
10
+
11
+ const columns: BoardColumn[] = [
12
+ { id: 'lead', label: 'Lead' },
13
+ { id: 'won', label: 'Won' },
14
+ ];
15
+ const cards: SalesCard[] = [{ id: 'sale-1', company: 'Acme', stage: 'lead' }];
16
+ </script>
17
+
18
+ {#snippet salesCard({ card }: { card: SalesCard; column: BoardColumn; index: number; isDragging: boolean })}
19
+ <strong>{card.company}</strong>
20
+ {/snippet}
21
+
22
+ <Board
23
+ {columns}
24
+ defaultCards={cards}
25
+ getCardColumnId={(card) => card.stage}
26
+ setCardColumnId={(card, stage) => ({ ...card, stage })}
27
+ getCardLabel={(card) => card.company}
28
+ card={salesCard}
29
+ label="Sales pipeline"
30
+ />
@@ -0,0 +1,19 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const BoardSsrHarness: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type BoardSsrHarness = InstanceType<typeof BoardSsrHarness>;
18
+ export default BoardSsrHarness;
19
+ //# sourceMappingURL=BoardSsrHarness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BoardSsrHarness.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/board/__tests__/BoardSsrHarness.svelte.ts"],"names":[],"mappings":"AA+BA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,eAAe;;kBAA+E,CAAC;AACnF,KAAK,eAAe,GAAG,YAAY,CAAC,OAAO,eAAe,CAAC,CAAC;AAC9D,eAAe,eAAe,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { default as Board } from './Board.svelte';
2
+ export type { BoardCard, BoardCardSnippetProps, BoardColumn, BoardColumnHeaderSnippetProps, BoardMoveIntent, BoardPosition, BoardProps, } from './types.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/board/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EACV,SAAS,EACT,qBAAqB,EACrB,WAAW,EACX,6BAA6B,EAC7B,eAAe,EACf,aAAa,EACb,UAAU,GACX,MAAM,YAAY,CAAC"}
@@ -0,0 +1 @@
1
+ export { default as Board } from './Board.svelte';
@@ -0,0 +1,84 @@
1
+ import type { Snippet } from 'svelte';
2
+ /** The minimum identity contract for a Board card. */
3
+ export interface BoardCard {
4
+ id: string;
5
+ }
6
+ /** A Board lane. Labels are deliberately presentation-only; ids drive moves. */
7
+ export interface BoardColumn {
8
+ id: string;
9
+ label: string;
10
+ /** Prevents cards from being moved into this lane. */
11
+ disabled?: boolean;
12
+ }
13
+ /** A card's position in a board. */
14
+ export interface BoardPosition {
15
+ columnId: string;
16
+ /** Zero-based insertion position. */
17
+ index: number;
18
+ }
19
+ /**
20
+ * The complete, domain-agnostic instruction emitted when a card is dropped.
21
+ *
22
+ * `target.index` is calculated after removing the card from `source`, making
23
+ * it directly usable with an immutable list update.
24
+ */
25
+ export interface BoardMoveIntent<Card extends BoardCard = BoardCard, Column extends BoardColumn = BoardColumn> {
26
+ card: Card;
27
+ source: BoardPosition;
28
+ target: BoardPosition;
29
+ sourceColumn: Column;
30
+ targetColumn: Column;
31
+ }
32
+ export interface BoardCardSnippetProps<Card extends BoardCard, Column extends BoardColumn> {
33
+ card: Card;
34
+ column: Column;
35
+ index: number;
36
+ isDragging: boolean;
37
+ }
38
+ export interface BoardColumnHeaderSnippetProps<Column extends BoardColumn> {
39
+ column: Column;
40
+ count: number;
41
+ collapsed: boolean;
42
+ }
43
+ /** Public props for the generic Svelte 5 Board component. */
44
+ export interface BoardProps<Card extends BoardCard, Column extends BoardColumn> {
45
+ /** Ordered lanes. The Board does not impose statuses or workflow names. */
46
+ columns: readonly Column[];
47
+ /** Authoritative controlled cards. Omit to use `defaultCards`. */
48
+ cards?: readonly Card[];
49
+ /** Initial cards for an uncontrolled Board. */
50
+ defaultCards?: readonly Card[];
51
+ /** Resolves the lane containing a card. */
52
+ getCardColumnId: (card: Card) => string;
53
+ /** Returns a copy of a card assigned to `columnId`; keeps Board domain-free. */
54
+ setCardColumnId: (card: Card, columnId: string) => Card;
55
+ /** Accessible text announced while a card is moved. */
56
+ getCardLabel: (card: Card) => string;
57
+ /** Required visual content for each card. */
58
+ card: Snippet<[BoardCardSnippetProps<Card, Column>]>;
59
+ /** Optional lane-header content, rendered beside the built-in count. */
60
+ columnHeader?: Snippet<[BoardColumnHeaderSnippetProps<Column>]>;
61
+ /** Accessible name for the board. */
62
+ label?: string;
63
+ /** Makes lane headers collapse their card lists. */
64
+ collapsible?: boolean;
65
+ /**
66
+ * Whether cards may be repositioned within their current lane. Defaults to
67
+ * `true`; set `false` for adapters whose persistence layer only supports
68
+ * column/status transitions.
69
+ */
70
+ allowSameColumnReorder?: boolean;
71
+ /**
72
+ * In controlled mode, retain the locally reordered presentation until the
73
+ * owner supplies a new `cards` array. The owner remains authoritative.
74
+ */
75
+ optimistic?: boolean;
76
+ /** Called when a card is activated without starting a move. */
77
+ onselect?: (card: Card) => void;
78
+ /**
79
+ * Receives a typed move intent. It may persist asynchronously; a rejection
80
+ * restores the existing presentation and is announced to assistive tech.
81
+ */
82
+ onmove?: (intent: BoardMoveIntent<Card, Column>) => void | Promise<void>;
83
+ }
84
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/board/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,sDAAsD;AACtD,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,oCAAoC;AACpC,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe,CAC9B,IAAI,SAAS,SAAS,GAAG,SAAS,EAClC,MAAM,SAAS,WAAW,GAAG,WAAW;IAExC,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB,CACpC,IAAI,SAAS,SAAS,EACtB,MAAM,SAAS,WAAW;IAE1B,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,6BAA6B,CAAC,MAAM,SAAS,WAAW;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,6DAA6D;AAC7D,MAAM,WAAW,UAAU,CACzB,IAAI,SAAS,SAAS,EACtB,MAAM,SAAS,WAAW;IAE1B,2EAA2E;IAC3E,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,kEAAkE;IAClE,KAAK,CAAC,EAAE,SAAS,IAAI,EAAE,CAAC;IACxB,+CAA+C;IAC/C,YAAY,CAAC,EAAE,SAAS,IAAI,EAAE,CAAC;IAC/B,2CAA2C;IAC3C,eAAe,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IACxC,gFAAgF;IAChF,eAAe,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD,uDAAuD;IACvD,YAAY,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IACrC,6CAA6C;IAC7C,IAAI,EAAE,OAAO,CAAC,CAAC,qBAAqB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACrD,wEAAwE;IACxE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC,6BAA6B,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAChE,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IAChC;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E"}
@@ -0,0 +1 @@
1
+ export {};
@@ -47,4 +47,11 @@ describe('buildI18nSnapshot', () => {
47
47
  expect(snapshot.messages['ui.snapshot_test.a']).toBe('Alpha');
48
48
  expect(snapshot.messages['ui.snapshot_test.b']).toBeUndefined();
49
49
  });
50
+ it('includes Board defaults when only the server snapshot entrypoint is imported', async () => {
51
+ const snapshot = await buildI18nSnapshot({
52
+ locale: 'en',
53
+ keys: ['ui.board.move_failed'],
54
+ });
55
+ expect(snapshot.messages['ui.board.move_failed']).toBe('Could not move {card}. The board was restored.');
56
+ });
50
57
  });