@recursica/mui-adapter 0.30.0 → 0.32.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/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mui-adapter"
15
15
  },
16
- "version": "0.30.0",
16
+ "version": "0.32.0",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -102,7 +102,7 @@
102
102
  "vitest": "^3.2.4"
103
103
  },
104
104
  "dependencies": {
105
- "@recursica/adapter-common": "^0.21.0",
105
+ "@recursica/adapter-common": "^0.23.0",
106
106
  "@recursica/official-release": "^2.8.0",
107
107
  "dayjs": "^1.11.21"
108
108
  },
@@ -27,3 +27,29 @@ This document contains specific design decisions, architectural constraints, and
27
27
  ## 4. Mark Label Color
28
28
 
29
29
  **Root cause:** MUI's `.sliderMarkLabel` already inherited the container text color using the min-max-label typography tokens. Mantine's equivalent class (`styles.sliderMarkLabel`) was referenced in `Slider.tsx`'s `classNames` map but was never defined in Mantine's `Slider.module.css`, so Mantine silently fell back to its own default theme grey instead of any recursica token. Fixed in `mantine-adapter` by adding the missing `.sliderMarkLabel` rule (same tokens/inherit-color approach as MUI) rather than copying Mantine's undefined behavior into MUI.
30
+
31
+ ## 5. Formatted Current Value, Label Overrides, Trailing Icon
32
+
33
+ **Symptom:** `.currentValue` always rendered the raw numeric value, even when `tooltipLabel` was a formatter function (already used for MUI's own `valueLabelFormat`) — a caller mapping values onto custom text got the formatted tooltip while dragging but the raw number next to the track otherwise. Same bug as `mantine-adapter`.
34
+
35
+ **Fix:** `.currentValue` now runs `resolvedValue` through `tooltipLabel` when it's a function, reusing the same formatter passed to `valueLabelFormat`. Added `minLabel`/`maxLabel` (new `adapter-common` props) to override the `.minMaxGuide` text at either end of the track, and `trailingIcon` (new `adapter-common` prop) to render a second icon opposite the existing `icon`, reusing the same `.iconWrapper` styling.
36
+
37
+ ## 6. Dual-Thumb / Range Support
38
+
39
+ **Decision:** Previously requested and declined (see git history for this section) as no use case needed it; reopened with a full spec and implemented. `value`/`defaultValue`/`onChange`/`onChangeEnd` are now typed `number | [number, number]` in `@recursica/adapter-common`'s `RecursicaSliderProps`.
40
+ **Implementation:**
41
+
42
+ - MUI's `Slider` already accepts `number[]` for `value`/`onChange` and renders two thumbs natively, so unlike `mantine-adapter` (which must swap to a separate `RangeSlider` component) this stays the same `<MuiSlider>` — arrays are no longer collapsed to `value[0]`.
43
+ - Internal state (`internalValue`, `resolvedValue`, `inputValue`) generalized from `number` to `number | [number, number]`; range-mode input handlers (`handleLowerInputChange`/`handleUpperInputChange`) clamp each thumb against the other's current value (not the shared `min`/`max`) so the two inputs can't cross.
44
+ - `SliderReadOnlyValue` and the floating `.currentValue` display both render `"lower – upper"` for a range value, running each side through `tooltipLabel` independently (`valueLabelFormat` already handled this per-thumb natively, unchanged).
45
+ - DOM order in range mode (input → leading icon → min label → track → max label → trailing icon → input) mirrors Forge's own Material/Carbon range layouts — see §7 below.
46
+
47
+ ## 7. Trailing Icon Order Relative to the Input Field
48
+
49
+ **Decision:** `trailingIcon` previously rendered after the numeric input (`... max label, input, trailing icon`), reversed from Forge's Material/Carbon kits, which always render the trailing icon directly after the max label and before the input.
50
+ **Implementation:** Moved `{trailingIconEl}` before the `showInput` input block in the single-value layout; the range layout was built with this order from the start (input → icon → min label → track → max label → trailing icon → input).
51
+
52
+ ## 8. Mark Vertically Off-Center
53
+
54
+ **Decision:** MUI's own mark is `top: 50%; transform: translate(-1px, -50%)` — the `-1px` assumes MUI's built-in 2px dot, the `-50%` is real vertical centering. `.sliderMark` overrode `transform` to `translateX(-50%)` (horizontal-only, meant to mirror the mantine-adapter) without noticing it dropped MUI's vertical `-50%`, leaving the dot hanging below the track's midpoint instead of centered.
55
+ **Implementation:** Changed `.sliderMark`'s transform to `translate(-50%, -50%)` — keeps MUI's vertical centering and swaps the horizontal term to properly center our (non-2px) `step-indicator-width` instead of MUI's hardcoded 1px.
@@ -2,8 +2,11 @@
2
2
  * HARDCODED VALUES:
3
3
  * - display: flex; align-items: center; width: 100%; (Standard CSS flexbox layouts for bidirectional components)
4
4
  * - flex-grow: 1; flex-shrink: 0; (Layout control structures)
5
- * - transform: translateX(-50%); (Step marks/mark labels positioning alignment offset, matching
6
- * Mantine's own offset mechanism)
5
+ * - transform: translate(-50%, -50%); on .sliderMark (preserves MUI's own vertical mark
6
+ * centering while re-deriving the horizontal offset for our step-indicator-width token — see
7
+ * .sliderMark below)
8
+ * - transform: translateX(-50%); on .sliderMarkLabel (horizontal-only; matches Mantine's offset
9
+ * mechanism)
7
10
  * - outline: none; border-style: solid; (Standard focus reset and border outlines)
8
11
  * - Focus ring on thumb/input: the token schema no longer provides per-component focus
9
12
  * colors for these (only `active` covers track/step-indicator-color). We apply the generic
@@ -164,7 +167,13 @@
164
167
  background-color: var(
165
168
  --recursica_ui-kit_components_slider_properties_colors_step-indicator-color
166
169
  );
167
- transform: translateX(-50%);
170
+ /* MUI's own mark is `top: 50%; transform: translate(-1px, -50%)` — the -1px assumes its
171
+ built-in 2px-wide dot and the -50% is real vertical centering, not decoration. Overriding
172
+ width/height above without preserving that vertical -50% would leave the dot vertically
173
+ un-centered (it would hang below the track's midpoint); translateX(-50%) alone stomped it.
174
+ translate(-50%, -50%) keeps the vertical centering and swaps the horizontal offset to match
175
+ our (non-2px) step-indicator-width. */
176
+ transform: translate(-50%, -50%);
168
177
  border: none;
169
178
  }
170
179
 
@@ -146,6 +146,115 @@ export const WithMarks: Story = {
146
146
  },
147
147
  };
