mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-14 05:02:01 -06:00
fix(a11y): drawers keep keyboard focus inside and are announced as dialogs (#33547)
This commit is contained in:
@@ -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 : ''}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
? ''
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user