// ~/src/siteplane/components/slider/slider.tsx "use client"; import { Slider as SliderPrimitive } from "@base-ui/react/slider"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; import { type SiteplaneControlSize, siteplaneUIDefaults, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; const sliderControlVariants = cva( "relative flex cursor-pointer touch-none select-none active:cursor-grabbing before:absolute before:content-[''] data-disabled:cursor-not-allowed data-[orientation=horizontal]:w-full data-[orientation=horizontal]:items-center data-[orientation=horizontal]:before:inset-x-0 data-[orientation=vertical]:h-full data-[orientation=vertical]:items-center data-[orientation=vertical]:flex-col data-[orientation=vertical]:before:inset-y-0", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "data-[orientation=horizontal]:min-h-6 data-[orientation=horizontal]:min-w-44 data-[orientation=horizontal]:before:inset-y-[-10px] data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:min-w-6 data-[orientation=vertical]:before:inset-x-[-10px]", lg: "data-[orientation=horizontal]:min-h-7 data-[orientation=horizontal]:min-w-48 data-[orientation=horizontal]:before:inset-y-[-12px] data-[orientation=vertical]:min-h-48 data-[orientation=vertical]:min-w-7 data-[orientation=vertical]:before:inset-x-[-12px]", sm: "data-[orientation=horizontal]:min-h-5 data-[orientation=horizontal]:min-w-40 data-[orientation=horizontal]:before:inset-y-[-8px] data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:min-w-5 data-[orientation=vertical]:before:inset-x-[-8px]", xl: "data-[orientation=horizontal]:min-h-8 data-[orientation=horizontal]:min-w-52 data-[orientation=horizontal]:before:inset-y-[-14px] data-[orientation=vertical]:min-h-52 data-[orientation=vertical]:min-w-8 data-[orientation=vertical]:before:inset-x-[-14px]", }, }, }, ); const sliderTrackVariants = cva( "relative grow cursor-inherit select-none active:cursor-grabbing before:absolute data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=horizontal]:before:inset-x-0.5 data-[orientation=vertical]:before:inset-x-0 data-[orientation=horizontal]:before:inset-y-0 data-[orientation=vertical]:before:inset-y-0.5", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "", lg: "", sm: "", xl: "", }, }, }, ); const sliderThumbVariants = cva( "block shrink-0 cursor-inherit select-none active:cursor-grabbing before:absolute before:inset-0 before:rounded-[inherit] has-focus-visible:ring-ring/24 data-dragging:scale-120 data-dragging:cursor-grabbing dark:has-focus-visible:ring-ring/48", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "", lg: "", sm: "", xl: "", }, }, }, ); // The value's type is the kit rule's at every size, with the scale labels // (design-rework.css): 12 px on an 18 px line, as the lg slider shows it. const sliderValueVariants = cva("flex justify-end", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "", lg: "", sm: "", xl: "", }, }, }); // The inset lines labels up with the thumb centers; end labels may use it to // stay inside the slider box. The labels tuck 4 px under the control's hit // area, across the 8 px gap of the Slider's stack. const sliderLabelsVariants = cva( "@container relative -mt-3 mx-(--slider-labels-inset) h-4", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "[--slider-labels-inset:--spacing(2.5)] sm:[--slider-labels-inset:--spacing(2)]", lg: "[--slider-labels-inset:--spacing(3)] sm:[--slider-labels-inset:--spacing(2.5)]", sm: "[--slider-labels-inset:--spacing(2.25)] sm:[--slider-labels-inset:--spacing(2)]", xl: "[--slider-labels-inset:--spacing(3.5)] sm:[--slider-labels-inset:--spacing(3)]", }, }, }, ); const SliderSizeContext = React.createContext( null, ); export interface SliderProps extends Omit, "size">, VariantProps { labels?: readonly { label: React.ReactNode; value: number }[]; } // Base UI measures an edge-aligned thumb and the indicator from the control // when they mount, and their resize observer finds no control at that point. // Mounted inside a hidden container (display: none), they keep a 0 px // measurement and stay hidden after it shows. The kit remounts them each time // the control is rendered again. function useRenderedKey( control: React.RefObject, ): number { const [renderedKey, setRenderedKey] = React.useState(0); React.useEffect(() => { const node = control.current; if (!node || typeof ResizeObserver === "undefined") return undefined; let rendered = node.getClientRects().length > 0; const observer = new ResizeObserver(() => { const next = node.getClientRects().length > 0; if (next && !rendered) setRenderedKey((key) => key + 1); rendered = next; }); observer.observe(node); return () => observer.disconnect(); }, [control]); return renderedKey; } function toSliderValues(value: number | readonly number[]) { return typeof value === "number" ? [value] : [...value]; } export function Slider({ "aria-label": ariaLabel, className, children, defaultValue, labels, value, min = 0, max = 100, onValueChange, orientation = "horizontal", size, ...props }: SliderProps): React.ReactElement { const resolvedSize = useResolvedControlSize(size); const controlRef = React.useRef(null); const renderedKey = useRenderedKey(controlRef); const values = React.useMemo(() => { if (value !== undefined) return toSliderValues(value); if (defaultValue !== undefined) return toSliderValues(defaultValue); return [min]; }, [value, defaultValue, min]); return ( {children} {Array.from({ length: values.length }, (_, index) => ( ))} {labels?.length ? ( ) : null} ); } function SliderLabels({ labels, max, min, orientation, size, }: { labels: NonNullable; max: number; min: number; orientation: "horizontal" | "vertical"; size: SiteplaneControlSize; }) { return ( (
{labels.map(({ label, value: labelValue }) => { const position = max === min ? 0 : Math.min( 100, Math.max(0, ((labelValue - min) / (max - min)) * 100), ); const fraction = position / 100; return ( {label} ); })}
)} /> ); } export interface SliderValueProps extends Omit, "size">, VariantProps {} export function SliderValue({ className, size, ...props }: SliderValueProps): React.ReactElement { const groupSize = React.useContext(SliderSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); return ( ); } export { SliderPrimitive }; // ~/src/siteplane/components/slider/index.ts export * from "./slider";