fix(a11y): drawers keep keyboard focus inside and are announced as dialogs (#33547)

This commit is contained in:
G30
2026-11-01 17:49:45 +09:00
committed by GitHub
parent 3cda94d403
commit 1dd3793d4e
10 changed files with 77 additions and 39 deletions
@@ -534,6 +534,7 @@
{#if threadId !== null}
<Drawer
show={threadId !== null}
ariaLabel={$i18n.t('Thread')}
onClose={() => {
threadId = null;
}}
@@ -325,6 +325,7 @@
{#if visible}
<Drawer
show={true}
ariaLabel={$i18n.t('Controls')}
onClose={() => showControls.set(false)}
onRequestClose={() => showControls.set(false)}
panelStyle={$mobile ? panelStyle : ''}
+1 -1
View File
@@ -83,7 +83,7 @@
</div>
</Modal>
{:else if $mobile}
<Drawer show={true} onClose={() => (view = null)}>
<Drawer show={true} ariaLabel={$i18n.t('Comments')} onClose={() => (view = null)}>
<div class="h-dvh"><CommentsPanel bind:view onClose={() => (view = null)} /></div>
</Drawer>
{:else}
@@ -26,7 +26,7 @@
});
</script>
<Drawer bind:show className="h-full" zIndexClass="z-99999">
<Drawer bind:show className="h-full" zIndexClass="z-99999" ariaLabel={$i18n.t('Code Editor')}>
<div class="flex h-full flex-col">
<div
class=" sticky top-0 z-30 flex justify-between bg-white px-4.5 pt-3 pb-3 dark:bg-gray-900 dark:text-gray-100"
+59
View File
@@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { fade, fly, slide } from 'svelte/transition';
import * as FocusTrap from 'focus-trap';
import { settings } from '$lib/stores';
import { matchKeybinding, Shortcut } from '$lib/shortcuts';
@@ -13,10 +14,12 @@
export let swipeSide: 'left' | 'right' | undefined = undefined;
export let onClose = () => {};
export let onRequestClose: (() => void) | null = null;
export let ariaLabel: string | undefined = undefined;
const requestClose = () => (onRequestClose ? onRequestClose() : (show = false));
let modalElement = null;
let mounted = false;
let focusTrap: FocusTrap.FocusTrap | null = null;
const handleKeyDown = (event: KeyboardEvent) => {
if (
@@ -34,16 +37,67 @@
return modals.length && modals[modals.length - 1] === modalElement;
};
let handleOutsideInteraction: ((event: Event) => void) | undefined;
let handleModalFocusIn: (() => void) | undefined;
const release = () => {
focusTrap?.deactivate();
focusTrap = null;
if (handleOutsideInteraction) {
document.removeEventListener('pointerdown', handleOutsideInteraction, true);
window.removeEventListener('focusin', handleOutsideInteraction, true);
}
if (handleModalFocusIn) modalElement?.removeEventListener('focusin', handleModalFocusIn);
};
onMount(() => {
mounted = true;
});
$: if (show && modalElement) {
document.body.appendChild(modalElement);
if (!focusTrap) {
focusTrap = FocusTrap.createFocusTrap(modalElement, {
escapeDeactivates: false,
allowOutsideClick: true,
fallbackFocus: modalElement
});
focusTrap.activate();
handleOutsideInteraction = (e) => {
if (
focusTrap &&
modalElement &&
e.target instanceof Node &&
!modalElement.contains(e.target) &&
(e.type === 'pointerdown' ||
(e.target instanceof Element &&
e.target.closest('[role="menu"], [role="listbox"], [role="combobox"]')))
) {
focusTrap.pause();
} else if (
focusTrap &&
modalElement &&
e.target instanceof Element &&
modalElement.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_PRECEDING &&
!e.target.closest('[data-sonner-toast]')
) {
focusTrap.unpause();
}
};
handleModalFocusIn = () => {
if (focusTrap) {
focusTrap.unpause();
}
};
document.addEventListener('pointerdown', handleOutsideInteraction, true);
window.addEventListener('focusin', handleOutsideInteraction, true);
modalElement.addEventListener('focusin', handleModalFocusIn);
}
window.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
} else if (modalElement) {
onClose();
release();
window.removeEventListener('keydown', handleKeyDown);
if (document.body.contains(modalElement)) {
@@ -53,6 +107,7 @@
}
onDestroy(() => {
release();
window.removeEventListener('keydown', handleKeyDown);
show = false;
if (modalElement) {
@@ -69,6 +124,10 @@
{#if show}
<div
bind:this={modalElement}
aria-modal="true"
aria-label={ariaLabel}
role="dialog"
tabindex="-1"
data-swipe-panel={swipeSide}
class="modal fixed right-0 bottom-0 left-0 {zIndexClass} flex h-screen max-h-[100dvh] w-full justify-center overflow-hidden overscroll-contain {swipeSide
? ''
+1 -1
View File
@@ -23,7 +23,7 @@
let inputElement;
</script>
<Drawer bind:show>
<Drawer bind:show ariaLabel={$i18n.t('Input')}>
<div class="flex h-full min-h-screen flex-col" {oninput}>
<div
class=" sticky top-0 z-30 flex justify-between bg-white px-4.5 pt-3 pb-3 dark:bg-gray-900 dark:text-gray-100"
+2 -1
View File
@@ -1512,6 +1512,7 @@ ${content}
<div class="flex min-h-0 flex-1">
<NotePanel
bind:show={showNoteTabs}
ariaLabel={$i18n.t('Pages')}
side="left"
panelWidth={280}
minWidth={180}
@@ -1659,7 +1660,7 @@ ${content}
</div>
{/if}
</div>
<NotePanel bind:show={showNoteChat} onClose={closeNotePanel}>
<NotePanel bind:show={showNoteChat} ariaLabel={$i18n.t('Chat')} onClose={closeNotePanel}>
<div class="flex items-center justify-between px-2 pt-2 pb-1 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#each commentsEnabled ? ['Chat', 'Comments'] : ['Chat'] as tab}
@@ -13,6 +13,7 @@
export let storageKey = '';
export let floating = false;
export let onClose: () => void = () => {};
export let ariaLabel: string | undefined = undefined;
let mediaQuery: MediaQueryList;
let largeScreen = false;
@@ -36,6 +37,7 @@
{#if show}
<Drawer
{show}
{ariaLabel}
onClose={() => {
show = false;
onClose();
@@ -2,7 +2,6 @@
import { getContext, onDestroy } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { mobile } from '$lib/stores';
import { createFocusTrap } from 'focus-trap';
import Files from './Files.svelte';
import FileViewer from './FileViewer.svelte';
import AddContentMenu from './AddContentMenu.svelte';
@@ -89,11 +88,6 @@
goto(navigation.to!.url.href).finally(() => (allowNavigation = false));
});
});
const trapFocus = (node: HTMLElement) => {
const trap = createFocusTrap(node, { escapeDeactivates: false });
trap.activate();
return { destroy: () => trap.deactivate() };
};
onDestroy(() => clearTimeout(debounce));
</script>
@@ -123,15 +117,12 @@
resizerId="knowledge-files-resizer">{@render sidebar()}</ResizableSidePanel
>{@render viewer()}{/if}
</div>
{#if $mobile}<Drawer show={drawerOpen} className="h-full" onRequestClose={close}
><div
class="h-full"
role="dialog"
aria-modal="true"
aria-label={$i18n.t('File preview')}
tabindex="-1"
use:trapFocus
>
{#if $mobile}<Drawer
show={drawerOpen}
className="h-full"
ariaLabel={$i18n.t('File preview')}
onRequestClose={close}
><div class="h-full">
{@render viewer()}
</div></Drawer
>{/if}
@@ -3,7 +3,6 @@
import { onDestroy, getContext, tick, type Snippet } from 'svelte';
import { toast } from 'svelte-sonner';
import { copyToClipboard } from '$lib/utils';
import { createFocusTrap } from 'focus-trap';
import { mobile } from '$lib/stores';
import Drawer from '$lib/components/common/Drawer.svelte';
import ResizableSidePanel from '$lib/components/common/ResizableSidePanel.svelte';
@@ -181,16 +180,7 @@
value = content;
flush();
};
const fileDrawer = (node: HTMLElement) => {
const trap = createFocusTrap(node, { escapeDeactivates: false });
trap.activate();
return {
destroy: () => {
flush();
trap.deactivate();
}
};
};
const fileDrawer = () => ({ destroy: flush });
$: if (!$mobile) showFileDrawer = false;
$: if (loadedPath && value !== loadedValue && !readOnly) {
dirty = true;
@@ -570,15 +560,8 @@
</div>
{#if $mobile}
<Drawer bind:show={showFileDrawer} className="h-full" onClose={flush}>
<div
class="h-full"
role="dialog"
aria-modal="true"
aria-label={selected}
tabindex="-1"
use:fileDrawer
>
<Drawer bind:show={showFileDrawer} className="h-full" ariaLabel={selected} onClose={flush}>
<div class="h-full" use:fileDrawer>
{@render fileContent()}
</div>
</Drawer>