@recursica/adapter-mui-v7 1.3.2 → 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 +21 -0
- package/README.md +1 -1
- 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 +3840 -3795
- package/dist/adapter-mui-v7.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/docs/PHILOSOPHY.md +4 -0
- package/package.json +4 -8
- 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/Heading/HEADING_IMPLEMENTATION_NOTES.md +10 -0
- package/src/components/Modal/Modal.module.css +9 -2
- package/src/components/NativeInputA11y.dom.test.tsx +59 -0
- package/src/components/Panel/PANEL_IMPLEMENTATION_NOTES.md +8 -0
- package/src/components/Panel/Panel.tsx +51 -9
- 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/components/Text/TEXT_IMPLEMENTATION_NOTES.md +12 -0
- package/src/components/Text/Text.headings.dom.test.tsx +54 -0
- package/src/components/Text/Text.tsx +16 -0
- package/src/utils/useNativeInputA11y.ts +43 -0
- package/.storybook/commonArgTypes.ts +0 -148
- package/recursica_brand.json +0 -2610
- package/recursica_tokens.json +0 -769
- package/recursica_ui-kit.json +0 -9049
- package/recursica_variables_scoped.css +0 -6178
- package/src/Introduction.stories.tsx +0 -178
- package/src/components/Accordion/Accordion.stories.tsx +0 -198
- package/src/components/AssistiveElement/AssistiveElement.stories.tsx +0 -68
- package/src/components/AutoComplete/AutoComplete.stories.tsx +0 -336
- package/src/components/Avatar/Avatar.stories.tsx +0 -92
- package/src/components/Badge/Badge.stories.tsx +0 -78
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +0 -114
- package/src/components/Button/Button.stories.tsx +0 -196
- package/src/components/Card/Card.stories.tsx +0 -132
- package/src/components/Checkbox/Checkbox.stories.tsx +0 -97
- package/src/components/Checkbox/CheckboxGroup.stories.tsx +0 -139
- package/src/components/Chip/Chip.stories.tsx +0 -154
- package/src/components/Container/Container.stories.tsx +0 -118
- package/src/components/DatePicker/DatePicker.stories.tsx +0 -163
- package/src/components/Dropdown/Dropdown.stories.tsx +0 -252
- package/src/components/FileInput/FileInput.stories.tsx +0 -313
- package/src/components/FileUpload/FileUpload.stories.tsx +0 -362
- package/src/components/Flex/Flex.stories.tsx +0 -128
- package/src/components/FormControlLayout/FormControlLayout.stories.tsx +0 -118
- package/src/components/FormControlWrapper/FormControlWrapper.stories.tsx +0 -129
- package/src/components/Group/Group.stories.tsx +0 -120
- package/src/components/Heading/Heading.stories.tsx +0 -112
- package/src/components/HoverCard/HoverCard.stories.tsx +0 -172
- package/src/components/Label/Label.stories.tsx +0 -131
- package/src/components/LayoutGrid/LayoutGrid.stories.tsx +0 -61
- package/src/components/Link/Link.stories.tsx +0 -58
- package/src/components/Loader/Loader.stories.tsx +0 -123
- package/src/components/Menu/Menu.stories.tsx +0 -402
- package/src/components/Modal/Modal.stories.tsx +0 -105
- package/src/components/NumberInput/NumberInput.stories.tsx +0 -96
- package/src/components/Pagination/Pagination.stories.tsx +0 -48
- package/src/components/Panel/Panel.stories.tsx +0 -227
- package/src/components/Popover/Popover.stories.tsx +0 -133
- package/src/components/Radio/Radio.stories.tsx +0 -93
- package/src/components/Radio/RadioGroup.stories.tsx +0 -117
- package/src/components/ReadOnlyField/ReadOnlyField.stories.tsx +0 -163
- package/src/components/SegmentedControl/SegmentedControl.stories.tsx +0 -106
- package/src/components/Slider/Slider.stories.tsx +0 -282
- package/src/components/Stack/Stack.stories.tsx +0 -108
- package/src/components/Stepper/Stepper.stories.tsx +0 -157
- package/src/components/Switch/Switch.stories.tsx +0 -114
- package/src/components/Switch/SwitchGroup.stories.tsx +0 -132
- package/src/components/Table/Table.stories.tsx +0 -154
- package/src/components/Tabs/Tabs.stories.tsx +0 -174
- package/src/components/Text/Text.stories.tsx +0 -123
- package/src/components/TextArea/TextArea.stories.tsx +0 -94
- package/src/components/TextField/TextField.stories.tsx +0 -162
- package/src/components/TimePicker/TimePicker.stories.tsx +0 -155
- package/src/components/Timeline/Timeline.stories.tsx +0 -128
- package/src/components/Toast/Toast.stories.tsx +0 -67
- package/src/components/Tooltip/Tooltip.stories.tsx +0 -165
- package/src/components/TransferList/TransferList.stories.tsx +0 -121
- package/src/components/Tree/Tree.stories.tsx +0 -104
package/dist/index.d.ts
CHANGED
|
@@ -985,18 +985,23 @@ export declare const Panel: PanelComponent;
|
|
|
985
985
|
* </Panel>
|
|
986
986
|
* ```
|
|
987
987
|
*
|
|
988
|
+
* Always non-modal, with no props to change it: the page behind stays usable
|
|
989
|
+
* (no overlay, focus trap, scroll lock, `aria-modal` or `aria-hidden` on the
|
|
990
|
+
* rest of the page), focus returns to the opener on close, Escape always
|
|
991
|
+
* closes, and outside clicks never do. Implemented with MUI's `persistent`
|
|
992
|
+
* Drawer variant (no Modal) plus an Escape listener and focus restoration.
|
|
993
|
+
*
|
|
988
994
|
* Mui Drawer sub-components available via dot-notation:
|
|
989
995
|
* - `Panel.Header` — Top section with title and close button
|
|
990
996
|
* - `Panel.Title` — Title text within the header
|
|
991
997
|
* - `Panel.CloseButton` — Close button within the header
|
|
992
998
|
* - `Panel.Body` — Scrollable body content area
|
|
993
999
|
* - `Panel.Content` — Outer content container
|
|
994
|
-
* - `Panel.Overlay` — Background overlay
|
|
995
1000
|
* - `Panel.Root` — Root element for advanced composition
|
|
996
1001
|
* - `Panel.Stack` — Stacked drawer context
|
|
997
1002
|
*/
|
|
998
1003
|
declare const PanelBase: {
|
|
999
|
-
({ overStyled, placement, keepMounted, wrapHeaderText, opened, title,
|
|
1004
|
+
({ overStyled, placement, keepMounted, wrapHeaderText, opened, title, withCloseButton, onClose, children, ...rest }: PanelProps): JSX.Element;
|
|
1000
1005
|
displayName: string;
|
|
1001
1006
|
};
|
|
1002
1007
|
|
|
@@ -1208,11 +1213,9 @@ export declare interface RecursicaNumberInputProps extends Omit<InputBaseProps,
|
|
|
1208
1213
|
/**
|
|
1209
1214
|
* Recursica Panel root props. Extends Mui Drawer.
|
|
1210
1215
|
*/
|
|
1211
|
-
export declare interface RecursicaPanelProps extends Omit<DrawerProps, "classes" | "position" | "style" | "anchor" | "open" | "title">, RecursicaPanelProps_2 {
|
|
1216
|
+
export declare interface RecursicaPanelProps extends Omit<DrawerProps, "classes" | "position" | "style" | "anchor" | "open" | "title" | "variant" | "hideBackdrop" | "ModalProps" | "BackdropComponent" | "BackdropProps" | "disableEscapeKeyDown" | "disableScrollLock" | "disableEnforceFocus" | "disableAutoFocus" | "disableRestoreFocus">, RecursicaPanelProps_2 {
|
|
1212
1217
|
/** Panel header title label. */
|
|
1213
1218
|
title?: ReactNode;
|
|
1214
|
-
/** Whether to display a background overlay. Default true. */
|
|
1215
|
-
withOverlay?: boolean;
|
|
1216
1219
|
/** Whether to display the close button in the header. Default true. */
|
|
1217
1220
|
withCloseButton?: boolean;
|
|
1218
1221
|
}
|
package/docs/PHILOSOPHY.md
CHANGED
|
@@ -4,6 +4,10 @@ Recursica's component architecture isn't just a wrapper; it's a strict enforcing
|
|
|
4
4
|
|
|
5
5
|
This document serves as the governing framework for why the `adapter-mui-v7` components are built the way they are.
|
|
6
6
|
|
|
7
|
+
## Why an Adapter?
|
|
8
|
+
|
|
9
|
+
Most companies already have an existing component library or use an existing UI kit, and switching to a different kit means painful refactoring. Rather than replace MUI, this adapter adapts to its API surface while enforcing the behaviors and styling Recursica defines for a consistent UX. You don't ship two separate UI kits, and you can migrate to Recursica systematically, over time.
|
|
10
|
+
|
|
7
11
|
## 1. Strict Separation of Props (The Unified Recursica Prop Layer)
|
|
8
12
|
|
|
9
13
|
Recursica has a **single universal API surface** internally regardless of whether we use MUI or another underlying UI library.
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/borderux/recursica-adapter-mui-v7.git"
|
|
14
14
|
},
|
|
15
|
-
"version": "1.3.
|
|
15
|
+
"version": "1.3.4",
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
@@ -31,18 +31,14 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
33
|
"src",
|
|
34
|
+
"!src/**/*.stories.tsx",
|
|
34
35
|
"CHANGELOG.md",
|
|
35
|
-
".storybook/commonArgTypes.ts",
|
|
36
36
|
"llms.txt",
|
|
37
37
|
"USAGE.md",
|
|
38
38
|
"ARCHITECTURE.md",
|
|
39
39
|
"SETUP.md",
|
|
40
40
|
"OVERSTYLING.md",
|
|
41
|
-
"docs/PHILOSOPHY.md"
|
|
42
|
-
"recursica_variables_scoped.css",
|
|
43
|
-
"recursica_brand.json",
|
|
44
|
-
"recursica_tokens.json",
|
|
45
|
-
"recursica_ui-kit.json"
|
|
41
|
+
"docs/PHILOSOPHY.md"
|
|
46
42
|
],
|
|
47
43
|
"keywords": [
|
|
48
44
|
"react",
|
|
@@ -85,7 +81,7 @@
|
|
|
85
81
|
"@playwright/test": "^1.62.1",
|
|
86
82
|
"@recursica/adapter-tester": "^5.3.1",
|
|
87
83
|
"@recursica/recursica-postcss-vars": "^1.5.0",
|
|
88
|
-
"@recursica/storybook-template": "^0.7.
|
|
84
|
+
"@recursica/storybook-template": "^0.7.16",
|
|
89
85
|
"@recursica/token-analyzer": "^1.8.0",
|
|
90
86
|
"@storybook/addon-a11y": "^10.3.3",
|
|
91
87
|
"@storybook/addon-docs": "^10.3.3",
|
|
@@ -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}`}>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Heading – Implementation Notes
|
|
2
|
+
|
|
3
|
+
## Heading owns semantic `h1`-`h6` (Matt Massey, 2026-10-08)
|
|
4
|
+
|
|
5
|
+
**Decision:** `Heading` is the only component that renders semantic `<h1>`-`<h6>` elements. Each
|
|
6
|
+
`order` maps to a heading level that Recursica and Forge define and style
|
|
7
|
+
(`recursica_brand_typography_h{order}`).
|
|
8
|
+
|
|
9
|
+
**Implementation:** Heading's styles are fixed by the Recursica JSON/CSS. Do not add variants to
|
|
10
|
+
Heading. Any other kind of text belongs in `Text`, which throws if asked to render `h1`-`h6`.
|
|
@@ -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
|
+
});
|
|
@@ -131,3 +131,11 @@ clips descenders (e.g. the "g" in a long title) whenever `text_line-height` is t
|
|
|
131
131
|
font's natural ascent+descent. Switched to `overflow: clip; overflow-clip-margin: 0.35em;` — same
|
|
132
132
|
truncation, but ink can bleed slightly past the line box before it's actually clipped.
|
|
133
133
|
Project-wide fix; see Chip's `CHIP_IMPLEMENTATION_NOTES.md` for the original discovery.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 10. Always non-modal (Matt Massey, 2026-10-08)
|
|
138
|
+
|
|
139
|
+
**Decision:** Panel is never modal, and there are no props to change that.
|
|
140
|
+
|
|
141
|
+
**Implementation:** The house rule is that the page behind a panel stays usable. `Panel.tsx` always renders MUI's Drawer with `variant="persistent"` (after the caller's props), which has no Modal: no backdrop, no focus trap, no scroll lock, no `aria-modal`, and no `aria-hidden` on the rest of the page. Because there is no Modal, `Panel.tsx` supplies the rest itself: a document `keydown` listener (while open) calls `onClose` on Escape, and focus returns to the opener (the element focused at open) on close. Escape is independent of outside clicks and always closes the panel; clicking the page behind never does. `withOverlay` and MUI's `variant`, `hideBackdrop`, `ModalProps`, `BackdropProps`/`BackdropComponent` and `disable*` (escape, scroll lock, enforce focus, auto focus, restore focus) props are omitted from `RecursicaPanelProps`. Every adapter must match.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, useEffect, useRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Drawer as MuiDrawer,
|
|
4
4
|
type DrawerProps as MuiDrawerProps,
|
|
@@ -23,13 +23,26 @@ import { type RecursicaPanelProps as BaseRecursicaPanelProps } from "@recursica/
|
|
|
23
23
|
export interface RecursicaPanelProps
|
|
24
24
|
extends Omit<
|
|
25
25
|
MuiDrawerProps,
|
|
26
|
-
|
|
26
|
+
| "classes"
|
|
27
|
+
| "position"
|
|
28
|
+
| "style"
|
|
29
|
+
| "anchor"
|
|
30
|
+
| "open"
|
|
31
|
+
| "title"
|
|
32
|
+
| "variant"
|
|
33
|
+
| "hideBackdrop"
|
|
34
|
+
| "ModalProps"
|
|
35
|
+
| "BackdropComponent"
|
|
36
|
+
| "BackdropProps"
|
|
37
|
+
| "disableEscapeKeyDown"
|
|
38
|
+
| "disableScrollLock"
|
|
39
|
+
| "disableEnforceFocus"
|
|
40
|
+
| "disableAutoFocus"
|
|
41
|
+
| "disableRestoreFocus"
|
|
27
42
|
>,
|
|
28
43
|
BaseRecursicaPanelProps {
|
|
29
44
|
/** Panel header title label. */
|
|
30
45
|
title?: ReactNode;
|
|
31
|
-
/** Whether to display a background overlay. Default true. */
|
|
32
|
-
withOverlay?: boolean;
|
|
33
46
|
/** Whether to display the close button in the header. Default true. */
|
|
34
47
|
withCloseButton?: boolean;
|
|
35
48
|
}
|
|
@@ -68,13 +81,18 @@ const CloseIcon = () => (
|
|
|
68
81
|
* </Panel>
|
|
69
82
|
* ```
|
|
70
83
|
*
|
|
84
|
+
* Always non-modal, with no props to change it: the page behind stays usable
|
|
85
|
+
* (no overlay, focus trap, scroll lock, `aria-modal` or `aria-hidden` on the
|
|
86
|
+
* rest of the page), focus returns to the opener on close, Escape always
|
|
87
|
+
* closes, and outside clicks never do. Implemented with MUI's `persistent`
|
|
88
|
+
* Drawer variant (no Modal) plus an Escape listener and focus restoration.
|
|
89
|
+
*
|
|
71
90
|
* Mui Drawer sub-components available via dot-notation:
|
|
72
91
|
* - `Panel.Header` — Top section with title and close button
|
|
73
92
|
* - `Panel.Title` — Title text within the header
|
|
74
93
|
* - `Panel.CloseButton` — Close button within the header
|
|
75
94
|
* - `Panel.Body` — Scrollable body content area
|
|
76
95
|
* - `Panel.Content` — Outer content container
|
|
77
|
-
* - `Panel.Overlay` — Background overlay
|
|
78
96
|
* - `Panel.Root` — Root element for advanced composition
|
|
79
97
|
* - `Panel.Stack` — Stacked drawer context
|
|
80
98
|
*/
|
|
@@ -85,13 +103,37 @@ const PanelBase = function Panel({
|
|
|
85
103
|
wrapHeaderText = true,
|
|
86
104
|
opened,
|
|
87
105
|
title,
|
|
88
|
-
withOverlay = true,
|
|
89
106
|
withCloseButton = true,
|
|
90
107
|
onClose,
|
|
91
108
|
children,
|
|
92
109
|
...rest
|
|
93
110
|
}: PanelProps) {
|
|
94
111
|
const sanitizedProps = filterStylingProps(rest, overStyled);
|
|
112
|
+
const isOpen = Boolean(opened);
|
|
113
|
+
const openerRef = useRef<HTMLElement | null>(null);
|
|
114
|
+
const onCloseRef = useRef(onClose);
|
|
115
|
+
onCloseRef.current = onClose;
|
|
116
|
+
|
|
117
|
+
// Non-modal: there is no Modal to handle Escape or focus restoration, so do both here.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!isOpen) return;
|
|
120
|
+
openerRef.current = document.activeElement as HTMLElement | null;
|
|
121
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
122
|
+
if (e.key === "Escape") {
|
|
123
|
+
onCloseRef.current?.(
|
|
124
|
+
e as unknown as React.SyntheticEvent,
|
|
125
|
+
"escapeKeyDown",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
document.addEventListener("keydown", onKeyDown);
|
|
130
|
+
return () => {
|
|
131
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
132
|
+
const opener = openerRef.current;
|
|
133
|
+
if (opener && opener.isConnected) opener.focus();
|
|
134
|
+
openerRef.current = null;
|
|
135
|
+
};
|
|
136
|
+
}, [isOpen]);
|
|
95
137
|
|
|
96
138
|
// MUI Drawer's `classes` prop only recognizes its own slot names (`root`, `paper`,
|
|
97
139
|
// `docked`, ...) — unlike Mantine's `classNames`, it has no `content`/`header`/`title`/`body`
|
|
@@ -113,10 +155,10 @@ const PanelBase = function Panel({
|
|
|
113
155
|
<MuiDrawer
|
|
114
156
|
anchor={placement} /* Recursica default: right; Mui default: left */
|
|
115
157
|
keepMounted={keepMounted}
|
|
116
|
-
open={
|
|
117
|
-
onClose={onClose}
|
|
118
|
-
hideBackdrop={!withOverlay}
|
|
158
|
+
open={isOpen}
|
|
119
159
|
{...(sanitizedProps as unknown as MuiDrawerProps)}
|
|
160
|
+
/* Always non-modal: persistent variant has no backdrop, focus trap, scroll lock or aria-modal */
|
|
161
|
+
variant="persistent"
|
|
120
162
|
classes={mergedClassNames as unknown as MuiDrawerProps["classes"]}
|
|
121
163
|
>
|
|
122
164
|
<div
|
|
@@ -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
|
|
|
@@ -18,3 +18,15 @@ accepts any string since `@recursica/adapter-common` 1.2.0). In development, `Te
|
|
|
18
18
|
if absent it logs one `console.error` per name and sets `data-typography-missing`, which
|
|
19
19
|
`Typography.module.css` hides with `visibility: hidden`. The attribute is never set when
|
|
20
20
|
`NODE_ENV === "production"`.
|
|
21
|
+
|
|
22
|
+
## Text must never render `h1`-`h6` (Matt Massey, 2026-10-08)
|
|
23
|
+
|
|
24
|
+
**Decision:** `Text` must never render an `<h1>`-`<h6>` element. It is not allowed. Semantic
|
|
25
|
+
headings use `Heading`.
|
|
26
|
+
|
|
27
|
+
**Implementation:** `Text` covers every other kind of text in the system (body, labels, captions,
|
|
28
|
+
etc.). Its variants can extend what the Recursica JSON and CSS define, so a variant need not
|
|
29
|
+
exist in the Recursica tokens. Heading levels are the one thing `Text` can't be: they have a
|
|
30
|
+
fixed definition and styling from Recursica and Forge, owned by `Heading`. `Text.tsx` throws
|
|
31
|
+
`Text cannot render <hN>. Use <Heading> for semantic h1-h6.` when `component` (or
|
|
32
|
+
`variantMapping.inherit`, which MUI uses to pick the element) is `h1`-`h6`.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { flushSync } from "react-dom";
|
|
5
|
+
import { Text } from "./Text";
|
|
6
|
+
|
|
7
|
+
class Boundary extends React.Component<
|
|
8
|
+
{ children: React.ReactNode },
|
|
9
|
+
{ message: string | null }
|
|
10
|
+
> {
|
|
11
|
+
state = { message: null as string | null };
|
|
12
|
+
static getDerivedStateFromError(error: Error) {
|
|
13
|
+
return { message: error.message };
|
|
14
|
+
}
|
|
15
|
+
render() {
|
|
16
|
+
return this.state.message ?? this.props.children;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function render(props: Record<string, unknown>): HTMLElement {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
flushSync(() =>
|
|
24
|
+
createRoot(container).render(
|
|
25
|
+
<Boundary>
|
|
26
|
+
<Text {...(props as object)}>Content</Text>
|
|
27
|
+
</Boundary>,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
return container;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Text heading elements", () => {
|
|
34
|
+
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
|
|
35
|
+
"throws when component is %s",
|
|
36
|
+
(component) => {
|
|
37
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
38
|
+
expect(render({ component }).textContent).toContain(
|
|
39
|
+
`Text cannot render <${component}>. Use <Heading> for semantic h1-h6.`,
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
it("throws when variantMapping maps to a heading", () => {
|
|
45
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
46
|
+
expect(render({ variantMapping: { inherit: "h2" } }).textContent).toContain(
|
|
47
|
+
"Text cannot render <h2>.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it.each(["p", "span", "label"])("renders component=%s", (component) => {
|
|
52
|
+
expect(render({ component }).querySelector(component)).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -11,10 +11,26 @@ export type TextProps = RecursicaOverStyled<
|
|
|
11
11
|
"color"
|
|
12
12
|
>;
|
|
13
13
|
|
|
14
|
+
const HEADING_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6"];
|
|
15
|
+
|
|
14
16
|
export const Text = forwardRef<HTMLElement, TextProps>(function Text(
|
|
15
17
|
{ variant = "body", emphasis = "high", color = "default", ...rest },
|
|
16
18
|
ref,
|
|
17
19
|
) {
|
|
20
|
+
// Text never renders semantic h1-h6; those belong to Heading. MUI picks the element from
|
|
21
|
+
// `component`, or (for variant "inherit") from `variantMapping.inherit`, so check both.
|
|
22
|
+
const { component, variantMapping } = rest as {
|
|
23
|
+
component?: unknown;
|
|
24
|
+
variantMapping?: Record<string, unknown>;
|
|
25
|
+
};
|
|
26
|
+
for (const element of [component, variantMapping?.inherit]) {
|
|
27
|
+
if (typeof element === "string" && HEADING_ELEMENTS.includes(element)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Text cannot render <${element}>. Use <Heading> for semantic h1-h6.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
// Dev only: an unknown variant hides the text (and logs) instead of silently rendering with the
|
|
19
35
|
// UI kit's default styling.
|
|
20
36
|
const typographyMissing =
|
|
@@ -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
|
+
}
|