@charcuterie/ui 4.24.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 +78 -1
- package/dist/GroupedList/GroupedList.d.ts.map +1 -1
- package/dist/GroupedList/GroupedList.js +14 -0
- 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/styles.css +36 -4
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +42 -1
- package/src/GroupedList/GroupedList.stories.tsx +252 -2
- package/src/GroupedList/GroupedList.test.tsx +391 -0
- package/src/GroupedList/GroupedList.tsx +78 -1
- package/src/GroupedList/GroupedListRow.tsx +135 -10
- package/src/styles.css +36 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
-
import type
|
|
3
|
+
import { type CSSProperties, useState } from "react"
|
|
4
4
|
|
|
5
5
|
import { Badge } from "../Badge/Badge.tsx"
|
|
6
6
|
import { Button } from "../Button/Button.tsx"
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
StoryGrid,
|
|
11
11
|
} from "../board.storyHelpers.tsx"
|
|
12
12
|
import { EmptyState } from "../EmptyState/EmptyState.tsx"
|
|
13
|
-
import
|
|
13
|
+
import { ValuePairs } from "../ValuePairs/ValuePairs.tsx"
|
|
14
|
+
import type {
|
|
15
|
+
GroupedListGroup,
|
|
16
|
+
GroupedListItem,
|
|
17
|
+
} from "./GroupedList.tsx"
|
|
14
18
|
import { GroupedList } from "./GroupedList.tsx"
|
|
15
19
|
import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts"
|
|
16
20
|
|
|
@@ -183,6 +187,142 @@ const CHORE_GROUPS: GroupedListGroup[] = [
|
|
|
183
187
|
},
|
|
184
188
|
]
|
|
185
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Fixture data only. A chore card whose whole row opens its editor:
|
|
192
|
+
* the shape tally-marks' Rows view draws, with the payout beside the
|
|
193
|
+
* text and the daily cap under it.
|
|
194
|
+
*/
|
|
195
|
+
type ChoreCard = {
|
|
196
|
+
isActive: boolean
|
|
197
|
+
isHighlighted?: boolean
|
|
198
|
+
key: string
|
|
199
|
+
label: string
|
|
200
|
+
maxPerDay: number
|
|
201
|
+
points: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const CHORE_CARDS: ChoreCard[] = [
|
|
205
|
+
{
|
|
206
|
+
isActive: true,
|
|
207
|
+
key: "water-ferns",
|
|
208
|
+
label: "Water the ferns",
|
|
209
|
+
maxPerDay: 5,
|
|
210
|
+
points: 5,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
isActive: true,
|
|
214
|
+
isHighlighted: true,
|
|
215
|
+
key: "sort-recycling",
|
|
216
|
+
label: "Sort the recycling",
|
|
217
|
+
maxPerDay: 6,
|
|
218
|
+
points: 2,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
isActive: false,
|
|
222
|
+
key: "feed-goldfish",
|
|
223
|
+
label: "Feed the goldfish",
|
|
224
|
+
maxPerDay: 2,
|
|
225
|
+
points: 2,
|
|
226
|
+
},
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One chore card as a row. `onSelect` opens it; the action beside it
|
|
231
|
+
* is a separate target. A switched-off card is muted and says so in
|
|
232
|
+
* a badge.
|
|
233
|
+
*/
|
|
234
|
+
const toChoreRow = (
|
|
235
|
+
card: ChoreCard,
|
|
236
|
+
{
|
|
237
|
+
onDelete,
|
|
238
|
+
onSelect,
|
|
239
|
+
}: {
|
|
240
|
+
onDelete?: (label: string) => void
|
|
241
|
+
onSelect?: (label: string) => void
|
|
242
|
+
} = {},
|
|
243
|
+
): GroupedListItem => ({
|
|
244
|
+
actions: (
|
|
245
|
+
<Button
|
|
246
|
+
appearance="ghost"
|
|
247
|
+
aria-label={`Delete ${card.label}`}
|
|
248
|
+
intent="danger"
|
|
249
|
+
onClick={() => {
|
|
250
|
+
onDelete?.(card.label)
|
|
251
|
+
}}
|
|
252
|
+
size="sm"
|
|
253
|
+
>
|
|
254
|
+
Delete
|
|
255
|
+
</Button>
|
|
256
|
+
),
|
|
257
|
+
aside: (
|
|
258
|
+
<ValuePairs
|
|
259
|
+
items={[
|
|
260
|
+
{
|
|
261
|
+
id: "scan",
|
|
262
|
+
label: "Per scan",
|
|
263
|
+
value: `${card.points} pts`,
|
|
264
|
+
},
|
|
265
|
+
]}
|
|
266
|
+
label="Payout"
|
|
267
|
+
/>
|
|
268
|
+
),
|
|
269
|
+
badges: card.isActive ? undefined : (
|
|
270
|
+
<Badge appearance="soft" size="sm">
|
|
271
|
+
off
|
|
272
|
+
</Badge>
|
|
273
|
+
),
|
|
274
|
+
content: `Up to ${card.maxPerDay} pts a day`,
|
|
275
|
+
description: card.key,
|
|
276
|
+
highlightIntent: "success",
|
|
277
|
+
isHighlighted: card.isHighlighted,
|
|
278
|
+
isMuted: !card.isActive,
|
|
279
|
+
key: card.key,
|
|
280
|
+
label: card.label,
|
|
281
|
+
onSelect: () => {
|
|
282
|
+
onSelect?.(card.label)
|
|
283
|
+
},
|
|
284
|
+
selectLabel: `Edit ${card.label}`,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const toChoreGroups = (
|
|
288
|
+
handlers?: Parameters<typeof toChoreRow>[1],
|
|
289
|
+
): GroupedListGroup[] => [
|
|
290
|
+
{
|
|
291
|
+
heading: "Team Orion",
|
|
292
|
+
items: CHORE_CARDS.map((card) =>
|
|
293
|
+
toChoreRow(card, handlers),
|
|
294
|
+
),
|
|
295
|
+
key: "orion",
|
|
296
|
+
summary: "3 cards · up to 11 pts a day",
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Fixture data only. The same plain row in each highlight intent.
|
|
302
|
+
*/
|
|
303
|
+
const HIGHLIGHT_INTENT_GROUPS: GroupedListGroup[] = [
|
|
304
|
+
{
|
|
305
|
+
heading: "Highlight intents",
|
|
306
|
+
items: (
|
|
307
|
+
[
|
|
308
|
+
"accent",
|
|
309
|
+
"success",
|
|
310
|
+
"info",
|
|
311
|
+
"warning",
|
|
312
|
+
"danger",
|
|
313
|
+
"neutral",
|
|
314
|
+
] as const
|
|
315
|
+
).map((intent) => ({
|
|
316
|
+
description: `highlightIntent="${intent}"`,
|
|
317
|
+
highlightIntent: intent,
|
|
318
|
+
isHighlighted: true,
|
|
319
|
+
key: intent,
|
|
320
|
+
label: `Highlighted ${intent}`,
|
|
321
|
+
})),
|
|
322
|
+
key: "intents",
|
|
323
|
+
},
|
|
324
|
+
]
|
|
325
|
+
|
|
186
326
|
const LONG_GROUPS: GroupedListGroup[] = [
|
|
187
327
|
{
|
|
188
328
|
heading:
|
|
@@ -524,6 +664,116 @@ export const StickyHeadings: Story = {
|
|
|
524
664
|
),
|
|
525
665
|
}
|
|
526
666
|
|
|
667
|
+
/**
|
|
668
|
+
* A row whose whole surface is its primary action. `onSelect` draws
|
|
669
|
+
* one native button under the row's text, named by `selectLabel`;
|
|
670
|
+
* the Delete action sits above it as its own target. The status
|
|
671
|
+
* line under the list says which one a click reached.
|
|
672
|
+
*
|
|
673
|
+
* The second card is highlighted (a scan just paid it), and the
|
|
674
|
+
* third is switched off, so it is muted.
|
|
675
|
+
*/
|
|
676
|
+
export const SelectableRows: Story = {
|
|
677
|
+
render: function SelectableRowsStory() {
|
|
678
|
+
const [lastEvent, setLastEvent] = useState(
|
|
679
|
+
"Nothing chosen yet",
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="flex flex-col gap-3">
|
|
684
|
+
<GroupedList
|
|
685
|
+
groups={toChoreGroups({
|
|
686
|
+
onDelete: (label) => {
|
|
687
|
+
setLastEvent(`Deleted ${label}`)
|
|
688
|
+
},
|
|
689
|
+
onSelect: (label) => {
|
|
690
|
+
setLastEvent(`Opened ${label}`)
|
|
691
|
+
},
|
|
692
|
+
})}
|
|
693
|
+
/>
|
|
694
|
+
|
|
695
|
+
<p
|
|
696
|
+
className="m-0 text-content-secondary text-sm"
|
|
697
|
+
role="status"
|
|
698
|
+
>
|
|
699
|
+
{lastEvent}
|
|
700
|
+
</p>
|
|
701
|
+
</div>
|
|
702
|
+
)
|
|
703
|
+
},
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Every per-row state at once: hover, press and keyboard focus on
|
|
708
|
+
* the whole-row button (forced by the pseudo-states addon, the only
|
|
709
|
+
* way to review them in a static board), a muted row, and a
|
|
710
|
+
* highlight in each intent. The highlight's pulse is frozen here, so
|
|
711
|
+
* this is also its static, reduced-motion look.
|
|
712
|
+
*/
|
|
713
|
+
export const RowStates: Story = {
|
|
714
|
+
parameters: {
|
|
715
|
+
pseudo: {
|
|
716
|
+
active: ["#active-row li:first-child > button"],
|
|
717
|
+
focusVisible: ["#focus-row li:first-child > button"],
|
|
718
|
+
hover: ["#hover-row li:first-child > button"],
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
render: () => (
|
|
722
|
+
<StoryGrid columns={2}>
|
|
723
|
+
{(
|
|
724
|
+
[
|
|
725
|
+
["resting", "resting-row"],
|
|
726
|
+
["hover (forced)", "hover-row"],
|
|
727
|
+
["active (forced)", "active-row"],
|
|
728
|
+
["focus-visible (forced)", "focus-row"],
|
|
729
|
+
] as const
|
|
730
|
+
).map(([label, id]) => (
|
|
731
|
+
<StoryCell align="stretch" key={id} label={label}>
|
|
732
|
+
<div id={id}>
|
|
733
|
+
<GroupedList
|
|
734
|
+
groups={[
|
|
735
|
+
{
|
|
736
|
+
heading: `Team Orion, ${label}`,
|
|
737
|
+
items: CHORE_CARDS.slice(0, 1).map(
|
|
738
|
+
(card) => toChoreRow(card),
|
|
739
|
+
),
|
|
740
|
+
key: id,
|
|
741
|
+
},
|
|
742
|
+
]}
|
|
743
|
+
size="sm"
|
|
744
|
+
/>
|
|
745
|
+
</div>
|
|
746
|
+
</StoryCell>
|
|
747
|
+
))}
|
|
748
|
+
|
|
749
|
+
<StoryCell align="stretch" label="isMuted">
|
|
750
|
+
<GroupedList
|
|
751
|
+
groups={[
|
|
752
|
+
{
|
|
753
|
+
heading: "Switched off",
|
|
754
|
+
items: [
|
|
755
|
+
toChoreRow(CHORE_CARDS[2] as ChoreCard),
|
|
756
|
+
],
|
|
757
|
+
key: "muted",
|
|
758
|
+
},
|
|
759
|
+
]}
|
|
760
|
+
size="sm"
|
|
761
|
+
/>
|
|
762
|
+
</StoryCell>
|
|
763
|
+
|
|
764
|
+
<StoryCell
|
|
765
|
+
align="stretch"
|
|
766
|
+
label="isHighlighted, each highlightIntent"
|
|
767
|
+
>
|
|
768
|
+
<GroupedList
|
|
769
|
+
groups={HIGHLIGHT_INTENT_GROUPS}
|
|
770
|
+
size="sm"
|
|
771
|
+
/>
|
|
772
|
+
</StoryCell>
|
|
773
|
+
</StoryGrid>
|
|
774
|
+
),
|
|
775
|
+
}
|
|
776
|
+
|
|
527
777
|
/**
|
|
528
778
|
* Light and dark side by side, each panel scoped with its own
|
|
529
779
|
* `data-scheme`, so both read at once whatever the toolbar says.
|
|
@@ -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,8 @@ const {
|
|
|
25
26
|
NarrowView,
|
|
26
27
|
Playground,
|
|
27
28
|
Responsive,
|
|
29
|
+
RowStates,
|
|
30
|
+
SelectableRows,
|
|
28
31
|
StickyHeadings,
|
|
29
32
|
} = composeStories(stories)
|
|
30
33
|
|
|
@@ -388,3 +391,391 @@ test("a stuck heading's background is the surface its host names, and surface-ba
|
|
|
388
391
|
).backgroundColor,
|
|
389
392
|
)
|
|
390
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
|
+
})
|