Files
EasyCLIProxyAPI/src/appNotice.tsx
T
lzt404 18a9bfbaa9 fix(ui): unify bottom-right notifications and improve dialog interactions
- Move operation feedback into a shared floating stack to prevent layout shifts
- Allow identical errors to reappear after dismissal
- Support keyboard navigation, scrolling, and focus restoration in dialogs
- Add notification interaction regression tests
- Remove code comments throughout the project
2026-09-15 02:39:03 +08:00

164 lines
5.9 KiB
TypeScript

import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
import { NoticePortal } from './components/NoticePortal';
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
import { useI18n } from './i18n';
import type { MessageKey } from './i18n/resources';
import {
appNoticeReducer,
initialAppNoticeState,
type AppNotice,
type AppNoticeAction,
type AppNoticeState,
type NoticeTone,
type NoticeMessage,
} from './services/appNotice';
export type { NoticeTone, NoticeMessage, AppNotice, AppNoticeState, AppNoticeAction };
export interface UseAppNoticeReturn {
showNotice: (message: NoticeMessage, tone?: NoticeTone) => void;
clearNotice: () => void;
notice: AppNotice | null;
revision: number;
}
export function useAppNotice(source?: MessageKey): UseAppNoticeReturn {
const [state, dispatch] = useReducer(appNoticeReducer, initialAppNoticeState);
const owner = useId();
const showNotice = useCallback((message: NoticeMessage, tone: NoticeTone = 'success') => {
dispatch({ type: 'show', notice: { owner, source, message, tone } });
}, [owner, source]);
const clearNotice = useCallback(() => {
dispatch({ type: 'dismiss', owner });
}, [owner]);
return {
showNotice,
clearNotice,
notice: state.notice,
revision: state.revision,
};
}
export interface InlineNoticeProps {
notice?: AppNotice | null;
onDismiss?: () => void;
className?: string;
}
export function InlineNotice({
notice,
onDismiss,
className = '',
}: InlineNoticeProps) {
const { t } = useI18n();
const message = notice
? typeof notice.message === 'string' ? notice.message : t(notice.message.key, notice.message.variables)
: '';
if (!notice || !message.trim()) {
return null;
}
const Icon = notice.tone === 'error' ? AlertCircle : notice.tone === 'success' ? CheckCircle2 : Info;
const isError = notice.tone === 'error';
return (
<div
className={('action-feedback inline-notice ' + notice.tone + (className ? ' ' + className : '')).trim()}
role={isError ? 'alert' : 'status'}
aria-live={isError ? 'assertive' : 'polite'}
aria-atomic="true"
>
<div className="action-feedback-main">
<Icon size={16} className="action-feedback-icon" aria-hidden="true" />
<div className="action-feedback-text" tabIndex={0}>
{notice.source ? <strong className="action-feedback-source">{t(notice.source)}: </strong> : null}
<span className="action-feedback-message">{message}</span>
</div>
</div>
{onDismiss ? (
<button
type="button"
className="action-feedback-dismiss"
onClick={onDismiss}
aria-label={t('app.notice.dismiss')}
title={t('app.notice.dismiss')}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
);
}
export function FloatingNotice(props: InlineNoticeProps) {
const { t } = useI18n();
const { notice } = props;
if (!notice) return null;
const message = typeof notice.message === 'string' ? notice.message : t(notice.message.key, notice.message.variables);
if (!message.trim()) return null;
return <FloatingNoticeInstance key={notice.tone + ':' + message} {...props} notice={notice} />;
}
function FloatingNoticeInstance({ notice, onDismiss, className }: InlineNoticeProps & { notice: AppNotice }) {
const [dismissed, setDismissed] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const entryRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const dismissRef = useRef(onDismiss);
useEffect(() => { dismissRef.current = onDismiss; }, [onDismiss]);
const dismiss = useCallback(() => {
const entry = entryRef.current;
if (entry?.contains(document.activeElement)) {
const dialog = entry.closest('dialog, [role="dialog"], [role="alertdialog"]');
const previous = returnFocusRef.current;
const target = previous?.isConnected && !previous.matches(':disabled')
&& !previous.closest('.app-notice-stack') && (!dialog || dialog.contains(previous))
? previous
: Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]') ?? [])
.find(control => !control.closest('.app-notice-stack') && control.getClientRects().length > 0);
target?.focus();
}
setDismissed(true);
dismissRef.current?.();
}, []);
useEffect(() => {
if (dismissed || hovered || focused || notice.tone !== 'success') return;
const timer = window.setTimeout(dismiss, 6_000);
return () => window.clearTimeout(timer);
}, [dismissed, hovered, focused, notice.tone, dismiss]);
if (dismissed) return null;
return <NoticePortal>
<div ref={entryRef} className="app-notice-entry"
onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}
onFocusCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
returnFocusRef.current = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
}
setFocused(true);
}}
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); }}
onKeyDown={(event) => { if (event.key !== 'Tab') event.stopPropagation(); }}
onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>
<InlineNotice notice={notice} onDismiss={dismiss} className={className} />
</div>
</NoticePortal>;
}
export function MessageNotice({ message, tone = 'error', onDismiss, source }: {
message?: NoticeMessage | null;
tone?: NoticeTone;
onDismiss?: () => void;
source?: MessageKey;
}) {
const owner = useId();
return <FloatingNotice notice={message ? { owner, message, tone, source } : null} onDismiss={onDismiss} />;
}
export const ActionFeedback = FloatingNotice;