@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.
- package/README.md +13 -0
- package/dist/GroupedList/GroupedList.d.ts +130 -0
- package/dist/GroupedList/GroupedList.d.ts.map +1 -0
- package/dist/GroupedList/GroupedList.js +71 -0
- package/dist/GroupedList/GroupedList.js.map +1 -0
- package/dist/GroupedList/GroupedListRow.d.ts +29 -0
- package/dist/GroupedList/GroupedListRow.d.ts.map +1 -0
- package/dist/GroupedList/GroupedListRow.js +38 -0
- package/dist/GroupedList/GroupedListRow.js.map +1 -0
- 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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +95 -0
- package/src/GroupedList/GroupedList.stories.tsx +562 -0
- package/src/GroupedList/GroupedList.test.tsx +390 -0
- package/src/GroupedList/GroupedList.tsx +235 -0
- package/src/GroupedList/GroupedListRow.tsx +117 -0
- package/src/GroupedList/groupedListStickyHeading.ts +24 -0
- package/src/index.ts +12 -0
- package/src/sourceRules.test.ts +4 -1
- package/src/Button/Button.test.tsx +0 -114
|
@@ -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
|
+
}
|