From 8796a5f618b41c8ada5841762c416b5faf1385ad Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 7 Mar 2026 14:43:39 +0800 Subject: [PATCH] fix(ui): improve editor accessibility flows --- .../common/NotificationContainer.tsx | 41 ++---- src/components/config/VisualConfigEditor.tsx | 43 ++++-- src/components/ui/Input.tsx | 32 ++++- src/components/ui/Modal.tsx | 114 +++++++++++++++- src/components/ui/Select.module.scss | 4 + src/components/ui/Select.tsx | 123 ++++++++++++++++-- 6 files changed, 295 insertions(+), 62 deletions(-) diff --git a/src/components/common/NotificationContainer.tsx b/src/components/common/NotificationContainer.tsx index d5f22390..95476eb4 100644 --- a/src/components/common/NotificationContainer.tsx +++ b/src/components/common/NotificationContainer.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNotificationStore } from '@/stores'; import { IconX } from '@/components/ui/icons'; import type { Notification } from '@/types'; @@ -10,52 +11,37 @@ interface AnimatedNotification extends Notification { const ANIMATION_DURATION = 300; // ms export function NotificationContainer() { + const { t } = useTranslation(); const { notifications, removeNotification } = useNotificationStore(); const [animatedNotifications, setAnimatedNotifications] = useState([]); const prevNotificationsRef = useRef([]); - // Track notifications and manage animation states useEffect(() => { const prevNotifications = prevNotificationsRef.current; const prevIds = new Set(prevNotifications.map((n) => n.id)); const currentIds = new Set(notifications.map((n) => n.id)); - // Find new notifications (for enter animation) const newNotifications = notifications.filter((n) => !prevIds.has(n.id)); - // Find removed notifications (for exit animation) - const removedIds = new Set( - prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id) - ); + const removedIds = new Set(prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)); setAnimatedNotifications((prev) => { - // Mark removed notifications as exiting - let updated = prev.map((n) => - removedIds.has(n.id) ? { ...n, isExiting: true } : n - ); + let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n)); - // Add new notifications newNotifications.forEach((n) => { - if (!updated.find((an) => an.id === n.id)) { + if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) { updated.push({ ...n, isExiting: false }); } }); - // Remove notifications that are not in current and not exiting - // (they've already completed their exit animation) - updated = updated.filter( - (n) => currentIds.has(n.id) || n.isExiting - ); + updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting); return updated; }); - // Clean up exited notifications after animation if (removedIds.size > 0) { setTimeout(() => { - setAnimatedNotifications((prev) => - prev.filter((n) => !removedIds.has(n.id)) - ); + setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id))); }, ANIMATION_DURATION); } @@ -63,12 +49,8 @@ export function NotificationContainer() { }, [notifications]); const handleClose = (id: string) => { - // Start exit animation - setAnimatedNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n)) - ); + setAnimatedNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))); - // Actually remove after animation setTimeout(() => { removeNotification(id); }, ANIMATION_DURATION); @@ -84,7 +66,12 @@ export function NotificationContainer() { className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`} >
{notification.message}
- diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 08f0efcd..76a95bd8 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from 'react'; +import { useId, useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -120,6 +120,9 @@ function ApiKeysCardEditor({ return [...apiKeyIds, ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId())]; }, [apiKeyIds, apiKeys.length]); + const apiKeyInputId = useId(); + const apiKeyHintId = `${apiKeyInputId}-hint`; + const apiKeyErrorId = `${apiKeyInputId}-error`; const [modalOpen, setModalOpen] = useState(false); const [editingApiKeyId, setEditingApiKeyId] = useState(null); const [inputValue, setInputValue] = useState(''); @@ -275,14 +278,17 @@ function ApiKeysCardEditor({ } >
- +
setInputValue(e.target.value)} disabled={disabled} + aria-describedby={formError ? `${apiKeyErrorId} ${apiKeyHintId}` : apiKeyHintId} + aria-invalid={Boolean(formError)} />
-
{t('config_management.visual.api_keys.input_hint')}
- {formError &&
{formError}
} +
{t('config_management.visual.api_keys.input_hint')}
+ {formError &&
{formError}
}
@@ -832,6 +838,14 @@ function PayloadFilterRulesEditor({ export function VisualConfigEditor({ values, validationErrors, disabled = false, onChange }: VisualConfigEditorProps) { const { t } = useTranslation(); + const routingStrategyLabelId = useId(); + const routingStrategyHintId = `${routingStrategyLabelId}-hint`; + const keepaliveInputId = useId(); + const keepaliveHintId = `${keepaliveInputId}-hint`; + const keepaliveErrorId = `${keepaliveInputId}-error`; + const nonstreamKeepaliveInputId = useId(); + const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`; + const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`; const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; const isNonstreamKeepaliveDisabled = values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; @@ -1032,20 +1046,22 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, error={maxRetryIntervalError} />
- + )}
- {keepaliveError &&
{keepaliveError}
} -
{t('config_management.visual.sections.streaming.keepalive_hint')}
+ {keepaliveError &&
{keepaliveError}
} +
{t('config_management.visual.sections.streaming.keepalive_hint')}
- +
)}
- {nonstreamKeepaliveError &&
{nonstreamKeepaliveError}
} -
+ {nonstreamKeepaliveError &&
{nonstreamKeepaliveError}
} +
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 3ebe3dc4..2284d57a 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1,4 +1,4 @@ -import type { InputHTMLAttributes, ReactNode } from 'react'; +import { useId, type InputHTMLAttributes, type ReactNode } from 'react'; interface InputProps extends InputHTMLAttributes { label?: string; @@ -7,20 +7,40 @@ interface InputProps extends InputHTMLAttributes { rightElement?: ReactNode; } -export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) { +export function Input({ label, hint, error, rightElement, className = '', id, ...rest }: InputProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = hint ? `${inputId}-hint` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = [rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined; + return (
- {label && } + {label && }
- + {rightElement && (
{rightElement}
)}
- {hint &&
{hint}
} - {error &&
{error}
} + {hint && ( +
+ {hint} +
+ )} + {error && ( +
+ {error} +
+ )}
); } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 03790b5b..50aab720 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,5 +1,14 @@ -import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type PropsWithChildren, + type ReactNode, +} from 'react'; import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; import { IconX } from './icons'; interface ModalProps { @@ -14,6 +23,14 @@ interface ModalProps { const CLOSE_ANIMATION_DURATION = 350; const MODAL_LOCK_CLASS = 'modal-open'; +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); let activeModalCount = 0; const scrollLockSnapshot = { @@ -107,11 +124,23 @@ export function Modal({ width = 520, className, closeDisabled = false, - children + children, }: PropsWithChildren) { + const { t } = useTranslation(); + const titleId = useId(); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); const closeTimerRef = useRef | null>(null); + const modalRef = useRef(null); + const closeButtonRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + const getFocusableElements = useCallback(() => { + if (!modalRef.current) return [] as HTMLElement[]; + return Array.from(modalRef.current.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => !element.hasAttribute('disabled') && element.tabIndex !== -1 + ); + }, []); const startClose = useCallback( (notifyParent: boolean) => { @@ -174,6 +203,70 @@ export function Modal({ return () => unlockScroll(); }, [shouldLockScroll]); + useEffect(() => { + if (!open) return; + + previouslyFocusedRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + const focusTimer = window.setTimeout(() => { + const firstFocusable = getFocusableElements()[0]; + (firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus(); + }, 0); + + return () => { + window.clearTimeout(focusTimer); + }; + }, [getFocusableElements, open]); + + useEffect(() => { + if (open || isVisible) return; + previouslyFocusedRef.current?.focus(); + previouslyFocusedRef.current = null; + }, [isVisible, open]); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (closeDisabled) return; + event.preventDefault(); + handleClose(); + return; + } + + if (event.key !== 'Tab') return; + + const focusableElements = getFocusableElements(); + if (focusableElements.length === 0) { + event.preventDefault(); + modalRef.current?.focus(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (event.shiftKey) { + if (activeElement === firstElement || activeElement === modalRef.current) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [closeDisabled, getFocusableElements, handleClose, open]); + if (!open && !isVisible) return null; const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`; @@ -181,18 +274,29 @@ export function Modal({ const modalContent = (
-
+
-
{title}
+
+ {title} +
{children}
{footer &&
{footer}
} diff --git a/src/components/ui/Select.module.scss b/src/components/ui/Select.module.scss index a0544ef2..93f1f031 100644 --- a/src/components/ui/Select.module.scss +++ b/src/components/ui/Select.module.scss @@ -108,3 +108,7 @@ background: rgba($primary-color, 0.1); font-weight: 600; } + +.optionHighlighted { + background: var(--bg-secondary); +} diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 817fea53..3bd9e916 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { IconChevronDown } from './icons'; import styles from './Select.module.scss'; @@ -15,7 +15,10 @@ interface SelectProps { className?: string; disabled?: boolean; ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; fullWidth?: boolean; + id?: string; } export function Select({ @@ -26,9 +29,16 @@ export function Select({ className, disabled = false, ariaLabel, - fullWidth = true + ariaLabelledBy, + ariaDescribedBy, + fullWidth = true, + id, }: SelectProps) { + const generatedId = useId(); + const selectId = id ?? generatedId; + const listboxId = `${selectId}-listbox`; const [open, setOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const wrapRef = useRef(null); useEffect(() => { @@ -41,23 +51,113 @@ export function Select({ }, [disabled, open]); const isOpen = open && !disabled; - - const selected = options.find((o) => o.value === value); + const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]); + const resolvedHighlightedIndex = + highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1; + const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined; const displayText = selected?.label ?? placeholder ?? ''; const isPlaceholder = !selected && placeholder; + const commitSelection = useCallback( + (nextIndex: number) => { + const nextOption = options[nextIndex]; + if (!nextOption) return; + onChange(nextOption.value); + setOpen(false); + setHighlightedIndex(nextIndex); + }, + [onChange, options] + ); + + const moveHighlight = useCallback( + (direction: 1 | -1) => { + if (options.length === 0) return; + const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length; + setHighlightedIndex(nextIndex); + }, + [options.length, resolvedHighlightedIndex] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (disabled) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + moveHighlight(1); + return; + case 'ArrowUp': + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + moveHighlight(-1); + return; + case 'Home': + if (!isOpen || options.length === 0) return; + event.preventDefault(); + setHighlightedIndex(0); + return; + case 'End': + if (!isOpen || options.length === 0) return; + event.preventDefault(); + setHighlightedIndex(options.length - 1); + return; + case 'Enter': + case ' ': { + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + if (resolvedHighlightedIndex >= 0) { + commitSelection(resolvedHighlightedIndex); + } + return; + } + case 'Escape': + if (!isOpen) return; + event.preventDefault(); + setOpen(false); + return; + case 'Tab': + if (isOpen) setOpen(false); + return; + default: + return; + } + }, + [commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex] + ); + return (
{isOpen && ( -
- {options.map((opt) => { +
+ {options.map((opt, index) => { const active = opt.value === value; + const highlighted = index === resolvedHighlightedIndex; return (