fix(ui): improve editor accessibility flows

This commit is contained in:
Supra4E8C
2026-03-07 14:43:39 +08:00
parent 1fc12c504f
commit 8796a5f618
6 changed files with 295 additions and 62 deletions
+14 -27
View File
@@ -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<AnimatedNotification[]>([]);
const prevNotificationsRef = useRef<Notification[]>([]);
// 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'}`}
>
<div className="message">{notification.message}</div>
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
<button
type="button"
className="close-btn"
onClick={() => handleClose(notification.id)}
aria-label={t('common.close')}
>
<IconX size={16} />
</button>
</div>
+30 -13
View File
@@ -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<string | null>(null);
const [inputValue, setInputValue] = useState('');
@@ -275,14 +278,17 @@ function ApiKeysCardEditor({
}
>
<div className="form-group">
<label>{t('config_management.visual.api_keys.input_label')}</label>
<label htmlFor={apiKeyInputId}>{t('config_management.visual.api_keys.input_label')}</label>
<div className={styles.apiKeyModalInputRow}>
<input
id={apiKeyInputId}
className="input"
placeholder={t('config_management.visual.api_keys.input_placeholder')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={disabled}
aria-describedby={formError ? `${apiKeyErrorId} ${apiKeyHintId}` : apiKeyHintId}
aria-invalid={Boolean(formError)}
/>
<Button
type="button"
@@ -294,8 +300,8 @@ function ApiKeysCardEditor({
{t('config_management.visual.api_keys.generate')}
</Button>
</div>
<div className="hint">{t('config_management.visual.api_keys.input_hint')}</div>
{formError && <div className="error-box">{formError}</div>}
<div id={apiKeyHintId} className="hint">{t('config_management.visual.api_keys.input_hint')}</div>
{formError && <div id={apiKeyErrorId} className="error-box">{formError}</div>}
</div>
</Modal>
</div>
@@ -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}
/>
<div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
<label id={routingStrategyLabelId} htmlFor={`${routingStrategyLabelId}-select`}>{t('config_management.visual.sections.network.routing_strategy')}</label>
<Select
value={values.routingStrategy}
options={[
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
{ value: 'fill-first', label: t('config_management.visual.sections.network.strategy_fill_first') },
]}
id={`${routingStrategyLabelId}-select`}
disabled={disabled}
ariaLabel={t('config_management.visual.sections.network.routing_strategy')}
ariaLabelledBy={routingStrategyLabelId}
ariaDescribedBy={routingStrategyHintId}
onChange={(nextValue) =>
onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] })
}
/>
<div className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
<div id={routingStrategyHintId} className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
</div>
</SectionGrid>
@@ -1089,9 +1105,10 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<SectionGrid>
<div className="form-group">
<label>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
<label htmlFor={keepaliveInputId}>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
<div style={{ position: 'relative' }}>
<input
id={keepaliveInputId}
className="input"
type="number"
placeholder="0"
@@ -1120,8 +1137,8 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
</span>
)}
</div>
{keepaliveError && <div className="error-box">{keepaliveError}</div>}
<div className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
{keepaliveError && <div id={keepaliveErrorId} className="error-box">{keepaliveError}</div>}
<div id={keepaliveHintId} className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
</div>
<Input
label={t('config_management.visual.sections.streaming.bootstrap_retries')}
@@ -1137,7 +1154,7 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
<SectionGrid>
<div className="form-group">
<label>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
<label htmlFor={nonstreamKeepaliveInputId}>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
<div style={{ position: 'relative' }}>
<input
className="input"
@@ -1170,8 +1187,8 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
</span>
)}
</div>
{nonstreamKeepaliveError && <div className="error-box">{nonstreamKeepaliveError}</div>}
<div className="hint">
{nonstreamKeepaliveError && <div id={nonstreamKeepaliveErrorId} className="error-box">{nonstreamKeepaliveError}</div>}
<div id={nonstreamKeepaliveHintId} className="hint">
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
</div>
</div>
+26 -6
View File
@@ -1,4 +1,4 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
@@ -7,20 +7,40 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
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 (
<div className="form-group">
{label && <label>{label}</label>}
{label && <label htmlFor={inputId}>{label}</label>}
<div style={{ position: 'relative' }}>
<input className={`input ${className}`.trim()} {...rest} />
<input
id={inputId}
className={`input ${className}`.trim()}
aria-invalid={Boolean(error) || rest['aria-invalid']}
aria-describedby={describedBy}
{...rest}
/>
{rightElement && (
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
{rightElement}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
{hint && (
<div id={hintId} className="hint">
{hint}
</div>
)}
{error && (
<div id={errorId} className="error-box">
{error}
</div>
)}
</div>
);
}
+109 -5
View File
@@ -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<ModalProps>) {
const { t } = useTranslation();
const titleId = useId();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const getFocusableElements = useCallback(() => {
if (!modalRef.current) return [] as HTMLElement[];
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(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 = (
<div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<div
ref={modalRef}
className={modalClass}
style={{ width }}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<button
ref={closeButtonRef}
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
aria-label={t('common.close')}
disabled={closeDisabled}
>
<IconX size={20} />
</button>
<div className="modal-header">
<div className="modal-title">{title}</div>
<div className="modal-title" id={title ? titleId : undefined}>
{title}
</div>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
+4
View File
@@ -108,3 +108,7 @@
background: rgba($primary-color, 0.1);
font-weight: 600;
}
.optionHighlighted {
background: var(--bg-secondary);
}
+112 -11
View File
@@ -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<HTMLDivElement | null>(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<HTMLButtonElement>) => {
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 (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
@@ -68,20 +168,21 @@ export function Select({
</span>
</button>
{isOpen && (
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
{options.map((opt) => {
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>