INIT
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s

This commit is contained in:
eewing
2026-02-18 15:17:47 -06:00
parent e74c106f85
commit ec317eb17c
11532 changed files with 1631690 additions and 1 deletions
@@ -0,0 +1,15 @@
import { FocusScope } from "./focus-scope.svelte.js";
export declare class FocusScopeManager {
#private;
static instance: FocusScopeManager;
static getInstance(): FocusScopeManager;
register(scope: FocusScope): void;
unregister(scope: FocusScope): void;
getActive(): FocusScope | undefined;
setFocusMemory(scope: FocusScope, element: HTMLElement): void;
getFocusMemory(scope: FocusScope): HTMLElement | undefined;
isActiveScope(scope: FocusScope): boolean;
setPreFocusMemory(scope: FocusScope, element: HTMLElement): void;
getPreFocusMemory(scope: FocusScope): HTMLElement | undefined;
clearPreFocusMemory(scope: FocusScope): void;
}
@@ -0,0 +1,55 @@
import { simpleBox } from "svelte-toolbelt";
import { FocusScope } from "./focus-scope.svelte.js";
export class FocusScopeManager {
static instance;
#scopeStack = simpleBox([]);
#focusHistory = new WeakMap();
#preFocusHistory = new WeakMap();
static getInstance() {
if (!this.instance) {
this.instance = new FocusScopeManager();
}
return this.instance;
}
register(scope) {
const current = this.getActive();
if (current && current !== scope) {
current.pause();
}
// capture the currently focused element before this scope becomes active
const activeElement = document.activeElement;
if (activeElement && activeElement !== document.body) {
this.#preFocusHistory.set(scope, activeElement);
}
this.#scopeStack.current = this.#scopeStack.current.filter((s) => s !== scope);
this.#scopeStack.current.unshift(scope);
}
unregister(scope) {
this.#scopeStack.current = this.#scopeStack.current.filter((s) => s !== scope);
const next = this.getActive();
if (next) {
next.resume();
}
}
getActive() {
return this.#scopeStack.current[0];
}
setFocusMemory(scope, element) {
this.#focusHistory.set(scope, element);
}
getFocusMemory(scope) {
return this.#focusHistory.get(scope);
}
isActiveScope(scope) {
return this.getActive() === scope;
}
setPreFocusMemory(scope, element) {
this.#preFocusHistory.set(scope, element);
}
getPreFocusMemory(scope) {
return this.#preFocusHistory.get(scope);
}
clearPreFocusMemory(scope) {
this.#preFocusHistory.delete(scope);
}
}
@@ -0,0 +1,27 @@
<script lang="ts">
import { boxWith } from "svelte-toolbelt";
import type { FocusScopeImplProps } from "./types.js";
import { noop } from "../../../internal/noop.js";
import { FocusScope } from "./focus-scope.svelte.js";
let {
enabled = false,
trapFocus = false,
loop = false,
onCloseAutoFocus = noop,
onOpenAutoFocus = noop,
focusScope,
ref,
}: FocusScopeImplProps = $props();
const focusScopeState = FocusScope.use({
enabled: boxWith(() => enabled),
trap: boxWith(() => trapFocus),
loop: loop,
onCloseAutoFocus: boxWith(() => onCloseAutoFocus),
onOpenAutoFocus: boxWith(() => onOpenAutoFocus),
ref,
});
</script>
{@render focusScope?.({ props: focusScopeState.props })}
@@ -0,0 +1,5 @@
import type { FocusScopeImplProps } from "./types.js";
import { FocusScope } from "./focus-scope.svelte.js";
declare const FocusScope: import("svelte").Component<FocusScopeImplProps, {}, "">;
type FocusScope = ReturnType<typeof FocusScope>;
export default FocusScope;
@@ -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,
};
},
};
}
}
+2
View File
@@ -0,0 +1,2 @@
export { default as FocusScope } from "./focus-scope.svelte";
export type { FocusScopeProps } from "./types.js";
+1
View File
@@ -0,0 +1 @@
export { default as FocusScope } from "./focus-scope.svelte";
+39
View File
@@ -0,0 +1,39 @@
import type { Snippet } from "svelte";
import type { EventCallback } from "../../../internal/events.js";
import type { ReadableBox } from "svelte-toolbelt";
export type FocusScopeProps = {
/**
* Event handler called when auto-focusing on open.
* Can be prevented.
*/
onOpenAutoFocus?: EventCallback;
/**
* Event handler called when auto-focusing on close.
* Can be prevented.
*/
onCloseAutoFocus?: EventCallback;
/**
* Whether focus is trapped within the focus scope.
*
* @defaultValue false
*/
trapFocus?: boolean;
};
export type FocusScopeImplProps = {
/**
* The snippet to render the focus scope container with its props.
*/
focusScope?: Snippet<[{
props: Record<string, unknown>;
}]>;
/**
* When `true` will loop through the tabbable elements in the focus scope.
*/
loop?: boolean;
/**
* Whether the content within the focus trap is being force mounted or not.
*/
forceMount?: boolean;
enabled: boolean;
ref: ReadableBox<HTMLElement | null>;
} & FocusScopeProps;
+1
View File
@@ -0,0 +1 @@
export {};