From 838d5d6bf30e16a277e5b367b648333e8923759a Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 03:15:27 +0800 Subject: [PATCH 1/2] feat: coordinate application modals --- src/components/ui/Dialog.tsx | 9 +- src/components/ui/ModalCoordinator.tsx | 127 ++++++++++++++++++ src/components/ui/PageHeader.tsx | 6 +- src/features/history/StorageCleanupCard.tsx | 107 ++++++++------- .../modalCoordinator.integration.test.ts | 41 ++++++ src/features/shared/modalCoordinator.test.ts | 110 +++++++++++++++ src/features/shared/modalCoordinator.ts | 125 +++++++++++++++++ src/layouts/GlobalShell.tsx | 54 ++++++-- src/layouts/ShellHeader.test.tsx | 28 +++- src/layouts/ShellHeader.tsx | 43 +++++- src/layouts/ShellUpdateModal.tsx | 5 +- src/pages/Install.tsx | 109 +++++++++------ src/pages/RunDetail.tsx | 80 ++++++----- 13 files changed, 698 insertions(+), 146 deletions(-) create mode 100644 src/components/ui/ModalCoordinator.tsx create mode 100644 src/features/shared/modalCoordinator.integration.test.ts create mode 100644 src/features/shared/modalCoordinator.test.ts create mode 100644 src/features/shared/modalCoordinator.ts diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index 15dcc424..04ff631e 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, type ReactNode } from 'react'; +import { useActiveModalDismissalPolicy } from './ModalCoordinator'; /** * Modal dialog backed by the native element. showModal() gives us the @@ -21,6 +22,8 @@ export function Dialog({ children: ReactNode; }) { const ref = useRef(null); + const dismissalPolicy = useActiveModalDismissalPolicy(); + const dismissalBlocked = dismissalPolicy === 'blocked'; useEffect(() => { const el = ref.current; @@ -38,11 +41,13 @@ export function Dialog({ onCancel={(event) => { // Escape fires `cancel`; we own the close so the parent state stays in sync. event.preventDefault(); - onClose('escape'); + if (!dismissalBlocked) onClose('escape'); }} onClick={(event) => { // A click on the dialog itself (the backdrop area around the card) closes it. - if (event.target === event.currentTarget) onClose('backdrop'); + if (!dismissalBlocked && event.target === event.currentTarget) { + onClose('backdrop'); + } }} > {children} diff --git a/src/components/ui/ModalCoordinator.tsx b/src/components/ui/ModalCoordinator.tsx new file mode 100644 index 00000000..01e8abee --- /dev/null +++ b/src/components/ui/ModalCoordinator.tsx @@ -0,0 +1,127 @@ +import { + createContext, + use, + useEffect, + useLayoutEffect, + useRef, + useSyncExternalStore, + type ReactNode, + type RefObject +} from 'react'; +import { + createModalCoordinator, + restoreModalFocus, + type ModalCoordinator, + type ModalDismissalPolicy, + type ModalPriority, + type RegisteredModal +} from '../../features/shared/modalCoordinator'; + +const ModalCoordinatorContext = createContext(null); +const ActiveModalPolicyContext = createContext( + null +); + +export function ModalCoordinatorProvider({ + children +}: { + children: ReactNode; +}) { + const coordinatorRef = useRef(null); + coordinatorRef.current ??= createModalCoordinator(); + const coordinator = coordinatorRef.current; + const snapshot = useSyncExternalStore( + coordinator.subscribe, + coordinator.getSnapshot, + coordinator.getSnapshot + ); + const previousActiveRef = useRef(null); + + useEffect(() => { + const previous = previousActiveRef.current; + previousActiveRef.current = snapshot.active; + if (!previous || previous.id === snapshot.active?.id) return; + + const wasPreempted = snapshot.queued.some(({ id }) => id === previous.id); + if (wasPreempted || snapshot.active) return; + + const preferred = previous.restoreFocusTo?.() ?? null; + let cancelled = false; + queueMicrotask(() => { + if (cancelled || coordinator.getSnapshot().active) return; + restoreModalFocus(preferred, [ + document.querySelector('[data-page-heading]'), + document.querySelector('main') + ]); + }); + return () => { + cancelled = true; + }; + }, [coordinator, snapshot]); + + return ( + + {children} + + ); +} + +export function ModalSource({ + id, + open, + priority, + dismissalPolicy, + restoreFocusRef, + children +}: { + id: string; + open: boolean; + priority: ModalPriority; + dismissalPolicy: ModalDismissalPolicy; + restoreFocusRef?: RefObject; + children: ReactNode; +}) { + const coordinator = use(ModalCoordinatorContext); + if (!coordinator) { + throw new Error( + 'ModalSource must be used inside ModalCoordinatorProvider.' + ); + } + const snapshot = useSyncExternalStore( + coordinator.subscribe, + coordinator.getSnapshot, + coordinator.getSnapshot + ); + + useLayoutEffect(() => { + if (!open) return; + const activeElement = + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body && + document.activeElement !== document.documentElement + ? document.activeElement + : null; + return coordinator.register({ + id, + priority, + dismissalPolicy, + restoreFocusTo: () => restoreFocusRef?.current ?? activeElement + }); + }, [coordinator, id, open, restoreFocusRef]); + + useLayoutEffect(() => { + if (!open) return; + coordinator.update(id, { priority, dismissalPolicy }); + }, [coordinator, dismissalPolicy, id, open, priority]); + + if (!open || snapshot.active?.id !== id) return null; + return ( + + {children} + + ); +} + +export function useActiveModalDismissalPolicy(): ModalDismissalPolicy | null { + return use(ActiveModalPolicyContext); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx index 5259d470..a445b927 100644 --- a/src/components/ui/PageHeader.tsx +++ b/src/components/ui/PageHeader.tsx @@ -28,7 +28,11 @@ export function PageHeader({ function Title({ children }: { children: ReactNode }) { return ( -

+

{children}

); diff --git a/src/features/history/StorageCleanupCard.tsx b/src/features/history/StorageCleanupCard.tsx index 27bf4aa1..33a0d31c 100644 --- a/src/features/history/StorageCleanupCard.tsx +++ b/src/features/history/StorageCleanupCard.tsx @@ -15,6 +15,7 @@ import { type CleanupScope, type PendingCleanup } from './useStorageCleanup'; +import { ModalSource } from '../../components/ui/ModalCoordinator'; const PRESETS: Array<{ preset: StorageCleanupPreset; @@ -121,55 +122,67 @@ export function StorageCleanupCard({ - {cleanup.pending && ( - -

- {t('storageCleanupTarget', { - scope: - cleanup.pending.scope === 'screenshots' - ? t('storageCleanupScreenshotsLabel') - : t('storageCleanupRunDataLabel'), - preset: t( - PRESETS.find(({ preset }) => preset === cleanup.pending?.preset) - ?.labelKey ?? 'storageCleanupPresetAll' - ) - })} -

-

- {pendingBody(cleanup.pending)} -

- {cleanup.pending.preview.skipped_pending_uploads > 0 && ( -

- {t('storageCleanupSkippedPending', { - count: cleanup.pending.preview.skipped_pending_uploads + + {cleanup.pending && ( + +

+ {t('storageCleanupTarget', { + scope: + cleanup.pending.scope === 'screenshots' + ? t('storageCleanupScreenshotsLabel') + : t('storageCleanupRunDataLabel'), + preset: t( + PRESETS.find( + ({ preset }) => preset === cleanup.pending?.preset + )?.labelKey ?? 'storageCleanupPresetAll' + ) })}

- )} - {cleanup.problem && ( - - )} -
- )} +

+ {pendingBody(cleanup.pending)} +

+ {cleanup.pending.preview.skipped_pending_uploads > 0 && ( +

+ {t('storageCleanupSkippedPending', { + count: cleanup.pending.preview.skipped_pending_uploads + })} +

+ )} + {cleanup.problem && ( + + )} + + )} + ); } diff --git a/src/features/shared/modalCoordinator.integration.test.ts b/src/features/shared/modalCoordinator.integration.test.ts new file mode 100644 index 00000000..c40e0d08 --- /dev/null +++ b/src/features/shared/modalCoordinator.integration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createModalCoordinator } from './modalCoordinator'; + +describe('shell and routed modal integration', () => { + it('queues background shell sources behind a routed confirmation across source unmounts', () => { + const coordinator = createModalCoordinator(); + coordinator.register({ + id: 'route:delete-video', + priority: 'confirmation', + dismissalPolicy: 'dismissible' + }); + coordinator.register({ + id: 'shell:update', + priority: 'system', + dismissalPolicy: 'dismissible' + }); + coordinator.register({ + id: 'shell:payment', + priority: 'informational', + dismissalPolicy: 'dismissible' + }); + + expect(coordinator.getSnapshot().active?.id).toBe('route:delete-video'); + + coordinator.update('route:delete-video', { + priority: 'critical', + dismissalPolicy: 'blocked' + }); + expect(coordinator.getSnapshot().active).toMatchObject({ + id: 'route:delete-video', + priority: 'critical', + dismissalPolicy: 'blocked' + }); + + coordinator.unregister('route:delete-video'); + expect(coordinator.getSnapshot().active?.id).toBe('shell:update'); + + coordinator.unregister('shell:update'); + expect(coordinator.getSnapshot().active?.id).toBe('shell:payment'); + }); +}); diff --git a/src/features/shared/modalCoordinator.test.ts b/src/features/shared/modalCoordinator.test.ts new file mode 100644 index 00000000..7a1f5a77 --- /dev/null +++ b/src/features/shared/modalCoordinator.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createModalCoordinator, + restoreModalFocus, + type ModalPriority, + type ModalRequest +} from './modalCoordinator'; + +function request(id: string, priority: ModalPriority): ModalRequest { + return { + id, + priority, + dismissalPolicy: priority === 'critical' ? 'blocked' : 'dismissible' + }; +} + +describe('modal coordinator', () => { + it('uses critical, confirmation, system, then informational priority', () => { + const coordinator = createModalCoordinator(); + + coordinator.register(request('payment', 'informational')); + expect(coordinator.getSnapshot().active?.id).toBe('payment'); + + coordinator.register(request('update', 'system')); + expect(coordinator.getSnapshot().active?.id).toBe('update'); + + coordinator.register(request('reset', 'confirmation')); + expect(coordinator.getSnapshot().active?.id).toBe('reset'); + + coordinator.register(request('delete-running', 'critical')); + expect(coordinator.getSnapshot().active?.id).toBe('delete-running'); + }); + + it('does not let update or support requests interrupt an active confirmation', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('cleanup', 'confirmation')); + coordinator.register(request('update', 'system')); + coordinator.register(request('payment', 'informational')); + + expect(coordinator.getSnapshot().active?.id).toBe('cleanup'); + expect(coordinator.getSnapshot().queued.map(({ id }) => id)).toEqual([ + 'update', + 'payment' + ]); + }); + + it('keeps equal priorities FIFO and safely removes a queued source', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('first-reset', 'confirmation')); + coordinator.register(request('second-delete', 'confirmation')); + coordinator.register(request('third-cleanup', 'confirmation')); + + coordinator.unregister('second-delete'); + coordinator.unregister('first-reset'); + + expect(coordinator.getSnapshot().active?.id).toBe('third-cleanup'); + expect(coordinator.getSnapshot().queued).toEqual([]); + }); + + it('retains an active confirmation when it adopts critical blocked semantics', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('reset', 'confirmation')); + coordinator.register(request('other-confirmation', 'confirmation')); + + coordinator.update('reset', { + priority: 'critical', + dismissalPolicy: 'blocked' + }); + + expect(coordinator.getSnapshot().active).toMatchObject({ + id: 'reset', + priority: 'critical', + dismissalPolicy: 'blocked' + }); + expect(coordinator.getSnapshot().queued.map(({ id }) => id)).toEqual([ + 'other-confirmation' + ]); + }); + + it('returns to a preempted source after critical work unregisters', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('update', 'system')); + coordinator.register(request('reset', 'confirmation')); + coordinator.register(request('native-critical', 'critical')); + + expect(coordinator.getSnapshot().active?.id).toBe('native-critical'); + coordinator.unregister('native-critical'); + expect(coordinator.getSnapshot().active?.id).toBe('reset'); + }); +}); + +describe('modal focus restoration', () => { + it('prefers a still-connected trigger', () => { + const trigger = { isConnected: true, focus: vi.fn() }; + const heading = { isConnected: true, focus: vi.fn() }; + + expect(restoreModalFocus(trigger, [heading])).toBe(trigger); + expect(trigger.focus).toHaveBeenCalledOnce(); + expect(heading.focus).not.toHaveBeenCalled(); + }); + + it('falls back to the first connected page target after route unmount', () => { + const staleTrigger = { isConnected: false, focus: vi.fn() }; + const staleHeading = { isConnected: false, focus: vi.fn() }; + const main = { isConnected: true, focus: vi.fn() }; + + expect(restoreModalFocus(staleTrigger, [staleHeading, main])).toBe(main); + expect(main.focus).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/shared/modalCoordinator.ts b/src/features/shared/modalCoordinator.ts new file mode 100644 index 00000000..8bfc1a42 --- /dev/null +++ b/src/features/shared/modalCoordinator.ts @@ -0,0 +1,125 @@ +export type ModalPriority = + | 'critical' + | 'confirmation' + | 'system' + | 'informational'; + +export type ModalDismissalPolicy = 'dismissible' | 'blocked'; + +export interface ModalFocusTarget { + readonly isConnected: boolean; + focus(): void; +} + +export interface ModalRequest { + id: string; + priority: ModalPriority; + dismissalPolicy: ModalDismissalPolicy; + restoreFocusTo?: () => ModalFocusTarget | null; +} + +export type RegisteredModal = ModalRequest & { sequence: number }; + +export type ModalCoordinatorSnapshot = { + active: RegisteredModal | null; + queued: readonly RegisteredModal[]; +}; + +export interface ModalCoordinator { + getSnapshot(): ModalCoordinatorSnapshot; + subscribe(listener: () => void): () => void; + register(request: ModalRequest): () => void; + update(id: string, patch: Partial>): boolean; + unregister(id: string): boolean; +} + +const PRIORITY_RANK: Record = { + critical: 4, + confirmation: 3, + system: 2, + informational: 1 +}; + +export function createModalCoordinator(): ModalCoordinator { + const entries = new Map(); + const listeners = new Set<() => void>(); + let nextSequence = 0; + let activeId: string | null = null; + let snapshot: ModalCoordinatorSnapshot = { active: null, queued: [] }; + + const sortedEntries = () => + [...entries.values()].sort( + (left, right) => + PRIORITY_RANK[right.priority] - PRIORITY_RANK[left.priority] || + left.sequence - right.sequence + ); + + const publish = () => { + const ordered = sortedEntries(); + const current = activeId ? entries.get(activeId) : null; + if (!current) { + activeId = ordered[0]?.id ?? null; + } else { + const challenger = ordered.find(({ id }) => id !== current.id); + if ( + challenger && + PRIORITY_RANK[challenger.priority] > PRIORITY_RANK[current.priority] + ) { + activeId = challenger.id; + } + } + + const active = activeId ? (entries.get(activeId) ?? null) : null; + snapshot = { + active, + queued: sortedEntries().filter(({ id }) => id !== active?.id) + }; + for (const listener of listeners) listener(); + }; + + const unregister = (id: string) => { + if (!entries.delete(id)) return false; + if (activeId === id) activeId = null; + publish(); + return true; + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + register: (request) => { + const existing = entries.get(request.id); + entries.set(request.id, { + ...request, + sequence: existing?.sequence ?? nextSequence++ + }); + publish(); + return () => { + unregister(request.id); + }; + }, + update: (id, patch) => { + const existing = entries.get(id); + if (!existing) return false; + entries.set(id, { ...existing, ...patch, id }); + publish(); + return true; + }, + unregister + }; +} + +export function restoreModalFocus( + preferred: ModalFocusTarget | null, + fallbacks: readonly (ModalFocusTarget | null)[] +): ModalFocusTarget | null { + const target = [preferred, ...fallbacks].find( + (candidate): candidate is ModalFocusTarget => + candidate !== null && candidate.isConnected + ); + target?.focus(); + return target ?? null; +} diff --git a/src/layouts/GlobalShell.tsx b/src/layouts/GlobalShell.tsx index f2a06fcf..40bf5daa 100644 --- a/src/layouts/GlobalShell.tsx +++ b/src/layouts/GlobalShell.tsx @@ -1,5 +1,5 @@ import { Outlet } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AppBootstrapProvider, useAppBootstrap @@ -10,12 +10,18 @@ import { ShellHeader } from './ShellHeader'; import { ShellNavRail } from './ShellNavRail'; import { ShellPaymentModal } from './ShellPaymentModal'; import { ShellUpdateModal } from './ShellUpdateModal'; +import { + ModalCoordinatorProvider, + ModalSource +} from '../components/ui/ModalCoordinator'; export default function GlobalShell() { return ( - + + + ); @@ -25,6 +31,8 @@ function GlobalShellContent() { const [showBilibili, setShowBilibili] = useState(false); const [showSupport, setShowSupport] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); + const bilibiliTriggerRef = useRef(null); + const supportTriggerRef = useRef(null); const app = useAppBootstrap(); const updater = useUpdater(); @@ -32,12 +40,16 @@ function GlobalShellContent() { // behaviour these controlled dropdowns were missing. useEffect(() => { if (!showBilibili && !showSupport) return; - const closeMenus = () => { + const closeMenus = (restoreFocus = false) => { + const focusTarget = showBilibili + ? bilibiliTriggerRef.current + : supportTriggerRef.current; setShowBilibili(false); setShowSupport(false); + if (restoreFocus) queueMicrotask(() => focusTarget?.focus()); }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeMenus(); + if (event.key === 'Escape') closeMenus(true); }; const onPointerDown = (event: PointerEvent) => { const target = event.target as HTMLElement | null; @@ -55,12 +67,14 @@ function GlobalShellContent() {
{ setShowBilibili((open) => !open); setShowSupport(false); }} showSupport={showSupport} + supportTriggerRef={supportTriggerRef} onToggleSupport={() => { setShowSupport((open) => !open); setShowBilibili(false); @@ -75,7 +89,10 @@ function GlobalShellContent() {
-
+
- {showPaymentModal && ( + setShowPaymentModal(false)} /> - )} - {isUpdateModalPhase(updater) && } + + + +
); } diff --git a/src/layouts/ShellHeader.test.tsx b/src/layouts/ShellHeader.test.tsx index 9e665d90..3b4da89f 100644 --- a/src/layouts/ShellHeader.test.tsx +++ b/src/layouts/ShellHeader.test.tsx @@ -24,15 +24,21 @@ const app: AppBootstrapController = { } }; -function renderOpenHeader() { +function renderHeader({ + showBilibili = false, + showSupport = false +}: { + showBilibili?: boolean; + showSupport?: boolean; +} = {}) { return renderToStaticMarkup( undefined} - showSupport={false} + showSupport={showSupport} onToggleSupport={() => undefined} onOpenPayment={() => undefined} onCloseBilibili={() => undefined} @@ -45,7 +51,7 @@ function renderOpenHeader() { describe('ShellHeader Bilibili menu', () => { it('shows the author, CoreDev, and project entries in order', () => { - const html = renderOpenHeader(); + const html = renderHeader({ showBilibili: true }); const authorHrefIndex = html.indexOf('https://example.com/bilibili-author'); const coreDevHrefIndex = html.indexOf( @@ -78,4 +84,18 @@ describe('ShellHeader Bilibili menu', () => { expect(coreDevSubtitleIndex).toBeLessThan(projectIndex); expect(projectSubtitleIndex).toBeGreaterThan(projectIndex); }); + + it('exposes controlled keyboard-operable disclosure semantics', () => { + const closed = renderHeader(); + const bilibiliOpen = renderHeader({ showBilibili: true }); + const supportOpen = renderHeader({ showSupport: true }); + + expect(closed).toContain('aria-controls="shell-bilibili-menu"'); + expect(closed).toContain('aria-controls="shell-support-menu"'); + expect(closed.match(/aria-expanded="false"/g)).toHaveLength(2); + expect(bilibiliOpen).toContain('id="shell-bilibili-menu"'); + expect(bilibiliOpen).toContain('aria-expanded="true"'); + expect(supportOpen).toContain('id="shell-support-menu"'); + expect(supportOpen).toContain('aria-expanded="true"'); + }); }); diff --git a/src/layouts/ShellHeader.tsx b/src/layouts/ShellHeader.tsx index 20e7661c..0af7c60b 100644 --- a/src/layouts/ShellHeader.tsx +++ b/src/layouts/ShellHeader.tsx @@ -10,7 +10,7 @@ import { QrCode, Users } from 'lucide-react'; -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, ReactNode, RefObject } from 'react'; import type { AppBootstrapController } from '../features/about/useAppBootstrap'; import { useUpdater } from '../features/about/UpdaterProvider'; import { useI18n } from '../i18n/LocaleProvider'; @@ -19,9 +19,11 @@ import xiaohongshuSvg from '../../static/support/xiaohongshu.svg'; type ShellHeaderProps = { app: AppBootstrapController; + bilibiliTriggerRef?: RefObject; showBilibili: boolean; onToggleBilibili: () => void; showSupport: boolean; + supportTriggerRef?: RefObject; onToggleSupport: () => void; onOpenPayment: () => void; onCloseBilibili: () => void; @@ -30,9 +32,11 @@ type ShellHeaderProps = { export function ShellHeader({ app, + bilibiliTriggerRef, showBilibili, onToggleBilibili, showSupport, + supportTriggerRef, onToggleSupport, onOpenPayment, onCloseBilibili, @@ -54,9 +58,11 @@ export function ShellHeader({ ; showBilibili: boolean; onToggleBilibili: () => void; showSupport: boolean; + supportTriggerRef?: RefObject; onToggleSupport: () => void; onOpenPayment: () => void; onCloseBilibili: () => void; @@ -159,9 +167,11 @@ type ShellHeaderActionsProps = { function ShellHeaderActions({ bootstrap, + bilibiliTriggerRef, showBilibili, onToggleBilibili, showSupport, + supportTriggerRef, onToggleSupport, onOpenPayment, onCloseBilibili, @@ -198,6 +208,7 @@ function ShellHeaderActions({
; showBilibili: boolean; onToggleBilibili: () => void; onCloseBilibili: () => void; @@ -442,6 +456,7 @@ function ShellSocialLinks({
{showBilibili && ( -
+