Trap keyboard focus in popovers

This commit is contained in:
SirBlob committed 2026-11-01 01:27:47 -04:00
1 parent 2432113c7f
commit 411fb03c22
6 files changed
+61 -5

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
import MusicNowPlaying from './MusicNowPlaying.svelte';
@@ -19,7 +20,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close music player" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Music player">
<div class="popover" role="dialog" aria-modal="true" aria-label="Music player" tabindex="-1" use:focusTrap>
<div class="popover-surface">
{#if track}
<MusicNowPlaying {track} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import {
@@ -41,7 +42,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Battery details">
<div class="popover" role="dialog" aria-modal="true" aria-label="Battery details" tabindex="-1" use:focusTrap>
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
<header class="summary">
<span class="battery-icon">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
@@ -12,7 +13,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Discord status">
<div class="popover" role="dialog" aria-modal="true" aria-label="Discord status" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="profile">
<div class="avatar">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
let { donateUrl, discordUrl, onclose }: { donateUrl: string; discordUrl: string; onclose: () => void } = $props();
@@ -11,7 +12,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close donate card" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Support me">
<div class="popover" role="dialog" aria-modal="true" aria-label="Support me" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="header">
<Icon icon="simple-icons:kofi" width="22" />
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import type { Snippet } from 'svelte';
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
@@ -11,7 +12,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
<div class="popup" role="dialog" aria-label={title}>
<div class="popup" role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap>
<div class="popup-title">{title}</div>
<div class="popup-body">
{@render children()}
+51
View File
@@ -0,0 +1,51 @@
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
function focusableElements(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(focusableSelector)].filter(
(element) => element.getClientRects().length > 0
);
}
export function focusTrap(container: HTMLElement) {
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
(focusableElements(container)[0] ?? container).focus({ preventScroll: true });
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const elements = focusableElements(container);
const first = elements[0];
const last = elements.at(-1);
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
container.focus({ preventScroll: true });
} else if (!container.contains(active)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', handleKeydown);
return {
destroy() {
document.removeEventListener('keydown', handleKeydown);
if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true });
}
};
}