@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
|
@@ -7,8 +7,13 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Picker.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
Playground,
|
|
13
|
+
Interactive,
|
|
14
|
+
NextToText,
|
|
15
|
+
Responsive,
|
|
16
|
+
} = composeStories(stories)
|
|
12
17
|
|
|
13
18
|
test("the trigger's accessible name carries the current value", async () => {
|
|
14
19
|
const { canvas, canvasElement } =
|
|
@@ -108,3 +113,87 @@ test("a rich option label keeps a plain-string trigger via textValue", async ()
|
|
|
108
113
|
|
|
109
114
|
await expectNoAxeViolations(canvasElement)
|
|
110
115
|
})
|
|
116
|
+
|
|
117
|
+
test("a stretched trigger puts the value at the start and the chevron at the end", async () => {
|
|
118
|
+
const { canvas, canvasElement } =
|
|
119
|
+
await mountStory(Responsive)
|
|
120
|
+
|
|
121
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
122
|
+
name: "Language at 34rem: English",
|
|
123
|
+
role: "button",
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const valueElement = canvas
|
|
127
|
+
.getAllByText("English")
|
|
128
|
+
.find((element: HTMLElement) =>
|
|
129
|
+
trigger.contains(element),
|
|
130
|
+
) as HTMLElement
|
|
131
|
+
|
|
132
|
+
// The text's own box, not the span's: the span grows to fill the
|
|
133
|
+
// trigger, so its box would sit at the start even with centered text.
|
|
134
|
+
const valueRange = document.createRange()
|
|
135
|
+
|
|
136
|
+
valueRange.selectNodeContents(valueElement)
|
|
137
|
+
|
|
138
|
+
const valueBox = valueRange.getBoundingClientRect()
|
|
139
|
+
const triggerBox = trigger.getBoundingClientRect()
|
|
140
|
+
const chevronBox = (
|
|
141
|
+
trigger.querySelector("svg") as SVGElement
|
|
142
|
+
).getBoundingClientRect()
|
|
143
|
+
|
|
144
|
+
const triggerStyle = getComputedStyle(trigger)
|
|
145
|
+
const contentStart =
|
|
146
|
+
triggerBox.left +
|
|
147
|
+
Number.parseFloat(triggerStyle.borderInlineStartWidth) +
|
|
148
|
+
Number.parseFloat(triggerStyle.paddingInlineStart)
|
|
149
|
+
const contentEnd =
|
|
150
|
+
triggerBox.right -
|
|
151
|
+
Number.parseFloat(triggerStyle.borderInlineEndWidth) -
|
|
152
|
+
Number.parseFloat(triggerStyle.paddingInlineEnd)
|
|
153
|
+
|
|
154
|
+
// The `Field` stretched the trigger well past its content, so a
|
|
155
|
+
// centered value would start far from the inline start.
|
|
156
|
+
await expect(
|
|
157
|
+
triggerBox.width - valueBox.width - chevronBox.width,
|
|
158
|
+
).toBeGreaterThan(200)
|
|
159
|
+
await expect(
|
|
160
|
+
Math.abs(valueBox.left - contentStart),
|
|
161
|
+
).toBeLessThan(1)
|
|
162
|
+
await expect(
|
|
163
|
+
Math.abs(chevronBox.right - contentEnd),
|
|
164
|
+
).toBeLessThan(1)
|
|
165
|
+
|
|
166
|
+
await expectNoAxeViolations(canvasElement)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("a value wider than the trigger truncates and keeps the chevron inside", async () => {
|
|
170
|
+
const { canvas } = await mountStory(Responsive)
|
|
171
|
+
|
|
172
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
173
|
+
name: "Subtitles at 15rem: English (SDH, signs and songs, full dialogue)",
|
|
174
|
+
role: "button",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const valueElement = canvas
|
|
178
|
+
.getAllByText(
|
|
179
|
+
"English (SDH, signs and songs, full dialogue)",
|
|
180
|
+
)
|
|
181
|
+
.find((element: HTMLElement) =>
|
|
182
|
+
trigger.contains(element),
|
|
183
|
+
) as HTMLElement
|
|
184
|
+
|
|
185
|
+
const triggerBox = trigger.getBoundingClientRect()
|
|
186
|
+
const chevronBox = (
|
|
187
|
+
trigger.querySelector("svg") as SVGElement
|
|
188
|
+
).getBoundingClientRect()
|
|
189
|
+
|
|
190
|
+
await expect(valueElement.scrollWidth).toBeGreaterThan(
|
|
191
|
+
valueElement.clientWidth,
|
|
192
|
+
)
|
|
193
|
+
await expect(trigger.scrollWidth).toBe(
|
|
194
|
+
trigger.clientWidth,
|
|
195
|
+
)
|
|
196
|
+
await expect(chevronBox.right).toBeLessThanOrEqual(
|
|
197
|
+
triggerBox.right,
|
|
198
|
+
)
|
|
199
|
+
})
|
package/src/Picker/Picker.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { Button } from "../Button/Button.tsx"
|
|
|
13
13
|
import type { IntentAppearance } from "../intentStyles.ts"
|
|
14
14
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
15
15
|
import { Listbox } from "../Listbox/Listbox.tsx"
|
|
16
|
+
import { toClassName } from "../toClassName.ts"
|
|
16
17
|
|
|
17
18
|
export type PickerOption = ListboxItem
|
|
18
19
|
|
|
@@ -122,6 +123,21 @@ export type PickerProps = Omit<
|
|
|
122
123
|
* mux-magic both moved to it while `id` was still being replaced — but
|
|
123
124
|
* `id` is no longer a trap. Everything else on the button (`data-*`,
|
|
124
125
|
* `onClick`, `form`, …) passes straight through.
|
|
126
|
+
*
|
|
127
|
+
* ### A stretched trigger reads like a select, not a button
|
|
128
|
+
*
|
|
129
|
+
* `Button` centers its content, which is right for an action and wrong
|
|
130
|
+
* for a value. Inside a `Field` (a `flex-col`, so the child stretches)
|
|
131
|
+
* or under `w-full`, the trigger used to become a bar with the value
|
|
132
|
+
* floating in the middle and the chevron beside it. The value now sits
|
|
133
|
+
* at the inline start, in a span that grows into the spare width and
|
|
134
|
+
* ends in an ellipsis when there is too little, and the chevron sits at
|
|
135
|
+
* the inline end. `justify-between` is passed through `className`, which
|
|
136
|
+
* `toClassName` merges over `Button`'s `justify-center` rather than
|
|
137
|
+
* leaving the two to stylesheet order — and the growing span makes the
|
|
138
|
+
* result correct even if a caller's `className` overrides it. A trigger
|
|
139
|
+
* at its content width has no spare width to distribute, so it renders
|
|
140
|
+
* exactly as it did before.
|
|
125
141
|
*/
|
|
126
142
|
export const Picker = ({
|
|
127
143
|
appearance = "outline",
|
|
@@ -154,6 +170,18 @@ export const Picker = ({
|
|
|
154
170
|
|
|
155
171
|
const triggerText = currentText ?? placeholder ?? ""
|
|
156
172
|
|
|
173
|
+
const triggerContent =
|
|
174
|
+
currentOption === undefined
|
|
175
|
+
? placeholder
|
|
176
|
+
: currentOption.label
|
|
177
|
+
|
|
178
|
+
// No content, no span: an empty flex item would still earn a `gap`
|
|
179
|
+
// before the chevron and widen a content-width trigger.
|
|
180
|
+
const hasTriggerContent =
|
|
181
|
+
triggerContent !== undefined &&
|
|
182
|
+
triggerContent !== null &&
|
|
183
|
+
triggerContent !== ""
|
|
184
|
+
|
|
157
185
|
return (
|
|
158
186
|
<Listbox
|
|
159
187
|
// A disabled control must not open, and hiding the panel is a
|
|
@@ -170,7 +198,10 @@ export const Picker = ({
|
|
|
170
198
|
{...buttonProps}
|
|
171
199
|
appearance={appearance}
|
|
172
200
|
aria-label={`${label}: ${triggerText}`}
|
|
173
|
-
className={
|
|
201
|
+
className={toClassName(
|
|
202
|
+
"justify-between",
|
|
203
|
+
className,
|
|
204
|
+
)}
|
|
174
205
|
iconEnd={iconEnd}
|
|
175
206
|
intent={intent}
|
|
176
207
|
isDisabled={isDisabled}
|
|
@@ -182,9 +213,17 @@ export const Picker = ({
|
|
|
182
213
|
size={size}
|
|
183
214
|
type="button"
|
|
184
215
|
>
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
216
|
+
{hasTriggerContent ? (
|
|
217
|
+
// Not `truncate`. Its `overflow: hidden` clips the
|
|
218
|
+
// anti-aliased edge pixels of glyphs and a rich label's
|
|
219
|
+
// badge, so a content-width trigger stopped rendering
|
|
220
|
+
// pixel-identically to the unwrapped one. `overflow-x-clip`
|
|
221
|
+
// leaves the block axis alone, and `-mx-1 px-1` moves the
|
|
222
|
+
// inline clip edge 4px outward without moving the text.
|
|
223
|
+
<span className="-mx-1 min-w-0 grow overflow-x-clip px-1 text-start text-ellipsis whitespace-nowrap">
|
|
224
|
+
{triggerContent}
|
|
225
|
+
</span>
|
|
226
|
+
) : null}
|
|
188
227
|
</Button>
|
|
189
228
|
}
|
|
190
229
|
/>
|
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"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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} }`)
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
composeStories,
|
|
3
|
-
composeStory,
|
|
4
|
-
} from "@storybook/react"
|
|
5
|
-
import { expect, fn, userEvent } from "storybook/test"
|
|
6
|
-
import { test } from "vitest"
|
|
7
|
-
|
|
8
|
-
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
9
|
-
import { expectAgentDrivable } from "../testing/index.ts"
|
|
10
|
-
import meta, * as stories from "./Button.stories.tsx"
|
|
11
|
-
|
|
12
|
-
const { AllStates, Playground, Loading } =
|
|
13
|
-
composeStories(stories)
|
|
14
|
-
|
|
15
|
-
test("is drivable by role and name", async () => {
|
|
16
|
-
const { canvas } = await mountStory(Playground)
|
|
17
|
-
|
|
18
|
-
expectAgentDrivable(canvas, {
|
|
19
|
-
name: "Start rip",
|
|
20
|
-
role: "button",
|
|
21
|
-
})
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The keyboard contract, asserted rather than assumed: Tab reaches
|
|
26
|
-
* it, Enter activates, Space activates. Every hand-rolled
|
|
27
|
-
* `<div onClick>` in the fleet fails all three.
|
|
28
|
-
*/
|
|
29
|
-
test("Tab reaches it, and Enter and Space both activate", async () => {
|
|
30
|
-
const onClick = fn()
|
|
31
|
-
|
|
32
|
-
const { canvas } = await mountStory(
|
|
33
|
-
composeStory(
|
|
34
|
-
{ args: { children: "Start rip", onClick } },
|
|
35
|
-
meta,
|
|
36
|
-
),
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
const button = expectAgentDrivable(canvas, {
|
|
40
|
-
name: "Start rip",
|
|
41
|
-
role: "button",
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
await userEvent.tab()
|
|
45
|
-
|
|
46
|
-
await expect(button).toHaveFocus()
|
|
47
|
-
|
|
48
|
-
await userEvent.keyboard("{Enter}")
|
|
49
|
-
|
|
50
|
-
await expect(onClick).toHaveBeenCalledTimes(1)
|
|
51
|
-
|
|
52
|
-
await userEvent.keyboard(" ")
|
|
53
|
-
|
|
54
|
-
await expect(onClick).toHaveBeenCalledTimes(2)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
test("a disabled button is skipped by Tab and ignores a click", async () => {
|
|
58
|
-
const onClick = fn()
|
|
59
|
-
|
|
60
|
-
const { canvas } = await mountStory(
|
|
61
|
-
composeStory(
|
|
62
|
-
{
|
|
63
|
-
args: {
|
|
64
|
-
children: "Start rip",
|
|
65
|
-
isDisabled: true,
|
|
66
|
-
onClick,
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
meta,
|
|
70
|
-
),
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
const button = canvas.getByRole("button", {
|
|
74
|
-
name: "Start rip",
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
await userEvent.click(button)
|
|
78
|
-
|
|
79
|
-
// A `<div role="button">` with a guard clause looks the same and
|
|
80
|
-
// is not the same: a real `disabled` is skipped by Tab, ignored
|
|
81
|
-
// by click, and reported to AT.
|
|
82
|
-
await expect(onClick).not.toHaveBeenCalled()
|
|
83
|
-
await expect(button).toBeDisabled()
|
|
84
|
-
|
|
85
|
-
await userEvent.tab()
|
|
86
|
-
|
|
87
|
-
await expect(button).not.toHaveFocus()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test("loading disables the button and announces the work", async () => {
|
|
91
|
-
const { canvas } = await mountStory(Loading)
|
|
92
|
-
|
|
93
|
-
const button = expectAgentDrivable(canvas, {
|
|
94
|
-
name: /Ripping disc 3/,
|
|
95
|
-
role: "button",
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
await expect(button).toBeDisabled()
|
|
99
|
-
await expect(button).toHaveAttribute("aria-busy", "true")
|
|
100
|
-
|
|
101
|
-
// The spinner's label lives in a `role="status"`, so a screen
|
|
102
|
-
// reader hears the work start rather than only seeing it.
|
|
103
|
-
await expect(
|
|
104
|
-
canvas.getByRole("status"),
|
|
105
|
-
).toBeInTheDocument()
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test("the states board really renders a busy button", async () => {
|
|
109
|
-
const { canvas } = await mountStory(AllStates)
|
|
110
|
-
|
|
111
|
-
await expect(
|
|
112
|
-
canvas.getByRole("button", { name: /loading/i }),
|
|
113
|
-
).toHaveAttribute("aria-busy", "true")
|
|
114
|
-
})
|