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.
597 lines
18 KiB
597 lines
18 KiB
import { context, type WithRefOpts } from '../../provider';
|
|
import { boolToEmptyStrOrUndef } from '$adom';
|
|
import { state, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
|
|
import type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
|
|
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import { STEPPER_LANGS } from './langs';
|
|
|
|
import { stepperMorfo } from '../../../morfo/components/stepper';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
export type StepState = 'complete' | 'current' | 'incomplete';
|
|
|
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperOpts
|
|
extends
|
|
WithRefOpts,
|
|
StateProps<{ value: number }>,
|
|
ActiveProps<{
|
|
orientation: Orientation;
|
|
dir: Direction;
|
|
linear: boolean;
|
|
}> {}
|
|
|
|
export class StepperProvider {
|
|
readonly opts: StepperOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<StepperProvider>('Stepper');
|
|
static get(): StepperProvider | undefined {
|
|
return this.ctx.getOr(undefined) as StepperProvider | undefined;
|
|
}
|
|
static require(): StepperProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: StepperOpts) {
|
|
return new StepperProvider(opts);
|
|
}
|
|
|
|
private _count = $state(0);
|
|
get count(): number {
|
|
return this._count;
|
|
}
|
|
|
|
incrementCount() {
|
|
this._count++;
|
|
}
|
|
decrementCount() {
|
|
this._count = Math.max(0, this._count - 1);
|
|
}
|
|
|
|
// Trigger registry for keyboard nav (avoids DOM queries)
|
|
private triggerRegistry = new Map<number, HTMLElement>();
|
|
|
|
registerTrigger(index: number, el: HTMLElement) {
|
|
this.triggerRegistry.set(index, el);
|
|
}
|
|
unregisterTrigger(index: number) {
|
|
this.triggerRegistry.delete(index);
|
|
}
|
|
|
|
/** Get ordered trigger elements from registry, skipping disabled. */
|
|
getRegisteredTriggers(): HTMLElement[] {
|
|
return [...this.triggerRegistry.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([, el]) => el)
|
|
.filter((el) => el.getAttribute('aria-disabled') !== 'true');
|
|
}
|
|
|
|
private constructor(opts: StepperOpts) {
|
|
this.opts = opts;
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(stepperMorfo, {
|
|
props: {
|
|
orientation: () => this.opts.orientation.current,
|
|
linear: () => this.opts.linear.current
|
|
}
|
|
});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: StepperProvider.ctx,
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
get step(): number {
|
|
return this.opts.value.current;
|
|
}
|
|
|
|
readonly isCompleted = $derived.by(() => this.opts.value.current >= this.count);
|
|
|
|
readonly percent = $derived.by(() => {
|
|
if (this.count <= 0) return 0;
|
|
return Math.round((this.opts.value.current / this.count) * 100);
|
|
});
|
|
|
|
readonly hasNextStep = $derived.by(() => this.opts.value.current < this.count);
|
|
readonly hasPrevStep = $derived.by(() => this.opts.value.current > 0);
|
|
|
|
setStep(step: number) {
|
|
const clamped = Math.max(0, Math.floor(step));
|
|
if (clamped > this.count) return;
|
|
this.opts.value.current = clamped;
|
|
}
|
|
|
|
goToNextStep() {
|
|
if (this.hasNextStep) this.setStep(this.step + 1);
|
|
}
|
|
|
|
goToPrevStep() {
|
|
if (this.hasPrevStep) this.setStep(this.step - 1);
|
|
}
|
|
|
|
getStepState(index: number): StepState {
|
|
if (index < this.step) return 'complete';
|
|
if (index === this.step) return 'current';
|
|
return 'incomplete';
|
|
}
|
|
|
|
canSelectStep(index: number): boolean {
|
|
if (!this.opts.linear.current) return true;
|
|
// In linear mode, can only go to completed steps or the next one
|
|
return index <= this.step;
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
dir: this.opts.dir.current
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperListOpts extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
|
|
|
|
export class StepperListProvider {
|
|
readonly opts: StepperListOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperListOpts) {
|
|
return new StepperListProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperListOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('list', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
role: 'tablist' as const,
|
|
'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(STEPPER_LANGS.LIST),
|
|
'aria-orientation': this.provider.opts.orientation.current,
|
|
'data-orientation': this.provider.opts.orientation.current
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Item ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperItemOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
index: number;
|
|
}> {}
|
|
|
|
export class StepperItemProvider {
|
|
readonly opts: StepperItemOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static readonly ctx = context<StepperItemProvider>('StepperItem');
|
|
static get(): StepperItemProvider | undefined {
|
|
return this.ctx.getOr(undefined) as StepperItemProvider | undefined;
|
|
}
|
|
static require(): StepperItemProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: StepperItemOpts) {
|
|
return new StepperItemProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
triggerId = state('');
|
|
contentId = state('');
|
|
|
|
private constructor(opts: StepperItemOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('item', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: StepperItemProvider.ctx,
|
|
props: {
|
|
state: () => this.stepState,
|
|
orientation: () => this.provider.opts.orientation.current,
|
|
current: () => this.isCurrent,
|
|
complete: () => this.isComplete,
|
|
incomplete: () => this.isIncomplete
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
|
|
// Track count: increment on mount, decrement on unmount
|
|
this.provider.incrementCount();
|
|
$effect(() => {
|
|
return () => this.provider.decrementCount();
|
|
});
|
|
}
|
|
|
|
readonly stepState = $derived.by(() => this.provider.getStepState(this.opts.index.current));
|
|
|
|
readonly isCurrent = $derived.by(() => this.stepState === 'current');
|
|
readonly isComplete = $derived.by(() => this.stepState === 'complete');
|
|
readonly isIncomplete = $derived.by(() => this.stepState === 'incomplete');
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Trigger ──────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperTriggerOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
disabled: boolean;
|
|
}> {}
|
|
|
|
export class StepperTriggerProvider {
|
|
readonly opts: StepperTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperTriggerOpts) {
|
|
return new StepperTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
readonly item: StepperItemProvider;
|
|
|
|
private constructor(opts: StepperTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.item = StepperItemProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
this.item.triggerId.current = opts.id.current;
|
|
|
|
// Register in trigger registry for keyboard nav
|
|
$effect(() => {
|
|
const el = opts.ref.current;
|
|
if (el) this.provider.registerTrigger(this.item.opts.index.current, el);
|
|
return () => this.provider.unregisterTrigger(this.item.opts.index.current);
|
|
});
|
|
}
|
|
|
|
private get index(): number {
|
|
return this.item.opts.index.current;
|
|
}
|
|
|
|
readonly isDisabled = $derived.by(() => {
|
|
if (this.opts.disabled.current) return true;
|
|
return !this.provider.canSelectStep(this.index);
|
|
});
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
|
|
if (this.isDisabled) return;
|
|
this.provider.setStep(this.index);
|
|
};
|
|
|
|
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
|
|
if (this.isDisabled) return;
|
|
|
|
const orientation = this.provider.opts.orientation.current;
|
|
const dir = this.provider.opts.dir.current;
|
|
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
|
|
const triggers = this.provider.getRegisteredTriggers();
|
|
const currentIndex = triggers.indexOf(e.currentTarget as HTMLElement);
|
|
|
|
let targetIndex = -1;
|
|
|
|
if (e.key === nextKey) {
|
|
e.preventDefault();
|
|
targetIndex = Math.min(currentIndex + 1, triggers.length - 1);
|
|
} else if (e.key === prevKey) {
|
|
e.preventDefault();
|
|
targetIndex = Math.max(currentIndex - 1, 0);
|
|
} else if (e.key === KEYS.HOME) {
|
|
e.preventDefault();
|
|
targetIndex = 0;
|
|
} else if (e.key === KEYS.END) {
|
|
e.preventDefault();
|
|
targetIndex = triggers.length - 1;
|
|
}
|
|
|
|
if (targetIndex >= 0 && triggers[targetIndex]) {
|
|
this.provider.soma.dom.focus(triggers[targetIndex]);
|
|
}
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
role: 'tab' as const,
|
|
type: 'button' as const,
|
|
'aria-controls': this.item.contentId.current || undefined,
|
|
'aria-current': this.item.isCurrent ? ('step' as const) : undefined,
|
|
'aria-disabled': this.isDisabled || undefined,
|
|
tabindex: this.item.isCurrent ? 0 : -1,
|
|
'data-state': this.item.stepState,
|
|
'data-current': boolToEmptyStrOrUndef(this.item.isCurrent),
|
|
'data-complete': boolToEmptyStrOrUndef(this.item.isComplete),
|
|
'data-incomplete': boolToEmptyStrOrUndef(this.item.isIncomplete),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-orientation': this.provider.opts.orientation.current,
|
|
onclick: this.onclick,
|
|
onkeydown: this.onkeydown
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Indicator ────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperIndicatorOpts extends WithRefOpts {}
|
|
|
|
export class StepperIndicatorProvider {
|
|
readonly opts: StepperIndicatorOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperIndicatorOpts) {
|
|
return new StepperIndicatorProvider(opts);
|
|
}
|
|
|
|
readonly item: StepperItemProvider;
|
|
|
|
private constructor(opts: StepperIndicatorOpts) {
|
|
this.opts = opts;
|
|
this.item = StepperItemProvider.require();
|
|
this.runtimePart = this.item.provider.runtime.part('indicator', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
props: {
|
|
state: () => this.item.stepState,
|
|
current: () => this.item.isCurrent,
|
|
complete: () => this.item.isComplete,
|
|
incomplete: () => this.item.isIncomplete
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
/** Proxy to `item.stepState` so the wrapper snippet doesn't need `ctx.get()`. */
|
|
readonly stepState = $derived.by(() => this.item.stepState);
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Separator ────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperSeparatorOpts extends WithRefOpts {}
|
|
|
|
export class StepperSeparatorProvider {
|
|
readonly opts: StepperSeparatorOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperSeparatorOpts) {
|
|
return new StepperSeparatorProvider(opts);
|
|
}
|
|
|
|
readonly item: StepperItemProvider;
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperSeparatorOpts) {
|
|
this.opts = opts;
|
|
this.item = StepperItemProvider.require();
|
|
this.provider = this.item.provider;
|
|
this.runtimePart = this.provider.runtime.part('separator', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
props: {
|
|
state: () => this.item.stepState,
|
|
orientation: () => this.provider.opts.orientation.current,
|
|
current: () => this.item.isCurrent,
|
|
complete: () => this.item.isComplete,
|
|
incomplete: () => this.item.isIncomplete
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Content ──────────────────────────────────────────────────────────────────
|
|
|
|
interface StepperContentOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
index: number;
|
|
}> {}
|
|
|
|
export class StepperContentProvider {
|
|
readonly opts: StepperContentOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperContentOpts) {
|
|
return new StepperContentProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperContentOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('content', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
props: {
|
|
state: () => this.stepState
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
// Content can be inside or outside an Item — use get() not require()
|
|
const item = StepperItemProvider.get();
|
|
if (item) item.contentId.current = opts.id.current;
|
|
}
|
|
|
|
readonly isCurrent = $derived.by(() => this.provider.step === this.opts.index.current);
|
|
|
|
readonly stepState = $derived.by(() => this.provider.getStepState(this.opts.index.current));
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
tabindex: this.isCurrent ? 0 : -1
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── CompletedContent ─────────────────────────────────────────────────────────
|
|
|
|
interface StepperCompletedContentOpts extends WithRefOpts {}
|
|
|
|
export class StepperCompletedContentProvider {
|
|
readonly opts: StepperCompletedContentOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperCompletedContentOpts) {
|
|
return new StepperCompletedContentProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperCompletedContentOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('completed-content', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
props: {
|
|
state: () => 'completed'
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
/** Proxy to `provider.isCompleted` so the wrapper doesn't need `ctx.get()`. */
|
|
readonly isCompleted = $derived.by(() => this.provider.isCompleted);
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── PrevTrigger ──────────────────────────────────────────────────────────────
|
|
|
|
interface StepperPrevTriggerOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
disabled: boolean;
|
|
}> {}
|
|
|
|
export class StepperPrevTriggerProvider {
|
|
readonly opts: StepperPrevTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperPrevTriggerOpts) {
|
|
return new StepperPrevTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperPrevTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('prev-trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly isDisabled = $derived.by(() => this.opts.disabled.current || !this.provider.hasPrevStep);
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
if (this.isDisabled) return;
|
|
this.provider.goToPrevStep();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
disabled: this.isDisabled || undefined,
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── NextTrigger ──────────────────────────────────────────────────────────────
|
|
|
|
interface StepperNextTriggerOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
disabled: boolean;
|
|
}> {}
|
|
|
|
export class StepperNextTriggerProvider {
|
|
readonly opts: StepperNextTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: StepperNextTriggerOpts) {
|
|
return new StepperNextTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: StepperProvider;
|
|
|
|
private constructor(opts: StepperNextTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = StepperProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('next-trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly isDisabled = $derived.by(() => this.opts.disabled.current || !this.provider.hasNextStep);
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
if (this.isDisabled) return;
|
|
this.provider.goToNextStep();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
disabled: this.isDisabled || undefined,
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|