148
148
 
149
+ export const WithIconsAndLabels: Story = {
150
+ args: {
151
+ label: "Volume",
152
+ assistiveText:
153
+ "Icons flank the track; min/max labels replace the raw bounds.",
154
+ defaultValue: 60,
155
+ minLabel: "Quiet",
156
+ maxLabel: "Loud",
157
+ tooltipLabel: (value: number) => `${value}%`,
158
+ icon: (
159
+ <svg
160
+ xmlns="http://www.w3.org/2000/svg"
161
+ width="16"
162
+ height="16"
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ strokeWidth="2"
167
+ strokeLinecap="round"
168
+ strokeLinejoin="round"
169
+ >
170
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
171
+ </svg>
172
+ ),
173
+ trailingIcon: (
174
+ <svg
175
+ xmlns="http://www.w3.org/2000/svg"
176
+ width="16"
177
+ height="16"
178
+ viewBox="0 0 24 24"
179
+ fill="none"
180
+ stroke="currentColor"
181
+ strokeWidth="2"
182
+ strokeLinecap="round"
183
+ strokeLinejoin="round"
184
+ >
185
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
186
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
187
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
188
+ </svg>
189
+ ),
190
+ },
191
+ };
192
+
193
+ export const RangeMode: Story = {
194
+ args: {
195
+ label: "Price Range",
196
+ assistiveText: "Pass a [number, number] tuple to render two thumbs.",
197
+ defaultValue: [20, 80],
198
+ min: 0,
199
+ max: 100,
200
+ showMinMaxLabels: true,
201
+ },
202
+ };
203
+
204
+ export const RangeModeWithInputs: Story = {
205
+ args: {
206
+ ...RangeMode.args,
207
+ showInput: true,
208
+ },
209
+ };
210
+
211
+ export const RangeModeWithIconsAndInputs: Story = {
212
+ args: {
213
+ label: "Price Range",
214
+ assistiveText:
215
+ "Full range usage: leading/trailing icons, min/max label overrides, and both bound inputs.",
216
+ defaultValue: [20, 80],
217
+ min: 0,
218
+ max: 100,
219
+ showInput: true,
220
+ minLabel: "$0",
221
+ maxLabel: "$100",
222
+ tooltipLabel: (value: number) => `$${value}`,
223
+ icon: (
224
+ <svg
225
+ xmlns="http://www.w3.org/2000/svg"
226
+ width="16"
227
+ height="16"
228
+ viewBox="0 0 24 24"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ strokeWidth="2"
232
+ strokeLinecap="round"
233
+ strokeLinejoin="round"
234
+ >
235
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
236
+ </svg>
237
+ ),
238
+ trailingIcon: (
239
+ <svg
240
+ xmlns="http://www.w3.org/2000/svg"
241
+ width="16"
242
+ height="16"
243
+ viewBox="0 0 24 24"
244
+ fill="none"
245
+ stroke="currentColor"
246
+ strokeWidth="2"
247
+ strokeLinecap="round"
248
+ strokeLinejoin="round"
249
+ >
250
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
251
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
252
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
253
+ </svg>
254
+ ),
255
+ },
256
+ };
257
+
149
258
  export const FormLayouts: Story = {
150
259
  render: () => (
151
260
  <div
@@ -20,7 +20,13 @@ import { type RecursicaSliderProps as BaseRecursicaSliderProps } from "@recursic
20
20
  export interface RecursicaSliderProps
21
21
  extends Omit<
22
22
  MuiSliderProps,
23
- "size" | "color" | "classes" | "onChange" | "onChangeCommitted"
23
+ | "size"
24
+ | "color"
25
+ | "classes"
26
+ | "onChange"
27
+ | "onChangeCommitted"
28
+ | "value"
29
+ | "defaultValue"
24
30
  >,
25
31
  Omit<
26
32
  RecursicaFormControlWrapperProps,
@@ -38,17 +44,26 @@ export type SliderProps = RecursicaOverStyled<RecursicaSliderProps>;
38
44
 
39
45
  /**
40
46
  * Custom Read-Only visual representation of the Slider value.
41
- * Utilizes component-specific read-only typography variables.
47
+ * Utilizes component-specific read-only typography variables. Renders a "lower – upper" pair
48
+ * when the value is a range tuple.
42
49
  */
43
- const SliderReadOnlyValue: React.FC<{ value: number }> = ({ value }) => {
44
- return <div className={styles.readOnlyValue}>{value}</div>;
50
+ const SliderReadOnlyValue: React.FC<{ value: number | [number, number] }> = ({
51
+ value,
52
+ }) => {
53
+ const display = Array.isArray(value) ? `${value[0]} – ${value[1]}` : value;
54
+ return <div className={styles.readOnlyValue}>{display}</div>;
45
55
  };
46
56
 
47
57
  /**
48
58
  * Recursica Slider component wrapping Mui's Slider.
49
59
  *
60
+ * MUI's own `Slider` already renders two thumbs natively when given a tuple `value`, so range
61
+ * mode here is a typing/handler concern rather than a different underlying component (contrast
62
+ * with the mantine-adapter, which swaps in Mantine's separate `RangeSlider`).
63
+ *
50
64
  * Implements a bidirectional text input field next to the slider track, responsive layouts,
51
- * custom typography-bound min/max labels, an optional leading icon, and an explicit read-only layout.
65
+ * custom typography-bound min/max labels (optionally overridden via `minLabel`/`maxLabel`),
66
+ * optional leading/trailing icons, and an explicit read-only layout.
52
67
  */
53
68
  export const Slider = forwardRef<HTMLDivElement, SliderProps>(
54
69
  function Slider(props, ref) {
@@ -82,45 +97,58 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
82
97
  value,
83
98
  defaultValue,
84
99
  icon,
100
+ trailingIcon,
85
101
  showInput = false,
86
102
  showMinMaxLabels = true,
87
103
  min = 0,
88
104
  max = 100,
105
+ minLabel,
106
+ maxLabel,
89
107
  step = 1,
90
108
  onChange,
91
109
  onChangeEnd,
92
110
  ...rest
93
111
  } = props;
94
112
 
95
- // Bidirectional state linking the slider track value to the input field string representation
96
- const [internalValue, setInternalValue] = useState<number>(() => {
97
- if (value !== undefined) return Array.isArray(value) ? value[0] : value;
98
- if (defaultValue !== undefined)
99
- return Array.isArray(defaultValue) ? defaultValue[0] : defaultValue;
113
+ // Bidirectional state linking the slider track value to the input field string
114
+ // representation. A `[number, number]` value/defaultValue switches the component into
115
+ // two-thumb range mode MUI's own Slider already renders two thumbs for a tuple value.
116
+ type SliderValue = number | [number, number];
117
+
118
+ const [internalValue, setInternalValue] = useState<SliderValue>(() => {
119
+ if (value !== undefined) return value;
120
+ if (defaultValue !== undefined) return defaultValue;
100
121
  return min;
101
122
  });
102
123
 
103
- const resolvedValue =
104
- value !== undefined
105
- ? Array.isArray(value)
106
- ? value[0]
107
- : value
108
- : internalValue;
109
- const [inputValue, setInputValue] = useState<string>(
110
- resolvedValue.toString(),
124
+ const resolvedValue: SliderValue =
125
+ value !== undefined ? value : internalValue;
126
+ const isRange = Array.isArray(resolvedValue);
127
+
128
+ const [inputValue, setInputValue] = useState<string | [string, string]>(
129
+ () =>
130
+ Array.isArray(resolvedValue)
131
+ ? [resolvedValue[0].toString(), resolvedValue[1].toString()]
132
+ : resolvedValue.toString(),
111
133
  );
112
134
 
113
- // Synchronize text input whenever the slider value changes
135
+ // Synchronize text input(s) whenever the slider value changes
114
136
  useEffect(() => {
115
- setInputValue(resolvedValue.toString());
137
+ setInputValue(
138
+ Array.isArray(resolvedValue)
139
+ ? [resolvedValue[0].toString(), resolvedValue[1].toString()]
140
+ : resolvedValue.toString(),
141
+ );
116
142
  }, [resolvedValue]);
117
143
 
118
144
  const handleValueChange = (_e: Event, val: number | number[]) => {
119
- const singleVal = Array.isArray(val) ? val[0] : val;
145
+ const normalized: SliderValue = Array.isArray(val)
146
+ ? [val[0], val[1]]
147
+ : val;
120
148
  if (value === undefined) {
121
- setInternalValue(singleVal);
149
+ setInternalValue(normalized);
122
150
  }
123
- onChange?.(singleVal);
151
+ onChange?.(normalized);
124
152
  };
125
153
 
126
154
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -136,7 +164,38 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
136
164
  };
137
165
 
138
166
  const handleInputBlur = () => {
139
- setInputValue(resolvedValue.toString());
167
+ setInputValue((resolvedValue as number).toString());
168
+ };
169
+
170
+ // Range-mode input handlers: each bound clamps against the other thumb rather than the
171
+ // shared min/max, so the lower thumb can never cross the upper one and vice versa.
172
+ const handleLowerInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
173
+ const current = resolvedValue as [number, number];
174
+ const valStr = e.target.value;
175
+ setInputValue([valStr, current[1].toString()]);
176
+
177
+ const parsed = parseFloat(valStr);
178
+ if (!isNaN(parsed)) {
179
+ const clamped = Math.max(min, Math.min(current[1], parsed));
180
+ handleValueChange(null as unknown as Event, [clamped, current[1]]);
181
+ }
182
+ };
183
+
184
+ const handleUpperInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
185
+ const current = resolvedValue as [number, number];
186
+ const valStr = e.target.value;
187
+ setInputValue([current[0].toString(), valStr]);
188
+
189
+ const parsed = parseFloat(valStr);
190
+ if (!isNaN(parsed)) {
191
+ const clamped = Math.max(current[0], Math.min(max, parsed));
192
+ handleValueChange(null as unknown as Event, [current[0], clamped]);
193
+ }
194
+ };
195
+
196
+ const handleRangeInputBlur = () => {
197
+ const current = resolvedValue as [number, number];
198
+ setInputValue([current[0].toString(), current[1].toString()]);
140
199
  };
141
200
 
142
201
  // Props this component intentionally doesn't support — deleted at runtime so they can't leak
@@ -220,6 +279,23 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
220
279
  </span>
221
280
  ) : null;
222
281
 
282
+ const trailingIconEl = trailingIcon ? (
283
+ <span className={styles.iconWrapper} aria-hidden>
284
+ {trailingIcon}
285
+ </span>
286
+ ) : null;
287
+
288
+ // Duplicates the raw numeric value next to the track by default; when `tooltipLabel` is a
289
+ // formatter, reuse it here too so both displays agree instead of one showing raw numbers.
290
+ // Range mode formats each thumb independently and joins them with an en dash.
291
+ const displayValue = Array.isArray(resolvedValue)
292
+ ? typeof tooltipLabel === "function"
293
+ ? `${tooltipLabel(resolvedValue[0])} – ${tooltipLabel(resolvedValue[1])}`
294
+ : `${resolvedValue[0]} – ${resolvedValue[1]}`
295
+ : typeof tooltipLabel === "function"
296
+ ? tooltipLabel(resolvedValue)
297
+ : resolvedValue;
298
+
223
299
  return (
224
300
  <WithReadOnlyWrapper
225
301
  ref={ref}
@@ -255,10 +331,26 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
255
331
  data-error={error ? "true" : undefined}
256
332
  data-suppress-focus-ring={suppressFocusRing ? "true" : undefined}
257
333
  >
334
+ {isRange && showInput && (
335
+ <input
336
+ type="number"
337
+ className={styles.inputField}
338
+ value={(inputValue as [string, string])[0]}
339
+ onChange={handleLowerInputChange}
340
+ onBlur={handleRangeInputBlur}
341
+ min={min}
342
+ max={(resolvedValue as [number, number])[1]}
343
+ step={step ?? undefined}
344
+ disabled={disabled}
345
+ data-error={error ? "true" : undefined}
346
+ aria-label="Minimum value"
347
+ />
348
+ )}
349
+
258
350
  {leadingIcon}
259
351
 
260
352
  {showMinMaxLabels && (
261
- <span className={styles.minMaxGuide}>{min}</span>
353
+ <span className={styles.minMaxGuide}>{minLabel ?? min}</span>
262
354
  )}
263
355
 
264
356
  <div className={styles.sliderTrackWrapper}>
@@ -295,27 +387,44 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
295
387
 
296
388
  <div className={styles.rightGuideContainer}>
297
389
  {!showInput && (
298
- <span className={styles.currentValue}>{resolvedValue}</span>
390
+ <span className={styles.currentValue}>{displayValue}</span>
299
391
  )}
300
392
  {showMinMaxLabels && (
301
- <span className={styles.minMaxGuide}>{max}</span>
393
+ <span className={styles.minMaxGuide}>{maxLabel ?? max}</span>
302
394
  )}
303
395
  </div>
304
396
 
305
- {showInput && (
306
- <input
307
- type="number"
308
- className={styles.inputField}
309
- value={inputValue}
310
- onChange={handleInputChange}
311
- onBlur={handleInputBlur}
312
- min={min}
313
- max={max}
314
- step={step ?? undefined}
315
- disabled={disabled}
316
- data-error={error ? "true" : undefined}
317
- />
318
- )}
397
+ {trailingIconEl}
398
+
399
+ {showInput &&
400
+ (isRange ? (
401
+ <input
402
+ type="number"
403
+ className={styles.inputField}
404
+ value={(inputValue as [string, string])[1]}
405
+ onChange={handleUpperInputChange}
406
+ onBlur={handleRangeInputBlur}
407
+ min={(resolvedValue as [number, number])[0]}
408
+ max={max}
409
+ step={step ?? undefined}
410
+ disabled={disabled}
411
+ data-error={error ? "true" : undefined}
412
+ aria-label="Maximum value"
413
+ />
414
+ ) : (
415
+ <input
416
+ type="number"
417
+ className={styles.inputField}
418
+ value={inputValue as string}
419
+ onChange={handleInputChange}
420
+ onBlur={handleInputBlur}
421
+ min={min}
422
+ max={max}
423
+ step={step ?? undefined}
424
+ disabled={disabled}
425
+ data-error={error ? "true" : undefined}
426
+ />
427
+ ))}
319
428
  </div>
320
429
  }
321
430
  />
@@ -34,3 +34,23 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
34
34
  > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
35
35
  > - **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
36
  > - **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.
37
+
38
+ ---
39
+
40
+ ## 4. Key Integration Features & Constraints
41
+
42
+ The `label` prop is passed through to the surrounding form label rather than MUI's dragging tooltip; use `tooltipLabel` to set the label shown while dragging. When `showInput` is enabled, a numeric text input is rendered alongside the track and stays in sync with the slider's value. Set `showMinMaxLabels` to `false` to hide the min/max guides shown at either end of the track. Otherwise, the current value is displayed near the track instead — pass `tooltipLabel` as a formatter function (`(value) => ReactNode`) and that same formatter is reused for this display, instead of always showing the raw number. `minLabel`/`maxLabel` override the text shown at either end of the track (defaults to the numeric `min`/`max`). `icon` renders a leading icon next to the track; `trailingIcon` renders one on the opposite side, rendered right after the max label and before the numeric input.
43
+
44
+ ### Range Mode
45
+
46
+ Pass a `[number, number]` tuple as `value`/`defaultValue` to render a two-thumb range slider — `onChange`/`onChangeEnd` are then called with a `[number, number]` tuple instead of a `number`. With `showInput` enabled, a second numeric input for the upper bound appears after the trailing icon, with the lower-bound input before the leading icon:
47
+
48
+ ```tsx
49
+ <Slider
50
+ label="Price Range"
51
+ defaultValue={[20, 80]}
52
+ min={0}
53
+ max={100}
54
+ onChange={(value) => console.log(value)} // [number, number]
55
+ />
56
+ ```
@@ -43,6 +43,9 @@
43
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
44
 
45
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
+
46
47
  - **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
48
  - **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
49
  - **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).
50
+
51
+ - **`ExpandToggleButton` could still steal DOM focus despite `tabIndex={-1}` (Matt Massey, 2026-08-24), caught via a live browser console warning, in both adapters.** `tabIndex={-1}`/`aria-hidden` only removes the embedded chevron `Button` from the sequential tab order; it doesn't stop a native `<button>` from receiving DOM focus on a direct mouse click on the chevron, which browsers still do regardless of `tabIndex`. Since the button also has `aria-hidden="true"`, clicking it left an aria-hidden element holding focus — flagged by Chrome as "Blocked aria-hidden on an element because its descendant retained focus." Fixed with `onMouseDown={(event) => event.preventDefault()}` on the `Button` inside `ExpandToggleButton`: this suppresses the browser's default click-focuses-the-target behavior without affecting expand/collapse (still driven entirely by `TreeItemIconContainer`'s own click handling, untouched by this). Same fix applied to mantine-adapter's embedded chevron `Button`.
@@ -71,7 +71,12 @@ function ChevronGlyph({ rotated }: { rotated?: boolean }) {
71
71
  * not by this Button itself, so the row stays the only focusable element and the button never
72
72
  * shows its own focus state. Rendered for every row, including leaf items, so row alignment
73
73
  * stays consistent; `hidden` (visibility, not display) is used for leaf rows instead of omitting
74
- * the button, so the layout space is reserved without duplicating Button's own size tokens. */
74
+ * the button, so the layout space is reserved without duplicating Button's own size tokens.
75
+ * `onMouseDown` prevents the browser's default click-focuses-the-button behavior (tabIndex={-1}
76
+ * only removes it from the tab order, it doesn't stop a direct mouse click on the chevron from
77
+ * focusing this button) — without this, clicking the chevron leaves the (aria-hidden) button
78
+ * focused, which browsers now flag as an accessibility violation ("focus must not be hidden from
79
+ * assistive technology"). */
75
80
  function ExpandToggleButton({
76
81
  rotated,
77
82
  hidden,
@@ -88,6 +93,7 @@ function ExpandToggleButton({
88
93
  aria-label="Toggle subtree"
89
94
  aria-hidden="true"
90
95
  tabIndex={-1}
96
+ onMouseDown={(event) => event.preventDefault()}
91
97
  className={
92
98
  hidden
93
99
  ? `${styles.expandButton} ${styles.expandButtonHidden}`