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/layers/dismissal.svelte.ts

250 lines
8.3 KiB

/**
* # Dismissal — merged Escape + Interact Outside behavior
*
* In terra these were two separate layers (EscapeLayer + DismissibleLayer)
* that always appeared together and shared the same behavior types and registry pattern.
* In soma they are a single behavior with a single global registry.
*
* ## Usage
*
* ```ts
* readonly dismissal = Dismissal.use({
* ref: opts.ref,
* enabled: readableActive(() => true),
* onEscapeKeydown: opts.onEscapeKeydown,
* escapeKeydownBehavior: opts.escapeKeydownBehavior,
* onInteractOutside: opts.onInteractOutside,
* interactOutsideBehavior: opts.interactOutsideBehavior,
* onFocusOutside: opts.onFocusOutside,
* isValidEvent: opts.isValidEvent,
* });
*
* // Merge dismissal.props into your provider props
* readonly props = $derived.by(() => ({
* ...this.runtimePart.props,
* ...this.dismissal.props,
* }));
* ```
*/
import { tick } from 'svelte';
import { watch } from 'runed';
import { readableActive, type Active } from '$libs/reactive';
import { isElement, isHTMLElement, contains, type ActiveDom } from '$adom';
import { KEYS } from '../keyboard';
import type { TimerHandle, TimerScheduler } from '$timer';
// ── Types ────────────────────────────────────────────────────────────────────
export type DismissalBehavior =
| 'close'
| 'defer-otherwise-close'
| 'defer-otherwise-ignore'
| 'ignore';
export interface DismissalOpts {
ref: Active<HTMLElement | null>;
dom: ActiveDom;
timers?: TimerScheduler;
enabled: Active<boolean>;
onEscapeKeydown: Active<(e: KeyboardEvent) => void>;
escapeKeydownBehavior: Active<DismissalBehavior>;
onInteractOutside: Active<(e: PointerEvent) => void>;
interactOutsideBehavior: Active<DismissalBehavior>;
onFocusOutside: Active<(e: FocusEvent) => void>;
isValidEvent: Active<(e: PointerEvent, node: HTMLElement) => boolean>;
}
// ── Registry ─────────────────────────────────────────────────────────────────
const registry = new Map<
Dismissal,
{
escapeBehavior: Active<DismissalBehavior>;
interactBehavior: Active<DismissalBehavior>;
}
>();
let dismissalId = 0;
// ── Dismissal ────────────────────────────────────────────────────────────────
export class Dismissal {
static use(opts: DismissalOpts) {
return new Dismissal(opts);
}
readonly opts: DismissalOpts;
readonly timerKey = `soma:dismissal:${++dismissalId}:register`;
private interceptedPointerdown = false;
private isResponsibleLayer = false;
private isFocusInsideDOMTree = false;
private unsubClickListener = () => {};
private constructor(opts: DismissalOpts) {
this.opts = opts;
let unsubEvents = () => {};
watch([() => opts.enabled.current, () => opts.ref.current], ([enabled, ref]) => {
if (!enabled || !ref) return;
// Small delay to avoid catching the same event that opened the layer
let nativeTimer: number | null = null;
let schedulerTimer: TimerHandle | null = null;
const register = () => {
if (!opts.ref.current) return;
registry.set(this, {
escapeBehavior: opts.escapeKeydownBehavior,
interactBehavior: opts.interactOutsideBehavior
});
unsubEvents();
unsubEvents = this.addEventListeners();
};
if (opts.timers) {
schedulerTimer = opts.timers.schedule(this.timerKey, 1, register, {
replace: true,
meta: { component: 'dismissal', action: 'register' }
});
} else {
const win = opts.dom.getWindow(ref);
nativeTimer = win.setTimeout(register, 1);
}
return () => {
schedulerTimer?.cancel();
if (nativeTimer !== null) opts.dom.getWindow(ref).clearTimeout(nativeTimer);
unsubEvents();
this.resetState();
registry.delete(this);
};
});
}
/** Props to spread into provider — captures focus/blur for focus-outside detection. */
readonly props = {
onfocuscapture: () => {
this.isFocusInsideDOMTree = true;
},
onblurcapture: () => {
this.isFocusInsideDOMTree = false;
}
};
private addEventListeners() {
const doc = this.opts.dom.getDocument(this.opts.ref.current);
const unsub1 = this.opts.dom.listen(doc, 'keydown', this.onKeydown, { passive: false });
const unsub2 = this.opts.dom.listen(doc, 'pointerdown', this.onPointerdownCapture, {
capture: true
});
const unsub3 = this.opts.dom.listen(doc, 'pointerdown', this.onPointerdown);
const unsub4 = this.opts.dom.listen(doc, 'focusin', this.onFocusIn);
return () => {
unsub1();
unsub2();
unsub3();
unsub4();
};
}
// ── Escape handling ──────────────────────────────────────────────────────
private onKeydown = (e: KeyboardEvent) => {
if (e.key !== KEYS.ESCAPE || !this.isResponsibleEscapeLayer()) return;
e.preventDefault();
const behavior = this.opts.escapeKeydownBehavior.current;
if (behavior === 'close' || behavior === 'defer-otherwise-close') {
this.opts.onEscapeKeydown.current(new KeyboardEvent(e.type, e));
}
};
private isResponsibleEscapeLayer(): boolean {
const layers = [...registry];
const topMost = layers.findLast(
([, { escapeBehavior }]) =>
escapeBehavior.current === 'close' || escapeBehavior.current === 'ignore'
);
if (topMost) return topMost[0] === this;
return layers[0]?.[0] === this;
}
// ── Interact outside handling ────────────────────────────────────────────
private onPointerdownCapture = (e: PointerEvent) => {
this.interceptedPointerdown = true;
this.markResponsibleLayer();
};
private onPointerdown = (e: PointerEvent) => {
this.interceptedPointerdown = false;
const node = this.opts.ref.current;
if (!node || !isHTMLElement(e.target as Node)) return;
if (!this.isResponsibleLayer || this.interceptedPointerdown) return;
const isOutside = this.opts.isValidEvent.current(e, node) || this.isDefaultValidEvent(e, node);
if (!isOutside) return;
const behavior = this.opts.interactOutsideBehavior.current;
if (behavior !== 'close' && behavior !== 'defer-otherwise-close') return;
if (e.pointerType === 'touch') {
this.unsubClickListener();
this.unsubClickListener = this.opts.dom.listen(
this.opts.dom.getDocument(node),
'click',
(_ce: MouseEvent) => {
this.opts.onInteractOutside.current(e);
},
{ once: true }
);
} else {
this.opts.onInteractOutside.current(e);
}
};
private isDefaultValidEvent(e: PointerEvent, node: HTMLElement): boolean {
const target = e.target;
if (!isElement(target)) return false;
const ownerDoc = this.opts.dom.getDocument(target);
return ownerDoc.documentElement.contains(target) && !contains(node, target);
}
private markResponsibleLayer() {
const node = this.opts.ref.current;
if (!node) return;
const layers = [...registry];
const topMost = layers.findLast(
([, { interactBehavior }]) =>
interactBehavior.current === 'close' || interactBehavior.current === 'ignore'
);
if (topMost) {
this.isResponsibleLayer = topMost[0].opts.ref.current === node;
} else {
this.isResponsibleLayer = layers[0]?.[0].opts.ref.current === node;
}
}
// ── Focus outside ────────────────────────────────────────────────────────
private onFocusIn = (e: FocusEvent) => {
if (e.defaultPrevented || !this.opts.ref.current) return;
tick().then(() => {
if (!this.opts.ref.current) return;
const target = e.target as HTMLElement;
if (target && !this.isFocusInsideDOMTree && !contains(this.opts.ref.current, target)) {
this.opts.onFocusOutside.current(e);
}
});
};
// ── State ────────────────────────────────────────────────────────────────
private resetState() {
this.interceptedPointerdown = false;
this.isResponsibleLayer = false;
this.unsubClickListener();
}
}

Powered by TurnKey Linux.