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