INIT
This commit is contained in:
+320
@@ -0,0 +1,320 @@
|
||||
import { attachRef, DOMContext, } from "svelte-toolbelt";
|
||||
import { Context } from "runed";
|
||||
import { createBitsAttrs, boolToStr, boolToEmptyStrOrUndef } 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": boolToStr(this.opts.required.current),
|
||||
"aria-disabled": this.opts.disabled.current ? "true" : undefined,
|
||||
"aria-label": "Rating",
|
||||
"data-disabled": boolToEmptyStrOrUndef(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": boolToEmptyStrOrUndef(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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user