Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions dashboard/src/components/AppDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AppDialog from "./AppDialog";

/**
* A11Y-5 regression guard: a dialog that declares `aria-modal="true"` must
* behave modally for the keyboard too.
*
* The hand-rolled overlays this replaces announced modality to assistive tech
* while leaving focus on the trigger behind them, letting Tab walk into the
* inert page and making Escape a no-op.
*/
describe("AppDialog", () => {
const renderDialog = (
props: Partial<React.ComponentProps<typeof AppDialog>> = {},
) =>
render(
<AppDialog aria-label="Test dialog" {...props}>
<button>first</button>
<button>last</button>
</AppDialog>,
);

it("moves focus into the dialog on open", () => {
renderDialog();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// First focusable control, not the trigger behind the overlay.
expect(screen.getByRole("button", { name: "first" })).toHaveFocus();
});

it("dismisses on Escape when dismissable", async () => {
const onClose = vi.fn();
renderDialog({ onClose });

await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
});

it("ignores Escape when not dismissable (e.g. mid-restart)", async () => {
const onClose = vi.fn();
renderDialog({ onClose, dismissable: false });

await userEvent.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});

it("traps Tab: focus stays inside the dialog", async () => {
renderDialog();
const first = screen.getByRole("button", { name: "first" });
const last = screen.getByRole("button", { name: "last" });

// Tab forward off the last control wraps back to the first.
last.focus();
await userEvent.tab();
expect(first).toHaveFocus();

// Shift+Tab from the first wraps to the last.
await userEvent.tab({ shift: true });
expect(last).toHaveFocus();
});

it("restores focus to the trigger on unmount", async () => {
const trigger = document.createElement("button");
trigger.textContent = "trigger";
document.body.appendChild(trigger);
trigger.focus();

const { unmount } = renderDialog();
expect(screen.getByRole("button", { name: "first" })).toHaveFocus();

unmount();
expect(trigger).toHaveFocus();
trigger.remove();
});

it("dismisses on backdrop click but not on inner click", async () => {
const onClose = vi.fn();
renderDialog({ onClose });

await userEvent.click(screen.getByRole("button", { name: "first" }));
expect(onClose).not.toHaveBeenCalled();

await userEvent.click(screen.getByRole("dialog"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
138 changes: 138 additions & 0 deletions dashboard/src/components/AppDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";

/**
* Modal dialog with the keyboard behaviour `role="dialog" aria-modal="true"`
* promises.
*
* The three hand-rolled overlays (PwaInstallPrompt's IosGuide and
* DesktopInstallGuide, ServiceRestartOverlay) already declare themselves modal,
* but none of them implement it: focus stays on the trigger behind the overlay,
* Tab walks out into the page underneath, Escape does nothing, and closing does
* not return focus. Assistive tech is told the rest of the page is inert while
* the keyboard can still reach it.
*
* Scope is deliberately narrow — focus move on open, Tab trap, Escape, restore on
* close. Layout and styling stay with the caller, so this replaces the shared
* behaviour without dictating appearance.
*/

/** Elements that can receive focus, in DOM order. */
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");

export interface AppDialogProps {
/** Accessible name; required unless the dialog is labelled by its content. */
"aria-label"?: string;
"aria-labelledby"?: string;
/** `alertdialog` for urgent interruptions (restart, destructive confirm). */
role?: "dialog" | "alertdialog";
/** When false, Escape and backdrop clicks do not dismiss (e.g. mid-restart). */
dismissable?: boolean;
/** Escape key or backdrop click. Omit for a non-dismissable dialog. */
onClose?: () => void;
/** Extra attributes for the overlay (className, style, aria-busy, …). */
overlayProps?: React.HTMLAttributes<HTMLDivElement>;
/** Class for the overlay element, appended to the default. */
overlayClassName?: string;
children: ReactNode;
}

export default function AppDialog({
role = "dialog",
dismissable = true,
onClose,
overlayProps,
overlayClassName,
children,
...aria
}: AppDialogProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
/** The element focused before we opened, so we can put focus back. */
const restoreRef = useRef<HTMLElement | null>(null);

const focusables = useCallback((): HTMLElement[] => {
const root = overlayRef.current;
if (!root) return [];
// No visibility filter: offsetParent is always null in jsdom (and would
// wrongly drop legitimately focusable controls there), so rely on the
// disabled/type selectors above plus `hidden` to exclude inert nodes.
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) =>
!el.hasAttribute("hidden") && el.getAttribute("aria-hidden") !== "true",
);
}, []);

// Move focus in on open, and restore it on close.
useEffect(() => {
restoreRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;

// Prefer the first focusable control; fall back to the overlay itself so
// focus is never left behind on the trigger.
const target = focusables()[0] ?? overlayRef.current;
target?.focus();

return () => {
restoreRef.current?.focus?.();
};
}, [focusables]);

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape" && dismissable) {
e.stopPropagation();
onClose?.();
return;
}
if (e.key !== "Tab") return;

// Trap Tab within the dialog. Without this the browser moves focus to the
// page behind the overlay, which aria-modal has already declared inert.
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;

if (
e.shiftKey &&
(active === first || !overlayRef.current?.contains(active))
) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};

return createPortal(
<div
{...overlayProps}
ref={overlayRef}
role={role}
aria-modal="true"
{...aria}
className={overlayClassName}
onKeyDown={handleKeyDown}
onClick={(e) => {
overlayProps?.onClick?.(e);
if (dismissable && e.target === e.currentTarget) onClose?.();
}}
>
{children}
</div>,
document.body,
);
}
32 changes: 11 additions & 21 deletions dashboard/src/components/PwaInstallPrompt/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useSyncExternalStore, useState } from "react";
import { createPortal } from "react-dom";
import AppDialog from "../AppDialog";
import { Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { Download, MonitorDown, Share, X } from "lucide-react";
Expand Down Expand Up @@ -35,15 +35,11 @@ function isStandalone(): boolean {

export function IosGuide({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return createPortal(
<div
className={styles.iosOverlay}
role="dialog"
aria-modal="true"
return (
<AppDialog
overlayClassName={styles.iosOverlay}
aria-label={t("pwa.addToHomeScreen", "添加到主屏幕")}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onClose={onClose}
>
<div className={styles.iosSheet}>
<button
Expand Down Expand Up @@ -79,8 +75,7 @@ export function IosGuide({ onClose }: { onClose: () => void }) {
</ol>
<div className={styles.iosArrow}>↓</div>
</div>
</div>,
document.body,
</AppDialog>
);
}

Expand All @@ -89,15 +84,11 @@ export function IosGuide({ onClose }: { onClose: () => void }) {
export function DesktopInstallGuide({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const isEdge = /edg/i.test(navigator.userAgent);
return createPortal(
<div
className={styles.desktopOverlay}
role="dialog"
aria-modal="true"
return (
<AppDialog
overlayClassName={styles.desktopOverlay}
aria-label={t("pwa.installDesktopApp", "安装为桌面应用")}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onClose={onClose}
>
<div className={styles.desktopSheet}>
<button
Expand Down Expand Up @@ -149,8 +140,7 @@ export function DesktopInstallGuide({ onClose }: { onClose: () => void }) {
)}
</p>
</div>
</div>,
document.body,
</AppDialog>
);
}

Expand Down
23 changes: 15 additions & 8 deletions dashboard/src/components/ServiceRestartOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import AppDialog from "./AppDialog";
import { CheckCircle, AlertTriangle, Power } from "lucide-react";
import { Button, Spin } from "antd";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -82,13 +82,21 @@ export default function ServiceRestartOverlay({

if (phase === "idle") return null;

return createPortal(
<div
style={OVERLAY_BLOCK_STYLE}
// Only the phases with a way out are dismissable. `restarting` is progress
// and `success` has its own dismiss control, so Escape must not close them
// out from under the user mid-restart.
const dismissable = phase === "confirm" || phase === "timeout";

return (
<AppDialog
role="alertdialog"
aria-modal="true"
aria-busy={phase === "restarting"}
overlayProps={{
style: OVERLAY_BLOCK_STYLE,
"aria-busy": phase === "restarting",
}}
aria-label={t("advancedSettings.update.restarting")}
dismissable={dismissable}
onClose={onDismiss}
>
<div className={styles.panel} style={PANEL_STYLE}>
{phase === "confirm" && (
Expand Down Expand Up @@ -153,7 +161,6 @@ export default function ServiceRestartOverlay({
</>
)}
</div>
</div>,
document.body,
</AppDialog>
);
}
1 change: 1 addition & 0 deletions dashboard/src/layouts/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ function NavItemButton({
type="button"
className={styles.navItemMain}
onClick={() => onNavigate(item.path)}
aria-current={active ? "page" : undefined}
style={{
color: active
? "var(--fn-sidebar-item-active-text)"
Expand Down
Loading