@talelk/newui-organisms 1.0.0 → 2.1.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 (50) hide show
  1. package/package.json +8 -4
  2. package/skills/newui-organisms/SKILL.md +91 -0
  3. package/src/exports.test.ts +3 -0
  4. package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
  5. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  6. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  7. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  8. package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
  9. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  10. package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
  11. package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
  12. package/src/organisms/DataState/DataState.test.tsx +45 -0
  13. package/src/organisms/DataState/DataState.tsx +111 -0
  14. package/src/organisms/DataState/index.ts +6 -0
  15. package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
  16. package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
  17. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  18. package/src/organisms/DataTable/DataTable.tsx +609 -149
  19. package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
  20. package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
  21. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  22. package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
  23. package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
  24. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  25. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  26. package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
  27. package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  29. package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
  30. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  31. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  32. package/src/organisms/Section/Section.a11y.test.tsx +49 -0
  33. package/src/organisms/Section/Section.browser.test.tsx +63 -0
  34. package/src/organisms/Section/Section.test.tsx +41 -0
  35. package/src/organisms/Section/Section.tsx +108 -0
  36. package/src/organisms/Section/index.ts +1 -0
  37. package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
  38. package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
  39. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  40. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  41. package/src/organisms/SelectionBar/index.ts +5 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  43. package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
  44. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  45. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  46. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  47. package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
  48. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  49. package/src/organisms/index.ts +3 -0
  50. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -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) => (
@@ -0,0 +1,49 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { MobileNav, MobileNavItem } from './MobileNav'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ describe('MobileNav accessibility', () => {
22
+ it('has no axe violations for a tab bar of links and buttons', async () => {
23
+ const { container } = render(() => (
24
+ <MobileNav aria-label="Primary">
25
+ <MobileNavItem
26
+ label="Home"
27
+ icon="lucide-house"
28
+ active
29
+ aria-current="page"
30
+ />
31
+ <MobileNavItem label="Search" icon="lucide-search" route="/search" />
32
+ <MobileNavItem label="You" icon="lucide-user-round" />
33
+ </MobileNav>
34
+ ))
35
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
36
+ })
37
+
38
+ it('has no axe violations with custom content in place of an icon', async () => {
39
+ const { container } = render(() => (
40
+ <MobileNav aria-label="Primary">
41
+ <MobileNavItem label="Home" active>
42
+ <span>AC</span>
43
+ </MobileNavItem>
44
+ <MobileNavItem label="More" icon="lucide-ellipsis" />
45
+ </MobileNav>
46
+ ))
47
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
48
+ })
49
+ })
@@ -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
  )}
@@ -1,18 +1,25 @@
1
- import { Button, type IconProp } from '@talelk/newui-atoms'
2
- import { Dropdown } from '@talelk/newui-molecules'
3
- import { type JSX, Show } from 'solid-js'
1
+ import { Button, cn, Icon, type IconProp } from '@talelk/newui-atoms'
2
+ import { Menu } from '@talelk/newui-molecules'
3
+ import { For, type JSX, Show } from 'solid-js'
4
+ import { Dynamic } from 'solid-js/web'
4
5
 
5
6
  export interface PageToolbarAction {
6
7
  label: string
7
- /** A `lucide-*` string or a component (P11). */
8
+ /** A `lucide-*` string or a component (P11). No icon is implied. */
8
9
  icon?: IconProp
9
10
  onClick?: () => void
11
+ loading?: boolean
12
+ disabled?: boolean
10
13
  }
11
14
 
12
15
  export interface PageToolbarProps {
13
16
  title: JSX.Element
17
+ /** Semantic level of the title. @default 1 */
18
+ headingLevel?: 1 | 2 | 3 | 4
14
19
  /** Options for the ellipsis menu beside the title. Omitted → no menu. */
15
20
  menu?: PageToolbarAction[]
21
+ /** Secondary actions, before the primary. */
22
+ secondary?: PageToolbarAction[]
16
23
  /** The primary action — the one thing this screen is for. */
17
24
  action?: PageToolbarAction
18
25
  /** Anything else on the trailing edge, after the primary action. */
@@ -36,35 +43,77 @@ export function PageToolbar(props: PageToolbarProps): JSX.Element {
36
43
  class={`flex min-h-12 shrink-0 items-center justify-between gap-control-gap border-b border-outline-gray-1 bg-surface-base px-page-gutter-compact sm:px-page-gutter ${props.class ?? ''}`}
37
44
  >
38
45
  <div class="flex min-w-0 items-center gap-1">
39
- <span class="truncate text-heading text-ink-gray-9">{props.title}</span>
46
+ <Dynamic
47
+ component={`h${props.headingLevel ?? 1}`}
48
+ class={cn(
49
+ 'truncate text-ink-gray-9',
50
+ (props.headingLevel ?? 1) === 1 ? 'text-heading' : 'text-section',
51
+ )}
52
+ >
53
+ {props.title}
54
+ </Dynamic>
40
55
  <Show when={props.menu}>
41
56
  {(menu) => (
42
- <Dropdown
43
- options={menu().map((entry) => ({
44
- label: entry.label,
45
- value: entry.label,
46
- ...(typeof entry.icon === 'string' ? { icon: entry.icon } : {}),
47
- }))}
57
+ <Menu.Root
48
58
  onSelect={(value) =>
49
59
  menu()
50
60
  .find((entry) => entry.label === value)
51
61
  ?.onClick?.()
52
62
  }
53
63
  >
54
- <span class="sr-only">Page actions</span>
55
- </Dropdown>
64
+ <Menu.Trigger
65
+ aria-label="Page actions"
66
+ class="grid size-7 shrink-0 place-items-center rounded-3 text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring"
67
+ >
68
+ <Icon icon="lucide-ellipsis" class="size-4" />
69
+ </Menu.Trigger>
70
+ <Menu.Content>
71
+ <For each={menu()}>
72
+ {(entry) => (
73
+ <Menu.Item value={entry.label}>
74
+ <Show when={entry.icon}>
75
+ {(icon) => (
76
+ <Icon
77
+ icon={icon()}
78
+ class="size-4 shrink-0 text-ink-gray-6"
79
+ />
80
+ )}
81
+ </Show>
82
+ <span>{entry.label}</span>
83
+ </Menu.Item>
84
+ )}
85
+ </For>
86
+ </Menu.Content>
87
+ </Menu.Root>
56
88
  )}
57
89
  </Show>
58
90
  </div>
59
91
 
60
92
  <div class="flex shrink-0 items-center gap-control-gap">
61
93
  {props.children}
94
+ <Show when={props.secondary}>
95
+ <For each={props.secondary}>
96
+ {(action) => (
97
+ <Button
98
+ variant="outline"
99
+ iconLeft={action.icon}
100
+ loading={action.loading}
101
+ disabled={action.disabled}
102
+ onClick={action.onClick}
103
+ >
104
+ {action.label}
105
+ </Button>
106
+ )}
107
+ </For>
108
+ </Show>
62
109
  <Show when={props.action}>
63
110
  {(action) => (
64
111
  <Button
65
112
  variant="solid"
66
113
  theme="blue"
67
- iconLeft={action().icon ?? 'lucide-plus'}
114
+ iconLeft={action().icon}
115
+ loading={action().loading}
116
+ disabled={action().disabled}
68
117
  onClick={action().onClick}
69
118
  >
70
119
  {action().label}