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/carousel/carousel-provider.svelte.ts

709 lines
23 KiB

import { context, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn, Orientation, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { Gesture } from '../../layers/gesture';
import { CAROUSEL_LANGS } from './langs';
import { carouselMorfo } from '../../../morfo/components/carousel';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(carouselMorfo);
// ── Root provider ──────────────────────────────────────────────────────────
interface CarouselOpts
extends
WithRefOpts,
StateProps<{ value: number }>,
ActiveProps<{
orientation: Orientation;
slidesPerView: number;
slidesToScroll: number;
align: 'start' | 'center' | 'end';
gap: string;
loop: boolean;
dragEnabled: boolean;
dragThreshold: number;
autoplay: boolean;
autoplayDelay: number;
playOnInit: boolean;
pauseOnHover: boolean;
stopOnInteraction: boolean;
stopOnFocusIn: boolean;
stopOnMouseEnter: boolean;
stopOnLastSnap: boolean;
disabled: boolean;
ariaLabel: string | undefined;
onValueChange: OnChangeFn<number> | undefined;
}> {}
export class CarouselProvider {
readonly opts: CarouselOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<CarouselProvider>('Carousel');
static get(): CarouselProvider | undefined {
return this.ctx.getOr(undefined) as CarouselProvider | undefined;
}
static require(): CarouselProvider {
return this.ctx.get();
}
static create(opts: CarouselOpts) {
return new CarouselProvider(opts);
}
// Refs — set by the respective child providers.
viewportRef = $state<HTMLElement | null>(null);
itemGroupRef = $state<HTMLElement | null>(null);
// Drag state (px offset during active drag).
dragOffset = $state(0);
// Autoplay pause state.
hovering = $state(false);
focused = $state(false);
/** User explicitly stopped (via play/pause API or stopOnInteraction). */
userStopped = $state(false);
/** Latched once interaction occurred when stopOnInteraction is true. */
interactionStopped = $state(false);
readonly gesture: ReturnType<typeof Gesture.base>;
private constructor(opts: CarouselOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(carouselMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: CarouselProvider.ctx
});
// Apply `playOnInit=false` — start in user-stopped state so the
// timer effect doesn't tick until the consumer calls `play()`.
if (opts.autoplay.current && opts.playOnInit.current === false) {
this.userStopped = true;
}
this.gesture = Gesture.base({
dom: this.soma.dom,
ref: readableActive(() => this.itemGroupRef),
enabled: readableActive(() => !this.isDisabled && this.opts.dragEnabled.current),
axis: readableActive(() => (this.opts.orientation.current === 'horizontal' ? 'x' : 'y')),
onCancel: () => {
this.dragOffset = 0;
}
});
// ── Autoplay ─────────────────────────────────────────────────────────
$effect(() => {
if (!opts.autoplay.current) return;
if (this.isDisabled) return;
if (!this.isPlaying) return;
const handle = this.soma.uix.timers.interval(
this.autoplayTimerKey,
opts.autoplayDelay.current,
() => {
if (this.opts.stopOnLastSnap.current && !this.opts.loop.current) {
if (this.opts.value.current >= this.slideCount - 1) {
this.userStopped = true;
return;
}
}
this.next();
},
{ replace: true, meta: { component: 'carousel', action: 'autoplay' } }
);
return () => handle.cancel();
});
}
private get autoplayTimerKey() {
return `soma:carousel:${this.opts.id.current}:autoplay`;
}
// ── Slide detection (live DOM query) ────────────────────────────────────
getItems(): HTMLElement[] {
const group = this.itemGroupRef;
if (!group) return [];
return Array.from(group.querySelectorAll<HTMLElement>(`[${attrs.item}]`));
}
readonly slideCount = $derived.by(() => this.getItems().length);
// ── Flags ────────────────────────────────────────────────────────────────
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly canGoPrev = $derived.by(() => this.opts.loop.current || this.opts.value.current > 0);
readonly canGoNext = $derived.by(
() => this.opts.loop.current || this.opts.value.current < this.slideCount - 1
);
/** Combined autoplay-paused state — true means the timer should not tick. */
readonly isPlaying = $derived.by(() => {
if (!this.opts.autoplay.current) return false;
if (this.isDisabled) return false;
if (this.userStopped) return false;
if (this.interactionStopped) return false;
if (this.opts.pauseOnHover.current && this.hovering) return false;
if (this.opts.stopOnMouseEnter.current && this.hovering) return false;
if (this.opts.stopOnFocusIn.current && this.focused) return false;
return true;
});
/** Public name kept for back-compat; `isPlaying` is the canonical signal. */
readonly paused = $derived.by(() => !this.isPlaying);
// ── Geometry ─────────────────────────────────────────────────────────────
/** Size (px) of the viewport along the primary axis. */
readonly viewportSize = $derived.by(() => {
const vp = this.viewportRef;
if (!vp) return 0;
return this.opts.orientation.current === 'horizontal' ? vp.clientWidth : vp.clientHeight;
});
/** Slide size (px) along the primary axis = viewport / slidesPerView. */
readonly slideSize = $derived.by(() => {
const spv = Math.max(1, this.opts.slidesPerView.current);
return this.viewportSize / spv;
});
/** Align offset (px) applied to the translate so the active slide(s) line up. */
readonly alignOffset = $derived.by(() => {
const align = this.opts.align.current;
const spv = Math.max(1, this.opts.slidesPerView.current);
if (align === 'start' || spv <= 1) return 0;
const free = this.viewportSize - this.slideSize;
if (align === 'center') return free / 2;
if (align === 'end') return free;
return 0;
});
/** Translate value (px) for the item-group. */
readonly translate = $derived.by(() => {
const base = -this.opts.value.current * this.slideSize + this.alignOffset;
return base + this.dragOffset;
});
readonly itemGroupTransform = $derived.by(() => {
const v = this.translate;
return this.opts.orientation.current === 'horizontal'
? `translate3d(${v}px, 0, 0)`
: `translate3d(0, ${v}px, 0)`;
});
// ── Navigation ───────────────────────────────────────────────────────────
commit(next: number) {
if (this.isDisabled) return;
const count = this.slideCount;
if (count === 0) return;
const spv = Math.max(1, this.opts.slidesPerView.current);
const lastSnap = Math.max(0, count - spv);
let target: number;
if (this.opts.loop.current) {
const wrap = lastSnap + 1;
target = ((next % wrap) + wrap) % wrap;
} else {
target = Math.max(0, Math.min(lastSnap, next));
}
if (target === this.opts.value.current) return;
this.opts.value.current = target;
this.opts.onValueChange.current?.(target);
}
/** Mark this navigation as user-driven so `stopOnInteraction` can latch. */
private noteInteraction() {
if (this.opts.stopOnInteraction.current && this.opts.autoplay.current) {
this.interactionStopped = true;
}
}
next() {
const step = Math.max(1, this.opts.slidesToScroll.current);
this.commit(this.opts.value.current + step);
}
prev() {
const step = Math.max(1, this.opts.slidesToScroll.current);
this.commit(this.opts.value.current - step);
}
goto(index: number) {
this.commit(index);
}
// ── Imperative API surface ───────────────────────────────────────────────
scrollNext() {
this.noteInteraction();
this.next();
}
scrollPrev() {
this.noteInteraction();
this.prev();
}
scrollTo(index: number) {
this.noteInteraction();
this.commit(index);
}
play() {
this.userStopped = false;
this.interactionStopped = false;
}
pause() {
this.userStopped = true;
}
reset() {
this.userStopped = false;
this.interactionStopped = false;
}
// ── Keyboard ─────────────────────────────────────────────────────────────
readonly onkeydown = (e: SomaKeyboardEvent<HTMLDivElement>) => {
if (this.isDisabled) return;
const { nextKey, prevKey } = getDirectionalKeys('ltr', this.opts.orientation.current);
if (e.key === nextKey) {
e.preventDefault();
this.scrollNext();
} else if (e.key === prevKey) {
e.preventDefault();
this.scrollPrev();
} else if (e.key === KEYS.HOME) {
e.preventDefault();
this.scrollTo(0);
} else if (e.key === KEYS.END) {
e.preventDefault();
const spv = Math.max(1, this.opts.slidesPerView.current);
this.scrollTo(Math.max(0, this.slideCount - spv));
}
};
readonly onpointerenter = () => {
this.hovering = true;
};
readonly onpointerleave = () => {
this.hovering = false;
};
readonly onfocusin = () => {
this.focused = true;
};
readonly onfocusout = (e: FocusEvent) => {
const next = e.relatedTarget as Node | null;
if (next && this.opts.ref.current?.contains(next)) return;
this.focused = false;
};
// ── Drag release → commit ────────────────────────────────────────────────
/** Called by the ItemGroup's drag lifecycle when the gesture ends. */
finishDrag() {
const size = this.slideSize;
if (size === 0) {
this.dragOffset = 0;
return;
}
const horizontal = this.opts.orientation.current === 'horizontal';
const offset = horizontal ? this.gesture.offsetX : this.gesture.offsetY;
const velocity = horizontal ? this.gesture.velocityX : this.gesture.velocityY;
const fraction = offset / size;
const threshold = this.opts.dragThreshold.current;
const fastSwipe = Math.abs(velocity) > 0.5;
const shouldAdvance = Math.abs(fraction) > threshold || fastSwipe;
this.dragOffset = 0;
if (!shouldAdvance) return;
this.noteInteraction();
// Negative offset = dragged left/up = advance to next slide.
if (offset < 0) this.next();
else this.prev();
}
// ── Snippet + root props ─────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
index: this.opts.value.current,
slideCount: this.slideCount,
canGoNext: this.canGoNext,
canGoPrev: this.canGoPrev,
isDragging: this.gesture.isDragging,
isPlaying: this.isPlaying,
api: {
scrollNext: () => this.scrollNext(),
scrollPrev: () => this.scrollPrev(),
scrollTo: (i: number) => this.scrollTo(i),
play: () => this.play(),
pause: () => this.pause(),
reset: () => this.reset()
}
}));
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
() => this.opts.ariaLabel.current || this.soma.langs.ts(CAROUSEL_LANGS.LABEL) || undefined
);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'region' as const,
'aria-roledescription': 'carousel',
'aria-label': this.resolvedAriaLabel.current,
tabindex: this.isDisabled ? -1 : 0,
'data-orientation': this.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.gesture.isDragging),
'data-autoplay': boolToEmptyStrOrUndef(this.opts.autoplay.current && !this.paused),
onkeydown: this.onkeydown,
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
onfocusin: this.onfocusin,
onfocusout: this.onfocusout
} as const)
);
}
// ── Viewport ───────────────────────────────────────────────────────────────
interface CarouselViewportOpts extends WithRefOpts {}
export class CarouselViewportProvider {
readonly opts: CarouselViewportOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselViewportOpts) {
return new CarouselViewportProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselViewportOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('viewport', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.provider.viewportRef = el;
}
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-orientation': this.provider.opts.orientation.current,
style: { overflow: 'hidden' }
} as const)
);
}
// ── ItemGroup ──────────────────────────────────────────────────────────────
interface CarouselItemGroupOpts extends WithRefOpts {}
export class CarouselItemGroupProvider {
readonly opts: CarouselItemGroupOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselItemGroupOpts) {
return new CarouselItemGroupProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselItemGroupOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('item-group', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.provider.itemGroupRef = el;
}
});
// Drag lifecycle — track offset during drag, commit on release.
let wasDragging = false;
$effect(() => {
const isDragging = this.provider.gesture.isDragging;
if (isDragging) {
const horizontal = this.provider.opts.orientation.current === 'horizontal';
this.provider.dragOffset = horizontal
? this.provider.gesture.offsetX
: this.provider.gesture.offsetY;
} else if (wasDragging) {
this.provider.finishDrag();
}
wasDragging = isDragging;
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-orientation': this.provider.opts.orientation.current,
'data-dragging': boolToEmptyStrOrUndef(this.provider.gesture.isDragging),
style: {
display: 'flex',
'flex-direction':
this.provider.opts.orientation.current === 'horizontal' ? 'row' : 'column',
gap: this.provider.opts.gap.current,
transform: this.provider.itemGroupTransform,
transition: this.provider.gesture.isDragging ? 'none' : 'transform 300ms ease-out',
'touch-action': this.provider.opts.orientation.current === 'horizontal' ? 'pan-y' : 'pan-x'
},
...this.provider.gesture.props
} as const)
);
}
// ── Item ───────────────────────────────────────────────────────────────────
interface CarouselItemOpts extends WithRefOpts, ActiveProps<{ index: number }> {}
export class CarouselItemProvider {
readonly opts: CarouselItemOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselItemOpts) {
return new CarouselItemProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselItemOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isActive = $derived.by(() => {
const i = this.opts.index.current;
const start = this.provider.opts.value.current;
const spv = Math.max(1, this.provider.opts.slidesPerView.current);
return i >= start && i < start + spv;
});
readonly snippetProps = $derived.by(() => ({
index: this.opts.index.current,
isActive: this.isActive
}));
readonly props = $derived.by(() => {
const spv = Math.max(1, this.provider.opts.slidesPerView.current);
const gap = this.provider.opts.gap.current;
// `flex-basis: calc((100% - (spv - 1) * gap) / spv)` keeps slides
// uniformly sized when gap > 0; falls through to `100% / spv` when
// gap is the default `0px`.
const basis = spv === 1 ? '100%' : `calc((100% - ${spv - 1} * ${gap}) / ${spv})`;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'aria-roledescription': 'slide',
'aria-label': `Slide ${this.opts.index.current + 1} of ${this.provider.slideCount}`,
'data-state': this.isActive ? 'active' : 'inactive',
'data-index': this.opts.index.current,
'data-orientation': this.provider.opts.orientation.current,
style: {
flex: `0 0 ${basis}`,
'min-width': 0,
'min-height': 0
}
} as const);
});
}
// ── PrevTrigger ────────────────────────────────────────────────────────────
interface CarouselPrevTriggerOpts
extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
export class CarouselPrevTriggerProvider {
readonly opts: CarouselPrevTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselPrevTriggerOpts) {
return new CarouselPrevTriggerProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselPrevTriggerOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('prev-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || !this.provider.canGoPrev);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.provider.prev();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.PREV),
disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick
} as const)
);
}
// ── NextTrigger ────────────────────────────────────────────────────────────
interface CarouselNextTriggerOpts
extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
export class CarouselNextTriggerProvider {
readonly opts: CarouselNextTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselNextTriggerOpts) {
return new CarouselNextTriggerProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselNextTriggerOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('next-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || !this.provider.canGoNext);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.provider.next();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.opts.ariaLabel.current || this.provider.soma.langs.ts(CAROUSEL_LANGS.NEXT),
disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick
} as const)
);
}
// ── IndicatorGroup ─────────────────────────────────────────────────────────
interface CarouselIndicatorGroupOpts extends WithRefOpts {}
export class CarouselIndicatorGroupProvider {
readonly opts: CarouselIndicatorGroupOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselIndicatorGroupOpts) {
return new CarouselIndicatorGroupProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselIndicatorGroupOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('indicator-group', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'tablist' as const,
'aria-orientation': this.provider.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current
} as const)
);
}
// ── Indicator ──────────────────────────────────────────────────────────────
interface CarouselIndicatorOpts
extends WithRefOpts, ActiveProps<{ index: number; ariaLabel: string | undefined }> {}
export class CarouselIndicatorProvider {
readonly opts: CarouselIndicatorOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CarouselIndicatorOpts) {
return new CarouselIndicatorProvider(opts);
}
readonly provider: CarouselProvider;
private constructor(opts: CarouselIndicatorOpts) {
this.opts = opts;
this.provider = CarouselProvider.require();
this.runtimePart = this.provider.runtime.part('indicator', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isActive = $derived.by(
() => this.opts.index.current === this.provider.opts.value.current
);
readonly snippetProps = $derived.by(() => ({
index: this.opts.index.current,
isActive: this.isActive
}));
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.provider.isDisabled) return;
this.provider.goto(this.opts.index.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'tab' as const,
'aria-selected': this.isActive,
'aria-label': this.opts.ariaLabel.current || `Go to slide ${this.opts.index.current + 1}`,
tabindex: this.isActive ? 0 : -1,
'data-state': this.isActive ? 'active' : 'inactive',
'data-index': this.opts.index.current,
onclick: this.onclick
} as const)
);
}

Powered by TurnKey Linux.