@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.
- package/dist/Select/Select.d.ts +36 -1
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +38 -2
- package/dist/Select/Select.js.map +1 -1
- package/package.json +1 -1
- package/src/Select/Select.mdx +44 -0
- package/src/Select/Select.stories.tsx +55 -0
- package/src/Select/Select.test.tsx +89 -1
- package/src/Select/Select.tsx +58 -2
package/dist/Select/Select.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/Select/Select.js
CHANGED
|
@@ -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",
|
|
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,
|
|
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;
|
|
1
|
+
{"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/Select/Select.mdx
CHANGED
|
@@ -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
|
+
})
|
package/src/Select/Select.tsx
CHANGED
|
@@ -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
|
|
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 ??
|