@recursica/mui-adapter 0.24.0 → 0.25.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/index.d.ts +166 -9
  3. package/dist/mui-adapter.cjs +69 -69
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.css +1 -1
  6. package/dist/mui-adapter.js +8868 -8066
  7. package/dist/mui-adapter.js.map +1 -1
  8. package/llms.txt +1 -0
  9. package/package.json +1 -1
  10. package/src/components/AssistiveElement/AssistiveElement.tsx +1 -1
  11. package/src/components/Button/BUTTON_IMPLEMENTATION_NOTES.md +12 -0
  12. package/src/components/Button/Button.module.css +6 -5
  13. package/src/components/Checkbox/Checkbox.tsx +4 -1
  14. package/src/components/FileInput/FileInput.tsx +6 -0
  15. package/src/components/Popover/IMPLEMENTATION_NOTES.md +22 -0
  16. package/src/components/Popover/Popover.module.css +123 -0
  17. package/src/components/Popover/Popover.stories.tsx +133 -0
  18. package/src/components/Popover/Popover.tsx +275 -0
  19. package/src/components/Popover/USAGE.md +69 -0
  20. package/src/components/Popover/index.ts +1 -0
  21. package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +6 -0
  22. package/src/components/SegmentedControl/SegmentedControl.module.css +33 -4
  23. package/src/components/SegmentedControl/SegmentedControl.tsx +18 -4
  24. package/src/components/Slider/IMPLEMENTATION_NOTES.md +29 -0
  25. package/src/components/Slider/Slider.module.css +80 -17
  26. package/src/components/Slider/Slider.stories.tsx +1 -1
  27. package/src/components/Slider/Slider.tsx +36 -1
  28. package/src/components/Stepper/IMPLEMENTATION_NOTES.md +54 -0
  29. package/src/components/Stepper/Stepper.module.css +139 -106
  30. package/src/components/Stepper/Stepper.tsx +76 -10
  31. package/src/components/Stepper/USAGE.md +4 -0
  32. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +12 -0
  33. package/src/components/Tabs/Tabs.module.css +107 -24
  34. package/src/components/Tabs/Tabs.tsx +1 -0
  35. package/src/components/TextArea/TextArea.module.css +20 -4
  36. package/src/components/TextArea/TextArea.tsx +12 -23
  37. package/src/components/Timeline/IMPLEMENTATION_NOTES.md +24 -3
  38. package/src/components/Timeline/Timeline.module.css +56 -68
  39. package/src/components/Timeline/Timeline.tsx +23 -27
  40. package/src/components/Timeline/TimelineItem.tsx +38 -35
  41. package/src/components/TransferList/TRANSFERLIST_IMPLEMENTATION_NOTES.md +140 -0
  42. package/src/components/TransferList/TransferList.module.css +179 -35
  43. package/src/components/TransferList/TransferList.stories.tsx +110 -6
  44. package/src/components/TransferList/TransferList.tsx +417 -8
  45. package/src/components/TransferList/USAGE.md +37 -6
  46. package/src/components/index.ts +1 -0
  47. package/src/index.ts +3 -0
