diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index a1a7101c..3484160a 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -26,6 +26,7 @@ export * from "./popover"; export * from "./rating"; export * from "./select"; export * from "./sidebar"; +export * from "./slider"; export * from "./spinner"; export * from "./switch"; export { default as TabButtons } from "./tabButtons"; diff --git a/packages/frappe-ui-react/src/components/slider/index.ts b/packages/frappe-ui-react/src/components/slider/index.ts new file mode 100644 index 00000000..a721cc50 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/index.ts @@ -0,0 +1,2 @@ +export { default as Slider } from "./slider"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/slider/slider.stories.tsx b/packages/frappe-ui-react/src/components/slider/slider.stories.tsx new file mode 100644 index 00000000..9037d030 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/slider.stories.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import Slider from "./slider"; +import type { SliderProps, SliderRangeValue, SliderSingleValue } from "./types"; + +export default { + title: "Components/Slider", + component: Slider, + tags: ["autodocs"], + argTypes: { + min: { control: "number", description: "Minimum value of the slider" }, + max: { control: "number", description: "Maximum value of the slider" }, + step: { control: "number", description: "Step value for the slider" }, + range: { control: "boolean", description: "Enable range selection" }, + knob: { control: "boolean", description: "Show knob on the slider" }, + tooltip: { control: "boolean", description: "Show tooltip on the knob" }, + showValue: { control: "boolean", description: "Display current value" }, + size: { + control: { type: "select", options: ["sm", "md", "lg", "xl"] }, + description: "Size of the slider", + }, + value: { control: "object", description: "Current value of the slider" }, + disabled: { control: "boolean", description: "Disable the slider" }, + onChange: { + action: "changed", + description: "Callback function when the slider value changes", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + min: 0, + max: 100, + value: 70, + range: false, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ setValue(newValue)} + /> +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; + +export const Range: Story = { + args: { + min: 0, + max: 100, + value: { min: 30, max: 70 }, + range: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+

+ Min value: {(value as SliderRangeValue)?.min} +

+

+ Max value: {(value as SliderRangeValue)?.max} +

+
+
+ ); + }, +}; + +export const WithTooltip: Story = { + args: { + min: 0, + max: 100, + value: { min: 20, max: 80 }, + range: true, + tooltip: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+

+ Min value: {(value as SliderRangeValue)?.min} +

+

+ Max value: {(value as SliderRangeValue)?.max} +

+
+
+ ); + }, +}; + +export const WithValues: Story = { + args: { + min: 0, + max: 100, + value: 70, + tooltip: true, + showValue: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; + +export const WithoutKnob: Story = { + args: { + min: 0, + max: 100, + value: 70, + knob: false, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; diff --git a/packages/frappe-ui-react/src/components/slider/slider.tsx b/packages/frappe-ui-react/src/components/slider/slider.tsx new file mode 100644 index 00000000..b42cf4ff --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/slider.tsx @@ -0,0 +1,297 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { SliderProps } from "./types"; +import clsx from "clsx"; +import Tooltip from "../tooltip/tooltip"; + +const Slider = ({ + min, + max, + value, + step = 1, + range = false, + knob = true, + tooltip = false, + showValue = false, + size = "md", + disabled = false, + className, + onChange, +}: SliderProps) => { + const [minVal, setMinVal] = useState( + typeof value === "object" ? value.min : (value ?? min) + ); + const [maxVal, setMaxVal] = useState( + typeof value === "object" ? value.max : max + ); + const [showMinTooltip, setShowMinTooltip] = useState(false); + const [showMaxTooltip, setShowMaxTooltip] = useState(false); + const rangeRef = useRef(null); + const trackRef = useRef(null); + const tooltipHelperRef = useRef(null); + const [thumbWidth, setThumbWidth] = useState(16); + const [trackWidth, setTrackWidth] = useState(0); + + const trackHeightClasses = { + sm: "h-0.5", + md: "h-1", + lg: "h-2", + xl: "h-2.5", + }[size]; + + const fontSizeClasses = { + sm: "text-xs", + md: "text-xs", + lg: "text-base", + xl: "text-base", + }[size]; + + const thumbSizeClasses = { + sm: clsx( + "[&::-webkit-slider-thumb]:h-3.5", + "[&::-webkit-slider-thumb]:w-3.5", + "[&::-moz-range-thumb]:h-3.5", + "[&::-moz-range-thumb]:w-3.5" + ), + md: clsx( + "[&::-webkit-slider-thumb]:h-4", + "[&::-webkit-slider-thumb]:w-4", + "[&::-moz-range-thumb]:h-4", + "[&::-moz-range-thumb]:w-4" + ), + lg: clsx( + "[&::-webkit-slider-thumb]:h-5", + "[&::-webkit-slider-thumb]:w-5", + "[&::-moz-range-thumb]:h-5", + "[&::-moz-range-thumb]:w-5" + ), + xl: clsx( + "[&::-webkit-slider-thumb]:h-6", + "[&::-webkit-slider-thumb]:w-6", + "[&::-moz-range-thumb]:h-6", + "[&::-moz-range-thumb]:w-6" + ), + }[size]; + + const tooltipHelperClasses = { + sm: "h-3.5 w-3.5", + md: "h-4 w-4", + lg: "h-5 w-5", + xl: "h-6 w-6", + }[size]; + + const calculatePercent = useCallback( + (value: number) => { + if (!trackWidth) { + return ((value - min) / (max - min)) * 100; + } + + const percent = (value - min) / (max - min); + const availableWidth = trackWidth - thumbWidth; + const thumbOffset = thumbWidth / 2; + + return ((percent * availableWidth + thumbOffset) / trackWidth) * 100; + }, + [min, max, thumbWidth, trackWidth] + ); + + const minPercent = useMemo( + () => calculatePercent(minVal), + [minVal, calculatePercent] + ); + + const maxPercent = useMemo( + () => calculatePercent(maxVal), + [maxVal, calculatePercent] + ); + + // Measure thumb width and track width. + useLayoutEffect(() => { + if (!tooltipHelperRef.current || !trackRef.current) return; + + const thumbElement = tooltipHelperRef.current; + const trackElement = trackRef.current; + + const observer = new ResizeObserver(() => { + const newThumbWidth = knob ? thumbElement.offsetWidth : 0; + const newTrackWidth = trackElement.offsetWidth; + + setThumbWidth(newThumbWidth); + setTrackWidth(newTrackWidth); + }); + + observer.observe(thumbElement); + observer.observe(trackElement); + + return () => observer.disconnect(); + }, [size, knob]); + + const thumbClasses = clsx( + "w-full absolute outline-none appearance-none pointer-events-none", + "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:pointer-events-auto", + knob + ? disabled + ? "[&::-webkit-slider-thumb]:bg-surface-gray-4" + : "[&::-webkit-slider-thumb]:bg-white" + : "[&::-webkit-slider-thumb]:bg-transparent", + knob && "[&::-webkit-slider-thumb]:border-none", + knob && "[&::-webkit-slider-thumb]:rounded-full", + knob && "[&::-webkit-slider-thumb]:shadow-[0_0_1px_1px_#ced4da]", + !disabled && knob && "[&::-webkit-slider-thumb]:cursor-pointer", + knob && "[&::-webkit-slider-thumb]:relative", + + "[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:pointer-events-auto", + knob + ? disabled + ? "[&::-moz-range-thumb]:bg-surface-gray-4" + : "[&::-moz-range-thumb]:bg-white" + : "[&::-moz-range-thumb]:bg-transparent", + knob && "[&::-moz-range-thumb]:border-none", + knob && "[&::-moz-range-thumb]:rounded-full", + knob && "[&::-moz-range-thumb]:shadow-[0_0_1px_1px_#ced4da]", + !disabled && knob && "[&::-moz-range-thumb]:cursor-pointer", + knob && "[&::-moz-range-thumb]:relative", + thumbSizeClasses, + trackHeightClasses + ); + + useEffect(() => { + if (!range) return; + + if (rangeRef.current) { + rangeRef.current.style.left = `${minPercent}%`; + rangeRef.current.style.width = `${maxPercent - minPercent}%`; + } + }, [minPercent, maxPercent, range]); + + useEffect(() => { + if (range) { + onChange?.({ min: minVal, max: maxVal }); + } else { + onChange?.(minVal); + } + }, [minVal, maxVal, onChange, range]); + + const handleMinChange = (event: React.ChangeEvent) => { + const newValue = range + ? Math.min(Number(event.target.value), maxVal - step) + : Number(event.target.value); + setMinVal(newValue); + }; + + const handleMaxChange = (event: React.ChangeEvent) => { + const newValue = Math.max(Number(event.target.value), minVal + step); + setMaxVal(newValue); + }; + + return ( +
+
+
+ {range && ( + +
+
+
+ + )} + +
+
+
+ +
+ {range && ( + setShowMinTooltip(true)} + onPointerUp={() => setShowMinTooltip(false)} + onPointerLeave={() => setShowMinTooltip(false)} + disabled={disabled} + className={clsx(thumbClasses, minVal > max - 100 ? "z-5" : "z-3")} + aria-label="Minimum value" + /> + )} + setShowMaxTooltip(true)} + onPointerUp={() => setShowMaxTooltip(false)} + onPointerLeave={() => setShowMaxTooltip(false)} + disabled={disabled} + className={clsx(thumbClasses, "z-4")} + aria-label={range ? "Maximum value" : "Value"} + /> +
+
+
+
+
+ {showValue && ( +
+
{min}
+
{max}
+
+ )} +
+ ); +}; + +export default Slider; diff --git a/packages/frappe-ui-react/src/components/slider/tests/slider.tsx b/packages/frappe-ui-react/src/components/slider/tests/slider.tsx new file mode 100644 index 00000000..8812b44c --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/tests/slider.tsx @@ -0,0 +1,161 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Slider from "../slider"; + +// Mock ResizeObserver globally +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe("Slider Component", () => { + describe("Single value slider", () => { + it("renders with required props and attributes", () => { + render(); + const slider = screen.getByRole("slider"); + expect(slider).toBeInTheDocument(); + expect(slider).toHaveValue("50"); + expect(slider).toHaveAttribute("min", "0"); + expect(slider).toHaveAttribute("max", "100"); + expect(slider).toHaveAttribute("step", "1"); + }); + + it("handles value change", () => { + const handleChange = jest.fn(); + render(); + + const slider = screen.getByRole("slider"); + fireEvent.change(slider, { target: { value: "75" } }); + + expect(slider).toHaveValue("75"); + expect(handleChange).toHaveBeenCalledWith(75); + }); + + it("displays min/max values when showValue is true", () => { + render(); + + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + }); + }); + + describe("Range slider", () => { + it("renders two inputs with correct values", () => { + render(); + + const minSlider = screen.getByLabelText("Minimum value"); + const maxSlider = screen.getByLabelText("Maximum value"); + + expect(minSlider).toHaveValue("25"); + expect(maxSlider).toHaveValue("75"); + }); + + it("prevents min value from exceeding max value", () => { + const handleChange = jest.fn(); + render( + + ); + + const minSlider = screen.getByLabelText("Minimum value"); + fireEvent.change(minSlider, { target: { value: "80" } }); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ min: 74, max: 75 }) + ); + }); + + it("prevents max value from going below min value", () => { + const handleChange = jest.fn(); + render( + + ); + + const maxSlider = screen.getByLabelText("Maximum value"); + fireEvent.change(maxSlider, { target: { value: "20" } }); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ min: 25, max: 26 }) + ); + }); + }); + + describe("Disabled state", () => { + it("disables slider input", () => { + render(); + const slider = screen.getByRole("slider"); + expect(slider).toBeDisabled(); + }); + }); + + describe("Tooltip functionality", () => { + it("shows and hides tooltip on pointer events", async () => { + render(); + + const input = screen.getByLabelText("Value"); + fireEvent.pointerDown(input); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent("50"); + + fireEvent.pointerUp(input); + + await waitFor(() => { + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + it("does not show tooltip when disabled", () => { + render(); + + const input = screen.getByLabelText("Value"); + fireEvent.pointerDown(input); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + describe("Knob visibility", () => { + it("hides knob when knob={false}", () => { + const { container } = render( + + ); + const sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass( + "[&::-webkit-slider-thumb]:bg-transparent" + ); + expect(sliderInput).toHaveClass("[&::-moz-range-thumb]:bg-transparent"); + }); + }); + + describe("Size variants", () => { + it("applies size classes correctly", () => { + const { container, rerender } = render( + + ); + let sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass("[&::-webkit-slider-thumb]:h-3.5"); + + rerender(); + sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass("[&::-webkit-slider-thumb]:h-6"); + }); + }); +}); diff --git a/packages/frappe-ui-react/src/components/slider/types.ts b/packages/frappe-ui-react/src/components/slider/types.ts new file mode 100644 index 00000000..44335940 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/types.ts @@ -0,0 +1,18 @@ +export type SliderSingleValue = number; +export type SliderRangeValue = { min: number; max: number }; +export type SliderValue = SliderSingleValue | SliderRangeValue; + +export interface SliderProps { + min: number; + max: number; + step?: number; + range?: boolean; + knob?: boolean; + tooltip?: boolean; + showValue?: boolean; + size?: "sm" | "md" | "lg" | "xl"; + value?: SliderValue; // number for single value, object for range. + disabled?: boolean; + className?: string; + onChange?: (value: SliderValue) => void; +} diff --git a/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx b/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx index e2380420..7699ebd8 100644 --- a/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx +++ b/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx @@ -11,6 +11,7 @@ const TooltipComponent: React.FC = ({ hoverDelay = 0.5, arrowClass = "fill-surface-gray-7", disabled = false, + open, }) => { const delayDuration = useMemo(() => hoverDelay * 1000, [hoverDelay]); @@ -36,7 +37,7 @@ const TooltipComponent: React.FC = ({ return ( - + {tooltipContent && ( diff --git a/packages/frappe-ui-react/src/components/tooltip/types.ts b/packages/frappe-ui-react/src/components/tooltip/types.ts index b7033d25..83b61b89 100644 --- a/packages/frappe-ui-react/src/components/tooltip/types.ts +++ b/packages/frappe-ui-react/src/components/tooltip/types.ts @@ -10,4 +10,5 @@ export interface TooltipProps { hoverDelay?: number; // In seconds arrowClass?: string; disabled?: boolean; + open?: boolean; }