@charcuterie/ui 4.22.0 → 4.22.1

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.
@@ -84,6 +84,21 @@ export type PickerProps = Omit<ComponentPropsWithRef<"button">, "disabled" | "on
84
84
  * mux-magic both moved to it while `id` was still being replaced — but
85
85
  * `id` is no longer a trap. Everything else on the button (`data-*`,
86
86
  * `onClick`, `form`, …) passes straight through.
87
+ *
88
+ * ### A stretched trigger reads like a select, not a button
89
+ *
90
+ * `Button` centers its content, which is right for an action and wrong
91
+ * for a value. Inside a `Field` (a `flex-col`, so the child stretches)
92
+ * or under `w-full`, the trigger used to become a bar with the value
93
+ * floating in the middle and the chevron beside it. The value now sits
94
+ * at the inline start, in a span that grows into the spare width and
95
+ * ends in an ellipsis when there is too little, and the chevron sits at
96
+ * the inline end. `justify-between` is passed through `className`, which
97
+ * `toClassName` merges over `Button`'s `justify-center` rather than
98
+ * leaving the two to stylesheet order — and the growing span makes the
99
+ * result correct even if a caller's `className` overrides it. A trigger
100
+ * at its content width has no spare width to distribute, so it renders
101
+ * exactly as it did before.
87
102
  */
88
103
  export declare const Picker: ({ appearance, className, iconEnd, intent, isDisabled, label, onChange, options, placement, placeholder, size, value, ...buttonProps }: PickerProps) => ReactNode;
89
104
  //# sourceMappingURL=Picker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAGd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,MAAM,MAAM,YAAY,GAAG,WAAW,CAAA;AAwBtC,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,OAAO,CAClC,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;IAChC,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,MAAM,GAAI,uIAcpB,WAAW,KAAG,SAoDhB,CAAA"}
1
+ {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAGd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAIzD,MAAM,MAAM,YAAY,GAAG,WAAW,CAAA;AAwBtC,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,OAAO,CAClC,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;IAChC,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AACH,eAAO,MAAM,MAAM,GAAI,uIAcpB,WAAW,KAAG,SA2EhB,CAAA"}
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useVisibility } from "@charcuterie/logic";
3
3
  import { Button } from "../Button/Button.js";
4
4
  import { Listbox } from "../Listbox/Listbox.js";
5
+ import { toClassName } from "../toClassName.js";
5
6
  /**
6
7
  * A constant element rather than a component, for the same reason
7
8
  * `Select` inlines its own: a second component in this file trips the
@@ -62,6 +63,21 @@ const CHEVRON_DOWN = (_jsx("svg", { "aria-hidden": "true", className: "size-4",
62
63
  * mux-magic both moved to it while `id` was still being replaced — but
63
64
  * `id` is no longer a trap. Everything else on the button (`data-*`,
64
65
  * `onClick`, `form`, …) passes straight through.
66
+ *
67
+ * ### A stretched trigger reads like a select, not a button
68
+ *
69
+ * `Button` centers its content, which is right for an action and wrong
70
+ * for a value. Inside a `Field` (a `flex-col`, so the child stretches)
71
+ * or under `w-full`, the trigger used to become a bar with the value
72
+ * floating in the middle and the chevron beside it. The value now sits
73
+ * at the inline start, in a span that grows into the spare width and
74
+ * ends in an ellipsis when there is too little, and the chevron sits at
75
+ * the inline end. `justify-between` is passed through `className`, which
76
+ * `toClassName` merges over `Button`'s `justify-center` rather than
77
+ * leaving the two to stylesheet order — and the growing span makes the
78
+ * result correct even if a caller's `className` overrides it. A trigger
79
+ * at its content width has no spare width to distribute, so it renders
80
+ * exactly as it did before.
65
81
  */
