@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.
- package/package.json +8 -4
- package/skills/newui-organisms/SKILL.md +91 -0
- package/src/exports.test.ts +3 -0
- package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
- package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
- package/src/organisms/DataTable/DataTable.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +609 -149
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- 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(
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
<
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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-
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
55
|
-
|
|
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
|
|
114
|
+
iconLeft={action().icon}
|
|
115
|
+
loading={action().loading}
|
|
116
|
+
disabled={action().disabled}
|
|
68
117
|
onClick={action().onClick}
|
|
69
118
|
>
|
|
70
119
|
{action().label}
|