Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/base/Slider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.2.0",
"@toptal/picasso-shared": "15.0.0",
"@toptal/picasso-tooltip": "2.0.5",
"@toptal/picasso-utils": "4.0.0"
Expand Down
208 changes: 159 additions & 49 deletions packages/base/Slider/src/Slider/Slider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// import type { ComponentProps } from 'react'
import React, { forwardRef, useRef } from 'react'
import { Slider as MUIBaseSlider } from '@mui/base/Slider'
import type { ReactNode } from 'react'
import React, { forwardRef, useMemo, useRef } from 'react'
import { Slider as BaseUISlider } from '@base-ui/react/slider'
import { useCombinedRefs, useOnScreen } from '@toptal/picasso-utils'
import { twJoin, twMerge } from '@toptal/picasso-tailwind-merge'
import type { BaseProps } from '@toptal/picasso-shared'
Expand Down Expand Up @@ -56,7 +56,64 @@ export interface Props extends BaseProps {
id?: string
}

export const Slider = forwardRef<HTMLElement, Props>(function Slider(
const computeMarkValues = ({
marks,
min,
max,
step,
}: {
marks: boolean | undefined
min: number
max: number
step?: number
}): number[] => {
if (!marks) {
return []
}
const inc = step && step > 0 ? step : 1
const values: number[] = []

for (let next = min; next <= max; next += inc) {
values.push(next)
}

return values
}

const isMarkActive = (
markValue: number,
value: number | number[] | undefined
): boolean => {
if (value === undefined || value === null) {
return false
}
if (Array.isArray(value)) {
const [first, second] = value
const lo = Math.min(first, second)
const hi = Math.max(first, second)

return markValue >= lo && markValue <= hi
}

return markValue <= value
}

const formatValue = (
raw: number,
index: number,
tooltipFormat: Props['tooltipFormat']
): ReactNode => {
if (typeof tooltipFormat === 'function') {
return tooltipFormat(raw, index)
}
if (typeof tooltipFormat === 'string') {
return tooltipFormat
}

return raw
}

export const Slider = forwardRef<HTMLDivElement, Props>(function Slider(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public ref type is narrowed here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Restored forwardRef<HTMLElement, Props>. Internally still need the narrower HTMLDivElement for Base UI's Slider.Root, but it's a type-only cast at that boundary now: sliderRef as React.Ref<HTMLDivElement>. Fixed in 5f82fe8.

{ defaultValue = 0, min = 0, max = 100, tooltip = 'off', ...props },
ref
) {
Expand All @@ -79,7 +136,10 @@ export const Slider = forwardRef<HTMLElement, Props>(function Slider(
'data-testid': dataTestid,
} = props
const containerRef = useRef<HTMLDivElement>(null)
const sliderRef = useCombinedRefs<HTMLElement>(ref, useRef<HTMLElement>(null))
const sliderRef = useCombinedRefs<HTMLDivElement>(
ref,
useRef<HTMLDivElement>(null)
)

// The rootMargin is not working correctly in the storybooks iframe
// To test properly we can open the iframe in new window
Expand All @@ -98,69 +158,119 @@ export const Slider = forwardRef<HTMLElement, Props>(function Slider(
const isThumbHidden =
hideThumbOnEmpty && (typeof value === 'undefined' || value === null)

const markValues = useMemo(
() => computeMarkValues({ marks, min, max, step }),
[marks, min, max, step]
)

const thumbCount = Array.isArray(value)
? value.length
: Array.isArray(defaultValue)
? defaultValue.length
: 1

const handleValueChange = (
newValue: number | readonly number[],
eventDetails: BaseUISlider.Root.ChangeEventDetails
) => {
if (!onChange) {
return
}

const mapped = Array.isArray(newValue)
? ([...newValue] as number[])
: (newValue as number)

onChange(
eventDetails.event as unknown as Event,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as unknown as T as is anti-pattern

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Removed the double cast. eventDetails.event is typed as InputEvent | Event | PointerEvent | MouseEvent | TouchEvent | KeyboardEvent, all DOM Event subclasses, so direct assignment to the Event parameter compiles without any cast. Fixed in 5f82fe8.

mapped,
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',
'-mt-[7px] -ml-[6px] outline-0 [&_input]:outline-none absolute transition-shadow cursor-pointer',
'!translate-none contain-layout',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forbidden to use !important.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Dropped the !translate-none Tailwind class. Now overriding via a hoisted style constant passed to Slider.Thumb's style prop — Base UI's mergeProps puts the user style after its own {translate:'-50% -50%'}, so it wins without !important. Fixed in 5f82fe8.

isThumbHidden && 'hidden'
)

return (
<div
ref={containerRef}
className={twMerge('my-[6px] mx-0', className)}
style={style}
>
<MUIBaseSlider
<BaseUISlider.Root
ref={sliderRef}
defaultValue={defaultValue}
value={value}
min={min}
max={max}
step={step}
marks={marks}
disabled={disabled}
data-testid={dataTestid}
data-private={dataPrivate}
onFocus={onFocus}
onBlur={onBlur}
onValueChange={handleValueChange}
name={name}
id={id}
slots={{
mark: SliderMark,
valueLabel: SliderValueLabel,
}}
slotProps={{
mark: {
// @ts-expect-error we have custom Mark component, where we extend props and MUI does not understand it
forceInactive: disableTrackHighlight,
},
root: {
className:
'block cursor-pointer width-full relative py-[6px] -my-[6px]',
},
rail: {
className:
'block absolute w-full h-[1px] opacity-[0.24] rounded-none bg-gray-500',
},
thumb: {
className: twJoin(
'group/thumb flex justify-center items-center w-[15px] h-[15px]',
'rounded-[50%] bg-blue-500 border-[2px] border-solid border-white',
'-mt-[7px] -ml-[6px] outline-0 absolute transition-shadow cursor-pointer',
isThumbHidden && 'hidden'
),
role: 'slider',
},
track: {
className: twJoin(
'block absolute h-[1px]',
disableTrackHighlight ? 'bg-gray-200' : 'bg-blue-500'
),
},
valueLabel: {
tooltip: isObserved ? tooltip : 'off',
onRender: handleValueLabelOnRender,
yPlacement: isOnScreen ? 'top' : 'bottom',
isOverlaped: isPartiallyOverlapped,
},
}}
valueLabelFormat={tooltipFormat}
onChange={onChange}
/>
className='block cursor-pointer width-full relative py-[6px] -my-[6px]'
>
<BaseUISlider.Control className='block absolute inset-0'>
<BaseUISlider.Track className='block absolute w-full h-[1px] top-1/2 rounded-none bg-gray-500/24'>
Comment thread
narghev marked this conversation as resolved.
<BaseUISlider.Indicator
className={twJoin(
'block h-[1px]',
disableTrackHighlight ? 'bg-gray-200' : 'bg-blue-500'
)}
/>
</BaseUISlider.Track>
{markValues.map((markValue, index) => {
const percent = ((markValue - min) / (max - min)) * 100

return (
<SliderMark
key={markValue}
data-index={index}
value={value}
markActive={isMarkActive(markValue, value)}
forceInactive={!!disableTrackHighlight}
style={{ left: `${percent}%` }}
/>
)
})}
{(thumbCount === 2 ? ['range-low', 'range-high'] : ['single']).map(
(thumbKey, index) => {
const currentVal = Array.isArray(value)
? (value[index] as number)
: (value as number | undefined) ?? min

return (
<BaseUISlider.Thumb
key={thumbKey}
index={index}
className={thumbClassName}
role='slider'
>
<SliderValueLabel
index={index}
value={currentVal}
tooltip={isObserved ? tooltip : 'off'}
yPlacement={isOnScreen ? 'top' : 'bottom'}
isOverlaped={isPartiallyOverlapped}
onRender={handleValueLabelOnRender}
>
{formatValue(currentVal, index, tooltipFormat)}
</SliderValueLabel>
</BaseUISlider.Thumb>
)
}
)}
</BaseUISlider.Control>
</BaseUISlider.Root>
</div>
)
})
Expand Down
Loading
Loading