@recursica/mantine-adapter 0.46.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 +23 -0
- package/dist/index.d.ts +17 -5
- 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 +1723 -1645
- package/dist/mantine-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Slider/IMPLEMENTATION_NOTES.md +33 -0
- package/src/components/Slider/Slider.module.css +28 -2
- package/src/components/Slider/Slider.stories.tsx +109 -0
- package/src/components/Slider/Slider.tsx +169 -43
- 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": {
|
|
@@ -57,3 +57,36 @@ This document contains specific design decisions, architectural constraints, and
|
|
|
57
57
|
- The thumb's focus box-shadow layers the ring on top of its existing `thumb-elevation` shadow rather than replacing it.
|
|
58
58
|
- `.sliderMark[data-filled]` has no default-state "-active" step-indicator token anymore (only `disabled`/`error` define one); it reuses `colors_track-active` so filled marks stay visually tied to the filled portion of the track. Worth a design review if a distinct filled-mark color is expected.
|
|
59
59
|
- `markLabel` was already mapped in the `classNames` prop but had no matching `.sliderMarkLabel` rule in this file, so Mantine silently fell back to its own default theme grey instead of any recursica token. Added a `.sliderMarkLabel` rule reusing the min-max-label typography tokens with `color: inherit`, matching the mui-adapter's equivalent.
|
|
60
|
+
|
|
61
|
+
## 7. Formatted Current Value, Label Overrides, Trailing Icon
|
|
62
|
+
|
|
63
|
+
**Decision:** The floating `.currentValue` display (see §5) always rendered the raw numeric value, even when `tooltipLabel` was a formatter function — a caller mapping values onto custom text (e.g. 0-4 → XS/S/M/L/XL) got the formatted tooltip while dragging but the raw number next to the track otherwise.
|
|
64
|
+
**Implementation:**
|
|
65
|
+
|
|
66
|
+
- `.currentValue` now runs `resolvedValue` through `tooltipLabel` when it's a function, so both displays agree. No change when `tooltipLabel` is absent or a static node — still the raw number.
|
|
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
|
+
- `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
|
+
|
|
70
|
+
## 8. Dual-Thumb / Range Support
|
|
71
|
+
|
|
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(
|
|
@@ -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
|
|
@@ -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,17 +47,23 @@ 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
|
-
* custom typography-bound min/max labels
|
|
65
|
+
* custom typography-bound min/max labels (optionally overridden via `minLabel`/`maxLabel`),
|
|
66
|
+
* optional leading/trailing icons, and an explicit read-only layout.
|
|
55
67
|
*/
|
|
56
68
|
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
57
69
|
function Slider(props, ref) {
|
|
@@ -84,34 +96,51 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
|
84
96
|
value,
|
|
85
97
|
defaultValue,
|
|
86
98
|
icon,
|
|
99
|
+
trailingIcon,
|
|
87
100
|
showInput = false,
|
|
88
101
|
showMinMaxLabels = true,
|
|
89
102
|
min = 0,
|
|
90
103
|
max = 100,
|
|
104
|
+
minLabel,
|
|
105
|
+
maxLabel,
|
|
91
106
|
step = 1,
|
|
92
107
|
onChange,
|
|
93
108
|
onChangeEnd,
|
|
94
109
|
...rest
|
|
95
110
|
} = props;
|
|
96
111
|
|
|
97
|
-
// Bidirectional state linking the slider track value to the input field string
|
|
98
|
-
|
|
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>(() => {
|
|
99
118
|
if (value !== undefined) return value;
|
|
100
119
|
if (defaultValue !== undefined) return defaultValue;
|
|
101
120
|
return min;
|
|
102
121
|
});
|
|
103
122
|
|
|
104
|
-
const resolvedValue =
|
|
105
|
-
|
|
106
|
-
|
|
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(),
|
|
107
132
|
);
|
|
108
133
|
|
|
109
|
-
// Synchronize text input whenever the slider value changes
|
|
134
|
+
// Synchronize text input(s) whenever the slider value changes
|
|
110
135
|
useEffect(() => {
|
|
111
|
-
setInputValue(
|
|
136
|
+
setInputValue(
|
|
137
|
+
Array.isArray(resolvedValue)
|
|
138
|
+
? [resolvedValue[0].toString(), resolvedValue[1].toString()]
|
|
139
|
+
: resolvedValue.toString(),
|
|
140
|
+
);
|
|
112
141
|
}, [resolvedValue]);
|
|
113
142
|
|
|
114
|
-
const handleValueChange = (val:
|
|
143
|
+
const handleValueChange = (val: SliderValue) => {
|
|
115
144
|
if (value === undefined) {
|
|
116
145
|
setInternalValue(val);
|
|
117
146
|
}
|
|
@@ -132,7 +161,38 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
|
132
161
|
|
|
133
162
|
const handleInputBlur = () => {
|
|
134
163
|
// Clean up text field on blur to reflect the final clamped resolved value
|
|
135
|
-
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()]);
|
|
136
196
|
};
|
|
137
197
|
|
|
138
198
|
// Props this component intentionally doesn't support — deleted at runtime so they can't leak
|
|
@@ -160,6 +220,7 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
|
160
220
|
track: styles.sliderTrack,
|
|
161
221
|
bar: styles.sliderBar,
|
|
162
222
|
thumb: styles.sliderThumb,
|
|
223
|
+
markWrapper: styles.sliderMarkWrapper,
|
|
163
224
|
mark: styles.sliderMark,
|
|
164
225
|
markLabel: styles.sliderMarkLabel,
|
|
165
226
|
},
|
|
@@ -176,6 +237,23 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
|
176
237
|
</span>
|
|
177
238
|
) : null;
|
|
178
239
|
|
|
240
|
+
const trailingIconEl = trailingIcon ? (
|
|
241
|
+
<span className={styles.iconWrapper} aria-hidden>
|
|
242
|
+
{trailingIcon}
|
|
243
|
+
</span>
|
|
244
|
+
) : null;
|
|
245
|
+
|
|
246
|
+
// Duplicates the raw numeric value next to the track by default; when `tooltipLabel` is a
|
|
247
|
+
// formatter, reuse it here too so both displays agree instead of one showing raw numbers.
|
|
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"
|
|
254
|
+
? tooltipLabel(resolvedValue)
|
|
255
|
+
: resolvedValue;
|
|
256
|
+
|
|
179
257
|
return (
|
|
180
258
|
<WithReadOnlyWrapper
|
|
181
259
|
ref={ref}
|
|
@@ -211,50 +289,98 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
|
|
211
289
|
data-disabled={disabled ? "true" : undefined}
|
|
212
290
|
data-error={error ? "true" : undefined}
|
|
213
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
|
+
|
|
214
308
|
{leadingIcon}
|
|
215
309
|
|
|
216
310
|
{showMinMaxLabels && (
|
|
217
|
-
<span className={styles.minMaxGuide}>{min}</span>
|
|
311
|
+
<span className={styles.minMaxGuide}>{minLabel ?? min}</span>
|
|
218
312
|
)}
|
|
219
313
|
|
|
220
314
|
<div className={styles.sliderTrackWrapper}>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
)}
|
|
233
342
|
</div>
|
|
234
343
|
|
|
235
344
|
<div className={styles.rightGuideContainer}>
|
|
236
345
|
{!showInput && (
|
|
237
|
-
<span className={styles.currentValue}>{
|
|
346
|
+
<span className={styles.currentValue}>{displayValue}</span>
|
|
238
347
|
)}
|
|
239
348
|
{showMinMaxLabels && (
|
|
240
|
-
<span className={styles.minMaxGuide}>{max}</span>
|
|
349
|
+
<span className={styles.minMaxGuide}>{maxLabel ?? max}</span>
|
|
241
350
|
)}
|
|
242
351
|
</div>
|
|
243
352
|
|
|
244
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
))}
|
|
258
384
|
</div>
|
|
259
385
|
}
|
|
260
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.
|
|
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;
|