@charcuterie/ui 4.22.1 → 4.23.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,271 @@
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
+ } = composeStories(stories)
29
+
30
+ test("a group's heading names its list", async () => {
31
+ const { canvas, canvasElement } =
32
+ await mountStory(Playground)
33
+
34
+ // The heading labels the `<ul>` rather than a `<section>`: a named
35
+ // section is a landmark, and one per group is the landmark
36
+ // proliferation the APG warns about.
37
+ const screens = expectAgentDrivable(canvas, {
38
+ name: "Screens",
39
+ role: "list",
40
+ })
41
+
42
+ await expect(
43
+ within(screens).getAllByRole("listitem"),
44
+ ).toHaveLength(2)
45
+
46
+ await expect(
47
+ canvasElement.querySelector("section"),
48
+ ).toBeNull()
49
+
50
+ await expect(
51
+ canvas.getByRole("heading", {
52
+ level: 3,
53
+ name: "Screens",
54
+ }),
55
+ ).toBeVisible()
56
+
57
+ expectAgentDrivable(canvas, {
58
+ name: "Open Lobby",
59
+ role: "button",
60
+ })
61
+
62
+ await expectNoAxeViolations(canvasElement)
63
+ })
64
+
65
+ test("the heading level is the caller's", async () => {
66
+ const { canvas } = await mountStory(
67
+ composeStory({ args: { headingLevel: 2 } }, meta),
68
+ )
69
+
70
+ const heading = expectAgentDrivable(canvas, {
71
+ name: "Not on a screen",
72
+ role: "heading",
73
+ })
74
+
75
+ await expect(heading.tagName).toBe("H2")
76
+ })
77
+
78
+ test("an empty group is not drawn", async () => {
79
+ const { canvas, canvasElement } =
80
+ await mountStory(AllStates)
81
+
82
+ await expect(
83
+ canvas.queryByRole("heading", { name: "Never drawn" }),
84
+ ).toBeNull()
85
+
86
+ expectAgentDrivable(canvas, {
87
+ name: "Drawn",
88
+ role: "list",
89
+ })
90
+
91
+ // The last cell has no group with an item, so it draws its
92
+ // `emptyState` and no list at all.
93
+ expectAgentDrivable(canvas, {
94
+ name: "No page matches",
95
+ role: "heading",
96
+ })
97
+
98
+ await expectNoAxeViolations(canvasElement)
99
+ })
100
+
101
+ test("a list with nothing in it draws the caller's empty state", async () => {
102
+ const { canvas, canvasElement } = await mountStory(Empty)
103
+
104
+ expectAgentDrivable(canvas, {
105
+ name: "This server has no pages",
106
+ role: "heading",
107
+ })
108
+
109
+ await expect(canvas.queryByRole("list")).toBeNull()
110
+
111
+ await expectNoAxeViolations(canvasElement)
112
+ })
113
+
114
+ test("a clipped label carries its full text as a tooltip, and a short one has none", async () => {
115
+ const { canvas, canvasElement } =
116
+ await mountStory(LongText)
117
+
118
+ const label = canvas.getByText(
119
+ "Quarterly All-Hands Countdown and Seating Chart",
120
+ )
121
+
122
+ await waitFor(() => {
123
+ expect(label).toHaveAttribute(
124
+ "title",
125
+ "Quarterly All-Hands Countdown and Seating Chart",
126
+ )
127
+ })
128
+
129
+ await expect(label.scrollWidth).toBeGreaterThan(
130
+ label.clientWidth,
131
+ )
132
+
133
+ const path = canvas.getByText(
134
+ "/views/quarterly-all-hands-countdown-and-seating-chart-for-the-north-atrium",
135
+ )
136
+
137
+ await waitFor(() => {
138
+ expect(path).toHaveAttribute("title")
139
+ })
140
+
141
+ await expect(canvas.getByText("Map")).not.toHaveAttribute(
142
+ "title",
143
+ )
144
+
145
+ expectAgentDrivable(canvas, {
146
+ name: "Open Map",
147
+ role: "button",
148
+ })
149
+
150
+ await expectNoAxeViolations(canvasElement)
151
+ })
152
+
153
+ test("isTextTruncated={false} wraps the whole label instead", async () => {
154
+ const { canvas } = await mountStory(AllVariants)
155
+
156
+ // The third cell truncates, the fourth wraps; same fixture.
157
+ const [truncated, wrapped] = canvas.getAllByText(
158
+ "Quarterly All-Hands Countdown and Seating Chart",
159
+ ) as [HTMLElement, HTMLElement]
160
+
161
+ await waitFor(() => {
162
+ expect(truncated).toHaveAttribute("title")
163
+ })
164
+
165
+ await expect(wrapped.scrollWidth).toBeLessThanOrEqual(
166
+ wrapped.clientWidth,
167
+ )
168
+
169
+ await expect(wrapped).not.toHaveAttribute("title")
170
+
171
+ await expect(
172
+ canvas.getAllByRole("list", {
173
+ name: "On the Conference Center North Atrium Wayfinding screen",
174
+ }),
175
+ ).toHaveLength(2)
176
+ })
177
+
178
+ test("Tab walks the actions in reading order", async () => {
179
+ const { canvas } = await mountStory(Interactive)
180
+
181
+ const order = [
182
+ "Open Lobby",
183
+ "Open Break Room",
184
+ "Open Welcome",
185
+ "Open Weather",
186
+ "Open Schedule",
187
+ "Open Test Pattern",
188
+ ]
189
+
190
+ for (const name of order) {
191
+ await userEvent.tab()
192
+
193
+ await expect(
194
+ expectAgentDrivable(canvas, { name, role: "button" }),
195
+ ).toHaveFocus()
196
+ }
197
+ })
198
+
199
+ /**
200
+ * The text column asks for 12rem and grows. In the 15rem panel the
201
+ * actions cannot fit beside it, so they drop under the text; in the
202
+ * 34rem panel they sit on the same line.
203
+ */
204
+ test("the actions wrap under the text when the list is narrow", async () => {
205
+ const { canvas } = await mountStory(Responsive)
206
+
207
+ const labels = canvas.getAllByText("Water the ferns")
208
+
209
+ const buttons = canvas.getAllByRole("button", {
210
+ name: "Edit Water the ferns",
211
+ })
212
+
213
+ const [narrowLabel, , wideLabel] = labels as [
214
+ HTMLElement,
215
+ HTMLElement,
216
+ HTMLElement,
217
+ ]
218
+
219
+ const [narrowButton, , wideButton] = buttons as [
220
+ HTMLElement,
221
+ HTMLElement,
222
+ HTMLElement,
223
+ ]
224
+
225
+ await expect(
226
+ narrowButton.getBoundingClientRect().top,
227
+ ).toBeGreaterThanOrEqual(
228
+ narrowLabel.getBoundingClientRect().bottom,
229
+ )
230
+
231
+ await expect(
232
+ wideButton.getBoundingClientRect().top,
233
+ ).toBeLessThan(wideLabel.getBoundingClientRect().bottom)
234
+
235
+ // Nothing pushes past its panel: no row is wider than its list.
236
+ for (const list of canvas.getAllByRole("list", {
237
+ name: "Team Orion",
238
+ })) {
239
+ await expect(list.scrollWidth).toBeLessThanOrEqual(
240
+ list.clientWidth,
241
+ )
242
+ }
243
+ })
244
+
245
+ test("the Narrow View keeps every row inside the window", async () => {
246
+ const { canvas, canvasElement } =
247
+ await mountStory(NarrowView)
248
+
249
+ const list = expectAgentDrivable(canvas, {
250
+ name: "Team Orion",
251
+ role: "list",
252
+ })
253
+
254
+ await expect(list.scrollWidth).toBeLessThanOrEqual(
255
+ list.clientWidth,
256
+ )
257
+
258
+ await expectNoAxeViolations(canvasElement)
259
+ })
260
+
261
+ test("both schemes pass axe, contrast included", async () => {
262
+ const { canvas, canvasElement } =
263
+ await mountStory(ColorSchemes)
264
+
265
+ expectAgentDrivable(canvas, {
266
+ name: "Open dark Lobby",
267
+ role: "button",
268
+ })
269
+
270
+ await expectNoAxeViolations(canvasElement)
271
+ })
@@ -0,0 +1,200 @@
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
+
7
+ export type GroupedListHeadingLevel = 2 | 3 | 4 | 5 | 6
8
+
9
+ export type GroupedListSize = "md" | "sm"
10
+
11
+ export type GroupedListItem = {
12
+ /**
13
+ * Trailing controls — usually one `Button size="sm"`. They sit at
14
+ * the row's inline end and wrap under the text when the list is
15
+ * too narrow to hold both.
16
+ *
17
+ * **Name a repeated action after its row.** Ten rows of "Open" are
18
+ * ten buttons an agent cannot tell apart; `aria-label="Open Lobby"`
19
+ * on each is one `getByRole` away.
20
+ */
21
+ actions?: ReactNode
22
+ /** `Badge`s, drawn after the label and wrapping onto their own line when they run out of room. */
23
+ badges?: ReactNode
24
+ /**
25
+ * Anything richer than one line of help — a payout, a rule list.
26
+ * Drawn under the description, inside the text column, and never
27
+ * truncated.
28
+ */
29
+ content?: ReactNode
30
+ /** One muted line under the label: a path, a key, a count. Truncated like the label. */
31
+ description?: string
32
+ key: string
33
+ /**
34
+ * Plain text, because it is what the row is called — the thing a
35
+ * search matched and the text a clipped row's tooltip repeats.
36
+ */
37
+ label: string
38
+ }
39
+
40
+ export type GroupedListGroup = {
41
+ /** Plain text. It is the accessible name of the group's list, so `getByRole("list", { name })` finds it. */
42
+ heading: string
43
+ items: readonly GroupedListItem[]
44
+ key: string
45
+ /**
46
+ * A muted note beside the heading — a count, a total. Kept out of
47
+ * the heading element so the list's name stays "Screens" rather
48
+ * than "Screens 3 pages".
49
+ */
50
+ summary?: ReactNode
51
+ }
52
+
53
+ export type GroupedListProps = {
54
+ className?: string
55
+ /**
56
+ * Rendered in place of the groups when **no group has an item** —
57
+ * usually an `EmptyState` whose wording says why (nothing yet, or
58
+ * nothing matches the search). Nothing renders without one.
59
+ */
60
+ emptyState?: ReactNode
61
+ groups: readonly GroupedListGroup[]
62
+ /**
63
+ * The level every group heading sits at. A list under a page's
64
+ * `<h2>` needs `3`, which is the default, and getting it wrong is
65
+ * a document-outline break no gate here can see.
66
+ */
67
+ headingLevel?: GroupedListHeadingLevel
68
+ /**
69
+ * `true` (the default) clips a long label or description to one
70
+ * line, with the full text as a tooltip while it is clipped.
71
+ * `false` wraps it anywhere instead — for a list whose rows are
72
+ * read rather than scanned.
73
+ */
74
+ isTextTruncated?: boolean
75
+ /** `sm` for a settings pane or a sidebar; `md` for a page. */
76
+ size?: GroupedListSize
77
+ }
78
+
79
+ /**
80
+ * Written out rather than interpolated into a tag name, matching
81
+ * `Accordion`: "which heading levels does this component emit"
82
+ * should be answerable by looking.
83
+ */
84
+ const HEADING_TAG: Record<
85
+ GroupedListHeadingLevel,
86
+ "h2" | "h3" | "h4" | "h5" | "h6"
87
+ > = {
88
+ 2: "h2",
89
+ 3: "h3",
90
+ 4: "h4",
91
+ 5: "h5",
92
+ 6: "h6",
93
+ }
94
+
95
+ /**
96
+ * An always-open list whose rows sit under group headings.
97
+ *
98
+ * Three apps had built this by hand — castkit's page picker (pages
99
+ * under the screen that shows them), tally-marks' cards (cards under
100
+ * the child who earns them) and whereabouts' nested holdings (things
101
+ * under the place that holds them). Same shape each time: a heading,
102
+ * then rows of a name, a muted line, some badges and a button. The
103
+ * library owns that shape; the app still owns the grouping, the
104
+ * search and the filters, because those are questions about its
105
+ * data.
106
+ *
107
+ * ### A group is a named list, not a landmark
108
+ *
109
+ * castkit's copy wrapped each group in `<section aria-labelledby>`,
110
+ * and a named `<section>` is a `region` landmark. One screen of
111
+ * pages is a dozen groups, so that is a dozen landmarks — the
112
+ * "landmark region proliferation" the APG warns about, which is why
113
+ * `Accordion`'s panels are a `group` too. Here the heading labels
114
+ * the `<ul>` itself: a screen reader announces "Screens, list, 3
115
+ * items", which is the same fact, and the landmark list keeps
116
+ * meaning "the parts of the page".
117
+ *
118
+ * ### An empty group is not drawn
119
+ *
120
+ * Every copy filtered them out before rendering, because a heading
121
+ * over nothing reads as a bug once a search narrows the list. So the
122
+ * component does it, and when every group is empty it draws
123
+ * `emptyState` instead.
124
+ *
125
+ * ### Not collapsible
126
+ *
127
+ * A group you can close is `Accordion`. This is for a list that is
128
+ * short enough to read whole and long enough to want headings.
129
+ */
130
+ export const GroupedList = ({
131
+ className,
132
+ emptyState,
133
+ groups,
134
+ headingLevel = 3,
135
+ isTextTruncated = true,
136
+ size = "md",
137
+ }: GroupedListProps): ReactNode => {
138
+ const baseId = useUniqueId()
139
+
140
+ const shownGroups = groups.filter(
141
+ (group) => group.items.length > 0,
142
+ )
143
+
144
+ const Heading = HEADING_TAG[headingLevel]
145
+
146
+ return (
147
+ <div
148
+ className={toClassName(
149
+ "flex flex-col",
150
+ size === "sm" ? "gap-4" : "gap-6",
151
+ className,
152
+ )}
153
+ >
154
+ {shownGroups.length === 0
155
+ ? (emptyState ?? null)
156
+ : shownGroups.map((group, index) => {
157
+ const headingId = `${baseId}-heading-${index}`
158
+
159
+ return (
160
+ <div
161
+ className="flex flex-col gap-2"
162
+ key={group.key}
163
+ >
164
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
165
+ <Heading
166
+ className={toClassName(
167
+ "m-0 min-w-0 wrap-anywhere font-semibold text-content-primary",
168
+ size === "sm" ? "text-sm" : "text-md",
169
+ )}
170
+ id={headingId}
171
+ >
172
+ {group.heading}
173
+ </Heading>
174
+
175
+ {group.summary ? (
176
+ <span className="min-w-0 wrap-anywhere text-content-secondary text-sm">
177
+ {group.summary}
178
+ </span>
179
+ ) : null}
180
+ </div>
181
+
182
+ <ul
183
+ aria-labelledby={headingId}
184
+ 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"
185
+ >
186
+ {group.items.map((item) => (
187
+ <GroupedListRow
188
+ isTextTruncated={isTextTruncated}
189
+ item={item}
190
+ key={item.key}
191
+ size={size}
192
+ />
193
+ ))}
194
+ </ul>
195
+ </div>
196
+ )
197
+ })}
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,117 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { useIsTextClipped } from "../Badge/useIsTextClipped.ts"
4
+ import { toClassName } from "../toClassName.ts"
5
+ import type {
6
+ GroupedListItem,
7
+ GroupedListSize,
8
+ } from "./GroupedList.tsx"
9
+
10
+ export type GroupedListRowProps = {
11
+ isTextTruncated: boolean
12
+ item: GroupedListItem
13
+ size: GroupedListSize
14
+ }
15
+
16
+ const ROW_SIZE_CLASS: Record<GroupedListSize, string> = {
17
+ md: "px-4 py-3",
18
+ sm: "px-3 py-2",
19
+ }
20
+
21
+ /**
22
+ * Its own file because each row measures its own text, and a hook
23
+ * cannot run in a loop.
24
+ *
25
+ * ### The tooltip appears only while the text is clipped
26
+ *
27
+ * `useIsTextClipped` is `Badge`'s: an ellipsis leaves no trace in the
28
+ * DOM, so the only way to know a label is cut off is to measure it.
29
+ * A `title` on every row would put a tooltip on forty short names to
30
+ * help the one long one.
31
+ *
32
+ * ### The actions wrap; they never squeeze the text
33
+ *
34
+ * The row is a wrapping flex line. The text column asks for 12rem
35
+ * and grows into the rest, so in a wide list the actions sit at the
36
+ * inline end of the same line, and in a list narrower than the text
37
+ * plus the actions they drop under the text instead of crushing the
38
+ * label to an ellipsis and nothing. No container query: the wrap is
39
+ * decided by the content itself.
40
+ */
41
+ export const GroupedListRow = ({
42
+ isTextTruncated,
43
+ item,
44
+ size,
45
+ }: GroupedListRowProps): ReactNode => {
46
+ const [labelRef, clippedLabel] = useIsTextClipped()
47
+
48
+ const [descriptionRef, clippedDescription] =
49
+ useIsTextClipped()
50
+
51
+ const overflowClass = isTextTruncated
52
+ ? "truncate"
53
+ : "wrap-anywhere"
54
+
55
+ return (
56
+ <li
57
+ className={toClassName(
58
+ "flex flex-wrap items-center gap-x-3 gap-y-2",
59
+ ROW_SIZE_CLASS[size],
60
+ )}
61
+ >
62
+ <div className="flex min-w-0 flex-[1_1_12rem] flex-col gap-0.5">
63
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
64
+ <span
65
+ className={toClassName(
66
+ "min-w-0 max-w-full font-medium text-content-primary",
67
+ size === "sm" ? "text-sm" : "text-md",
68
+ overflowClass,
69
+ )}
70
+ ref={labelRef}
71
+ title={
72
+ isTextTruncated ? clippedLabel : undefined
73
+ }
74
+ >
75
+ {item.label}
76
+ </span>
77
+
78
+ {item.badges ? (
79
+ <span className="flex min-w-0 flex-wrap items-center gap-1">
80
+ {item.badges}
81
+ </span>
82
+ ) : null}
83
+ </div>
84
+
85
+ {item.description ? (
86
+ <span
87
+ className={toClassName(
88
+ "block min-w-0 text-content-secondary",
89
+ size === "sm" ? "text-xs" : "text-sm",
90
+ overflowClass,
91
+ )}
92
+ ref={descriptionRef}
93
+ title={
94
+ isTextTruncated
95
+ ? clippedDescription
96
+ : undefined
97
+ }
98
+ >
99
+ {item.description}
100
+ </span>
101
+ ) : null}
102
+
103
+ {item.content ? (
104
+ <div className="mt-1 min-w-0 wrap-anywhere text-content-secondary text-sm">
105
+ {item.content}
106
+ </div>
107
+ ) : null}
108
+ </div>
109
+
110
+ {item.actions ? (
111
+ <div className="flex shrink-0 items-center gap-2">
112
+ {item.actions}
113
+ </div>
114
+ ) : null}
115
+ </li>
116
+ )
117
+ }
package/src/index.ts CHANGED
@@ -235,6 +235,14 @@ export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
235
235
  export { FieldGroup } from "./Field/FieldGroup.tsx"
236
236
  export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
237
237
  export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
238
+ export type {
239
+ GroupedListGroup,
240
+ GroupedListHeadingLevel,
241
+ GroupedListItem,
242
+ GroupedListProps,
243
+ GroupedListSize,
244
+ } from "./GroupedList/GroupedList.tsx"
245
+ export { GroupedList } from "./GroupedList/GroupedList.tsx"
238
246
  export type { HeaderProps } from "./Header/Header.tsx"
239
247
  export { Header } from "./Header/Header.tsx"
240
248
  export type { IconButtonProps } from "./IconButton/IconButton.tsx"
@@ -623,7 +623,10 @@ test("the barrel is the only place components are re-exported", async () => {
623
623
  // TableViewport owns readable comparisons and full-window expansion.
624
624
  // MetricCard shares prominent values and category-coloured icons through Card.
625
625
  // ComparisonChart and TableColumns share compact comparisons and column controls.
626
- expect(componentNames.length).toBe(77)
626
+ // GroupedList owns rows under group headings; castkit, tally-marks
627
+ // and whereabouts each drew it by hand. `GroupedListRow.tsx` is its
628
+ // member and stays out of this count by the `<Name>/<Name>.tsx` rule.
629
+ expect(componentNames.length).toBe(78)
627
630
 
628
631
  for (const name of componentNames) {
629
632
  expect(barrel).toContain(`export { ${name} }`)