diff --git a/docs/pages/experiments/slider-gradient.tsx b/docs/pages/experiments/slider-gradient.tsx new file mode 100644 index 0000000000..92ae043f4d --- /dev/null +++ b/docs/pages/experiments/slider-gradient.tsx @@ -0,0 +1,389 @@ +import * as React from 'react'; +import { alpha } from '@mui/system'; +import * as Slider from '@base_ui/react/Slider2'; +import { percentToValue, roundValueToStep } from '@base_ui/react/useSlider2/utils'; +import { clamp } from '@base_ui/react/utils/clamp'; +import { BaseUIEvent } from '@base_ui/react/utils/BaseUI.types'; + +type Stop = { + color: string; + position: number; +}; + +const INITIAL_VALUES: Stop[] = [ + { color: '#833ab4', position: 0 }, + { color: '#fd1d1d', position: 50 }, + { color: '#fcb045', position: 100 }, +]; + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +export default function App() { + const trackDefaultPreventedRef = React.useRef(false); + const trackRef = React.useRef(null); + + const [values, setValues] = React.useState(INITIAL_VALUES); + const [openThumbIndex, setOpenThumbIndex] = React.useState(0); + + const thumbInputRef = React.useRef([]); + const activeStopRef = React.useRef(null); + const isDraggingRef = React.useRef(false); + + const insertNewValue = (newPosition: number) => { + const newValue = { color: '#000000', position: newPosition }; + const newValues = [...values, newValue].sort((a, b) => a.position - b.position); + setValues(newValues); + }; + + const removeValueByIndex = (index: number) => { + // console.log('remove by index:', index); + + const newValues = values.filter((_v, i) => i !== index); + + setValues(newValues); + + const { current: prevRefs } = thumbInputRef; + const newRefs = prevRefs.filter((_r, i) => i !== index); + + thumbInputRef.current = newRefs; + }; + + const handleValueChange = (newValue: number | number[], activeThumbIndex: number) => { + if (!Array.isArray(newValue)) { + console.error('array only!') + return; + } + + const activeStopColor = activeStopRef.current?.color ?? null; + // FIXME: bug happens if activeStopColor appears twice or more + const valuesWithoutActiveStop = values.filter(val => val.color !== activeStopColor); + // console.log('valuesWithoutActiveStop', JSON.stringify(valuesWithoutActiveStop)) + // console.log('newThumbIndex', activeThumbIndex); + + const newValues = [ + ...valuesWithoutActiveStop, + { + ...activeStopRef.current, + position: newValue[activeThumbIndex] + }, + ].sort((a, b) => a.position - b.position); + + // console.log('handleValueChange', newValues); + // @ts-ignore + setValues(newValues); + } + + const handlePointerDown = (event: BaseUIEvent) => { + if (event.target === trackRef.current) { + event.preventBaseUIHandler(); + trackDefaultPreventedRef.current = true; + } + }; + + const handlePointerUp = (event: BaseUIEvent) => { + if (trackDefaultPreventedRef.current === true) { + trackDefaultPreventedRef.current = false; + // console.log('offsetX/Y', event.nativeEvent.offsetX, event.nativeEvent.offsetY); + // console.log('clientX/Y', event.nativeEvent.clientX, event.nativeEvent.clientY); + const { current: track } = trackRef; + const { width, left } = track!.getBoundingClientRect(); + + const percent = (event.nativeEvent.offsetX - left) / width; + + let newValue = percentToValue(percent, 0, 100); + newValue = roundValueToStep(newValue, 1, 0); + newValue = clamp(newValue, 0, 100); + // console.log('onPointerUp insertNewValue:', newValue); + insertNewValue(newValue); + } + }; + + const gradient = `linear-gradient(to right ${values.reduce((acc, value) => { + const { color, position } = value; + return `${acc}, ${color} ${position}%`; + }, '')})`.trim(); + + return ( +
+ position)} + onValueChange={handleValueChange} + > +
background: {gradient}
+ } + ref={trackRef} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} + style={{ + background: gradient, + }} + > + {values.map(({ color }, index) => ( + >) => { + const currentIndex = Number(event.target.dataset.index); + if (Number.isInteger(currentIndex)) { + setOpenThumbIndex(currentIndex); + if (isDraggingRef.current === false) { + activeStopRef.current = values[currentIndex]; + } + } + }} + onBlur={() => { + if (isDraggingRef.current === false) { + activeStopRef.current = null; + } + }} + onPointerDown={event => { + isDraggingRef.current = true; + const currentIndex = Number(event.currentTarget.dataset.index); + // console.log('currentStop', values[currentIndex]) + if (Number.isInteger(currentIndex)) { + activeStopRef.current = values[currentIndex]; + } + }} + onPointerUp={() => { + isDraggingRef.current = false; + activeStopRef.current = null; + }} + ref={(node: HTMLElement | null) => { + if (node) { + thumbInputRef.current[index] = node; + } + }} + style={{ + backgroundColor: color, + }} + /> + ))} + +
+ +
+
+ Edit selected color +
+ +
+ Stops + {values.map(({ color, position }, index) => { + const setActive = () => setOpenThumbIndex(index); + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ { + const newValues = values.map((val, i) => { + if (i === index) { + return { + ...val, + color: event.target.value, + } + } + return val; + }) + setValues(newValues) + }} + /> + + + +
+ ); + })} +
+
+ +
+ ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +function Styles() { + const isDarkMode = false; + return ( + + ); +} diff --git a/packages/mui-base/src/useSlider2/useSlider2.ts b/packages/mui-base/src/useSlider2/useSlider2.ts index c7edf9a818..a4b3993cad 100644 --- a/packages/mui-base/src/useSlider2/useSlider2.ts +++ b/packages/mui-base/src/useSlider2/useSlider2.ts @@ -8,7 +8,7 @@ import { useControlled } from '../utils/useControlled'; import { useEventCallback } from '../utils/useEventCallback'; import { useForkRef } from '../utils/useForkRef'; import { useCompoundParent } from '../useCompound'; -import { valueToPercent } from './utils'; +import { percentToValue, roundValueToStep, valueToPercent } from './utils'; import { Mark, UseSliderParameters, UseSliderReturnValue } from './useSlider.types'; import { ThumbMetadata } from './useSliderThumb.types'; @@ -73,28 +73,6 @@ function focusThumb({ } } -function getDecimalPrecision(num: number) { - // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. - // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. - if (Math.abs(num) < 1) { - const parts = num.toExponential().split('e-'); - const matissaDecimalPart = parts[0].split('.')[1]; - return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); - } - - const decimalPart = num.toString().split('.')[1]; - return decimalPart ? decimalPart.length : 0; -} - -function percentToValue(percent: number, min: number, max: number) { - return (max - min) * percent + min; -} - -function roundValueToStep(value: number, step: number, min: number) { - const nearest = Math.round((value - min) / step) * step + min; - return Number(nearest.toFixed(getDecimalPrecision(step))); -} - function setValueIndex({ values, newValue, diff --git a/packages/mui-base/src/useSlider2/utils.ts b/packages/mui-base/src/useSlider2/utils.ts index 9886ee07f2..95927f20ba 100644 --- a/packages/mui-base/src/useSlider2/utils.ts +++ b/packages/mui-base/src/useSlider2/utils.ts @@ -1,3 +1,25 @@ +function getDecimalPrecision(num: number) { + // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. + // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. + if (Math.abs(num) < 1) { + const parts = num.toExponential().split('e-'); + const matissaDecimalPart = parts[0].split('.')[1]; + return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); + } + + const decimalPart = num.toString().split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} + +export function percentToValue(percent: number, min: number, max: number) { + return (max - min) * percent + min; +} + +export function roundValueToStep(value: number, step: number, min: number) { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(getDecimalPrecision(step))); +} + export function valueToPercent(value: number, min: number, max: number) { return ((value - min) * 100) / (max - min); }