@recursica/adapter-mui-v7 1.3.3 → 1.3.4
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/CHANGELOG.md +8 -0
- package/dist/adapter-mui-v7.cjs +60 -60
- package/dist/adapter-mui-v7.cjs.map +1 -1
- package/dist/adapter-mui-v7.css +1 -1
- package/dist/adapter-mui-v7.js +3480 -3457
- package/dist/adapter-mui-v7.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Checkbox/CHECKBOX_IMPLEMENTATION_NOTES.md +4 -0
- package/src/components/Checkbox/Checkbox.module.css +1 -0
- package/src/components/Checkbox/Checkbox.tsx +7 -4
- package/src/components/Modal/Modal.module.css +9 -2
- package/src/components/NativeInputA11y.dom.test.tsx +59 -0
- package/src/components/Radio/RADIO_IMPLEMENTATION_NOTES.md +4 -0
- package/src/components/Radio/Radio.module.css +1 -0
- package/src/components/Radio/Radio.tsx +7 -4
- package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +4 -0
- package/src/components/Switch/Switch.tsx +7 -3
- package/src/utils/useNativeInputA11y.ts +43 -0
package/package.json
CHANGED
|
@@ -12,3 +12,7 @@ is no group-level max-width token in the schema (`checkbox-group_variants_layout
|
|
|
12
12
|
covers gutter/margin/padding) — fixed by leaving `controlMaxWidth` `undefined`, matching
|
|
13
13
|
`SwitchGroup`'s existing pattern. Same bug existed in `RadioGroup`, fixed the same way.
|
|
14
14
|
Mirrors the identical fix in mantine-adapter (source of truth).
|
|
15
|
+
|
|
16
|
+
### Accessible name lands on the native `<input>` (2026-10-15)
|
|
17
|
+
|
|
18
|
+
`RequireAccessibleLabel` enforces a name at compile time (`label`, `aria-label` or `aria-labelledby`). MUI spreads unknown props onto the outer span, so `aria-label`/`aria-labelledby`/`aria-describedby` never reached the `<input>`, and a `label` with no `id` left `<label htmlFor>` unlinked. `useNativeInputA11y` (`src/utils`) moves the aria attributes to `slotProps.input` and generates an `id` for labelled controls. Same fix in Checkbox, Radio and Switch. Covered by `src/components/NativeInputA11y.dom.test.tsx`. Mantine already behaves this way natively and has the same test.
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
align-items: center;
|
|
67
67
|
justify-content: center;
|
|
68
68
|
margin: 0; /* HARDCODE: structural reset */
|
|
69
|
+
padding: 0; /* HARDCODE: drops MUI's 9px SwitchBase padding, which would crush the box inside the fixed size */
|
|
69
70
|
width: var(--recursica_ui-kit_components_checkbox_properties_size);
|
|
70
71
|
height: var(--recursica_ui-kit_components_checkbox_properties_size);
|
|
71
72
|
/* Apply font-size to this element so that an 'em' based line-height token evaluates to the correct exact pixel value. */
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../FormControlLayout/FormControlLayout";
|
|
22
22
|
import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
|
|
23
23
|
|
|
24
|
+
import { useNativeInputA11y } from "../../utils/useNativeInputA11y";
|
|
24
25
|
import styles from "./Checkbox.module.css";
|
|
25
26
|
|
|
26
27
|
export type CheckboxWrapperProps = Omit<MuiCheckboxProps, "size" | "color"> &
|
|
@@ -67,9 +68,12 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
|
67
68
|
"color", // Colors are token-driven; MUI's native palette isn't exposed
|
|
68
69
|
] as const satisfies readonly (keyof MuiCheckboxProps)[];
|
|
69
70
|
|
|
70
|
-
const sanitizedProps =
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const sanitizedProps = useNativeInputA11y(
|
|
72
|
+
omitUnsupportedProps(
|
|
73
|
+
filterStylingProps(rest, overStyled),
|
|
74
|
+
UNSUPPORTED_PROPS,
|
|
75
|
+
),
|
|
76
|
+
!!label,
|
|
73
77
|
);
|
|
74
78
|
const restRecord = sanitizedProps as Record<string, unknown>;
|
|
75
79
|
|
|
@@ -175,7 +179,6 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
|
175
179
|
{...(isControlled ? { checked: isChecked as boolean } : {})}
|
|
176
180
|
onChange={handleChange}
|
|
177
181
|
disableRipple
|
|
178
|
-
sx={label ? { padding: 0 } : undefined}
|
|
179
182
|
icon={<div className={styles.input} />}
|
|
180
183
|
checkedIcon={
|
|
181
184
|
<div className={`${styles.input} ${styles.inputChecked}`}>
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
--recursica_ui-kit_components_modal_properties_border-radius
|
|
39
39
|
);
|
|
40
40
|
overflow: hidden;
|
|
41
|
+
background-color: var(
|
|
42
|
+
--recursica_ui-kit_components_modal_properties_colors_content-background-color
|
|
43
|
+
);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
.content {
|
|
@@ -80,7 +83,9 @@
|
|
|
80
83
|
var(
|
|
81
84
|
--recursica_ui-kit_components_modal_properties_header-footer-horizontal-padding
|
|
82
85
|
);
|
|
83
|
-
background-color:
|
|
86
|
+
background-color: var(
|
|
87
|
+
--recursica_ui-kit_components_modal_properties_colors_header-background-color
|
|
88
|
+
);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
.title {
|
|
@@ -209,7 +214,9 @@
|
|
|
209
214
|
var(
|
|
210
215
|
--recursica_ui-kit_components_modal_properties_header-footer-horizontal-padding
|
|
211
216
|
);
|
|
212
|
-
background-color:
|
|
217
|
+
background-color: var(
|
|
218
|
+
--recursica_ui-kit_components_modal_properties_colors_footer-background-color
|
|
219
|
+
);
|
|
213
220
|
display: flex;
|
|
214
221
|
justify-content: flex-end;
|
|
215
222
|
gap: var(--recursica_ui-kit_components_modal_properties_button-gap);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { flushSync } from "react-dom";
|
|
5
|
+
import { Checkbox } from "./Checkbox/Checkbox";
|
|
6
|
+
import { Radio } from "./Radio/Radio";
|
|
7
|
+
import { Switch } from "./Switch/Switch";
|
|
8
|
+
const wrap = (n: React.ReactElement) => n;
|
|
9
|
+
|
|
10
|
+
// Where the naming attributes land matters, not just that they render: a screen reader reads
|
|
11
|
+
// the native <input>, so aria-* on a wrapper leaves the control unnamed.
|
|
12
|
+
function render(node: React.ReactElement): HTMLElement {
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
document.body.appendChild(container);
|
|
15
|
+
flushSync(() => createRoot(container).render(wrap(node)));
|
|
16
|
+
return container;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const controls = [
|
|
20
|
+
["Checkbox", Checkbox],
|
|
21
|
+
["Radio", Radio],
|
|
22
|
+
["Switch", Switch],
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
describe.each(controls)("%s accessible name", (_name, Control) => {
|
|
26
|
+
// The union type under test is already enforced at compile time; cast to exercise each branch.
|
|
27
|
+
const C = Control as unknown as React.ComponentType<Record<string, unknown>>;
|
|
28
|
+
|
|
29
|
+
it("puts aria-label on the native input only", () => {
|
|
30
|
+
const c = render(<C aria-label="Select all" />);
|
|
31
|
+
expect(c.querySelector("input")!.getAttribute("aria-label")).toBe(
|
|
32
|
+
"Select all",
|
|
33
|
+
);
|
|
34
|
+
expect(c.querySelectorAll("[aria-label]")).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("puts aria-labelledby and aria-describedby on the native input only", () => {
|
|
38
|
+
const c = render(<C aria-labelledby="a" aria-describedby="b" />);
|
|
39
|
+
const input = c.querySelector("input")!;
|
|
40
|
+
expect(input.getAttribute("aria-labelledby")).toBe("a");
|
|
41
|
+
expect(input.getAttribute("aria-describedby")).toBe("b");
|
|
42
|
+
expect(c.querySelectorAll("[aria-labelledby]")).toHaveLength(1);
|
|
43
|
+
expect(c.querySelectorAll("[aria-describedby]")).toHaveLength(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("links a visible label to the input without a caller-supplied id", () => {
|
|
47
|
+
const c = render(<C label="Hello" />);
|
|
48
|
+
const input = c.querySelector("input")!;
|
|
49
|
+
const label = c.querySelector("label")!;
|
|
50
|
+
expect(input.id).not.toBe("");
|
|
51
|
+
expect(label.getAttribute("for")).toBe(input.id);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps a caller-supplied id", () => {
|
|
55
|
+
const c = render(<C label="Hello" id="mine" />);
|
|
56
|
+
expect(c.querySelector("input")!.id).toBe("mine");
|
|
57
|
+
expect(c.querySelector("label")!.getAttribute("for")).toBe("mine");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -10,3 +10,7 @@ layout the group's own label got squeezed into that same 400px alongside the rad
|
|
|
10
10
|
no group-level max-width token in the schema — fixed by leaving `controlMaxWidth` `undefined`,
|
|
11
11
|
matching `SwitchGroup`'s existing pattern. Same bug existed in `CheckboxGroup`, fixed the same
|
|
12
12
|
way. Mirrors the identical fix in mantine-adapter (source of truth).
|
|
13
|
+
|
|
14
|
+
### Accessible name lands on the native `<input>` (2026-10-15)
|
|
15
|
+
|
|
16
|
+
`RequireAccessibleLabel` enforces a name at compile time (`label`, `aria-label` or `aria-labelledby`). MUI spreads unknown props onto the outer span, so `aria-label`/`aria-labelledby`/`aria-describedby` never reached the `<input>`, and a `label` with no `id` left `<label htmlFor>` unlinked. `useNativeInputA11y` (`src/utils`) moves the aria attributes to `slotProps.input` and generates an `id` for labelled controls. Same fix in Checkbox, Radio and Switch. Covered by `src/components/NativeInputA11y.dom.test.tsx`. Mantine already behaves this way natively and has the same test.
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
align-items: center;
|
|
76
76
|
justify-content: center;
|
|
77
77
|
margin: 0; /* HARDCODE: structural reset */
|
|
78
|
+
padding: 0; /* HARDCODE: drops MUI's 9px SwitchBase padding, which would crush the circle inside the fixed size */
|
|
78
79
|
width: var(--recursica_ui-kit_components_radio-button_properties_size);
|
|
79
80
|
height: var(--recursica_ui-kit_components_radio-button_properties_size);
|
|
80
81
|
/* Apply font-size to this element so that an 'em' based line-height token evaluates to the correct exact pixel value. */
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../FormControlLayout/FormControlLayout";
|
|
22
22
|
import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
|
|
23
23
|
|
|
24
|
+
import { useNativeInputA11y } from "../../utils/useNativeInputA11y";
|
|
24
25
|
import styles from "./Radio.module.css";
|
|
25
26
|
|
|
26
27
|
const RadioIcon: React.FC<{
|
|
@@ -81,9 +82,12 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
|
|
81
82
|
"color", // Colors are token-driven; MUI's native palette isn't exposed
|
|
82
83
|
] as const satisfies readonly (keyof MuiRadioProps)[];
|
|
83
84
|
|
|
84
|
-
const sanitizedProps =
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
const sanitizedProps = useNativeInputA11y(
|
|
86
|
+
omitUnsupportedProps(
|
|
87
|
+
filterStylingProps(rest, overStyled),
|
|
88
|
+
UNSUPPORTED_PROPS,
|
|
89
|
+
),
|
|
90
|
+
!!label,
|
|
87
91
|
);
|
|
88
92
|
const restRecord = sanitizedProps as Record<string, unknown>;
|
|
89
93
|
|
|
@@ -159,7 +163,6 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
|
|
159
163
|
classes={mergedClassNames}
|
|
160
164
|
disabled={readOnly || disabled}
|
|
161
165
|
disableRipple
|
|
162
|
-
sx={label ? { padding: 0 } : undefined}
|
|
163
166
|
/>
|
|
164
167
|
);
|
|
165
168
|
|
|
@@ -145,3 +145,7 @@ pattern, not a MUI-only bug). Fixed by not capping the group's control width at
|
|
|
145
145
|
switch's own label already wraps at 200px via `.labelWrapper`, so no group-level cap is needed.
|
|
146
146
|
Verified live in both adapters: side-by-side now shows the label column at left with the
|
|
147
147
|
switches beside it; stacked layout unaffected.
|
|
148
|
+
|
|
149
|
+
### Accessible name lands on the native `<input>` (2026-10-15)
|
|
150
|
+
|
|
151
|
+
`RequireAccessibleLabel` enforces a name at compile time (`label`, `aria-label` or `aria-labelledby`). MUI spreads unknown props onto the outer span, so `aria-label`/`aria-labelledby`/`aria-describedby` never reached the `<input>`, and a `label` with no `id` left `<label htmlFor>` unlinked. `useNativeInputA11y` (`src/utils`) moves the aria attributes to `slotProps.input` and generates an `id` for labelled controls. Same fix in Checkbox, Radio and Switch. Covered by `src/components/NativeInputA11y.dom.test.tsx`. Mantine already behaves this way natively and has the same test.
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type FormControlLayoutProps,
|
|
21
21
|
} from "../FormControlLayout/FormControlLayout";
|
|
22
22
|
import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
|
|
23
|
+
import { useNativeInputA11y } from "../../utils/useNativeInputA11y";
|
|
23
24
|
import styles from "./Switch.module.css";
|
|
24
25
|
|
|
25
26
|
import { SwitchGroup, SwitchGroupContext } from "./SwitchGroup";
|
|
@@ -110,9 +111,12 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
|
|
110
111
|
"color", // Recursica hardcodes color="default" below; MUI's palette-driven colors are unused
|
|
111
112
|
] as const satisfies readonly (keyof MuiSwitchProps)[];
|
|
112
113
|
|
|
113
|
-
const sanitizedProps =
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const sanitizedProps = useNativeInputA11y(
|
|
115
|
+
omitUnsupportedProps(
|
|
116
|
+
filterStylingProps(rest, overStyled),
|
|
117
|
+
UNSUPPORTED_PROPS,
|
|
118
|
+
),
|
|
119
|
+
!!label,
|
|
116
120
|
);
|
|
117
121
|
const restRecord = sanitizedProps as Record<string, unknown>;
|
|
118
122
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
|
|
3
|
+
const INPUT_ARIA_KEYS = [
|
|
4
|
+
"aria-label",
|
|
5
|
+
"aria-labelledby",
|
|
6
|
+
"aria-describedby",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MUI's Checkbox/Radio/Switch spread unknown props (including `aria-*`) onto the outer span,
|
|
11
|
+
* so a screen reader never sees them on the native `<input>`. Moves the naming attributes to
|
|
12
|
+
* `slotProps.input`, and guarantees a labelled control has an `id` so `<label htmlFor>` links.
|
|
13
|
+
*/
|
|
14
|
+
export function useNativeInputA11y<T extends object>(
|
|
15
|
+
props: T,
|
|
16
|
+
hasLabel: boolean,
|
|
17
|
+
): T {
|
|
18
|
+
const generatedId = useId();
|
|
19
|
+
const source = { ...props } as Record<string, unknown>;
|
|
20
|
+
const slotProps = (source.slotProps ?? {}) as Record<string, unknown>;
|
|
21
|
+
if (typeof slotProps.input === "function") {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"slotProps.input must be an object; function form is not supported",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const input = { ...(slotProps.input as object | undefined) } as Record<
|
|
27
|
+
string,
|
|
28
|
+
unknown
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
for (const key of INPUT_ARIA_KEYS) {
|
|
32
|
+
if (key in source) {
|
|
33
|
+
input[key] = source[key];
|
|
34
|
+
delete source[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (hasLabel && source.id === undefined) source.id = generatedId;
|
|
38
|
+
|
|
39
|
+
if (Object.keys(input).length > 0) {
|
|
40
|
+
source.slotProps = { ...slotProps, input };
|
|
41
|
+
}
|
|
42
|
+
return source as T;
|
|
43
|
+
}
|