@recursica/mui-adapter 0.31.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +11 -3
  3. package/dist/mui-adapter.cjs +50 -50
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.css +1 -1
  6. package/dist/mui-adapter.js +3717 -3659
  7. package/dist/mui-adapter.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/components/Accordion/Accordion.module.css +7 -0
  10. package/src/components/Autocomplete/Autocomplete.module.css +10 -0
  11. package/src/components/Button/Button.module.css +10 -0
  12. package/src/components/Card/Card.stories.tsx +2 -4
  13. package/src/components/Chip/Chip.module.css +9 -0
  14. package/src/components/DatePicker/DatePicker.module.css +13 -0
  15. package/src/components/Dropdown/Dropdown.module.css +10 -0
  16. package/src/components/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +4 -1
  17. package/src/components/FileInput/FileInput.module.css +17 -0
  18. package/src/components/FileUpload/FileUpload.module.css +6 -0
  19. package/src/components/Link/Link.module.css +5 -0
  20. package/src/components/Menu/Menu.module.css +6 -0
  21. package/src/components/NumberInput/NumberInput.module.css +8 -0
  22. package/src/components/Pagination/Pagination.module.css +7 -0
  23. package/src/components/Slider/IMPLEMENTATION_NOTES.md +18 -2
  24. package/src/components/Slider/Slider.module.css +20 -3
  25. package/src/components/Slider/Slider.stories.tsx +65 -0
  26. package/src/components/Slider/Slider.tsx +130 -40
  27. package/src/components/Slider/USAGE.md +15 -1
  28. package/src/components/Switch/Switch.module.css +8 -0
  29. package/src/components/Tabs/Tabs.module.css +9 -0
  30. package/src/components/TextArea/TextArea.module.css +8 -0
  31. package/src/components/TextField/TextField.module.css +8 -0
  32. package/src/components/TimePicker/TimePicker.module.css +8 -0
  33. package/src/components/Tree/IMPLEMENTATION_NOTES.md +3 -0
  34. package/src/components/Tree/Tree.module.css +11 -0
  35. 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.31.0",
16
+ "version": "0.32.1",
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.22.0",
105
+ "@recursica/adapter-common": "^0.23.0",
106
106
  "@recursica/official-release": "^2.8.0",
107
107
  "dayjs": "^1.11.21"
108
108
  },
@@ -1,3 +1,10 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_disabled
4
+ * recursica-allow-brand: --recursica_brand_states_hover_color
5
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
6
+ */
7
+
1
8
  /* ==== ACCORDION CONTAINER ==== */
