astryx-dracula 0.1.3 → 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.
Files changed (94) hide show
  1. package/BRAND.md +1 -1
  2. package/README.md +2 -0
  3. package/USAGE.md +13 -0
  4. package/astryx-dracula.js +4 -4
  5. package/astryx.integration.mjs +3 -0
  6. package/package.json +4 -2
  7. package/templates/ai-chat-landing.template.mjs +6 -0
  8. package/templates/ai-chat-landing.tsx +476 -0
  9. package/templates/ai-chat.template.mjs +6 -0
  10. package/templates/ai-chat.tsx +762 -0
  11. package/templates/blank.template.mjs +6 -0
  12. package/templates/blank.tsx +16 -0
  13. package/templates/centered-hero.template.mjs +6 -0
  14. package/templates/centered-hero.tsx +188 -0
  15. package/templates/classic-gallery.template.mjs +6 -0
  16. package/templates/classic-gallery.tsx +181 -0
  17. package/templates/contact-form.template.mjs +6 -0
  18. package/templates/contact-form.tsx +292 -0
  19. package/templates/dashboard-portfolio.template.mjs +6 -0
  20. package/templates/dashboard-portfolio.tsx +800 -0
  21. package/templates/dashboard.template.mjs +6 -0
  22. package/templates/dashboard.tsx +774 -0
  23. package/templates/detail-page.template.mjs +6 -0
  24. package/templates/detail-page.tsx +695 -0
  25. package/templates/documentation-design.template.mjs +6 -0
  26. package/templates/documentation-design.tsx +751 -0
  27. package/templates/documentation-technical.template.mjs +6 -0
  28. package/templates/documentation-technical.tsx +292 -0
  29. package/templates/documentation.template.mjs +6 -0
  30. package/templates/documentation.tsx +347 -0
  31. package/templates/editor.template.mjs +6 -0
  32. package/templates/editor.tsx +959 -0
  33. package/templates/file-explorer.template.mjs +6 -0
  34. package/templates/file-explorer.tsx +577 -0
  35. package/templates/form-two-column.template.mjs +6 -0
  36. package/templates/form-two-column.tsx +314 -0
  37. package/templates/gallery-hero.template.mjs +6 -0
  38. package/templates/gallery-hero.tsx +287 -0
  39. package/templates/ide.template.mjs +6 -0
  40. package/templates/ide.tsx +533 -0
  41. package/templates/incident-console.template.mjs +6 -0
  42. package/templates/incident-console.tsx +584 -0
  43. package/templates/kanban-board.template.mjs +6 -0
  44. package/templates/kanban-board.tsx +746 -0
  45. package/templates/library.template.mjs +6 -0
  46. package/templates/library.tsx +489 -0
  47. package/templates/login-card.template.mjs +6 -0
  48. package/templates/login-card.tsx +200 -0
  49. package/templates/login-split.template.mjs +6 -0
  50. package/templates/login-split.tsx +318 -0
  51. package/templates/login-sso.template.mjs +6 -0
  52. package/templates/login-sso.tsx +277 -0
  53. package/templates/login.template.mjs +6 -0
  54. package/templates/login.tsx +98 -0
  55. package/templates/messaging-shell.template.mjs +6 -0
  56. package/templates/messaging-shell.tsx +675 -0
  57. package/templates/mixed-gallery.template.mjs +6 -0
  58. package/templates/mixed-gallery.tsx +190 -0
  59. package/templates/payment-form.template.mjs +6 -0
  60. package/templates/payment-form.tsx +990 -0
  61. package/templates/product-detail.template.mjs +6 -0
  62. package/templates/product-detail.tsx +366 -0
  63. package/templates/product-gallery.template.mjs +6 -0
  64. package/templates/product-gallery.tsx +173 -0
  65. package/templates/settings-dialog.template.mjs +6 -0
  66. package/templates/settings-dialog.tsx +920 -0
  67. package/templates/settings-sidebar.template.mjs +6 -0
  68. package/templates/settings-sidebar.tsx +952 -0
  69. package/templates/settings.template.mjs +6 -0
  70. package/templates/settings.tsx +235 -0
  71. package/templates/shell-nav.template.mjs +6 -0
  72. package/templates/shell-nav.tsx +306 -0
  73. package/templates/shell-side-nav.template.mjs +6 -0
  74. package/templates/shell-side-nav.tsx +238 -0
  75. package/templates/shell-top-nav.template.mjs +6 -0
  76. package/templates/shell-top-nav.tsx +243 -0
  77. package/templates/side-gallery.template.mjs +6 -0
  78. package/templates/side-gallery.tsx +179 -0
  79. package/templates/table-grouped.template.mjs +6 -0
  80. package/templates/table-grouped.tsx +1174 -0
  81. package/templates/table-page-chart.template.mjs +6 -0
  82. package/templates/table-page-chart.tsx +693 -0
  83. package/templates/table-page-heatmap-status.template.mjs +6 -0
  84. package/templates/table-page-heatmap-status.tsx +547 -0
  85. package/templates/table-page-shoe-store-heatmap.template.mjs +6 -0
  86. package/templates/table-page-shoe-store-heatmap.tsx +1038 -0
  87. package/templates/table-page.template.mjs +6 -0
  88. package/templates/table-page.tsx +445 -0
  89. package/templates/table.template.mjs +6 -0
  90. package/templates/table.tsx +84 -0
  91. package/templates/theme-showcase.template.mjs +6 -0
  92. package/templates/theme-showcase.tsx +1282 -0
  93. package/theme.css +4 -4
  94. package/tokens.css +4 -4
