bits-ui
Version:
The headless components for Svelte.
205 lines (204 loc) • 6.82 kB
JavaScript
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.#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 previously focused element
const prevFocused = document.activeElement;
if (prevFocused && prevFocused !== document.body) {
prevFocused.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 < 2)
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,
};
},
};
}
}