@charcuterie/ui 2.18.0 → 3.0.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.
@@ -24,7 +24,23 @@ export type SelectItem = SelectOption | SelectOptionGroup;
24
24
  * Same arrangement as `Button`, for the same reason.
25
25
  */
26
26
  export type SelectProps = Omit<ComponentPropsWithRef<"select">, "disabled" | "onChange" | "size" | "value"> & {
27
+ /**
28
+ * Goes on the **wrapper**, which is the box a caller means when
29
+ * they size a control. See the component docblock — the chevron
30
+ * is positioned against that wrapper, so a width anywhere else
31
+ * leaves it behind.
32
+ */
27
33
  className?: string;
34
+ /**
35
+ * The escape hatch for the inner `<select>` itself, for the
36
+ * things that genuinely are not the outer box: `font-mono` on the
37
+ * option text, a `text-*` override, a `bg-*`.
38
+ *
39
+ * Sizing does **not** belong here. The inner element is `w-full`
40
+ * and a width on it desynchronises it from the wrapper the
41
+ * chevron is pinned to, which is the whole of #112.
42
+ */
43
+ controlClassName?: string;
28
44
  isDisabled?: boolean;
29
45
  /**
30
46
  * The accessible name, when this is **not** inside a `Field`. A
@@ -59,6 +75,25 @@ export type SelectProps = Omit<ComponentPropsWithRef<"select">, "disabled" | "on
59
75
  * A styled native `<select>`, and the fleet's fourteen hand-rolled
60
76
  * ones collapse onto it.
61
77
  *
78
+ * ### Sizing: `className` is the wrapper, `controlClassName` is the select
79
+ *
80
+ * This renders a wrapper around the native `<select>` because the
81
+ * chevron has to live somewhere, and the chevron is positioned
82
+ * against that wrapper. `className` therefore lands on the wrapper
83
+ * and the `<select>` stays `w-full` inside it, so
84
+ * `className="w-44"` means "this control is 176px" — chevron
85
+ * included — which is what a caller writing a width already assumes.
86
+ *
87
+ * Before this, `className` went to the inner element only. The
88
+ * wrapper stayed `w-full`, the chevron stayed at *its* edge, and
89
+ * mux-magic's rules builder measured the gap at **869.6px** with the
90
+ * control's own text clipping beside it. The width never worked;
91
+ * `ui@2.16.0`'s `tailwind-merge` only made half of it work and made
92
+ * the other half visible.
93
+ *
94
+ * For the genuinely-inner things — `font-mono` on the option text, a
95
+ * `bg-*` — there is `controlClassName`.
96
+ *
62
97
  * ### There is no state kind here, and that is the finding
63
98
  *
64
99
  * Every other interactive component in this package owns its state
@@ -91,5 +126,5 @@ export type SelectProps = Omit<ComponentPropsWithRef<"select">, "disabled" | "on
91
126
  * from nothing: type-ahead, Home/End, PageUp/PageDown, the mobile
92
127
  * wheel picker, form submission, `:invalid`, and autofill.
93
128
  */
94
- export declare const Select: ({ className, isDisabled, label, onChange, options, placeholder, size, value, ...selectProps }: SelectProps) => ReactNode;
129
+ export declare const Select: ({ className, controlClassName, isDisabled, label, onChange, options, placeholder, size, value, ...selectProps }: SelectProps) => ReactNode;
95
130
  //# sourceMappingURL=Select.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AASd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,iBAAiB,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,CAC3C,GAAG;IACF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;IAC9B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,MAAM,GAAI,+FAUpB,WAAW,KAAG,SAkFhB,CAAA"}
1
+ {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AASd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,iBAAiB,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,CAC3C,GAAG;IACF;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;IAC9B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,MAAM,GAAI,iHAWpB,WAAW,KAAG,SAsGhB,CAAA"}
@@ -7,6 +7,25 @@ const getIsGroup = (item) => "options" in item;
7
7
  * A styled native `<select>`, and the fleet's fourteen hand-rolled
8
8
  * ones collapse onto it.
9
9
  *
10
+ * ### Sizing: `className` is the wrapper, `controlClassName` is the select
11
+ *
12
+ * This renders a wrapper around the native `<select>` because the
13
+ * chevron has to live somewhere, and the chevron is positioned
14
+ * against that wrapper. `className` therefore lands on the wrapper
15
+ * and the `<select>` stays `w-full` inside it, so
16
+ * `className="w-44"` means "this control is 176px" — chevron
17
+ * included — which is what a caller writing a width already assumes.
18
+ *
19
+ * Before this, `className` went to the inner element only. The
20
+ * wrapper stayed `w-full`, the chevron stayed at *its* edge, and
21
+ * mux-magic's rules builder measured the gap at **869.6px** with the
22
+ * control's own text clipping beside it. The width never worked;
23
+ * `ui@2.16.0`'s `tailwind-merge` only made half of it work and made
24
+ * the other half visible.
25
+ *
26
+ * For the genuinely-inner things — `font-mono` on the option text, a
27
+ * `bg-*` — there is `controlClassName`.
28
+ *
10
29
  * ### There is no state kind here, and that is the finding
11
30
  *
12
31
  * Every other interactive component in this package owns its state
@@ -39,7 +58,20 @@ const getIsGroup = (item) => "options" in item;
39
58
  * from nothing: type-ahead, Home/End, PageUp/PageDown, the mobile
40
59
  * wheel picker, form submission, `:invalid`, and autofill.
41
60
  */
42
- export const Select = ({ className, isDisabled = false, label, onChange, options, placeholder, size = "md", value, ...selectProps }) => (_jsxs("div", { className: "relative inline-grid w-full items-center", children: [_jsxs("select", { ...selectProps, "aria-label": label, className: toClassName("w-full cursor-pointer appearance-none rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard",
61
+ export const Select = ({ className, controlClassName, isDisabled = false, label, onChange, options, placeholder, size = "md", value, ...selectProps }) => (_jsxs("div", { className: toClassName("relative inline-grid w-full items-center",
62
+ // `className` lands HERE, not on the `<select>`, and that is
63
+ // the fix for #112 rather than a stylistic choice. The chevron
64
+ // below is `absolute end-3` against this element, so a width
65
+ // that reaches only the inner control moves the control and
66
+ // leaves the chevron pinned to the old edge — measured at
67
+ // 869.6px adrift in mux-magic's rules builder, with the
68
+ // control's own text clipping at the same time.
69
+ //
70
+ // It also matches every other component in this package:
71
+ // `className` is the outermost box. `ms-auto` — which mux-magic
72
+ // passes and which did nothing on a `w-full` inner select — is
73
+ // the tell that callers already believed this.
74
+ className), children: [_jsxs("select", { ...selectProps, "aria-label": label, className: toClassName("w-full cursor-pointer appearance-none rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard",
43
75
  // Room for the chevron below, which sits in the same grid
44
76
  // cell. A `<select>`'s own arrow is what `appearance-none`
45
77
  // removes, and forgetting to put one back leaves a control
@@ -50,7 +82,11 @@ export const Select = ({ className, isDisabled = false, label, onChange, options
50
82
  // job (`[data-density]` scales `--control-height-*` past 44px on a
51
83
  // kiosk), so forcing 44px here made Select taller than Button et al.
52
84
  // at desktop density. See the controls-share-one-height decision.
53
- CONTROL_SIZE_CLASS[size], "hover:border-border-strong", FOCUS_RING_CLASS, DISABLED_CLASS, className), defaultValue: value ??
85
+ CONTROL_SIZE_CLASS[size], "hover:border-border-strong", FOCUS_RING_CLASS, DISABLED_CLASS,
86
+ // Inner-only, and deliberately not `className`. `w-full`
87
+ // above keeps this element the same width as the wrapper the
88
+ // chevron is measured from, so the two can never drift.
89
+ controlClassName), defaultValue: value ??
54
90
  (placeholder === undefined ? undefined : ""), disabled: isDisabled, onChange: (changeEvent) => {
55
91
  onChange?.(changeEvent.currentTarget.value);
56
92
  }, children: [placeholder === undefined ? null : (_jsx("option", { disabled: true, value: "", children: placeholder })), options.map((item) => getIsGroup(item) ? (_jsx("optgroup", { label: item.label, children: item.options.map((option) => (_jsx("option", { disabled: option.isDisabled, value: option.value, children: option.label }, option.value))) }, item.label)) : (_jsx("option", { disabled: item.isDisabled, value: item.value, children: item.label }, item.value)))] }), _jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute end-3 size-4 text-content-secondary", fill: "none", focusable: false, stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.75, viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }));
@@ -1 +1 @@
1
- {"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":";AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,EACL,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAgE/C,MAAM,UAAU,GAAG,CACjB,IAAgB,EACW,EAAE,CAAC,SAAS,IAAI,IAAI,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAW,EACX,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE,CAAC,CAC5B,eAAK,SAAS,EAAC,0CAA0C,aACvD,qBACM,WAAW,gBACH,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,iLAAiL;YACjL,0DAA0D;YAC1D,2DAA2D;YAC3D,2DAA2D;YAC3D,wCAAwC;YACxC,MAAM;YACN,kEAAkE;YAClE,mEAAmE;YACnE,mEAAmE;YACnE,qEAAqE;YACrE,kEAAkE;YAClE,kBAAkB,CAAC,IAAI,CAAC,EACxB,4BAA4B,EAC5B,gBAAgB,EAChB,cAAc,EACd,SAAS,CACV,EACD,YAAY,EACV,KAAK;gBACL,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,EAE9C,QAAQ,EAAE,UAAU,EACpB,QAAQ,EAAE,CACR,WAA2C,EAC3C,EAAE;gBACF,QAAQ,EAAE,CAAC,WAAW,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;YAC7C,CAAC,aAEA,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,iBAAQ,QAAQ,QAAC,KAAK,EAAC,EAAE,YACtB,WAAW,GACL,CACV,EAEA,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACpB,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CACjB,mBAA2B,KAAK,EAAE,IAAI,CAAC,KAAK,YACzC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAC5B,iBACE,QAAQ,EAAE,MAAM,CAAC,UAAU,EAE3B,KAAK,EAAE,MAAM,CAAC,KAAK,YAElB,MAAM,CAAC,KAAK,IAHR,MAAM,CAAC,KAAK,CAIV,CACV,CAAC,IATW,IAAI,CAAC,KAAK,CAUd,CACZ,CAAC,CAAC,CAAC,CACF,iBACE,QAAQ,EAAE,IAAI,CAAC,UAAU,EAEzB,KAAK,EAAE,IAAI,CAAC,KAAK,YAEhB,IAAI,CAAC,KAAK,IAHN,IAAI,CAAC,KAAK,CAIR,CACV,CACF,IACM,EAET,6BAGc,MAAM,EAClB,SAAS,EAAC,kEAAkE,EAC5E,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,IACF,CACP,CAAA"}
1
+ {"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.18.0",
3
+ "version": "3.0.0",
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",
@@ -17,6 +17,50 @@ see below.
17
17
 
18
18
  <Controls />
19
19
 
20
+ ## Sizing: `className` is the wrapper, `controlClassName` is the select
21
+
22
+ The chevron has to live somewhere, so this renders a wrapper around the native
23
+ `<select>` and positions the chevron against **that wrapper**. `className` therefore
24
+ lands on the wrapper and the `<select>` stays `w-full` inside it:
25
+
26
+ ```tsx
27
+ <Select className="w-44" controlClassName="font-mono" label="Operation" options={OPS} />
28
+ ```
29
+
30
+ means "this control is 176px" — chevron included — which is what a caller writing a
31
+ width already assumes.
32
+
33
+ It did not used to. `className` reached only the inner element while the wrapper stayed
34
+ `w-full`, so the control shrank and the chevron stayed pinned to the row's right edge:
35
+
36
+ | | left | right | width |
37
+ | --- | --- | --- | --- |
38
+ | wrapper | 55 | 1128.7 | 1073.6 |
39
+ | select | 55 | 231 | 176 |
40
+ | chevron | 1100.7 | 1116.7 | — |
41
+
42
+ **869.6px** between the control and its own chevron, measured in mux-magic's DSL rules
43
+ builder, with the control's own text clipping at the same time (`setScriptInfo` →
44
+ `setScriptInfc`). The width never worked; `ui@2.16.0`'s `tailwind-merge` made half of it
45
+ work, which is what made the other half visible
46
+ ([#112](https://github.com/Sawtaytoes/charcuterie/issues/112)).
47
+
48
+ `Select.test.tsx` asserts this as a **measurement** rather than a class-name check —
49
+ every class-name assertion passed while the chevron was 869.6px away, because `w-44`
50
+ really was present, on the element nobody was looking at.
51
+
52
+ <Canvas of={SelectStories.Sized} />
53
+
54
+ ### What goes where
55
+
56
+ | | Goes to | For |
57
+ | --- | --- | --- |
58
+ | `className` | the wrapper | width, margin (`ms-auto`), display, position — the outer box |
59
+ | `controlClassName` | the `<select>` | `font-mono` on the option text, a `text-*`, a `bg-*` |
60
+
61
+ A width in `controlClassName` is the bug again by hand: it desynchronises the inner
62
+ element from the wrapper the chevron is measured against.
63
+
20
64
  ## There is no state kind here, and that is the finding
21
65
 
22
66
  Every other interactive component in this package owns its state through
@@ -132,3 +132,58 @@ export const AllStates: Story = {
132
132
  </StoryGrid>
133
133
  ),
134
134
  }
135
+
136
+ /**
137
+ * `className` sizes the **control**, chevron included, because it
138
+ * lands on the wrapper the chevron is positioned against.
139
+ *
140
+ * The first cell is the shape that broke: before this, `w-44`
141
+ * reached only the inner `<select>` while the wrapper stayed
142
+ * `w-full`, so the chevron sat at the *row's* right edge — 869.6px
143
+ * away in mux-magic's rules builder. `controlClassName` is the
144
+ * inner element, for the things that are not the outer box, and
145
+ * `font-mono` is the one every caller reaches for.
146
+ *
147
+ * The width is still the caller's to get right: `w-44` is narrower
148
+ * than the longest option here, and a `<select>` clips rather than
149
+ * wraps. That is why mux-magic measured its widths rather than
150
+ * guessing them.
151
+ */
152
+ export const Sized: Story = {
153
+ args: { label: "Rip profile", options: PROFILES },
154
+ render: () => (
155
+ <StoryGrid columns={1}>
156
+ <StoryCell label="w-44, in a wide row">
157
+ <Select
158
+ className="w-44"
159
+ label="Profile sized"
160
+ options={PROFILES}
161
+ />
162
+ </StoryCell>
163
+
164
+ <StoryCell label="w-64, monospaced option text">
165
+ <Select
166
+ className="w-64"
167
+ controlClassName="font-mono"
168
+ label="Profile monospaced"
169
+ options={PROFILES}
170
+ />
171
+ </StoryCell>
172
+
173
+ <StoryCell label="ms-auto, pushed to the end">
174
+ <Select
175
+ className="ms-auto w-56"
176
+ label="Profile pushed"
177
+ options={PROFILES}
178
+ />
179
+ </StoryCell>
180
+
181
+ <StoryCell label="default: fills its parent">
182
+ <Select
183
+ label="Profile full width"
184
+ options={PROFILES}
185
+ />
186
+ </StoryCell>
187
+ </StoryGrid>
188
+ ),
189
+ }
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Select.stories.tsx"
9
9
 
10
- const { AllStates, AllVariants, Default } =
10
+ const { AllStates, AllVariants, Default, Sized } =
11
11
  composeStories(stories)
12
12
 
13
13
  test("a select is named, and therefore findable", async () => {
@@ -127,3 +127,91 @@ test("inside a Field, the label does the naming", async () => {
127
127
 
128
128
  await expect(select).toHaveAttribute("aria-describedby")
129
129
  })
130
+
131
+ /**
132
+ * The #112 regression test, and it has to be a *measurement*. Every
133
+ * class-name assertion this could have been passed while the control
134
+ * was 869.6px away from its own chevron: `w-44` was present, on the
135
+ * element nobody was looking at.
136
+ */
137
+ test("a width on className sizes the control, chevron included", async () => {
138
+ const { canvas } = await mountStory(Sized)
139
+
140
+ const select = expectAgentDrivable(canvas, {
141
+ name: "Profile sized",
142
+ role: "combobox",
143
+ }) as HTMLSelectElement
144
+
145
+ const wrapper = select.parentElement as HTMLElement
146
+ const chevron = wrapper.querySelector("svg") as SVGElement
147
+
148
+ const selectBox = select.getBoundingClientRect()
149
+ const wrapperBox = wrapper.getBoundingClientRect()
150
+ const chevronBox = chevron.getBoundingClientRect()
151
+
152
+ // 11rem. The caller asked for one box, not two of different sizes.
153
+ await expect(wrapperBox.width).toBe(176)
154
+
155
+ await expect(selectBox.width).toBe(wrapperBox.width)
156
+
157
+ // The chevron is `end-3` against the wrapper, so this is the whole
158
+ // bug in one number: it used to read 869.6.
159
+ await expect(
160
+ selectBox.right - chevronBox.right,
161
+ ).toBeCloseTo(12, 1)
162
+
163
+ // …and it is inside the control on the other side too, rather than
164
+ // merely near it.
165
+ await expect(chevronBox.left).toBeGreaterThan(
166
+ selectBox.left,
167
+ )
168
+ })
169
+
170
+ /**
171
+ * The other half of the split: an inner-element class still reaches
172
+ * the inner element, and does not become the wrapper's problem.
173
+ */
174
+ test("controlClassName reaches the select, not the wrapper", async () => {
175
+ const { canvas } = await mountStory(Sized)
176
+
177
+ const select = expectAgentDrivable(canvas, {
178
+ name: "Profile monospaced",
179
+ role: "combobox",
180
+ }) as HTMLSelectElement
181
+
182
+ const wrapper = select.parentElement as HTMLElement
183
+
184
+ await expect(select.className).toContain("font-mono")
185
+
186
+ await expect(wrapper.className).not.toContain("font-mono")
187
+
188
+ // …and the wrapper is still the one carrying the width.
189
+ await expect(wrapper.getBoundingClientRect().width).toBe(
190
+ 256,
191
+ )
192
+ })
193
+
194
+ /**
195
+ * The default is unchanged, which is the other half of the fix being
196
+ * safe: every existing caller passes no width and expects the
197
+ * control to fill its parent.
198
+ */
199
+ test("with no className, the control still fills its parent", async () => {
200
+ const { canvas } = await mountStory(Sized)
201
+
202
+ const select = expectAgentDrivable(canvas, {
203
+ name: "Profile full width",
204
+ role: "combobox",
205
+ }) as HTMLSelectElement
206
+
207
+ const wrapper = select.parentElement as HTMLElement
208
+ const parent = wrapper.parentElement as HTMLElement
209
+
210
+ await expect(
211
+ wrapper.getBoundingClientRect().width,
212
+ ).toBeCloseTo(parent.getBoundingClientRect().width, 1)
213
+
214
+ await expect(
215
+ select.getBoundingClientRect().width,
216
+ ).toBeCloseTo(wrapper.getBoundingClientRect().width, 1)
217
+ })
@@ -42,7 +42,23 @@ export type SelectProps = Omit<
42
42
  ComponentPropsWithRef<"select">,
43
43
  "disabled" | "onChange" | "size" | "value"
44
44
  > & {
45
+ /**
46
+ * Goes on the **wrapper**, which is the box a caller means when
47
+ * they size a control. See the component docblock — the chevron
48
+ * is positioned against that wrapper, so a width anywhere else
49
+ * leaves it behind.
50
+ */
45
51
  className?: string
52
+ /**
53
+ * The escape hatch for the inner `<select>` itself, for the
54
+ * things that genuinely are not the outer box: `font-mono` on the
55
+ * option text, a `text-*` override, a `bg-*`.
56
+ *
57
+ * Sizing does **not** belong here. The inner element is `w-full`
58
+ * and a width on it desynchronises it from the wrapper the
59
+ * chevron is pinned to, which is the whole of #112.
60
+ */
61
+ controlClassName?: string
46
62
  isDisabled?: boolean
47
63
  /**
48
64
  * The accessible name, when this is **not** inside a `Field`. A
@@ -82,6 +98,25 @@ const getIsGroup = (
82
98
  * A styled native `<select>`, and the fleet's fourteen hand-rolled
83
99
  * ones collapse onto it.
84
100
  *
101
+ * ### Sizing: `className` is the wrapper, `controlClassName` is the select
102
+ *
103
+ * This renders a wrapper around the native `<select>` because the
104
+ * chevron has to live somewhere, and the chevron is positioned
105
+ * against that wrapper. `className` therefore lands on the wrapper
106
+ * and the `<select>` stays `w-full` inside it, so
107
+ * `className="w-44"` means "this control is 176px" — chevron
108
+ * included — which is what a caller writing a width already assumes.
109
+ *
110
+ * Before this, `className` went to the inner element only. The
111
+ * wrapper stayed `w-full`, the chevron stayed at *its* edge, and
112
+ * mux-magic's rules builder measured the gap at **869.6px** with the
113
+ * control's own text clipping beside it. The width never worked;
114
+ * `ui@2.16.0`'s `tailwind-merge` only made half of it work and made
115
+ * the other half visible.
116
+ *
117
+ * For the genuinely-inner things — `font-mono` on the option text, a
118
+ * `bg-*` — there is `controlClassName`.
119
+ *
85
120
  * ### There is no state kind here, and that is the finding
86
121
  *
87
122
  * Every other interactive component in this package owns its state
@@ -116,6 +151,7 @@ const getIsGroup = (
116
151
  */
117
152
  export const Select = ({
118
153
  className,
154
+ controlClassName,
119
155
  isDisabled = false,
120
156
  label,
121
157
  onChange,
@@ -125,7 +161,24 @@ export const Select = ({
125
161
  value,
126
162
  ...selectProps
127
163
  }: SelectProps): ReactNode => (
128
- <div className="relative inline-grid w-full items-center">
164
+ <div
165
+ className={toClassName(
166
+ "relative inline-grid w-full items-center",
167
+ // `className` lands HERE, not on the `<select>`, and that is
168
+ // the fix for #112 rather than a stylistic choice. The chevron
169
+ // below is `absolute end-3` against this element, so a width
170
+ // that reaches only the inner control moves the control and
171
+ // leaves the chevron pinned to the old edge — measured at
172
+ // 869.6px adrift in mux-magic's rules builder, with the
173
+ // control's own text clipping at the same time.
174
+ //
175
+ // It also matches every other component in this package:
176
+ // `className` is the outermost box. `ms-auto` — which mux-magic
177
+ // passes and which did nothing on a `w-full` inner select — is
178
+ // the tell that callers already believed this.
179
+ className,
180
+ )}
181
+ >
129
182
  <select
130
183
  {...selectProps}
131
184
  aria-label={label}
@@ -145,7 +198,10 @@ export const Select = ({
145
198
  "hover:border-border-strong",
146
199
  FOCUS_RING_CLASS,
147
200
  DISABLED_CLASS,
148
- className,
201
+ // Inner-only, and deliberately not `className`. `w-full`
202
+ // above keeps this element the same width as the wrapper the
203
+ // chevron is measured from, so the two can never drift.
204
+ controlClassName,
149
205
  )}
150
206
  defaultValue={
151
207
  value ??