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.
- package/BRAND.md +1 -1
- package/README.md +2 -0
- package/USAGE.md +13 -0
- package/astryx-dracula.js +4 -4
- package/astryx.integration.mjs +3 -0
- package/package.json +4 -2
- package/templates/ai-chat-landing.template.mjs +6 -0
- package/templates/ai-chat-landing.tsx +476 -0
- package/templates/ai-chat.template.mjs +6 -0
- package/templates/ai-chat.tsx +762 -0
- package/templates/blank.template.mjs +6 -0
- package/templates/blank.tsx +16 -0
- package/templates/centered-hero.template.mjs +6 -0
- package/templates/centered-hero.tsx +188 -0
- package/templates/classic-gallery.template.mjs +6 -0
- package/templates/classic-gallery.tsx +181 -0
- package/templates/contact-form.template.mjs +6 -0
- package/templates/contact-form.tsx +292 -0
- package/templates/dashboard-portfolio.template.mjs +6 -0
- package/templates/dashboard-portfolio.tsx +800 -0
- package/templates/dashboard.template.mjs +6 -0
- package/templates/dashboard.tsx +774 -0
- package/templates/detail-page.template.mjs +6 -0
- package/templates/detail-page.tsx +695 -0
- package/templates/documentation-design.template.mjs +6 -0
- package/templates/documentation-design.tsx +751 -0
- package/templates/documentation-technical.template.mjs +6 -0
- package/templates/documentation-technical.tsx +292 -0
- package/templates/documentation.template.mjs +6 -0
- package/templates/documentation.tsx +347 -0
- package/templates/editor.template.mjs +6 -0
- package/templates/editor.tsx +959 -0
- package/templates/file-explorer.template.mjs +6 -0
- package/templates/file-explorer.tsx +577 -0
- package/templates/form-two-column.template.mjs +6 -0
- package/templates/form-two-column.tsx +314 -0
- package/templates/gallery-hero.template.mjs +6 -0
- package/templates/gallery-hero.tsx +287 -0
- package/templates/ide.template.mjs +6 -0
- package/templates/ide.tsx +533 -0
- package/templates/incident-console.template.mjs +6 -0
- package/templates/incident-console.tsx +584 -0
- package/templates/kanban-board.template.mjs +6 -0
- package/templates/kanban-board.tsx +746 -0
- package/templates/library.template.mjs +6 -0
- package/templates/library.tsx +489 -0
- package/templates/login-card.template.mjs +6 -0
- package/templates/login-card.tsx +200 -0
- package/templates/login-split.template.mjs +6 -0
- package/templates/login-split.tsx +318 -0
- package/templates/login-sso.template.mjs +6 -0
- package/templates/login-sso.tsx +277 -0
- package/templates/login.template.mjs +6 -0
- package/templates/login.tsx +98 -0
- package/templates/messaging-shell.template.mjs +6 -0
- package/templates/messaging-shell.tsx +675 -0
- package/templates/mixed-gallery.template.mjs +6 -0
- package/templates/mixed-gallery.tsx +190 -0
- package/templates/payment-form.template.mjs +6 -0
- package/templates/payment-form.tsx +990 -0
- package/templates/product-detail.template.mjs +6 -0
- package/templates/product-detail.tsx +366 -0
- package/templates/product-gallery.template.mjs +6 -0
- package/templates/product-gallery.tsx +173 -0
- package/templates/settings-dialog.template.mjs +6 -0
- package/templates/settings-dialog.tsx +920 -0
- package/templates/settings-sidebar.template.mjs +6 -0
- package/templates/settings-sidebar.tsx +952 -0
- package/templates/settings.template.mjs +6 -0
- package/templates/settings.tsx +235 -0
- package/templates/shell-nav.template.mjs +6 -0
- package/templates/shell-nav.tsx +306 -0
- package/templates/shell-side-nav.template.mjs +6 -0
- package/templates/shell-side-nav.tsx +238 -0
- package/templates/shell-top-nav.template.mjs +6 -0
- package/templates/shell-top-nav.tsx +243 -0
- package/templates/side-gallery.template.mjs +6 -0
- package/templates/side-gallery.tsx +179 -0
- package/templates/table-grouped.template.mjs +6 -0
- package/templates/table-grouped.tsx +1174 -0
- package/templates/table-page-chart.template.mjs +6 -0
- package/templates/table-page-chart.tsx +693 -0
- package/templates/table-page-heatmap-status.template.mjs +6 -0
- package/templates/table-page-heatmap-status.tsx +547 -0
- package/templates/table-page-shoe-store-heatmap.template.mjs +6 -0
- package/templates/table-page-shoe-store-heatmap.tsx +1038 -0
- package/templates/table-page.template.mjs +6 -0
- package/templates/table-page.tsx +445 -0
- package/templates/table.template.mjs +6 -0
- package/templates/table.tsx +84 -0
- package/templates/theme-showcase.template.mjs +6 -0
- package/templates/theme-showcase.tsx +1282 -0
- package/theme.css +4 -4
- 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
|
+
}
|