diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index f93554d6f5..13b2b77e33 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -22,6 +22,7 @@ export * from './ui/popover'; export * from './ui/radio'; export * from './ui/scrollbar'; export * from './ui/skeleton'; +export * from './ui/slider'; export * from './ui/switch'; export * from './ui/table'; export * from './ui/tabs'; diff --git a/packages/frontend/component/src/ui/slider/index.css.ts b/packages/frontend/component/src/ui/slider/index.css.ts new file mode 100644 index 0000000000..d83ac9c9df --- /dev/null +++ b/packages/frontend/component/src/ui/slider/index.css.ts @@ -0,0 +1,54 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const sliderContainerStyle = style({ + position: 'relative', + width: '250px', + height: '16px', +}); + +export const trackStyle = style({ + width: '100%', + height: '1px', + backgroundColor: cssVarV2('layer/border'), + position: 'relative', + display: 'flex', + justifyContent: 'space-between', +}); + +export const filledTrackStyle = style({ + height: '100%', + backgroundColor: cssVarV2('icon/primary'), + borderRadius: '1px', + position: 'absolute', + top: '0', + left: '0', +}); + +export const thumbStyle = style({ + width: '8px', + height: '8px', + backgroundColor: cssVarV2('icon/primary'), + borderRadius: '50%', + position: 'absolute', + top: '50%', + transform: 'translate(-50%, -50%)', + cursor: 'pointer', +}); + +export const nodeStyle = style({ + width: '4px', + height: '4px', + border: '2px solid transparent', + backgroundColor: cssVarV2('layer/border'), + borderRadius: '50%', + position: 'absolute', + top: '50%', + cursor: 'pointer', + transform: 'translate(-50%, -50%)', + selectors: { + '&[data-active="true"]': { + backgroundColor: cssVarV2('icon/primary'), + }, + }, +}); diff --git a/packages/frontend/component/src/ui/slider/index.ts b/packages/frontend/component/src/ui/slider/index.ts new file mode 100644 index 0000000000..eb0742f801 --- /dev/null +++ b/packages/frontend/component/src/ui/slider/index.ts @@ -0,0 +1 @@ +export * from './slider'; diff --git a/packages/frontend/component/src/ui/slider/slider.stories.tsx b/packages/frontend/component/src/ui/slider/slider.stories.tsx new file mode 100644 index 0000000000..e7cbd0ed09 --- /dev/null +++ b/packages/frontend/component/src/ui/slider/slider.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryFn } from '@storybook/react'; + +import type { SliderProps } from './index'; +import { Slider } from './index'; + +export default { + title: 'UI/Slider', + component: Slider, +} satisfies Meta; + +const Template: StoryFn = args => ; + +export const Default: StoryFn = Template.bind(undefined); +Default.args = {}; diff --git a/packages/frontend/component/src/ui/slider/slider.tsx b/packages/frontend/component/src/ui/slider/slider.tsx new file mode 100644 index 0000000000..be12a7278c --- /dev/null +++ b/packages/frontend/component/src/ui/slider/slider.tsx @@ -0,0 +1,101 @@ +import type { KeyboardEvent, MouseEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import * as styles from './index.css'; + +export interface SliderProps { + value?: number; + onChange?: (value: number) => void; + min?: number; + max?: number; + step?: number; + nodes?: number[]; // The values where the nodes should be placed +} + +export const Slider = ({ + value: propValue = 50, + onChange, + min = 0, + max = 100, + step = 1, + nodes = [20, 40, 60, 80], +}: SliderProps) => { + const [value, setValue] = useState(propValue); + const sliderRef = useRef(null); + + useEffect(() => { + setValue(propValue); + }, [propValue]); + + const handleThumbMove = useCallback( + (e: MouseEvent) => { + if (!sliderRef.current) return; + + const sliderRect = sliderRef.current.getBoundingClientRect(); + const newValue = + ((e.clientX - sliderRect.left) / sliderRect.width) * (max - min) + min; + const clampedValue = Math.min( + max, + Math.max(min, Math.round(newValue / step) * step) + ); + + setValue(clampedValue); + if (onChange) { + onChange(clampedValue); + } + }, + [max, min, onChange, step] + ); + + const handleThumbKeyDown = useCallback( + (e: KeyboardEvent) => { + let newValue = value; + if (e.key === 'ArrowLeft') { + newValue = Math.max(min, value - step); + } else if (e.key === 'ArrowRight') { + newValue = Math.min(max, value + step); + } + setValue(newValue); + if (onChange) { + onChange(newValue); + } + }, + [max, min, onChange, step, value] + ); + const thumbPosition = useMemo( + () => ((value - min) / (max - min)) * 100, + [max, min, value] + ); + return ( +
+
+
+ {nodes.map((nodeValue, index) => ( +
= nodeValue} + style={{ + left: `${((nodeValue - min) / (max - min)) * 100}%`, + }} + /> + ))} +
e.stopPropagation()} + onMouseMove={handleThumbMove} + onKeyDown={handleThumbKeyDown} + tabIndex={0} + /> +
+
+ ); +};