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