@talelk/newui-organisms 0.0.0-stage → 2.0.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/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
cn,
|
|
4
|
+
Icon,
|
|
5
|
+
type IconProp,
|
|
6
|
+
Portal,
|
|
7
|
+
ScrollArea,
|
|
8
|
+
} from '@talelk/newui-atoms'
|
|
9
|
+
import { Card, Menu } from '@talelk/newui-molecules'
|
|
10
|
+
import {
|
|
11
|
+
createEffect,
|
|
12
|
+
createMemo,
|
|
13
|
+
createSignal,
|
|
14
|
+
For,
|
|
15
|
+
type JSX,
|
|
16
|
+
onCleanup,
|
|
17
|
+
Show,
|
|
18
|
+
} from 'solid-js'
|
|
19
|
+
import { DataState } from '../DataState'
|
|
20
|
+
import { createKanbanDrag, type KanbanSlot } from './drag'
|
|
21
|
+
|
|
22
|
+
export type KanbanTone = 'gray' | 'amber' | 'blue' | 'green' | 'red' | 'violet'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Literal classes: Tailwind scans for whole names, so `bg-surface-${tone}-7`
|
|
26
|
+
* would compile to nothing. `css:check` fails on that, which is why.
|
|
27
|
+
*/
|
|
28
|
+
const TONES: Record<KanbanTone, string> = {
|
|
29
|
+
gray: 'bg-surface-gray-7',
|
|
30
|
+
amber: 'bg-surface-amber-7',
|
|
31
|
+
blue: 'bg-surface-blue-7',
|
|
32
|
+
green: 'bg-surface-green-7',
|
|
33
|
+
red: 'bg-surface-red-7',
|
|
34
|
+
violet: 'bg-surface-violet-7',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ms the cards around a moving slot take to slide to their new places. */
|
|
38
|
+
const FLIP_MS = 200
|
|
39
|
+
|
|
40
|
+
export interface KanbanColumn<T> {
|
|
41
|
+
id: string
|
|
42
|
+
/**
|
|
43
|
+
* Plain text on purpose: it is the column's heading, its accessible name, and
|
|
44
|
+
* the entry a card's menu offers when moving the card here. One string, so the
|
|
45
|
+
* three can never disagree.
|
|
46
|
+
*/
|
|
47
|
+
name: string
|
|
48
|
+
/** Status dot tone. Omit for no dot. */
|
|
49
|
+
theme?: KanbanTone
|
|
50
|
+
items: T[]
|
|
51
|
+
/** Muted line under the heading — a column total, a filter summary. */
|
|
52
|
+
summary?: JSX.Element
|
|
53
|
+
/** A trailing control in the heading, usually "add". */
|
|
54
|
+
action?: { label: string; icon?: IconProp; onClick?: () => void }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface KanbanMove<T> {
|
|
58
|
+
item: T
|
|
59
|
+
itemId: string
|
|
60
|
+
from: KanbanSlot
|
|
61
|
+
to: KanbanSlot
|
|
62
|
+
/** The columns with the move applied — the next value for a controlled board. */
|
|
63
|
+
columns: KanbanColumn<T>[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface KanbanBoardProps<T extends { id: string }> {
|
|
67
|
+
/** Controlled columns. When given, the board never changes them itself. */
|
|
68
|
+
columns?: KanbanColumn<T>[]
|
|
69
|
+
/** Uncontrolled initial columns. The board owns them from here. */
|
|
70
|
+
defaultColumns?: KanbanColumn<T>[]
|
|
71
|
+
/** Fires on every accepted drop, controlled or not. */
|
|
72
|
+
onMove?: (move: KanbanMove<T>) => void
|
|
73
|
+
/** The card body. The frame — grab, ghost, move menu — belongs to the block. */
|
|
74
|
+
children: (item: T) => JSX.Element
|
|
75
|
+
/** Replaces the built-in "Move to…" menu on every card. */
|
|
76
|
+
renderActions?: (item: T) => JSX.Element
|
|
77
|
+
/** Accessible name for the board region. */
|
|
78
|
+
label?: string
|
|
79
|
+
/** How a card is named in its menu and its accessible name. Defaults to its id. */
|
|
80
|
+
itemLabel?: (item: T) => string
|
|
81
|
+
/** Rendered in a column with no cards. */
|
|
82
|
+
emptyColumn?: (column: KanbanColumn<T>) => JSX.Element
|
|
83
|
+
/** Replaces the board with the shared loading skeleton. */
|
|
84
|
+
loading?: boolean
|
|
85
|
+
/** Replaces the board with the shared error state. */
|
|
86
|
+
error?: JSX.Element
|
|
87
|
+
onRetry?: () => void
|
|
88
|
+
class?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Move an item to a slot, without mutating the input.
|
|
93
|
+
*
|
|
94
|
+
* `to.index` counts the column's *other* items — the index to insert at once the
|
|
95
|
+
* item has been taken out, which is the number the drag gesture produces and the
|
|
96
|
+
* one a `splice` pair wants. Getting this wrong is an off-by-one that only shows
|
|
97
|
+
* when dragging downwards, so it is stated once, here, and pinned by a test.
|
|
98
|
+
*/
|
|
99
|
+
export function placeKanbanItem<T extends { id: string }>(
|
|
100
|
+
columns: KanbanColumn<T>[],
|
|
101
|
+
itemId: string,
|
|
102
|
+
to: KanbanSlot,
|
|
103
|
+
): KanbanColumn<T>[] {
|
|
104
|
+
const from = columns.find((column) =>
|
|
105
|
+
column.items.some((item) => item.id === itemId),
|
|
106
|
+
)
|
|
107
|
+
const record = from?.items.find((item) => item.id === itemId)
|
|
108
|
+
if (!from || !record) return columns
|
|
109
|
+
|
|
110
|
+
const moved: KanbanColumn<T>[] = columns.map((column) => {
|
|
111
|
+
if (column.id !== from.id && column.id !== to.column) return column
|
|
112
|
+
const items = column.items.filter((item) => item.id !== itemId)
|
|
113
|
+
if (column.id === to.column) {
|
|
114
|
+
const index = Math.max(0, Math.min(to.index, items.length))
|
|
115
|
+
items.splice(index, 0, record)
|
|
116
|
+
}
|
|
117
|
+
return { ...column, items }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A move that changes nothing should compare equal to what came in, so a
|
|
121
|
+
// consumer diffing the value does not re-render the world.
|
|
122
|
+
if (from.id === to.column) {
|
|
123
|
+
const after = moved.find((column) => column.id === from.id)?.items
|
|
124
|
+
if (
|
|
125
|
+
after &&
|
|
126
|
+
from.items.length === after.length &&
|
|
127
|
+
from.items.every((item, index) => item === after[index])
|
|
128
|
+
)
|
|
129
|
+
return columns
|
|
130
|
+
}
|
|
131
|
+
return moved
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A board of columns you drag cards between.
|
|
136
|
+
*
|
|
137
|
+
* **The gesture is an enhancement, not the only way.** Every card carries a
|
|
138
|
+
* "Move to…" menu listing the other columns, so the board is operable from the
|
|
139
|
+
* keyboard and from a screen reader. The reference implementation is
|
|
140
|
+
* pointer-only, which puts reordering out of reach without a mouse; that is a
|
|
141
|
+
* gap to close, not a detail to copy.
|
|
142
|
+
*
|
|
143
|
+
* **Reordering is previewed, then committed once.** The reference splices the
|
|
144
|
+
* caller's data during the drag and splices it back on cancel. Here the drop is
|
|
145
|
+
* reported a single time, at the end — cancelling is discarding a preview, so
|
|
146
|
+
* there is no restore path that can drift out of sync with the data, and a
|
|
147
|
+
* controlled board never receives a move it did not accept.
|
|
148
|
+
*
|
|
149
|
+
* `columns` makes it controlled, `defaultColumns` makes it own its state; either
|
|
150
|
+
* way `onMove` reports the accepted move with the next columns already computed.
|
|
151
|
+
*/
|
|
152
|
+
export function KanbanBoard<T extends { id: string }>(
|
|
153
|
+
props: KanbanBoardProps<T>,
|
|
154
|
+
): JSX.Element {
|
|
155
|
+
const [internal, setInternal] = createSignal<KanbanColumn<T>[]>(
|
|
156
|
+
props.defaultColumns ?? [],
|
|
157
|
+
)
|
|
158
|
+
const isControlled = () => props.columns !== undefined
|
|
159
|
+
const columns = () => props.columns ?? internal()
|
|
160
|
+
const nameOf = (item: T) => props.itemLabel?.(item) ?? item.id
|
|
161
|
+
|
|
162
|
+
/** The live preview of the item in flight: a slot, not a commit. */
|
|
163
|
+
const [preview, setPreview] = createSignal<KanbanSlot & { itemId: string }>()
|
|
164
|
+
/** A card to re-focus after a keyboard move has re-rendered the column. */
|
|
165
|
+
const [focusAfterMove, setFocusAfterMove] = createSignal<string>()
|
|
166
|
+
|
|
167
|
+
const rendered = createMemo(() => {
|
|
168
|
+
const slot = preview()
|
|
169
|
+
return slot ? placeKanbanItem(columns(), slot.itemId, slot) : columns()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The columns are walked by *id*, not by object.
|
|
174
|
+
*
|
|
175
|
+
* A controlled board is handed fresh column objects whenever its owner
|
|
176
|
+
* re-renders — a keystroke in a filter, a re-sorted list. Keying the rows on
|
|
177
|
+
* those objects would throw each column's DOM away and build it again: the
|
|
178
|
+
* scroll position resets, the ScrollArea machine remounts, and any card in
|
|
179
|
+
* flight loses the element the gesture is holding. An id survives both.
|
|
180
|
+
*/
|
|
181
|
+
const columnIds = createMemo(() => rendered().map((column) => column.id))
|
|
182
|
+
const status = () =>
|
|
183
|
+
props.loading
|
|
184
|
+
? ('loading' as const)
|
|
185
|
+
: props.error
|
|
186
|
+
? ('error' as const)
|
|
187
|
+
: columnIds().length === 0
|
|
188
|
+
? ('empty' as const)
|
|
189
|
+
: ('ready' as const)
|
|
190
|
+
const columnOf = (columnId: string): KanbanColumn<T> =>
|
|
191
|
+
rendered().find((column) => column.id === columnId) ?? {
|
|
192
|
+
id: columnId,
|
|
193
|
+
name: columnId,
|
|
194
|
+
items: [],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let boardRoot: HTMLDivElement | undefined
|
|
198
|
+
const columnRoots = new Map<string, HTMLElement>()
|
|
199
|
+
|
|
200
|
+
const boardScroller = () =>
|
|
201
|
+
boardRoot?.querySelector<HTMLElement>(
|
|
202
|
+
'[data-slot="scroll-area-viewport"]',
|
|
203
|
+
) ?? undefined
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A column's vertical scroller is the ScrollArea machine's viewport, which the
|
|
207
|
+
* machine owns and hands out no ref for. The column's own root *is* ours, so
|
|
208
|
+
* the viewport is looked up inside it — the one honest way to reach an element
|
|
209
|
+
* the library keeps.
|
|
210
|
+
*/
|
|
211
|
+
const columnScroller = (columnId: string): HTMLElement | undefined =>
|
|
212
|
+
columnRoots
|
|
213
|
+
.get(columnId)
|
|
214
|
+
?.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
|
|
215
|
+
undefined
|
|
216
|
+
|
|
217
|
+
const slotOf = (itemId: string): KanbanSlot | undefined => {
|
|
218
|
+
for (const column of rendered()) {
|
|
219
|
+
const index = column.items.findIndex((item) => item.id === itemId)
|
|
220
|
+
if (index !== -1) return { column: column.id, index }
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const motionAllowed = () =>
|
|
226
|
+
typeof matchMedia !== 'function' ||
|
|
227
|
+
!matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
228
|
+
|
|
229
|
+
function measure(): Map<string, DOMRect> {
|
|
230
|
+
const rects = new Map<string, DOMRect>()
|
|
231
|
+
const cards =
|
|
232
|
+
boardRoot?.querySelectorAll<HTMLElement>('[data-kanban-card]') ?? []
|
|
233
|
+
for (const card of cards) {
|
|
234
|
+
const id = card.dataset['kanbanId']
|
|
235
|
+
if (id) rects.set(id, card.getBoundingClientRect())
|
|
236
|
+
}
|
|
237
|
+
return rects
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* FLIP: reorder first (Solid moves the nodes synchronously), then play each
|
|
242
|
+
* card back from where it was. `Element.animate` rather than a transition
|
|
243
|
+
* class, so there is nothing to clean up afterwards — and it is skipped
|
|
244
|
+
* entirely when motion is reduced, which the Web Animations API does not do on
|
|
245
|
+
* its own.
|
|
246
|
+
*/
|
|
247
|
+
function playFlip(before: Map<string, DOMRect>) {
|
|
248
|
+
if (!motionAllowed()) return
|
|
249
|
+
const cards =
|
|
250
|
+
boardRoot?.querySelectorAll<HTMLElement>('[data-kanban-card]') ?? []
|
|
251
|
+
for (const card of cards) {
|
|
252
|
+
const id = card.dataset['kanbanId']
|
|
253
|
+
const was = id ? before.get(id) : undefined
|
|
254
|
+
if (!was || typeof card.animate !== 'function') continue
|
|
255
|
+
const now = card.getBoundingClientRect()
|
|
256
|
+
const dx = was.left - now.left
|
|
257
|
+
const dy = was.top - now.top
|
|
258
|
+
if (dx === 0 && dy === 0) continue
|
|
259
|
+
card.animate(
|
|
260
|
+
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
|
|
261
|
+
{ duration: FLIP_MS, easing: 'ease' },
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function previewTo(itemId: string, to: KanbanSlot) {
|
|
267
|
+
const before = measure()
|
|
268
|
+
setPreview({ itemId, ...to })
|
|
269
|
+
playFlip(before)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function settle(
|
|
273
|
+
item: T | undefined,
|
|
274
|
+
from: KanbanSlot | undefined,
|
|
275
|
+
to: KanbanSlot | undefined,
|
|
276
|
+
restoreFocus = false,
|
|
277
|
+
) {
|
|
278
|
+
setPreview(undefined)
|
|
279
|
+
if (!item || !from || !to) return
|
|
280
|
+
const next = placeKanbanItem(columns(), item.id, to)
|
|
281
|
+
if (!isControlled()) setInternal(next)
|
|
282
|
+
props.onMove?.({ item, itemId: item.id, from, to, columns: next })
|
|
283
|
+
if (restoreFocus) setFocusAfterMove(item.id)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const drag = createKanbanDrag<T>({
|
|
287
|
+
container: boardScroller,
|
|
288
|
+
columnScroller,
|
|
289
|
+
columnIds,
|
|
290
|
+
slotOf,
|
|
291
|
+
onPreview: previewTo,
|
|
292
|
+
onDrop: (item, from, to) => settle(item, from, to),
|
|
293
|
+
onCancel: () => settle(undefined, undefined, undefined),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
/** The keyboard path: same commit as a drop, at the end of the target column. */
|
|
297
|
+
const moveTo = (item: T, columnId: string) => {
|
|
298
|
+
const from = slotOf(item.id)
|
|
299
|
+
if (!from || from.column === columnId) return
|
|
300
|
+
settle(
|
|
301
|
+
item,
|
|
302
|
+
from,
|
|
303
|
+
{
|
|
304
|
+
column: columnId,
|
|
305
|
+
index: columnOf(columnId).items.length,
|
|
306
|
+
},
|
|
307
|
+
true,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
createEffect(() => {
|
|
312
|
+
if (!drag.state()) return
|
|
313
|
+
const onKey = (event: KeyboardEvent) => {
|
|
314
|
+
if (event.key !== 'Escape') return
|
|
315
|
+
event.preventDefault()
|
|
316
|
+
drag.cancel()
|
|
317
|
+
}
|
|
318
|
+
window.addEventListener('keydown', onKey)
|
|
319
|
+
onCleanup(() => window.removeEventListener('keydown', onKey))
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
createEffect(() => {
|
|
323
|
+
const id = focusAfterMove()
|
|
324
|
+
if (!id) return
|
|
325
|
+
// Keyboard moves re-create the card in its destination column, so the
|
|
326
|
+
// trigger that opened the menu is gone. Put focus back on the same card
|
|
327
|
+
// and keep it in view instead of dropping the reader on <body>.
|
|
328
|
+
queueMicrotask(() => {
|
|
329
|
+
const card = [
|
|
330
|
+
...(boardRoot?.querySelectorAll<HTMLElement>('[data-kanban-id]') ?? []),
|
|
331
|
+
].find((element) => element.dataset['kanbanId'] === id)
|
|
332
|
+
if (typeof card?.scrollIntoView === 'function')
|
|
333
|
+
card.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
334
|
+
card?.querySelector<HTMLElement>('[data-kanban-action]')?.focus()
|
|
335
|
+
setFocusAfterMove(undefined)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div
|
|
341
|
+
ref={boardRoot}
|
|
342
|
+
data-slot="kanban-board"
|
|
343
|
+
data-kanban-dragging={drag.state() ? '' : undefined}
|
|
344
|
+
class={cn('flex min-h-0 flex-col', props.class)}
|
|
345
|
+
>
|
|
346
|
+
<Show
|
|
347
|
+
when={status() === 'ready'}
|
|
348
|
+
fallback={
|
|
349
|
+
<DataState
|
|
350
|
+
status={status()}
|
|
351
|
+
skeleton="cards"
|
|
352
|
+
title={props.emptyColumn ? undefined : 'No columns yet'}
|
|
353
|
+
error={props.error}
|
|
354
|
+
onRetry={props.onRetry}
|
|
355
|
+
/>
|
|
356
|
+
}
|
|
357
|
+
>
|
|
358
|
+
<ScrollArea.Root class="min-h-0 flex-1">
|
|
359
|
+
<ScrollArea.Viewport>
|
|
360
|
+
<ul
|
|
361
|
+
// A list of columns, each holding a list of cards: the structure a
|
|
362
|
+
// screen reader walks. The drag is invisible to it, which is exactly
|
|
363
|
+
// why the card menu exists.
|
|
364
|
+
class={cn(
|
|
365
|
+
'flex h-full min-w-max items-stretch gap-card-gap p-card-pad',
|
|
366
|
+
// Below `lg` the board is a phone-width panner, and there the
|
|
367
|
+
// reference snaps: a column that lands half-off the edge is a
|
|
368
|
+
// column you cannot read. Wide, it is a drag surface, and snapping
|
|
369
|
+
// under a pointer mid-drop is just fighting the user.
|
|
370
|
+
'max-lg:snap-x max-lg:snap-mandatory max-lg:scroll-px-4',
|
|
371
|
+
// Text selection during a drag turns a card into a smear.
|
|
372
|
+
drag.state() && 'select-none',
|
|
373
|
+
)}
|
|
374
|
+
aria-label={props.label ?? 'Board'}
|
|
375
|
+
>
|
|
376
|
+
<For each={columnIds()}>
|
|
377
|
+
{(columnId) => {
|
|
378
|
+
const column = () => columnOf(columnId)
|
|
379
|
+
return (
|
|
380
|
+
<li
|
|
381
|
+
ref={(element) => columnRoots.set(columnId, element)}
|
|
382
|
+
data-kanban-column={columnId}
|
|
383
|
+
class="flex min-h-0 w-72 shrink-0 flex-col rounded-card border border-outline-gray-1 bg-surface-gray-1 max-lg:snap-start min-[1440px]:w-64 2xl:w-72 dark:bg-transparent"
|
|
384
|
+
>
|
|
385
|
+
<div class="flex items-center justify-between gap-2 pe-1 ps-3 pt-1 pb-2">
|
|
386
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
387
|
+
<Show when={column().theme}>
|
|
388
|
+
{(tone) => (
|
|
389
|
+
<span
|
|
390
|
+
class={cn('size-2 rounded-full', TONES[tone()])}
|
|
391
|
+
aria-hidden="true"
|
|
392
|
+
/>
|
|
393
|
+
)}
|
|
394
|
+
</Show>
|
|
395
|
+
{/* `h2`, like the reference: the board is the page's
|
|
396
|
+
main content, and a card's own headings then nest
|
|
397
|
+
under it. An `h3` here trips the heading-order rule
|
|
398
|
+
on every screen whose board sits directly under an
|
|
399
|
+
`h1`. */}
|
|
400
|
+
<h2 class="truncate text-sm-medium text-ink-gray-8">
|
|
401
|
+
{column().name}
|
|
402
|
+
</h2>
|
|
403
|
+
{/* The count sits on the column's tinted surface, so it
|
|
404
|
+
is `ink-gray-6`: the step above fails AA here. */}
|
|
405
|
+
<span class="text-sm text-ink-gray-6">
|
|
406
|
+
{column().items.length}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
<Show when={column().action}>
|
|
410
|
+
{(action) => (
|
|
411
|
+
<Button
|
|
412
|
+
variant="ghost"
|
|
413
|
+
size="sm"
|
|
414
|
+
icon={action().icon ?? 'lucide-plus'}
|
|
415
|
+
aria-label={action().label}
|
|
416
|
+
class="max-lg:size-10"
|
|
417
|
+
onClick={action().onClick}
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
</Show>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<Show when={column().summary}>
|
|
424
|
+
<div class="ps-3 pb-2 text-sm tabular-nums text-ink-gray-6">
|
|
425
|
+
{column().summary}
|
|
426
|
+
</div>
|
|
427
|
+
</Show>
|
|
428
|
+
|
|
429
|
+
<ScrollArea.Root class="min-h-0 flex-1">
|
|
430
|
+
<ScrollArea.Viewport class="px-2 pb-2">
|
|
431
|
+
<ul class="flex min-h-24 flex-col gap-2">
|
|
432
|
+
<For
|
|
433
|
+
each={column().items}
|
|
434
|
+
fallback={
|
|
435
|
+
<li class="rounded-card border border-dashed border-outline-gray-3 p-6 text-center text-sm text-ink-gray-6">
|
|
436
|
+
{props.emptyColumn?.(column()) ??
|
|
437
|
+
'Nothing in this column.'}
|
|
438
|
+
</li>
|
|
439
|
+
}
|
|
440
|
+
>
|
|
441
|
+
{(item) => (
|
|
442
|
+
<li
|
|
443
|
+
data-kanban-card=""
|
|
444
|
+
data-kanban-id={item.id}
|
|
445
|
+
data-dragging={
|
|
446
|
+
drag.isDragging(item.id) ? '' : undefined
|
|
447
|
+
}
|
|
448
|
+
// Wrapped: a handler read straight off an
|
|
449
|
+
// accessor is captured once, and this one needs
|
|
450
|
+
// the live element.
|
|
451
|
+
onPointerDown={(event) =>
|
|
452
|
+
drag.start(event, item, event.currentTarget)
|
|
453
|
+
}
|
|
454
|
+
class="shrink-0"
|
|
455
|
+
>
|
|
456
|
+
<Show
|
|
457
|
+
when={!drag.isDragging(item.id)}
|
|
458
|
+
fallback={
|
|
459
|
+
// The card that was picked up keeps its
|
|
460
|
+
// place as the hole the ghost lands on, so
|
|
461
|
+
// the column does not reflow when it lifts.
|
|
462
|
+
<div
|
|
463
|
+
class="rounded-card border-2 border-dashed border-outline-gray-2 bg-surface-gray-2"
|
|
464
|
+
style={{
|
|
465
|
+
height: `${drag.state()?.height ?? 0}px`,
|
|
466
|
+
}}
|
|
467
|
+
/>
|
|
468
|
+
}
|
|
469
|
+
>
|
|
470
|
+
<KanbanCardFrame>
|
|
471
|
+
<div class="flex items-start justify-between gap-1">
|
|
472
|
+
<div class="min-w-0 flex-1">
|
|
473
|
+
{props.children(item)}
|
|
474
|
+
</div>
|
|
475
|
+
<Show when={!props.renderActions}>
|
|
476
|
+
<div class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
477
|
+
<Menu.Root
|
|
478
|
+
onSelect={(value) =>
|
|
479
|
+
moveTo(item, value)
|
|
480
|
+
}
|
|
481
|
+
>
|
|
482
|
+
<Menu.Trigger
|
|
483
|
+
data-kanban-action=""
|
|
484
|
+
aria-label={`Actions for ${nameOf(item)}`}
|
|
485
|
+
class="flex size-7 cursor-pointer items-center justify-center rounded-3 text-ink-gray-6 transition-colors hover:bg-surface-gray-3 hover:text-ink-gray-8 focus-visible:focus-ring max-lg:size-10"
|
|
486
|
+
>
|
|
487
|
+
<Icon
|
|
488
|
+
icon="lucide-ellipsis"
|
|
489
|
+
class="size-4"
|
|
490
|
+
/>
|
|
491
|
+
</Menu.Trigger>
|
|
492
|
+
<Menu.Content>
|
|
493
|
+
<Menu.Label>Move to</Menu.Label>
|
|
494
|
+
<For
|
|
495
|
+
each={columnIds().filter(
|
|
496
|
+
(id) => id !== columnId,
|
|
497
|
+
)}
|
|
498
|
+
>
|
|
499
|
+
{(target) => (
|
|
500
|
+
<Menu.Item value={target}>
|
|
501
|
+
{columnOf(target).name}
|
|
502
|
+
</Menu.Item>
|
|
503
|
+
)}
|
|
504
|
+
</For>
|
|
505
|
+
</Menu.Content>
|
|
506
|
+
</Menu.Root>
|
|
507
|
+
</div>
|
|
508
|
+
</Show>
|
|
509
|
+
<Show when={props.renderActions}>
|
|
510
|
+
{(render) => (
|
|
511
|
+
<div class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
512
|
+
{render()(item)}
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
</Show>
|
|
516
|
+
</div>
|
|
517
|
+
</KanbanCardFrame>
|
|
518
|
+
</Show>
|
|
519
|
+
</li>
|
|
520
|
+
)}
|
|
521
|
+
</For>
|
|
522
|
+
</ul>
|
|
523
|
+
</ScrollArea.Viewport>
|
|
524
|
+
<ScrollArea.Scrollbar />
|
|
525
|
+
</ScrollArea.Root>
|
|
526
|
+
</li>
|
|
527
|
+
)
|
|
528
|
+
}}
|
|
529
|
+
</For>
|
|
530
|
+
</ul>
|
|
531
|
+
</ScrollArea.Viewport>
|
|
532
|
+
<ScrollArea.Scrollbar orientation="horizontal" />
|
|
533
|
+
</ScrollArea.Root>
|
|
534
|
+
</Show>
|
|
535
|
+
|
|
536
|
+
<Show when={drag.state()}>
|
|
537
|
+
{(state) => (
|
|
538
|
+
<Portal>
|
|
539
|
+
{/* The lifted card: fixed, inert and out of hit-testing, so the
|
|
540
|
+
pointer sees the board underneath it. */}
|
|
541
|
+
<div
|
|
542
|
+
class="pointer-events-none fixed top-0 left-0 z-overlay will-change-transform"
|
|
543
|
+
classList={{
|
|
544
|
+
'transition-transform duration-base ease-out': state().settling,
|
|
545
|
+
}}
|
|
546
|
+
style={{
|
|
547
|
+
width: `${state().width}px`,
|
|
548
|
+
transform: `translate3d(${state().x}px, ${state().y}px, 0)`,
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
<KanbanCardFrame
|
|
552
|
+
ghost
|
|
553
|
+
class={cn(
|
|
554
|
+
!state().settling && 'scale-[1.02] rotate-2 shadow-xl',
|
|
555
|
+
)}
|
|
556
|
+
>
|
|
557
|
+
<div class="flex items-start justify-between gap-1">
|
|
558
|
+
<div class="min-w-0 flex-1">
|
|
559
|
+
{props.children(state().item)}
|
|
560
|
+
</div>
|
|
561
|
+
{/* Keeps the ghost's header row the same height as the real
|
|
562
|
+
card's, without a menu that would do nothing. */}
|
|
563
|
+
<Show when={!props.renderActions}>
|
|
564
|
+
<Button
|
|
565
|
+
variant="ghost"
|
|
566
|
+
size="sm"
|
|
567
|
+
icon="lucide-ellipsis"
|
|
568
|
+
class="opacity-0"
|
|
569
|
+
tabindex="-1"
|
|
570
|
+
aria-hidden="true"
|
|
571
|
+
/>
|
|
572
|
+
</Show>
|
|
573
|
+
</div>
|
|
574
|
+
</KanbanCardFrame>
|
|
575
|
+
</div>
|
|
576
|
+
</Portal>
|
|
577
|
+
)}
|
|
578
|
+
</Show>
|
|
579
|
+
</div>
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The card itself: the grab cursor, the hover ring, the ghost's lift. Separate
|
|
585
|
+
* so the ghost is provably the same box as the card it replaces — a ghost a few
|
|
586
|
+
* pixels off makes every drop feel like a miss.
|
|
587
|
+
*/
|
|
588
|
+
function KanbanCardFrame(props: {
|
|
589
|
+
ghost?: boolean
|
|
590
|
+
class?: string
|
|
591
|
+
children: JSX.Element
|
|
592
|
+
}): JSX.Element {
|
|
593
|
+
return (
|
|
594
|
+
<Card
|
|
595
|
+
padding="sm"
|
|
596
|
+
class={cn(
|
|
597
|
+
'group relative border-outline-gray-1',
|
|
598
|
+
props.ghost ? 'cursor-grabbing' : 'cursor-grab hover:shadow-sm',
|
|
599
|
+
props.class,
|
|
600
|
+
)}
|
|
601
|
+
>
|
|
602
|
+
{props.children}
|
|
603
|
+
</Card>
|
|
604
|
+
)
|
|
605
|
+
}
|