INIT
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
import { onDestroyEffect } from "svelte-toolbelt";
|
||||
import { FocusScopeManager } from "./focus-scope-manager.js";
|
||||
import { focusable, isFocusable, tabbable } from "tabbable";
|
||||
import { on } from "svelte/events";
|
||||
import { watch } from "runed";
|
||||
export class FocusScope {
|
||||
#paused = false;
|
||||
#container = null;
|
||||
#manager = FocusScopeManager.getInstance();
|
||||
#cleanupFns = [];
|
||||
#opts;
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
}
|
||||
get paused() {
|
||||
return this.#paused;
|
||||
}
|
||||
pause() {
|
||||
this.#paused = true;
|
||||
}
|
||||
resume() {
|
||||
this.#paused = false;
|
||||
}
|
||||
#cleanup() {
|
||||
for (const fn of this.#cleanupFns) {
|
||||
fn();
|
||||
}
|
||||
this.#cleanupFns = [];
|
||||
}
|
||||
mount(container) {
|
||||
if (this.#container) {
|
||||
this.unmount();
|
||||
}
|
||||
this.#container = container;
|
||||
this.#manager.register(this);
|
||||
this.#setupEventListeners();
|
||||
this.#handleOpenAutoFocus();
|
||||
}
|
||||
unmount() {
|
||||
if (!this.#container)
|
||||
return;
|
||||
this.#cleanup();
|
||||
// handle close auto-focus
|
||||
this.#handleCloseAutoFocus();
|
||||
this.#manager.unregister(this);
|
||||
this.#manager.clearPreFocusMemory(this);
|
||||
this.#container = null;
|
||||
}
|
||||
#handleOpenAutoFocus() {
|
||||
if (!this.#container)
|
||||
return;
|
||||
const event = new CustomEvent("focusScope.onOpenAutoFocus", {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
});
|
||||
this.#opts.onOpenAutoFocus.current(event);
|
||||
if (!event.defaultPrevented) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.#container)
|
||||
return;
|
||||
const firstTabbable = this.#getFirstTabbable();
|
||||
if (firstTabbable) {
|
||||
firstTabbable.focus();
|
||||
this.#manager.setFocusMemory(this, firstTabbable);
|
||||
}
|
||||
else {
|
||||
this.#container.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
#handleCloseAutoFocus() {
|
||||
const event = new CustomEvent("focusScope.onCloseAutoFocus", {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
});
|
||||
this.#opts.onCloseAutoFocus.current?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
// return focus to the element that was focused before this scope opened
|
||||
const preFocusedElement = this.#manager.getPreFocusMemory(this);
|
||||
if (preFocusedElement && document.contains(preFocusedElement)) {
|
||||
// ensure the element is still focusable and in the document
|
||||
try {
|
||||
preFocusedElement.focus();
|
||||
}
|
||||
catch {
|
||||
// fallback if focus fails
|
||||
document.body.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#setupEventListeners() {
|
||||
if (!this.#container || !this.#opts.trap.current)
|
||||
return;
|
||||
const container = this.#container;
|
||||
const doc = container.ownerDocument;
|
||||
const handleFocus = (e) => {
|
||||
if (this.#paused || !this.#manager.isActiveScope(this))
|
||||
return;
|
||||
const target = e.target;
|
||||
if (!target)
|
||||
return;
|
||||
const isInside = container.contains(target);
|
||||
if (isInside) {
|
||||
// store last focused element
|
||||
this.#manager.setFocusMemory(this, target);
|
||||
}
|
||||
else {
|
||||
// focus escaped - bring it back
|
||||
const lastFocused = this.#manager.getFocusMemory(this);
|
||||
if (lastFocused && container.contains(lastFocused) && isFocusable(lastFocused)) {
|
||||
e.preventDefault();
|
||||
lastFocused.focus();
|
||||
}
|
||||
else {
|
||||
// fallback to first tabbable or first focusable or container
|
||||
const firstTabbable = this.#getFirstTabbable();
|
||||
const firstFocusable = this.#getAllFocusables()[0];
|
||||
(firstTabbable || firstFocusable || container).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleKeydown = (e) => {
|
||||
if (!this.#opts.loop || this.#paused || e.key !== "Tab")
|
||||
return;
|
||||
if (!this.#manager.isActiveScope(this))
|
||||
return;
|
||||
const tabbables = this.#getTabbables();
|
||||
if (tabbables.length === 0)
|
||||
return;
|
||||
const first = tabbables[0];
|
||||
const last = tabbables[tabbables.length - 1];
|
||||
if (!e.shiftKey && doc.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
else if (e.shiftKey && doc.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
};
|
||||
this.#cleanupFns.push(on(doc, "focusin", handleFocus, { capture: true }), on(container, "keydown", handleKeydown));
|
||||
const observer = new MutationObserver(() => {
|
||||
const lastFocused = this.#manager.getFocusMemory(this);
|
||||
if (lastFocused && !container.contains(lastFocused)) {
|
||||
// last focused element was removed
|
||||
const firstTabbable = this.#getFirstTabbable();
|
||||
const firstFocusable = this.#getAllFocusables()[0];
|
||||
const elementToFocus = firstTabbable || firstFocusable;
|
||||
if (elementToFocus) {
|
||||
elementToFocus.focus();
|
||||
this.#manager.setFocusMemory(this, elementToFocus);
|
||||
}
|
||||
else {
|
||||
// no focusable elements left, focus container
|
||||
container.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
this.#cleanupFns.push(() => observer.disconnect());
|
||||
}
|
||||
#getTabbables() {
|
||||
if (!this.#container)
|
||||
return [];
|
||||
return tabbable(this.#container, {
|
||||
includeContainer: false,
|
||||
getShadowRoot: true,
|
||||
});
|
||||
}
|
||||
#getFirstTabbable() {
|
||||
const tabbables = this.#getTabbables();
|
||||
return tabbables[0] || null;
|
||||
}
|
||||
#getAllFocusables() {
|
||||
if (!this.#container)
|
||||
return [];
|
||||
return focusable(this.#container, {
|
||||
includeContainer: false,
|
||||
getShadowRoot: true,
|
||||
});
|
||||
}
|
||||
static use(opts) {
|
||||
let scope = null;
|
||||
watch([() => opts.ref.current, () => opts.enabled.current], ([ref, enabled]) => {
|
||||
if (ref && enabled) {
|
||||
if (!scope) {
|
||||
scope = new FocusScope(opts);
|
||||
}
|
||||
scope.mount(ref);
|
||||
}
|
||||
else if (scope) {
|
||||
scope.unmount();
|
||||
scope = null;
|
||||
}
|
||||
});
|
||||
onDestroyEffect(() => {
|
||||
scope?.unmount();
|
||||
});
|
||||
return {
|
||||
get props() {
|
||||
return {
|
||||
tabindex: -1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user