@charcuterie/ui 4.23.0 → 4.25.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/dist/GroupedList/GroupedList.d.ts +104 -2
- package/dist/GroupedList/GroupedList.d.ts.map +1 -1
- package/dist/GroupedList/GroupedList.js +24 -2
- package/dist/GroupedList/GroupedList.js.map +1 -1
- package/dist/GroupedList/GroupedListRow.d.ts +17 -6
- package/dist/GroupedList/GroupedListRow.d.ts.map +1 -1
- package/dist/GroupedList/GroupedListRow.js +75 -8
- package/dist/GroupedList/GroupedListRow.js.map +1 -1
- package/dist/GroupedList/groupedListStickyHeading.d.ts +20 -0
- package/dist/GroupedList/groupedListStickyHeading.d.ts.map +1 -0
- package/dist/GroupedList/groupedListStickyHeading.js +20 -0
- package/dist/GroupedList/groupedListStickyHeading.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +36 -4
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +70 -1
- package/src/GroupedList/GroupedList.stories.tsx +339 -1
- package/src/GroupedList/GroupedList.test.tsx +510 -0
- package/src/GroupedList/GroupedList.tsx +115 -3
- package/src/GroupedList/GroupedListRow.tsx +135 -10
- package/src/GroupedList/groupedListStickyHeading.ts +24 -0
- package/src/index.ts +4 -0
- package/src/styles.css +36 -4
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
within,
|
|
10
10
|
} from "storybook/test"
|
|
11
11
|
import { test } from "vitest"
|
|
12
|
+
import { userEvent as browserUserEvent } from "vitest/browser"
|
|
12
13
|
|
|
13
14
|
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
14
15
|
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
@@ -25,6 +26,9 @@ const {
|
|
|
25
26
|
NarrowView,
|
|
26
27
|
Playground,
|
|
27
28
|
Responsive,
|
|
29
|
+
RowStates,
|
|
30
|
+
SelectableRows,
|
|
31
|
+
StickyHeadings,
|
|
28
32
|
} = composeStories(stories)
|
|
29
33
|
|
|
30
34
|
test("a group's heading names its list", async () => {
|
|
@@ -269,3 +273,509 @@ test("both schemes pass axe, contrast included", async () => {
|
|
|
269
273
|
|
|
270
274
|
await expectNoAxeViolations(canvasElement)
|
|
271
275
|
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The element `isHeadingSticky` pins: the heading's row, which also
|
|
279
|
+
* holds the summary.
|
|
280
|
+
*/
|
|
281
|
+
const headingRowOf = (heading: HTMLElement) =>
|
|
282
|
+
heading.parentElement as HTMLElement
|
|
283
|
+
|
|
284
|
+
test("a heading scrolls with its rows unless isHeadingSticky is set", async () => {
|
|
285
|
+
const { canvas } = await mountStory(Playground)
|
|
286
|
+
|
|
287
|
+
const heading = expectAgentDrivable(canvas, {
|
|
288
|
+
name: "Screens",
|
|
289
|
+
role: "heading",
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await expect(
|
|
293
|
+
getComputedStyle(headingRowOf(heading)).position,
|
|
294
|
+
).toBe("static")
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test("a sticky heading stays at the top of its pane while its rows scroll under it", async () => {
|
|
298
|
+
const { canvas, canvasElement } =
|
|
299
|
+
await mountStory(StickyHeadings)
|
|
300
|
+
|
|
301
|
+
const pane = expectAgentDrivable(canvas, {
|
|
302
|
+
name: "Pages on a raised surface",
|
|
303
|
+
role: "region",
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const paneCanvas = within(pane)
|
|
307
|
+
|
|
308
|
+
const heading = paneCanvas.getByRole("heading", {
|
|
309
|
+
name: "On the Lobby screen",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const list = paneCanvas.getByRole("list", {
|
|
313
|
+
name: "On the Lobby screen",
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// The space under a stuck heading is its own padding, not a
|
|
317
|
+
// transparent gap the rows would show through.
|
|
318
|
+
await expect(
|
|
319
|
+
headingRowOf(heading).getBoundingClientRect().bottom,
|
|
320
|
+
).toBeCloseTo(list.getBoundingClientRect().top, 0)
|
|
321
|
+
|
|
322
|
+
pane.scrollTop = 96
|
|
323
|
+
|
|
324
|
+
await waitFor(() => {
|
|
325
|
+
expect(pane.scrollTop).toBe(96)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const paneTop =
|
|
329
|
+
pane.getBoundingClientRect().top + pane.clientTop
|
|
330
|
+
|
|
331
|
+
await expect(
|
|
332
|
+
headingRowOf(heading).getBoundingClientRect().top,
|
|
333
|
+
).toBeCloseTo(paneTop, 0)
|
|
334
|
+
|
|
335
|
+
// The first row has scrolled up past the heading, under it.
|
|
336
|
+
const firstRow = within(list).getAllByRole(
|
|
337
|
+
"listitem",
|
|
338
|
+
)[0] as HTMLElement
|
|
339
|
+
|
|
340
|
+
await expect(
|
|
341
|
+
firstRow.getBoundingClientRect().top,
|
|
342
|
+
).toBeLessThan(paneTop)
|
|
343
|
+
|
|
344
|
+
await expectNoAxeViolations(canvasElement)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test("a stuck heading's background is the surface its host names, and surface-base by default", async () => {
|
|
348
|
+
const { canvas } = await mountStory(StickyHeadings)
|
|
349
|
+
|
|
350
|
+
for (const name of [
|
|
351
|
+
"Pages on a base surface",
|
|
352
|
+
"Pages on a raised surface",
|
|
353
|
+
]) {
|
|
354
|
+
const pane = expectAgentDrivable(canvas, {
|
|
355
|
+
name,
|
|
356
|
+
role: "region",
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const heading = within(pane).getByRole("heading", {
|
|
360
|
+
name: "On the Break Room screen",
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const headingBackground = getComputedStyle(
|
|
364
|
+
headingRowOf(heading),
|
|
365
|
+
).backgroundColor
|
|
366
|
+
|
|
367
|
+
await expect(headingBackground).toBe(
|
|
368
|
+
getComputedStyle(pane).backgroundColor,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
await expect(headingBackground).not.toBe(
|
|
372
|
+
"rgba(0, 0, 0, 0)",
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// And the two surfaces are different, or the assertion above
|
|
377
|
+
// could pass with the property ignored.
|
|
378
|
+
await expect(
|
|
379
|
+
getComputedStyle(
|
|
380
|
+
expectAgentDrivable(canvas, {
|
|
381
|
+
name: "Pages on a base surface",
|
|
382
|
+
role: "region",
|
|
383
|
+
}),
|
|
384
|
+
).backgroundColor,
|
|
385
|
+
).not.toBe(
|
|
386
|
+
getComputedStyle(
|
|
387
|
+
expectAgentDrivable(canvas, {
|
|
388
|
+
name: "Pages on a raised surface",
|
|
389
|
+
role: "region",
|
|
390
|
+
}),
|
|
391
|
+
).backgroundColor,
|
|
392
|
+
)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
/** The `<li>` a whole-row button stretches over. */
|
|
396
|
+
const rowOf = (button: HTMLElement) =>
|
|
397
|
+
button.closest("li") as HTMLElement
|
|
398
|
+
|
|
399
|
+
/** What the pointer would hit at the middle of an element's box. */
|
|
400
|
+
const elementAtCentreOf = (element: HTMLElement) => {
|
|
401
|
+
const box = element.getBoundingClientRect()
|
|
402
|
+
|
|
403
|
+
return document.elementFromPoint(
|
|
404
|
+
box.left + box.width / 2,
|
|
405
|
+
box.top + box.height / 2,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
test("a row without onSelect draws no row button", async () => {
|
|
410
|
+
const { canvas } = await mountStory(Playground)
|
|
411
|
+
|
|
412
|
+
await expect(
|
|
413
|
+
canvas.queryByRole("button", { name: "Lobby" }),
|
|
414
|
+
).toBeNull()
|
|
415
|
+
|
|
416
|
+
await expect(
|
|
417
|
+
canvas
|
|
418
|
+
.getAllByRole("button")
|
|
419
|
+
.map((button: HTMLElement) =>
|
|
420
|
+
button.getAttribute("aria-label"),
|
|
421
|
+
),
|
|
422
|
+
).toEqual([
|
|
423
|
+
"Open Lobby",
|
|
424
|
+
"Open Break Room",
|
|
425
|
+
"Open Welcome",
|
|
426
|
+
"Open Weather",
|
|
427
|
+
"Open Schedule",
|
|
428
|
+
"Open Test Pattern",
|
|
429
|
+
])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test("onSelect makes the whole row one native button, named by selectLabel", async () => {
|
|
433
|
+
const { canvas, canvasElement } =
|
|
434
|
+
await mountStory(SelectableRows)
|
|
435
|
+
|
|
436
|
+
const rowButton = expectAgentDrivable(canvas, {
|
|
437
|
+
name: "Edit Water the ferns",
|
|
438
|
+
role: "button",
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
await expect(rowButton.tagName).toBe("BUTTON")
|
|
442
|
+
|
|
443
|
+
await expect(rowButton).toHaveAttribute("type", "button")
|
|
444
|
+
|
|
445
|
+
// Nothing is nested inside it: the row's text is a sibling drawn
|
|
446
|
+
// over it, so there is no interactive content inside a button.
|
|
447
|
+
await expect(rowButton.childElementCount).toBe(0)
|
|
448
|
+
|
|
449
|
+
const row = rowOf(rowButton)
|
|
450
|
+
|
|
451
|
+
const buttonBox = rowButton.getBoundingClientRect()
|
|
452
|
+
|
|
453
|
+
// Full-area: the button covers the row's whole padding box — all
|
|
454
|
+
// of it but the 1px divider between rows.
|
|
455
|
+
await expect(buttonBox.width).toBeCloseTo(
|
|
456
|
+
row.clientWidth,
|
|
457
|
+
0,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
await expect(buttonBox.height).toBeCloseTo(
|
|
461
|
+
row.clientHeight,
|
|
462
|
+
0,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
// A pointer on the label, the description, the cap line or the
|
|
466
|
+
// payout lands on the row's button.
|
|
467
|
+
for (const text of [
|
|
468
|
+
"Water the ferns",
|
|
469
|
+
"water-ferns",
|
|
470
|
+
"Up to 5 pts a day",
|
|
471
|
+
"5 pts",
|
|
472
|
+
]) {
|
|
473
|
+
await expect(
|
|
474
|
+
elementAtCentreOf(within(row).getByText(text)),
|
|
475
|
+
).toBe(rowButton)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await userEvent.click(rowButton)
|
|
479
|
+
|
|
480
|
+
await expect(
|
|
481
|
+
canvas.getByRole("status"),
|
|
482
|
+
).toHaveTextContent("Opened Water the ferns")
|
|
483
|
+
|
|
484
|
+
await expectNoAxeViolations(canvasElement)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
test("a row's action stays its own target and never selects the row", async () => {
|
|
488
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
489
|
+
|
|
490
|
+
const deleteButton = expectAgentDrivable(canvas, {
|
|
491
|
+
name: "Delete Water the ferns",
|
|
492
|
+
role: "button",
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const hit = elementAtCentreOf(deleteButton)
|
|
496
|
+
|
|
497
|
+
await expect(
|
|
498
|
+
hit === deleteButton || deleteButton.contains(hit),
|
|
499
|
+
).toBe(true)
|
|
500
|
+
|
|
501
|
+
// A real click at the action's centre, with real hit-testing.
|
|
502
|
+
await browserUserEvent.click(deleteButton)
|
|
503
|
+
|
|
504
|
+
await expect(
|
|
505
|
+
canvas.getByRole("status"),
|
|
506
|
+
).toHaveTextContent("Deleted Water the ferns")
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
test("Tab reaches each row's button, then its action, in reading order, and Enter and Space open the row", async () => {
|
|
510
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
511
|
+
|
|
512
|
+
const order = [
|
|
513
|
+
"Edit Water the ferns",
|
|
514
|
+
"Delete Water the ferns",
|
|
515
|
+
"Edit Sort the recycling",
|
|
516
|
+
"Delete Sort the recycling",
|
|
517
|
+
"Edit Feed the goldfish",
|
|
518
|
+
"Delete Feed the goldfish",
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
for (const name of order) {
|
|
522
|
+
await userEvent.tab()
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
expectAgentDrivable(canvas, { name, role: "button" }),
|
|
526
|
+
).toHaveFocus()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const muted = canvas.getByRole("button", {
|
|
530
|
+
name: "Edit Feed the goldfish",
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
muted.focus()
|
|
534
|
+
|
|
535
|
+
await userEvent.keyboard("{Enter}")
|
|
536
|
+
|
|
537
|
+
await expect(
|
|
538
|
+
canvas.getByRole("status"),
|
|
539
|
+
).toHaveTextContent("Opened Feed the goldfish")
|
|
540
|
+
|
|
541
|
+
canvas
|
|
542
|
+
.getByRole("button", {
|
|
543
|
+
name: "Edit Sort the recycling",
|
|
544
|
+
})
|
|
545
|
+
.focus()
|
|
546
|
+
|
|
547
|
+
await userEvent.keyboard(" ")
|
|
548
|
+
|
|
549
|
+
await expect(
|
|
550
|
+
canvas.getByRole("status"),
|
|
551
|
+
).toHaveTextContent("Opened Sort the recycling")
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
test("keyboard focus draws the focus ring inside the row's edge", async () => {
|
|
555
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
556
|
+
|
|
557
|
+
// A real Tab, because only a trusted key press makes the browser
|
|
558
|
+
// match `:focus-visible`.
|
|
559
|
+
await browserUserEvent.tab()
|
|
560
|
+
|
|
561
|
+
const rowButton = expectAgentDrivable(canvas, {
|
|
562
|
+
name: "Edit Water the ferns",
|
|
563
|
+
role: "button",
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
await expect(rowButton).toHaveFocus()
|
|
567
|
+
|
|
568
|
+
const style = getComputedStyle(rowButton)
|
|
569
|
+
|
|
570
|
+
await expect(style.outlineStyle).toBe("solid")
|
|
571
|
+
|
|
572
|
+
// Inside the edge: the group's list clips its corners, and an
|
|
573
|
+
// outside ring would be cut off on the first and last row. One
|
|
574
|
+
// ring-width further in again, so it never shares pixels with the
|
|
575
|
+
// highlight ring that hugs the edge.
|
|
576
|
+
const ringWidth = Number.parseFloat(style.outlineWidth)
|
|
577
|
+
|
|
578
|
+
await expect(ringWidth).toBeGreaterThan(0)
|
|
579
|
+
|
|
580
|
+
await expect(
|
|
581
|
+
Number.parseFloat(style.outlineOffset),
|
|
582
|
+
).toBeCloseTo(-2 * ringWidth, 1)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test("hover tints the row and rings its edge; holding the row presses it", async () => {
|
|
586
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
587
|
+
|
|
588
|
+
const rowButton = expectAgentDrivable(canvas, {
|
|
589
|
+
name: "Edit Water the ferns",
|
|
590
|
+
role: "button",
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
const resting = getComputedStyle(rowButton)
|
|
594
|
+
|
|
595
|
+
const restingBackground = resting.backgroundColor
|
|
596
|
+
|
|
597
|
+
const restingShadow = resting.boxShadow
|
|
598
|
+
|
|
599
|
+
await browserUserEvent.hover(rowButton)
|
|
600
|
+
|
|
601
|
+
await waitFor(() => {
|
|
602
|
+
expect(
|
|
603
|
+
getComputedStyle(rowButton).backgroundColor,
|
|
604
|
+
).not.toBe(restingBackground)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
await expect(
|
|
608
|
+
getComputedStyle(rowButton).boxShadow,
|
|
609
|
+
).not.toBe(restingShadow)
|
|
610
|
+
|
|
611
|
+
await browserUserEvent.unhover(rowButton)
|
|
612
|
+
|
|
613
|
+
// Holding Space keeps a focused button `:active`, which is a press
|
|
614
|
+
// without a pointer — and it presses only the row whose button it
|
|
615
|
+
// is.
|
|
616
|
+
rowButton.focus()
|
|
617
|
+
|
|
618
|
+
await browserUserEvent.keyboard("{ >}")
|
|
619
|
+
|
|
620
|
+
const row = rowOf(rowButton)
|
|
621
|
+
|
|
622
|
+
// Tailwind's `scale-*` writes the individual `scale` property,
|
|
623
|
+
// not `transform`.
|
|
624
|
+
await waitFor(() => {
|
|
625
|
+
expect(getComputedStyle(row).scale).toBe("0.99")
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
await expect(
|
|
629
|
+
getComputedStyle(
|
|
630
|
+
rowOf(
|
|
631
|
+
canvas.getByRole("button", {
|
|
632
|
+
name: "Edit Sort the recycling",
|
|
633
|
+
}),
|
|
634
|
+
),
|
|
635
|
+
).scale,
|
|
636
|
+
).toBe("none")
|
|
637
|
+
|
|
638
|
+
await browserUserEvent.keyboard("{/ }")
|
|
639
|
+
|
|
640
|
+
await expect(
|
|
641
|
+
canvas.getByRole("status"),
|
|
642
|
+
).toHaveTextContent("Opened Water the ferns")
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
test("a muted row is a sunken surface with a quieter label, and still clears axe", async () => {
|
|
646
|
+
const { canvas, canvasElement } =
|
|
647
|
+
await mountStory(SelectableRows)
|
|
648
|
+
|
|
649
|
+
const mutedRow = rowOf(
|
|
650
|
+
canvas.getByRole("button", {
|
|
651
|
+
name: "Edit Feed the goldfish",
|
|
652
|
+
}),
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
const activeRow = rowOf(
|
|
656
|
+
canvas.getByRole("button", {
|
|
657
|
+
name: "Edit Water the ferns",
|
|
658
|
+
}),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
await expect(
|
|
662
|
+
getComputedStyle(mutedRow).backgroundColor,
|
|
663
|
+
).not.toBe(getComputedStyle(activeRow).backgroundColor)
|
|
664
|
+
|
|
665
|
+
await expect(
|
|
666
|
+
getComputedStyle(
|
|
667
|
+
within(mutedRow).getByText("Feed the goldfish"),
|
|
668
|
+
).color,
|
|
669
|
+
).not.toBe(
|
|
670
|
+
getComputedStyle(
|
|
671
|
+
within(activeRow).getByText("Water the ferns"),
|
|
672
|
+
).color,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
// Muted is not disabled: the row still opens.
|
|
676
|
+
await expect(
|
|
677
|
+
canvas.getByRole("button", {
|
|
678
|
+
name: "Edit Feed the goldfish",
|
|
679
|
+
}),
|
|
680
|
+
).toBeEnabled()
|
|
681
|
+
|
|
682
|
+
await expectNoAxeViolations(canvasElement)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
test("a highlighted row is ringed, and the ring lets the pointer through", async () => {
|
|
686
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
687
|
+
|
|
688
|
+
const rowButton = canvas.getByRole("button", {
|
|
689
|
+
name: "Edit Sort the recycling",
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const row = rowOf(rowButton)
|
|
693
|
+
|
|
694
|
+
const ring = row.querySelector(
|
|
695
|
+
".charcuterie-highlight",
|
|
696
|
+
) as HTMLElement
|
|
697
|
+
|
|
698
|
+
await expect(ring).not.toBeNull()
|
|
699
|
+
|
|
700
|
+
await expect(ring).toHaveAttribute("aria-hidden", "true")
|
|
701
|
+
|
|
702
|
+
await expect(getComputedStyle(ring).boxShadow).toContain(
|
|
703
|
+
"inset",
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
await expect(
|
|
707
|
+
elementAtCentreOf(within(row).getByText("2 pts")),
|
|
708
|
+
).toBe(rowButton)
|
|
709
|
+
|
|
710
|
+
// Only the row that asked for it.
|
|
711
|
+
await expect(
|
|
712
|
+
rowOf(
|
|
713
|
+
canvas.getByRole("button", {
|
|
714
|
+
name: "Edit Water the ferns",
|
|
715
|
+
}),
|
|
716
|
+
).querySelector(".charcuterie-highlight"),
|
|
717
|
+
).toBeNull()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
test("the aside sits beside the text in a wide list and never pushes the row past its list", async () => {
|
|
721
|
+
const { canvas } = await mountStory(SelectableRows)
|
|
722
|
+
|
|
723
|
+
const list = expectAgentDrivable(canvas, {
|
|
724
|
+
name: "Team Orion",
|
|
725
|
+
role: "list",
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
await expect(list.scrollWidth).toBeLessThanOrEqual(
|
|
729
|
+
list.clientWidth,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
const row = rowOf(
|
|
733
|
+
canvas.getByRole("button", {
|
|
734
|
+
name: "Edit Water the ferns",
|
|
735
|
+
}),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
const label = within(row).getByText("Water the ferns")
|
|
739
|
+
|
|
740
|
+
const capLine = within(row).getByText("Up to 5 pts a day")
|
|
741
|
+
|
|
742
|
+
const payout = within(row).getByText("Per scan")
|
|
743
|
+
|
|
744
|
+
// 12rem of text, the payout and the action fit on one line well
|
|
745
|
+
// inside 40rem; below that the content decides.
|
|
746
|
+
if (list.clientWidth >= 640) {
|
|
747
|
+
await expect(
|
|
748
|
+
payout.getBoundingClientRect().top,
|
|
749
|
+
).toBeLessThan(capLine.getBoundingClientRect().bottom)
|
|
750
|
+
|
|
751
|
+
await expect(
|
|
752
|
+
payout.getBoundingClientRect().left,
|
|
753
|
+
).toBeGreaterThan(label.getBoundingClientRect().right)
|
|
754
|
+
} else {
|
|
755
|
+
await expect(
|
|
756
|
+
payout.getBoundingClientRect().top,
|
|
757
|
+
).toBeGreaterThanOrEqual(
|
|
758
|
+
capLine.getBoundingClientRect().bottom,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
test("every row state passes axe in both schemes", async () => {
|
|
764
|
+
const { canvas, canvasElement } =
|
|
765
|
+
await mountStory(RowStates)
|
|
766
|
+
|
|
767
|
+
expectAgentDrivable(canvas, {
|
|
768
|
+
name: "Highlight intents",
|
|
769
|
+
role: "list",
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
// The canvas paints the scheme's own page surface, so the headings
|
|
773
|
+
// are measured against the background they would really sit on.
|
|
774
|
+
canvasElement.classList.add("bg-surface-base")
|
|
775
|
+
|
|
776
|
+
for (const scheme of ["light", "dark"]) {
|
|
777
|
+
canvasElement.setAttribute("data-scheme", scheme)
|
|
778
|
+
|
|
779
|
+
await expectNoAxeViolations(canvasElement)
|
|
780
|
+
}
|
|
781
|
+
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
3
|
import type { ReactNode } from "react"
|
|
3
4
|
|
|
4
5
|
import { toClassName } from "../toClassName.ts"
|
|
5
6
|
import { GroupedListRow } from "./GroupedListRow.tsx"
|
|
7
|
+
import { STICKY_HEADING_CLASS } from "./groupedListStickyHeading.ts"
|
|
6
8
|
|
|
7
9
|
export type GroupedListHeadingLevel = 2 | 3 | 4 | 5 | 6
|
|
8
10
|
|
|
@@ -17,24 +19,86 @@ export type GroupedListItem = {
|
|
|
17
19
|
* **Name a repeated action after its row.** Ten rows of "Open" are
|
|
18
20
|
* ten buttons an agent cannot tell apart; `aria-label="Open Lobby"`
|
|
19
21
|
* on each is one `getByRole` away.
|
|
22
|
+
*
|
|
23
|
+
* In a row with `onSelect` these stay their own targets: they sit
|
|
24
|
+
* above the whole-row button, so pressing one never selects the
|
|
25
|
+
* row.
|
|
20
26
|
*/
|
|
21
27
|
actions?: ReactNode
|
|
28
|
+
/**
|
|
29
|
+
* A figure that belongs beside the text rather than under it — a
|
|
30
|
+
* payout, a total, a schedule. It sits between the text and the
|
|
31
|
+
* actions in a wide list and drops under the text in a narrow one,
|
|
32
|
+
* the same content-decided wrap the actions use: it asks for at
|
|
33
|
+
* least 11rem beside the text column's 12rem. Never interactive:
|
|
34
|
+
* in a row with `onSelect` the whole-row button sits over it.
|
|
35
|
+
*/
|
|
36
|
+
aside?: ReactNode
|
|
22
37
|
/** `Badge`s, drawn after the label and wrapping onto their own line when they run out of room. */
|
|
23
38
|
badges?: ReactNode
|
|
24
39
|
/**
|
|
25
40
|
* Anything richer than one line of help — a payout, a rule list.
|
|
26
41
|
* Drawn under the description, inside the text column, and never
|
|
27
|
-
* truncated.
|
|
42
|
+
* truncated. Never interactive in a row with `onSelect`; a control
|
|
43
|
+
* belongs in `actions`.
|
|
28
44
|
*/
|
|
29
45
|
content?: ReactNode
|
|
30
46
|
/** One muted line under the label: a path, a key, a count. Truncated like the label. */
|
|
31
47
|
description?: string
|
|
48
|
+
/**
|
|
49
|
+
* The intent `isHighlighted` rings the row in. `accent` by default;
|
|
50
|
+
* an app whose highlight means "this just paid out" passes
|
|
51
|
+
* `success`.
|
|
52
|
+
*/
|
|
53
|
+
highlightIntent?: IntentName
|
|
54
|
+
/**
|
|
55
|
+
* `true` rings the row and pulses the ring twice — "this one just
|
|
56
|
+
* changed", for a row a scan, a save or an arrival has touched.
|
|
57
|
+
* The ring stays while the flag does, so the app decides how long
|
|
58
|
+
* it lasts, usually by clearing it on a timer.
|
|
59
|
+
*
|
|
60
|
+
* **To pulse a row that is already highlighted again, change its
|
|
61
|
+
* `key`.** A new key is a new row, and a new row starts the pulse
|
|
62
|
+
* from the beginning; `` `${id}-${sequence}` `` is the usual shape.
|
|
63
|
+
*
|
|
64
|
+
* Under `prefers-reduced-motion` and on ePaper the ring does not
|
|
65
|
+
* pulse; it is simply there.
|
|
66
|
+
*/
|
|
67
|
+
isHighlighted?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* `true` draws the row receded — a sunken surface and a quieter
|
|
70
|
+
* label — for an entry that exists but is switched off. It stays
|
|
71
|
+
* fully operable and every colour on it still clears AA, which is
|
|
72
|
+
* why it is a surface and a text role rather than an opacity: a
|
|
73
|
+
* faded row is not a disabled one, and only a disabled control may
|
|
74
|
+
* drop below the contrast floor. Say WHY in a badge too ("off").
|
|
75
|
+
*/
|
|
76
|
+
isMuted?: boolean
|
|
32
77
|
key: string
|
|
33
78
|
/**
|
|
34
79
|
* Plain text, because it is what the row is called — the thing a
|
|
35
80
|
* search matched and the text a clipped row's tooltip repeats.
|
|
36
81
|
*/
|
|
37
82
|
label: string
|
|
83
|
+
/**
|
|
84
|
+
* Makes the whole row one native `<button>` — "open this one" for a
|
|
85
|
+
* row whose primary action is the row itself. Hover tints the row
|
|
86
|
+
* and rings its edge, a press sinks it (with motion only when the
|
|
87
|
+
* user has not asked for less), and keyboard focus draws the focus
|
|
88
|
+
* ring inside the row's edge. `actions` stay separate targets
|
|
89
|
+
* above it.
|
|
90
|
+
*
|
|
91
|
+
* The button is drawn UNDER the row's text and the text lets the
|
|
92
|
+
* pointer through, so nothing interactive is ever nested inside a
|
|
93
|
+
* button: keep `badges`, `content` and `aside` free of controls.
|
|
94
|
+
*/
|
|
95
|
+
onSelect?: () => void
|
|
96
|
+
/**
|
|
97
|
+
* The whole-row button's accessible name. Defaults to `label`.
|
|
98
|
+
* Name the verb when there is one — `"Edit Water the ferns"` —
|
|
99
|
+
* the same rule as a repeated action.
|
|
100
|
+
*/
|
|
101
|
+
selectLabel?: string
|
|
38
102
|
}
|
|
39
103
|
|
|
40
104
|
export type GroupedListGroup = {
|
|
@@ -65,6 +129,24 @@ export type GroupedListProps = {
|
|
|
65
129
|
* a document-outline break no gate here can see.
|
|
66
130
|
*/
|
|
67
131
|
headingLevel?: GroupedListHeadingLevel
|
|
132
|
+
/**
|
|
133
|
+
* `true` pins each group's heading to the top of the nearest
|
|
134
|
+
* scrolling ancestor while that group's rows scroll under it, and
|
|
135
|
+
* the next group's heading pushes it away. For a list inside a
|
|
136
|
+
* scrolling pane, where the heading is the only thing that says
|
|
137
|
+
* which screen or team a row belongs to once it scrolls out of
|
|
138
|
+
* sight. `false` (the default) scrolls the heading with its rows.
|
|
139
|
+
*
|
|
140
|
+
* A stuck heading needs an opaque background or the rows show
|
|
141
|
+
* through it, and no one surface fits every host — a page is
|
|
142
|
+
* `surface-base`, a settings pane is `surface-raised`. So the
|
|
143
|
+
* background is read from `--charcuterie-grouped-list-heading-surface`,
|
|
144
|
+
* which falls back to `--color-surface-base`. Set it on any
|
|
145
|
+
* ancestor to match the surface the list sits on. Under a sticky
|
|
146
|
+
* page `Header`, set `--charcuterie-grouped-list-heading-offset`
|
|
147
|
+
* to the header's height so the headings stop below it.
|
|
148
|
+
*/
|
|
149
|
+
isHeadingSticky?: boolean
|
|
68
150
|
/**
|
|
69
151
|
* `true` (the default) clips a long label or description to one
|
|
70
152
|
* line, with the full text as a tooltip while it is clipped.
|
|
@@ -122,6 +204,27 @@ const HEADING_TAG: Record<
|
|
|
122
204
|
* component does it, and when every group is empty it draws
|
|
123
205
|
* `emptyState` instead.
|
|
124
206
|
*
|
|
207
|
+
* ### A sticky heading is opt-in
|
|
208
|
+
*
|
|
209
|
+
* castkit's copy pinned its headings, and a pinned heading needs a
|
|
210
|
+
* background that matches whatever it sits on. The component cannot
|
|
211
|
+
* know that surface, so `isHeadingSticky` reads it from a custom
|
|
212
|
+
* property the host can set, falling back to `surface-base`.
|
|
213
|
+
*
|
|
214
|
+
* ### A row can be the target
|
|
215
|
+
*
|
|
216
|
+
* tally-marks' card list opens a card's editor from anywhere on the
|
|
217
|
+
* card, and the owner asked for that to feel like an app: hover,
|
|
218
|
+
* press, focus, touch. `onSelect` is that, as one real `<button>`
|
|
219
|
+
* stretched under the row's text — not a click handler on an
|
|
220
|
+
* `<li>`, which no keyboard reaches and no screen reader announces.
|
|
221
|
+
* The text lets the pointer through to it; the actions sit above it
|
|
222
|
+
* and keep their own targets, so "Delete" never opens the editor.
|
|
223
|
+
*
|
|
224
|
+
* A row can also be muted (switched off but still there) and
|
|
225
|
+
* highlighted (just touched by a scan or a save) — the two other
|
|
226
|
+
* per-row states the same list drew by hand.
|
|
227
|
+
*
|
|
125
228
|
* ### Not collapsible
|
|
126
229
|
*
|
|
127
230
|
* A group you can close is `Accordion`. This is for a list that is
|
|
@@ -132,6 +235,7 @@ export const GroupedList = ({
|
|
|
132
235
|
emptyState,
|
|
133
236
|
groups,
|
|
134
237
|
headingLevel = 3,
|
|
238
|
+
isHeadingSticky = false,
|
|
135
239
|
isTextTruncated = true,
|
|
136
240
|
size = "md",
|
|
137
241
|
}: GroupedListProps): ReactNode => {
|
|
@@ -158,10 +262,18 @@ export const GroupedList = ({
|
|
|
158
262
|
|
|
159
263
|
return (
|
|
160
264
|
<div
|
|
161
|
-
className=
|
|
265
|
+
className={toClassName(
|
|
266
|
+
"flex flex-col",
|
|
267
|
+
isHeadingSticky ? "gap-0" : "gap-2",
|
|
268
|
+
)}
|
|
162
269
|
key={group.key}
|
|
163
270
|
>
|
|
164
|
-
<div
|
|
271
|
+
<div
|
|
272
|
+
className={toClassName(
|
|
273
|
+
"flex flex-wrap items-baseline gap-x-3 gap-y-0.5",
|
|
274
|
+
isHeadingSticky && STICKY_HEADING_CLASS,
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
165
277
|
<Heading
|
|
166
278
|
className={toClassName(
|
|
167
279
|
"m-0 min-w-0 wrap-anywhere font-semibold text-content-primary",
|