66
82
  export const Picker = ({ appearance = "outline", className, iconEnd = CHEVRON_DOWN, intent = "neutral", isDisabled = false, label, onChange, options, placement, placeholder, size, value, ...buttonProps }) => {
67
83
  const { hide, isVisible, toggle } = useVisibility();
@@ -73,6 +89,14 @@ export const Picker = ({ appearance = "outline", className, iconEnd = CHEVRON_DO
73
89
  ? currentOption.label
74
90
  : undefined));
75
91
  const triggerText = currentText ?? placeholder ?? "";
92
+ const triggerContent = currentOption === undefined
93
+ ? placeholder
94
+ : currentOption.label;
95
+ // No content, no span: an empty flex item would still earn a `gap`
96
+ // before the chevron and widen a content-width trigger.
97
+ const hasTriggerContent = triggerContent !== undefined &&
98
+ triggerContent !== null &&
99
+ triggerContent !== "";
76
100
  return (_jsx(Listbox
77
101
  // A disabled control must not open, and hiding the panel is a
78
102
  // truer way to say that than trusting the trigger's click alone —
@@ -81,11 +105,9 @@ export const Picker = ({ appearance = "outline", className, iconEnd = CHEVRON_DO
81
105
  // A disabled control must not open, and hiding the panel is a
82
106
  // truer way to say that than trusting the trigger's click alone —
83
107
  // Escape and outside-press both route through `onDismiss`.
84
- isVisible: isVisible && !isDisabled, onDismiss: hide, onSelect: onChange, options: options, placement: placement, selectedValue: value, trigger: _jsx(Button, { ...buttonProps, appearance: appearance, "aria-label": `${label}: ${triggerText}`, className: className, iconEnd: iconEnd, intent: intent, isDisabled: isDisabled, onClick: (clickEvent) => {
108
+ isVisible: isVisible && !isDisabled, onDismiss: hide, onSelect: onChange, options: options, placement: placement, selectedValue: value, trigger: _jsx(Button, { ...buttonProps, appearance: appearance, "aria-label": `${label}: ${triggerText}`, className: toClassName("justify-between", className), iconEnd: iconEnd, intent: intent, isDisabled: isDisabled, onClick: (clickEvent) => {
85
109
  buttonProps.onClick?.(clickEvent);
86
110
  toggle();
87
- }, size: size, type: "button", children: currentOption === undefined
88
- ? placeholder
89
- : currentOption.label }) }));
111
+ }, size: size, type: "button", children: hasTriggerContent ? (_jsx("span", { className: "-mx-1 min-w-0 grow overflow-x-clip px-1 text-start text-ellipsis whitespace-nowrap", children: triggerContent })) : null }) }));
90
112
  };
