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