@recursica/mantine-adapter 0.47.0 → 0.48.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +9 -4
  3. package/dist/mantine-adapter.cjs +2 -2
  4. package/dist/mantine-adapter.cjs.map +1 -1
  5. package/dist/mantine-adapter.css +1 -1
  6. package/dist/mantine-adapter.js +1633 -1559
  7. package/dist/mantine-adapter.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/components/Accordion/Accordion.module.css +11 -0
  10. package/src/components/AutoComplete/AutoComplete.module.css +10 -0
  11. package/src/components/Button/Button.module.css +10 -0
  12. package/src/components/Card/Card.stories.tsx +2 -4
  13. package/src/components/Chip/Chip.module.css +9 -0
  14. package/src/components/DatePicker/DatePicker.module.css +16 -0
  15. package/src/components/Dropdown/Dropdown.module.css +10 -0
  16. package/src/components/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +4 -1
  17. package/src/components/FileInput/FileInput.module.css +17 -0
  18. package/src/components/FileUpload/FileUpload.module.css +6 -0
  19. package/src/components/Link/Link.module.css +5 -0
  20. package/src/components/Menu/Menu.module.css +6 -0
  21. package/src/components/NumberInput/NumberInput.module.css +8 -0
  22. package/src/components/Pagination/Pagination.module.css +7 -0
  23. package/src/components/Slider/IMPLEMENTATION_NOTES.md +22 -2
  24. package/src/components/Slider/Slider.module.css +36 -2
  25. package/src/components/Slider/Slider.stories.tsx +65 -0
  26. package/src/components/Slider/Slider.tsx +149 -42
  27. package/src/components/Slider/USAGE.md +15 -1
  28. package/src/components/Switch/Switch.module.css +8 -0
  29. package/src/components/Table/Table.module.css +6 -0
  30. package/src/components/Tabs/Tabs.module.css +7 -0
  31. package/src/components/TextArea/TextArea.module.css +8 -0
  32. package/src/components/TextField/TextField.module.css +8 -0
  33. package/src/components/TimePicker/TimePicker.module.css +8 -0
  34. package/src/components/Tree/IMPLEMENTATION_NOTES.md +3 -0
  35. package/src/components/Tree/Tree.module.css +11 -0
  36. package/src/components/Tree/Tree.tsx +11 -6
@@ -1,7 +1,9 @@
1
1
  import React, { forwardRef, useState, useEffect } from "react";
2
2
  import {
3
3
  Slider as MantineSlider,
4
+ RangeSlider as MantineRangeSlider,
4
5
  type SliderProps as MantineSliderProps,
6
+ type RangeSliderProps as MantineRangeSliderProps,
5
7
  type InputWrapperProps,
6
8
  } from "@mantine/core";
7
9
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
@@ -28,6 +30,10 @@ export interface RecursicaSliderProps
28
30
  | "classNames"
29
31
  | "styles"
30
32
  | "label"
33
+ | "value"
34
+ | "defaultValue"
35
+ | "onChange"
36
+ | "onChangeEnd"
31
37
  >,
32
38
  Omit<
33
39
  RecursicaFormControlWrapperProps,
@@ -41,14 +47,19 @@ export type SliderProps = RecursicaOverStyled<RecursicaSliderProps>;
41
47
 
42
48
  /**
43
49
  * Custom Read-Only visual representation of the Slider value.
44
- * Utilizes component-specific read-only typography variables.
50
+ * Utilizes component-specific read-only typography variables. Renders a "lower – upper" pair
51
+ * when the value is a range tuple.
45
52
  */
