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.
250 lines
8.3 KiB
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();
|
|
}
|
|
}
|