@@ -0,0 +1,69 @@
1
+ # Popover - Usage Guide
2
+
3
+ This document describes how to integrate and use the `Popover` component in your projects using `@recursica/mui-adapter`.
4
+
5
+ ---
6
+
7
+ ## 1. Import Reference
8
+
9
+ ```tsx
10
+ import { Popover } from "@recursica/mui-adapter";
11
+ ```
12
+
13
+ ---
14
+
15
+ ## 2. Basic Example
16
+
17
+ ```tsx
18
+ import React from "react";
19
+ import { Popover, Button, Text } from "@recursica/mui-adapter";
20
+
21
+ export default function Demo() {
22
+ return (
23
+ <Popover position="bottom" withBeak>
24
+ <Popover.Target>
25
+ <Button>Open Popover</Button>
26
+ </Popover.Target>
27
+ <Popover.Dropdown>
28
+ <Text size="rec-sm">This is the popover content.</Text>
29
+ </Popover.Dropdown>
30
+ </Popover>
31
+ );
32
+ }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 3. Design System Integration
38
+
39
+ All Recursica components in the `@recursica/mui-adapter` package adhere strictly to design system spacing, scaling, and behavior patterns.
40
+
41
+ > [!IMPORTANT]
42
+ >
43
+ > - **Anti-override protection**: Rogue style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
44
+ > - **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.
45
+ > - **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.
46
+
47
+ ---
48
+
49
+ ## 4. Key Integration Features & Constraints
50
+
51
+ ### Composition
52
+
53
+ `Popover`, `Popover.Target`, and `Popover.Dropdown` are used together: `Popover.Target` wraps the trigger element and applies no styling of its own, while `Popover.Dropdown` renders the styled panel content. Both `Popover.Target` and `Popover.Dropdown` are required — omitting either throws.
54
+
55
+ ### Open/close behavior
56
+
57
+ The dropdown opens when the user clicks the target and closes on an outside click, on Escape, or by clicking the target again. Use `opened`/`onChange` for controlled usage, or `defaultOpened` to set the initial uncontrolled state.
58
+
59
+ ### Beak (Arrow)
60
+
61
+ The Recursica prop `withBeak` (defaulting to `true`) controls whether the pointer beak is shown.
62
+
63
+ ### Position
64
+
65
+ `position` accepts the same 12 placement values as `HoverCard`/`Tooltip` (e.g. `"top"`, `"bottom-start"`, `"right-end"`) and defaults to `"top"`.
66
+
67
+ ### Width
68
+
69
+ An optional `width` prop sets a fixed width on the dropdown panel.
@@ -0,0 +1 @@
1
+ export * from "./Popover";
@@ -0,0 +1,6 @@
1
+ # SegmentedControl Implementation Notes
2
+
3
+ ## Labels rendering uppercase (2026-08-19)
4
+
5
+ - **Root cause:** `--recursica_ui-kit_components_segmented-control-item_variants_selection-states_{unselected,selected}_properties_text_text-transform` resolves to `--recursica_tokens_font_cases_original`, which has no definition in `recursica_variables_scoped.css` (only `_lowercase`/`_titlecase`/`_uppercase` are defined there). The resulting `var()` on `.label` is invalid, and since `text-transform` is an inherited property, the invalid value falls back to the inherited value from `.control` (`.MuiToggleButton-root`) — which carries MUI's own `text-transform: uppercase` button default. Mantine's control has no such native uppercase default, so the same broken token never surfaced there.
6
+ - **Fix:** Reset `text-transform: none` on `.root .control` alongside the other MUI ToggleButton baseline resets (padding/border/etc.) already there, so nothing uppercase is left to inherit. Matches the existing `text-transform: none` MUI-baseline reset pattern in `Button.module.css`. Not a design-token value — it's a structural reset of MUI's own default, same category as the other hardcoded resets already exempted at the top of this file.
@@ -2,12 +2,17 @@
2
2
  - border-style: solid; on container and indicator
3
3
  - background-color: transparent; on label hover (overriding Mantine)
4
4
  - Scope prefix .root to enforce Figma tokens over Mantine's inline calculation without using !important
5
+ - padding/border/border-radius/min-height/min-width: 0 and background-color: transparent on
6
+ .control (MUI's ToggleButton root) so its own baseline button box model does not stack on
7
+ top of .label's token-driven height/border/radius, mirroring Mantine's transparent .control wrapper
8
+ - text-transform: none on .control resets MUI's ToggleButton uppercase default (see comment
9
+ above that rule); needed because the item text-transform token has no valid scoped value
5
10
  */
6
11
 
7
12
  /* EXEMPTIONS:
8
13
  - segmented-control-item_properties_item_border-radius is ignored because the item border-radius
9
14
  is fully governed by the per-selection-state tokens (unselected/selected `properties_border-radius`,
10
- already applied to `.label` and `.control.Mui-selected` below); this generic, state-agnostic radius
15
+ already applied to `.label` and `.control:global(.Mui-selected)` below); this generic, state-agnostic radius
11
16
  token has no distinct consumption site without conflicting with those state-specific overrides.
12
17
  The Mantine reference adapter exempts this same variable for the same reason. */
13
18
  /* recursica-ignore: --recursica_ui-kit_components_segmented-control-item_properties_item_border-radius */
@@ -38,6 +43,28 @@
38
43
  gap: var(--recursica_ui-kit_components_segmented-control_properties_item-gap);
39
44
  }
40
45
 
46
+ /* MUI's ToggleButton root ships its own padding/border/border-radius/min-height/min-width and a
47
+ text-transform: uppercase button default; reset all of it so it doesn't stack on top of (or leak
48
+ through, via inheritance, into) .label's token-driven box model/typography below. The
49
+ text-transform reset matters because the item's text-transform token
50
+ (segmented-control-item_..._text_text-transform) currently has no valid scoped value to resolve
51
+ to, so without this reset .label's own `text-transform: var(...)` below is invalid and the
52
+ inherited MUI uppercase default would otherwise show through (Mantine has no such native
53
+ default, so it never surfaced there). */
54
+ .root .control {
55
+ padding: 0;
56
+ border: none;
57
+ border-radius: 0;
58
+ min-height: 0;
59
+ min-width: 0;
60
+ background-color: transparent;
61
+ text-transform: none;
62
+ }
63
+
64
+ .root .control:hover {
65
+ background-color: transparent;
66
+ }
67
+
41
68
  .root .label {
42
69
  padding-left: var(
43
70
  --recursica_ui-kit_components_segmented-control-item_properties_item_padding-horizontal
@@ -128,8 +155,10 @@
128
155
  );
129
156
  }
130
157
 
131
- /* Indicator mapping (MUI ToggleButton applies .Mui-selected to the button instead of rendering a sliding indicator) */
132
- .root .control.Mui-selected {
158
+ /* Indicator mapping (MUI ToggleButton applies .Mui-selected to the button instead of rendering a sliding indicator).
159
+ Mui-selected must be wrapped in :global() — otherwise CSS Modules locally hashes it and the
160
+ selector never matches MUI's actual global class (silently dropping the selected state). */
161
+ .root .control:global(.Mui-selected) {
133
162
  background-color: var(
134
163
  --recursica_ui-kit_components_segmented-control-item_variants_selection-states_selected_properties_colors_background-color
135
164
  );
@@ -149,7 +178,7 @@
149
178
  }
150
179
 
151
180
  /* Selected label text color override */
152
- .root .control.Mui-selected .label {
181
+ .root .control:global(.Mui-selected) .label {
153
182
  color: var(
154
183
  --recursica_ui-kit_components_segmented-control-item_variants_selection-states_selected_properties_colors_text-color
155
184
  );
@@ -1,4 +1,4 @@
1
- import { forwardRef } from "react";
1
+ import { forwardRef, useState } from "react";
2
2
  import {
3
3
  ToggleButtonGroup as MuiSegmentedControl,
4
4
  ToggleButton,
@@ -85,12 +85,26 @@ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
85
85
 
86
86
  const stylingParams = useSegmentedControlClassNames(restRecord);
87
87
 
88
+ // MUI's ToggleButtonGroup is controlled-only and selects nothing when `value` is
89
+ // undefined; Mantine's SegmentedControl instead defaults to the first data item. Track an
90
+ // uncontrolled fallback so both adapters render the same default selection.
91
+ const firstValue = data.length
92
+ ? typeof data[0] === "string"
93
+ ? data[0]
94
+ : data[0].value
95
+ : undefined;
96
+ const [uncontrolledValue, setUncontrolledValue] = useState<
97
+ string | undefined
98
+ >(value ?? firstValue);
99
+ const activeValue = value !== undefined ? value : uncontrolledValue;
100
+
88
101
  const handleChange = (
89
102
  _event: React.MouseEvent<HTMLElement>,
90
103
  newValue: string | null,
91
104
  ) => {
92
- if (newValue !== null && onChange) {
93
- onChange(newValue);
105
+ if (newValue !== null) {
106
+ setUncontrolledValue(newValue);
107
+ onChange?.(newValue);
94
108
  }
95
109
  };
96
110
 
@@ -107,7 +121,7 @@ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
107
121
  fullWidth={fullWidth}
108
122
  data-orientation={orientation}
109
123
  exclusive
110
- value={value}
124
+ value={activeValue}
111
125
  onChange={
112
126
  handleChange as React.ComponentProps<
113
127
  typeof MuiSegmentedControl
@@ -0,0 +1,29 @@
1
+ # Slider Implementation Notes
2
+
3
+ This document contains specific design decisions, architectural constraints, and hacks required to bridge the Recursica design system with MUI's underlying `Slider` primitive.
4
+
5
+ ## 1. Pointer Clicks Falsely Trigger MUI's `Mui-focusVisible` Ring
6
+
7
+ **Symptom:** Clicking or dragging the thumb showed the keyboard focus ring, which Mantine never shows for a plain mouse interaction.
8
+
9
+ **Root cause:** MUI's `Slider` always programmatically re-focuses its hidden native `<input type="range">` on pointer-down (`focusThumb()` in `useSlider`). Because the focus target (`<input>`) differs from the element the pointer actually interacted with, and because a script-driven `.focus()` call is what browsers use to decide visibility, the native `:focus-visible` heuristic (which MUI's own `isFocusVisible` check relies on) resolves to `true` even for a plain click. Mantine's thumb is a plain `<div tabIndex>` that receives real native focus directly from the click, so the same heuristic correctly resolves to `false` there.
10
+
11
+ **Fix:** `Slider.tsx` tracks pointer-vs-keyboard itself (`onMouseDown` sets a ref, `onFocus` reads it to flag `suppressFocusRing`, `onKeyDown`/`onBlur` clear it — mirroring how real `:focus-visible` re-evaluates on a subsequent keypress). `Slider.module.css` only paints the ring when `[data-suppress-focus-ring="true"]` is absent from `.sliderContainer`.
12
+
13
+ ## 2. Disabled Track Stayed Red
14
+
15
+ **Symptom:** Once `disabled` was wired through correctly, the filled track (`.sliderBar`) still rendered the active/red color instead of the disabled grey token.
16
+
17
+ **Root cause:** The base `.sliderBar` rule had `background-color: ... !important`, so the (non-`!important`) `[data-disabled="true"] .sliderBar` override could never win the cascade regardless of source order.
18
+
19
+ **Fix:** Removed the unneeded `!important` flags from the base `.sliderBar` rule — `injectFirst` already makes our CSS-module class beat MUI's native `.MuiSlider-track` via source order alone (same as `.sliderTrack`/`.sliderThumb`/`.sliderMark`, none of which need `!important`).
20
+
21
+ ## 3. Assistive Text Rendered as `<div>` Instead of `<span>`
22
+
23
+ **Root cause:** `AssistiveElement.tsx` (mui-adapter) hardcoded a `<div>` around the children text; Mantine's equivalent uses a `<span>`. Not shared via `adapter-common` — each adapter has its own `AssistiveElement`.
24
+
25
+ **Fix:** Changed the inner text wrapper to a `<span>`. No CSS selector depended on the element type (`.text` is a class-only selector and remains a valid flex item as a span).
26
+
27
+ ## 4. Mark Label Color
28
+
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.
@@ -2,12 +2,15 @@
2
2
  * HARDCODED VALUES:
3
3
  * - display: flex; align-items: center; width: 100%; (Standard CSS flexbox layouts for bidirectional components)
4
4
  * - flex-grow: 1; flex-shrink: 0; (Layout control structures)
5
- * - transform: translateX(-50%); (Mantine's standard step marks positioning alignment offset)
5
+ * - transform: translateX(-50%); (Step marks/mark labels positioning alignment offset, matching
6
+ * Mantine's own offset mechanism)
6
7
  * - outline: none; border-style: solid; (Standard focus reset and border outlines)
7
8
  * - Focus ring on thumb/input: the token schema no longer provides per-component focus
8
9
  * colors for these (only `active` covers track/step-indicator-color). We apply the generic
9
10
  * --recursica_brand_states_focus_* ring tokens (border-size/color/margin/blur) as a box-shadow
10
11
  * ring instead of a color swap.
12
+ * - Focus ring on thumb is applied via MUI's own `Mui-focusVisible` class (not native
13
+ * `:focus`/`:focus-visible`, which never match the thumb span — see inline comment).
11
14
  */
12
15
 
13
16
  /* ==========================================
@@ -79,16 +82,14 @@
79
82
  }
80
83
 
81
84
  .sliderBar {
82
- height: var(
83
- --recursica_ui-kit_components_slider_properties_track-height
84
- ) !important;
85
+ height: var(--recursica_ui-kit_components_slider_properties_track-height);
85
86
  border-radius: var(
86
87
  --recursica_ui-kit_components_slider_properties_track-border-radius
87
- ) !important;
88
+ );
88
89
  background-color: var(
89
90
  --recursica_ui-kit_components_slider_properties_colors_track-active
90
- ) !important;
91
- border: none !important;
91
+ );
92
+ border: none;
92
93
  }
93
94
 
94
95
  .sliderThumb {
@@ -116,8 +117,21 @@
116
117
  );
117
118
  }
118
119
 
119
- .sliderThumb:focus,
120
- .sliderThumb:focus-visible {
120
+ /*
121
+ * MUI toggles its own `Mui-focusVisible` class on the thumb (for both keyboard focus and
122
+ * mouse-driven dragging, since MUI's slider routes real DOM focus to a hidden native input,
123
+ * never the visible thumb span) and pairs it with a hardcoded `theme.palette.primary.main`
124
+ * box-shadow ring. Native `:focus`/`:focus-visible` never match the thumb span itself, so we
125
+ * target MUI's own class directly to replace its default-blue ring with the recursica ring.
126
+ *
127
+ * MUI also always programmatically re-focuses that hidden input on pointer interaction, which
128
+ * browsers' `:focus-visible` heuristic treats as keyboard-visible — unlike Mantine's plain div
129
+ * thumb, where a real click correctly resolves to a non-visible focus. `data-suppress-focus-ring`
130
+ * (set in Slider.tsx by tracking pointer-vs-keyboard ourselves) keeps the ring keyboard-only,
131
+ * matching Mantine.
132
+ */
133
+ .sliderContainer:not([data-suppress-focus-ring="true"])
134
+ .sliderThumb:global(.Mui-focusVisible) {
121
135
  outline: none;
122
136
  box-shadow:
123
137
  var(--recursica_ui-kit_components_slider_properties_thumb-elevation),
@@ -128,6 +142,14 @@
128
142
  var(--recursica_brand_states_focus_color);
129
143
  }
130
144
 
145
+ .sliderContainer[data-suppress-focus-ring="true"]
146
+ .sliderThumb:global(.Mui-focusVisible) {
147
+ outline: none;
148
+ box-shadow: var(
149
+ --recursica_ui-kit_components_slider_properties_thumb-elevation
150
+ );
151
+ }
152
+
131
153
  .sliderMark {
132
154
  box-sizing: border-box;
133
155
  width: var(
@@ -154,6 +176,49 @@
154
176
  );
155
177
  }
156
178
 
179
+ /*
180
+ * MUI's default markLabel sits ~30-40px below the mark (`top: 30`, `top: 40` under
181
+ * `(pointer: coarse)`) and uses `theme.palette.text.secondary`. Mantine's equivalent label sits
182
+ * right under the mark (a half-step-indicator offset plus a small gap) and has no explicit color,
183
+ * inheriting the container's text color. Reusing the min-max-label typography tokens (the closest
184
+ * existing "small label near the track" token set) and inheriting color keeps this visually
185
+ * consistent with Mantine without a dedicated mark-label token.
186
+ */
187
+ .sliderMarkLabel {
188
+ top: calc(
189
+ var(--recursica_ui-kit_components_slider_properties_step-indicator-width) /
190
+ 2 +
191
+ var(--recursica_ui-kit_globals_form_properties_label-field-gap-vertical)
192
+ );
193
+ transform: translateX(-50%);
194
+ color: inherit;
195
+
196
+ font-family: var(
197
+ --recursica_ui-kit_components_slider_properties_min-max-label_font-family
198
+ );
199
+ font-size: var(
200
+ --recursica_ui-kit_components_slider_properties_min-max-label_font-size
201
+ );
202
+ font-style: var(
203
+ --recursica_ui-kit_components_slider_properties_min-max-label_font-style
204
+ );
205
+ font-weight: var(
206
+ --recursica_ui-kit_components_slider_properties_min-max-label_font-weight
207
+ );
208
+ letter-spacing: var(
209
+ --recursica_ui-kit_components_slider_properties_min-max-label_letter-spacing
210
+ );
211
+ line-height: var(
212
+ --recursica_ui-kit_components_slider_properties_min-max-label_line-height
213
+ );
214
+ text-decoration: var(
215
+ --recursica_ui-kit_components_slider_properties_min-max-label_text-decoration
216
+ );
217
+ text-transform: var(
218
+ --recursica_ui-kit_components_slider_properties_min-max-label_text-transform
219
+ );
220
+ }
221
+
157
222
  .sliderTooltip {
158
223
  background-color: var(--form-field-text-valued) !important;
159
224
  color: var(--form-field-background) !important;
@@ -398,14 +463,12 @@
398
463
 
399
464
  /* Focus states */
400
465
  /* The token schema's `active` state (not a separate `focus` state) now covers track/step-indicator
401
- color while focus is within the slider; there is no `active` override for the filled mark. */
402
- .sliderRoot:has(.sliderThumb:focus) .sliderTrack,
403
- .sliderRoot:focus-within .sliderTrack {
404
- background-color: var(
405
- --recursica_ui-kit_components_slider_variants_states_active_properties_colors_track
406
- );
407
- }
408
-
466
+ color while focus is within the slider. Unlike Mantine (whose unfilled track is painted by an
467
+ internal `::before` layer that this rule never actually reaches), MUI's rail element is styled
468
+ directly by `.sliderTrack`, so applying the active-track color here visibly repaints the rail a
469
+ different color for the whole duration of a drag. There is no design requirement for the rail to
470
+ change color while dragging, so the track is intentionally left out of this rule; only the
471
+ step-indicator (mark) is included, matching the token schema's remaining active-state coverage. */
409
472
  .sliderRoot:has(.sliderThumb:focus) .sliderMark,
410
473
  .sliderRoot:focus-within .sliderMark {
411
474
  background-color: var(
@@ -92,7 +92,7 @@ export const Disabled: Story = {
92
92
  label: "Decommissioned Server Node",
93
93
  assistiveText: "Modifications to this environment are frozen.",
94
94
  defaultValue: 35,
95
- disabled: false,
95
+ disabled: true,
96
96
  },
97
97
  };
98
98
 
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, useState, useEffect } from "react";
1
+ import React, { forwardRef, useState, useEffect, useRef } from "react";
2
2
  import {
3
3
  Slider as MuiSlider,
4
4
  type SliderProps as MuiSliderProps,
@@ -145,6 +145,36 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
145
145
  delete restRecord["color"];
146
146
  delete restRecord["wrapperProps"];
147
147
 
148
+ // MUI always programmatically re-focuses its hidden native input on pointer interaction,
149
+ // which browsers' `:focus-visible` heuristic treats as keyboard-visible — unlike Mantine's
150
+ // plain div thumb, where a real click correctly resolves to a non-visible focus. Track
151
+ // pointer-vs-keyboard ourselves so the focus ring only paints for genuine keyboard focus,
152
+ // matching Mantine (a later keypress while still focused reveals the ring, same as native).
153
+ const pointerDownRef = useRef(false);
154
+ const [suppressFocusRing, setSuppressFocusRing] = useState(false);
155
+ const externalOnMouseDown = (sanitizedProps as MuiSliderProps).onMouseDown;
156
+ const externalOnFocus = (sanitizedProps as MuiSliderProps).onFocus;
157
+ const externalOnBlur = (sanitizedProps as MuiSliderProps).onBlur;
158
+ const externalOnKeyDown = (sanitizedProps as MuiSliderProps).onKeyDown;
159
+
160
+ const handleThumbMouseDown = (e: React.MouseEvent<HTMLSpanElement>) => {
161
+ pointerDownRef.current = true;
162
+ externalOnMouseDown?.(e);
163
+ };
164
+ const handleThumbFocus = (e: React.FocusEvent<HTMLSpanElement>) => {
165
+ setSuppressFocusRing(pointerDownRef.current);
166
+ pointerDownRef.current = false;
167
+ externalOnFocus?.(e);
168
+ };
169
+ const handleThumbBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
170
+ setSuppressFocusRing(false);
171
+ externalOnBlur?.(e);
172
+ };
173
+ const handleThumbKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
174
+ setSuppressFocusRing(false);
175
+ externalOnKeyDown?.(e);
176
+ };
177
+
148
178
  // Securely map core native blocks down ensuring nested CSS modules map precisely
149
179
  const mergedClassNames: Partial<Record<string, string>> = {
150
180
  root: styles.sliderRoot,
@@ -226,6 +256,7 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
226
256
  data-form-layout={formLayout}
227
257
  data-disabled={disabled ? "true" : undefined}
228
258
  data-error={error ? "true" : undefined}
259
+ data-suppress-focus-ring={suppressFocusRing ? "true" : undefined}
229
260
  >
230
261
  {leadingIcon}
231
262
 
@@ -240,6 +271,10 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
240
271
  disabled={disabled}
241
272
  value={resolvedValue}
242
273
  onChange={handleValueChange}
274
+ onMouseDown={handleThumbMouseDown}
275
+ onFocus={handleThumbFocus}
276
+ onBlur={handleThumbBlur}
277
+ onKeyDown={handleThumbKeyDown}
243
278
  onChangeCommitted={
244
279
  onChangeEnd as unknown as (
245
280
  event: Event | React.SyntheticEvent,
@@ -2,3 +2,57 @@
2
2
 
3
3
  - **Compositional API Dropped:** Mantine manages stepper state and content via `<Stepper.Step>` and `<Stepper.Completed>`. MUI delegates content rendering to the developer and focuses purely on the stepper visual layout using `<Step>`, `<StepLabel>`, etc.
4
4
  - **Monolithic API Adopted:** Following architectural review, we have abandoned the fabricated context wrappers for `mui-adapter`. We now natively export `Stepper`, `Step`, `StepLabel`, `StepButton`, and `StepConnector` wrapping their `@mui/material` counterparts. Developers are expected to manage the active step logic and content rendering outside the `Stepper` component, consistent with MUI patterns. Storybook tests have been updated to reflect this divergence while retaining core visual compatibility.
5
+
6
+ ## Layout fixes (root-caused against mantine's visual reference)
7
+
8
+ Several parts of the original implementation used the correct-looking classes but wired
9
+ them to elements that never carry those classes at runtime, or copy-pasted comments/logic
10
+ from `mantine-adapter` that don't describe how MUI's Stepper actually works. Fixed:
11
+
12
+ - **Horizontal layout uses `alternativeLabel`:** MUI's own `Stepper` prop doc says
13
+ `alternativeLabel` positions the label under the icon — that's exactly Recursica's
14
+ horizontal layout, and it's also what makes `StepConnector`'s `alternativeLabel` variant
15
+ absolutely-position itself relative to each `Step`, centered on the icon. The adapter
16
+ previously never set this prop and instead tried to force column layout via CSS on
17
+ `Step`'s own root, which has no effect (`StepLabel`'s row-vs-column layout is driven
18
+ entirely by the `alternativeLabel` context flag, not by its parent's `flex-direction`).
19
+ Without it, labels rendered beside the icon (row layout) and the connector used the
20
+ non-`alternativeLabel` inline-flex positioning, both very visibly wrong.
21
+ - **Custom `StepIconComponent`:** MUI's default `StepIcon` draws its own self-contained
22
+ circle+check `CheckCircle` SVG (Material's `check_circle` glyph) whenever `completed` is
23
+ true, colored via `theme.palette.primary.main` (blue) — it never reads our tokens and its
24
+ SVG viewBox doesn't scale to `--stepper-indicator-size`. We now pass `RecursicaStepIcon`
25
+ (a plain span + our own inline check glyph, same convention as `Checkbox.tsx`'s `CheckIcon`)
26
+ so the token-driven circle (`.stepIconCircle`) is the only circle ever drawn, and the check
27
+ mark is sized via `--stepper-svg-size` and colored via the `completed-indicator-text` token.
28
+ - **`classes.labelContainer` was never mapped:** all of the `.stepBody` centering/max-width
29
+ CSS (copied verbatim from mantine, where `stepBody` is a real classNames key) was dead code
30
+ for MUI — `StepLabel` calls this slot `labelContainer`, not `stepBody`. Now mapped via
31
+ `classes={{ labelContainer: styles.stepBody }}`.
32
+ - **Vertical connecting line — real primitive doesn't fit, so it's drawn on `.stepIcon`:**
33
+ MUI's vertical `StepConnector` is a sibling with a fixed `minHeight: 24`; it can't stretch to
34
+ match a step's actual rendered height (e.g. a 2-line description), which is exactly why the
35
+ line floated disconnected from both circles. MUI's own answer to this is `StepContent`
36
+ (its `border-left` spans whatever height its content needs) — but Recursica intentionally
37
+ hides `.content` (steppers are structural-only, no per-step content in the DOM). Since
38
+ neither of MUI's two connecting-line primitives fits, the vertical connector is drawn as a
39
+ `.stepIcon::after` pseudo-element instead: `StepLabelRoot` gets `align-items: stretch` (a
40
+ real CSS stretch, not JS measurement) so `.stepIcon` (`iconContainer`) grows to match its
41
+ sibling label column's actual height, and the rail spans `top: indicator-size` down through
42
+ `bottom: calc(-1 * step-gap)` (the step's own padding-bottom, exactly reaching the next
43
+ step's icon). `Stepper.tsx` passes an empty `<></>` as the vertical `connector` since this
44
+ fully replaces its job. This requires zeroing MUI's own hardcoded
45
+ `StepLabelRoot` vertical `padding: 8px 0` (`.root.vertical .stepLabelRoot`) — leaving it in
46
+ place made the rail land 16px short of the next icon.
47
+ - **`:global()` was missing everywhere `.Mui-active`/`.Mui-completed`/`.MuiStepLabel-root` was
48
+ referenced:** in a CSS Module, a bare `.Mui-completed` selector gets scoped/hashed like any
49
+ other local class, so it silently never matches the real global class MUI stamps onto the
50
+ DOM (same pitfall documented in `Button.module.css`/`Switch.module.css` for `.Mui-disabled`/
51
+ `.Mui-checked`). This affected the label/description state-color rules (already present
52
+ before this fix) and the new separator/rail state-color rules — all now wrapped in
53
+ `:global(...)`. While fixing this, the description-color selectors were also corrected to
54
+ use `.stepBody:has(.stepLabel:global(.Mui-completed)) .stepDescription` — the prior
55
+ `:has(~ .Mui-completed)` could never match because `optional`/description renders _after_
56
+ the label in the DOM (not before), and `.MuiStepLabel-root.Mui-completed` could never match
57
+ because MUI only stamps the completed/active state class onto the `label`/`iconContainer`
58
+ slots, never onto `StepLabel`'s own root.