import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createSignal, Show, untrack } from "solid-js"; import { useCspNonce } from "../../csp-provider/csp-context.ts"; import { useDirection } from "../../direction-provider/direction-provider-context.ts"; import { useBaseUiId } from "../../internals/base-ui-id"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useCompositeListItem } from "../../internals/composite/composite-list.tsx"; import { ownerWindow } from "../../internals/dom/owner-window.ts"; import { useFieldRootContext } from "../../internals/field-context"; import { isHydrating } from "../../internals/is-hydrating.ts"; import { useAriaLabelledBy } from "../../internals/labelable-provider/use-aria-labelled-by.ts"; import { useLabelableId } from "../../internals/labelable-provider/use-labelable-id.ts"; import { mergePropsN } from "../../internals/merge-props-n.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { clamp } from "../../internals/slider-utils/clamp.ts"; import { getMidpoint } from "../../internals/slider-utils/get-midpoint.ts"; import { getSliderValue } from "../../internals/slider-utils/get-slider-value.ts"; import { getDecimalPrecision, roundValueToStep, } from "../../internals/slider-utils/round-value-to-step.ts"; import type { BaseUIComponentProps } from "../../internals/types"; import { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect.ts"; import { valueToPercent } from "../../internals/value-to-percent"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style"; import { type SliderRootState, type ThumbMetadata, useSliderRootContext, } from "../slider-root-context.ts"; import { sliderDataAttrs } from "../utils/state-attrs.ts"; import { sliderPrehydrationScript } from "./prehydration-script.ts"; const ARROW_UP = "ArrowUp"; const ARROW_DOWN = "ArrowDown"; const ARROW_LEFT = "ArrowLeft"; const ARROW_RIGHT = "ArrowRight"; const HOME = "Home"; const END = "End"; const PAGE_UP = "PageUp"; const PAGE_DOWN = "PageDown"; const HANDLED_KEYS = new Set([ ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, HOME, END, PAGE_UP, PAGE_DOWN, ]); // Keys that Base UI's composite primitives also consume (arrows / Home / End). // React stops propagation for these so a parent `Menu`/`Listbox`/composite // doesn't double-handle a slider's own keyboard event. Mirrors // `@base-ui/react`'s `COMPOSITE_KEYS` constant. const COMPOSITE_KEYS = new Set([ ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, HOME, END, ]); export interface SliderThumbState extends SliderRootState {} export interface SliderThumbProps extends Omit< BaseUIComponentProps<"div", SliderThumbState>, "onBlur" | "onFocus" > { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; disabled?: boolean | undefined; getAriaLabel?: ((index: number) => string) | null | undefined; getAriaValueText?: | ((formattedValue: string, value: number, index: number) => string) | null | undefined; /** Explicit index override for range sliders. */ index?: number | undefined; inputRef?: ((element: HTMLInputElement | null) => void) | undefined; onBlur?: ((event: FocusEvent) => void) | undefined; onFocus?: ((event: FocusEvent) => void) | undefined; tabIndex?: number | undefined; } function formatNumber( value: number, locale: Intl.LocalesArgument | undefined, format: Intl.NumberFormatOptions | undefined, ): string { if (!format) return String(value); try { return new Intl.NumberFormat(locale, format).format(value); } catch { return String(value); } } function getDefaultAriaValueText( values: readonly number[], index: number, format: Intl.NumberFormatOptions | undefined, locale: Intl.LocalesArgument | undefined, ): string | undefined { if (index < 0) return undefined; if (values.length === 2) { if (index === 0) { return `${formatNumber(values[index] ?? 0, locale, format)} start range`; } return `${formatNumber(values[index] ?? 0, locale, format)} end range`; } return format ? formatNumber(values[index] ?? 0, locale, format) : undefined; } function getNewValue( thumbValue: number, increment: number, direction: 1 | -1, min: number, max: number, ): number { const next = direction === 1 ? thumbValue + increment : thumbValue - increment; const precision = Math.max( getDecimalPrecision(thumbValue), getDecimalPrecision(increment), getDecimalPrecision(min), ); return clamp(Number(next.toFixed(precision)), min, max); } /** * The draggable part of the slider at the tip of the indicator. Renders a * `
` with a nested `` for form participation and * keyboard navigation. * * Keyboard contract (matches React Base UI): * * - Arrow keys: ±step (or ±largeStep with Shift). Horizontal arrows honor * RTL via the direction context. * - Page Up / Page Down: ±largeStep. * - Home / End: jumps to the range edge (or the adjacent thumb's value +/- * `step * minStepsBetweenValues` in range mode). * * Solid 2 notes: * * - `index` is resolved through `useCompositeListItem` when the consumer * doesn't pass `index` explicitly — matches React's "auto-index" branch. * - `aria-labelledby` is seeded with `""` so Solid 2's DOM spread keeps the * key in `ownKeys` (see `test/internals/merge-props-undefined-attr.test.tsx`). * - The input ref is exposed through `props.inputRef` — consumers compose * with Solid's native array refs on the outer thumb via `ref`. */ export function SliderThumb(props: SliderThumbProps): JSX.Element { const context = useSliderRootContext(); const field = useFieldRootContext(); const direction = useDirection(); const nonce = useCspNonce(); const id = useBaseUiId( untrack(() => (typeof props.id === "string" ? props.id : undefined)), ); const disabled = (): boolean => context.disabled() || props.disabled === true; const range = (): boolean => context.values().length > 1; const defaultInputId = useBaseUiId(); const { id: labelableInputId } = useLabelableId(); const inputId = (): string => (range() ? defaultInputId : labelableInputId); const metadata = createMemo(() => ({ inputId: inputId(), })); // `useCompositeListItem` reads its metadata input once at body time to // register with the parent list — wrap in `untrack` so the snapshot // read of `metadata()` (and the cascading `inputId()` / `range()` / // `context.values()` reads behind it) doesn't trip Solid 2's component // strict-read shell. Reactivity flows through the registry callbacks. const { ref: listItemRef, index: compositeIndex } = untrack(() => useCompositeListItem({ metadata: metadata(), }), ); const index = (): number => { if (!range()) return 0; if (typeof props.index === "number") return props.index; return compositeIndex(); }; const thumbValue = (): number => { const values = context.values(); const i = index(); return values[i] ?? 0; }; const thumbValuePercent = (): number => valueToPercent(thumbValue(), context.min(), context.max()); // The composed `aria-labelledby` for the hidden input. Prefers an explicit // `aria-label` on the thumb, falling back to ids registered via the // labelable provider above. const ariaLabel = (): string | undefined => { const getAriaLabel = props.getAriaLabel; if (typeof getAriaLabel === "function") return getAriaLabel(index()); const raw = (props as { "aria-label"?: string | undefined })["aria-label"]; return typeof raw === "string" ? raw : undefined; }; const externalAriaLabelledBy = (): string | undefined => { const v = (props as { "aria-labelledby"?: string | undefined })[ "aria-labelledby" ]; return typeof v === "string" && v.length > 0 ? v : undefined; }; // Hook seeds its initial state from the argument at body time; wrap // in `untrack` to keep that snapshot out of the strict-read shell. const composedLabelledBy = untrack(() => useAriaLabelledBy(externalAriaLabelledBy), ); const thumbRef: { current: HTMLElement | null } = { current: null }; const inputRef: { current: HTMLInputElement | null } = { current: null }; // Element-valued signal driving the ResizeObserver effect below. The // render ref callback mirrors into both `thumbRef.current` (for // synchronous reads in handlers) and this signal (for effect re-runs). const [thumbElement, setThumbElement] = createSignal( null, { ownedWrite: true }, ); // Edge-alignment geometry (React `SliderThumb.tsx:196-223`). Written by // the ResizeObserver callback and consumed by the thumb's style getter; // `undefined` until the first measurement lands or when `inset` is off. // This signal lives outside Solid's reactive graph on the measurement // side — the ResizeObserver callback is the only writer — so we expose // only the accessor to consumers. const [positionPercent, setPositionPercent] = createSignal< number | undefined >(undefined, { ownedWrite: true }); const isLastThumb = (): boolean => index() === context.values().length - 1; function getInsetPosition(): void { // `untrack` so reads of `context.orientation()` / `thumbValuePercent()` // / `index()` / `isLastThumb()` don't trip `[STRICT_READ_UNTRACKED]` // when this is called from inside an effect's apply callback. The // reads here are snapshots driving DOM math + setIndicatorPosition; // re-runs are scheduled by the effects that own this function and by // the ResizeObserver wired up below. untrack(() => { const control = context.controlRef.current; const thumb = thumbRef.current; if (!control || !thumb) return; const thumbRect = thumb.getBoundingClientRect(); const controlRect = control.getBoundingClientRect(); const vertical = context.orientation() === "vertical"; const side = vertical ? "height" : "width"; // Usable travel = control side minus one thumb side (thumb is // inset by half its size at each endpoint, so its *edge* lands on // the control edge at min/max). const controlSize = controlRect[side] - thumbRect[side]; const thumbOffsetFromControlEdge = thumbRect[side] / 2 + (controlSize * thumbValuePercent()) / 100; const nextPositionPercent = (thumbOffsetFromControlEdge / controlRect[side]) * 100; const nextInsetPosition = Number.isFinite(nextPositionPercent) ? nextPositionPercent : undefined; setPositionPercent(nextInsetPosition); const i = index(); if (i === 0) { context.setIndicatorPosition((prev) => [nextInsetPosition, prev[1]]); } if (isLastThumb()) { context.setIndicatorPosition((prev) => [prev[0], nextInsetPosition]); } }); } // Initial measurement on mount. Queueing via microtask matches React's // `queueMicrotask` — lets the thumb + control settle in the DOM before // we read their rects. useIsoLayoutEffect( () => [context.inset(), thumbElement()] as const, ([inset, el]) => { if (!inset || !el) return undefined; queueMicrotask(getInsetPosition); return undefined; }, ); // Re-measure whenever the thumb's value percent changes. Size is // stable but its target position isn't. useIsoLayoutEffect( () => [context.inset(), thumbValuePercent(), thumbElement()] as const, ([inset, , el]) => { if (!inset || !el) return undefined; getInsetPosition(); return undefined; }, ); // Two ResizeObservers (thumb + control) re-run the geometry whenever // either element resizes. Matches React's `SliderThumb.tsx:237-257`. // `ownerWindow` grabs the correct ResizeObserver constructor for // shadow-DOM / iframe hosts — don't reach for the global directly. useIsoLayoutEffect( () => [context.inset(), thumbElement()] as const, ([inset, thumbEl]) => { const controlEl = context.controlRef.current; if (!inset || !thumbEl || !controlEl) return undefined; const win = ownerWindow(thumbEl) as Window & { ResizeObserver?: typeof ResizeObserver; }; const ROConstructor = win.ResizeObserver; if (typeof ROConstructor !== "function") return undefined; const observer = new ROConstructor(() => { getInsetPosition(); }); observer.observe(thumbEl); observer.observe(controlEl); return () => { observer.disconnect(); }; }, ); function handleKeyDown(event: KeyboardEvent): void { if (!HANDLED_KEYS.has(event.key)) return; // Matches React `SliderThumb.tsx:383-385`: stop the event from bubbling // to a surrounding `Menu`/composite so the same arrow key doesn't also // drive the outer focus ring. if (COMPOSITE_KEYS.has(event.key)) { event.stopPropagation(); } const input = event.currentTarget as HTMLInputElement; let newValue: number | null = null; const min = context.min(); const max = context.max(); const step = context.step(); const largeStep = context.largeStep(); const minSteps = context.minStepsBetweenValues(); const rtl = direction() === "rtl"; const i = index(); const sliderValues = context.values(); const roundedValue = roundValueToStep(thumbValue(), step, min); const increment = event.shiftKey ? largeStep : step; switch (event.key) { case ARROW_UP: newValue = getNewValue(roundedValue, increment, 1, min, max); break; case ARROW_RIGHT: newValue = getNewValue(roundedValue, increment, rtl ? -1 : 1, min, max); break; case ARROW_DOWN: newValue = getNewValue(roundedValue, increment, -1, min, max); break; case ARROW_LEFT: newValue = getNewValue(roundedValue, increment, rtl ? 1 : -1, min, max); break; case PAGE_UP: newValue = getNewValue(roundedValue, largeStep, 1, min, max); break; case PAGE_DOWN: newValue = getNewValue(roundedValue, largeStep, -1, min, max); break; case END: { const neighbor = sliderValues[i + 1]; newValue = range() && Number.isFinite(neighbor) ? (neighbor as number) - step * minSteps : max; break; } case HOME: { const neighbor = sliderValues[i - 1]; newValue = range() && Number.isFinite(neighbor) ? (neighbor as number) + step * minSteps : min; break; } default: break; } if (newValue !== null) { context.handleInputChange(newValue, i, event); event.preventDefault(); } input.focus({ preventScroll: true }); } function handleInputChange( event: Event & { currentTarget: HTMLInputElement }, ): void { context.handleInputChange( event.currentTarget.valueAsNumber, index(), event, ); } function handlePointerDown(event: PointerEvent): void { const i = index(); context.pressedThumbIndexRef.current = i; const thumbEl = thumbRef.current; if (thumbEl != null) { const vertical = context.orientation() === "vertical"; const midpoint = getMidpoint(thumbEl as HTMLElement); const offset = vertical ? event.clientY - midpoint.y : event.clientX - midpoint.x; context.pressedThumbCenterOffsetRef.current = offset; } if ( inputRef.current != null && context.pressedInputRef.current !== inputRef.current ) { context.pressedInputRef.current = inputRef.current; } } function getThumbStyle(): JSX.CSSProperties { const vertical = context.orientation() === "vertical"; const rtl = direction() === "rtl"; const startEdge = vertical ? "bottom" : "inset-inline-start"; const crossOffsetProperty = vertical ? "left" : "top"; const percent = thumbValuePercent(); const activeIndex = context.active(); const i = index(); const isRange = range(); const last = context.lastUsedThumbIndex(); const isInset = context.inset(); let zIndex: number | undefined; if (isRange) { if (activeIndex === i) zIndex = 2; else if (last === i) zIndex = 1; } else if (activeIndex === i) { zIndex = 1; } if (!isInset) { if (!Number.isFinite(percent)) { return { ...VISUALLY_HIDDEN_STYLE } as JSX.CSSProperties; } return { position: "absolute", [startEdge]: `${percent}%`, [crossOffsetProperty]: "50%", translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`, "z-index": zIndex, }; } // Edge mode: position the thumb from the measured `positionPercent` // via a CSS custom property so the prehydration script (see SSR // deferral below) can overwrite it before hydration without // touching inline styles directly. Hide the thumb until the first // measurement lands or until hydration completes — prevents a // one-frame flash at a wrong position. const pos = positionPercent(); const renderBH = context.renderBeforeHydration(); return { "--position": `${pos ?? 0}%`, visibility: (renderBH && isHydrating()) || pos === undefined ? "hidden" : undefined, position: "absolute", [startEdge]: "var(--position)", [crossOffsetProperty]: "50%", translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`, "z-index": zIndex, }; } const onInputFocus = (event: FocusEvent): void => { context.setActive(index()); field.setFocused(true); props.onFocus?.(event); }; const onInputBlur = (event: FocusEvent): void => { if (thumbRef.current == null) return; context.setActive(-1); // Matches React `SliderThumb.tsx:371-378`: on blur, flip the field // `touched` + `focused` latches and, when validationMode is "onBlur", // commit the thumb's current value into the validation pipeline. field.setTouched(true); field.setFocused(false); if (field.validationMode() === "onBlur") { const i = index(); void context.commitValidation( getSliderValue( thumbValue(), i, context.min(), context.max(), range(), context.values(), ), ); } props.onBlur?.(event); }; const forwardedProps = (): Record => { // Strip the props we redirect onto the . Keep `id` off the // forwarded set — we emit it explicitly on the root wrapper. const out: Record = {}; for (const key of Object.keys(props)) { if ( key === "as" || key === "disabled" || key === "getAriaLabel" || key === "getAriaValueText" || key === "index" || key === "inputRef" || key === "tabIndex" || key === "onBlur" || key === "onFocus" || key === "id" || key === "class" || key === "style" || key === "attrs" || key === "aria-label" || key === "aria-labelledby" || key === "aria-describedby" ) { continue; } const desc = Object.getOwnPropertyDescriptor(props, key); if (desc) Object.defineProperty(out, key, desc); } return out; }; const cssWritingMode = (): JSX.CSSProperties["writing-mode"] => { if (context.orientation() !== "vertical") return undefined; return direction() === "rtl" ? "vertical-rl" : "vertical-lr"; }; function renderHiddenInput(): JSX.Element { const inputStyle = (): JSX.CSSProperties => ({ ...(VISUALLY_HIDDEN_STYLE as JSX.CSSProperties), width: "100%", height: "100%", "writing-mode": cssWritingMode(), }); const inputRefCallback = (el: HTMLInputElement | null): void => { inputRef.current = el; // Bridge to the field validation inputRef so native validation // (e.g. `element.validity`) flows through `useFieldValidation`. field.validation.inputRef.current = el; props.inputRef?.(el); }; const externalDescribedBy = ( props as { "aria-describedby"?: string | undefined; } )["aria-describedby"]; const valueTextAccessor = (): string | undefined => { const gt = props.getAriaValueText; if (typeof gt === "function") { return gt( formatNumber(thumbValue(), context.locale(), context.formatOptions()), thumbValue(), index(), ); } return getDefaultAriaValueText( context.values(), index(), context.formatOptions(), context.locale(), ); }; const labelledBy = (): string => ariaLabel() != null ? "" : (composedLabelledBy() ?? ""); // Pull the field's input-validation props and surface them on the // rendered ``. The shape includes `onInput` (native validation // commit bridge), plus `aria-invalid` / `aria-describedby` carried // over from `getValidationProps`. Matches React `SliderThumb.tsx:472`. const validationInputProps = mergePropsN([ field.validation.getInputValidationProps() as Record, ]); const onValidationInput = validationInputProps.onInput as | ((event: InputEvent) => void) | undefined; function handleInput( event: InputEvent & { currentTarget: HTMLInputElement }, ): void { // Bridge to the field validation pipeline. Chain order matches // React's merged props — validation runs on every native `input`, // and our slider value update continues to fire via `onChange`. onValidationInput?.(event); } // Compose `aria-describedby`: caller's explicit describedBy takes // precedence; otherwise fall back to whatever validation supplies. const composedDescribedBy = (): string | undefined => externalDescribedBy ?? (validationInputProps["aria-describedby"] as string | undefined); const ariaInvalid = (): | "grammar" | "spelling" | "true" | "false" | undefined => { // `getValidationProps` emits literal strings (`"true"` / `"false"`) // or `undefined`; normalize any stray boolean values from external // sources. const v = validationInputProps["aria-invalid"]; if (v === undefined) return undefined; if (v === true || v === "true") return "true"; if (v === false || v === "false") return "false"; if (v === "grammar" || v === "spelling") return v; return undefined; }; return ( ); } const localRef = (el: HTMLElement | null): void => { thumbRef.current = el; setThumbElement(el); listItemRef(el); }; const internalSlot = { id, get "data-index"() { return String(index()); }, get style() { return getThumbStyle(); }, onPointerDown: handlePointerDown, get children() { // SSR prehydration slot (mirrors React `SliderThumb.tsx:487-499`): // emit the inline `