UNPKG

bits-ui

Version:

The headless components for Svelte.

321 lines (320 loc) 12.1 kB
import { attachRef, DOMContext, } from "svelte-toolbelt"; import { Context } from "runed"; import { createBitsAttrs, getAriaRequired, getDataDisabled } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; const ratingGroupAttrs = createBitsAttrs({ component: "rating-group", parts: ["root", "item"], }); const RatingGroupRootContext = new Context("RatingGroup.Root"); export class RatingGroupRootState { static create(opts) { return RatingGroupRootContext.set(new RatingGroupRootState(opts)); } opts; attachment; #hoverValue = $state(null); #keySequence = $state(""); #keySequenceTimeout = null; domContext; hasValue = $derived.by(() => this.opts.value.current > 0); valueToUse = $derived.by(() => this.#hoverValue ?? this.opts.value.current); isRTL = $derived.by(() => { const element = this.opts.ref.current; if (!element) return false; const style = getComputedStyle(element); return style.direction === "rtl"; }); ariaValuetext = $derived.by(() => { return typeof this.opts.ariaValuetext.current === "function" ? this.opts.ariaValuetext.current(this.opts.value.current, this.opts.max.current) : this.opts.ariaValuetext.current; }); items = $derived.by(() => { const value = this.valueToUse; return Array.from({ length: this.opts.max.current }, (_, i) => { const itemValue = i + 1; const halfValue = itemValue - 0.5; const state = value >= itemValue ? "active" : this.opts.allowHalf.current && value >= halfValue ? "partial" : "inactive"; return { index: i, state }; }); }); constructor(opts) { this.opts = opts; this.attachment = attachRef(opts.ref); this.onkeydown = this.onkeydown.bind(this); this.onpointerleave = this.onpointerleave.bind(this); this.domContext = new DOMContext(this.opts.ref); } isActive(itemIndex) { return this.valueToUse >= itemIndex + 1; } isPartial(itemIndex) { if (!this.opts.allowHalf.current) return false; const itemValue = itemIndex + 1; return this.valueToUse >= itemValue - 0.5 && this.valueToUse < itemValue; } setHoverValue(value) { if (this.opts.readonly.current || this.opts.disabled.current || !this.opts.hoverPreview.current) return; this.#hoverValue = value === null ? null : Math.max(this.opts.min.current, Math.min(this.opts.max.current, value)); } setValue(value) { if (this.opts.readonly.current || this.opts.disabled.current) return; this.opts.value.current = Math.max(this.opts.min.current, Math.min(this.opts.max.current, value)); } calculateRatingFromPointer(itemIndex, event) { const ratingValue = itemIndex + 1; if (!this.opts.allowHalf.current) return ratingValue; const rect = event.currentTarget.getBoundingClientRect(); const style = getComputedStyle(event.currentTarget); const isHorizontal = this.opts.orientation.current === "horizontal"; const position = isHorizontal ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height; const normalizedPosition = style.direction === "rtl" ? 1 - position : position; return normalizedPosition < 0.5 ? ratingValue - 0.5 : ratingValue; } onpointerleave() { this.setHoverValue(null); } handlers = { [kbd.ARROW_UP]: () => { this.setHoverValue(null); this.#adjustValue(this.opts.allowHalf.current ? 0.5 : 1); }, [kbd.ARROW_RIGHT]: () => { this.setHoverValue(null); const increment = this.opts.allowHalf.current ? 0.5 : 1; // in RTL mode, right arrow should decrement this.#adjustValue(this.isRTL ? -increment : increment); }, [kbd.ARROW_DOWN]: () => { this.setHoverValue(null); this.#adjustValue(this.opts.allowHalf.current ? -0.5 : -1); }, [kbd.ARROW_LEFT]: () => { this.setHoverValue(null); const increment = this.opts.allowHalf.current ? 0.5 : 1; // in RTL mode, left arrow should increment this.#adjustValue(this.isRTL ? increment : -increment); }, [kbd.HOME]: () => { this.setHoverValue(null); this.setValue(this.opts.min.current); }, [kbd.END]: () => { this.setHoverValue(null); this.setValue(this.opts.max.current); }, [kbd.PAGE_UP]: () => { this.setHoverValue(null); this.#adjustValue(1); }, [kbd.PAGE_DOWN]: () => { this.setHoverValue(null); this.#adjustValue(-1); }, }; onkeydown(e) { if (this.opts.disabled.current || this.opts.readonly.current) return; if (this.handlers[e.key]) { e.preventDefault(); this.#clearKeySequence(); this.handlers[e.key]?.(); return; } if (this.opts.allowHalf.current && this.#handleDecimalInput(e)) return; // handle direct number input const num = parseInt(e.key || ""); if (!isNaN(num) && e.key) { e.preventDefault(); if (num >= this.opts.min.current && num <= this.opts.max.current) { this.setValue(num); if (this.opts.allowHalf.current) { this.#startDecimalListening(num); } } return; } this.#clearKeySequence(); } #adjustValue(delta) { this.setValue(this.opts.value.current + delta); } #handleDecimalInput(e) { if (!e.key) return false; if (e.key === ".") { e.preventDefault(); this.#keySequence += e.key; return true; } if (e.key === "5" && this.#keySequence.match(/^\d+\.$/)) { e.preventDefault(); this.#keySequence += e.key; const match = this.#keySequence.match(/^(\d+)\.5$/); if (match?.[1]) { const value = parseFloat(this.#keySequence); if (value >= this.opts.min.current && value <= this.opts.max.current) { this.setValue(value); this.#clearKeySequence(); } } return true; } return false; } #startDecimalListening(baseValue) { this.#keySequence = baseValue.toString(); if (this.#keySequenceTimeout) { this.domContext.clearTimeout(this.#keySequenceTimeout); } this.#keySequenceTimeout = this.domContext.setTimeout(() => this.#clearKeySequence(), 1000); } #clearKeySequence() { this.#keySequence = ""; if (this.#keySequenceTimeout) { this.domContext.clearTimeout(this.#keySequenceTimeout); this.#keySequenceTimeout = null; } } snippetProps = $derived.by(() => ({ items: this.items, value: this.opts.value.current, max: this.opts.max.current, })); props = $derived.by(() => { return { id: this.opts.id.current, role: "slider", "aria-valuenow": this.opts.value.current, "aria-valuemin": this.opts.min.current, "aria-valuemax": this.opts.max.current, "aria-valuetext": this.ariaValuetext, "aria-orientation": this.opts.orientation.current, "aria-required": getAriaRequired(this.opts.required.current), "aria-disabled": this.opts.disabled.current ? "true" : undefined, "aria-label": "Rating", "data-disabled": getDataDisabled(this.opts.disabled.current), "data-readonly": this.opts.readonly.current ? "" : undefined, "data-orientation": this.opts.orientation.current, tabindex: this.opts.disabled.current ? -1 : 0, [ratingGroupAttrs.root]: "", onkeydown: this.onkeydown, onpointerleave: this.onpointerleave, ...this.attachment, }; }); } export class RatingGroupItemState { static create(opts) { return new RatingGroupItemState(opts, RatingGroupRootContext.get()); } opts; root; attachment; #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.opts.disabled.current); #isActive = $derived.by(() => this.root.isActive(this.opts.index.current)); #isPartial = $derived.by(() => this.root.isPartial(this.opts.index.current)); #state = $derived.by(() => { if (this.#isActive) return "active"; if (this.#isPartial) return "partial"; return "inactive"; }); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); this.onclick = this.onclick.bind(this); this.onpointermove = this.onpointermove.bind(this); } onclick(e) { if (this.#isDisabled || this.root.opts.readonly.current) return; // handle clearing when clicking on first item (index 0) that's already // active and min is 0 if (this.opts.index.current === 0 && this.root.opts.min.current === 0 && this.root.opts.value.current > 0) { const newValue = this.root.calculateRatingFromPointer(this.opts.index.current, e); const currentValue = this.root.opts.value.current; // only clear if the calculated rating exactly matches current value if (newValue === currentValue) { this.root.setValue(0); if (this.root.opts.ref.current) { this.root.opts.ref.current.focus(); } return; } } const newValue = this.root.calculateRatingFromPointer(this.opts.index.current, e); this.root.setValue(newValue); if (this.root.opts.ref.current) { this.root.opts.ref.current.focus(); } } onpointermove(e) { if (this.#isDisabled || this.root.opts.readonly.current || !this.root.opts.hoverPreview.current) return; // skip hover preview for touch devices if (e.pointerType === "touch") return; const hoverValue = this.root.calculateRatingFromPointer(this.opts.index.current, e); this.root.setHoverValue(hoverValue); } snippetProps = $derived.by(() => { return { state: this.#state, }; }); props = $derived.by(() => ({ id: this.opts.id.current, role: "presentation", "data-value": this.opts.index.current + 1, "data-orientation": this.root.opts.orientation.current, "data-disabled": getDataDisabled(this.#isDisabled), "data-readonly": this.root.opts.readonly.current ? "" : undefined, "data-state": this.#state, [ratingGroupAttrs.item]: "", // onclick: this.onclick, onpointermove: this.onpointermove, ...this.attachment, })); } export class RatingGroupHiddenInputState { static create() { return new RatingGroupHiddenInputState(RatingGroupRootContext.get()); } root; shouldRender = $derived.by(() => this.root.opts.name.current !== undefined); props = $derived.by(() => ({ name: this.root.opts.name.current, value: this.root.opts.value.current, required: this.root.opts.required.current, disabled: this.root.opts.disabled.current, })); constructor(root) { this.root = root; } }