@@ -0,0 +1,746 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type PointerEvent as ReactPointerEvent,
10
+ type ReactNode,
11
+ } from 'react';
12
+
13
+ import {
14
+ Layout,
15
+ LayoutHeader,
16
+ LayoutContent,
17
+ HStack,
18
+ VStack,
19
+ } from '@astryxdesign/core/Layout';
20
+ import {Text, Heading} from '@astryxdesign/core/Text';
21
+ import {Card} from '@astryxdesign/core/Card';
22
+ import {Badge} from '@astryxdesign/core/Badge';
23
+ import {Button} from '@astryxdesign/core/Button';
24
+ import {IconButton} from '@astryxdesign/core/IconButton';
25
+ import {Icon} from '@astryxdesign/core/Icon';
26
+ import {StatusDot} from '@astryxdesign/core/StatusDot';
27
+ import {EmptyState} from '@astryxdesign/core/EmptyState';
28
+ import {MoreMenu} from '@astryxdesign/core/MoreMenu';
29
+ import {Selector} from '@astryxdesign/core/Selector';
30
+ import {Tooltip} from '@astryxdesign/core/Tooltip';
31
+ import {Divider} from '@astryxdesign/core/Divider';
32
+ import {Toolbar} from '@astryxdesign/core/Toolbar';
33
+ import {Section} from '@astryxdesign/core/Section';
34
+ import {Grid} from '@astryxdesign/core/Grid';
35
+
36
+ import {
37
+ Plus,
38
+ Search,
39
+ ArrowUpDown,
40
+ Filter,
41
+ RotateCw,
42
+ CircleCheck,
43
+ Inbox,
44
+ Info,
45
+ ClipboardCheck,
46
+ } from 'lucide-react';
47
+
48
+ // ============= TYPES =============
49
+
50
+ type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';
51
+ type Priority = 'high' | 'medium' | 'low';
52
+
53
+ interface WorkItem {
54
+ id: string;
55
+ column: ColumnId;
56
+ ref: string;
57
+ priority: Priority;
58
+ title: string;
59
+ description: string;
60
+ lastEdited: string;
61
+ dueDate: string;
62
+ }
63
+
64
+ interface ColumnMeta {
65
+ id: ColumnId;
66
+ title: string;
67
+ variant: 'neutral' | 'accent' | 'warning' | 'success';
68
+ tooltip: string;
69
+ emptyTitle: string;
70
+ emptyDescription: string;
71
+ emptyIcon: typeof Inbox;
72
+ }
73
+
74
+ // Where a dragged card will land: a column and an insertion index within it
75
+ // (measured against the cards remaining after the dragged card is removed).
76
+ interface DropTarget {
77
+ column: ColumnId;
78
+ index: number;
79
+ }
80
+
81
+ // Live state of an in-progress pointer drag. Coordinates are in viewport space.
82
+ interface DragState {
83
+ id: string;
84
+ width: number;
85
+ height: number;
86
+ offsetX: number;
87
+ offsetY: number;
88
+ pointerX: number;
89
+ pointerY: number;
90
+ target: DropTarget | null;
91
+ }
92
+
93
+ // ============= DATA =============
94
+
95
+ const COLUMNS: ColumnMeta[] = [
96
+ {
97
+ id: 'todo',
98
+ title: 'To-do',
99
+ variant: 'neutral',
100
+ tooltip: 'Palette tasks queued for this sprint, waiting to be picked up.',
101
+ emptyTitle: 'To-do is empty',
102
+ emptyDescription: 'Tasks pulled into this sprint appear here.',
103
+ emptyIcon: Inbox,
104
+ },
105
+ {
106
+ id: 'in-progress',
107
+ title: 'In progress',
108
+ variant: 'accent',
109
+ tooltip: 'Theme work currently on the easel.',
110
+ emptyTitle: 'Nothing in progress',
111
+ emptyDescription: 'Tasks being worked on appear here.',
112
+ emptyIcon: RotateCw,
113
+ },
114
+ {
115
+ id: 'in-review',
116
+ title: 'In review',
117
+ variant: 'warning',
118
+ tooltip: 'Shades waiting for your contrast review.',
119
+ emptyTitle: 'Nothing in review',
120
+ emptyDescription: 'Tasks awaiting your review appear here.',
121
+ emptyIcon: ClipboardCheck,
122
+ },
123
+ {
124
+ id: 'done',
125
+ title: 'Done',
126
+ variant: 'success',
127
+ tooltip: 'Shipped shades, sealed after dark.',
128
+ emptyTitle: 'Nothing done yet',
129
+ emptyDescription: 'Completed tasks appear here.',
130
+ emptyIcon: CircleCheck,
131
+ },
132
+ ];
133
+
134
+ const PRIORITY_META: Record<
135
+ Priority,
136
+ {label: string; variant: 'red' | 'yellow' | 'cyan'}
137
+ > = {
138
+ high: {label: 'High', variant: 'red'},
139
+ medium: {label: 'Medium', variant: 'yellow'},
140
+ low: {label: 'Low', variant: 'cyan'},
141
+ };
142
+
143
+ const INITIAL_ITEMS: WorkItem[] = [
144
+ {
145
+ id: 't1',
146
+ column: 'todo',
147
+ ref: 'Task 4821',
148
+ priority: 'low',
149
+ title: 'Draft the palette rollout brief',
150
+ description:
151
+ 'Write a short brief outlining goals, scope, and contrast criteria for the Dracula rollout.',
152
+ lastEdited: '2h ago',
153
+ dueDate: 'Jul 8',
154
+ },
155
+ {
156
+ id: 't2',
157
+ column: 'todo',
158
+ ref: 'Task 4842',
159
+ priority: 'low',
160
+ title: 'Collect feedback from night-shift reviewers',
161
+ description:
162
+ 'Gather input from key reviewers and summarize the main themes for the next review.',
163
+ lastEdited: '1d ago',
164
+ dueDate: 'Jul 11',
165
+ },
166
+ {
167
+ id: 'p1',
168
+ column: 'in-progress',
169
+ ref: 'Task 4825',
170
+ priority: 'high',
171
+ title: 'Design the midnight landing page',
172
+ description:
173
+ 'Create a first-pass layout in pure Dracula and share it for early feedback after dark.',
174
+ lastEdited: '18m ago',
175
+ dueDate: 'Jul 3',
176
+ },
177
+ {
178
+ id: 'p2',
179
+ column: 'in-progress',
180
+ ref: 'Task 4833',
181
+ priority: 'medium',
182
+ title: 'Set up the theme workspace',
183
+ description:
184
+ 'Configure the shared workspace and invite the coven so everyone has access.',
185
+ lastEdited: '5m ago',
186
+ dueDate: 'Jul 4',
187
+ },
188
+ {
189
+ id: 'v1',
190
+ column: 'in-review',
191
+ ref: 'Task 4831',
192
+ priority: 'medium',
193
+ title: 'Review the moonlight contrast pass',
194
+ description:
195
+ 'Check the new palette against text and border contrast gates before it ships.',
196
+ lastEdited: '2h ago',
197
+ dueDate: 'Jul 5',
198
+ },
199
+ {
200
+ id: 'r1',
201
+ column: 'done',
202
+ ref: 'Task 4788',
203
+ priority: 'low',
204
+ title: 'Publish the weekly changelog',
205
+ description:
206
+ 'Summarize progress, blockers, and next steps in a short update for the night shift.',
207
+ lastEdited: 'Yesterday',
208
+ dueDate: 'Jul 1',
209
+ },
210
+ {
211
+ id: 'r2',
212
+ column: 'done',
213
+ ref: 'Task 4789',
214
+ priority: 'high',
215
+ title: 'Prepare the midnight demo walkthrough',
216
+ description:
217
+ 'Put together a candlelit walkthrough covering the main haunts for the demo.',
218
+ lastEdited: '3d ago',
219
+ dueDate: 'Jun 30',
220
+ },
221
+ {
222
+ id: 'r3',
223
+ column: 'done',
224
+ ref: 'Task 4790',
225
+ priority: 'medium',
226
+ title: 'Review and merge open pull requests',
227
+ description:
228
+ 'Go through the pending bloodlines, leave comments, and merge the ones that are ready.',
229
+ lastEdited: '4d ago',
230
+ dueDate: 'Jun 28',
231
+ },
232
+ ];
233
+
234
+ // Pointer travel (px) before a press is promoted to a drag, so taps and clicks
235
+ // on card controls still register normally.
236
+ const DRAG_THRESHOLD = 5;
237
+
238
+ // ============= STYLES =============
239
+
240
+ // Responsive board grid: columns collapse to fewer tracks, then a single
241
+ // stack. The board keeps its own internal scroll as the only scroller;
242
+ // grid children get room to shrink so nothing forces page-level scroll.
243
+ const boardColumnsStyle: CSSProperties = {
244
+ overflowX: 'auto',
245
+ overflowY: 'auto',
246
+ height: '100%',
247
+ padding: 'var(--space-gap)',
248
+ };
249
+ const columnShellStyle: CSSProperties = {
250
+ minWidth: 0,
251
+ height: '100%',
252
+ };
253
+ // Cards stay static per the Dracula brand (no hover lift); the floating drag
254
+ // clone floats above the board with the high shadow token instead.
255
+ const cardStyle: CSSProperties = {
256
+ cursor: 'grab',
257
+ userSelect: 'none',
258
+ touchAction: 'none',
259
+ };
260
+ // The dragged card is lifted out of flow and follows the pointer. It ignores
261
+ // pointer events so hit-testing reads the columns underneath it.
262
+ const floatingStyle: CSSProperties = {
263
+ position: 'fixed',
264
+ insetBlockStart: 0,
265
+ insetInlineStart: 0,
266
+ pointerEvents: 'none',
267
+ cursor: 'grabbing',
268
+ zIndex: 1000,
269
+ };
270
+ // Placeholder marking the landing slot; matches the dragged card's height.
271
+ const ghostStyle = (height: number): CSSProperties => ({
272
+ height,
273
+ borderRadius: 'var(--radius-element)',
274
+ backgroundColor: 'var(--color-background-muted)',
275
+ });
276
+ const toolbarDividerStyle: CSSProperties = {
277
+ alignSelf: 'stretch',
278
+ };
279
+ const columnEmptyStateStyle: CSSProperties = {
280
+ paddingBlock: 'var(--space-gap)',
281
+ };
282
+
283
+ // ============= CARD BODY =============
284
+
285
+ // Shared card contents, rendered both in the column list and inside the
286
+ // floating drag clone so the two stay pixel-identical.
287
+ function BoardCardBody({
288
+ item,
289
+ onMove,
290
+ }: {
291
+ item: WorkItem;
292
+ onMove: (id: string, to: ColumnId) => void;
293
+ }) {
294
+ const priority = PRIORITY_META[item.priority];
295
+ const moveTargets = COLUMNS.filter(c => c.id !== item.column).map(c => ({
296
+ label: `Move to ${c.title}`,
297
+ onClick: () => onMove(item.id, c.id),
298
+ }));
299
+
300
+ return (
301
+ <VStack gap={2}>
302
+ <HStack hAlign="between" vAlign="start">
303
+ <HStack gap={1} vAlign="center" wrap="wrap">
304
+ <Badge label={item.ref} variant="neutral" />
305
+ <Badge label={priority.label} variant={priority.variant} />
306
+ </HStack>
307
+ <MoreMenu
308
+ label="Work item actions"
309
+ size="sm"
310
+ items={[
311
+ {label: 'Open', onClick: () => {}},
312
+ {label: 'Assign to me', onClick: () => {}},
313
+ {type: 'divider'},
314
+ ...moveTargets,
315
+ ]}
316
+ />
317
+ </HStack>
318
+
319
+ <VStack gap={1}>
320
+ <Heading level={4}>{item.title}</Heading>
321
+ <Text type="body" color="secondary" maxLines={2}>
322
+ {item.description}
323
+ </Text>
324
+ </VStack>
325
+
326
+ <Text type="supporting" color="secondary">
327
+ Edited {item.lastEdited} · Due {item.dueDate}
328
+ </Text>
329
+ </VStack>
330
+ );
331
+ }
332
+
333
+ // ============= BOARD CARD =============
334
+
335
+ function BoardCard({
336
+ item,
337
+ cardRef,
338
+ onPointerDown,
339
+ onMove,
340
+ }: {
341
+ item: WorkItem;
342
+ cardRef: (el: HTMLDivElement | null) => void;
343
+ onPointerDown: (e: ReactPointerEvent, id: string) => void;
344
+ onMove: (id: string, to: ColumnId) => void;
345
+ }) {
346
+ return (
347
+ <Card
348
+ ref={cardRef}
349
+ padding={3}
350
+ style={cardStyle}
351
+ onPointerDown={e => onPointerDown(e, item.id)}>
352
+ <BoardCardBody item={item} onMove={onMove} />
353
+ </Card>
354
+ );
355
+ }
356
+
357
+ // ============= BOARD COLUMN =============
358
+
359
+ function BoardColumn({
360
+ meta,
361
+ count,
362
+ contentRef,
363
+ children,
364
+ }: {
365
+ meta: ColumnMeta;
366
+ count: number;
367
+ contentRef: (el: HTMLDivElement | null) => void;
368
+ children: ReactNode;
369
+ }) {
370
+ return (
371
+ <Card variant="muted" padding={0} style={columnShellStyle}>
372
+ <Layout
373
+ height="fill"
374
+ header={
375
+ <LayoutHeader hasDivider padding={3}>
376
+ <HStack hAlign="between" vAlign="center">
377
+ <HStack gap={2} vAlign="center">
378
+ <StatusDot
379
+ variant={meta.variant}
380
+ label={`${meta.title} status`}
381
+ />
382
+ <Heading level={4}>{meta.title}</Heading>
383
+ <Tooltip content={meta.tooltip}>
384
+ <Icon icon={Info} size="sm" color="secondary" />
385
+ </Tooltip>
386
+ </HStack>
387
+ <Text type="supporting" color="secondary" hasTabularNumbers>
388
+ {count}
389
+ </Text>
390
+ </HStack>
391
+ </LayoutHeader>
392
+ }
393
+ content={
394
+ <LayoutContent ref={contentRef} padding={2}>
395
+ {children ?? (
396
+ <EmptyState
397
+ isCompact
398
+ style={columnEmptyStateStyle}
399
+ icon={
400
+ <Icon icon={meta.emptyIcon} size="lg" color="secondary" />
401
+ }
402
+ title={meta.emptyTitle}
403
+ description={meta.emptyDescription}
404
+ />
405
+ )}
406
+ </LayoutContent>
407
+ }
408
+ />
409
+ </Card>
410
+ );
411
+ }
412
+
413
+ // ============= MAIN =============
414
+
415
+ export default function KanbanBoard() {
416
+ const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
417
+ const [sprint, setSprint] = useState('003');
418
+ const [drag, setDrag] = useState<DragState | null>(null);
419
+
420
+ // Live element registries for pointer hit-testing (kept out of render state).
421
+ const columnEls = useRef(new Map<ColumnId, HTMLElement>());
422
+ const cardEls = useRef(new Map<string, HTMLElement>());
423
+ const columnRefCbs = useRef(
424
+ new Map<ColumnId, (el: HTMLDivElement | null) => void>(),
425
+ );
426
+ const cardRefCbs = useRef(
427
+ new Map<string, (el: HTMLDivElement | null) => void>(),
428
+ );
429
+ const teardownRef = useRef<(() => void) | null>(null);
430
+
431
+ // Stable ref callbacks so registering an element never churns across renders.
432
+ const getColumnRef = (id: ColumnId) => {
433
+ let cb = columnRefCbs.current.get(id);
434
+ if (!cb) {
435
+ cb = el => {
436
+ if (el) {
437
+ columnEls.current.set(id, el);
438
+ } else {
439
+ columnEls.current.delete(id);
440
+ }
441
+ };
442
+ columnRefCbs.current.set(id, cb);
443
+ }
444
+ return cb;
445
+ };
446
+
447
+ const getCardRef = (id: string) => {
448
+ let cb = cardRefCbs.current.get(id);
449
+ if (!cb) {
450
+ cb = el => {
451
+ if (el) {
452
+ cardEls.current.set(id, el);
453
+ } else {
454
+ cardEls.current.delete(id);
455
+ }
456
+ };
457
+ cardRefCbs.current.set(id, cb);
458
+ }
459
+ return cb;
460
+ };
461
+
462
+ const itemsByColumn = useMemo(() => {
463
+ const map: Record<ColumnId, WorkItem[]> = {
464
+ todo: [],
465
+ 'in-progress': [],
466
+ 'in-review': [],
467
+ done: [],
468
+ };
469
+ for (const item of items) {
470
+ map[item.column].push(item);
471
+ }
472
+ return map;
473
+ }, [items]);
474
+
475
+ const moveItem = (id: string, to: ColumnId) => {
476
+ setItems(prev =>
477
+ prev.map(item => (item.id === id ? {...item, column: to} : item)),
478
+ );
479
+ };
480
+
481
+ // Resolve the pointer position to a column + insertion index, ignoring the
482
+ // card being dragged so the math is against the cards that stay in place.
483
+ const computeTarget = (
484
+ px: number,
485
+ py: number,
486
+ draggedId: string,
487
+ ): DropTarget | null => {
488
+ for (const [colId, el] of Array.from(columnEls.current.entries())) {
489
+ const r = el.getBoundingClientRect();
490
+ if (px < r.left || px > r.right || py < r.top || py > r.bottom) {
491
+ continue;
492
+ }
493
+
494
+ const ids = itemsByColumn[colId]
495
+ .filter(it => it.id !== draggedId)
496
+ .map(it => it.id);
497
+
498
+ let index = ids.length;
499
+ for (let i = 0; i < ids.length; i++) {
500
+ const cardEl = cardEls.current.get(ids[i]);
501
+ if (!cardEl) {
502
+ continue;
503
+ }
504
+ const cr = cardEl.getBoundingClientRect();
505
+ if (py < cr.top + cr.height / 2) {
506
+ index = i;
507
+ break;
508
+ }
509
+ }
510
+ return {column: colId, index};
511
+ }
512
+ return null;
513
+ };
514
+
515
+ // Rebuild the flat item list so the dragged card lands at the resolved slot
516
+ // while every other card keeps its relative order.
517
+ const commitDrag = (id: string, target: DropTarget) => {
518
+ setItems(prev => {
519
+ const moved = prev.find(it => it.id === id);
520
+ if (!moved) {
521
+ return prev;
522
+ }
523
+
524
+ const rest = prev.filter(it => it.id !== id);
525
+ const updated: WorkItem = {...moved, column: target.column};
526
+ const colItems = rest.filter(it => it.column === target.column);
527
+ const anchor = colItems[target.index];
528
+
529
+ if (!anchor) {
530
+ return [...rest, updated];
531
+ }
532
+ const at = rest.indexOf(anchor);
533
+ return [...rest.slice(0, at), updated, ...rest.slice(at)];
534
+ });
535
+ };
536
+
537
+ const onCardPointerDown = (e: ReactPointerEvent, id: string) => {
538
+ if (e.button !== 0) {
539
+ return;
540
+ }
541
+ // Let the card's own controls (the actions menu) handle the press.
542
+ if (
543
+ (e.target as HTMLElement).closest(
544
+ 'button, [role="menuitem"], [role="menu"]',
545
+ )
546
+ ) {
547
+ return;
548
+ }
549
+
550
+ const el = cardEls.current.get(id);
551
+ if (!el) {
552
+ return;
553
+ }
554
+
555
+ const rect = el.getBoundingClientRect();
556
+ const startX = e.clientX;
557
+ const startY = e.clientY;
558
+ const offsetX = startX - rect.left;
559
+ const offsetY = startY - rect.top;
560
+ const {width, height} = rect;
561
+
562
+ let started = false;
563
+ let target: DropTarget | null = null;
564
+
565
+ const onMove = (ev: PointerEvent) => {
566
+ if (
567
+ !started &&
568
+ Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) <
569
+ DRAG_THRESHOLD
570
+ ) {
571
+ return;
572
+ }
573
+ started = true;
574
+ target = computeTarget(ev.clientX, ev.clientY, id);
575
+ setDrag({
576
+ id,
577
+ width,
578
+ height,
579
+ offsetX,
580
+ offsetY,
581
+ pointerX: ev.clientX,
582
+ pointerY: ev.clientY,
583
+ target,
584
+ });
585
+ };
586
+
587
+ const onUp = () => {
588
+ teardownRef.current?.();
589
+ if (started && target) {
590
+ commitDrag(id, target);
591
+ }
592
+ setDrag(null);
593
+ };
594
+
595
+ const teardown = () => {
596
+ window.removeEventListener('pointermove', onMove);
597
+ window.removeEventListener('pointerup', onUp);
598
+ teardownRef.current = null;
599
+ };
600
+ teardownRef.current = teardown;
601
+
602
+ window.addEventListener('pointermove', onMove);
603
+ window.addEventListener('pointerup', onUp);
604
+ };
605
+
606
+ const draggedItem = drag ? items.find(it => it.id === drag.id) : undefined;
607
+ const isDragging = drag !== null;
608
+
609
+ // Suppress selection while dragging and detach listeners on unmount.
610
+ useEffect(() => {
611
+ if (!isDragging) {
612
+ return;
613
+ }
614
+ const previous = document.body.style.userSelect;
615
+ document.body.style.userSelect = 'none';
616
+ return () => {
617
+ document.body.style.userSelect = previous;
618
+ };
619
+ }, [isDragging]);
620
+
621
+ useEffect(() => () => teardownRef.current?.(), []);
622
+
623
+ // Card nodes for a column, or null when the column should show its empty
624
+ // state. A dashed ghost box marks the landing slot during a drag.
625
+ const renderColumnCards = (colId: ColumnId): ReactNode => {
626
+ const colItems = itemsByColumn[colId];
627
+ const visible = drag ? colItems.filter(it => it.id !== drag.id) : colItems;
628
+ const ghostTarget =
629
+ drag && drag.target && drag.target.column === colId ? drag : null;
630
+
631
+ if (visible.length === 0 && !ghostTarget) {
632
+ return null;
633
+ }
634
+
635
+ const nodes: ReactNode[] = visible.map(it => (
636
+ <BoardCard
637
+ key={it.id}
638
+ item={it}
639
+ cardRef={getCardRef(it.id)}
640
+ onPointerDown={onCardPointerDown}
641
+ onMove={moveItem}
642
+ />
643
+ ));
644
+
645
+ if (ghostTarget && ghostTarget.target) {
646
+ const index = Math.min(ghostTarget.target.index, nodes.length);
647
+ nodes.splice(
648
+ index,
649
+ 0,
650
+ <VStack key="drag-ghost" style={ghostStyle(ghostTarget.height)} />,
651
+ );
652
+ }
653
+
654
+ return <VStack gap={2}>{nodes}</VStack>;
655
+ };
656
+
657
+ return (
658
+ <Section height="100dvh">
659
+ <Layout
660
+ height="fill"
661
+ header={
662
+ <LayoutHeader hasDivider padding={4}>
663
+ <Toolbar
664
+ label="Board actions"
665
+ gap={2}
666
+ startContent={
667
+ <>
668
+ <Heading level={3}>Sprint Board</Heading>
669
+ <Badge label={items.length} variant="neutral" />
670
+ </>
671
+ }
672
+ endContent={
673
+ <HStack gap={2} wrap="wrap">
674
+ <Selector
675
+ label="Sprint"
676
+ size="lg"
677
+ width={160}
678
+ isLabelHidden
679
+ value={sprint}
680
+ onChange={setSprint}
681
+ options={[
682
+ {value: '003', label: 'Sprint 003'},
683
+ {value: '002', label: 'Sprint 002'},
684
+ {value: '001', label: 'Sprint 001'},
685
+ ]}
686
+ />
687
+ <Divider
688
+ variant="strong"
689
+ orientation="vertical"
690
+ style={toolbarDividerStyle}
691
+ />
692
+ <HStack gap={1} vAlign="center" wrap="wrap">
693
+ <IconButton
694
+ icon={<Icon icon={ArrowUpDown} size="sm" />}
695
+ label="Sort"
696
+ />
697
+ <IconButton
698
+ icon={<Icon icon={Filter} size="sm" />}
699
+ label="Filter"
700
+ />
701
+ <IconButton
702
+ icon={<Icon icon={Search} size="sm" />}
703
+ label="Search"
704
+ />
705
+ </HStack>
706
+ <Button
707
+ label="Add task"
708
+ variant="primary"
709
+ icon={<Icon icon={Plus} size="sm" />}
710
+ />
711
+ </HStack>
712
+ }
713
+ />
714
+ </LayoutHeader>
715
+ }
716
+ content={
717
+ <LayoutContent padding={0}>
718
+ <Grid columns={{ minWidth: 280, max: 4 }} gap={4} style={boardColumnsStyle}>
719
+ {COLUMNS.map(meta => (
720
+ <BoardColumn
721
+ key={meta.id}
722
+ meta={meta}
723
+ count={itemsByColumn[meta.id].length}
724
+ contentRef={getColumnRef(meta.id)}>
725
+ {renderColumnCards(meta.id)}
726
+ </BoardColumn>
727
+ ))}
728
+ </Grid>
729
+ </LayoutContent>
730
+ }
731
+ />
732
+ {drag && draggedItem ? (
733
+ <Card
734
+ padding={3}
735
+ elevation="high"
736
+ style={{
737
+ ...floatingStyle,
738
+ width: drag.width,
739
+ transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
740
+ }}>
741
+ <BoardCardBody item={draggedItem} onMove={() => {}} />
742
+ </Card>
743
+ ) : null}
744
+ </Section>
745
+ );
746
+ }