46
- const SliderReadOnlyValue: React.FC<{ value: number }> = ({ value }) => {
47
- return <div className={styles.readOnlyValue}>{value}</div>;
53
+ const SliderReadOnlyValue: React.FC<{ value: number | [number, number] }> = ({
54
+ value,
55
+ }) => {
56
+ const display = Array.isArray(value) ? `${value[0]} – ${value[1]}` : value;
57
+ return <div className={styles.readOnlyValue}>{display}</div>;
48
58
  };
49
59
 
50
60
  /**
51
- * Recursica Slider component wrapping Mantine's Slider.
61
+ * Recursica Slider component wrapping Mantine's Slider (or, when `value`/`defaultValue` is a
62
+ * `[number, number]` tuple, Mantine's two-thumb RangeSlider).
52
63
  *
53
64
  * Implements a bidirectional text input field next to the slider track, responsive layouts,
54
65
  * custom typography-bound min/max labels (optionally overridden via `minLabel`/`maxLabel`),
@@ -98,24 +109,38 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
98
109
  ...rest
99
110
  } = props;
100
111
 
101
- // Bidirectional state linking the slider track value to the input field string representation
102
- const [internalValue, setInternalValue] = useState<number>(() => {
112
+ // Bidirectional state linking the slider track value to the input field string
113
+ // representation. A `[number, number]` value/defaultValue switches the component into
114
+ // two-thumb range mode, backed by Mantine's RangeSlider instead of Slider.
115
+ type SliderValue = number | [number, number];
116
+
117
+ const [internalValue, setInternalValue] = useState<SliderValue>(() => {
103
118
  if (value !== undefined) return value;
104
119
  if (defaultValue !== undefined) return defaultValue;
105
120
  return min;
106
121
  });
107
122
 
108
- const resolvedValue = value !== undefined ? value : internalValue;
109
- const [inputValue, setInputValue] = useState<string>(
110
- resolvedValue.toString(),
123
+ const resolvedValue: SliderValue =
124
+ value !== undefined ? value : internalValue;
125
+ const isRange = Array.isArray(resolvedValue);
126
+
127
+ const [inputValue, setInputValue] = useState<string | [string, string]>(
128
+ () =>
129
+ Array.isArray(resolvedValue)
130
+ ? [resolvedValue[0].toString(), resolvedValue[1].toString()]
131
+ : resolvedValue.toString(),
111
132
  );
112
133
 
113
- // Synchronize text input whenever the slider value changes
134
+ // Synchronize text input(s) whenever the slider value changes
114
135
  useEffect(() => {
115
- setInputValue(resolvedValue.toString());
136
+ setInputValue(
137
+ Array.isArray(resolvedValue)
138
+ ? [resolvedValue[0].toString(), resolvedValue[1].toString()]
139
+ : resolvedValue.toString(),
140
+ );
116
141
  }, [resolvedValue]);
117
142
 
118
- const handleValueChange = (val: number) => {
143
+ const handleValueChange = (val: SliderValue) => {
119
144
  if (value === undefined) {
120
145
  setInternalValue(val);
121
146
  }
@@ -136,7 +161,38 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
136
161
 
137
162
  const handleInputBlur = () => {
138
163
  // Clean up text field on blur to reflect the final clamped resolved value
139
- setInputValue(resolvedValue.toString());
164
+ setInputValue((resolvedValue as number).toString());
165
+ };
166
+
167
+ // Range-mode input handlers: each bound clamps against the other thumb rather than the
168
+ // shared min/max, so the lower thumb can never cross the upper one and vice versa.
169
+ const handleLowerInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
170
+ const current = resolvedValue as [number, number];
171
+ const valStr = e.target.value;
172
+ setInputValue([valStr, current[1].toString()]);
173
+
174
+ const parsed = parseFloat(valStr);
175
+ if (!isNaN(parsed)) {
176
+ const clamped = Math.max(min, Math.min(current[1], parsed));
177
+ handleValueChange([clamped, current[1]]);
178
+ }
179
+ };
180
+
181
+ const handleUpperInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
182
+ const current = resolvedValue as [number, number];
183
+ const valStr = e.target.value;
184
+ setInputValue([current[0].toString(), valStr]);
185
+
186
+ const parsed = parseFloat(valStr);
187
+ if (!isNaN(parsed)) {
188
+ const clamped = Math.max(current[0], Math.min(max, parsed));
189
+ handleValueChange([current[0], clamped]);
190
+ }
191
+ };
192
+
193
+ const handleRangeInputBlur = () => {
194
+ const current = resolvedValue as [number, number];
195
+ setInputValue([current[0].toString(), current[1].toString()]);
140
196
  };
141
197
 
142
198
  // Props this component intentionally doesn't support — deleted at runtime so they can't leak
@@ -164,6 +220,7 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
164
220
  track: styles.sliderTrack,
165
221
  bar: styles.sliderBar,
166
222
  thumb: styles.sliderThumb,
223
+ markWrapper: styles.sliderMarkWrapper,
167
224
  mark: styles.sliderMark,
168
225
  markLabel: styles.sliderMarkLabel,
169
226
  },
@@ -188,8 +245,12 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
188
245
 
189
246
  // Duplicates the raw numeric value next to the track by default; when `tooltipLabel` is a
190
247
  // formatter, reuse it here too so both displays agree instead of one showing raw numbers.
191
- const displayValue =
192
- typeof tooltipLabel === "function"
248
+ // Range mode formats each thumb independently and joins them with an en dash.
249
+ const displayValue = Array.isArray(resolvedValue)
250
+ ? typeof tooltipLabel === "function"
251
+ ? `${tooltipLabel(resolvedValue[0])} – ${tooltipLabel(resolvedValue[1])}`
252
+ : `${resolvedValue[0]} – ${resolvedValue[1]}`
253
+ : typeof tooltipLabel === "function"
193
254
  ? tooltipLabel(resolvedValue)
194
255
  : resolvedValue;
195
256
 
@@ -228,6 +289,22 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
228
289
  data-disabled={disabled ? "true" : undefined}
229
290
  data-error={error ? "true" : undefined}
230
291
  >
292
+ {isRange && showInput && (
293
+ <input
294
+ type="number"
295
+ className={styles.inputField}
296
+ value={(inputValue as [string, string])[0]}
297
+ onChange={handleLowerInputChange}
298
+ onBlur={handleRangeInputBlur}
299
+ min={min}
300
+ max={(resolvedValue as [number, number])[1]}
301
+ step={step}
302
+ disabled={disabled}
303
+ data-error={error ? "true" : undefined}
304
+ aria-label="Minimum value"
305
+ />
306
+ )}
307
+
231
308
  {leadingIcon}
232
309
 
233
310
  {showMinMaxLabels && (
@@ -235,18 +312,33 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
235
312
  )}
236
313
 
237
314
  <div className={styles.sliderTrackWrapper}>
238
- <MantineSlider
239
- {...(sanitizedProps as unknown as MantineSliderProps)}
240
- classNames={mergedClassNames}
241
- disabled={disabled}
242
- value={resolvedValue}
243
- onChange={handleValueChange}
244
- onChangeEnd={onChangeEnd}
245
- min={min}
246
- max={max}
247
- step={step}
248
- label={tooltipLabel}
249
- />
315
+ {isRange ? (
316
+ <MantineRangeSlider
317
+ {...(sanitizedProps as unknown as MantineRangeSliderProps)}
318
+ classNames={mergedClassNames}
319
+ disabled={disabled}
320
+ value={resolvedValue as [number, number]}
321
+ onChange={handleValueChange}
322
+ onChangeEnd={onChangeEnd}
323
+ min={min}
324
+ max={max}
325
+ step={step}
326
+ label={tooltipLabel}
327
+ />
328
+ ) : (
329
+ <MantineSlider
330
+ {...(sanitizedProps as unknown as MantineSliderProps)}
331
+ classNames={mergedClassNames}
332
+ disabled={disabled}
333
+ value={resolvedValue as number}
334
+ onChange={handleValueChange}
335
+ onChangeEnd={onChangeEnd}
336
+ min={min}
337
+ max={max}
338
+ step={step}
339
+ label={tooltipLabel}
340
+ />
341
+ )}
250
342
  </div>
251
343
 
252
344
  <div className={styles.rightGuideContainer}>
@@ -258,22 +350,37 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
258
350
  )}
259
351
  </div>
260
352
 
261
- {showInput && (
262
- <input
263
- type="number"
264
- className={styles.inputField}
265
- value={inputValue}
266
- onChange={handleInputChange}
267
- onBlur={handleInputBlur}
268
- min={min}
269
- max={max}
270
- step={step}
271
- disabled={disabled}
272
- data-error={error ? "true" : undefined}
273
- />
274
- )}
275
-
276
353
  {trailingIconEl}
354
+
355
+ {showInput &&
356
+ (isRange ? (
357
+ <input
358
+ type="number"
359
+ className={styles.inputField}
360
+ value={(inputValue as [string, string])[1]}
361
+ onChange={handleUpperInputChange}
362
+ onBlur={handleRangeInputBlur}
363
+ min={(resolvedValue as [number, number])[0]}
364
+ max={max}
365
+ step={step}
366
+ disabled={disabled}
367
+ data-error={error ? "true" : undefined}
368
+ aria-label="Maximum value"
369
+ />
370
+ ) : (
371
+ <input
372
+ type="number"
373
+ className={styles.inputField}
374
+ value={inputValue as string}
375
+ onChange={handleInputChange}
376
+ onBlur={handleInputBlur}
377
+ min={min}
378
+ max={max}
379
+ step={step}
380
+ disabled={disabled}
381
+ data-error={error ? "true" : undefined}
382
+ />
383
+ ))}
277
384
  </div>
278
385
  }
279
386
  />
@@ -39,4 +39,18 @@ All Recursica components in the `@recursica/mantine-adapter` package adhere stri
39
39
 
40
40
  ## 4. Key Integration Features & Constraints
41
41
 
42
- The `label` prop is passed through to the surrounding form label rather than Mantine'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.
42
+ The `label` prop is passed through to the surrounding form label rather than Mantine'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`. This is a real component swap under the hood (Mantine's `RangeSlider`, not `Slider`), so `value` and `defaultValue` must agree on shape for a given instance. 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
+ ```
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* HARDCODED VALUES
2
10
  *
3
11
  * 1. track border: none; (we do not use border for the switch track)
@@ -1,3 +1,9 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_hover_color
4
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
5
+ */
6
+
1
7
  /* HARDCODED VALUES:
2
8
  * - border-collapse: separate; border-spacing: 0; (To support custom border-radius on table elements)
3
9
  * - overflow: hidden; (To prevent content from bleeding outside the rounded table border)
@@ -1,3 +1,10 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_hover_color
4
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
5
+ * recursica-allow-brand: --recursica_brand_states_disabled
6
+ */
7
+
1
8
  /*
2
9
  Recursica Tabs CSS module natively binding to Figma variables.
3
10
  */
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* LAYOUT SPACING OVERRIDES:
2
10
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
11
  - Also sets the --textarea-control-{max,min}-width hooks consumed inline in TextArea.tsx, since
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* LAYOUT SPACING OVERRIDES:
2
10
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
11
  - Also sets the --text-field-control-{max,min}-width hooks consumed inline in TextField.tsx,
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* LAYOUT SPACING OVERRIDES:
2
10
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens. */
3
11
  .layoutOverride {
@@ -40,6 +40,9 @@
40
40
  - No MUI-style leftover "default selected background" issue existed here — Mantine's own `Tree` has no default row styling of its own once a custom `renderNode` is supplied (see the very first entry in this file), so there was nothing to neutralize on this side.
41
41
 
42
42
  - **Whole-tree `disabled` (Matt Massey, 2026-08-10), added to support a `Disabled` story.** Mantine's `Tree`/`useTree`/`TreeNode` have no `disabled` concept anywhere in their API, unlike `@mui/x-tree-view` (which already had per-item `disabled` plumbing sitting mostly unused — see mui-adapter's own note on this). Per-node disabling still isn't exposed (no token, no `disabled` field on `RecursicaTreeNode` — same reasoning as the existing "Deliberately not implemented" entry above), only a single whole-tree toggle.
43
+
43
44
  - **Mouse**: `selectOnClick={!disabled}` reuses Mantine's own flag for row clicks. The chevron `Button`'s `onClick` bypasses that flag entirely (calls `tree.toggleExpanded` directly), so it needs its own explicit `if (disabled) return;` guard. `.root[data-disabled] { pointer-events: none; }` is a second, CSS-only backstop covering both at once — belt-and-suspenders, not strictly required given the two guards above, but consistent with how little the library gives us to rely on here.
44
45
  - **Keyboard**: our own `Enter`/`Space` → `select` listener (added in the entry above) just checks `disabled` at the top now. `ArrowLeft`/`ArrowRight` expand/collapse is baked into Mantine's own `TreeNode.handleKeyDown`, on the `<li>` itself, with no prop to disable it — the only reachable way to block it is a _second_, capture-phase `keydown` listener on the tree root that unconditionally calls `stopPropagation()` when disabled. Since capture fires before the event ever reaches its target (the focused `<li>`), this keeps Mantine's internal handler — and our own bubble-phase listener on the same root — from ever running, without needing to fork `TreeNode`.
45
46
  - **Visual**: `opacity: var(--recursica_brand_states_disabled)` on `.root` — the generic disabled token, same convention used everywhere else in the design system for components without a dedicated disabled token (no `tree`-specific one exists). A selected node's chip stays visible underneath, just dimmed along with everything else, per Matt's ask to verify that combination looks right.
47
+
48
+ - **Chevron `Button` could still steal DOM focus despite `tabIndex={-1}` (Matt Massey, 2026-08-24), caught via a live browser console warning.** The embedded chevron `Button`'s `tabIndex={-1}`/`aria-hidden` combination (added when the chevron became a real `Button` — see above) only removes it from the sequential tab order; it doesn't stop a native `<button>` from receiving DOM focus on a direct mouse click, which browsers still do regardless of `tabIndex`. Since the button also has `aria-hidden="true"`, a click on 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 same `Button`: this suppresses the browser's default click-focuses-the-target behavior without affecting the `onClick` handler (`click` still fires normally on mouseup). Same fix applied to mui-adapter's `ExpandToggleButton`.
@@ -1,3 +1,14 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_disabled
4
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
5
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
6
+ * recursica-allow-brand: --recursica_brand_states_focus_color
7
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
8
+ * recursica-allow-brand: --recursica_brand_states_hover_color
9
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
10
+ */
11
+
1
12
  /* HARDCODED VALUES:
2
13
  * - list-style/margin/padding resets on .root/.subtree: layout resets, no corresponding
3
14
  * design tokens (structural, not visual design values).
@@ -65,12 +65,16 @@ function createRenderTreeNode(disabled: boolean) {
65
65
  row. Stops propagation so the click never also reaches `.row`'s own handler and
66
66
  selects the node. Never independently focusable/tab-stoppable (tabIndex={-1},
67
67
  aria-hidden), so the row stays the only focusable element and this button never
68
- shows its own focus state. Always rendered, even for leaf nodes, so every row
69
- reserves the same layout space; CSS hides it (visibility, not display) when there
70
- are no children to toggle. Guards `disabled` itself (not just via the CSS
71
- `pointer-events: none` on `.root`) since this handler bypasses Mantine's own
72
- `expandOnClick`/`selectOnClick` flags entirely by calling `tree.toggleExpanded`
73
- directly. */}
68
+ shows its own focus state. `onMouseDown` prevents the browser's default
69
+ click-focuses-the-button behavior (tabIndex={-1} only removes it from the tab
70
+ order, it doesn't stop a direct mouse click from focusing it) without this, a
71
+ click here leaves the (aria-hidden) button focused, which browsers now flag as an
72
+ accessibility violation ("focus must not be hidden from assistive technology").
73
+ Always rendered, even for leaf nodes, so every row reserves the same layout space;
74
+ CSS hides it (visibility, not display) when there are no children to toggle. Guards
75
+ `disabled` itself (not just via the CSS `pointer-events: none` on `.root`) since this
76
+ handler bypasses Mantine's own `expandOnClick`/`selectOnClick` flags entirely by
77
+ calling `tree.toggleExpanded` directly. */}
74
78
  <Button
75
79
  overStyled
76
80
  variant="text"
@@ -80,6 +84,7 @@ function createRenderTreeNode(disabled: boolean) {
80
84
  aria-hidden="true"
81
85
  tabIndex={-1}
82
86
  className={styles.expandButton}
87
+ onMouseDown={(event) => event.preventDefault()}
83
88
  onClick={(event) => {
84
89
  event.stopPropagation();
85
90
  if (disabled) return;