91
113
  //# sourceMappingURL=Picker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Picker.js","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAWlD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAG7C,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAwB,CAAA;AAIhD;;;;GAIG;AACH,MAAM,YAAY,GAAG,CACnB,6BACc,MAAM,EAClB,SAAS,EAAC,QAAQ,EAClB,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,CACP,CAAA;AAgCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,UAAU,GAAG,SAAS,EACtB,SAAS,EACT,OAAO,GAAG,YAAY,EACtB,MAAM,GAAG,SAAS,EAClB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,SAAS,EACT,WAAW,EACX,IAAI,EACJ,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;IAEnD,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,KAAK,KAAK,CACnC,CAAA;IAED,MAAM,WAAW,GACf,aAAa,KAAK,SAAS;QACzB,CAAC,CAAC,WAAW;QACb,CAAC,CAAC,CAAC,aAAa,CAAC,SAAS;YACxB,CAAC,OAAO,aAAa,CAAC,KAAK,KAAK,QAAQ;gBACtC,CAAC,CAAC,aAAa,CAAC,KAAK;gBACrB,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IAErB,MAAM,WAAW,GAAG,WAAW,IAAI,WAAW,IAAI,EAAE,CAAA;IAEpD,OAAO,CACL,KAAC,OAAO;IACN,8DAA8D;IAC9D,kEAAkE;IAClE,2DAA2D;;QAF3D,8DAA8D;QAC9D,kEAAkE;QAClE,2DAA2D;QAC3D,SAAS,EAAE,SAAS,IAAI,CAAC,UAAU,EACnC,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,KAAK,EACpB,OAAO,EACL,KAAC,MAAM,OACD,WAAW,EACf,UAAU,EAAE,UAAU,gBACV,GAAG,KAAK,KAAK,WAAW,EAAE,EACtC,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;gBACtB,WAAW,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,CAAA;gBAEjC,MAAM,EAAE,CAAA;YACV,CAAC,EACD,IAAI,EAAE,IAAI,EACV,IAAI,EAAC,QAAQ,YAEZ,aAAa,KAAK,SAAS;gBAC1B,CAAC,CAAC,WAAW;gBACb,CAAC,CAAC,aAAa,CAAC,KAAK,GAChB,GAEX,CACH,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"Picker.js","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAWlD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAG7C,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAwB,CAAA;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAI/C;;;;GAIG;AACH,MAAM,YAAY,GAAG,CACnB,6BACc,MAAM,EAClB,SAAS,EAAC,QAAQ,EAClB,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,CACP,CAAA;AAgCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,UAAU,GAAG,SAAS,EACtB,SAAS,EACT,OAAO,GAAG,YAAY,EACtB,MAAM,GAAG,SAAS,EAClB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,SAAS,EACT,WAAW,EACX,IAAI,EACJ,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;IAEnD,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,KAAK,KAAK,CACnC,CAAA;IAED,MAAM,WAAW,GACf,aAAa,KAAK,SAAS;QACzB,CAAC,CAAC,WAAW;QACb,CAAC,CAAC,CAAC,aAAa,CAAC,SAAS;YACxB,CAAC,OAAO,aAAa,CAAC,KAAK,KAAK,QAAQ;gBACtC,CAAC,CAAC,aAAa,CAAC,KAAK;gBACrB,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IAErB,MAAM,WAAW,GAAG,WAAW,IAAI,WAAW,IAAI,EAAE,CAAA;IAEpD,MAAM,cAAc,GAClB,aAAa,KAAK,SAAS;QACzB,CAAC,CAAC,WAAW;QACb,CAAC,CAAC,aAAa,CAAC,KAAK,CAAA;IAEzB,mEAAmE;IACnE,wDAAwD;IACxD,MAAM,iBAAiB,GACrB,cAAc,KAAK,SAAS;QAC5B,cAAc,KAAK,IAAI;QACvB,cAAc,KAAK,EAAE,CAAA;IAEvB,OAAO,CACL,KAAC,OAAO;IACN,8DAA8D;IAC9D,kEAAkE;IAClE,2DAA2D;;QAF3D,8DAA8D;QAC9D,kEAAkE;QAClE,2DAA2D;QAC3D,SAAS,EAAE,SAAS,IAAI,CAAC,UAAU,EACnC,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,KAAK,EACpB,OAAO,EACL,KAAC,MAAM,OACD,WAAW,EACf,UAAU,EAAE,UAAU,gBACV,GAAG,KAAK,KAAK,WAAW,EAAE,EACtC,SAAS,EAAE,WAAW,CACpB,iBAAiB,EACjB,SAAS,CACV,EACD,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;gBACtB,WAAW,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,CAAA;gBAEjC,MAAM,EAAE,CAAA;YACV,CAAC,EACD,IAAI,EAAE,IAAI,EACV,IAAI,EAAC,QAAQ,YAEZ,iBAAiB,CAAC,CAAC,CAAC,CAOnB,eAAM,SAAS,EAAC,oFAAoF,YACjG,cAAc,GACV,CACR,CAAC,CAAC,CAAC,IAAI,GACD,GAEX,CACH,CAAA;AACH,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "4.22.0",
3
+ "version": "4.22.1",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }
@@ -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
  />