@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.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. 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
+ }