fix: improve UI accessibility and responsive layouts

This commit is contained in:
lzt404 committed 2026-09-27 16:33:50 +08:00
1 parent cf40abe7ee
commit 89bdbc7aea
56 files changed
+4564 -3815

No files matched your search

+4 -2
View File
@@ -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" />
+1 -1
View File
@@ -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}
+25
View File
@@ -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());
}
+131
View File
@@ -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;
}