This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
@@ -0,0 +1,10 @@
<script lang="ts">
import { RatingGroupHiddenInputState } from "../rating-group.svelte.js";
import HiddenInput from "../../utilities/hidden-input.svelte";
const inputState = RatingGroupHiddenInputState.create();
</script>
{#if inputState.shouldRender}
<HiddenInput {...inputState.props} />
{/if}
@@ -0,0 +1,18 @@
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
$$bindings?: Bindings;
} & Exports;
(internal: unknown, props: {
$$events?: Events;
$$slots?: Slots;
}): Exports & {
$set?: any;
$on?: any;
};
z_$$bindings?: Bindings;
}
declare const RatingGroupInput: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
[evt: string]: CustomEvent<any>;
}, {}, {}, string>;
type RatingGroupInput = InstanceType<typeof RatingGroupInput>;
export default RatingGroupInput;
@@ -0,0 +1,38 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { RatingGroupItemProps } from "../types.js";
import { RatingGroupItemState } from "../rating-group.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
disabled = false,
index,
children,
child,
ref = $bindable(null),
id = createId(uid),
...restProps
}: RatingGroupItemProps = $props();
const itemState = RatingGroupItemState.create({
disabled: boxWith(() => Boolean(disabled)),
index: boxWith(() => index),
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, itemState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...itemState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(itemState.snippetProps)}
</div>
{/if}
@@ -0,0 +1,4 @@
import type { RatingGroupItemProps } from "../types.js";
declare const RatingGroupItem: import("svelte").Component<RatingGroupItemProps, {}, "ref">;
type RatingGroupItem = ReturnType<typeof RatingGroupItem>;
export default RatingGroupItem;
@@ -0,0 +1,80 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { RatingGroupRootProps } from "../types.js";
import { RatingGroupRootState } from "../rating-group.svelte.js";
import RatingGroupInput from "./rating-group-input.svelte";
import { createId } from "../../../internal/create-id.js";
import { noop } from "../../../internal/noop.js";
const uid = $props.id();
let {
disabled = false,
children,
child,
value = $bindable(0),
ref = $bindable(null),
orientation = "horizontal",
name = undefined,
required = false,
min = 0,
max = 5,
allowHalf = false,
readonly = false,
id = createId(uid),
onValueChange = noop,
"aria-label": ariaLabel,
"aria-valuetext": ariaValuetextProp,
hoverPreview = true,
...restProps
}: RatingGroupRootProps = $props();
if (value < min || value > max) {
value = Math.max(min, Math.min(max, value));
}
const ariaValuetext: NonNullable<RatingGroupRootProps["aria-valuetext"]> = $derived.by(() => {
if (ariaValuetextProp) return ariaValuetextProp;
return (value: number, max: number) => `${value} out of ${max}`;
});
const rootState = RatingGroupRootState.create({
orientation: boxWith(() => orientation),
disabled: boxWith(() => disabled),
name: boxWith(() => name),
required: boxWith(() => required),
min: boxWith(() => min),
max: boxWith(() => max),
allowHalf: boxWith(() => allowHalf),
readonly: boxWith(() => readonly),
id: boxWith(() => id),
value: boxWith(
() => value,
(v) => {
if (v === value) return;
value = v;
onValueChange?.(v);
}
),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
ariaValuetext: boxWith(() => ariaValuetext),
hoverPreview: boxWith(() => hoverPreview),
});
const mergedProps = $derived(
mergeProps(restProps, rootState.props, { "aria-label": ariaLabel })
);
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
{/if}
<RatingGroupInput />
@@ -0,0 +1,4 @@
import type { RatingGroupRootProps } from "../types.js";
declare const RatingGroup: import("svelte").Component<RatingGroupRootProps, {}, "value" | "ref">;
type RatingGroup = ReturnType<typeof RatingGroup>;
export default RatingGroup;
+3
View File
@@ -0,0 +1,3 @@
export { default as Root } from "./components/rating-group.svelte";
export { default as Item } from "./components/rating-group-item.svelte";
export type { RatingGroupRootProps as RootProps, RatingGroupItemProps as ItemProps, } from "./types.js";
+2
View File
@@ -0,0 +1,2 @@
export { default as Root } from "./components/rating-group.svelte";
export { default as Item } from "./components/rating-group-item.svelte";
+1
View File
@@ -0,0 +1 @@
export * as RatingGroup from "./exports.js";
+1
View File
@@ -0,0 +1 @@
export * as RatingGroup from "./exports.js";
+115
View File
@@ -0,0 +1,115 @@
import { DOMContext, type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt";
import type { BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, RefAttachment, WithRefOpts } from "../../internal/types.js";
import type { RatingGroupAriaValuetext, RatingGroupItemState as RatingGroupItemStateType } from "./types.js";
import type { Orientation } from "../../shared/index.js";
interface RatingGroupRootStateOpts extends WithRefOpts, ReadableBoxedValues<{
disabled: boolean;
required: boolean;
orientation: Orientation;
name: string | undefined;
min: number;
max: number;
allowHalf: boolean;
readonly: boolean;
hoverPreview: boolean;
ariaValuetext: NonNullable<RatingGroupAriaValuetext>;
}>, WritableBoxedValues<{
value: number;
}> {
}
export declare class RatingGroupRootState {
#private;
static create(opts: RatingGroupRootStateOpts): RatingGroupRootState;
readonly opts: RatingGroupRootStateOpts;
readonly attachment: RefAttachment;
domContext: DOMContext;
readonly hasValue: boolean;
readonly valueToUse: number;
readonly isRTL: boolean;
readonly ariaValuetext: string;
readonly items: {
index: number;
state: RatingGroupItemStateType;
}[];
constructor(opts: RatingGroupRootStateOpts);
isActive(itemIndex: number): boolean;
isPartial(itemIndex: number): boolean;
setHoverValue(value: number | null): void;
setValue(value: number): void;
calculateRatingFromPointer(itemIndex: number, event: {
clientX: number;
clientY: number;
currentTarget: HTMLElement;
}): number;
onpointerleave(): void;
readonly handlers: Record<string, () => void>;
onkeydown(e: BitsKeyboardEvent): void;
readonly snippetProps: {
items: {
index: number;
state: RatingGroupItemStateType;
}[];
value: number;
max: number;
};
readonly props: {
readonly id: string;
readonly role: "slider";
readonly "aria-valuenow": number;
readonly "aria-valuemin": number;
readonly "aria-valuemax": number;
readonly "aria-valuetext": string;
readonly "aria-orientation": Orientation;
readonly "aria-required": "true" | "false";
readonly "aria-disabled": "true" | undefined;
readonly "aria-label": "Rating";
readonly "data-disabled": "" | undefined;
readonly "data-readonly": "" | undefined;
readonly "data-orientation": Orientation;
readonly tabindex: 0 | -1;
readonly onkeydown: (e: BitsKeyboardEvent) => void;
readonly onpointerleave: () => void;
};
}
interface RatingGroupItemStateOpts extends WithRefOpts, ReadableBoxedValues<{
disabled: boolean;
index: number;
}> {
}
export declare class RatingGroupItemState {
#private;
static create(opts: RatingGroupItemStateOpts): RatingGroupItemState;
readonly opts: RatingGroupItemStateOpts;
readonly root: RatingGroupRootState;
readonly attachment: RefAttachment;
constructor(opts: RatingGroupItemStateOpts, root: RatingGroupRootState);
onclick(e: BitsMouseEvent): void;
onpointermove(e: BitsPointerEvent): void;
readonly snippetProps: {
readonly state: RatingGroupItemStateType;
};
readonly props: {
readonly id: string;
readonly role: "presentation";
readonly "data-value": number;
readonly "data-orientation": Orientation;
readonly "data-disabled": "" | undefined;
readonly "data-readonly": "" | undefined;
readonly "data-state": RatingGroupItemStateType;
readonly onclick: (e: BitsMouseEvent) => void;
readonly onpointermove: (e: BitsPointerEvent) => void;
};
}
export declare class RatingGroupHiddenInputState {
static create(): RatingGroupHiddenInputState;
readonly root: RatingGroupRootState;
readonly shouldRender: boolean;
readonly props: {
readonly name: string | undefined;
readonly value: number;
readonly required: boolean;
readonly disabled: boolean;
};
constructor(root: RatingGroupRootState);
}
export {};
+320
View File
@@ -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;
}
}
+111
View File
@@ -0,0 +1,111 @@
import type { OnChangeFn, WithChild, Without } from "../../internal/types.js";
import type { Orientation } from "../../index.js";
import type { BitsPrimitiveDivAttributes } from "../../shared/attributes.js";
export type RatingGroupItemState = "active" | "partial" | "inactive";
export type RatingGroupItemData = {
index: number;
state: RatingGroupItemState;
};
export type RatingGroupRootSnippetProps = {
items: RatingGroupItemData[];
value: number;
max: number;
};
export type RatingGroupAriaValuetext = BitsPrimitiveDivAttributes["aria-valuetext"] | ((value: number, max: number) => string);
export type RatingGroupRootPropsWithoutHTML = WithChild<{
/**
* The orientation of the rating group. Used to determine
* how keyboard interactions work.
*
* @default "horizontal"
*/
orientation?: Orientation;
/**
* The current rating value.
*
* @default 0
*/
value?: number;
/**
* The callback to call when the rating value changes.
*/
onValueChange?: OnChangeFn<number>;
/**
* The name to apply to the rating group's input element for
* form submission. If not provided, a hidden input will not
* be rendered and the rating group will not be part of a form.
*
* @default undefined
*/
name?: string;
/**
* Whether the rating group is disabled.
*
* @default false
*/
disabled?: boolean;
/**
* Whether the rating group is required for form submission.
* If `true`, ensure you provide a `name` prop so the hidden
* input is rendered.
*
* @default false
*/
required?: boolean;
/**
* The minimum rating value.
*
* @default 0
*/
min?: number;
/**
* The maximum rating value (number of items).
*
* @default 5
*/
max?: number;
/**
* Whether to allow half-star ratings.
*
* @default false
*/
allowHalf?: boolean;
/**
* Whether the rating group is readonly.
*
* @default false
*/
readonly?: boolean;
/**
* Whether to show a preview when hovering over rating items.
* Touch events are ignored to prevent accidental previews.
*
* @default true
*/
hoverPreview?: boolean;
/**
* An extended `aria-valuetext` property to use for the rating group.
* Can either be a string, or a function that receives the current value
* and max value and returns a string.
*
* @default ((value: number, max: number) => `${value} out of ${max}`)
*/
"aria-valuetext"?: RatingGroupAriaValuetext;
}, RatingGroupRootSnippetProps>;
export type RatingGroupRootProps = RatingGroupRootPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, RatingGroupRootPropsWithoutHTML>;
export type RatingGroupItemSnippetProps = {
state: RatingGroupItemState;
};
export type RatingGroupItemPropsWithoutHTML = WithChild<{
/**
* The index of the rating item (0-based index).
*/
index: number;
/**
* Whether the rating item is disabled.
*
* @default false
*/
disabled?: boolean | null | undefined;
}, RatingGroupItemSnippetProps>;
export type RatingGroupItemProps = RatingGroupItemPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, RatingGroupItemPropsWithoutHTML>;
+1
View File
@@ -0,0 +1 @@
export {};