@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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +9 -4
- package/dist/mantine-adapter.cjs +2 -2
- package/dist/mantine-adapter.cjs.map +1 -1
- package/dist/mantine-adapter.css +1 -1
- package/dist/mantine-adapter.js +1633 -1559
- package/dist/mantine-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Slider/IMPLEMENTATION_NOTES.md +22 -2
- package/src/components/Slider/Slider.module.css +28 -2
- package/src/components/Slider/Slider.stories.tsx +65 -0
- package/src/components/Slider/Slider.tsx +149 -42
- package/src/components/Slider/USAGE.md +15 -1
- package/src/components/Tree/IMPLEMENTATION_NOTES.md +3 -0
- package/src/components/Tree/Tree.tsx +11 -6
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.
|
|
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.
|
|
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.
|
|
70
|
+
## 8. Dual-Thumb / Range Support
|
|
71
71
|
|
|
72
|
-
**Decision:**
|
|
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:
|
|
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:
|
|
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 }> = ({
|
|
47
|
-
|
|
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
|
|
102
|
-
|
|
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 =
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|