/** * Shared slider layout renderer. * * Renders the "slider" layout: an animated highlight that slides under the * selected swatch. This shared implementation uses only shape helpers and * standard browser APIs; optional extension tuning can pass a selector padding * value while the base runtime uses the default. */ import React, { useCallback, useEffect, useRef, useState } from "react"; import { getContainerShapeClass, getShapeClipPath, } from "../shared/shapeClasses"; import { useReducedMotion } from "../shared/useReducedMotion"; import type { SwatchOption } from "./SwatchGroup"; type SliderLayoutProps = { options: SwatchOption[]; renderSwatch: (option: SwatchOption) => React.ReactNode; selectedValue: string; selectedColor: string; shape: string; selectorPadding?: number; layoutPadding?: string; gapStyle?: React.CSSProperties; }; type ThumbState = { x: number; y: number; w: number; h: number; ready: boolean; }; const getSwatchGeometry = ( containerEl: HTMLDivElement, selectedEl: HTMLElement, ): { x: number; y: number; w: number; h: number } => { const containerRect = containerEl.getBoundingClientRect(); const selectedRect = selectedEl.getBoundingClientRect(); const selectedContainer = selectedEl.closest( "[data-b3-wvs-swatch-container]", ) as HTMLElement | null; const anchorRect = selectedContainer ? selectedContainer.getBoundingClientRect() : selectedRect; const xOffset = Math.max(0, (anchorRect.width - selectedRect.width) / 2); return { x: anchorRect.left - containerRect.left - containerEl.clientLeft + xOffset, // Labels extend the swatch container below the interactive box. Keep the // selector anchored to the box's stable top edge instead of centering it // within that label-inclusive height. y: anchorRect.top - containerRect.top - containerEl.clientTop, w: selectedRect.width, h: selectedRect.height, }; }; const SliderLayout = ({ options, renderSwatch, selectedValue, selectedColor, shape, selectorPadding = 4, layoutPadding = "0px 0px 0px 0px", gapStyle = {}, }: SliderLayoutProps): React.ReactElement | null => { const [thumb, setThumb] = useState({ x: 0, y: 0, w: 0, h: 0, ready: false, }); const containerRef = useRef(null); const swatchRefs = useRef>({}); const reducedMotion = useReducedMotion(); const containerShapeClass = getContainerShapeClass(shape); const clipPath = getShapeClipPath(shape); const offset = Math.max(0, selectorPadding); const measure = useCallback(() => { const selectedEl = selectedValue ? swatchRefs.current[selectedValue] : null; const containerEl = containerRef.current; if (!selectedEl || !containerEl) { setThumb((prev) => ({ ...prev, ready: false })); return; } const geometry = getSwatchGeometry(containerEl, selectedEl); setThumb({ ...geometry, ready: true }); }, [selectedValue]); useEffect(() => { if (!selectedValue) { setThumb((prev) => ({ ...prev, ready: false })); return; } const raf = requestAnimationFrame(measure); let ro: ResizeObserver | null = null; const selectedEl = swatchRefs.current[selectedValue]; if (typeof ResizeObserver !== "undefined" && containerRef.current) { ro = new ResizeObserver(() => measure()); ro.observe(containerRef.current); if (selectedEl) { ro.observe(selectedEl); } } const onResize = () => measure(); window.addEventListener("resize", onResize); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); if (ro) { ro.disconnect(); } }; }, [measure, selectedValue, options.length, selectedColor, offset]); if (!Array.isArray(options) || options.length === 0 || !renderSwatch) { return null; } const assignRef = (value: string) => (el: HTMLElement | null) => { if (!el) { swatchRefs.current[value] = null; return; } const interactive = (el.querySelector("[data-b3-wvs-swatch]") as HTMLElement | null) || el; swatchRefs.current[value] = interactive; }; return (
); }; export default SliderLayout;