bits-ui
Version:
The headless components for Svelte.
321 lines (320 loc) • 12.1 kB
JavaScript
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;
}
}