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.
1201 lines
43 KiB
1201 lines
43 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, SomaRuntimePart } from '../../runtime.svelte';
|
|
import type { SemaSignatureOverride } from '$uix/sema';
|
|
import type { TimerHandle } from '$timer';
|
|
|
|
// ── 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; morfo declares them as contract,
|
|
* while the component owns their gesture/nesting calculations.
|
|
*/
|
|
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.soma = Soma.require();
|
|
|
|
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({
|
|
dom: this.soma.dom,
|
|
open: opts.open,
|
|
ref: this.contentRef,
|
|
onComplete: (open) => opts.onOpenChangeComplete.current(open)
|
|
});
|
|
|
|
this.overlayPresence = new Presence({
|
|
dom: this.soma.dom,
|
|
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.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.triggerClose(eventName);
|
|
}
|
|
|
|
triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise<void> {
|
|
const target = this.contentRef.current ?? fallbackTarget ?? this.triggerNode.current;
|
|
if (!target) return this.runtime.trigger(eventName);
|
|
return this.runtime.trigger(eventName, { fallbackTarget: target });
|
|
}
|
|
|
|
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.soma.dom.focus(this.triggerNode.current);
|
|
}
|
|
|
|
forceClose() {
|
|
if (this.isPersistent) return;
|
|
this.opts.activeSnapPoint.current = null;
|
|
this.opts.open.current = false;
|
|
if (!this.isOverlay) this.soma.dom.focus(this.triggerNode.current);
|
|
}
|
|
|
|
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: TimerHandle | 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({
|
|
dom: this.provider.soma.dom,
|
|
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({
|
|
dom: this.provider.soma.dom,
|
|
timers: this.provider.soma.uix?.timers,
|
|
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.triggerClose('close-dismiss', opts.ref.current ?? undefined);
|
|
}),
|
|
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(
|
|
this.provider.soma.dom,
|
|
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 = this.provider.soma.dom.requestFrame(flushDragProgress, opts.ref.current);
|
|
};
|
|
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) {
|
|
this.provider.soma.dom.cancelFrame(dragProgressFrame, opts.ref.current);
|
|
dragProgressFrame = null;
|
|
}
|
|
if (axialDragProgressFrame !== null) {
|
|
this.provider.soma.dom.cancelFrame(axialDragProgressFrame, opts.ref.current);
|
|
axialDragProgressFrame = null;
|
|
}
|
|
this.wasDragging = false;
|
|
this.provider.opts.isDragging.current = false;
|
|
void this.provider.runtime.trigger('drag-end');
|
|
};
|
|
|
|
this.gesture = Gesture.drag({
|
|
dom: this.provider.soma.dom,
|
|
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) return;
|
|
const doc = this.provider.soma.dom.getDocument(el);
|
|
const cleanupPointerUp = this.provider.soma.dom.listen(
|
|
doc,
|
|
'pointerup',
|
|
onPointerFinish,
|
|
true
|
|
);
|
|
const cleanupPointerCancel = this.provider.soma.dom.listen(
|
|
doc,
|
|
'pointercancel',
|
|
onPointerFinish,
|
|
true
|
|
);
|
|
releaseListenerCleanup = () => {
|
|
cleanupPointerUp();
|
|
cleanupPointerCancel();
|
|
};
|
|
};
|
|
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.
|
|
const cleanupPointerDown = this.provider.soma.dom.listen(
|
|
el,
|
|
'pointerdown',
|
|
onPointerDown,
|
|
true
|
|
);
|
|
return () => {
|
|
cleanupPointerDown();
|
|
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;
|
|
this.clearScrollTimer();
|
|
this.scrollTimer = this.provider.soma.uix.timers.schedule(
|
|
this.scrollTimerKey,
|
|
this.provider.opts.scrollLockTimeout.current,
|
|
() => {
|
|
this.scrollTimer = null;
|
|
this.scrollDragBlocked = false;
|
|
},
|
|
{ replace: true, meta: { component: 'drawer', action: 'scroll-drag-guard' } }
|
|
);
|
|
};
|
|
const cleanupScroll = this.provider.soma.dom.listen(el, 'scroll', onScroll, {
|
|
passive: true,
|
|
capture: true
|
|
});
|
|
return () => {
|
|
cleanupScroll();
|
|
this.clearScrollTimer();
|
|
};
|
|
});
|
|
|
|
// 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 = this.provider.soma.dom.requestFrame(() => {
|
|
const rect = el.getBoundingClientRect();
|
|
const axis = sideToAxis(this.provider.resolvedSide);
|
|
this.panelMaxSize = axis === 'x' ? rect.width : rect.height;
|
|
captured = true;
|
|
}, el);
|
|
return () => this.provider.soma.dom.cancelFrame(id, el);
|
|
});
|
|
|
|
// ── 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 = this.provider.soma.dom.requestFrame(() => {
|
|
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
|
|
)
|
|
});
|
|
}, opts.ref.current);
|
|
};
|
|
|
|
this.axial = new AxialDrag({
|
|
dom: this.provider.soma.dom,
|
|
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);
|
|
|
|
private clearScrollTimer() {
|
|
if (this.scrollTimer) {
|
|
this.scrollTimer.cancel();
|
|
this.scrollTimer = null;
|
|
}
|
|
}
|
|
|
|
private get scrollTimerKey() {
|
|
return `soma:drawer:${this.opts.id.current}:scroll-drag-guard`;
|
|
}
|
|
|
|
// 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.triggerClose('close-dismiss', e.currentTarget as HTMLElement);
|
|
}
|
|
};
|
|
|
|
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 = (e: SomaMouseEvent<HTMLElement>) => {
|
|
if (this.provider.opts.dismissible.current) {
|
|
void this.provider.triggerClose('close-dismiss-outside', e.currentTarget as HTMLElement);
|
|
}
|
|
};
|
|
|
|
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.triggerClose('close-cancel', e.currentTarget as HTMLElement);
|
|
};
|
|
|
|
readonly props = $derived.by(() => ({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE),
|
|
onclick: this.onclick
|
|
}));
|
|
}
|