UNPKG

bits-ui

Version:

The headless components for Svelte.

811 lines (810 loc) 31.1 kB
/** * This logic is adapted from the @melt-ui/svelte slider, which was mostly written by * Abdelrahman (https://github.com/abdel-17) */ import { untrack } from "svelte"; import { executeCallbacks, onMountEffect, attachRef, DOMContext, } from "svelte-toolbelt"; import { on } from "svelte/events"; import { Context, watch } from "runed"; import { getRangeStyles, getThumbStyles, getTickStyles, normalizeSteps, snapValueToCustomSteps, getAdjacentStepValue, getTickLabelStyles, getThumbLabelStyles, } from "./helpers.js"; import { createBitsAttrs, getAriaDisabled, getAriaOrientation, getDataDisabled, getDataOrientation, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { isElementOrSVGElement } from "../../internal/is.js"; import { isValidIndex } from "../../internal/arrays.js"; import { linearScale } from "../../internal/math.js"; const sliderAttrs = createBitsAttrs({ component: "slider", parts: ["root", "thumb", "range", "tick", "tick-label", "thumb-label"], }); export const SliderRootContext = new Context("Slider.Root"); class SliderBaseRootState { opts; attachment; isActive = $state(false); direction = $derived.by(() => { if (this.opts.orientation.current === "horizontal") { return this.opts.dir.current === "rtl" ? "rl" : "lr"; } else { return this.opts.dir.current === "rtl" ? "tb" : "bt"; } }); // Normalized steps array for consistent handling normalizedSteps = $derived.by(() => { return normalizeSteps(this.opts.step.current, this.opts.min.current, this.opts.max.current); }); domContext; constructor(opts) { this.opts = opts; this.attachment = attachRef(opts.ref); this.domContext = new DOMContext(this.opts.ref); } isThumbActive(_index) { return this.isActive; } #touchAction = $derived.by(() => { if (this.opts.disabled.current) return undefined; return this.opts.orientation.current === "horizontal" ? "pan-y" : "pan-x"; }); getAllThumbs = () => { const node = this.opts.ref.current; if (!node) return []; return Array.from(node.querySelectorAll(sliderAttrs.selector("thumb"))); }; getThumbScale = () => { // If trackPadding is explicitly set, use it directly instead of calculating from thumb size const trackPadding = this.opts.trackPadding?.current; if (trackPadding !== undefined && trackPadding > 0) { return [trackPadding, 100 - trackPadding]; } if (this.opts.thumbPositioning.current === "exact") { // User opted out of containment return [0, 100]; } const isVertical = this.opts.orientation.current === "vertical"; // this assumes all thumbs are the same width const activeThumb = this.getAllThumbs()[0]; const thumbSize = isVertical ? activeThumb?.offsetHeight : activeThumb?.offsetWidth; // if thumb size is undefined or 0, fallback to a 0-100 scale if (thumbSize === undefined || Number.isNaN(thumbSize) || thumbSize === 0) return [0, 100]; const trackSize = isVertical ? this.opts.ref.current?.offsetHeight : this.opts.ref.current?.offsetWidth; // if track size is undefined or 0, fallback to a 0-100 scale if (trackSize === undefined || Number.isNaN(trackSize) || trackSize === 0) return [0, 100]; // the padding on either side // half the width of the thumb const percentPadding = (thumbSize / 2 / trackSize) * 100; const min = percentPadding; const max = 100 - percentPadding; return [min, max]; }; getPositionFromValue = (thumbValue) => { const thumbScale = this.getThumbScale(); const scale = linearScale([this.opts.min.current, this.opts.max.current], thumbScale); return scale(thumbValue); }; props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), "data-disabled": getDataDisabled(this.opts.disabled.current), style: { touchAction: this.#touchAction, }, [sliderAttrs.root]: "", ...this.attachment, })); } class SliderSingleRootState extends SliderBaseRootState { opts; isMulti = false; constructor(opts) { super(opts); this.opts = opts; onMountEffect(() => { return executeCallbacks(on(this.domContext.getDocument(), "pointerdown", this.handlePointerDown), on(this.domContext.getDocument(), "pointerup", this.handlePointerUp), on(this.domContext.getDocument(), "pointermove", this.handlePointerMove), on(this.domContext.getDocument(), "pointerleave", this.handlePointerUp)); }); watch([ () => this.opts.step.current, () => this.opts.min.current, () => this.opts.max.current, () => this.opts.value.current, ], ([step, min, max, value]) => { const steps = normalizeSteps(step, min, max); const isValidValue = (v) => { return steps.includes(v); }; const gcv = (v) => { return snapValueToCustomSteps(v, steps); }; if (!isValidValue(value)) { this.opts.value.current = gcv(value); } }); } isTickValueSelected = (tickValue) => { return this.opts.value.current === tickValue; }; applyPosition({ clientXY, start, end }) { const min = this.opts.min.current; const max = this.opts.max.current; const percent = (clientXY - start) / (end - start); const val = percent * (max - min) + min; if (val < min) { this.updateValue(min); } else if (val > max) { this.updateValue(max); } else { const steps = this.normalizedSteps; const newValue = snapValueToCustomSteps(val, steps); this.updateValue(newValue); } } updateValue = (newValue) => { this.opts.value.current = snapValueToCustomSteps(newValue, this.normalizedSteps); }; handlePointerMove = (e) => { if (!this.isActive || this.opts.disabled.current) return; e.preventDefault(); e.stopPropagation(); const sliderNode = this.opts.ref.current; const activeThumb = this.getAllThumbs()[0]; if (!sliderNode || !activeThumb) return; activeThumb.focus(); const { left, right, top, bottom } = sliderNode.getBoundingClientRect(); if (this.direction === "lr") { this.applyPosition({ clientXY: e.clientX, start: left, end: right, }); } else if (this.direction === "rl") { this.applyPosition({ clientXY: e.clientX, start: right, end: left, }); } else if (this.direction === "bt") { this.applyPosition({ clientXY: e.clientY, start: bottom, end: top, }); } else if (this.direction === "tb") { this.applyPosition({ clientXY: e.clientY, start: top, end: bottom, }); } }; handlePointerDown = (e) => { if (e.button !== 0 || this.opts.disabled.current) return; const sliderNode = this.opts.ref.current; const closestThumb = this.getAllThumbs()[0]; if (!closestThumb || !sliderNode) return; const target = e.composedPath()[0] ?? e.target; if (!isElementOrSVGElement(target) || !sliderNode.contains(target)) return; e.preventDefault(); closestThumb.focus(); this.isActive = true; this.handlePointerMove(e); }; handlePointerUp = () => { if (this.opts.disabled.current) return; if (this.isActive) { this.opts.onValueCommit.current(untrack(() => this.opts.value.current)); } this.isActive = false; }; thumbsPropsArr = $derived.by(() => { const currValue = this.opts.value.current; return Array.from({ length: 1 }, () => { const thumbValue = currValue; const thumbPosition = this.getPositionFromValue(thumbValue); const style = getThumbStyles(this.direction, thumbPosition); return { role: "slider", "aria-valuemin": this.opts.min.current, "aria-valuemax": this.opts.max.current, "aria-valuenow": thumbValue, "aria-disabled": getAriaDisabled(this.opts.disabled.current), "aria-orientation": getAriaOrientation(this.opts.orientation.current), "data-value": thumbValue, "data-orientation": getDataOrientation(this.opts.orientation.current), style, [sliderAttrs.thumb]: "", }; }); }); thumbsRenderArr = $derived.by(() => { return this.thumbsPropsArr.map((_, i) => i); }); ticksPropsArr = $derived.by(() => { const steps = this.normalizedSteps; const currValue = this.opts.value.current; return steps.map((tickValue, i) => { // Calculate position relative to the range const tickPosition = this.getPositionFromValue(tickValue); const isFirst = i === 0; const isLast = i === steps.length - 1; const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; const style = getTickStyles(this.direction, tickPosition, offsetPercentage); const bounded = tickValue <= currValue; return { "data-disabled": getDataDisabled(this.opts.disabled.current), "data-orientation": getDataOrientation(this.opts.orientation.current), "data-bounded": bounded ? "" : undefined, "data-value": tickValue, "data-selected": this.isTickValueSelected(tickValue) ? "" : undefined, style, [sliderAttrs.tick]: "", }; }); }); ticksRenderArr = $derived.by(() => { return this.ticksPropsArr.map((_, i) => i); }); tickItemsArr = $derived.by(() => { return this.ticksPropsArr.map((tick, i) => ({ value: tick["data-value"], index: i, })); }); thumbItemsArr = $derived.by(() => { const currValue = this.opts.value.current; return [ { value: currValue, index: 0, }, ]; }); snippetProps = $derived.by(() => ({ ticks: this.ticksRenderArr, thumbs: this.thumbsRenderArr, tickItems: this.tickItemsArr, thumbItems: this.thumbItemsArr, })); } class SliderMultiRootState extends SliderBaseRootState { opts; isMulti = true; activeThumb = $state(null); currentThumbIdx = $state(0); constructor(opts) { super(opts); this.opts = opts; onMountEffect(() => { return executeCallbacks(on(this.domContext.getDocument(), "pointerdown", this.handlePointerDown), on(this.domContext.getDocument(), "pointerup", this.handlePointerUp), on(this.domContext.getDocument(), "pointermove", this.handlePointerMove), on(this.domContext.getDocument(), "pointerleave", this.handlePointerUp)); }); watch([ () => this.opts.step.current, () => this.opts.min.current, () => this.opts.max.current, () => this.opts.value.current, ], ([step, min, max, value]) => { const steps = normalizeSteps(step, min, max); const isValidValue = (v) => { return steps.includes(v); }; const gcv = (v) => { return snapValueToCustomSteps(v, steps); }; if (value.some((v) => !isValidValue(v))) { this.opts.value.current = value.map(gcv); } }); } isTickValueSelected = (tickValue) => { return this.opts.value.current.includes(tickValue); }; isThumbActive(index) { return this.isActive && this.activeThumb?.idx === index; } applyPosition({ clientXY, activeThumbIdx, start, end, }) { const min = this.opts.min.current; const max = this.opts.max.current; const percent = (clientXY - start) / (end - start); const val = percent * (max - min) + min; if (val < min) { this.updateValue(min, activeThumbIdx); } else if (val > max) { this.updateValue(max, activeThumbIdx); } else { const steps = this.normalizedSteps; const newValue = snapValueToCustomSteps(val, steps); this.updateValue(newValue, activeThumbIdx); } } #getClosestThumb = (e) => { const thumbs = this.getAllThumbs(); if (!thumbs.length) return; for (const thumb of thumbs) { thumb.blur(); } const distances = thumbs.map((thumb) => { if (this.opts.orientation.current === "horizontal") { const { left, right } = thumb.getBoundingClientRect(); return Math.abs(e.clientX - (left + right) / 2); } else { const { top, bottom } = thumb.getBoundingClientRect(); return Math.abs(e.clientY - (top + bottom) / 2); } }); const node = thumbs[distances.indexOf(Math.min(...distances))]; const idx = thumbs.indexOf(node); return { node, idx, }; }; handlePointerMove = (e) => { if (!this.isActive || this.opts.disabled.current) return; e.preventDefault(); e.stopPropagation(); const sliderNode = this.opts.ref.current; const activeThumb = this.activeThumb; if (!sliderNode || !activeThumb) return; activeThumb.node.focus(); const { left, right, top, bottom } = sliderNode.getBoundingClientRect(); const direction = this.direction; if (direction === "lr") { this.applyPosition({ clientXY: e.clientX, activeThumbIdx: activeThumb.idx, start: left, end: right, }); } else if (direction === "rl") { this.applyPosition({ clientXY: e.clientX, activeThumbIdx: activeThumb.idx, start: right, end: left, }); } else if (direction === "bt") { this.applyPosition({ clientXY: e.clientY, activeThumbIdx: activeThumb.idx, start: bottom, end: top, }); } else if (direction === "tb") { this.applyPosition({ clientXY: e.clientY, activeThumbIdx: activeThumb.idx, start: top, end: bottom, }); } }; handlePointerDown = (e) => { if (e.button !== 0 || this.opts.disabled.current) return; const sliderNode = this.opts.ref.current; const closestThumb = this.#getClosestThumb(e); if (!closestThumb || !sliderNode) return; const target = e.composedPath()[0] ?? e.target; if (!isElementOrSVGElement(target) || !sliderNode.contains(target)) return; e.preventDefault(); this.activeThumb = closestThumb; closestThumb.node.focus(); this.isActive = true; this.handlePointerMove(e); }; handlePointerUp = () => { if (this.opts.disabled.current) return; if (this.isActive) { this.opts.onValueCommit.current(untrack(() => this.opts.value.current)); } this.isActive = false; }; getAllThumbs = () => { const node = this.opts.ref.current; if (!node) return []; const thumbs = Array.from(node.querySelectorAll(sliderAttrs.selector("thumb"))); return thumbs; }; updateValue = (thumbValue, idx) => { const currValue = this.opts.value.current; if (!currValue.length) { this.opts.value.current.push(thumbValue); return; } const valueAtIndex = currValue[idx]; if (valueAtIndex === thumbValue) return; const newValue = [...currValue]; if (!isValidIndex(idx, newValue)) return; const direction = newValue[idx] > thumbValue ? -1 : +1; const swap = () => { const diffIndex = idx + direction; newValue[idx] = newValue[diffIndex]; newValue[diffIndex] = thumbValue; const thumbs = this.getAllThumbs(); if (!thumbs.length) return; thumbs[diffIndex]?.focus(); this.activeThumb = { node: thumbs[diffIndex], idx: diffIndex }; }; if (this.opts.autoSort.current && ((direction === -1 && thumbValue < newValue[idx - 1]) || (direction === 1 && thumbValue > newValue[idx + 1]))) { swap(); this.opts.value.current = newValue; return; } const steps = this.normalizedSteps; newValue[idx] = snapValueToCustomSteps(thumbValue, steps); this.opts.value.current = newValue; }; thumbsPropsArr = $derived.by(() => { const currValue = this.opts.value.current; return Array.from({ length: currValue.length || 1 }, (_, i) => { const currThumb = untrack(() => this.currentThumbIdx); if (currThumb < currValue.length) { untrack(() => { this.currentThumbIdx = currThumb + 1; }); } const thumbValue = currValue[i]; const thumbPosition = this.getPositionFromValue(thumbValue ?? 0); const style = getThumbStyles(this.direction, thumbPosition); return { role: "slider", "aria-valuemin": this.opts.min.current, "aria-valuemax": this.opts.max.current, "aria-valuenow": thumbValue, "aria-disabled": getAriaDisabled(this.opts.disabled.current), "aria-orientation": getAriaOrientation(this.opts.orientation.current), "data-value": thumbValue, "data-orientation": getDataOrientation(this.opts.orientation.current), style, [sliderAttrs.thumb]: "", }; }); }); thumbsRenderArr = $derived.by(() => { return this.thumbsPropsArr.map((_, i) => i); }); ticksPropsArr = $derived.by(() => { const steps = this.normalizedSteps; const currValue = this.opts.value.current; return steps.map((tickValue, i) => { // Calculate position relative to the range const tickPosition = this.getPositionFromValue(tickValue); const isFirst = i === 0; const isLast = i === steps.length - 1; const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; const style = getTickStyles(this.direction, tickPosition, offsetPercentage); const bounded = currValue.length === 1 ? tickValue <= currValue[0] : currValue[0] <= tickValue && tickValue <= currValue[currValue.length - 1]; return { "data-disabled": getDataDisabled(this.opts.disabled.current), "data-orientation": getDataOrientation(this.opts.orientation.current), "data-bounded": bounded ? "" : undefined, "data-value": tickValue, style, [sliderAttrs.tick]: "", }; }); }); ticksRenderArr = $derived.by(() => { return this.ticksPropsArr.map((_, i) => i); }); tickItemsArr = $derived.by(() => { return this.ticksPropsArr.map((tick, i) => ({ value: tick["data-value"], index: i, })); }); thumbItemsArr = $derived.by(() => { const currValue = this.opts.value.current; return currValue.map((value, index) => ({ value, index, })); }); snippetProps = $derived.by(() => ({ ticks: this.ticksRenderArr, thumbs: this.thumbsRenderArr, tickItems: this.tickItemsArr, thumbItems: this.thumbItemsArr, })); } export class SliderRootState { static create(opts) { const { type, ...rest } = opts; const rootState = type === "single" ? new SliderSingleRootState(rest) : new SliderMultiRootState(rest); return SliderRootContext.set(rootState); } } const VALID_SLIDER_KEYS = [ kbd.ARROW_LEFT, kbd.ARROW_RIGHT, kbd.ARROW_UP, kbd.ARROW_DOWN, kbd.HOME, kbd.END, ]; export class SliderRangeState { static create(opts) { return new SliderRangeState(opts, SliderRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } rangeStyles = $derived.by(() => { if (Array.isArray(this.root.opts.value.current)) { // Multi-slider: range between min and max thumbs const min = this.root.opts.value.current.length > 1 ? this.root.getPositionFromValue(Math.min(...this.root.opts.value.current) ?? 0) : 0; const max = 100 - this.root.getPositionFromValue(Math.max(...this.root.opts.value.current) ?? 0); return { position: "absolute", ...getRangeStyles(this.root.direction, min, max), }; } else { // Single slider: range from start to current value const trackPadding = this.root.opts.trackPadding?.current; const currentValue = this.root.opts.value.current; const maxValue = this.root.opts.max.current; // Always start from 0% (beginning of track container) const min = 0; // If trackPadding is set and we're at max value, extend to fill the container // Otherwise use the thumb position const max = trackPadding !== undefined && trackPadding > 0 && currentValue === maxValue ? 0 // 100% - 0% = full width : 100 - this.root.getPositionFromValue(currentValue); return { position: "absolute", ...getRangeStyles(this.root.direction, min, max), }; } }); props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.root.opts.orientation.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), style: this.rangeStyles, [sliderAttrs.range]: "", ...this.attachment, })); } export class SliderThumbState { static create(opts) { return new SliderThumbState(opts, SliderRootContext.get()); } opts; root; attachment; #isDisabled = $derived.by(() => this.root.opts.disabled.current || this.opts.disabled.current); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); this.onkeydown = this.onkeydown.bind(this); } #updateValue(newValue) { if (this.root.isMulti) { this.root.updateValue(newValue, this.opts.index.current); } else { this.root.updateValue(newValue); } } onkeydown(e) { if (this.#isDisabled) return; const currNode = this.opts.ref.current; if (!currNode) return; const thumbs = this.root.getAllThumbs(); if (!thumbs.length) return; const idx = thumbs.indexOf(currNode); if (this.root.isMulti) { this.root.currentThumbIdx = idx; } if (!VALID_SLIDER_KEYS.includes(e.key)) return; e.preventDefault(); const min = this.root.opts.min.current; const max = this.root.opts.max.current; const value = this.root.opts.value.current; const thumbValue = Array.isArray(value) ? value[idx] : value; const orientation = this.root.opts.orientation.current; const direction = this.root.direction; const steps = this.root.normalizedSteps; switch (e.key) { case kbd.HOME: this.#updateValue(min); break; case kbd.END: this.#updateValue(max); break; case kbd.ARROW_LEFT: if (orientation !== "horizontal") break; if (e.metaKey) { const newValue = direction === "rl" ? max : min; this.#updateValue(newValue); } else { const stepDirection = direction === "rl" ? "next" : "prev"; const newValue = getAdjacentStepValue(thumbValue, steps, stepDirection); this.#updateValue(newValue); } break; case kbd.ARROW_RIGHT: if (orientation !== "horizontal") break; if (e.metaKey) { const newValue = direction === "rl" ? min : max; this.#updateValue(newValue); } else { const stepDirection = direction === "rl" ? "prev" : "next"; const newValue = getAdjacentStepValue(thumbValue, steps, stepDirection); this.#updateValue(newValue); } break; case kbd.ARROW_UP: if (e.metaKey) { const newValue = direction === "tb" ? min : max; this.#updateValue(newValue); } else { const stepDirection = direction === "tb" ? "prev" : "next"; const newValue = getAdjacentStepValue(thumbValue, steps, stepDirection); this.#updateValue(newValue); } break; case kbd.ARROW_DOWN: if (e.metaKey) { const newValue = direction === "tb" ? max : min; this.#updateValue(newValue); } else { const stepDirection = direction === "tb" ? "next" : "prev"; const newValue = getAdjacentStepValue(thumbValue, steps, stepDirection); this.#updateValue(newValue); } break; } // @ts-expect-error - this is fine this.root.opts.onValueCommit.current(this.root.opts.value.current); } props = $derived.by(() => ({ ...this.root.thumbsPropsArr[this.opts.index.current], id: this.opts.id.current, onkeydown: this.onkeydown, "data-active": this.root.isThumbActive(this.opts.index.current) ? "" : undefined, "data-disabled": getDataDisabled(this.opts.disabled.current || this.root.opts.disabled.current), tabindex: this.opts.disabled.current || this.root.opts.disabled.current ? -1 : 0, ...this.attachment, })); } export class SliderTickState { static create(opts) { return new SliderTickState(opts, SliderRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } props = $derived.by(() => ({ ...this.root.ticksPropsArr[this.opts.index.current], id: this.opts.id.current, ...this.attachment, })); } export class SliderTickLabelState { static create(opts) { return new SliderTickLabelState(opts, SliderRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } props = $derived.by(() => { const tickProps = this.root.ticksPropsArr[this.opts.index.current]; const steps = this.root.normalizedSteps; const tickValue = steps[this.opts.index.current]; const tickPosition = this.root.getPositionFromValue(tickValue); const labelPosition = this.opts.position?.current ?? "top"; const style = getTickLabelStyles(this.root.direction, tickPosition, labelPosition); return { id: this.opts.id.current, "data-orientation": getDataOrientation(this.root.opts.orientation.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), "data-bounded": tickProps["data-bounded"], "data-value": tickValue, "data-selected": this.root.isTickValueSelected(tickValue) ? "" : undefined, "data-position": labelPosition, style, [sliderAttrs["tick-label"]]: "", ...this.attachment, }; }); } export class SliderThumbLabelState { static create(opts) { return new SliderThumbLabelState(opts, SliderRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } props = $derived.by(() => { const value = this.root.opts.value.current; const thumbValue = Array.isArray(value) ? value[this.opts.index.current] : value; const thumbPosition = this.root.getPositionFromValue(thumbValue); const labelPosition = this.opts.position?.current ?? "top"; const style = getThumbLabelStyles(this.root.direction, thumbPosition, labelPosition); return { id: this.opts.id.current, "data-orientation": getDataOrientation(this.root.opts.orientation.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), "data-value": thumbValue, "data-active": this.root.isThumbActive(this.opts.index.current) ? "" : undefined, "data-position": labelPosition, style, [sliderAttrs["thumb-label"]]: "", ...this.attachment, }; }); }