2
9
  .root {
3
10
  box-sizing: border-box;
@@ -1,3 +1,13 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ * recursica-allow-brand: --recursica_brand_states_hover_color
8
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
9
+ */
10
+
1
11
  /* LAYOUT SPACING OVERRIDES:
2
12
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
13
  - Also sets the --autocomplete-control-{max,min}-width hooks consumed inline in Autocomplete.tsx,
@@ -1,3 +1,13 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ * recursica-allow-brand: --recursica_brand_states_hover_color
8
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
9
+ */
10
+
1
11
  /* Baseline and Reset */
2
12
  .root {
3
13
  box-sizing: border-box;
@@ -37,8 +37,7 @@ export const Default: Story = {
37
37
  <div
38
38
  style={{
39
39
  padding: "48px",
40
- backgroundColor:
41
- "var(--recursica_brand_palettes_neutral_050_color_tone)",
40
+ backgroundColor: "#e9ecef",
42
41
  }}
43
42
  >
44
43
  <Layer layer={0}>
@@ -73,8 +72,7 @@ export const HeaderlessAndFooterless: Story = {
73
72
  <div
74
73
  style={{
75
74
  padding: "48px",
76
- backgroundColor:
77
- "var(--recursica_brand_palettes_neutral_050_color_tone)",
75
+ backgroundColor: "#e9ecef",
78
76
  }}
79
77
  >
80
78
  <Layer layer={0}>
@@ -1,3 +1,12 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_disabled
4
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
5
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
6
+ * recursica-allow-brand: --recursica_brand_states_focus_color
7
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
8
+ */
9
+
1
10
  /* HARDCODED VALUES
2
11
  border-style: solid; (Baseline reset)
3
12
  box-sizing: border-box; (Baseline reset)
@@ -1,3 +1,16 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * No ui-kit_components_date-picker radius token exists for the calendar day cells; reuses the brand default border-radius.
3
+ * recursica-allow-brand: --recursica_brand_dimensions_border-radii_default
4
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
5
+ * recursica-allow-brand: --recursica_brand_states_disabled
6
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
7
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
8
+ * recursica-allow-brand: --recursica_brand_states_focus_color
9
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
10
+ * recursica-allow-brand: --recursica_brand_states_hover_color
11
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
12
+ */
13
+
1
14
  /* HARDCODED VALUES:
2
15
  - border-style: solid. Native structural rendering rule.
3
16
  - outline: none. Bypassing browser focus rings to rely strictly on Recursica focus states natively.
@@ -1,3 +1,13 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ * recursica-allow-brand: --recursica_brand_states_hover_color
8
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
9
+ */
10
+
1
11
  /* LAYOUT SPACING OVERRIDES:
2
12
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
13
  - Also sets the --dropdown-control-{max,min}-width hooks consumed inline in Dropdown.tsx, since
@@ -136,7 +136,10 @@ roving group, _then_ the trailing clear button as its own stop.
136
136
  wraps onto multiple lines below the dropzone — `FileInput` is a fixed single-line, `min-height`d
137
137
  control, so wrapping would grow it vertically. Enough chips to overflow the control's own width
138
138
  scroll horizontally within it instead (mouse wheel/trackpad or a native scrollbar), same tradeoff
139
- already made for `.value`'s ellipsis truncation.
139
+ already made for `.value`'s ellipsis truncation. The native scrollbar itself is hidden
140
+ (`scrollbar-width: none`, `-ms-overflow-style: none`, `::-webkit-scrollbar { display: none }`) —
141
+ the control's `min-height` isn't tall enough to host a visible horizontal scrollbar without
142
+ clipping the chips, and scrolling (wheel/trackpad/drag) still works with it hidden.
140
143
 
141
144
  ## Read-only vs disabled
142
145
 
@@ -1,3 +1,13 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ * recursica-allow-brand: --recursica_brand_states_hover_color
8
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
9
+ */
10
+
1
11
  /* LAYOUT SPACING OVERRIDES:
2
12
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
13
  - Also sets the --file-input-control-{max,min}-width hooks consumed inline in FileInput.tsx,
@@ -178,6 +188,13 @@
178
188
  overflow-x: auto;
179
189
  overflow-y: hidden;
180
190
  gap: var(--recursica_ui-kit_components_file-input_properties_icon-text-gap);
191
+ /* HARDCODE: scrollbar stays functional (still scrolls via wheel/trackpad/drag) but hidden —
192
+ the control isn't tall enough to host a visible scrollbar without clipping the chips. */
193
+ scrollbar-width: none; /* Firefox */
194
+ -ms-overflow-style: none; /* legacy Edge */
195
+ }
196
+ .chipRow::-webkit-scrollbar {
197
+ display: none; /* Chrome, Safari */
181
198
  }
182
199
 
183
200
  .chipWrapper {
@@ -1,3 +1,9 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_hover_color
4
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
5
+ */
6
+
1
7
  /* HARDCODED VALUES:
2
8
  - .root: display: flex; flex-direction: column; width: 100% — structural layout, not a design
3
9
  token concern (matches how other block-level components like Layer are laid out).
@@ -1,3 +1,8 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Only source for link hover-underline; no per-component token exists (see IMPLEMENTATION_NOTES.md).
3
+ * recursica-allow-brand: --recursica_brand_states_link_decoration
4
+ */
5
+
1
6
  /*
2
7
  HARDCODED VALUES:
3
8
  - display: inline-flex; (Base layout to align icon and text correctly)
@@ -1,3 +1,9 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_hover_color
4
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
5
+ */
6
+
1
7
  /* HARDCODED VALUES:
2
8
  - border-style: solid. Structural rendering rule for the dropdown border (Mantine uses Paper which may not set border natively).
3
9
  - NOTE: overflow is intentionally NOT set on .dropdown. Mantine renders sub-menu dropdowns
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* LAYOUT SPACING OVERRIDES:
2
10
  - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens.
3
11
  - Also sets the --number-input-control-{max,min}-width hooks consumed inline in NumberInput.tsx,
@@ -1,3 +1,10 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_disabled
4
+ * recursica-allow-brand: --recursica_brand_states_hover_color
5
+ * recursica-allow-brand: --recursica_brand_states_hover_opacity
6
+ */
7
+
1
8
  .root {
2
9
  display: flex;
3
10
  align-items: center;
@@ -34,6 +34,22 @@ This document contains specific design decisions, architectural constraints, and
34
34
 
35
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
36
 
37
- ## 6. No Dual-Thumb / Range Support
37
+ ## 6. Dual-Thumb / Range Support
38
38
 
39
- **Decision:** Requested (MUI's `Slider` already accepts `number[]` for `value`/`onChange` and renders multiple thumbs natively), declined — no current use case needs it. `Slider` stays single-thumb only; `value`/`onChange` remain typed as `number` and arrays continue to be collapsed to `value[0]`.
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.
@@ -1,9 +1,20 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /*
2
10
  * HARDCODED VALUES:
3
11
  * - display: flex; align-items: center; width: 100%; (Standard CSS flexbox layouts for bidirectional components)
4
12
  * - flex-grow: 1; flex-shrink: 0; (Layout control structures)
5
- * - transform: translateX(-50%); (Step marks/mark labels positioning alignment offset, matching
6
- * Mantine's own offset mechanism)
13
+ * - transform: translate(-50%, -50%); on .sliderMark (preserves MUI's own vertical mark
14
+ * centering while re-deriving the horizontal offset for our step-indicator-width token — see
15
+ * .sliderMark below)
16
+ * - transform: translateX(-50%); on .sliderMarkLabel (horizontal-only; matches Mantine's offset
17
+ * mechanism)
7
18
  * - outline: none; border-style: solid; (Standard focus reset and border outlines)
8
19
  * - Focus ring on thumb/input: the token schema no longer provides per-component focus
9
20
  * colors for these (only `active` covers track/step-indicator-color). We apply the generic
@@ -164,7 +175,13 @@
164
175
  background-color: var(
165
176
  --recursica_ui-kit_components_slider_properties_colors_step-indicator-color
166
177
  );
167
- transform: translateX(-50%);
178
+ /* MUI's own mark is `top: 50%; transform: translate(-1px, -50%)` — the -1px assumes its
179
+ built-in 2px-wide dot and the -50% is real vertical centering, not decoration. Overriding
180
+ width/height above without preserving that vertical -50% would leave the dot vertically
181
+ un-centered (it would hang below the track's midpoint); translateX(-50%) alone stomped it.
182
+ translate(-50%, -50%) keeps the vertical centering and swaps the horizontal offset to match
183
+ our (non-2px) step-indicator-width. */
184
+ transform: translate(-50%, -50%);
168
185
  border: none;
169
186
  }
170
187
 
@@ -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
@@ -20,7 +20,13 @@ import { type RecursicaSliderProps as BaseRecursicaSliderProps } from "@recursic
20
20
  export interface RecursicaSliderProps
21
21
  extends Omit<
22
22
  MuiSliderProps,
23
- "size" | "color" | "classes" | "onChange" | "onChangeCommitted"
23
+ | "size"
24
+ | "color"
25
+ | "classes"
26
+ | "onChange"
27
+ | "onChangeCommitted"
28
+ | "value"
29
+ | "defaultValue"
24
30
  >,
25
31
  Omit<
26
32
  RecursicaFormControlWrapperProps,
@@ -38,15 +44,23 @@ 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 }> = ({ value }) => {
44
- return <div className={styles.readOnlyValue}>{value}</div>;
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
65
  * custom typography-bound min/max labels (optionally overridden via `minLabel`/`maxLabel`),
52
66
  * optional leading/trailing icons, and an explicit read-only layout.
@@ -96,35 +110,45 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
96
110
  ...rest
97
111
  } = props;
98
112
 
99
- // Bidirectional state linking the slider track value to the input field string representation
100
- const [internalValue, setInternalValue] = useState<number>(() => {
101
- if (value !== undefined) return Array.isArray(value) ? value[0] : value;
102
- if (defaultValue !== undefined)
103
- return Array.isArray(defaultValue) ? defaultValue[0] : defaultValue;
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;
104
121
  return min;
105
122
  });
106
123
 
107
- const resolvedValue =
108
- value !== undefined
109
- ? Array.isArray(value)
110
- ? value[0]
111
- : value
112
- : internalValue;
113
- const [inputValue, setInputValue] = useState<string>(
114
- resolvedValue.toString(),
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(),
115
133
  );
116
134
 
117
- // Synchronize text input whenever the slider value changes
135
+ // Synchronize text input(s) whenever the slider value changes
118
136
  useEffect(() => {
119
- setInputValue(resolvedValue.toString());
137
+ setInputValue(
138
+ Array.isArray(resolvedValue)
139
+ ? [resolvedValue[0].toString(), resolvedValue[1].toString()]
140
+ : resolvedValue.toString(),
141
+ );
120
142
  }, [resolvedValue]);
121
143
 
122
144
  const handleValueChange = (_e: Event, val: number | number[]) => {
123
- const singleVal = Array.isArray(val) ? val[0] : val;
145
+ const normalized: SliderValue = Array.isArray(val)
146
+ ? [val[0], val[1]]
147
+ : val;
124
148
  if (value === undefined) {
125
- setInternalValue(singleVal);
149
+ setInternalValue(normalized);
126
150
  }
127
- onChange?.(singleVal);
151
+ onChange?.(normalized);
128
152
  };
129
153
 
130
154
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -140,7 +164,38 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
140
164
  };
141
165
 
142
166
  const handleInputBlur = () => {
143
- 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()]);
144
199
  };
145
200
 
146
201
  // Props this component intentionally doesn't support — deleted at runtime so they can't leak
@@ -232,8 +287,12 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
232
287
 
233
288
  // Duplicates the raw numeric value next to the track by default; when `tooltipLabel` is a
234
289
  // formatter, reuse it here too so both displays agree instead of one showing raw numbers.
235
- const displayValue =
236
- typeof tooltipLabel === "function"
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"
237
296
  ? tooltipLabel(resolvedValue)
238
297
  : resolvedValue;
239
298
 
@@ -272,6 +331,22 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
272
331
  data-error={error ? "true" : undefined}
273
332
  data-suppress-focus-ring={suppressFocusRing ? "true" : undefined}
274
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
+
275
350
  {leadingIcon}
276
351
 
277
352
  {showMinMaxLabels && (
@@ -319,22 +394,37 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
319
394
  )}
320
395
  </div>
321
396
 
322
- {showInput && (
323
- <input
324
- type="number"
325
- className={styles.inputField}
326
- value={inputValue}
327
- onChange={handleInputChange}
328
- onBlur={handleInputBlur}
329
- min={min}
330
- max={max}
331
- step={step ?? undefined}
332
- disabled={disabled}
333
- data-error={error ? "true" : undefined}
334
- />
335
- )}
336
-
337
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
+ ))}
338
428
  </div>
339
429
  }
340
430
  />
@@ -39,4 +39,18 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
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 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.
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
+ ```
@@ -1,3 +1,11 @@
1
+ /* Brand-layer exemptions (recursica-allow-brand) — see recursica-token-analyzer README.md.
2
+ * Global hover/focus/disabled state tokens (recursica_variables_scoped.css header, 'Hover & Focus states' / 'Disabled state' — implicit rule for every interactive element; components must not define their own per-component treatment).
3
+ * recursica-allow-brand: --recursica_brand_states_focus_blur
4
+ * recursica-allow-brand: --recursica_brand_states_focus_border-size
5
+ * recursica-allow-brand: --recursica_brand_states_focus_color
6
+ * recursica-allow-brand: --recursica_brand_states_focus_margin
7
+ */
8
+
1
9
  /* HARDCODED VALUES
2
10
  *
3
11
  * 1. track border: none; (we do not use border for the switch track)