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

596 lines
19 KiB

import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type Active, type ActiveProps } from '$libs/reactive';
import type { Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { SCROLL_AREA_LANGS } from './langs';
import { isBrowser } from '$adom';
import { scrollAreaMorfo } from '../../../morfo/components/scroll-area';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
export type ScrollAreaType = 'hover' | 'scroll' | 'auto' | 'always';
// ── Root ─────────────────────────────────────────────────────────────────────
interface ScrollAreaOpts
extends
WithRefOpts,
ActiveProps<{
type: ScrollAreaType;
scrollHideDelay: number;
dir: Direction;
}> {}
export class ScrollAreaProvider {
readonly opts: ScrollAreaOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<ScrollAreaProvider>('ScrollArea');
static get(): ScrollAreaProvider | undefined {
return this.ctx.getOr(undefined) as ScrollAreaProvider | undefined;
}
static require(): ScrollAreaProvider {
return this.ctx.get();
}
static create(opts: ScrollAreaOpts) {
return new ScrollAreaProvider(opts);
}
// Viewport tracking
viewportRef = state<HTMLElement | null>(null);
contentWidth = $state(0);
contentHeight = $state(0);
viewportWidth = $state(0);
viewportHeight = $state(0);
scrollTop = $state(0);
scrollLeft = $state(0);
private resizeObserver: ResizeObserver | null = null;
private constructor(opts: ScrollAreaOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(scrollAreaMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ScrollAreaProvider.ctx
});
// Cleanup resize observer on unmount
$effect(() => {
return () => this.cleanupResize();
});
}
// ── Overflow detection ───────────────────────────────────────────────────
readonly hasOverflowX = $derived.by(() => this.contentWidth > this.viewportWidth);
readonly hasOverflowY = $derived.by(() => this.contentHeight > this.viewportHeight);
// ── Position tracking ────────────────────────────────────────────────────
readonly isAtTop = $derived.by(() => this.scrollTop <= 0);
readonly isAtBottom = $derived.by(
() => this.scrollTop >= this.contentHeight - this.viewportHeight - 1
);
readonly isAtLeft = $derived.by(() => this.scrollLeft <= 0);
readonly isAtRight = $derived.by(
() => this.scrollLeft >= this.contentWidth - this.viewportWidth - 1
);
// ── Scroll progress (0..1) ───────────────────────────────────────────────
getScrollProgressY(): number {
const max = this.contentHeight - this.viewportHeight;
return max <= 0 ? 0 : this.scrollTop / max;
}
getScrollProgressX(): number {
const max = this.contentWidth - this.viewportWidth;
return max <= 0 ? 0 : this.scrollLeft / max;
}
// ── Viewport setup ───────────────────────────────────────────────────────
setupViewport(el: HTMLElement | null) {
this.viewportRef.current = el;
this.cleanupResize();
if (!el || !isBrowser) return;
const update = () => {
this.viewportWidth = el.clientWidth;
this.viewportHeight = el.clientHeight;
this.contentWidth = el.scrollWidth;
this.contentHeight = el.scrollHeight;
};
update();
this.resizeObserver = new ResizeObserver(update);
this.resizeObserver.observe(el);
if (el.firstElementChild) {
this.resizeObserver.observe(el.firstElementChild);
}
}
handleScroll(el: HTMLElement) {
this.scrollTop = el.scrollTop;
this.scrollLeft = el.scrollLeft;
// Re-measure in case content changed
this.contentWidth = el.scrollWidth;
this.contentHeight = el.scrollHeight;
}
private cleanupResize() {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
dir: this.opts.dir.current,
style: {
position: 'relative',
'--soma-scroll-area-corner-width':
this.hasOverflowX && this.hasOverflowY
? 'var(--soma-scroll-area-scrollbar-size, 8px)'
: '0px',
'--soma-scroll-area-corner-height':
this.hasOverflowX && this.hasOverflowY
? 'var(--soma-scroll-area-scrollbar-size, 8px)'
: '0px'
},
'data-overflow-x': boolToEmptyStrOrUndef(this.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.hasOverflowY)
} as const)
);
}
// ── Viewport ────────────────────────────────────────────────────────────────
interface ScrollAreaViewportOpts extends WithRefOpts {}
export class ScrollAreaViewportProvider {
readonly opts: ScrollAreaViewportOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ScrollAreaViewportOpts) {
return new ScrollAreaViewportProvider(opts);
}
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaViewportOpts) {
this.opts = opts;
this.provider = ScrollAreaProvider.require();
this.runtimePart = this.provider.runtime.part('viewport', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.provider.setupViewport(el);
}
});
}
readonly onscroll = (e: Event) => {
this.provider.handleScroll(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
style: {
overflow: 'scroll',
'scrollbar-width': 'none'
},
'data-at-top': boolToEmptyStrOrUndef(this.provider.isAtTop),
'data-at-bottom': boolToEmptyStrOrUndef(this.provider.isAtBottom),
'data-at-left': boolToEmptyStrOrUndef(this.provider.isAtLeft),
'data-at-right': boolToEmptyStrOrUndef(this.provider.isAtRight),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onscroll: this.onscroll
} as const)
);
}
// ── Scrollbar ───────────────────────────────────────────────────────────────
type Orientation = 'horizontal' | 'vertical';
interface ScrollAreaScrollbarOpts
extends
WithRefOpts,
ActiveProps<{
orientation: Orientation;
}> {}
export class ScrollAreaScrollbarProvider {
readonly opts: ScrollAreaScrollbarOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<ScrollAreaScrollbarProvider>('ScrollAreaScrollbar');
static get(): ScrollAreaScrollbarProvider | undefined {
return this.ctx.getOr(undefined) as ScrollAreaScrollbarProvider | undefined;
}
static require(): ScrollAreaScrollbarProvider {
return this.ctx.get();
}
static create(opts: ScrollAreaScrollbarOpts) {
return new ScrollAreaScrollbarProvider(opts);
}
readonly provider: ScrollAreaProvider;
// Visibility state
visible = $state(false);
private hideTimer: TimerHandle | null = null;
private mounted = false;
hovering = $state(false);
dragging = $state(false);
private constructor(opts: ScrollAreaScrollbarOpts) {
this.opts = opts;
this.provider = ScrollAreaProvider.require();
this.runtimePart = this.provider.runtime.part('scrollbar', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ScrollAreaScrollbarProvider.ctx
});
// Skip initial-mount flash — defer mounted flag to next frame
const mountedFrame = this.provider.soma.dom.requestFrame(() => {
this.mounted = true;
}, opts.ref.current);
// Cleanup on unmount
$effect(() => {
return () => {
this.provider.soma.dom.cancelFrame(mountedFrame, opts.ref.current);
this.clearHideTimer();
};
});
// Reveal the scrollbar on parent scroll for 'scroll' and 'hover' types.
// Previously lived in the wrapper as an ad-hoc effect.
$effect(() => {
void this.provider.scrollTop;
void this.provider.scrollLeft;
const type = this.provider.opts.type.current;
if (type === 'scroll' || type === 'hover') {
this.show();
this.scheduleHide();
}
});
// Reveal on root hover for 'hover' type. Previously the wrapper attached
// pointerenter/pointerleave listeners to the root element — move that
// behavior to the provider so the wrapper stays thin.
$effect(() => {
const rootEl = this.provider.opts.ref?.current;
if (!rootEl || this.provider.opts.type.current !== 'hover') return;
const onenter = () => this.show();
const onleave = () => {
if (!this.dragging) this.scheduleHide();
};
const cleanupEnter = this.provider.soma.dom.listen(rootEl, 'pointerenter', onenter);
const cleanupLeave = this.provider.soma.dom.listen(rootEl, 'pointerleave', onleave);
return () => {
cleanupEnter();
cleanupLeave();
};
});
}
private get isVertical(): boolean {
return this.opts.orientation.current === 'vertical';
}
private get hasOverflow(): boolean {
return this.isVertical ? this.provider.hasOverflowY : this.provider.hasOverflowX;
}
/** Whether the scrollbar should be displayed based on type and state. */
readonly shouldShow = $derived.by(() => {
const type = this.provider.opts.type.current;
if (type === 'always') return true;
if (!this.hasOverflow) return false;
// Skip initial mount to prevent flash
if (!this.mounted && (type === 'scroll' || type === 'hover')) return false;
if (type === 'auto') return true;
if (type === 'hover') return this.visible;
if (type === 'scroll') return this.visible;
return false;
});
/** Show the scrollbar (for hover/scroll modes). */
show() {
this.clearHideTimer();
this.visible = true;
}
/** Schedule hiding the scrollbar after delay. */
scheduleHide() {
this.clearHideTimer();
const delay = this.provider.opts.scrollHideDelay.current;
this.hideTimer = this.provider.soma.uix.timers.schedule(
this.hideTimerKey,
delay,
() => {
this.hideTimer = null;
if (!this.hovering && !this.dragging) {
this.visible = false;
}
},
{ replace: true, meta: { component: 'scroll-area', action: 'hide-scrollbar' } }
);
}
private clearHideTimer() {
if (this.hideTimer) {
this.hideTimer.cancel();
this.hideTimer = null;
}
}
private get hideTimerKey() {
return `soma:scroll-area:${this.opts.id.current}:hide:${this.opts.orientation.current}`;
}
readonly onpointerenter = () => {
this.hovering = true;
this.show();
};
readonly onpointerleave = () => {
this.hovering = false;
if (!this.dragging) this.scheduleHide();
};
/** Handle click on the track (not thumb) to scroll to that position. */
readonly onclick = (e: MouseEvent) => {
const el = this.opts.ref.current;
const viewport = this.provider.viewportRef.current;
if (!el || !viewport) return;
const rect = el.getBoundingClientRect();
if (this.isVertical) {
const clickRatio = (e.clientY - rect.top) / rect.height;
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = clickRatio * maxScroll;
} else {
const clickRatio = (e.clientX - rect.left) / rect.width;
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = clickRatio * maxScroll;
}
};
// ── Scroll value for ARIA ────────────────────────────────────────────────
private get scrollPercent(): number {
return Math.round(
(this.isVertical ? this.provider.getScrollProgressY() : this.provider.getScrollProgressX()) *
100
);
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'scrollbar' as const,
'aria-controls': this.provider.viewportRef.current?.id || undefined,
'aria-valuenow': this.scrollPercent,
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-orientation': this.opts.orientation.current,
'aria-label': this.isVertical
? this.provider.soma.langs.ts(SCROLL_AREA_LANGS.SCROLLBAR_VERTICAL)
: this.provider.soma.langs.ts(SCROLL_AREA_LANGS.SCROLLBAR_HORIZONTAL),
tabindex: -1,
'data-state': this.shouldShow ? 'visible' : 'hidden',
'data-orientation': this.opts.orientation.current,
'data-hover': boolToEmptyStrOrUndef(this.hovering),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
onclick: this.onclick
} as const)
);
}
// ── Thumb ────────────────────────────────────────────────────────────────────
interface ScrollAreaThumbOpts extends WithRefOpts {}
export class ScrollAreaThumbProvider {
readonly opts: ScrollAreaThumbOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ScrollAreaThumbOpts) {
return new ScrollAreaThumbProvider(opts);
}
readonly scrollbar: ScrollAreaScrollbarProvider;
readonly provider: ScrollAreaProvider;
private dragStartY = 0;
private dragStartX = 0;
private dragStartScrollTop = 0;
private dragStartScrollLeft = 0;
private constructor(opts: ScrollAreaThumbOpts) {
this.opts = opts;
this.scrollbar = ScrollAreaScrollbarProvider.require();
this.provider = this.scrollbar.provider;
this.runtimePart = this.provider.runtime.part('thumb', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
private get isVertical(): boolean {
return this.scrollbar.opts.orientation.current === 'vertical';
}
// ── Size calculation ─────────────────────────────────────────────────────
private get thumbSize(): number {
if (this.isVertical) {
if (this.provider.contentHeight <= 0) return 0;
const ratio = this.provider.viewportHeight / this.provider.contentHeight;
const trackHeight = this.scrollbar.opts.ref.current?.clientHeight ?? 0;
return Math.max(ratio * trackHeight, 20);
} else {
if (this.provider.contentWidth <= 0) return 0;
const ratio = this.provider.viewportWidth / this.provider.contentWidth;
const trackWidth = this.scrollbar.opts.ref.current?.clientWidth ?? 0;
return Math.max(ratio * trackWidth, 20);
}
}
private get thumbOffset(): number {
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!scrollbarEl) return 0;
if (this.isVertical) {
const trackHeight = scrollbarEl.clientHeight;
const maxOffset = trackHeight - this.thumbSize;
return this.provider.getScrollProgressY() * maxOffset;
} else {
const trackWidth = scrollbarEl.clientWidth;
const maxOffset = trackWidth - this.thumbSize;
return this.provider.getScrollProgressX() * maxOffset;
}
}
// ── Drag handling ────────────────────────────────────────────────────────
readonly onpointerdown = (e: PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const viewport = this.provider.viewportRef.current;
if (!viewport) return;
this.scrollbar.dragging = true;
this.dragStartY = e.clientY;
this.dragStartX = e.clientX;
this.dragStartScrollTop = viewport.scrollTop;
this.dragStartScrollLeft = viewport.scrollLeft;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.scrollbar.dragging) return;
e.preventDefault();
const viewport = this.provider.viewportRef.current;
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!viewport || !scrollbarEl) return;
if (this.isVertical) {
const deltaY = e.clientY - this.dragStartY;
const trackHeight = scrollbarEl.clientHeight;
const scrollRatio = deltaY / (trackHeight - this.thumbSize);
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = this.dragStartScrollTop + scrollRatio * maxScroll;
} else {
const deltaX = e.clientX - this.dragStartX;
const trackWidth = scrollbarEl.clientWidth;
const scrollRatio = deltaX / (trackWidth - this.thumbSize);
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = this.dragStartScrollLeft + scrollRatio * maxScroll;
}
};
readonly onpointerup = (_e: PointerEvent) => {
this.scrollbar.dragging = false;
this.scrollbar.scheduleHide();
};
readonly onlostpointercapture = () => {
this.scrollbar.dragging = false;
};
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-state': this.scrollbar.shouldShow ? 'visible' : 'hidden',
'data-orientation': this.scrollbar.opts.orientation.current,
'data-hover': boolToEmptyStrOrUndef(this.scrollbar.hovering),
'data-dragging': boolToEmptyStrOrUndef(this.scrollbar.dragging),
style: {
position: 'absolute',
[this.isVertical ? 'top' : 'left']: `${this.thumbOffset}px`,
[this.isVertical ? 'height' : 'width']: `${this.thumbSize}px`,
[this.isVertical ? 'width' : 'height']: '100%',
'border-radius': 'inherit',
'--soma-scroll-area-thumb-width': `${this.isVertical ? '100%' : `${this.thumbSize}px`}`,
'--soma-scroll-area-thumb-height': `${this.isVertical ? `${this.thumbSize}px` : '100%'}`
},
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onlostpointercapture: this.onlostpointercapture,
onclick: this.onclick
} as const)
);
}
// ── Corner ──────────────────────────────────────────────────────────────────
interface ScrollAreaCornerOpts extends WithRefOpts {}
export class ScrollAreaCornerProvider {
readonly opts: ScrollAreaCornerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ScrollAreaCornerOpts) {
return new ScrollAreaCornerProvider(opts);
}
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaCornerOpts) {
this.opts = opts;
this.provider = ScrollAreaProvider.require();
this.runtimePart = this.provider.runtime.part('corner', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly shouldShow = $derived.by(() => this.provider.hasOverflowX && this.provider.hasOverflowY);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-state': this.shouldShow ? 'visible' : 'hidden',
style: {
width: 'var(--soma-scroll-area-corner-width)',
height: 'var(--soma-scroll-area-corner-height)'
}
} as const)
);
}

Powered by TurnKey Linux.