INIT
This commit is contained in:
+15
@@ -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;
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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 })}
|
||||
+5
@@ -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;
|
||||
+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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { default as FocusScope } from "./focus-scope.svelte";
|
||||
export type { FocusScopeProps } from "./types.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default as FocusScope } from "./focus-scope.svelte";
|
||||
+39
@@ -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
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Reference in New Issue
Block a user