You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
346 lines
11 KiB
346 lines
11 KiB
import { context, type WithRefOpts } from '../../provider';
|
|
import { boolToEmptyStrOrUndef } from '$adom';
|
|
import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
|
|
import type {
|
|
Direction,
|
|
OnChangeFn,
|
|
Orientation,
|
|
SomaKeyboardEvent,
|
|
SomaMouseEvent,
|
|
SomaPointerEvent
|
|
} from '../../types';
|
|
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import { FieldProvider } from '../field/field-provider.svelte';
|
|
import { RATING_GROUP_LANGS } from './langs';
|
|
import type { RatingItemDescriptor, RatingItemState } from './types';
|
|
|
|
import { ratingGroupMorfo } from '../../../morfo/components/rating-group';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function quantize(value: number, allowHalf: boolean): number {
|
|
if (!allowHalf) return Math.round(value);
|
|
return Math.round(value * 2) / 2;
|
|
}
|
|
|
|
// ── Root provider ──────────────────────────────────────────────────────────
|
|
|
|
interface RatingGroupOpts
|
|
extends
|
|
WithRefOpts,
|
|
StateProps<{ value: number }>,
|
|
ActiveProps<{
|
|
min: number;
|
|
max: number;
|
|
allowHalf: boolean;
|
|
hoverPreview: boolean;
|
|
clearable: boolean;
|
|
disabled: boolean;
|
|
readonly: boolean;
|
|
required: boolean;
|
|
invalid: boolean;
|
|
orientation: Orientation;
|
|
dir: Direction;
|
|
name: string | undefined;
|
|
ariaLabel: string | undefined;
|
|
onValueChange: OnChangeFn<number> | undefined;
|
|
}> {}
|
|
|
|
export class RatingGroupProvider {
|
|
readonly opts: RatingGroupOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<RatingGroupProvider>('RatingGroup');
|
|
static get(): RatingGroupProvider | undefined {
|
|
return this.ctx.getOr(undefined) as RatingGroupProvider | undefined;
|
|
}
|
|
static require(): RatingGroupProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: RatingGroupOpts) {
|
|
return new RatingGroupProvider(opts);
|
|
}
|
|
|
|
readonly field = FieldProvider.get();
|
|
|
|
hoverValue = $state<number | null>(null);
|
|
focused = $state(false);
|
|
|
|
private constructor(opts: RatingGroupOpts) {
|
|
this.opts = opts;
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(ratingGroupMorfo, {});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: RatingGroupProvider.ctx
|
|
});
|
|
}
|
|
|
|
// ── Field-aware flags (OR-merge) ─────────────────────────────────────────
|
|
|
|
readonly isDisabled = $derived.by(
|
|
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
|
|
);
|
|
readonly isReadonly = $derived.by(
|
|
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
|
|
);
|
|
readonly isRequired = $derived.by(
|
|
() => this.opts.required.current || (this.field?.isRequired ?? false)
|
|
);
|
|
readonly isInvalid = $derived.by(
|
|
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
|
|
);
|
|
|
|
// ── Value display logic ──────────────────────────────────────────────────
|
|
|
|
/** Value shown (committed or hover preview). */
|
|
readonly visualValue = $derived.by(() => this.hoverValue ?? this.opts.value.current);
|
|
|
|
readonly items: RatingItemDescriptor[] = $derived.by(() => {
|
|
const visual = this.visualValue;
|
|
const max = this.opts.max.current;
|
|
const allowHalf = this.opts.allowHalf.current;
|
|
return Array.from({ length: max }, (_, i) => {
|
|
const slot = i + 1;
|
|
let state: RatingItemState;
|
|
if (visual >= slot) state = 'active';
|
|
else if (allowHalf && visual >= slot - 0.5) state = 'partial';
|
|
else state = 'inactive';
|
|
return { index: i, state };
|
|
});
|
|
});
|
|
|
|
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
|
|
() => this.opts.ariaLabel.current || this.soma.langs.ts(RATING_GROUP_LANGS.LABEL) || undefined
|
|
);
|
|
|
|
// ── Mutations ────────────────────────────────────────────────────────────
|
|
|
|
private commit(next: number) {
|
|
if (this.isDisabled || this.isReadonly) return;
|
|
const clamped = clamp(
|
|
quantize(next, this.opts.allowHalf.current),
|
|
this.opts.min.current,
|
|
this.opts.max.current
|
|
);
|
|
if (clamped === this.opts.value.current) return;
|
|
this.opts.value.current = clamped;
|
|
this.opts.onValueChange.current?.(clamped);
|
|
}
|
|
|
|
setHover(v: number | null) {
|
|
if (!this.opts.hoverPreview.current || this.isDisabled || this.isReadonly) return;
|
|
this.hoverValue =
|
|
v === null
|
|
? null
|
|
: clamp(
|
|
quantize(v, this.opts.allowHalf.current),
|
|
this.opts.min.current,
|
|
this.opts.max.current
|
|
);
|
|
}
|
|
|
|
clearHover() {
|
|
this.hoverValue = null;
|
|
}
|
|
|
|
/** Value for `index` given pointer position inside the item bounding rect. */
|
|
calcFromPointer(
|
|
index: number,
|
|
e: { clientX: number; clientY: number; currentTarget: HTMLElement }
|
|
): number {
|
|
const slot = index + 1;
|
|
if (!this.opts.allowHalf.current) return slot;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const horizontal = this.opts.orientation.current === 'horizontal';
|
|
const pos = horizontal
|
|
? (e.clientX - rect.left) / rect.width
|
|
: (e.clientY - rect.top) / rect.height;
|
|
return pos < 0.5 ? slot - 0.5 : slot;
|
|
}
|
|
|
|
handleItemClick(index: number, e: SomaMouseEvent<HTMLElement>) {
|
|
if (this.isDisabled || this.isReadonly) return;
|
|
const next = this.calcFromPointer(index, e);
|
|
// Clearable: clicking the current value (when min is 0) resets.
|
|
if (
|
|
this.opts.clearable.current &&
|
|
this.opts.min.current === 0 &&
|
|
next === this.opts.value.current &&
|
|
this.opts.value.current > 0
|
|
) {
|
|
this.commit(0);
|
|
} else {
|
|
this.commit(next);
|
|
}
|
|
// Refocus the slider root so arrow keys work after pointer interaction.
|
|
this.opts.ref.current?.focus();
|
|
}
|
|
|
|
handleItemPointerMove(index: number, e: SomaPointerEvent<HTMLElement>) {
|
|
if (e.pointerType === 'touch') return;
|
|
this.setHover(this.calcFromPointer(index, e));
|
|
}
|
|
|
|
// ── Root events ──────────────────────────────────────────────────────────
|
|
|
|
readonly onkeydown = (e: SomaKeyboardEvent<HTMLDivElement>) => {
|
|
if (this.isDisabled || this.isReadonly) return;
|
|
const step = this.opts.allowHalf.current ? 0.5 : 1;
|
|
const key = e.key;
|
|
// APG slider pattern: horizontal increment flips under RTL, vertical
|
|
// UP/DOWN are absolute (UP always increments).
|
|
const { nextKey, prevKey } = getDirectionalKeys(this.opts.dir.current, 'horizontal');
|
|
const isIncrement = key === nextKey || key === KEYS.ARROW_UP;
|
|
const isDecrement = key === prevKey || key === KEYS.ARROW_DOWN;
|
|
|
|
if (isIncrement) {
|
|
e.preventDefault();
|
|
this.clearHover();
|
|
this.commit(this.opts.value.current + step);
|
|
} else if (isDecrement) {
|
|
e.preventDefault();
|
|
this.clearHover();
|
|
this.commit(this.opts.value.current - step);
|
|
} else if (e.key === KEYS.HOME) {
|
|
e.preventDefault();
|
|
this.commit(this.opts.min.current);
|
|
} else if (e.key === KEYS.END) {
|
|
e.preventDefault();
|
|
this.commit(this.opts.max.current);
|
|
} else {
|
|
const n = Number(e.key);
|
|
if (
|
|
!Number.isNaN(n) &&
|
|
n >= this.opts.min.current &&
|
|
n <= this.opts.max.current &&
|
|
Number.isInteger(n)
|
|
) {
|
|
e.preventDefault();
|
|
this.commit(n);
|
|
}
|
|
}
|
|
};
|
|
|
|
readonly onpointerleave = () => {
|
|
this.clearHover();
|
|
};
|
|
|
|
readonly onfocus = () => {
|
|
this.focused = true;
|
|
};
|
|
readonly onblur = () => {
|
|
this.focused = false;
|
|
};
|
|
|
|
// ── Root props ───────────────────────────────────────────────────────────
|
|
|
|
readonly snippetProps = $derived.by(() => ({
|
|
items: this.items,
|
|
value: this.opts.value.current,
|
|
max: this.opts.max.current,
|
|
min: this.opts.min.current,
|
|
isFocused: this.focused,
|
|
isHovering: this.hoverValue !== null
|
|
}));
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
role: 'slider' as const,
|
|
'aria-valuenow': this.opts.value.current,
|
|
'aria-valuemin': this.opts.min.current,
|
|
'aria-valuemax': this.opts.max.current,
|
|
'aria-orientation': this.opts.orientation.current,
|
|
'aria-required': this.isRequired || undefined,
|
|
'aria-disabled': this.isDisabled || undefined,
|
|
'aria-readonly': this.isReadonly || undefined,
|
|
'aria-invalid': this.isInvalid || undefined,
|
|
'aria-label': this.resolvedAriaLabel.current,
|
|
tabindex: this.isDisabled ? -1 : 0,
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
|
|
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
|
|
'data-required': boolToEmptyStrOrUndef(this.isRequired),
|
|
'data-focused': boolToEmptyStrOrUndef(this.focused),
|
|
'data-orientation': this.opts.orientation.current,
|
|
onkeydown: this.onkeydown,
|
|
onpointerleave: this.onpointerleave,
|
|
onfocus: this.onfocus,
|
|
onblur: this.onblur
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Item ───────────────────────────────────────────────────────────────────
|
|
|
|
interface RatingGroupItemOpts
|
|
extends WithRefOpts, ActiveProps<{ index: number; disabled: boolean }> {}
|
|
|
|
export class RatingGroupItemProvider {
|
|
readonly opts: RatingGroupItemOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: RatingGroupItemOpts) {
|
|
return new RatingGroupItemProvider(opts);
|
|
}
|
|
|
|
readonly provider: RatingGroupProvider;
|
|
|
|
private constructor(opts: RatingGroupItemOpts) {
|
|
this.opts = opts;
|
|
this.provider = RatingGroupProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('item', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly isDisabled = $derived.by(() => this.opts.disabled.current || this.provider.isDisabled);
|
|
|
|
readonly state: RatingItemState = $derived.by(() => {
|
|
const i = this.opts.index.current;
|
|
return this.provider.items[i]?.state ?? 'inactive';
|
|
});
|
|
|
|
readonly snippetProps = $derived.by(() => ({
|
|
index: this.opts.index.current,
|
|
state: this.state
|
|
}));
|
|
|
|
readonly onclick = (e: SomaMouseEvent<HTMLSpanElement>) => {
|
|
if (this.isDisabled) return;
|
|
this.provider.handleItemClick(this.opts.index.current, e);
|
|
};
|
|
|
|
readonly onpointermove = (e: SomaPointerEvent<HTMLSpanElement>) => {
|
|
if (this.isDisabled) return;
|
|
this.provider.handleItemPointerMove(this.opts.index.current, e);
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
role: 'presentation' as const,
|
|
'data-state': this.state,
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
|
|
'data-orientation': this.provider.opts.orientation.current,
|
|
'data-value': this.opts.index.current + 1,
|
|
onclick: this.onclick,
|
|
onpointermove: this.onpointermove
|
|
} as const)
|
|
);
|
|
}
|