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