@charcuterie/ui 4.22.1 → 4.24.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.
@@ -0,0 +1,390 @@
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import {
6
+ expect,
7
+ userEvent,
8
+ waitFor,
9
+ within,
10
+ } from "storybook/test"
11
+ import { test } from "vitest"
12
+
13
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
14
+ import { mountStory } from "../mountStory.testHelpers.ts"
15
+ import { expectAgentDrivable } from "../testing/index.ts"
16
+ import meta, * as stories from "./GroupedList.stories.tsx"
17
+
18
+ const {
19
+ AllStates,
20
+ AllVariants,
21
+ ColorSchemes,
22
+ Empty,
23
+ Interactive,
24
+ LongText,
25
+ NarrowView,
26
+ Playground,
27
+ Responsive,
28
+ StickyHeadings,
29
+ } = composeStories(stories)
30
+
31
+ test("a group's heading names its list", async () => {
32
+ const { canvas, canvasElement } =
33
+ await mountStory(Playground)
34
+
35
+ // The heading labels the `<ul>` rather than a `<section>`: a named
36
+ // section is a landmark, and one per group is the landmark
37
+ // proliferation the APG warns about.
38
+ const screens = expectAgentDrivable(canvas, {
39
+ name: "Screens",
40
+ role: "list",
41
+ })
42
+
43
+ await expect(
44
+ within(screens).getAllByRole("listitem"),
45
+ ).toHaveLength(2)
46
+
47
+ await expect(
48
+ canvasElement.querySelector("section"),
49
+ ).toBeNull()
50
+
51
+ await expect(
52
+ canvas.getByRole("heading", {
53
+ level: 3,
54
+ name: "Screens",
55
+ }),
56
+ ).toBeVisible()
57
+
58
+ expectAgentDrivable(canvas, {
59
+ name: "Open Lobby",
60
+ role: "button",
61
+ })
62
+
63
+ await expectNoAxeViolations(canvasElement)
64
+ })
65
+
66
+ test("the heading level is the caller's", async () => {
67
+ const { canvas } = await mountStory(
68
+ composeStory({ args: { headingLevel: 2 } }, meta),
69
+ )
70
+
71
+ const heading = expectAgentDrivable(canvas, {
72
+ name: "Not on a screen",
73
+ role: "heading",
74
+ })
75
+
76
+ await expect(heading.tagName).toBe("H2")
77
+ })
78
+
79
+ test("an empty group is not drawn", async () => {
80
+ const { canvas, canvasElement } =
81
+ await mountStory(AllStates)
82
+
83
+ await expect(
84
+ canvas.queryByRole("heading", { name: "Never drawn" }),
85
+ ).toBeNull()
86
+
87
+ expectAgentDrivable(canvas, {
88
+ name: "Drawn",
89
+ role: "list",
90
+ })
91
+
92
+ // The last cell has no group with an item, so it draws its
93
+ // `emptyState` and no list at all.
94
+ expectAgentDrivable(canvas, {
95
+ name: "No page matches",
96
+ role: "heading",
97
+ })
98
+
99
+ await expectNoAxeViolations(canvasElement)
100
+ })
101
+
102
+ test("a list with nothing in it draws the caller's empty state", async () => {
103
+ const { canvas, canvasElement } = await mountStory(Empty)
104
+
105
+ expectAgentDrivable(canvas, {
106
+ name: "This server has no pages",
107
+ role: "heading",
108
+ })
109
+
110
+ await expect(canvas.queryByRole("list")).toBeNull()
111
+
112
+ await expectNoAxeViolations(canvasElement)
113
+ })
114
+
115
+ test("a clipped label carries its full text as a tooltip, and a short one has none", async () => {
116
+ const { canvas, canvasElement } =
117
+ await mountStory(LongText)
118
+
119
+ const label = canvas.getByText(
120
+ "Quarterly All-Hands Countdown and Seating Chart",
121
+ )
122
+
123
+ await waitFor(() => {
124
+ expect(label).toHaveAttribute(
125
+ "title",
126
+ "Quarterly All-Hands Countdown and Seating Chart",
127
+ )
128
+ })
129
+
130
+ await expect(label.scrollWidth).toBeGreaterThan(
131
+ label.clientWidth,
132
+ )
133
+
134
+ const path = canvas.getByText(
135
+ "/views/quarterly-all-hands-countdown-and-seating-chart-for-the-north-atrium",
136
+ )
137
+
138
+ await waitFor(() => {
139
+ expect(path).toHaveAttribute("title")
140
+ })
141
+
142
+ await expect(canvas.getByText("Map")).not.toHaveAttribute(
143
+ "title",
144
+ )
145
+
146
+ expectAgentDrivable(canvas, {
147
+ name: "Open Map",
148
+ role: "button",
149
+ })
150
+
151
+ await expectNoAxeViolations(canvasElement)
152
+ })
153
+
154
+ test("isTextTruncated={false} wraps the whole label instead", async () => {
155
+ const { canvas } = await mountStory(AllVariants)
156
+
157
+ // The third cell truncates, the fourth wraps; same fixture.
158
+ const [truncated, wrapped] = canvas.getAllByText(
159
+ "Quarterly All-Hands Countdown and Seating Chart",
160
+ ) as [HTMLElement, HTMLElement]
161
+
162
+ await waitFor(() => {
163
+ expect(truncated).toHaveAttribute("title")
164
+ })
165
+
166
+ await expect(wrapped.scrollWidth).toBeLessThanOrEqual(
167
+ wrapped.clientWidth,
168
+ )
169
+
170
+ await expect(wrapped).not.toHaveAttribute("title")
171
+
172
+ await expect(
173
+ canvas.getAllByRole("list", {
174
+ name: "On the Conference Center North Atrium Wayfinding screen",
175
+ }),
176
+ ).toHaveLength(2)
177
+ })
178
+
179
+ test("Tab walks the actions in reading order", async () => {
180
+ const { canvas } = await mountStory(Interactive)
181
+
182
+ const order = [
183
+ "Open Lobby",
184
+ "Open Break Room",
185
+ "Open Welcome",
186
+ "Open Weather",
187
+ "Open Schedule",
188
+ "Open Test Pattern",
189
+ ]
190
+
191
+ for (const name of order) {
192
+ await userEvent.tab()
193
+
194
+ await expect(
195
+ expectAgentDrivable(canvas, { name, role: "button" }),
196
+ ).toHaveFocus()
197
+ }
198
+ })
199
+
200
+ /**
201
+ * The text column asks for 12rem and grows. In the 15rem panel the
202
+ * actions cannot fit beside it, so they drop under the text; in the
203
+ * 34rem panel they sit on the same line.
204
+ */
205
+ test("the actions wrap under the text when the list is narrow", async () => {
206
+ const { canvas } = await mountStory(Responsive)
207
+
208
+ const labels = canvas.getAllByText("Water the ferns")
209
+
210
+ const buttons = canvas.getAllByRole("button", {
211
+ name: "Edit Water the ferns",
212
+ })
213
+
214
+ const [narrowLabel, , wideLabel] = labels as [
215
+ HTMLElement,
216
+ HTMLElement,
217
+ HTMLElement,
218
+ ]
219
+
220
+ const [narrowButton, , wideButton] = buttons as [
221
+ HTMLElement,
222
+ HTMLElement,
223
+ HTMLElement,
224
+ ]
225
+
226
+ await expect(
227
+ narrowButton.getBoundingClientRect().top,
228
+ ).toBeGreaterThanOrEqual(
229
+ narrowLabel.getBoundingClientRect().bottom,
230
+ )
231
+
232
+ await expect(
233
+ wideButton.getBoundingClientRect().top,
234
+ ).toBeLessThan(wideLabel.getBoundingClientRect().bottom)
235
+
236
+ // Nothing pushes past its panel: no row is wider than its list.
237
+ for (const list of canvas.getAllByRole("list", {
238
+ name: "Team Orion",
239
+ })) {
240
+ await expect(list.scrollWidth).toBeLessThanOrEqual(
241
+ list.clientWidth,
242
+ )
243
+ }
244
+ })
245
+
246
+ test("the Narrow View keeps every row inside the window", async () => {
247
+ const { canvas, canvasElement } =
248
+ await mountStory(NarrowView)
249
+
250
+ const list = expectAgentDrivable(canvas, {
251
+ name: "Team Orion",
252
+ role: "list",
253
+ })
254
+
255
+ await expect(list.scrollWidth).toBeLessThanOrEqual(
256
+ list.clientWidth,
257
+ )
258
+
259
+ await expectNoAxeViolations(canvasElement)
260
+ })
261
+
262
+ test("both schemes pass axe, contrast included", async () => {
263
+ const { canvas, canvasElement } =
264
+ await mountStory(ColorSchemes)
265
+
266
+ expectAgentDrivable(canvas, {
267
+ name: "Open dark Lobby",
268
+ role: "button",
269
+ })
270
+
271
+ await expectNoAxeViolations(canvasElement)
272
+ })
273
+
274
+ /**
275
+ * The element `isHeadingSticky` pins: the heading's row, which also
276
+ * holds the summary.
277
+ */
278
+ const headingRowOf = (heading: HTMLElement) =>
279
+ heading.parentElement as HTMLElement
280
+
281
+ test("a heading scrolls with its rows unless isHeadingSticky is set", async () => {
282
+ const { canvas } = await mountStory(Playground)
283
+
284
+ const heading = expectAgentDrivable(canvas, {
285
+ name: "Screens",
286
+ role: "heading",
287
+ })
288
+
289
+ await expect(
290
+ getComputedStyle(headingRowOf(heading)).position,
291
+ ).toBe("static")
292
+ })
293
+
294
+ test("a sticky heading stays at the top of its pane while its rows scroll under it", async () => {
295
+ const { canvas, canvasElement } =
296
+ await mountStory(StickyHeadings)
297
+
298
+ const pane = expectAgentDrivable(canvas, {
299
+ name: "Pages on a raised surface",
300
+ role: "region",
301
+ })
302
+
303
+ const paneCanvas = within(pane)
304
+
305
+ const heading = paneCanvas.getByRole("heading", {
306
+ name: "On the Lobby screen",
307
+ })
308
+
309
+ const list = paneCanvas.getByRole("list", {
310
+ name: "On the Lobby screen",
311
+ })
312
+
313
+ // The space under a stuck heading is its own padding, not a
314
+ // transparent gap the rows would show through.
315
+ await expect(
316
+ headingRowOf(heading).getBoundingClientRect().bottom,
317
+ ).toBeCloseTo(list.getBoundingClientRect().top, 0)
318
+
319
+ pane.scrollTop = 96
320
+
321
+ await waitFor(() => {
322
+ expect(pane.scrollTop).toBe(96)
323
+ })
324
+
325
+ const paneTop =
326
+ pane.getBoundingClientRect().top + pane.clientTop
327
+
328
+ await expect(
329
+ headingRowOf(heading).getBoundingClientRect().top,
330
+ ).toBeCloseTo(paneTop, 0)
331
+
332
+ // The first row has scrolled up past the heading, under it.
333
+ const firstRow = within(list).getAllByRole(
334
+ "listitem",
335
+ )[0] as HTMLElement
336
+
337
+ await expect(
338
+ firstRow.getBoundingClientRect().top,
339
+ ).toBeLessThan(paneTop)
340
+
341
+ await expectNoAxeViolations(canvasElement)
342
+ })
343
+
344
+ test("a stuck heading's background is the surface its host names, and surface-base by default", async () => {
345
+ const { canvas } = await mountStory(StickyHeadings)
346
+
347
+ for (const name of [
348
+ "Pages on a base surface",
349
+ "Pages on a raised surface",
350
+ ]) {
351
+ const pane = expectAgentDrivable(canvas, {
352
+ name,
353
+ role: "region",
354
+ })
355
+
356
+ const heading = within(pane).getByRole("heading", {
357
+ name: "On the Break Room screen",
358
+ })
359
+
360
+ const headingBackground = getComputedStyle(
361
+ headingRowOf(heading),
362
+ ).backgroundColor
363
+
364
+ await expect(headingBackground).toBe(
365
+ getComputedStyle(pane).backgroundColor,
366
+ )
367
+
368
+ await expect(headingBackground).not.toBe(
369
+ "rgba(0, 0, 0, 0)",
370
+ )
371
+ }
372
+
373
+ // And the two surfaces are different, or the assertion above
374
+ // could pass with the property ignored.
375
+ await expect(
376
+ getComputedStyle(
377
+ expectAgentDrivable(canvas, {
378
+ name: "Pages on a base surface",
379
+ role: "region",
380
+ }),
381
+ ).backgroundColor,
382
+ ).not.toBe(
383
+ getComputedStyle(
384
+ expectAgentDrivable(canvas, {
385
+ name: "Pages on a raised surface",
386
+ role: "region",
387
+ }),
388
+ ).backgroundColor,
389
+ )
390
+ })
@@ -0,0 +1,235 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { toClassName } from "../toClassName.ts"
5
+ import { GroupedListRow } from "./GroupedListRow.tsx"
6
+ import { STICKY_HEADING_CLASS } from "./groupedListStickyHeading.ts"
7
+
8
+ export type GroupedListHeadingLevel = 2 | 3 | 4 | 5 | 6
9
+
10
+ export type GroupedListSize = "md" | "sm"
11
+
12
+ export type GroupedListItem = {
13
+ /**
14
+ * Trailing controls — usually one `Button size="sm"`. They sit at
15
+ * the row's inline end and wrap under the text when the list is
16
+ * too narrow to hold both.
17
+ *
18
+ * **Name a repeated action after its row.** Ten rows of "Open" are
19
+ * ten buttons an agent cannot tell apart; `aria-label="Open Lobby"`
20
+ * on each is one `getByRole` away.
21
+ */
22
+ actions?: ReactNode
23
+ /** `Badge`s, drawn after the label and wrapping onto their own line when they run out of room. */
24
+ badges?: ReactNode
25
+ /**
26
+ * Anything richer than one line of help — a payout, a rule list.
27
+ * Drawn under the description, inside the text column, and never
28
+ * truncated.
29
+ */
30
+ content?: ReactNode
31
+ /** One muted line under the label: a path, a key, a count. Truncated like the label. */
32
+ description?: string
33
+ key: string
34
+ /**
35
+ * Plain text, because it is what the row is called — the thing a
36
+ * search matched and the text a clipped row's tooltip repeats.
37
+ */
38
+ label: string
39
+ }
40
+
41
+ export type GroupedListGroup = {
42
+ /** Plain text. It is the accessible name of the group's list, so `getByRole("list", { name })` finds it. */
43
+ heading: string
44
+ items: readonly GroupedListItem[]
45
+ key: string
46
+ /**
47
+ * A muted note beside the heading — a count, a total. Kept out of
48
+ * the heading element so the list's name stays "Screens" rather
49
+ * than "Screens 3 pages".
50
+ */
51
+ summary?: ReactNode
52
+ }
53
+
54
+ export type GroupedListProps = {
55
+ className?: string
56
+ /**
57
+ * Rendered in place of the groups when **no group has an item** —
58
+ * usually an `EmptyState` whose wording says why (nothing yet, or
59
+ * nothing matches the search). Nothing renders without one.
60
+ */
61
+ emptyState?: ReactNode
62
+ groups: readonly GroupedListGroup[]
63
+ /**
64
+ * The level every group heading sits at. A list under a page's
65
+ * `<h2>` needs `3`, which is the default, and getting it wrong is
66
+ * a document-outline break no gate here can see.
67
+ */
68
+ headingLevel?: GroupedListHeadingLevel
69
+ /**
70
+ * `true` pins each group's heading to the top of the nearest
71
+ * scrolling ancestor while that group's rows scroll under it, and
72
+ * the next group's heading pushes it away. For a list inside a
73
+ * scrolling pane, where the heading is the only thing that says
74
+ * which screen or team a row belongs to once it scrolls out of
75
+ * sight. `false` (the default) scrolls the heading with its rows.
76
+ *
77
+ * A stuck heading needs an opaque background or the rows show
78
+ * through it, and no one surface fits every host — a page is
79
+ * `surface-base`, a settings pane is `surface-raised`. So the
80
+ * background is read from `--charcuterie-grouped-list-heading-surface`,
81
+ * which falls back to `--color-surface-base`. Set it on any
82
+ * ancestor to match the surface the list sits on. Under a sticky
83
+ * page `Header`, set `--charcuterie-grouped-list-heading-offset`
84
+ * to the header's height so the headings stop below it.
85
+ */
86
+ isHeadingSticky?: boolean
87
+ /**
88
+ * `true` (the default) clips a long label or description to one
89
+ * line, with the full text as a tooltip while it is clipped.
90
+ * `false` wraps it anywhere instead — for a list whose rows are
91
+ * read rather than scanned.
92
+ */
93
+ isTextTruncated?: boolean
94
+ /** `sm` for a settings pane or a sidebar; `md` for a page. */
95
+ size?: GroupedListSize
96
+ }
97
+
98
+ /**
99
+ * Written out rather than interpolated into a tag name, matching
100
+ * `Accordion`: "which heading levels does this component emit"
101
+ * should be answerable by looking.
102
+ */
103
+ const HEADING_TAG: Record<
104
+ GroupedListHeadingLevel,
105
+ "h2" | "h3" | "h4" | "h5" | "h6"
106
+ > = {
107
+ 2: "h2",
108
+ 3: "h3",
109
+ 4: "h4",
110
+ 5: "h5",
111
+ 6: "h6",
112
+ }
113
+
114
+ /**
115
+ * An always-open list whose rows sit under group headings.
116
+ *
117
+ * Three apps had built this by hand — castkit's page picker (pages
118
+ * under the screen that shows them), tally-marks' cards (cards under
119
+ * the child who earns them) and whereabouts' nested holdings (things
120
+ * under the place that holds them). Same shape each time: a heading,
121
+ * then rows of a name, a muted line, some badges and a button. The
122
+ * library owns that shape; the app still owns the grouping, the
123
+ * search and the filters, because those are questions about its
124
+ * data.
125
+ *
126
+ * ### A group is a named list, not a landmark
127
+ *
128
+ * castkit's copy wrapped each group in `<section aria-labelledby>`,
129
+ * and a named `<section>` is a `region` landmark. One screen of
130
+ * pages is a dozen groups, so that is a dozen landmarks — the
131
+ * "landmark region proliferation" the APG warns about, which is why
132
+ * `Accordion`'s panels are a `group` too. Here the heading labels
133
+ * the `<ul>` itself: a screen reader announces "Screens, list, 3
134
+ * items", which is the same fact, and the landmark list keeps
135
+ * meaning "the parts of the page".
136
+ *
137
+ * ### An empty group is not drawn
138
+ *
139
+ * Every copy filtered them out before rendering, because a heading
140
+ * over nothing reads as a bug once a search narrows the list. So the
141
+ * component does it, and when every group is empty it draws
142
+ * `emptyState` instead.
143
+ *
144
+ * ### A sticky heading is opt-in
145
+ *
146
+ * castkit's copy pinned its headings, and a pinned heading needs a
147
+ * background that matches whatever it sits on. The component cannot
148
+ * know that surface, so `isHeadingSticky` reads it from a custom
149
+ * property the host can set, falling back to `surface-base`.
150
+ *
151
+ * ### Not collapsible
152
+ *
153
+ * A group you can close is `Accordion`. This is for a list that is
154
+ * short enough to read whole and long enough to want headings.
155
+ */
156
+ export const GroupedList = ({
157
+ className,
158
+ emptyState,
159
+ groups,
160
+ headingLevel = 3,
161
+ isHeadingSticky = false,
162
+ isTextTruncated = true,
163
+ size = "md",
164
+ }: GroupedListProps): ReactNode => {
165
+ const baseId = useUniqueId()
166
+
167
+ const shownGroups = groups.filter(
168
+ (group) => group.items.length > 0,
169
+ )
170
+
171
+ const Heading = HEADING_TAG[headingLevel]
172
+
173
+ return (
174
+ <div
175
+ className={toClassName(
176
+ "flex flex-col",
177
+ size === "sm" ? "gap-4" : "gap-6",
178
+ className,
179
+ )}
180
+ >
181
+ {shownGroups.length === 0
182
+ ? (emptyState ?? null)
183
+ : shownGroups.map((group, index) => {
184
+ const headingId = `${baseId}-heading-${index}`
185
+
186
+ return (
187
+ <div
188
+ className={toClassName(
189
+ "flex flex-col",
190
+ isHeadingSticky ? "gap-0" : "gap-2",
191
+ )}
192
+ key={group.key}
193
+ >
194
+ <div
195
+ className={toClassName(
196
+ "flex flex-wrap items-baseline gap-x-3 gap-y-0.5",
197
+ isHeadingSticky && STICKY_HEADING_CLASS,
198
+ )}
199
+ >
200
+ <Heading
201
+ className={toClassName(
202
+ "m-0 min-w-0 wrap-anywhere font-semibold text-content-primary",
203
+ size === "sm" ? "text-sm" : "text-md",
204
+ )}
205
+ id={headingId}
206
+ >
207
+ {group.heading}
208
+ </Heading>
209
+
210
+ {group.summary ? (
211
+ <span className="min-w-0 wrap-anywhere text-content-secondary text-sm">
212
+ {group.summary}
213
+ </span>
214
+ ) : null}
215
+ </div>
216
+
217
+ <ul
218
+ aria-labelledby={headingId}
219
+ className="m-0 flex list-none flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-subtle bg-surface-raised p-0"
220
+ >
221
+ {group.items.map((item) => (
222
+ <GroupedListRow
223
+ isTextTruncated={isTextTruncated}
224
+ item={item}
225
+ key={item.key}
226
+ size={size}
227
+ />
228
+ ))}
229
+ </ul>
230
+ </div>
231
+ )
232
+ })}
233
+ </div>
234
+ )
235
+ }