@recursica/mui-adapter 0.19.0 → 0.21.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/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/dist/mui-adapter.cjs +89 -58
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +26163 -8727
- package/dist/mui-adapter.js.map +1 -1
- package/dist/src/components/Dropdown/BareDropdown.d.ts +41 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts +16 -3
- package/dist/src/components/Tree/Tree.d.ts +5 -0
- package/dist/src/index.d.ts +1 -1
- package/docs/PHILOSOPHY.md +39 -0
- package/package.json +10 -3
- package/src/components/Box/USAGE.md +1 -1
- package/src/components/Button/BUTTON_IMPLEMENTATION_NOTES.md +22 -0
- package/src/components/Button/Button.module.css +10 -7
- package/src/components/Button/Button.tsx +4 -0
- package/src/components/Button/USAGE.md +1 -13
- package/src/components/Card/USAGE.md +1 -13
- package/src/components/Container/USAGE.md +1 -1
- package/src/components/Dropdown/BareDropdown.tsx +135 -0
- package/src/components/Dropdown/Dropdown.tsx +16 -0
- package/src/components/Grid/USAGE.md +6 -10
- package/src/components/Loader/USAGE.md +1 -23
- package/src/components/Menu/USAGE.md +0 -7
- package/src/components/Pagination/USAGE.md +0 -6
- package/src/components/Panel/USAGE.md +1 -58
- package/src/components/Stepper/USAGE.md +0 -7
- package/src/components/Tabs/USAGE.md +0 -7
- package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +68 -0
- package/src/components/TimePicker/TimePicker.module.css +213 -37
- package/src/components/TimePicker/TimePicker.stories.tsx +119 -4
- package/src/components/TimePicker/TimePicker.tsx +257 -8
- package/src/components/TimePicker/USAGE.md +27 -2
- package/src/components/Tree/IMPLEMENTATION_NOTES.md +28 -1
- package/src/components/Tree/Tree.module.css +114 -69
- package/src/components/Tree/Tree.stories.tsx +13 -0
- package/src/components/Tree/Tree.tsx +99 -32
- package/src/components/Tree/USAGE.md +18 -2
- package/src/index.ts +1 -0
|
@@ -1,10 +1,259 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import "
|
|
3
|
-
import {
|
|
1
|
+
import React, { forwardRef, useEffect, useState } from "react";
|
|
2
|
+
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
3
|
+
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|
4
|
+
import {
|
|
5
|
+
TimePicker as MuiTimePicker,
|
|
6
|
+
type TimePickerProps as MuiTimePickerProps,
|
|
7
|
+
} from "@mui/x-date-pickers/TimePicker";
|
|
8
|
+
import dayjs, { type Dayjs } from "dayjs";
|
|
9
|
+
import customParseFormat from "dayjs/plugin/customParseFormat";
|
|
10
|
+
import { type ReadOnlyControlProps } from "@recursica/adapter-common";
|
|
11
|
+
import {
|
|
12
|
+
filterStylingProps,
|
|
13
|
+
type RecursicaOverStyled,
|
|
14
|
+
} from "../../utils/filterStylingProps";
|
|
15
|
+
import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
|
|
16
|
+
import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
|
|
17
|
+
import { BareDropdown } from "../Dropdown/BareDropdown";
|
|
18
|
+
import styles from "./TimePicker.module.css";
|
|
4
19
|
|
|
5
|
-
|
|
6
|
-
RecursicaTimePickerProps;
|
|
20
|
+
import { type RecursicaTimePickerProps as BaseRecursicaTimePickerProps } from "@recursica/adapter-common";
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
// Required for dayjs's 2-arg `dayjs(value, format)` string-parsing signature used by `toDayjs`
|
|
23
|
+
// below — without this, dayjs silently ignores the format string and falls back to native `Date`
|
|
24
|
+
// parsing, which fails on a bare "HH:mm" time string (no date component) and returns an Invalid
|
|
25
|
+
// Date. This went unnoticed until a real initial `value`/`defaultValue` needed parsing (every
|
|
26
|
+
// interactive test before that produced Dayjs objects directly from the field's own onChange,
|
|
27
|
+
// never exercising this path).
|
|
28
|
+
dayjs.extend(customParseFormat);
|
|
29
|
+
|
|
30
|
+
const TIME_FORMAT_SECONDS = "HH:mm:ss";
|
|
31
|
+
const TIME_FORMAT_MINUTES = "HH:mm";
|
|
32
|
+
const AM_PM_DATA = [
|
|
33
|
+
{ value: "AM", label: "AM" },
|
|
34
|
+
{ value: "PM", label: "PM" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Parses a Recursica "HH:mm" / "HH:mm:ss" string into a Dayjs instance, or undefined if not set. */
|
|
38
|
+
function toDayjs(value: string | undefined): Dayjs | undefined {
|
|
39
|
+
if (!value) return undefined;
|
|
40
|
+
const format = value.length > 5 ? TIME_FORMAT_SECONDS : TIME_FORMAT_MINUTES;
|
|
41
|
+
return dayjs(value, format);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Formats an "HH:mm"/"HH:mm:ss" 24-hour value as a 12-hour + AM/PM string for read-only display
|
|
46
|
+
* (e.g. "14:30" -> "2:30 PM") — the raw 24-hour string was being shown as-is in read-only mode,
|
|
47
|
+
* with no AM/PM, unlike the interactive composite. Returns undefined if not parseable.
|
|
48
|
+
*/
|
|
49
|
+
function formatReadOnlyTime(value: string | undefined): string | undefined {
|
|
50
|
+
const parsed = toDayjs(value);
|
|
51
|
+
if (!parsed) return value;
|
|
52
|
+
return parsed.format(value && value.length > 5 ? "h:mm:ss A" : "h:mm A");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RecursicaTimePickerProps
|
|
56
|
+
extends Omit<
|
|
57
|
+
MuiTimePickerProps,
|
|
58
|
+
| "value"
|
|
59
|
+
| "defaultValue"
|
|
60
|
+
| "onChange"
|
|
61
|
+
| "minTime"
|
|
62
|
+
| "maxTime"
|
|
63
|
+
| "views"
|
|
64
|
+
| "format"
|
|
65
|
+
| "style"
|
|
66
|
+
>,
|
|
67
|
+
Pick<
|
|
68
|
+
RecursicaFormControlWrapperProps,
|
|
69
|
+
| "label"
|
|
70
|
+
| "error"
|
|
71
|
+
| "required"
|
|
72
|
+
| "id"
|
|
73
|
+
| "assistiveText"
|
|
74
|
+
| "assistiveWithIcon"
|
|
75
|
+
| "formLayout"
|
|
76
|
+
| "labelSize"
|
|
77
|
+
| "labelAlignment"
|
|
78
|
+
| "labelOptionalText"
|
|
79
|
+
| "labelWithEditIcon"
|
|
80
|
+
| "onLabelEditClick"
|
|
81
|
+
>,
|
|
82
|
+
ReadOnlyControlProps,
|
|
83
|
+
BaseRecursicaTimePickerProps {
|
|
84
|
+
/** Selected time as an "HH:mm" (or "HH:mm:ss" with `withSeconds`) string, matching the mantine-adapter convention. */
|
|
85
|
+
value?: string;
|
|
86
|
+
/** Uncontrolled initial time as an "HH:mm" (or "HH:mm:ss" with `withSeconds`) string. */
|
|
87
|
+
defaultValue?: string;
|
|
88
|
+
/** Fires with the new time as an "HH:mm" (or "HH:mm:ss" with `withSeconds`) string, or `null` if cleared. */
|
|
89
|
+
onChange?: (value: string | null) => void;
|
|
90
|
+
/** Caller-provided inline style, passed through to the FormControlWrapper root. */
|
|
91
|
+
style?: React.CSSProperties;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type TimePickerProps = RecursicaOverStyled<RecursicaTimePickerProps>;
|
|
95
|
+
|
|
96
|
+
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
97
|
+
function TimePicker(props, ref) {
|
|
98
|
+
const {
|
|
99
|
+
overStyled = false,
|
|
100
|
+
formLayout = "stacked",
|
|
101
|
+
|
|
102
|
+
// Label & Wrapper Maps
|
|
103
|
+
labelSize,
|
|
104
|
+
labelAlignment,
|
|
105
|
+
labelOptionalText,
|
|
106
|
+
labelWithEditIcon,
|
|
107
|
+
onLabelEditClick,
|
|
108
|
+
|
|
109
|
+
label,
|
|
110
|
+
assistiveText,
|
|
111
|
+
assistiveWithIcon,
|
|
112
|
+
error,
|
|
113
|
+
required,
|
|
114
|
+
id,
|
|
115
|
+
className,
|
|
116
|
+
style,
|
|
117
|
+
disabled,
|
|
118
|
+
readOnly,
|
|
119
|
+
readOnlyComponent,
|
|
120
|
+
emptyValueComponent,
|
|
121
|
+
value,
|
|
122
|
+
defaultValue,
|
|
123
|
+
onChange,
|
|
124
|
+
withSeconds,
|
|
125
|
+
minTime,
|
|
126
|
+
maxTime,
|
|
127
|
+
...rest
|
|
128
|
+
} = props;
|
|
129
|
+
|
|
130
|
+
const sanitizedProps = filterStylingProps(rest, overStyled);
|
|
131
|
+
|
|
132
|
+
// Internal full 24-hour value. Needed (unlike every other component in this adapter) because
|
|
133
|
+
// the time field and the AM/PM BareDropdown both mutate the same conceptual value — see
|
|
134
|
+
// TIMEPICKER_IMPLEMENTATION_NOTES.md.
|
|
135
|
+
const [internalValue, setInternalValue] = useState<Dayjs | null>(
|
|
136
|
+
() => toDayjs(value) ?? toDayjs(defaultValue) ?? null,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (value !== undefined) {
|
|
141
|
+
setInternalValue(toDayjs(value) ?? null);
|
|
142
|
+
}
|
|
143
|
+
}, [value]);
|
|
144
|
+
|
|
145
|
+
const isPM = internalValue ? internalValue.hour() >= 12 : false;
|
|
146
|
+
|
|
147
|
+
const wrapperClass = className
|
|
148
|
+
? `${styles.layoutOverride} ${className}`
|
|
149
|
+
: styles.layoutOverride;
|
|
150
|
+
|
|
151
|
+
const emitChange = (next: Dayjs | null) => {
|
|
152
|
+
setInternalValue(next);
|
|
153
|
+
onChange?.(
|
|
154
|
+
next
|
|
155
|
+
? next.format(withSeconds ? TIME_FORMAT_SECONDS : TIME_FORMAT_MINUTES)
|
|
156
|
+
: null,
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handleFieldChange = (next: Dayjs | null) => {
|
|
161
|
+
emitChange(next);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleMeridiemChange = (next: string | null) => {
|
|
165
|
+
if (!internalValue || !next) return;
|
|
166
|
+
const wantsPM = next === "PM";
|
|
167
|
+
const currentHour = internalValue.hour();
|
|
168
|
+
const currentlyPM = currentHour >= 12;
|
|
169
|
+
if (wantsPM === currentlyPM) return;
|
|
170
|
+
const nextHour = wantsPM ? currentHour + 12 : currentHour - 12;
|
|
171
|
+
emitChange(internalValue.hour(nextHour));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<WithReadOnlyWrapper
|
|
176
|
+
ref={ref}
|
|
177
|
+
className={wrapperClass}
|
|
178
|
+
style={style as React.CSSProperties}
|
|
179
|
+
controlMaxWidth={undefined}
|
|
180
|
+
controlMinWidth={undefined}
|
|
181
|
+
overStyled={overStyled as true}
|
|
182
|
+
formLayout={formLayout}
|
|
183
|
+
labelSize={labelSize}
|
|
184
|
+
labelAlignment={labelAlignment}
|
|
185
|
+
labelOptionalText={labelOptionalText}
|
|
186
|
+
labelWithEditIcon={labelWithEditIcon}
|
|
187
|
+
onLabelEditClick={onLabelEditClick}
|
|
188
|
+
label={label}
|
|
189
|
+
assistiveText={assistiveText}
|
|
190
|
+
assistiveWithIcon={assistiveWithIcon}
|
|
191
|
+
error={error}
|
|
192
|
+
required={required}
|
|
193
|
+
id={id}
|
|
194
|
+
readOnly={readOnly}
|
|
195
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
196
|
+
readOnlyComponent={readOnlyComponent as any}
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
emptyValueComponent={(emptyValueComponent as any) || undefined}
|
|
199
|
+
readOnlyType="text"
|
|
200
|
+
readOnlyValue={formatReadOnlyTime(
|
|
201
|
+
value !== undefined ? value : defaultValue,
|
|
202
|
+
)}
|
|
203
|
+
readOnlyNativeProps={props}
|
|
204
|
+
activeComponent={
|
|
205
|
+
/* Naked field execution safely decoupled from MUI X's own label/error macro handling.
|
|
206
|
+
format="hh:mm" is always on (12-hour digits, no native meridiem section) — this is the
|
|
207
|
+
only way this component operates, not a user choice. The AM/PM BareDropdown next to it
|
|
208
|
+
is the only AM/PM control; see TIMEPICKER_IMPLEMENTATION_NOTES.md. */
|
|
209
|
+
<div className={styles.root} data-error={error ? "true" : undefined}>
|
|
210
|
+
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
211
|
+
<MuiTimePicker
|
|
212
|
+
value={internalValue}
|
|
213
|
+
onChange={handleFieldChange}
|
|
214
|
+
disabled={disabled}
|
|
215
|
+
format={`hh:${withSeconds ? "mm:ss" : "mm"}`}
|
|
216
|
+
views={
|
|
217
|
+
withSeconds
|
|
218
|
+
? ["hours", "minutes", "seconds"]
|
|
219
|
+
: ["hours", "minutes"]
|
|
220
|
+
}
|
|
221
|
+
minTime={toDayjs(minTime)}
|
|
222
|
+
maxTime={toDayjs(maxTime)}
|
|
223
|
+
// No icon/open-picker button: time-picker's own token schema has no icon slot (see
|
|
224
|
+
// EXEMPTIONS above), and the popup clock/list view this button opens isn't styled
|
|
225
|
+
// to Recursica tokens anyway (see TIMEPICKER_IMPLEMENTATION_NOTES.md) — showing an
|
|
226
|
+
// affordance to open an unstyled popup would be worse than not showing one. Typing
|
|
227
|
+
// directly into the field's masked hour/minute segments is the only interaction.
|
|
228
|
+
slots={{ openPickerButton: () => null }}
|
|
229
|
+
slotProps={{
|
|
230
|
+
field: {
|
|
231
|
+
className: styles.field,
|
|
232
|
+
},
|
|
233
|
+
}}
|
|
234
|
+
{...(sanitizedProps as unknown as Partial<MuiTimePickerProps>)}
|
|
235
|
+
/>
|
|
236
|
+
</LocalizationProvider>
|
|
237
|
+
<BareDropdown
|
|
238
|
+
overStyled
|
|
239
|
+
className={styles.amPmSelect}
|
|
240
|
+
// Dropdown.module.css's own .root sets width: 100% (correct for a standalone
|
|
241
|
+
// Dropdown filling its form-control column) — overStyled lets us override just the
|
|
242
|
+
// width via inline style, keeping every other Recursica style (border, colors,
|
|
243
|
+
// padding) intact. See TIMEPICKER_IMPLEMENTATION_NOTES.md.
|
|
244
|
+
style={{ width: "fit-content" }}
|
|
245
|
+
data={AM_PM_DATA}
|
|
246
|
+
value={isPM ? "PM" : "AM"}
|
|
247
|
+
onChange={handleMeridiemChange}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
error={!!error}
|
|
250
|
+
aria-label="AM or PM"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
TimePicker.displayName = "TimePicker";
|
|
@@ -10,6 +10,8 @@ This document describes how to integrate and use the `TimePicker` component in y
|
|
|
10
10
|
import { TimePicker } from "@recursica/mui-adapter";
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
`TimePicker` requires `@mui/x-date-pickers` (optional peer dependency) to be installed alongside `@mui/material`. See [SETUP.md](../../../SETUP.md).
|
|
14
|
+
|
|
13
15
|
---
|
|
14
16
|
|
|
15
17
|
## 2. Basic Example
|
|
@@ -19,10 +21,26 @@ import React from "react";
|
|
|
19
21
|
import { TimePicker } from "@recursica/mui-adapter";
|
|
20
22
|
|
|
21
23
|
export default function Demo() {
|
|
22
|
-
return <TimePicker label="Select Time"
|
|
24
|
+
return <TimePicker label="Select Time" />;
|
|
23
25
|
}
|
|
24
26
|
```
|
|
25
27
|
|
|
28
|
+
`TimePicker`'s value is a plain `"HH:mm"` string (matching `@recursica/mantine-adapter`'s convention) — always 24-hour. Pass `withSeconds` to add a seconds segment (`"HH:mm:ss"`), and `minTime`/`maxTime` (in the same string format) to bound the allowed range.
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
<TimePicker
|
|
32
|
+
label="Precise Time"
|
|
33
|
+
withSeconds
|
|
34
|
+
minTime="09:00:00"
|
|
35
|
+
maxTime="17:00:00"
|
|
36
|
+
onChange={(value) => console.log(value)} // "HH:mm:ss" | null
|
|
37
|
+
/>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> [!IMPORTANT] > **Recursica-specific behavior:** `TimePicker` always renders a 12-hour field with a dedicated AM/PM `Dropdown`-style selector next to it — a deviation from `@mui/x-date-pickers`'s own default (24-hour, or a single field with an inline meridiem section) and **not configurable**. There is no prop to switch to a plain 24-hour field; this is the only way the component operates.
|
|
41
|
+
|
|
42
|
+
The AM/PM control visually matches Recursica's `Dropdown` component exactly, rather than a plain native `<select>`.
|
|
43
|
+
|
|
26
44
|
---
|
|
27
45
|
|
|
28
46
|
## 3. Design System Integration
|
|
@@ -31,6 +49,13 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
|
|
|
31
49
|
|
|
32
50
|
> [!IMPORTANT]
|
|
33
51
|
>
|
|
34
|
-
> - **Anti-override protection**:
|
|
52
|
+
> - **Anti-override protection**: Rogue style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
|
|
35
53
|
> - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
|
|
36
54
|
> - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
|
|
55
|
+
> - **Known limitation**: the closed field is fully token-driven; the open clock/list dropdown currently renders with MUI's default styling (no Figma token spec exists for it yet).
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 4. Read-Only Mode
|
|
60
|
+
|
|
61
|
+
Pass `readOnly` to render the current value as static text, matching every other Recursica form control. The value is formatted as 12-hour + AM/PM (e.g. `"14:30"` displays as `"2:30 PM"`).
|
|
@@ -18,4 +18,31 @@
|
|
|
18
18
|
|
|
19
19
|
- **Deliberately not implemented (no tokens back them):** checkbox/indeterminate node state (`@mui/x-tree-view` supports `checkboxSelection`, but the Figma UI Kit's `tree` tokens only define `selected`/`unselected`, not a checked state) and per-node `disabled` (no token, and no `disabled` field in `RecursicaTreeNode`). Matches the mantine-adapter implementation's same decision.
|
|
20
20
|
|
|
21
|
-
- **KNOWN GAP (2026-08-06): Forge's own preview shows a red chevron with more chevron-to-label spacing than what's currently implemented here.**
|
|
21
|
+
- **RESOLVED (2026-08-08) — KNOWN GAP (2026-08-06): Forge's own preview shows a red chevron with more chevron-to-label spacing than what's currently implemented here.** Explained by the Button swap below: Forge's chevron is Button's own "text" variant color (Recursica's red/alert-adjacent brand color), not a `tree`-namespace icon-color token — there never was a missing token to find. Re-verified against both the compiled `recursica_variables_scoped.css` and the raw `recursica_ui-kit.json` at the time: the `tree` schema has no `icon-color` property at all, and `button-node-gap` (used as `.row`'s `gap`) resolves to exactly 4px — confirmed not a mapping bug on our side, just the wrong mental model (a hand-drawn glyph instead of an actual Button).
|
|
22
|
+
|
|
23
|
+
- **Keyboard nav (Matt Massey, 2026-08-08): arrow up/down already worked — only the focus ring was missing.** `@mui/x-tree-view`'s own `useTreeItem` already implements full WAI-ARIA roving-tabindex keyboard navigation — confirmed via Playwright that focus genuinely moves between rows on `ArrowDown`. The reason it looked broken: DOM focus lands on `.node` (the same `<li role="treeitem">` `TreeItemRoot` renders as), and `.row`'s only focus-related rule reset MUI's own focused-state _background color_ back to the unselected default — no replacement focus indicator was ever added. Fixed by adding the same `:focus-visible` box-shadow ring every other focusable component uses, keyed off `.node:focus-visible` since that's where DOM focus actually lands. Originally drawn on `.row` (the whole button+label box); see the next entry for why it now targets `.label` only.
|
|
24
|
+
|
|
25
|
+
- **Chevron → `Button` (Matt Massey, 2026-08-08).** Forge's real design uses an actual `Button` (`variant="text"` `size="small"`, icon-only) for the expand/collapse chevron, not a hand-drawn glyph — see the resolved "KNOWN GAP" above. `ExpandGlyph`/`CollapseGlyph`/`EndGlyph` (the three `slots.expandIcon`/`collapseIcon`/`endIcon` components MUI's `TreeItemIcon` picks between based on row state) now all delegate to a shared `ExpandToggleButton`, which renders a real `<Button icon={<ChevronGlyph/>} tabIndex={-1} aria-hidden="true" .../>`:
|
|
26
|
+
|
|
27
|
+
- **Doesn't reopen any nested-focusable-element concern**: the embedded Button is never independently focusable or tab-stoppable (`tabIndex={-1}`) and is hidden from assistive tech (`aria-hidden`) — the row (`.node`) stays the single focusable/interactive element. Confirmed MUI's `ButtonBase` doesn't call `stopPropagation()` on click, so clicking the chevron still bubbles up to `TreeItemContent`'s existing click handling and triggers expand/collapse exactly as before — no `onClick` needed on the embedded Button itself.
|
|
28
|
+
- **Rendered for every row, including leaves** (`EndGlyph` → `ExpandToggleButton hidden`), so every row reserves identical layout space; a `.expandButtonHidden { visibility: hidden; }` class hides it on leaf rows without needing to duplicate Button's own size tokens for a placeholder. `.iconContainer`'s old hardcoded `width: 1em` (sized for the previous plain-SVG glyph) was removed so the real Button can size itself from its own tokens.
|
|
29
|
+
- **Rotation targets the glyph directly, not Button's internals**: `ChevronGlyph`'s `<svg>` carries its own `styles.chevron`/`styles.chevronExpanded` classes directly, not reached through Button's DOM at all — `Button`'s own `.iconWrapper` isn't reachable from `Tree.module.css` in the first place (CSS Modules don't expose a stable cross-file class name for it, unlike MUI's own stable `Mui*` global classes used elsewhere in this file).
|
|
30
|
+
- **Focus ring narrowed to `.label` only, excluding the Button** (Matt: "the focus ring for an item should be around just the node, not including the chevron button" / "the chevron button should not have a focus state"). `.label` was given `flex: 1 1 auto; height: 100%` so the ring still reads as a clean box covering the row's remaining width, not a tight text-only outline.
|
|
31
|
+
- **Found and fixed a real, separate bug while embedding this**: `Button.tsx` (both adapters) explicitly sets `className={finalClass}` (merging `styles.root` with any caller className) but then spreads `{...sanitizedProps}` _after_ it — and `sanitizedProps` still contains the original, unmodified `className` key, since it was only _read_, never deleted. The later spread silently overwrote `finalClass` with just the caller's own class whenever one was passed (e.g. `styles.expandButton` here) — exact same bug class as this adapter's own `Dropdown.tsx`/`BareDropdown.tsx` had. This was fully visible here: the chevron rendered in MUI's own default blue (`MuiButton-colorPrimary`), since `Button-module__root` — and therefore every `[data-variant]` color rule — was missing from the DOM entirely. Confirmed mantine-adapter had the identical bug in its own `Button.tsx`, just masked there by a separate `classNames={{root: ...}}` object prop unaffected by the overwrite — fixed in both regardless.
|
|
32
|
+
|
|
33
|
+
- **Expand/collapse and select made fully independent (Matt Massey, 2026-08-10), superseding the "approximated, not exact" `expandOnClick`/`selectOnClick` mapping described above.** Matt clarified the real requirement: the button must be the _only_ way to expand/collapse on click, a row click must _only_ select, `Enter`/`Space` must _only_ select (never expand, even on a node with children), and `ArrowLeft`/`ArrowRight` must _only_ expand/collapse (never select) — letting a user toggle a subtree open without ever changing selection, and vice versa. Removed `expandOnClick`/`selectOnClick` entirely (per point 5 of that request), since the old approximation problem disappears once the pattern is fixed rather than configurable.
|
|
34
|
+
|
|
35
|
+
- **Click**: `expansionTrigger` is now unconditionally `"iconContainer"` (previously `expandOnClick ? "content" : "iconContainer"`). That alone wasn't enough: `@mui/x-tree-view`'s `createContentHandleClick` (wired onto `TreeItemContent`/`.row`) calls `interactions.handleSelection` on _any_ click that reaches it — including a click on the icon container, since it's a DOM descendant of the content and the icon container's own click handler never stops propagation. `CustomTreeItem` now wraps `getIconContainerProps()`'s `onClick` with `event.stopPropagation()` before delegating to it, so an icon-container click still expands/collapses (via the library's own `createIconContainerHandleClick`) but never bubbles up to also select.
|
|
36
|
+
- **Enter**: `@mui/x-tree-view`'s own keyboard plugin expands an expandable node on `Enter` and only falls back to selecting when the node has no children — not what we need (`Enter` must always select). `Space` already selects unconditionally by default, so it needed no change. Overrode just `Enter` via `getRootProps({..., onKeyDown: handleRootKeyDown})`: `useTreeItem`'s `createRootHandleKeyDown` runs any externally-supplied `onKeyDown` _before_ its own internal handling and skips that internal handling entirely if the external handler sets `event.defaultMuiPrevented = true` — the library's documented extension point for exactly this kind of override, not an undocumented hack. `handleRootKeyDown` calls `interactions.handleSelection` (from `useTreeItemUtils`, the same hook `useTreeItem` uses internally) directly. `ArrowLeft`/`ArrowRight` needed no changes — the built-in handling already only calls `store.expansion.setItemExpansion`, never touches selection.
|
|
37
|
+
- **Background/focus scoped to the label, not the whole node** (Matt: "The focus/background color should be on the node's label, not the entire node"): moved every visual property (padding, border, font, unselected/selected colors, hover overlay) off `.row` and onto `.label` — `.row` is now a plain flex layout container (indentation + `button-node-gap` only). `data-selected`/`data-focused` are still only set on `.row` (via `getContentProps()`), so the selected-state rule now reads `.row[data-selected] .label` (a descendant selector) instead of styling `.row` directly; same for the hover overlay (`.row:hover .label::after`). The focus ring (added in the entry above) already targeted `.label` only, so it needed no further change.
|
|
38
|
+
|
|
39
|
+
- **Three follow-on fixes to the label-scoping work above (Matt Massey, 2026-08-10):**
|
|
40
|
+
|
|
41
|
+
- **Selected/hover chip was full-row width, not label width, in both adapters.** `.label` had `flex: 1 1 auto` — the `1` flex-grow stretched it to fill `.row`'s entire remaining width (icon container aside), so the highlighted chip visually covered the whole row even though the _properties_ were correctly scoped to `.label`. Changed to `flex: 0 1 auto`, but that alone wasn't enough here (unlike mantine-adapter): `@mui/x-tree-view`'s own `.MuiTreeItem-label` sets a literal `width: 100%`, which a flex-basis change can't beat since it's a direct `width` declaration, not a sizing default. Added an explicit `width: auto` to `.label` to override it.
|
|
42
|
+
- **Focusing an expanded parent drew the ring on every descendant label too, in both adapters.** `.node:focus-visible .label` is a plain descendant selector — every child node's `.label` (inside the nested `.subtree` `<ul>`) is _also_ a descendant of the focused parent's `.node` `<li>`, at any depth, so the ring matched all of them at once. Fixed by scoping to `.node:focus-visible > .row .label`: `.row` is always a direct child of its own `.node`, but never of an ancestor `.node` (those reach it through the intervening `.subtree` `<ul>` and nested `<li>` instead) — the `>` combinator excludes every one of them.
|
|
43
|
+
- **MUI-only: a leftover default selected background was still showing through.** `@mui/x-tree-view`'s own `TreeItemContent` ships a built-in selected tint (`rgba(25, 118, 210, 0.08)`, and a separate, higher-specificity `rgba(25, 118, 210, 0.2)` for the `[data-selected][data-focused]` combination specifically) that the earlier `.row:hover, .row[data-focused] { background-color: transparent; }` neutralizer never covered. Added `.row[data-selected]` to that neutralizer — but the `[data-selected][data-focused]` compound needed its _own_ explicit rule too: MUI's equivalent compound selector has higher specificity (two attribute selectors plus its own class) than a single-attribute `.row[data-selected]` rule, so it kept winning regardless of source order until matched with an equally-specific `.row[data-selected][data-focused]` rule on our side.
|
|
44
|
+
|
|
45
|
+
- **Whole-tree `disabled` (Matt Massey, 2026-08-10), added to support a `Disabled` story.** Unlike mantine-adapter (which has zero `disabled` concept anywhere in its `Tree` API — see its own note on this), `@mui/x-tree-view` already had per-item `disabled` plumbing sitting mostly dormant here: `CustomTreeItem` already destructured `disabled` off `UseTreeItemParameters`, and `.row[data-disabled] { opacity: ...; cursor: auto; }` already existed in `Tree.module.css` — just with no way for a caller to actually set it, since `RecursicaTreeNode` has no `disabled` field (and still doesn't; per-node disabling remains unexposed, same reasoning as the existing "Deliberately not implemented" entry above).
|
|
46
|
+
- **Implementation**: `isItemDisabled={disabled ? () => true : undefined}` passed to `<RichTreeView>` — marking every item disabled at once, reusing the library's own per-item mechanism rather than inventing a parallel one. `interactions.handleExpansion`/`handleSelection` (used by both the icon container's click handling and our own `Enter`-key override) already check `status.disabled` internally and no-op, so nothing in `CustomTreeItem` needed an extra guard.
|
|
47
|
+
- **Visual**: the pre-existing `.row[data-disabled]` rule now actually activates. Verified a pre-selected node's chip stays visible underneath the dimming (`.row[data-selected] .label` and `.row[data-disabled]` are independent, non-conflicting selectors — one styles `.label`, the other dims the whole `.row` via `opacity`), per Matt's ask to check that combination.
|
|
48
|
+
- **Found while verifying**: disabled rows still showed the hover tint (`.label::after` opacity) on mouse-over — MUI's own disabled checks block the actual select/expand _action_ on click, but `:hover` is pure CSS with nothing stopping it. Added `pointer-events: none` to `.row[data-disabled]`, matching mantine-adapter's `.root[data-disabled]` (tree-wide there; per-row here, since MUI's disabled state is inherently per-item even when every item is disabled at once by the same whole-tree prop).
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/* HARDCODED VALUES:
|
|
2
2
|
* - list-style/margin/padding resets on .root/.subtree/.node: layout resets, no corresponding
|
|
3
3
|
* design tokens (structural, not visual design values).
|
|
4
|
-
* - .chevron
|
|
5
|
-
*
|
|
6
|
-
* color
|
|
4
|
+
* - .chevron rotation timing (150ms ease): no dedicated timing token exists for Tree; the glyph
|
|
5
|
+
* itself is sized/colored entirely by Button's own "text"/"small" tokens now, not a hand-drawn
|
|
6
|
+
* size/color like the previous plain-SVG implementation.
|
|
7
7
|
* - Hover overlay uses the generic --recursica_brand_states_hover_* tokens (same technique as
|
|
8
8
|
* Menu/Accordion/Button): Tree has no component-specific hover tokens of its own.
|
|
9
|
-
* - .row's padding-left keeps MUI's own depth-based indentation formula (
|
|
10
|
-
*
|
|
9
|
+
* - .row's padding-left keeps MUI's own depth-based indentation formula (indent * depth), just
|
|
10
|
+
* without the horizontal-padding base — that base now lives on .label instead (see below).
|
|
11
|
+
*
|
|
12
|
+
* NOTE: the row's box model (padding/border/font/color/background) lives entirely on `.label`,
|
|
13
|
+
* not `.row` — the highlighted "chip" (hover/selected/focus) covers only the label, never the
|
|
14
|
+
* expand button. `.row` itself is a plain flex layout container (indentation + button-node-gap).
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
.root {
|
|
@@ -42,6 +46,28 @@
|
|
|
42
46
|
list-style: none;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/* @mui/x-tree-view's roving-tabindex keyboard navigation (arrow up/down moves focus between
|
|
50
|
+
rows, left/right expand/collapse, matching the WAI-ARIA treeitem pattern) already works out of
|
|
51
|
+
the box — but with no visible indicator, it looked broken. DOM focus lands on this <li> (.node),
|
|
52
|
+
not the visible row box, so the ring is drawn on .label instead — the "node" content area,
|
|
53
|
+
deliberately excluding the expand Button, which never shows a focus state of its own (it's not
|
|
54
|
+
independently focusable at all: tabIndex={-1}). */
|
|
55
|
+
.node:focus-visible {
|
|
56
|
+
outline: none;
|
|
57
|
+
}
|
|
58
|
+
/* `> .row` (child combinator), not a plain descendant selector: a node's own `.row` is always a
|
|
59
|
+
direct child of its `.node` <li>, while every descendant node's `.row` (inside the nested
|
|
60
|
+
`.subtree` <ul>) is not — without the `>`, focusing a parent node drew the ring on every
|
|
61
|
+
expanded descendant's label too, since they're all still descendants of the focused `.node`. */
|
|
62
|
+
.node:focus-visible > .row .label {
|
|
63
|
+
box-shadow:
|
|
64
|
+
0 0 0 var(--recursica_brand_states_focus_border-size)
|
|
65
|
+
var(--recursica_brand_states_focus_color),
|
|
66
|
+
0 0 var(--recursica_brand_states_focus_blur)
|
|
67
|
+
var(--recursica_brand_states_focus_margin)
|
|
68
|
+
var(--recursica_brand_states_focus_color);
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
.row {
|
|
46
72
|
position: relative;
|
|
47
73
|
box-sizing: border-box;
|
|
@@ -50,22 +76,77 @@
|
|
|
50
76
|
width: 100%;
|
|
51
77
|
cursor: pointer;
|
|
52
78
|
gap: var(--recursica_ui-kit_components_tree_properties_button-node-gap);
|
|
53
|
-
|
|
54
|
-
padding-top: var(
|
|
55
|
-
--recursica_ui-kit_components_tree_properties_vertical-padding
|
|
56
|
-
);
|
|
57
|
-
padding-bottom: var(
|
|
58
|
-
--recursica_ui-kit_components_tree_properties_vertical-padding
|
|
59
|
-
);
|
|
60
|
-
padding-right: var(
|
|
61
|
-
--recursica_ui-kit_components_tree_properties_horizontal-padding
|
|
62
|
-
);
|
|
63
79
|
padding-left: calc(
|
|
64
|
-
var(--
|
|
65
|
-
var(--TreeView-itemChildrenIndentation, 0px) *
|
|
66
|
-
var(--TreeView-itemDepth, 0)
|
|
80
|
+
var(--TreeView-itemChildrenIndentation, 0px) * var(--TreeView-itemDepth, 0)
|
|
67
81
|
);
|
|
82
|
+
}
|
|
68
83
|
|
|
84
|
+
/* Neutralize MUI's own hover/focused/selected background-color changes (see TreeItemContent's
|
|
85
|
+
defaults in @mui/x-tree-view, e.g. its built-in `rgba(25, 118, 210, 0.08)` selected tint) so
|
|
86
|
+
only the Recursica hover overlay and selected styling on .label below control the look; .row
|
|
87
|
+
itself carries no background-color of its own to fall back to now. The `[data-selected]
|
|
88
|
+
[data-focused]` compound needs its own rule: MUI's own equivalent compound selector has higher
|
|
89
|
+
specificity (2 attributes + its class) than a single-attribute `.row[data-selected]` rule
|
|
90
|
+
would, so it would otherwise still win regardless of source order. */
|
|
91
|
+
.row:hover,
|
|
92
|
+
.row[data-focused],
|
|
93
|
+
.row[data-selected],
|
|
94
|
+
.row[data-selected][data-focused] {
|
|
95
|
+
background-color: transparent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.row[data-disabled] {
|
|
99
|
+
opacity: var(--recursica_brand_states_disabled);
|
|
100
|
+
cursor: auto;
|
|
101
|
+
/* MUI's own disabled checks already block select/expand on click, but hover is pure CSS —
|
|
102
|
+
without this, a disabled row still showed the hover tint (.label::after opacity) on mouse-
|
|
103
|
+
over, misleadingly suggesting it's interactive. Matches mantine-adapter's `.root[data-
|
|
104
|
+
disabled] { pointer-events: none; }`, just scoped per-row instead of tree-wide, since MUI's
|
|
105
|
+
disabled state is a per-item concept even when driven by our own whole-tree prop. */
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.iconContainer {
|
|
110
|
+
position: relative;
|
|
111
|
+
z-index: 1;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
flex-shrink: 0;
|
|
116
|
+
color: inherit;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* The expand Button is always rendered (even for leaf nodes, which have nothing to toggle) so
|
|
120
|
+
every row reserves identical layout space — hidden via visibility (not display) on leaf rows so
|
|
121
|
+
the label stays aligned across the whole tree without duplicating Button's own size tokens. */
|
|
122
|
+
.expandButton {
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
.expandButtonHidden {
|
|
126
|
+
visibility: hidden;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.chevron {
|
|
130
|
+
transition: transform 150ms ease;
|
|
131
|
+
}
|
|
132
|
+
.chevronExpanded {
|
|
133
|
+
transform: rotate(90deg);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.label {
|
|
137
|
+
position: relative;
|
|
138
|
+
z-index: 1;
|
|
139
|
+
/* Sized to its own content, not stretched to fill the row's remaining width — the
|
|
140
|
+
hover/selected/focus "chip" should cover just the label, not the full row width.
|
|
141
|
+
`width: auto` is required in addition to `flex: 0 1 auto`: MuiTreeItem's own default
|
|
142
|
+
`.MuiTreeItem-label` sets a literal `width: 100%`, which a flex-basis alone can't beat. */
|
|
143
|
+
flex: 0 1 auto;
|
|
144
|
+
width: auto;
|
|
145
|
+
box-sizing: border-box;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
padding: var(--recursica_ui-kit_components_tree_properties_vertical-padding)
|
|
149
|
+
var(--recursica_ui-kit_components_tree_properties_horizontal-padding);
|
|
69
150
|
border-style: solid; /* HARDCODE: structural border rule; thickness/color are tokened */
|
|
70
151
|
border-width: var(--recursica_ui-kit_components_tree_properties_border-size);
|
|
71
152
|
border-radius: var(
|
|
@@ -108,36 +189,33 @@
|
|
|
108
189
|
);
|
|
109
190
|
}
|
|
110
191
|
|
|
111
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/* Hover overlay via ::after pseudo-element (same technique as Menu/Accordion) */
|
|
121
|
-
.row::after {
|
|
192
|
+
/* Hover overlay via ::after pseudo-element (same technique as Menu/Accordion), scoped to the
|
|
193
|
+
label's own box so hover never visually covers the expand button. `.label`'s `position:
|
|
194
|
+
relative` + `z-index: 1` above already forms a stacking context, so `z-index: -1` here paints
|
|
195
|
+
the overlay behind the label's real text content within that context, not behind `.label`
|
|
196
|
+
itself. */
|
|
197
|
+
.label::after {
|
|
122
198
|
content: "";
|
|
123
199
|
position: absolute;
|
|
124
200
|
inset: 0;
|
|
125
201
|
border-radius: inherit;
|
|
126
|
-
z-index:
|
|
202
|
+
z-index: -1;
|
|
127
203
|
pointer-events: none;
|
|
128
204
|
background-color: var(--recursica_brand_states_hover_color);
|
|
129
205
|
opacity: 0;
|
|
130
206
|
transition: opacity 150ms ease;
|
|
131
207
|
}
|
|
132
|
-
.row:hover::after {
|
|
208
|
+
.row:hover .label::after {
|
|
133
209
|
opacity: var(--recursica_brand_states_hover_opacity);
|
|
134
210
|
}
|
|
135
211
|
|
|
136
|
-
/* Selected
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.
|
|
140
|
-
.row[data-selected]
|
|
212
|
+
/* Selected state — read off `.row`'s own `data-selected`/`data-focused` (set by
|
|
213
|
+
`getContentProps()`), applied to `.label`. Written after the hover/focus reset above so it
|
|
214
|
+
wins the equal-specificity tie via source order, including when hovered/focused while
|
|
215
|
+
selected. */
|
|
216
|
+
.row[data-selected] .label,
|
|
217
|
+
.row[data-selected]:hover .label,
|
|
218
|
+
.row[data-selected][data-focused] .label {
|
|
141
219
|
font-family: var(
|
|
142
220
|
--recursica_ui-kit_components_tree_variants_selection-states_selected_properties_text_font-family
|
|
143
221
|
);
|
|
@@ -173,36 +251,3 @@
|
|
|
173
251
|
--recursica_ui-kit_components_tree_variants_selection-states_selected_properties_colors_border-color
|
|
174
252
|
);
|
|
175
253
|
}
|
|
176
|
-
|
|
177
|
-
.row[data-disabled] {
|
|
178
|
-
opacity: var(--recursica_brand_states_disabled);
|
|
179
|
-
cursor: auto;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.iconContainer {
|
|
183
|
-
position: relative;
|
|
184
|
-
z-index: 1;
|
|
185
|
-
width: 1em;
|
|
186
|
-
display: flex;
|
|
187
|
-
align-items: center;
|
|
188
|
-
justify-content: center;
|
|
189
|
-
flex-shrink: 0;
|
|
190
|
-
color: inherit;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.chevron {
|
|
194
|
-
transition: transform 150ms ease;
|
|
195
|
-
}
|
|
196
|
-
.chevronExpanded {
|
|
197
|
-
transform: rotate(90deg);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.label {
|
|
201
|
-
position: relative;
|
|
202
|
-
z-index: 1;
|
|
203
|
-
color: inherit;
|
|
204
|
-
font: inherit;
|
|
205
|
-
letter-spacing: inherit;
|
|
206
|
-
text-decoration: inherit;
|
|
207
|
-
text-transform: inherit;
|
|
208
|
-
}
|
|
@@ -80,6 +80,19 @@ export const MultipleSelection: StoryObj<typeof Tree> = {
|
|
|
80
80
|
),
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
/** Whole tree disabled, with a node pre-selected so the selected chip's styling under the
|
|
84
|
+
* disabled dimming can be checked alongside unselected rows. */
|
|
85
|
+
export const Disabled: StoryObj<typeof Tree> = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<Tree
|
|
88
|
+
data={sampleData}
|
|
89
|
+
initialExpandedValues={["documents"]}
|
|
90
|
+
initialSelectedValues={["documents/resume.pdf"]}
|
|
91
|
+
disabled
|
|
92
|
+
/>
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
|
|
83
96
|
/** Demonstrates the component nested inside a non-default layer — the one case where an
|
|
84
97
|
* explicit `<Layer>` wrap belongs in a story (see COMPONENT_STORYBOOK_GUIDE.md §9). */
|
|
85
98
|
export const LayerOne: StoryObj<typeof Tree> = {
|