fix: improve UI accessibility and responsive layouts
This commit is contained in:
1 parent
cf40abe7ee
commit
89bdbc7aea
56 files changed
+4564
-3815
No files matched your search
@@ -4,6 +4,7 @@ import { Check, Copy, LoaderCircle, Search, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { managementApi } from '../services/managementApi';
|
||||
import { oauthModelsFromPayload, type OAuthModelDefinition } from '../services/oauthModels';
|
||||
import { useDialogFocusTrap } from './useDialogFocusTrap';
|
||||
|
||||
type AuthFileModelsDialogProps = {
|
||||
name: string;
|
||||
@@ -17,6 +18,7 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [copied, setCopied] = useState('');
|
||||
const dialogRef = useDialogFocusTrap<HTMLElement>({ onEscape: onClose });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -45,14 +47,14 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp
|
||||
|
||||
return (
|
||||
<div className="model-discovery-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
|
||||
<section className="model-discovery-dialog auth-model-dialog auth-model-view-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-model-view-title" onKeyDown={(event) => { if (event.key === 'Escape') onClose(); }}>
|
||||
<section ref={dialogRef} className="model-discovery-dialog auth-model-dialog auth-model-view-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-model-view-title">
|
||||
<div className="model-discovery-header">
|
||||
<div>
|
||||
<h2 id="auth-model-view-title">{t('authFiles.models.viewTitle')}</h2>
|
||||
<span className="auth-model-target">{name}</span>
|
||||
<span>{t('authFiles.models.viewDescription')}</span>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')}><X size={18} /></button>
|
||||
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
|
||||
</div>
|
||||
<div className="model-discovery-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ConfirmationDialog({ title, message, confirmText, warning, detai
|
||||
<section ref={dialogRef} className="config-dialog app-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}>
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={20} aria-hidden="true" /><h2 id={titleId}>{title}</h2></div>
|
||||
<button type="button" className="icon-button quiet" onClick={() => onDecision(false)} title={t('common.close')}><X size={18} /></button>
|
||||
<button type="button" className="icon-button quiet" onClick={() => onDecision(false)} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
|
||||
</div>
|
||||
<p id={descriptionId}>{message}</p>
|
||||
{details?.length ? <dl className="app-confirm-details">{details.map((detail) => <div key={detail.label}><dt>{detail.label}</dt><dd>{detail.value}</dd></div>)}</dl> : null}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
const horizontalKeys = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
||||
|
||||
export function handleHorizontalTabKey<T extends string>(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
tabs: readonly T[],
|
||||
current: T,
|
||||
activate: (tab: T) => void,
|
||||
focusTab: (tab: T) => HTMLElement | null,
|
||||
) {
|
||||
if (!horizontalKeys.has(event.key) || tabs.length === 0) return;
|
||||
const index = Math.max(0, tabs.indexOf(current));
|
||||
const nextIndex = event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % tabs.length
|
||||
: (index - 1 + tabs.length) % tabs.length;
|
||||
const next = tabs[nextIndex];
|
||||
event.preventDefault();
|
||||
activate(next);
|
||||
window.requestAnimationFrame(() => focusTab(next)?.focus());
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'button:not(:disabled)',
|
||||
'[href]',
|
||||
'input:not(:disabled)',
|
||||
'select:not(:disabled)',
|
||||
'textarea:not(:disabled)',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
let bodyLockCount = 0;
|
||||
let bodyOverflowBeforeLock = '';
|
||||
|
||||
function lockBodyScroll() {
|
||||
if (bodyLockCount === 0) {
|
||||
bodyOverflowBeforeLock = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
bodyLockCount += 1;
|
||||
return () => {
|
||||
bodyLockCount = Math.max(0, bodyLockCount - 1);
|
||||
if (bodyLockCount === 0) document.body.style.overflow = bodyOverflowBeforeLock;
|
||||
};
|
||||
}
|
||||
|
||||
function visible(element: HTMLElement) {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
|
||||
}
|
||||
|
||||
function focusableElements(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(visible);
|
||||
}
|
||||
|
||||
function topModal() {
|
||||
const dialogs = Array.from(document.querySelectorAll<HTMLElement>(
|
||||
'dialog[open], [role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"]',
|
||||
)).filter(visible);
|
||||
return dialogs[dialogs.length - 1] ?? null;
|
||||
}
|
||||
|
||||
type DialogFocusOptions = {
|
||||
active?: boolean;
|
||||
onEscape?: () => void;
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
preventEscape?: boolean;
|
||||
};
|
||||
|
||||
export function useDialogFocusTrap<T extends HTMLElement>({
|
||||
active = true,
|
||||
onEscape,
|
||||
initialFocusRef,
|
||||
preventEscape = false,
|
||||
}: DialogFocusOptions = {}) {
|
||||
const dialogRef = useRef<T>(null);
|
||||
const escapeRef = useRef(onEscape);
|
||||
const initialFocus = useRef(initialFocusRef);
|
||||
const preventEscapeRef = useRef(preventEscape);
|
||||
const wasActiveRef = useRef(false);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
if (active && !wasActiveRef.current && typeof document !== 'undefined') {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}
|
||||
wasActiveRef.current = active;
|
||||
escapeRef.current = onEscape;
|
||||
initialFocus.current = initialFocusRef;
|
||||
preventEscapeRef.current = preventEscape;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return undefined;
|
||||
|
||||
const previousFocus = previousFocusRef.current
|
||||
?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
const unlockBodyScroll = lockBodyScroll();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const requested = initialFocus.current?.current;
|
||||
const autoFocus = dialog.querySelector<HTMLElement>('[autofocus]');
|
||||
const first = focusableElements(dialog)[0];
|
||||
const target = requested && visible(requested) ? requested : autoFocus ?? first ?? dialog;
|
||||
if (!dialog.hasAttribute('tabindex') && target === dialog) dialog.tabIndex = -1;
|
||||
target.focus();
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (topModal() !== dialog) return;
|
||||
if (event.key === 'Escape') {
|
||||
if (!escapeRef.current && !preventEscapeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
escapeRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const controls = focusableElements(dialog);
|
||||
if (controls.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
const focusInside = dialog.contains(document.activeElement);
|
||||
if (event.shiftKey && (!focusInside || document.activeElement === first)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (!focusInside || document.activeElement === last)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
unlockBodyScroll();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
Reference in new issue
Block a user