Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/lib/components/command-palette/modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import IconButton from '$lib/holocene/icon-button.svelte';
import { translate } from '$lib/i18n/translate';
import { focusTrap } from '$lib/utilities/focus-trap';

interface Props extends HTMLAttributes<HTMLDialogElement> {
content: Snippet;
Expand Down Expand Up @@ -73,7 +72,6 @@
aria-modal="true"
aria-labelledby="modal-title-{id}"
data-testid={dataTestId}
use:focusTrap={true}
>
{#if !loading}
<IconButton
Expand Down
32 changes: 29 additions & 3 deletions src/lib/holocene/maximizable/maximizable.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { type Snippet } from 'svelte';
import { type Snippet, tick } from 'svelte';
import { twMerge as merge } from 'tailwind-merge';

import { portal } from '$lib/holocene/portal/portal-action';
Expand All @@ -23,14 +23,40 @@
actions = undefined,
}: Props = $props();

// Captured here (before toggling) because maximizing re-renders the content
// (e.g. CodeMirror), which blurs focus to <body> before the focus-trap action
// activates — so the action can't observe the real trigger on its own.
let previouslyFocused: HTMLElement | null = null;

const maximize = () => {
previouslyFocused =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
maximized = true;
};

const minimize = async () => {
maximized = false;
await tick();
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
previouslyFocused = null;
};

let escapeListener = (event: KeyboardEvent) => {
if (maximized && event.key === 'Escape') {
maximized = false;
minimize();
}
};

const handleClick = () => {
maximized = !maximized;
if (maximized) {
minimize();
} else {
maximize();
}
};

const handleFocusOut = (event: FocusEvent) => {
Expand Down
2 changes: 0 additions & 2 deletions src/lib/holocene/modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import { twMerge as merge } from 'tailwind-merge';

import Button from '$lib/holocene/button.svelte';
import { focusTrap } from '$lib/utilities/focus-trap';

import IconButton from './icon-button.svelte';

Expand Down Expand Up @@ -103,7 +102,6 @@
aria-labelledby="modal-title-{id}"
data-testid={$$props['data-testid']}
{...$$restProps}
use:focusTrap={true}
>
{#if !loading}
<IconButton
Expand Down
78 changes: 76 additions & 2 deletions src/lib/utilities/focus-trap.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';

import { getFocusableElements } from './focus-trap';
import { focusTrap, getFocusableElements } from './focus-trap';

describe('getFocusableElements', () => {
it('should return focusable elements', () => {
Expand Down Expand Up @@ -78,3 +78,77 @@ describe('getFocusableElements', () => {
expect(focusable.length).toBe(1);
});
});

describe('focusTrap focus management (inert-based)', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't hurt to add a test for overlapping traps (e.g. if there is a Maximizable component inside a Drawer component).

afterEach(() => {
document.body.innerHTML = '';
});

// Drawer pattern: node is portaled, created-on-open with enabled=true.
it('inerts background content while active and clears it on destroy', () => {
const appRoot = document.createElement('div');
appRoot.appendChild(document.createElement('button'));
const portal = document.createElement('div');
const trapNode = document.createElement('div');
trapNode.appendChild(document.createElement('button'));
portal.appendChild(trapNode);
document.body.append(appRoot, portal);

const action = focusTrap(trapNode, true);
expect(appRoot.inert).toBe(true); // background inerted
expect(portal.inert).toBeFalsy(); // the trap's own branch is not inerted

action.destroy();
expect(appRoot.inert).toBe(false); // cleared on close
});

it('moves focus into the trap and restores it to the trigger on destroy', () => {
const trigger = document.createElement('button');
const trapNode = document.createElement('div');
const inner = document.createElement('button');
trapNode.appendChild(inner);
document.body.append(trigger, trapNode);

trigger.focus();
const action = focusTrap(trapNode, true);
expect(trapNode.contains(document.activeElement)).toBe(true);

action.destroy();
expect(document.activeElement).toBe(trigger);
});

// Maximizable pattern: node always mounted, enabled toggled via update.
it('toggles the trap and restores focus via update', () => {
const trigger = document.createElement('button');
const wrapper = document.createElement('div');
const inner = document.createElement('button');
wrapper.appendChild(inner);
document.body.append(trigger, wrapper);

trigger.focus();
const action = focusTrap(wrapper, false); // mounted disabled
expect(trigger.inert).toBeFalsy();

action.update(true);
expect(trigger.inert).toBe(true);
expect(wrapper.contains(document.activeElement)).toBe(true);

action.update(false);
expect(trigger.inert).toBe(false);
expect(document.activeElement).toBe(trigger);
});

it('does not clear inert that was already set before activation', () => {
const preInert = document.createElement('div');
preInert.inert = true;
const trapNode = document.createElement('div');
trapNode.appendChild(document.createElement('button'));
document.body.append(preInert, trapNode);

const action = focusTrap(trapNode, true);
expect(preInert.inert).toBe(true);

action.destroy();
expect(preInert.inert).toBe(true); // we didn't set it, so we must not clear it
});
});
93 changes: 55 additions & 38 deletions src/lib/utilities/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,64 +9,81 @@ export const getFocusableElements = (node: HTMLElement) =>
!(element.getAttribute('tabindex') === '-1'),
);

export const focusTrap = (node: HTMLElement, enabled: boolean) => {
let firstFocusable: HTMLElement;
let lastFocusable: HTMLElement;
// Make everything outside `node`'s ancestor chain `inert` — removing it from
// the focus order, tab sequence, pointer events, and the accessibility tree.
// Walks up to <body>, marking the siblings at each level. Returns a function
// that clears only the elements this call inerted (pre-existing `inert` state
// is left untouched). This delegates containment to the platform rather than
// re-implementing it with keydown wrapping.
const inertBackground = (node: HTMLElement): (() => void) => {
const inerted: HTMLElement[] = [];
let current: HTMLElement = node;

const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable.focus();
event.preventDefault();
}
} else if (document.activeElement === lastFocusable) {
firstFocusable.focus();
event.preventDefault();
while (current !== document.body && current.parentElement) {
for (const sibling of Array.from(current.parentElement.children)) {
if (
sibling !== current &&
sibling instanceof HTMLElement &&
!sibling.inert
) {
sibling.inert = true;
inerted.push(sibling);
}
}
current = current.parentElement;
}

return () => {
for (const element of inerted) element.inert = false;
};
};

export const focusTrap = (node: HTMLElement, enabled: boolean) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL Svelte runs action setup top-to-bottom, because if we change the order of use:portal and use:focusTrap in the Drawer component it silently breaks. Not sure we need to make any changes, just might be worth noting and/or adding some kind of "use portal before focus trap" lint warning.

let active = false;
let releaseBackground: () => void = () => {};
let previouslyFocused: HTMLElement | null = null;

const setFocus = (fromObserver: boolean = false) => {
if (enabled === false) return;
const activate = () => {
if (active) return;
active = true;

const focusable = getFocusableElements(node);
firstFocusable = focusable[0];
lastFocusable = focusable[focusable.length - 1];
previouslyFocused =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;

if (!fromObserver) firstFocusable?.focus();
releaseBackground = inertBackground(node);

firstFocusable?.addEventListener('keydown', onKeydown);
lastFocusable?.addEventListener('keydown', onKeydown);
const [firstFocusable] = getFocusableElements(node);
(firstFocusable ?? node).focus();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Might be nice to add tabindex="-1" to components like maximizable.svelte and drawer.svelte since by default they have none. Although, it shouldn't ever really be an issue because they will likely always contain a focusable child element.

};

const cleanUp = () => {
firstFocusable?.removeEventListener('keydown', onKeydown);
lastFocusable?.removeEventListener('keydown', onKeydown);
};
const deactivate = () => {
if (!active) return;
active = false;

const onChange = (
mutationRecords: MutationRecord[],
observer: MutationObserver,
) => {
if (mutationRecords.length) {
cleanUp();
setFocus(true);
releaseBackground();
releaseBackground = () => {};

if (previouslyFocused && document.body.contains(previouslyFocused)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we provide a way to pass a fallback or default here? That way if whatever opened the focus trap re-renders (e.g. a CodeBlock) while the user is in it they won't be kicked back to <body>.

previouslyFocused.focus();
}
return observer;
previouslyFocused = null;
};
const observer = new MutationObserver(onChange);
observer.observe(node, { childList: true, subtree: true });

setFocus();
if (enabled) activate();

return {
update(newArgs: boolean) {
enabled = newArgs;
newArgs ? setFocus() : cleanUp();
if (enabled) {
activate();
} else {
deactivate();
}
},
destroy() {
cleanUp();
deactivate();
},
};
};
Loading