@talelk/newui-organisms 1.0.0 → 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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  3. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  4. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  5. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  6. package/src/organisms/DataState/DataState.test.tsx +45 -0
  7. package/src/organisms/DataState/DataState.tsx +111 -0
  8. package/src/organisms/DataState/index.ts +6 -0
  9. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  10. package/src/organisms/DataTable/DataTable.tsx +426 -148
  11. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  12. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  13. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  14. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  15. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  16. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  17. package/src/organisms/Section/Section.test.tsx +41 -0
  18. package/src/organisms/Section/Section.tsx +108 -0
  19. package/src/organisms/Section/index.ts +1 -0
  20. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  21. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  22. package/src/organisms/SelectionBar/index.ts +5 -0
  23. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  24. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  25. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  26. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  27. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  28. package/src/organisms/index.ts +3 -0
  29. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -1,6 +1,7 @@
1
1
  import { Badge, Button, type ButtonTheme } from '@talelk/newui-atoms'
2
2
  import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
3
3
  import { For, type JSX, Show } from 'solid-js'
4
+ import { Dynamic } from 'solid-js/web'
4
5
 
5
6
  export interface DetailAction {
6
7
  label: string
@@ -11,6 +12,8 @@ export interface DetailHeaderProps {
11
12
  /** The trail above the title. Omitted → no breadcrumbs. */
12
13
  breadcrumbs?: BreadcrumbItem[]
13
14
  title: JSX.Element
15
+ /** Semantic level of the title. @default 1 */
16
+ headingLevel?: 1 | 2 | 3 | 4
14
17
  /** Muted line under the title — a record id, an owner, a timestamp. */
15
18
  subtitle?: JSX.Element
16
19
  /** Status pill beside the title. */
@@ -49,7 +52,12 @@ export function DetailHeader(props: DetailHeaderProps): JSX.Element {
49
52
  <div class="flex flex-wrap items-start justify-between gap-card-gap">
50
53
  <div class="flex min-w-0 flex-col gap-1">
51
54
  <div class="flex min-w-0 items-center gap-control-gap">
52
- <h1 class="truncate text-metric text-ink-gray-9">{props.title}</h1>
55
+ <Dynamic
56
+ component={`h${props.headingLevel ?? 1}`}
57
+ class="min-w-0 text-metric text-ink-gray-9 [overflow-wrap:anywhere]"
58
+ >
59
+ {props.title}
60
+ </Dynamic>
53
61
  <Show when={props.status}>
54
62
  {(status) => (
55
63
  <Badge
@@ -135,6 +135,7 @@ describe('FilterBar', () => {
135
135
  render(() => (
136
136
  <FilterBar
137
137
  tabs={{
138
+ label: 'View',
138
139
  value: 'list',
139
140
  onValueChange,
140
141
  options: [
@@ -145,7 +146,7 @@ describe('FilterBar', () => {
145
146
  />
146
147
  ))
147
148
 
148
- await userEvent.click(screen.getByRole('tab', { name: 'Board' }))
149
+ await userEvent.click(screen.getByRole('radio', { name: 'Board' }))
149
150
  expect(onValueChange).toHaveBeenCalledWith('board')
150
151
  })
151
152
  })
@@ -1,4 +1,4 @@
1
- import { Button, Icon, TextInput } from '@talelk/newui-atoms'
1
+ import { Button, cn, Icon, TextInput } from '@talelk/newui-atoms'
2
2
  import { Select, type SelectOption, TabButtons } from '@talelk/newui-molecules'
3
3
  import { For, type JSX, onCleanup, onMount, Show } from 'solid-js'
4
4
 
@@ -35,6 +35,8 @@ export interface FilterBarProps {
35
35
  selects?: FilterSelect[]
36
36
  /** A segmented control on the trailing edge — the view switcher. */
37
37
  tabs?: {
38
+ /** Accessible name for the radiogroup. */
39
+ label: string
38
40
  options: FilterOption[]
39
41
  value?: string
40
42
  onValueChange?: (value: string) => void
@@ -51,6 +53,8 @@ export interface FilterBarProps {
51
53
  resultCount?: number
52
54
  /** Anything else on the trailing edge, after the tabs. */
53
55
  children?: JSX.Element
56
+ /** `bare` removes the card surface for a toolbar composed into a Section. */
57
+ surface?: 'card' | 'bare'
54
58
  class?: string
55
59
  }
56
60
 
@@ -106,6 +110,8 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
106
110
  (props.chips?.length ?? 0) +
107
111
  (props.selects ?? []).filter((select) => Boolean(select.value)).length +
108
112
  (props.query?.value ? 1 : 0)
113
+ const hasLeading = () =>
114
+ Boolean(props.query) || (props.selects?.length ?? 0) > 0
109
115
  const showClearAll = () => props.onClearAll !== undefined && activeCount() > 0
110
116
  const showMetaRow = () =>
111
117
  (props.chips?.length ?? 0) > 0 ||
@@ -115,9 +121,16 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
115
121
  return (
116
122
  <div
117
123
  data-slot="filter-bar"
118
- class={`flex max-w-full flex-col gap-control-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad ${props.class ?? ''}`}
124
+ data-surface={props.surface ?? 'card'}
125
+ class={cn(
126
+ 'flex max-w-full flex-col gap-control-gap',
127
+ (props.surface ?? 'card') === 'card'
128
+ ? 'rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad'
129
+ : 'p-0',
130
+ props.class,
131
+ )}
119
132
  >
120
- <div class="grid max-w-full grid-cols-1 items-center gap-control-gap sm:grid-cols-2 xl:flex xl:flex-wrap">
133
+ <div class="grid max-w-full grid-cols-1 items-center gap-control-gap min-[360px]:grid-cols-2 xl:flex xl:flex-wrap">
121
134
  <Show when={props.query}>
122
135
  {(query) => (
123
136
  <span
@@ -163,10 +176,16 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
163
176
  )}
164
177
  </For>
165
178
 
166
- <div class="col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap sm:justify-end xl:ms-auto">
179
+ <div
180
+ class={cn(
181
+ 'col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap',
182
+ hasLeading() ? 'sm:justify-end xl:ms-auto' : 'sm:justify-start',
183
+ )}
184
+ >
167
185
  <Show when={props.tabs}>
168
186
  {(tabs) => (
169
187
  <TabButtons
188
+ label={tabs().label}
170
189
  options={tabs().options}
171
190
  value={tabs().value}
172
191
  onValueChange={tabs().onValueChange}
@@ -203,7 +222,7 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
203
222
  <button
204
223
  type="button"
205
224
  aria-label={`Remove filter: ${chip.label}`}
206
- class="grid size-4 place-items-center rounded-full text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
225
+ class="grid size-6 place-items-center rounded-full text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
207
226
  onClick={() => onRemove()()}
208
227
  >
209
228
  <Icon icon="lucide-x" class="size-3" />
@@ -16,6 +16,7 @@ import {
16
16
  onCleanup,
17
17
  Show,
18
18
  } from 'solid-js'
19
+ import { DataState } from '../DataState'
19
20
  import { createKanbanDrag, type KanbanSlot } from './drag'
20
21
 
21
22
  export type KanbanTone = 'gray' | 'amber' | 'blue' | 'green' | 'red' | 'violet'
@@ -79,6 +80,11 @@ export interface KanbanBoardProps<T extends { id: string }> {
79
80
  itemLabel?: (item: T) => string
80
81
  /** Rendered in a column with no cards. */
81
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
82
88
  class?: string
83
89
  }
84
90
 
@@ -155,6 +161,8 @@ export function KanbanBoard<T extends { id: string }>(
155
161
 
156
162
  /** The live preview of the item in flight: a slot, not a commit. */
157
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>()
158
166
 
159
167
  const rendered = createMemo(() => {
160
168
  const slot = preview()
@@ -171,6 +179,14 @@ export function KanbanBoard<T extends { id: string }>(
171
179
  * flight loses the element the gesture is holding. An id survives both.
172
180
  */
173
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)
174
190
  const columnOf = (columnId: string): KanbanColumn<T> =>
175
191
  rendered().find((column) => column.id === columnId) ?? {
176
192
  id: columnId,
@@ -257,12 +273,14 @@ export function KanbanBoard<T extends { id: string }>(
257
273
  item: T | undefined,
258
274
  from: KanbanSlot | undefined,
259
275
  to: KanbanSlot | undefined,
276
+ restoreFocus = false,
260
277
  ) {
261
278
  setPreview(undefined)
262
279
  if (!item || !from || !to) return
263
280
  const next = placeKanbanItem(columns(), item.id, to)
264
281
  if (!isControlled()) setInternal(next)
265
282
  props.onMove?.({ item, itemId: item.id, from, to, columns: next })
283
+ if (restoreFocus) setFocusAfterMove(item.id)
266
284
  }
267
285
 
268
286
  const drag = createKanbanDrag<T>({
@@ -279,10 +297,15 @@ export function KanbanBoard<T extends { id: string }>(
279
297
  const moveTo = (item: T, columnId: string) => {
280
298
  const from = slotOf(item.id)
281
299
  if (!from || from.column === columnId) return
282
- settle(item, from, {
283
- column: columnId,
284
- index: columnOf(columnId).items.length,
285
- })
300
+ settle(
301
+ item,
302
+ from,
303
+ {
304
+ column: columnId,
305
+ index: columnOf(columnId).items.length,
306
+ },
307
+ true,
308
+ )
286
309
  }
287
310
 
288
311
  createEffect(() => {
@@ -296,6 +319,23 @@ export function KanbanBoard<T extends { id: string }>(
296
319
  onCleanup(() => window.removeEventListener('keydown', onKey))
297
320
  })
298
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
+
299
339
  return (
300
340
  <div
301
341
  ref={boardRoot}
@@ -303,180 +343,195 @@ export function KanbanBoard<T extends { id: string }>(
303
343
  data-kanban-dragging={drag.state() ? '' : undefined}
304
344
  class={cn('flex min-h-0 flex-col', props.class)}
305
345
  >
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
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
344
396
  main content, and a card's own headings then nest
345
397
  under it. An `h3` here trips the heading-order rule
346
398
  on every screen whose board sits directly under an
347
399
  `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
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
352
404
  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>
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>
356
421
  </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
422
 
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>
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>
375
428
 
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
- />
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)
415
453
  }
454
+ class="shrink-0"
416
455
  >
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>
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)}
453
474
  </div>
454
- </Show>
455
- <Show when={props.renderActions}>
456
- {(render) => (
475
+ <Show when={!props.renderActions}>
457
476
  <div class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
458
- {render()(item)}
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>
459
507
  </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>
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>
480
535
 
481
536
  <Show when={drag.state()}>
482
537
  {(state) => (
@@ -101,7 +101,7 @@ export function MobileNavItem(props: MobileNavItemProps): JSX.Element {
101
101
  onClick={local.onClick}
102
102
  class={cn(
103
103
  'flex min-h-14 flex-col items-center justify-center gap-1 py-2 text-ink-gray-6 transition',
104
- 'active:scale-95 data-[state=active]:text-ink-gray-8',
104
+ 'active:scale-95 data-[state=active]:bg-surface-blue-1 data-[state=active]:text-ink-blue-8',
105
105
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3',
106
106
  local.class,
107
107
  )}