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

1146 lines
41 KiB

import { context } from '../../provider';
import { boolToEmptyStrOrUndef, getDataOpenClosed } from '$adom';
import {
readableActive,
state,
type Active,
type ActiveProps,
type State,
type StateProps
} from '$libs/reactive';
import type { SomaMouseEvent, SomaKeyboardEvent, Direction } from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { watch } from 'runed';
import { untrack } from 'svelte';
import { Presence } from '../../layers/presence.svelte';
import { FocusScope } from '../../layers/focus-scope.svelte';
import { Dismissal } from '../../layers/dismissal.svelte';
import { ScrollLock } from '../../layers/scroll-lock.svelte';
import { TextSelection } from '../../layers/text-selection.svelte';
import { Gesture } from '../../layers/gesture';
import { AxialDrag, type AxialStartSide } from '../../layers/manipulation';
import type {
DrawerSide,
DrawerDirection,
DrawerStateFields,
DrawerActiveFields,
DrawerContentActiveFields
} from './types';
import type { DragDirection } from '../../layers/gesture';
import { DRAWER_LANGS } from './langs';
import { drawerMorfo } from '../../../morfo/components/drawer';
import { type SomaRuntime, type SomaRuntimePart } from '$soma';
import type { SemaSignatureOverride } from '$uix/sema';
// ── Direction resolution ────────────────────────────────────────────────────
function resolveDirection(direction: DrawerDirection, dir: Direction): DrawerSide {
if (direction === 'start') return dir === 'rtl' ? 'right' : 'left';
if (direction === 'end') return dir === 'rtl' ? 'left' : 'right';
return direction;
}
function sideToDragDirection(side: DrawerSide): DragDirection {
switch (side) {
case 'bottom':
return 'down';
case 'top':
return 'up';
case 'left':
return 'left';
case 'right':
return 'right';
}
}
function sideToAxis(side: DrawerSide): 'x' | 'y' {
return side === 'left' || side === 'right' ? 'x' : 'y';
}
function sideToAxialStart(side: DrawerSide): AxialStartSide {
return side;
}
function resolveSize(value: unknown, refSize: number): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === 'number') return value;
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (trimmed.endsWith('%')) {
const pct = parseFloat(trimmed.slice(0, -1));
return Number.isFinite(pct) ? (pct / 100) * refSize : undefined;
}
const px = parseFloat(trimmed.endsWith('px') ? trimmed.slice(0, -2) : trimmed);
return Number.isFinite(px) ? px : undefined;
}
/** Default minSize floor: max(80px, 20% * refSize). */
function defaultMinSize(refSize: number): number {
return Math.max(80, refSize * 0.2);
}
// ── Provider (root) ─────────────────────────────────────────────────────────
interface DrawerOpts extends StateProps<DrawerStateFields>, ActiveProps<DrawerActiveFields> {
id: Active<string>;
ref?: State<HTMLElement | null>;
}
/**
* Drawer root — runtime-direct (no `Provider` base inheritance).
*
* Same shape as Dialog but with extras: snap points, gesture-driven drag,
* a Handle part, side-resolution against Soma's `dir`. The morfo runtime
* fires the prewrite → semantic.emit → handler cadence; component-specific
* data-attrs (`data-side`, `data-dragging`, `data-nested*`) stay as inline
* overrides in each part's `props` derived because they're not declared
* with a `value:` source in the morfo today.
*/
export class DrawerProvider {
static readonly ctx = context<DrawerProvider>('Drawer');
static get(): DrawerProvider | undefined {
return this.ctx.getOr(undefined) as DrawerProvider | undefined;
}
static require(): DrawerProvider {
return this.ctx.get();
}
static create(opts: DrawerOpts) {
return new DrawerProvider(opts);
}
readonly opts: DrawerOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
contentId = state('');
titleId = state('');
descriptionId = state('');
triggerId = state('');
triggerNode = state<HTMLElement | null>(null);
handleNode = state<HTMLElement | null>(null);
// ── Nesting ──────────────────────────────────────────────────────────────
readonly parent: DrawerProvider | undefined;
readonly depth: number;
nestedOpenCount = $state(0);
// ── Presence ─────────────────────────────────────────────────────────────
readonly contentPresence: Presence;
readonly overlayPresence: Presence;
private contentRef = state<HTMLElement | null>(null);
private overlayRef = state<HTMLElement | null>(null);
readonly resolvedSide = $derived.by((): DrawerSide => {
const dir = this.soma.prefs.getDir();
return resolveDirection(this.opts.direction.current, dir);
});
/**
* Modal-equivalent boolean derived from `variant`. Drives focus trap,
* scroll-lock, dismissal layer enablement and the `aria-modal` attr.
* Only `'overlay'` is modal — `'inline'` and `'persistent'` are not.
*/
readonly isOverlay = $derived.by(() => this.opts.variant.current === 'overlay');
/**
* Always-visible mode. `open` is forced to `true`; Trigger has no effect;
* `close-*` events are not fired by user gestures (Escape, click-outside).
*/
readonly isPersistent = $derived.by(() => this.opts.variant.current === 'persistent');
private constructor(opts: DrawerOpts) {
const parent = DrawerProvider.get() as DrawerProvider | undefined;
this.opts = opts;
DrawerProvider.ctx.set(this);
this.parent = parent;
this.depth = parent ? parent.depth + 1 : 0;
if (parent) {
watch(
() => opts.open.current,
(isOpen, prevOpen) => {
untrack(() => {
if (isOpen && !prevOpen) parent.nestedOpenCount++;
else if (!isOpen && prevOpen)
parent.nestedOpenCount = Math.max(0, parent.nestedOpenCount - 1);
});
}
);
}
this.contentPresence = new Presence({
open: opts.open,
ref: this.contentRef,
onComplete: (open) => opts.onOpenChangeComplete.current(open)
});
this.overlayPresence = new Presence({
open: opts.open,
ref: this.overlayRef
});
const onPresent = () => this.handleOpen();
const onClose = () => this.handleClose();
// Direct-manipulation events have no JS handler — they exist only
// for the perceptual layer. State (data-dragging, activeSnapPoint)
// is owned by the Gesture layer and applied via `dom.apply` /
// `data-dragging` on Content props.
const noop = () => {};
this.soma = Soma.require();
this.runtime = this.soma.runtime(drawerMorfo, {
// 'persistent' forces open=true so eidos can render the layout
// drawer without the consumer needing to bind `open`. The morfo
// state is read once per tick so the override is reactive.
states: {
open: () => (opts.variant.current === 'persistent' ? true : opts.open.current)
},
props: {
disabled: () => opts.disabled.current,
variant: () => opts.variant.current,
// `modal` is the morfo-internal derived used by `aria-modal`'s
// `prop-truthy` condition — kept here so the morfo schema does
// not need a `prop-equals` discriminator. Tracks variant 1:1.
modal: () => opts.variant.current === 'overlay'
},
parts: {
trigger: () => this.triggerId.current,
content: () => this.contentId.current,
title: () => this.titleId.current,
description: () => this.descriptionId.current
},
events: {
present: onPresent,
'close-save': onClose,
'close-cancel': onClose,
'close-dismiss': onClose,
'close-dismiss-outside': onClose,
'close-after-fail': onClose,
'drag-start': noop,
'drag-progress': noop,
'drag-end': noop,
resize: noop
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: DrawerProvider.ctx,
syncAttrs: true
});
}
/**
* Programmatic dismiss with named cause. Same API as Dialog.dismissWith.
*/
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
return this.runtime.trigger(eventName);
}
readonly isNested = $derived.by(() => this.depth > 0);
readonly hasNestedOpen = $derived.by(() => this.nestedOpenCount > 0);
handleOpen() {
if (this.opts.disabled.current) return;
// Persistent drawer is always open; Trigger interactions are no-ops.
if (this.isPersistent) return;
this.opts.open.current = true;
}
handleClose() {
if (this.opts.disabled.current) return;
// `dismissible` is NOT checked here. It governs dismissal gestures
// (Escape, click-outside, opt-in drag-to-dismiss) which are already gated
// upstream — the Dismissal layer, the Gesture's `enabled`, and the
// Overlay's onclick all bail when `dismissible=false`. Explicit user
// actions (Close button click → 'close-cancel', programmatic
// `dismissWith('save')`, etc.) MUST still work; otherwise a
// non-dismissible drawer would be uncloseable except via binding
// `open` from the consumer, which contradicts the API.
if (this.isPersistent) return;
this.opts.activeSnapPoint.current = null;
this.opts.open.current = false;
// Non-modal: FocusScope is disabled, so return focus manually
if (!this.isOverlay) this.triggerNode.current?.focus();
}
forceClose() {
if (this.isPersistent) return;
this.opts.activeSnapPoint.current = null;
this.opts.open.current = false;
if (!this.isOverlay) this.triggerNode.current?.focus();
}
setContentRef(el: HTMLElement | null) {
this.contentRef.current = el;
}
setOverlayRef(el: HTMLElement | null) {
this.overlayRef.current = el;
}
/** Overlay opacity: 1 fully open, scales toward 0 as the panel shrinks via snap. */
get overlayOpacity(): number {
if (!this.opts.open.current) return 0;
const snap = this.opts.activeSnapPoint.current;
if (snap === null || typeof snap !== 'number' || snap > 1) return 1;
// snap fraction directly maps to opacity — 0.4 visible = 40 % opaque.
return Math.max(0, Math.min(1, snap));
}
readonly props = $derived.by(() => ({
...this.runtimePart.props,
'data-state': getDataOpenClosed(this.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
}));
}
// ── Trigger ─────────────────────────────────────────────────────────────────
interface DrawerTriggerOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerTriggerProvider {
static create(opts: DrawerTriggerOpts) {
return new DrawerTriggerProvider(opts);
}
readonly opts: DrawerTriggerOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerTriggerOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.provider.triggerId.current = opts.id.current;
// A30: mirror trigger element to provider via `onRefChange` (attachRef
// fires at mount/unmount inside `untrack`), avoiding the `$effect`
// child→parent reactive edge.
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
onRefChange: (el) => {
this.provider.triggerNode.current = el;
},
owner: this,
syncAttrs: true
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
type: 'button' as const,
'aria-haspopup': 'dialog' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.TRIGGER),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown
}));
}
// ── Content ─────────────────────────────────────────────────────────────────
interface DrawerContentOpts extends ActiveProps<DrawerContentActiveFields> {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerContentProvider {
static create(opts: DrawerContentOpts) {
return new DrawerContentProvider(opts);
}
readonly opts: DrawerContentOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
readonly focusScope: ReturnType<typeof FocusScope.use>;
readonly dismissal: Dismissal;
readonly scrollLock: ScrollLock;
readonly textSelection: TextSelection;
readonly gesture: ReturnType<typeof Gesture.drag>;
/**
* AxialDrag instance used for resize, either freeform or with snap points.
* Mutually exclusive with the opt-in translate-to-dismiss path of
* `gesture`. Both are always constructed; only one is "live" at a time,
* gated by `enabled`.
*/
readonly axial: AxialDrag;
// Scroll-drag guard: cancel gesture if user scrolled recently
private scrollDragBlocked = $state(false);
private scrollTimer: ReturnType<typeof setTimeout> | null = null;
// Tracks whether the current pointer interaction has already fired
// drag-start. Reset on release so the next gesture re-fires it.
private wasDragging = $state(false);
/**
* Stable PX size of the panel "fully open" on the drag axis. Captured
* once on first transition to `open` (NOT refreshed on each drag —
* that would pick up partial-snap sizes and break snap-fraction math).
* Reset to 0 when drawer closes; recaptured on next open.
*/
private panelMaxSize = $state(0);
private hasSnapPoints(): boolean {
const snaps = this.provider.opts.snapPoints.current;
return !!snaps && snaps.length > 0;
}
private usesAxialResize(): boolean {
return this.hasSnapPoints() || !this.provider.opts.dragToDismiss.current;
}
private constructor(opts: DrawerContentOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.provider.contentId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('content', {
id: opts.id,
ref: opts.ref,
onRefChange: (el) => this.provider.setContentRef(el),
owner: this,
syncAttrs: true
});
// ── Focus scope ──────────────────────────────────────────────────────
// Non-modal: no focus trap, no auto-focus — background must stay interactive.
const isModal = () => this.provider.isOverlay;
this.focusScope = FocusScope.use({
onOpenAutoFocus: readableActive(() =>
isModal() ? opts.onOpenAutoFocus.current : (e: Event) => e.preventDefault()
),
onCloseAutoFocus: opts.onCloseAutoFocus,
trap: readableActive(() => opts.trapFocus.current && isModal()),
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.provider.contentPresence.isPresent && isModal())
});
// ── Dismissal ────────────────────────────────────────────────────────
// A drawer is NOT a popover. interactOutside is ALWAYS 'ignore':
// - Modal: overlay handles click-to-close
// - Non-modal: background interaction is the whole point
// Escape is the only dismissal mechanism from the Dismissal layer.
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(
() => this.provider.opts.open.current && this.provider.opts.dismissible.current && isModal()
),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) void this.provider.runtime.trigger('close-dismiss');
}),
escapeKeydownBehavior: readableActive(() => opts.escapeKeydownBehavior.current ?? 'close'),
onInteractOutside: readableActive(() => (_e: PointerEvent) => {}),
interactOutsideBehavior: readableActive(
() => opts.interactOutsideBehavior.current ?? 'ignore'
),
onFocusOutside: opts.onFocusOutside,
isValidEvent: readableActive(() => () => false)
});
// ── Scroll lock ──────────────────────────────────────────────────────
// Non-modal: no scroll lock — background scrolling must work.
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current);
$effect(() => {
this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current && this.provider.isOverlay;
});
// ── Text selection ───────────────────────────────────────────────────
this.textSelection = TextSelection.use({
ref: readableActive(() => opts.ref.current),
dom: this.provider.soma.dom,
enabled: readableActive(() => this.provider.opts.open.current),
onPointerDown: readableActive(() => () => {}),
onPointerUp: readableActive(() => () => {})
});
// ── Gesture (drag to dismiss / resize) ───────────────────────────────
// Wires the runtime's morfo-declared `drag-*` / `resize` events. The
// drag-progress trigger is rAF-throttled — `onDrag` fires on every
// pointermove, but the perceptual signal only re-fires once per frame
// to avoid queueing dozens of overlapping holds per gesture.
//
// drag-progress emit carries DYNAMIC sound overrides modulated by the
// gesture state (progress / velocity / direction), producing a sparse,
// low-gain whistle. It is intentionally throttled below rAF cadence:
// separate synth notes every frame read as rough buzzing, not as air.
// The cascade rule for drag-progress only sets `channels` — it must
// NOT override the same primitives or it would clobber these per-emit
// values (cascade > signal.overrides in the layer order).
let dragProgressFrame: number | null = null;
let lastDragProgressSignalAt = 0;
// Track the last offset on the drag axis to derive direction (the
// gesture's velocity sign is noisy at low speeds — using delta is
// more robust for direction inference).
let lastAxisOffset = 0;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const DRAG_PROGRESS_SIGNAL_MS = 88;
const dragProgressSound = (
value: number,
velocityPx: number,
axisOffsetToward: number
): SemaSignatureOverride => {
const value01 = clamp(value, 0, 1);
const velThresh = this.provider.opts.velocityThreshold.current || 0.5;
const velocity01 = clamp(Math.abs(velocityPx) / velThresh, 0, 1);
const directionToward = axisOffsetToward - lastAxisOffset;
lastAxisOffset = axisOffsetToward;
return {
sound: {
pitch: lerp(960, 1500, value01),
gain: clamp(0.012 + velocity01 * 0.018, 0.012, 0.03),
centroid: lerp(5200, 8200, velocity01),
duration: 96,
contour: directionToward > 0 ? 'ascending' : 'descending',
attack: 26,
decay: 90,
roughness: 0.002
}
};
};
const flushDragProgress = () => {
dragProgressFrame = null;
if (!this.gesture.isDragging) return;
const now = performance.now();
if (now - lastDragProgressSignalAt < DRAG_PROGRESS_SIGNAL_MS) return;
lastDragProgressSignalAt = now;
const side = this.provider.resolvedSide;
const axis = sideToAxis(side);
// value01: progress toward dismiss (0 = fully open, 1 = at threshold)
const value01 = this.gesture.progress;
const rawVel = axis === 'x' ? this.gesture.velocityX : this.gesture.velocityY;
// direction: sign of the offset DELTA on the drag axis (more
// robust than velocity sign at low speeds). Positive = moving
// in the dismiss direction (panel shrinking), negative = moving
// back (panel growing).
const currentOffset = axis === 'x' ? this.gesture.offsetX : this.gesture.offsetY;
const dismissSign = side === 'top' || side === 'left' ? -1 : 1;
void this.provider.runtime.trigger('drag-progress', {
overrides: dragProgressSound(value01, rawVel, currentOffset * dismissSign)
});
};
const queueDragProgress = () => {
if (dragProgressFrame !== null) return;
dragProgressFrame = requestAnimationFrame(flushDragProgress);
};
let axialDragProgressFrame: number | null = null;
const beginDrag = (e: PointerEvent, axisOffsetToward = 0) => {
if (this.wasDragging) return false;
this.wasDragging = true;
lastDragProgressSignalAt = 0;
lastAxisOffset = axisOffsetToward;
this.provider.opts.isDragging.current = true;
this.provider.opts.onDragStart.current?.(e);
void this.provider.runtime.trigger('drag-start');
return true;
};
// Idempotent end-of-gesture cleanup. Called from gesture/axial release
// (real drags) and the pointerup fallback (tap-without-drag). The
// wasDragging gate makes a second call a no-op.
const releaseDrag = () => {
if (!this.wasDragging) return;
if (dragProgressFrame !== null) {
cancelAnimationFrame(dragProgressFrame);
dragProgressFrame = null;
}
if (axialDragProgressFrame !== null) {
cancelAnimationFrame(axialDragProgressFrame);
axialDragProgressFrame = null;
}
this.wasDragging = false;
this.provider.opts.isDragging.current = false;
void this.provider.runtime.trigger('drag-end');
};
this.gesture = Gesture.drag({
ref: opts.ref,
enabled: readableActive(
() =>
this.provider.opts.dragToDismiss.current &&
!this.hasSnapPoints() &&
this.provider.opts.open.current &&
this.provider.opts.dismissible.current &&
!this.scrollDragBlocked
),
direction: readableActive(() => sideToDragDirection(this.provider.resolvedSide)),
axis: readableActive(() => sideToAxis(this.provider.resolvedSide)),
dragFrom: readableActive(() =>
this.provider.opts.handleOnly.current ? 'handle-only' : 'anywhere'
),
handle: readableActive(() => this.provider.handleNode.current),
threshold: readableActive(() => this.provider.opts.closeThreshold.current),
velocityThreshold: readableActive(() => this.provider.opts.velocityThreshold.current),
moveBuffer: 5,
onDrag: (e, dragState) => {
this.provider.opts.onDrag.current?.(e, dragState.progress);
// drag-start fires on pointerdown (see pointerdown effect
// below) so the user gets sound feedback on click even when
// no actual drag movement happens. onDrag here just queues
// the rAF-throttled drag-progress emit.
queueDragProgress();
},
onRelease: (e, releaseState) => {
this.provider.opts.onRelease.current?.(e, releaseState.dismissed);
releaseDrag();
},
onDismiss: () => {
this.provider.handleClose();
}
});
// Pickup acknowledgement on pointerdown — fires drag-start BEFORE the
// gesture's `moveBuffer` (5 px) is crossed so a tap on the handle
// immediately enters the drag visual state and produces the perceptual
// signal. The movement threshold still belongs to Gesture/AxialDrag.
$effect(() => {
const el = opts.ref.current;
if (!el) return;
let releaseListenerCleanup: (() => void) | null = null;
const cleanupReleaseListeners = () => {
releaseListenerCleanup?.();
releaseListenerCleanup = null;
};
const onPointerFinish = () => {
if (!this.gesture.isDragging && !this.axial.isDragging) {
releaseDrag();
}
cleanupReleaseListeners();
};
const bindReleaseListeners = () => {
if (releaseListenerCleanup || typeof document === 'undefined') return;
document.addEventListener('pointerup', onPointerFinish, true);
document.addEventListener('pointercancel', onPointerFinish, true);
releaseListenerCleanup = () => {
document.removeEventListener('pointerup', onPointerFinish, true);
document.removeEventListener('pointercancel', onPointerFinish, true);
};
};
const isHandleTarget = (e: PointerEvent) => {
if (!this.provider.opts.handleOnly.current) return true;
const handle = this.provider.handleNode.current;
if (!handle || !(e.target instanceof Node)) return false;
return e.target === handle || handle.contains(e.target);
};
const onPointerDown = (e: PointerEvent) => {
if (this.wasDragging) return;
if (e.button !== 0) return;
if (!this.provider.opts.open.current) return;
if (this.scrollDragBlocked) return;
const usesResize = this.usesAxialResize();
const usesDismissDrag = this.provider.opts.dragToDismiss.current && !this.hasSnapPoints();
if (!usesResize && !usesDismissDrag) return;
if (usesDismissDrag && !this.provider.opts.dismissible.current) return;
if (!isHandleTarget(e)) return;
if (beginDrag(e, usesResize ? this.axial.offsetPx : 0)) {
bindReleaseListeners();
}
};
// Pointerup fallback for taps that never trigger the gesture's
// own release (movement never crossed moveBuffer). Real drags
// are left to Gesture/AxialDrag so resize/snap resolves first.
el.addEventListener('pointerdown', onPointerDown, true);
return () => {
el.removeEventListener('pointerdown', onPointerDown, true);
cleanupReleaseListeners();
};
});
// scroll-drag guard disables the gesture entirely (not just the callback)
$effect(() => {
const el = opts.ref.current;
if (!el) return;
const onScroll = () => {
this.scrollDragBlocked = true;
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = setTimeout(() => {
this.scrollDragBlocked = false;
}, this.provider.opts.scrollLockTimeout.current);
};
el.addEventListener('scroll', onScroll, { passive: true, capture: true });
return () => {
el.removeEventListener('scroll', onScroll, { capture: true });
if (this.scrollTimer) clearTimeout(this.scrollTimer);
};
});
// cleanup gesture on unmount
$effect(() => {
return () => {
this.gesture.cancel();
};
});
// ── Capture panelMaxSize on first open ───────────────────────────────
// The recipe's height/width applies on initial render; measure after
// it's done. Used as the stable reference for snap-fraction → px
// conversions and minSize/maxSize defaults.
let captured = false;
$effect(() => {
if (!this.provider.opts.open.current) {
captured = false;
this.panelMaxSize = 0;
return;
}
if (captured) return;
const el = opts.ref.current;
if (!el) return;
const id = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
const axis = sideToAxis(this.provider.resolvedSide);
this.panelMaxSize = axis === 'x' ? rect.width : rect.height;
captured = true;
});
return () => cancelAnimationFrame(id);
});
// ── AxialDrag (resize-on-drag, free or snapped) ──────────────────────
// Active for free resize by default, or snapped resize when `snapPoints`
// is configured. Operates on the SAME
// content ref as Gesture.drag but the two are mutually exclusive via
// the `enabled` gate (Gesture's enabled checks `!hasSnapPoints()`).
const axialReleaseDrag = () => releaseDrag();
const queueAxialProgress = () => {
if (axialDragProgressFrame !== null) return;
axialDragProgressFrame = requestAnimationFrame(() => {
axialDragProgressFrame = null;
if (!this.axial.isDragging) return;
void this.provider.runtime.trigger('drag-progress', {
overrides: dragProgressSound(
this.axial.value01,
this.axial.velocityPx,
this.axial.offsetPx
)
});
});
};
this.axial = new AxialDrag({
ref: opts.ref,
enabled: readableActive(
() => this.usesAxialResize() && this.provider.opts.open.current && !this.scrollDragBlocked
),
startSide: readableActive(() => sideToAxialStart(this.provider.resolvedSide)),
referenceSize: readableActive(() => this.panelMaxSize),
handle: readableActive(() => this.provider.handleNode.current),
handleOnly: readableActive(() => this.provider.opts.handleOnly.current),
snapPoints: readableActive(() => this.provider.opts.snapPoints.current),
activeSnap: readableActive(() =>
this.hasSnapPoints() ? this.provider.opts.activeSnapPoint.current : undefined
),
snapToSequential: readableActive(() => this.provider.opts.snapToSequential.current),
minOffsetPx: readableActive(() => {
const ref = this.panelMaxSize;
if (ref <= 0) return 0;
const max = resolveSize(this.provider.opts.maxSize.current, ref);
// Negative offset means "grow past the natural full-open size".
return max !== undefined && max > ref ? ref - max : 0;
}),
maxOffsetPx: readableActive(() => {
const ref = this.panelMaxSize;
if (ref <= 0) return Number.POSITIVE_INFINITY;
const min = resolveSize(this.provider.opts.minSize.current, ref) ?? defaultMinSize(ref);
// Resize is size manipulation, not dismissal. Clamp at the
// minimum visible size and leave close semantics to explicit
// affordances or the opt-in no-snap `dragToDismiss` path.
return Math.max(0, ref - min);
}),
dismissPx: readableActive(() => undefined),
moveBuffer: 5,
onDragStart: (e) => {
beginDrag(e, this.axial.offsetPx);
},
onDrag: (e, state) => {
this.provider.opts.onDrag.current?.(e, state.value01);
queueAxialProgress();
},
onDragEnd: (e, state) => {
this.provider.opts.onRelease.current?.(e, state.dismissed);
if (!this.hasSnapPoints() && !state.dismissed) {
void this.provider.runtime.trigger('resize');
}
axialReleaseDrag();
},
onSnap: (snap) => {
this.provider.opts.activeSnapPoint.current = snap;
this.provider.opts.onActiveSnapPointChange.current(snap);
void this.provider.runtime.trigger('resize');
},
onDismiss: () => {}
});
$effect(() => {
return () => {
this.axial.cancel();
};
});
$effect(() => {
if (!this.provider.opts.open.current) this.axial.resetOffset();
});
}
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
// Escape for non-modal (Dismissal layer is disabled for non-modal)
readonly onkeydown = (e: KeyboardEvent) => {
if (
e.key === KEYS.ESCAPE &&
!this.provider.isOverlay &&
this.provider.opts.dismissible.current
) {
e.preventDefault();
void this.provider.runtime.trigger('close-dismiss');
}
};
private get dragTransform(): string {
const side = this.provider.resolvedSide;
const ox = this.gesture.offsetX;
const oy = this.gesture.offsetY;
if (!this.gesture.isDragging) return '';
// Translate-to-dismiss only — clamp to dismiss direction so users
// can't drag the panel past its open position toward the inside of
// the viewport. The opposite direction (toward dismissal) is open.
switch (side) {
case 'bottom':
return `translate3d(0, ${Math.max(0, oy)}px, 0)`;
case 'top':
return `translate3d(0, ${Math.min(0, oy)}px, 0)`;
case 'right':
return `translate3d(${Math.max(0, ox)}px, 0, 0)`;
case 'left':
return `translate3d(${Math.min(0, ox)}px, 0, 0)`;
}
}
/**
* Resize-on-drag inline size (free or snapped). Returns the CSS property
* (`height` for top/bottom, `width` for left/right) and value in px,
* or `null` when the translate-to-dismiss mechanic is active.
*
* currentSize = panelMaxSize - axial.offsetPx
*
* During drag: tracks axial.offsetPx live.
* After release: axial.offsetPx is set by AxialDrag from the resolved
* snap fraction, or kept at the released freeform offset when no snaps
* exist, so this getter naturally returns the persistent size.
*/
private get sizeOnAxis(): { property: 'height' | 'width'; value: string } | null {
if (!this.usesAxialResize()) return null;
if (this.panelMaxSize <= 0) return null;
const side = this.provider.resolvedSide;
const cssProperty: 'height' | 'width' =
side === 'left' || side === 'right' ? 'width' : 'height';
const offset = this.axial.offsetPx;
const size = this.panelMaxSize - offset;
return { property: cssProperty, value: `${Math.max(0, size)}px` };
}
readonly props = $derived.by(() => {
const isAxialDragging = this.axial.isDragging;
const isGestureDragging = this.gesture.isDragging;
const isDragging = this.wasDragging || isAxialDragging || isGestureDragging;
const sizeStyle = this.sizeOnAxis;
const progress = this.usesAxialResize() ? this.axial.value01 : this.gesture.progress;
const axialOffset = this.axial.offsetPx;
const side = this.provider.resolvedSide;
const axialOffsetX = side === 'right' ? axialOffset : side === 'left' ? -axialOffset : 0;
const axialOffsetY = side === 'bottom' ? axialOffset : side === 'top' ? -axialOffset : 0;
// Gesture.drag and AxialDrag both expose an onpointerdown via their
// `props` object. They're mutually-exclusive at runtime via the
// `enabled` gates (snapPoints presence flips the active layer), but
// at the prop-spread level we need ONE composed handler. Calling
// both is safe — the disabled one bails on its own check.
const onPointerDown = (e: PointerEvent) => {
this.gesture.props.onpointerdown(e);
this.axial.props.onpointerdown(e);
};
return {
...this.runtimePart.props,
role: 'dialog' as const,
'aria-modal': this.provider.isOverlay ? true : undefined,
'aria-describedby': this.provider.descriptionId.current || undefined,
'aria-labelledby': this.provider.titleId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-side': side,
'data-dragging': boolToEmptyStrOrUndef(isDragging),
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
onpointerdown: onPointerDown,
style: {
'pointer-events': 'auto',
'touch-action': 'none',
'--drawer-progress': `${progress}`,
'--drawer-offset-x': `${this.usesAxialResize() ? axialOffsetX : this.gesture.offsetX}px`,
'--drawer-offset-y': `${this.usesAxialResize() ? axialOffsetY : this.gesture.offsetY}px`,
'--drawer-depth': `${this.provider.depth}`,
// Resize mode: inline height/width derived from AxialDrag's
// offsetPx (live during drag, settled after release).
...(sizeStyle ? { [sizeStyle.property]: sizeStyle.value } : {}),
...(isAxialDragging ? { transition: 'none' } : {}),
// Translate-to-dismiss (no-snap mode): apply transform only
// while gesture is dragging.
...(isGestureDragging ? { transform: this.dragTransform, transition: 'none' } : {})
},
onkeydown: this.onkeydown,
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
...this.dismissal.props,
// Note: this.gesture.props.onpointerdown + this.axial.props.onpointerdown
// are composed manually above into `onPointerDown`. Don't spread
// `...this.gesture.props` here — it would clobber the composed
// handler. Re-emit only fields that aren't onpointerdown if any
// are needed in the future.
// Non-modal needs tabindex to receive keyboard events without focus trap
...(this.provider.isOverlay ? {} : { tabindex: -1 })
};
});
}
// ── Overlay ─────────────────────────────────────────────────────────────────
interface DrawerOverlayOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerOverlayProvider {
static create(opts: DrawerOverlayOpts) {
return new DrawerOverlayProvider(opts);
}
readonly opts: DrawerOverlayOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerOverlayOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.runtimePart = this.provider.runtime.part('overlay', {
id: opts.id,
ref: opts.ref,
onRefChange: (el) => this.provider.setOverlayRef(el),
owner: this,
syncAttrs: true
});
}
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
readonly onclick = () => {
if (this.provider.opts.dismissible.current) {
void this.provider.runtime.trigger('close-dismiss-outside');
}
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'aria-hidden': true as const,
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--drawer-depth': `${this.provider.depth}`,
'--drawer-overlay-opacity': `${this.provider.overlayOpacity}`
},
onclick: this.onclick,
...this.provider.overlayPresence.transitionAttrs
}));
}
// ── Handle ──────────────────────────────────────────────────────────────────
interface DrawerHandleOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerHandleProvider {
static create(opts: DrawerHandleOpts) {
return new DrawerHandleProvider(opts);
}
readonly opts: DrawerHandleOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerHandleOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
// A30: handleNode synced via `onRefChange`. `handleNode` is read by
// the Gesture layer in the root provider (used by `dragFrom='handle-only'`
// to gate where drag may initiate). Without snapPoints, the Handle
// is a pure visual + drag-init affordance — no click action.
this.runtimePart = this.provider.runtime.part('handle', {
id: opts.id,
ref: opts.ref,
onRefChange: (el) => {
this.provider.handleNode.current = el;
},
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props,
'aria-hidden': true as const,
style: { 'touch-action': 'none' }
}));
}
// ── Title ───────────────────────────────────────────────────────────────────
interface DrawerTitleOpts extends ActiveProps<{ level: number }> {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerTitleProvider {
static create(opts: DrawerTitleOpts) {
return new DrawerTitleProvider(opts);
}
readonly opts: DrawerTitleOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerTitleOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.provider.titleId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('title', {
id: opts.id,
ref: opts.ref,
props: { level: () => this.opts.level.current },
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props,
role: 'heading' as const,
'aria-level': this.opts.level.current
}));
}
// ── Description ─────────────────────────────────────────────────────────────
interface DrawerDescriptionOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerDescriptionProvider {
static create(opts: DrawerDescriptionOpts) {
return new DrawerDescriptionProvider(opts);
}
readonly opts: DrawerDescriptionOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerDescriptionOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.provider.descriptionId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('description', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Close ───────────────────────────────────────────────────────────────────
interface DrawerCloseOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class DrawerCloseProvider {
static create(opts: DrawerCloseOpts) {
return new DrawerCloseProvider(opts);
}
readonly opts: DrawerCloseOpts;
readonly provider: DrawerProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: DrawerCloseOpts) {
this.opts = opts;
this.provider = DrawerProvider.require();
this.runtimePart = this.provider.runtime.part('close', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
void this.provider.runtime.trigger('close-cancel');
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE),
onclick: this.onclick
}));
}

Powered by TurnKey Linux.