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.
svelte-kit-vice/src/uix/soma/components/rating-group/rating-group-provider.svelt...

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)
);
}

Powered by TurnKey Linux.