|
|
|
|
@ -1,11 +1,7 @@
|
|
|
|
|
import {
|
|
|
|
|
state,
|
|
|
|
|
type Active,
|
|
|
|
|
type ActiveProps
|
|
|
|
|
} from '$libs/reactive';
|
|
|
|
|
import { state, type Active, type ActiveProps } from '$libs/reactive';
|
|
|
|
|
import { focusable, isFocusable, tabbable } from 'tabbable';
|
|
|
|
|
import { on } from 'svelte/events';
|
|
|
|
|
import { watch } from 'runed';
|
|
|
|
|
import { isHTMLElement, type ActiveDom } from '$adom';
|
|
|
|
|
|
|
|
|
|
// ── FocusScopeManager (singleton) ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@ -24,8 +20,9 @@ class FocusScopeManager {
|
|
|
|
|
const current = this.getActive();
|
|
|
|
|
if (current && current !== scope) current.pause();
|
|
|
|
|
|
|
|
|
|
const active = document.activeElement as HTMLElement;
|
|
|
|
|
if (active && active !== document.body) this.preFocusHistory.set(scope, active);
|
|
|
|
|
const doc = scope.opts.dom.getDocument(scope.container);
|
|
|
|
|
const active = scope.opts.dom.activeElement(doc);
|
|
|
|
|
if (isHTMLElement(active) && active !== doc.body) this.preFocusHistory.set(scope, active);
|
|
|
|
|
|
|
|
|
|
this.scopeStack.current = this.scopeStack.current.filter((s) => s !== scope);
|
|
|
|
|
this.scopeStack.current.unshift(scope);
|
|
|
|
|
@ -63,6 +60,7 @@ interface FocusScopeOpts extends ActiveProps<{
|
|
|
|
|
onCloseAutoFocus: (event: Event) => void;
|
|
|
|
|
trap: boolean;
|
|
|
|
|
}> {
|
|
|
|
|
dom: ActiveDom;
|
|
|
|
|
loop: Active<boolean>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -122,16 +120,17 @@ export class FocusScope {
|
|
|
|
|
});
|
|
|
|
|
this.opts.onOpenAutoFocus.current(event);
|
|
|
|
|
if (!event.defaultPrevented) {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
const frame = this.opts.dom.requestFrame(() => {
|
|
|
|
|
if (!this.container) return;
|
|
|
|
|
const first = this.getFirstTabbable();
|
|
|
|
|
if (first) {
|
|
|
|
|
first.focus();
|
|
|
|
|
this.opts.dom.focus(first);
|
|
|
|
|
this.manager.setFocusMemory(this, first);
|
|
|
|
|
} else {
|
|
|
|
|
this.container.focus();
|
|
|
|
|
this.opts.dom.focus(this.container);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, this.container);
|
|
|
|
|
this.cleanupFns.push(() => this.opts.dom.cancelFrame(frame, this.container));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -143,11 +142,12 @@ export class FocusScope {
|
|
|
|
|
this.opts.onCloseAutoFocus.current?.(event);
|
|
|
|
|
if (!event.defaultPrevented) {
|
|
|
|
|
const preFocused = this.manager.getPreFocusMemory(this);
|
|
|
|
|
if (preFocused && document.contains(preFocused)) {
|
|
|
|
|
const doc = preFocused ? this.opts.dom.getDocument(preFocused) : undefined;
|
|
|
|
|
if (preFocused && doc?.contains(preFocused)) {
|
|
|
|
|
try {
|
|
|
|
|
preFocused.focus();
|
|
|
|
|
this.opts.dom.focus(preFocused);
|
|
|
|
|
} catch {
|
|
|
|
|
document.body.focus();
|
|
|
|
|
this.opts.dom.focus(doc.body);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -156,23 +156,23 @@ export class FocusScope {
|
|
|
|
|
private setupEventListeners() {
|
|
|
|
|
if (!this.container || !this.opts.trap.current) return;
|
|
|
|
|
const container = this.container;
|
|
|
|
|
const doc = container.ownerDocument;
|
|
|
|
|
const doc = this.opts.dom.getDocument(container);
|
|
|
|
|
|
|
|
|
|
const handleFocus = (e: FocusEvent) => {
|
|
|
|
|
if (this.paused || !this.manager.isActiveScope(this)) return;
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (!target) return;
|
|
|
|
|
const target = e.target;
|
|
|
|
|
if (!isHTMLElement(target)) return;
|
|
|
|
|
if (container.contains(target)) {
|
|
|
|
|
this.manager.setFocusMemory(this, target);
|
|
|
|
|
} else {
|
|
|
|
|
const lastFocused = this.manager.getFocusMemory(this);
|
|
|
|
|
if (lastFocused && container.contains(lastFocused) && isFocusable(lastFocused)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
lastFocused.focus();
|
|
|
|
|
this.opts.dom.focus(lastFocused);
|
|
|
|
|
} else {
|
|
|
|
|
const firstTabbable = this.getFirstTabbable();
|
|
|
|
|
const firstFocusable = this.getAllFocusables()[0];
|
|
|
|
|
(firstTabbable || firstFocusable || container).focus();
|
|
|
|
|
this.opts.dom.focus(firstTabbable || firstFocusable || container);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
@ -184,18 +184,19 @@ export class FocusScope {
|
|
|
|
|
if (tabs.length === 0) return;
|
|
|
|
|
const first = tabs[0]!;
|
|
|
|
|
const last = tabs[tabs.length - 1]!;
|
|
|
|
|
if (!e.shiftKey && doc.activeElement === last) {
|
|
|
|
|
const active = this.opts.dom.activeElement(doc);
|
|
|
|
|
if (!e.shiftKey && active === last) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
first.focus();
|
|
|
|
|
} else if (e.shiftKey && doc.activeElement === first) {
|
|
|
|
|
this.opts.dom.focus(first);
|
|
|
|
|
} else if (e.shiftKey && active === first) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
last.focus();
|
|
|
|
|
this.opts.dom.focus(last);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.cleanupFns.push(
|
|
|
|
|
on(doc, 'focusin', handleFocus, { capture: true }),
|
|
|
|
|
on(container, 'keydown', handleKeydown)
|
|
|
|
|
this.opts.dom.listen(doc, 'focusin', handleFocus, { capture: true }),
|
|
|
|
|
this.opts.dom.listen(container, 'keydown', handleKeydown)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const observer = new MutationObserver(() => {
|
|
|
|
|
@ -203,9 +204,9 @@ export class FocusScope {
|
|
|
|
|
if (lastFocused && !container.contains(lastFocused)) {
|
|
|
|
|
const el = this.getFirstTabbable() || this.getAllFocusables()[0];
|
|
|
|
|
if (el) {
|
|
|
|
|
el.focus();
|
|
|
|
|
this.opts.dom.focus(el);
|
|
|
|
|
this.manager.setFocusMemory(this, el);
|
|
|
|
|
} else container.focus();
|
|
|
|
|
} else this.opts.dom.focus(container);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
observer.observe(container, { childList: true, subtree: true });
|
|
|
|
|
|