@charcuterie/ui 2.17.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/Checkbox/Checkbox.d.ts +20 -1
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +2 -2
- package/dist/Checkbox/Checkbox.js.map +1 -1
- 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/Checkbox/Checkbox.mdx +24 -0
- package/src/Checkbox/Checkbox.stories.tsx +42 -0
- package/src/Checkbox/Checkbox.test.tsx +52 -1
- package/src/Checkbox/Checkbox.tsx +21 -0
- 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
|
@@ -34,6 +34,25 @@ export type CheckboxProps = {
|
|
|
34
34
|
label: ReactNode;
|
|
35
35
|
onChange?: (isChecked: boolean) => void;
|
|
36
36
|
size?: ControlSize;
|
|
37
|
+
/**
|
|
38
|
+
* The `<input>`'s `value` attribute — which member of a group this
|
|
39
|
+
* box IS, as opposed to whether it is ticked.
|
|
40
|
+
*
|
|
41
|
+
* A lone boolean does not need this. A GROUP does: the browser
|
|
42
|
+
* submits `name=value` for each ticked box in a form, and, more
|
|
43
|
+
* relevantly here, a group is normally read back with one query —
|
|
44
|
+
* `[...root.querySelectorAll("input")].filter(i => i.checked)
|
|
45
|
+
* .map(i => i.value)`. Without a `value` every box in the group
|
|
46
|
+
* reports the string `"on"` and the read is useless, which is why
|
|
47
|
+
* queuepilot's library and ratings pickers were still hand-rolling
|
|
48
|
+
* a raw `<input type="checkbox">` rather than adopting this
|
|
49
|
+
* component.
|
|
50
|
+
*
|
|
51
|
+
* Passed straight through and otherwise inert. It is NOT the
|
|
52
|
+
* checked state and does not become one: `isChecked` still seeds
|
|
53
|
+
* the box, and `onChange` still reports a boolean.
|
|
54
|
+
*/
|
|
55
|
+
value?: string;
|
|
37
56
|
};
|
|
38
57
|
/**
|
|
39
58
|
* A single boolean, on or off, that owns nothing but itself.
|
|
@@ -64,5 +83,5 @@ export type CheckboxProps = {
|
|
|
64
83
|
* `<input>`'s job. `isChecked` seeds the first paint and nothing
|
|
65
84
|
* after.
|
|
66
85
|
*/
|
|
67
|
-
export declare const Checkbox: ({ className, id, isChecked, isDisabled, isReadOnly, label, onChange, size, }: CheckboxProps) => ReactNode;
|
|
86
|
+
export declare const Checkbox: ({ className, id, isChecked, isDisabled, isReadOnly, label, onChange, size, value, }: CheckboxProps) => ReactNode;
|
|
68
87
|
//# sourceMappingURL=Checkbox.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,qFAUtB,aAAa,KAAG,SA2GlB,CAAA"}
|
|
@@ -45,7 +45,7 @@ const TEXT_SIZE_CLASS = {
|
|
|
45
45
|
* `<input>`'s job. `isChecked` seeds the first paint and nothing
|
|
46
46
|
* after.
|
|
47
47
|
*/
|
|
48
|
-
export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", }) => (_jsxs("label", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
|
|
48
|
+
export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", value, }) => (_jsxs("label", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
|
|
49
49
|
// One cursor, chosen by state — two cursor utilities of the same
|
|
50
50
|
// specificity do not resolve by class-list order.
|
|
51
51
|
isDisabled
|
|
@@ -99,7 +99,7 @@ export const Checkbox = ({ className, id, isChecked = false, isDisabled = false,
|
|
|
99
99
|
if (isReadOnly && keyEvent.key === " ") {
|
|
100
100
|
keyEvent.preventDefault();
|
|
101
101
|
}
|
|
102
|
-
}, type: "checkbox" }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute peer-checked:visible",
|
|
102
|
+
}, type: "checkbox", value: value }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute peer-checked:visible",
|
|
103
103
|
// Matches whichever fill the box wears, so the tick keeps its
|
|
104
104
|
// guaranteed on-solid contrast in either intent.
|
|
105
105
|
isReadOnly
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"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"}
|
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":";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;AAgF/C,MAAM,UAAU,GAAG,CACjB,IAAgB,EACW,EAAE,CAAC,SAAS,IAAI,IAAI,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,gBAAgB,EAChB,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,eACE,SAAS,EAAE,WAAW,CACpB,0CAA0C;IAC1C,6DAA6D;IAC7D,+DAA+D;IAC/D,6DAA6D;IAC7D,4DAA4D;IAC5D,0DAA0D;IAC1D,wDAAwD;IACxD,gDAAgD;IAChD,EAAE;IACF,yDAAyD;IACzD,gEAAgE;IAChE,+DAA+D;IAC/D,+CAA+C;IAC/C,SAAS,CACV,aAED,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;YACd,yDAAyD;YACzD,6DAA6D;YAC7D,wDAAwD;YACxD,gBAAgB,CACjB,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"}
|
package/package.json
CHANGED
|
@@ -62,6 +62,30 @@ is a control every consumer has to hold it for.
|
|
|
62
62
|
here and change elsewhere, not an actionable accent. It announces `aria-readonly`, and
|
|
63
63
|
the toggle is blocked on both pointer and `Space`.
|
|
64
64
|
|
|
65
|
+
## A group of boxes needs `value`
|
|
66
|
+
|
|
67
|
+
`value` is the `<input>`'s `value` attribute: **which member of a group** this box is, as
|
|
68
|
+
opposed to whether it is ticked. A lone boolean should omit it.
|
|
69
|
+
|
|
70
|
+
A group needs it, because a group is read back with one query over its container rather
|
|
71
|
+
than by keeping a callback's worth of state per box:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const chosen = [...group.querySelectorAll<HTMLInputElement>("input[type=checkbox]")]
|
|
75
|
+
.filter((input) => input.checked)
|
|
76
|
+
.map((input) => input.value)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Without a `value` every box in the group answers the UA default `"on"`, so that read
|
|
80
|
+
returns N copies of one meaningless string. This is why queuepilot's library and ratings
|
|
81
|
+
pickers were still hand-rolling a raw `<input type="checkbox">` instead of using this
|
|
82
|
+
component — the component was correct and simply could not express the case.
|
|
83
|
+
|
|
84
|
+
It is passed straight through and is otherwise inert: it is **not** the checked state and
|
|
85
|
+
does not become one. `isChecked` still seeds the box, `onChange` still reports a boolean.
|
|
86
|
+
|
|
87
|
+
<Canvas of={CheckboxStories.WithValues} />
|
|
88
|
+
|
|
65
89
|
## Checkbox or `Switch`
|
|
66
90
|
|
|
67
91
|
Same state kind, different meaning. A checkbox is a value you **submit** — "delete
|
|
@@ -120,3 +120,45 @@ export const AllStates: Story = {
|
|
|
120
120
|
export const Interactive: Story = {
|
|
121
121
|
args: { label: "Delete originals after import" },
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `value` names which member of a group a box IS — not whether it is
|
|
126
|
+
* ticked. It matters as soon as there is more than one box: a group is
|
|
127
|
+
* read back with a single query over its container, and without a
|
|
128
|
+
* `value` every box in it answers `"on"`.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const chosen = [...group.querySelectorAll("input")]
|
|
132
|
+
* .filter((input) => input.checked)
|
|
133
|
+
* .map((input) => input.value)
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* A lone boolean needs none of this and should omit the prop.
|
|
137
|
+
*/
|
|
138
|
+
export const WithValues: Story = {
|
|
139
|
+
args: { label: "Anime" },
|
|
140
|
+
render: (controlProps) => (
|
|
141
|
+
<StorySection title="A library picker: each box carries the id the caller will read back, and the checked ones are the answer.">
|
|
142
|
+
<StoryGrid columns={2}>
|
|
143
|
+
{[
|
|
144
|
+
{ id: "11", isChosen: true, title: "Anime" },
|
|
145
|
+
{ id: "1", isChosen: false, title: "Movies" },
|
|
146
|
+
{ id: "15", isChosen: true, title: "Shorts" },
|
|
147
|
+
{ id: "2", isChosen: false, title: "Shows" },
|
|
148
|
+
].map((library) => (
|
|
149
|
+
<StoryCell
|
|
150
|
+
key={library.id}
|
|
151
|
+
label={`value="${library.id}"`}
|
|
152
|
+
>
|
|
153
|
+
<Checkbox
|
|
154
|
+
{...controlProps}
|
|
155
|
+
isChecked={library.isChosen}
|
|
156
|
+
label={library.title}
|
|
157
|
+
value={library.id}
|
|
158
|
+
/>
|
|
159
|
+
</StoryCell>
|
|
160
|
+
))}
|
|
161
|
+
</StoryGrid>
|
|
162
|
+
</StorySection>
|
|
163
|
+
),
|
|
164
|
+
}
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Checkbox.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Default, Interactive } =
|
|
10
|
+
const { AllStates, Default, Interactive, WithValues } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("it is a checkbox a screen reader can name", async () => {
|
|
@@ -107,3 +107,54 @@ test("a read-only box is announced and cannot be toggled", async () => {
|
|
|
107
107
|
|
|
108
108
|
await expectNoAxeViolations(canvasElement)
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
test("value names the member, and a group reads back the ticked ones", async () => {
|
|
112
|
+
const { canvas, canvasElement } =
|
|
113
|
+
await mountStory(WithValues)
|
|
114
|
+
|
|
115
|
+
// The prop is the `<input>`'s `value` and nothing else — it must not
|
|
116
|
+
// touch the checked state, which `isChecked` still owns alone.
|
|
117
|
+
await expect(
|
|
118
|
+
expectAgentDrivable(canvas, {
|
|
119
|
+
name: "Anime",
|
|
120
|
+
role: "checkbox",
|
|
121
|
+
}),
|
|
122
|
+
).toHaveAttribute("value", "11")
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
expectAgentDrivable(canvas, {
|
|
126
|
+
name: "Movies",
|
|
127
|
+
role: "checkbox",
|
|
128
|
+
}),
|
|
129
|
+
).not.toBeChecked()
|
|
130
|
+
|
|
131
|
+
// The read this prop exists for. Without a `value` every box here
|
|
132
|
+
// answers the UA default `"on"` and the result is four copies of one
|
|
133
|
+
// meaningless string, which is why a group could not use this
|
|
134
|
+
// component at all before.
|
|
135
|
+
const chosen = [
|
|
136
|
+
...canvasElement.querySelectorAll<HTMLInputElement>(
|
|
137
|
+
"input[type=checkbox]",
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
.filter((input) => input.checked)
|
|
141
|
+
.map((input) => input.value)
|
|
142
|
+
|
|
143
|
+
await expect(chosen).toEqual(["11", "15"])
|
|
144
|
+
|
|
145
|
+
await expectNoAxeViolations(canvasElement)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("a box with no value stays out of the way", async () => {
|
|
149
|
+
const { canvas } = await mountStory(Default)
|
|
150
|
+
|
|
151
|
+
// `value` is optional and a lone boolean should omit it. React drops
|
|
152
|
+
// an `undefined` attribute entirely rather than writing an empty
|
|
153
|
+
// string, so the DOM looks exactly as it did before the prop existed.
|
|
154
|
+
await expect(
|
|
155
|
+
expectAgentDrivable(canvas, {
|
|
156
|
+
name: "Delete originals after import",
|
|
157
|
+
role: "checkbox",
|
|
158
|
+
}),
|
|
159
|
+
).not.toHaveAttribute("value")
|
|
160
|
+
})
|
|
@@ -38,6 +38,25 @@ export type CheckboxProps = {
|
|
|
38
38
|
label: ReactNode
|
|
39
39
|
onChange?: (isChecked: boolean) => void
|
|
40
40
|
size?: ControlSize
|
|
41
|
+
/**
|
|
42
|
+
* The `<input>`'s `value` attribute — which member of a group this
|
|
43
|
+
* box IS, as opposed to whether it is ticked.
|
|
44
|
+
*
|
|
45
|
+
* A lone boolean does not need this. A GROUP does: the browser
|
|
46
|
+
* submits `name=value` for each ticked box in a form, and, more
|
|
47
|
+
* relevantly here, a group is normally read back with one query —
|
|
48
|
+
* `[...root.querySelectorAll("input")].filter(i => i.checked)
|
|
49
|
+
* .map(i => i.value)`. Without a `value` every box in the group
|
|
50
|
+
* reports the string `"on"` and the read is useless, which is why
|
|
51
|
+
* queuepilot's library and ratings pickers were still hand-rolling
|
|
52
|
+
* a raw `<input type="checkbox">` rather than adopting this
|
|
53
|
+
* component.
|
|
54
|
+
*
|
|
55
|
+
* Passed straight through and otherwise inert. It is NOT the
|
|
56
|
+
* checked state and does not become one: `isChecked` still seeds
|
|
57
|
+
* the box, and `onChange` still reports a boolean.
|
|
58
|
+
*/
|
|
59
|
+
value?: string
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
const BOX_SIZE_CLASS: Record<ControlSize, string> = {
|
|
@@ -96,6 +115,7 @@ export const Checkbox = ({
|
|
|
96
115
|
label,
|
|
97
116
|
onChange,
|
|
98
117
|
size = "md",
|
|
118
|
+
value,
|
|
99
119
|
}: CheckboxProps): ReactNode => (
|
|
100
120
|
<label
|
|
101
121
|
className={toClassName(
|
|
@@ -176,6 +196,7 @@ export const Checkbox = ({
|
|
|
176
196
|
}
|
|
177
197
|
}}
|
|
178
198
|
type="checkbox"
|
|
199
|
+
value={value}
|
|
179
200
|
/>
|
|
180
201
|
|
|
181
202
|
<svg
|
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 ??
|