@charcuterie/ui 4.22.0 → 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.
- package/README.md +13 -0
- package/dist/GroupedList/GroupedList.d.ts +105 -0
- package/dist/GroupedList/GroupedList.d.ts.map +1 -0
- package/dist/GroupedList/GroupedList.js +63 -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/Picker/Picker.d.ts +15 -0
- package/dist/Picker/Picker.d.ts.map +1 -1
- package/dist/Picker/Picker.js +26 -4
- package/dist/Picker/Picker.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +67 -0
- package/src/GroupedList/GroupedList.stories.tsx +474 -0
- package/src/GroupedList/GroupedList.test.tsx +271 -0
- package/src/GroupedList/GroupedList.tsx +200 -0
- package/src/GroupedList/GroupedListRow.tsx +117 -0
- package/src/Picker/Picker.mdx +14 -0
- package/src/Picker/Picker.stories.tsx +46 -0
- package/src/Picker/Picker.test.tsx +91 -2
- package/src/Picker/Picker.tsx +43 -4
- package/src/index.ts +8 -0
- package/src/sourceRules.test.ts +4 -1
- package/src/Button/Button.test.tsx +0 -114
|
@@ -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/Picker/Picker.mdx
CHANGED
|
@@ -77,6 +77,20 @@ all flush; they are all the same trap.
|
|
|
77
77
|
|
|
78
78
|
<Canvas of={PickerStories.NextToText} />
|
|
79
79
|
|
|
80
|
+
## A stretched trigger reads like a select
|
|
81
|
+
|
|
82
|
+
`Picker` is a `Button`, and a `Button` centers its content. Inside a `Field` — a `flex-col`,
|
|
83
|
+
so the trigger stretches to the column — or under `w-full`, that used to give a full-width bar
|
|
84
|
+
with the value floating in the middle. The trigger now lays its value out at the **inline
|
|
85
|
+
start** and the chevron at the **inline end**, and a value wider than the trigger truncates
|
|
86
|
+
with an ellipsis rather than pushing the chevron out. The full value stays in the accessible
|
|
87
|
+
name and in the open list.
|
|
88
|
+
|
|
89
|
+
The width still comes from the container: a trigger the parent does not stretch keeps its
|
|
90
|
+
content width and renders exactly as before.
|
|
91
|
+
|
|
92
|
+
<Canvas of={PickerStories.Responsive} />
|
|
93
|
+
|
|
80
94
|
## `id` does not survive; `data-testid` does
|
|
81
95
|
|
|
82
96
|
`useAnchoredOverlay` overwrites the trigger's `id` with a generated one so the portalled
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../argTypes.storyHelpers.ts"
|
|
11
11
|
import { Badge } from "../Badge/Badge.tsx"
|
|
12
12
|
import {
|
|
13
|
+
ContainerBoard,
|
|
13
14
|
StoryRow,
|
|
14
15
|
StorySection,
|
|
15
16
|
} from "../board.storyHelpers.tsx"
|
|
@@ -258,3 +259,48 @@ export const NextToText: Story = {
|
|
|
258
259
|
</div>
|
|
259
260
|
),
|
|
260
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* One value long enough to outgrow the narrowest container, so the
|
|
265
|
+
* board shows the truncation as well as the stretch.
|
|
266
|
+
*/
|
|
267
|
+
const SUBTITLES: PickerOption[] = [
|
|
268
|
+
{ label: "Off", value: "off" },
|
|
269
|
+
{
|
|
270
|
+
label: "English (SDH, signs and songs, full dialogue)",
|
|
271
|
+
value: "eng-sdh",
|
|
272
|
+
},
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Inside a `Field` the trigger stretches to the container, the way a
|
|
277
|
+
* select does: the value sits at the inline start, the chevron at the
|
|
278
|
+
* inline end, and a value wider than the container truncates rather
|
|
279
|
+
* than pushing the chevron out.
|
|
280
|
+
*/
|
|
281
|
+
export const Responsive: Story = {
|
|
282
|
+
render: () => (
|
|
283
|
+
<ContainerBoard>
|
|
284
|
+
{(width) => (
|
|
285
|
+
<div className="flex flex-col gap-4">
|
|
286
|
+
<Field label={`Language at ${width}`}>
|
|
287
|
+
<Picker
|
|
288
|
+
label={`Language at ${width}`}
|
|
289
|
+
onChange={() => undefined}
|
|
290
|
+
options={LANGUAGES}
|
|
291
|
+
value="eng"
|
|
292
|
+
/>
|
|
293
|
+
</Field>
|
|
294
|
+
<Field label={`Subtitles at ${width}`}>
|
|
295
|
+
<Picker
|
|
296
|
+
label={`Subtitles at ${width}`}
|
|
297
|
+
onChange={() => undefined}
|
|
298
|
+
options={SUBTITLES}
|
|
299
|
+
value="eng-sdh"
|
|
300
|
+
/>
|
|
301
|
+
</Field>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
</ContainerBoard>
|
|
305
|
+
),
|
|
306
|
+
}
|