@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.
@@ -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 { AllStates, Playground, Interactive, NextToText } =
11
- composeStories(stories)
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
+ })
@@ -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={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
- {currentOption === undefined
186
- ? placeholder
187
- : currentOption.label}
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"
@@ -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} }`)
@@ -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
- })