diff --git a/.changeset/slider-migration.md b/.changeset/slider-migration.md
new file mode 100644
index 0000000000..44352b0031
--- /dev/null
+++ b/.changeset/slider-migration.md
@@ -0,0 +1,11 @@
+---
+'@toptal/picasso-slider': major
+---
+
+### Slider
+
+- Migrate internals from @mui/base to @base-ui/react (behavioral parity)
+- Rebuild marks and value-label rendering on top of @base-ui/react's compound parts (Slider.Root + Slider.Control + Slider.Track + Slider.Indicator + Slider.Thumb)
+- Remove `@mui/base` peer dependency; add `@base-ui/react` peer dependency
+- React peer range lifted: upper bound dropped (`>=16.12.0 < 19.0.0` → `>=16.12.0`), enabling React 19 consumers
+- **DOM structure change**: the root element is now a `
` (was `
` in @mui/base). Nesting depth increases by two levels — all slider content now lives inside Control > Track wrappers rather than directly under the root. Snapshots that assert on the DOM tree will need updating.
diff --git a/.gitignore b/.gitignore
index 1cefe9a415..43aa1750da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,9 @@ migration-runs
# debris example.
.playwright-mcp
+# Per-sweep temp files the review-response agent writes for `gh pr comment
+# --body-file` (sandbox blocks shell-redirection to outside the worktree
+# and blocks rm/mv, so the file is written here, consumed by gh, and left
+# behind). Same rationale as .playwright-mcp.
+*.tmp.md
+
diff --git a/packages/base/Slider/package.json b/packages/base/Slider/package.json
index 478732abbd..383e49c2a4 100644
--- a/packages/base/Slider/package.json
+++ b/packages/base/Slider/package.json
@@ -22,7 +22,7 @@
},
"homepage": "https://github.com/toptal/picasso/tree/master/packages/picasso#readme",
"dependencies": {
- "@mui/base": "5.0.0-beta.58",
+ "@base-ui/react": "^1.4.1",
"@toptal/picasso-shared": "15.0.0",
"@toptal/picasso-tooltip": "2.0.5",
"@toptal/picasso-utils": "4.0.0"
@@ -32,7 +32,7 @@
"@toptal/picasso-tailwind-merge": "^2.0.0",
"@toptal/picasso-provider": "*",
"@toptal/picasso-tailwind": ">2.1.0",
- "react": ">=16.12.0 < 19.0.0"
+ "react": ">=16.12.0"
},
"exports": {
".": "./dist-package/src/index.js"
diff --git a/packages/base/Slider/src/Slider/Slider.tsx b/packages/base/Slider/src/Slider/Slider.tsx
index 91774370d0..aa76a2b474 100644
--- a/packages/base/Slider/src/Slider/Slider.tsx
+++ b/packages/base/Slider/src/Slider/Slider.tsx
@@ -1,7 +1,7 @@
-// import type { ComponentProps } from 'react'
import React, { forwardRef, useRef } from 'react'
-import { Slider as MUIBaseSlider } from '@mui/base/Slider'
-import { useCombinedRefs, useOnScreen } from '@toptal/picasso-utils'
+import { Slider as BaseUISlider } from '@base-ui/react/slider'
+import type { SliderRootChangeEventDetails } from '@base-ui/react/slider'
+import { useOnScreen } from '@toptal/picasso-utils'
import { twJoin, twMerge } from '@toptal/picasso-tailwind-merge'
import type { BaseProps } from '@toptal/picasso-shared'
@@ -56,7 +56,88 @@ export interface Props extends BaseProps {
id?: string
}
-export const Slider = forwardRef(function Slider(
+const formatValueLabel = (
+ value: number,
+ index: number,
+ format?: string | ((value: number, index: number) => React.ReactNode)
+): React.ReactNode => {
+ if (typeof format === 'function') {
+ return format(value, index)
+ }
+
+ if (typeof format === 'string') {
+ return format
+ }
+
+ return value
+}
+
+const generateMarkPositions = (
+ min: number,
+ max: number,
+ step: number
+): number[] => {
+ const positions: number[] = []
+ const range = max - min
+
+ if (range <= 0 || step <= 0) {
+ return positions
+ }
+
+ for (let mark = min; mark <= max; mark += step) {
+ positions.push(mark)
+ }
+
+ return positions
+}
+
+const resolveThumbValues = (
+ value: number | number[] | undefined,
+ defaultValue: number | number[],
+ min: number
+): number[] => {
+ if (Array.isArray(value)) {
+ return value
+ }
+
+ if (typeof value === 'number') {
+ return [value]
+ }
+
+ if (Array.isArray(defaultValue)) {
+ return defaultValue
+ }
+
+ return [typeof defaultValue === 'number' ? defaultValue : min]
+}
+
+// base-ui's Slider.Thumb renders a hidden with
+// position:fixed; height/width:100% for VoiceOver focus-indicator sizing.
+// Happo's DOM-snapshot renderer treats position:fixed as in-flow, making the
+// body as tall as the viewport (543px vs the expected ~60px). Restore the
+// compact absolute positioning that @mui/base used.
+const resetInputRef = (node: HTMLInputElement | null) => {
+ if (node) {
+ node.style.position = 'absolute'
+ node.style.width = '1px'
+ node.style.height = '1px'
+ node.style.top = 'auto'
+ node.style.left = 'auto'
+ }
+}
+
+// base-ui applies `translate: -50% -50%` inline on the thumb to centre it on
+// the track. That CSS transform creates a GPU compositing layer absent in the
+// old @mui/base implementation (which used negative margins instead), causing
+// subtle antialiasing differences that Happo detects. Override with no
+// transform and replicate @mui/base's manual top offset (-7px = half of the
+// 15px thumb minus the 0.5px track centre).
+const thumbPositionStyle: React.CSSProperties = {
+ translate: 'none',
+ top: '-7px',
+}
+
+export const Slider = forwardRef(function Slider(
{ defaultValue = 0, min = 0, max = 100, tooltip = 'off', ...props },
ref
) {
@@ -64,7 +145,7 @@ export const Slider = forwardRef(function Slider(
marks,
value,
tooltipFormat,
- step,
+ step = 1,
disabled,
onChange,
onBlur,
@@ -79,7 +160,6 @@ export const Slider = forwardRef(function Slider(
'data-testid': dataTestid,
} = props
const containerRef = useRef(null)
- const sliderRef = useCombinedRefs(ref, useRef(null))
// The rootMargin is not working correctly in the storybooks iframe
// To test properly we can open the iframe in new window
@@ -98,69 +178,115 @@ export const Slider = forwardRef(function Slider(
const isThumbHidden =
hideThumbOnEmpty && (typeof value === 'undefined' || value === null)
+ const thumbValues = resolveThumbValues(value, defaultValue, min)
+
+ const handleValueChange = (
+ newValue: number | readonly number[],
+ eventDetails: SliderRootChangeEventDetails
+ ) => {
+ if (!onChange) {
+ return
+ }
+
+ const normalizedValue: number | number[] = Array.isArray(newValue)
+ ? [...newValue]
+ : (newValue as number)
+
+ onChange(eventDetails.event, normalizedValue, eventDetails.activeThumbIndex)
+ }
+
+ const thumbClassName = twJoin(
+ 'group/thumb flex justify-center items-center w-[15px] h-[15px]',
+ 'rounded-[50%] bg-blue-500 border-[2px] border-solid border-white',
+ 'outline-0 absolute transition-shadow cursor-pointer ml-[-6px]',
+ isThumbHidden && 'hidden'
+ )
+
+ const indicatorClassName = twJoin(
+ 'block h-[1px]',
+ disableTrackHighlight ? 'bg-gray-200' : 'bg-blue-500'
+ )
+
+ const markPositions = marks ? generateMarkPositions(min, max, step) : []
+
return (
-
+ onValueChange={handleValueChange}
+ className='block w-full relative py-[6px] -my-[6px]'
+ >
+ {/* py/-my also on Control because @base-ui/react attaches
+ pointerdown to SliderControl (not Root); Root's padding alone
+ wouldn't make the 12px-tall click target hot. */}
+
+ {/* mb-[-1px] zeroes Track's 1px contribution to Control.contentBox,
+ matching @mui/base which rendered the rail as `position: absolute`
+ (0px layout impact). Without it, our wrapper renders 1px taller
+ than master, shifting the thumb 1px upward. */}
+
+
+ {marks &&
+ markPositions.map((markValue, index) => (
+
+ ))}
+ {thumbValues.map((thumbValue, index) => {
+ const thumbId = index === 0 ? id : undefined
+ const label = formatValueLabel(thumbValue, index, tooltipFormat)
+
+ return (
+
+
+ {label}
+
+
+ )
+ })}
+
+
+
)
})
diff --git a/packages/base/Slider/src/Slider/__snapshots__/test.tsx.snap b/packages/base/Slider/src/Slider/__snapshots__/test.tsx.snap
index 15ad979d69..8d3052852f 100644
--- a/packages/base/Slider/src/Slider/__snapshots__/test.tsx.snap
+++ b/packages/base/Slider/src/Slider/__snapshots__/test.tsx.snap
@@ -5,46 +5,60 @@ exports[`Slider renders 1`] = `
+
+
`;
@@ -54,46 +68,60 @@ exports[`Slider with initial value 1`] = `
+
+
`;
diff --git a/packages/base/Slider/src/Slider/test.tsx b/packages/base/Slider/src/Slider/test.tsx
index 659bbbf7c4..15f8fb5dab 100644
--- a/packages/base/Slider/src/Slider/test.tsx
+++ b/packages/base/Slider/src/Slider/test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, it } from '@jest/globals'
+import { describe, expect, it, beforeAll, beforeEach } from '@jest/globals'
import React from 'react'
import type { RenderResult } from '@testing-library/react'
import { render } from '@testing-library/react'
@@ -7,6 +7,22 @@ import type { OmitInternalProps } from '@toptal/picasso-shared'
import type { Props } from './Slider'
import { Slider } from './Slider'
+class IntersectionObserverMock {
+ callback: (entries: Partial[]) => void
+
+ constructor(callback: (entries: Partial[]) => void) {
+ this.callback = callback
+ }
+
+ observe(element: Element) {
+ this.callback([{ isIntersecting: true, target: element }])
+ }
+
+ unobserve() {}
+
+ disconnect() {}
+}
+
const renderSlider = (props: OmitInternalProps) => {
const { value } = props
@@ -16,6 +32,14 @@ const renderSlider = (props: OmitInternalProps) => {
describe('Slider', () => {
let api: RenderResult
+ beforeAll(() => {
+ Object.defineProperty(window, 'IntersectionObserver', {
+ writable: true,
+ configurable: true,
+ value: IntersectionObserverMock,
+ })
+ })
+
beforeEach(() => {
api = renderSlider({})
})
diff --git a/packages/base/Slider/src/SliderMark/SliderMark.tsx b/packages/base/Slider/src/SliderMark/SliderMark.tsx
index 7b6db22a00..19d86e3308 100644
--- a/packages/base/Slider/src/SliderMark/SliderMark.tsx
+++ b/packages/base/Slider/src/SliderMark/SliderMark.tsx
@@ -4,29 +4,46 @@ import { twJoin } from '@toptal/picasso-tailwind-merge'
import { getBgColor } from '../utils'
export type SliderMarkProps = {
- markActive: boolean
- ownerState: { value: number }
- style: React.CSSProperties
- 'data-index': number
+ index: number
+ markValue: number
+ sliderValue?: number | number[]
+ positionPercent: number
forceInactive: boolean
}
+const isMarkActive = (
+ markValue: number,
+ value?: number | number[]
+): boolean => {
+ if (Array.isArray(value)) {
+ return markValue >= value[0] && markValue <= value[1]
+ }
+
+ if (typeof value === 'number') {
+ return markValue <= value
+ }
+
+ return false
+}
+
// We need custom Mark component because we have
// different bg color based on value of the Slider
const SliderMark = ({
- markActive,
- ownerState,
- 'data-index': dataIndex,
- style,
+ index,
+ markValue,
+ sliderValue,
+ positionPercent,
forceInactive,
}: SliderMarkProps) => {
+ const markActive = isMarkActive(markValue, sliderValue)
+
return (
)
diff --git a/packages/base/Slider/src/SliderValueLabel/SliderValueLabel.tsx b/packages/base/Slider/src/SliderValueLabel/SliderValueLabel.tsx
index 9de8b83af2..d8c0f47e76 100644
--- a/packages/base/Slider/src/SliderValueLabel/SliderValueLabel.tsx
+++ b/packages/base/Slider/src/SliderValueLabel/SliderValueLabel.tsx
@@ -1,5 +1,4 @@
-import type { SliderValueLabelSlotProps } from '@mui/base/Slider'
-import type { RefObject } from 'react'
+import type { ReactNode, RefObject } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { twJoin } from '@toptal/picasso-tailwind-merge'
@@ -26,21 +25,26 @@ const yPlacementClasses = {
top: 'bottom-[calc(100%+2px)]',
} as const
-const SliderValueLabel = ({
- children,
- index = -1,
- tooltip = 'off',
- onRender,
- yPlacement,
- isOverlaped,
- ownerState: { value },
-}: SliderValueLabelSlotProps & {
+export type SliderValueLabelProps = {
+ children: ReactNode
+ index: number
+ value: number
tooltip: ValueLabelDisplay
yPlacement: 'top' | 'bottom'
/** indicates if there are two SliderValueLabels that overlap each other */
isOverlaped: boolean
onRender: (index: number, ref: RefObject) => void
-}) => {
+}
+
+const SliderValueLabel = ({
+ children,
+ index,
+ value,
+ tooltip,
+ onRender,
+ yPlacement,
+ isOverlaped,
+}: SliderValueLabelProps) => {
const ref = useRef(null)
// we need to change the placement of the label if it is overlaped
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc70d2fc8c..cf05a553eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2633,9 +2633,9 @@ importers:
packages/base/Slider:
dependencies:
- '@mui/base':
- specifier: 5.0.0-beta.58
- version: 5.0.0-beta.58(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@base-ui/react':
+ specifier: ^1.4.1
+ version: 1.4.1(@types/react@17.0.39)(date-fns@2.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@toptal/picasso-shared':
specifier: 15.0.0
version: link:../../shared