@recursica/mantine-adapter 0.47.0 → 0.48.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/mantine-adapter"
15
15
  },
16
- "version": "0.47.0",
16
+ "version": "0.48.0",
17
17
  "type": "module",
18
18
  "main": "./dist/mantine-adapter.cjs",
19
19
  "module": "./dist/mantine-adapter.js",
@@ -94,7 +94,7 @@
94
94
  "vitest": "^3.2.4"
95
95
  },
96
96
  "dependencies": {
97
- "@recursica/adapter-common": "^0.22.0",
97
+ "@recursica/adapter-common": "^0.23.0",
98
98
  "@recursica/official-release": "^2.8.0"
99
99
  },
100
100
  "peerDependencies": {
@@ -67,6 +67,26 @@ This document contains specific design decisions, architectural constraints, and
67
67
  - `minLabel`/`maxLabel` (new `adapter-common` props) override the `.minMaxGuide` text at either end of the track, falling back to the numeric `min`/`max`.
68
68
  - `trailingIcon` (new `adapter-common` prop) renders a second icon on the opposite side of the track from the existing `icon`, reusing the same `.iconWrapper` styling (disabled/error/focus states already target `.iconWrapper` generically, so no new CSS was needed).
69
69
 
70
- ## 8. No Dual-Thumb / Range Support
70
+ ## 8. Dual-Thumb / Range Support
71
71
 
72
- **Decision:** Requested (a caller passing `[number, number]` for `value`/`onChange`, backed by Mantine's separate `RangeSlider` component), declined no current use case needs it. `Slider` stays single-thumb only; `value`/`onChange` remain typed as `number`.
72
+ **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`.
73
+ **Implementation:**
74
+
75
+ - `isRange = Array.isArray(resolvedValue)` picks which underlying Mantine component to render: single-thumb `Slider` (unchanged) or two-thumb `RangeSlider` — Mantine ships these as two separate components (unlike raw MUI, which renders two thumbs off a single `Slider` given a tuple `value`), so this is a real component swap, not just a type/prop change. Both share the same `SliderStylesNames` shape, so `mergedClassNames` (root/track/bar/thumb/mark/markLabel) applies unchanged to either.
76
+ - 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.
77
+ - `SliderReadOnlyValue` and the floating `.currentValue` display both render `"lower – upper"` for a range value, running each side through `tooltipLabel` independently when it's a formatter.
78
+ - 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 §9 below.
79
+ - Not carried over: Mantine `RangeSlider`'s own `minRange`/`maxRange`/`pushOnOverlap`/`thumbFromLabel`/`thumbToLabel` pass through via the existing unsanitized `...rest` spread if a caller sets them directly; no adapter-level default is applied (e.g. Mantine's own `minRange` default of `10` is left as-is).
80
+
81
+ ## 9. Trailing Icon Order Relative to the Input Field
82
+
83
+ **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.
84
+ **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).
85
+
86
+ ## 10. Marks Rendering Off Mantine Defaults Instead of Recursica Tokens
87
+
88
+ **Decision:** §3 claimed marks were "fully styles-mapped back to our scoped variables" — not quite true. Mantine's `markWrapper` style slot (the zero-height, top-anchored positioning wrapper around each mark dot + label) was never mapped in `mergedClassNames`, and `markLabel`'s vertical position was never overridden, so both stayed on raw Mantine defaults.
89
+ **Implementation:**
90
+
91
+ - `markWrapper` is anchored to Mantine's own `top: 0` (the track's top edge), with the mark dot inside it unpositioned. Mantine's own dot has no size override so it happens to fill the track height exactly by default; our `step-indicator-width` token is independent of track height, so the dot rendered small and flush to the top instead of centered. Added `markWrapper: styles.sliderMarkWrapper` to `mergedClassNames`, with `.sliderMarkWrapper { top: 50%; }`, and changed `.sliderMark`'s transform from `translateX(-50%)` to `translate(-50%, -50%)` to center the dot on that midpoint.
92
+ - `.sliderMarkLabel` never overrode `transform`, so its vertical offset stayed Mantine's own `calc(var(--mantine-spacing-xs) / 2)` — an unbound theme default, not a recursica token. Replaced the vertical term with `--recursica_ui-kit_globals_form_properties_label-field-gap-vertical` (the same token the mui-adapter's markLabel already uses), keeping the horizontal term (`-50% + var(--slider-size) / 2`, Mantine's own mark-offset compensation) unchanged.
@@ -2,7 +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%); (Mantine's standard step marks positioning alignment offset)
5
+ * - transform: translate(-50%, -50%); (custom step-mark centering offset see .sliderMarkWrapper
6
+ * / .sliderMark below; not inherited from Mantine, which centers marks via its own
7
+ * --slider-size variable that our step-indicator-width token doesn't participate in)
8
+ * - top: 50%; on .sliderMarkWrapper (re-anchors Mantine's zero-height mark wrapper to the
9
+ * track's vertical midpoint instead of its top edge)
6
10
  * - outline: none; border-style: solid; (Standard focus reset and border outlines)
7
11
  * - Focus ring on thumb/icon/input: the token schema no longer provides per-component focus
8
12
  * colors for these (only `active` covers track/step-indicator-color). We apply the generic
@@ -109,6 +113,18 @@
109
113
  var(--recursica_brand_states_focus_color);
110
114
  }
111
115
 
116
+ /*
117
+ * Mantine positions the mark dot inside a zero-height `markWrapper` anchored to the track's
118
+ * top edge, relying on the dot's height matching Mantine's own `--slider-size` to land centered.
119
+ * Our step-indicator-width token is independent of that, so without an explicit vertical
120
+ * transform the dot sits flush to the track's top instead of centered. `sliderMarkWrapper`
121
+ * moves the anchor to the track's vertical midpoint; `translate(-50%, -50%)` below centers the
122
+ * dot on that midpoint (in addition to the pre-existing horizontal centering).
123
+ */
124
+ .sliderMarkWrapper {
125
+ top: 50%;
126
+ }
127
+
112
128
  .sliderMark {
113
129
  box-sizing: border-box;
114
130
  width: var(
@@ -123,7 +139,7 @@
123
139
  background-color: var(
124
140
  --recursica_ui-kit_components_slider_properties_colors_step-indicator-color
125
141
  );
126
- transform: translateX(-50%);
142
+ transform: translate(-50%, -50%);
127
143
  border: none;
128
144
  }
129
145
 
@@ -139,8 +155,18 @@
139
155
  * Mantine's default markLabel has no dedicated recursica token; it renders with Mantine's own
140
156
  * theme grey instead of inheriting the container text color. Reusing the min-max-label
141
157
  * typography tokens and inheriting color keeps this visually consistent with the min/max guides.
158
+ *
159
+ * The vertical gap between mark and label is also Mantine's own default (half of
160
+ * --mantine-spacing-xs, unbound to any recursica token) unless overridden here. The transform's
161
+ * horizontal term (`-50% + var(--slider-size) / 2`) is Mantine's own mark-offset compensation
162
+ * and is left untouched; only the vertical term is replaced with the same label-field gap token
163
+ * the mui-adapter's markLabel already uses, for cross-adapter consistency.
142
164
  */
143
165
  .sliderMarkLabel {
166
+ transform: translate(
167
+ calc(-50% + var(--slider-size) / 2),
168
+ var(--recursica_ui-kit_globals_form_properties_label-field-gap-vertical)
169
+ );
144
170
  color: inherit;
145
171
 
146
172
  font-family: var(
@@ -190,6 +190,71 @@ export const WithIconsAndLabels: Story = {
190
190
  },
191
191
  };
192
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
+
193
258
  export const FormLayouts: Story = {
194
259
  render: () => (
195
260
  <div
@@ -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
+ ```
@@ -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`.
@@ -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;