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 (
{onDismiss ? ( ) : null}
); } 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 ; } 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(null); const returnFocusRef = useRef(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('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
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()}>
; } export function MessageNotice({ message, tone = 'error', onDismiss, source }: { message?: NoticeMessage | null; tone?: NoticeTone; onDismiss?: () => void; source?: MessageKey; }) { const owner = useId(); return ; } export const ActionFeedback = FloatingNotice;