diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index 16614d30..5b61da9a 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -766,8 +766,6 @@ async function assertInputProcessCopyJourney(context, baseUrl, locale) { const copyButton = page.getByRole("button", { name: /^Copy$/ }).first(); await copyButton.waitFor({ state: "visible", timeout: 15_000 }); - // Wait for DeferredToaster to mount (which has a 2000ms delay) - await page.waitForTimeout(2500); await copyButton.click(); const copiedToast = page.getByText(/copied/i).first(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2edbb0e8..c91681ae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "@/app/globals.css"; -import { DeferredToaster } from "@/components/ui/deferred-toaster"; +import { AppToaster } from "@/components/ui/app-toaster"; import { PWA_THEME_COLOR } from "@/core/pwa/constants"; import { buildDefaultOgImageUrl, buildSiteKeywords } from "@/core/seo/seo"; @@ -47,7 +47,7 @@ export default function RootLayout({ {children} - + ); diff --git a/src/components/ui/app-toaster.tsx b/src/components/ui/app-toaster.tsx new file mode 100644 index 00000000..3083db06 --- /dev/null +++ b/src/components/ui/app-toaster.tsx @@ -0,0 +1,12 @@ +"use client" + +import dynamic from "next/dynamic" + +const Toaster = dynamic( + () => import("./sonner").then((mod) => mod.Toaster), + { ssr: false }, +) + +export function AppToaster() { + return +} diff --git a/src/components/ui/deferred-toaster.tsx b/src/components/ui/deferred-toaster.tsx deleted file mode 100644 index ee8db929..00000000 --- a/src/components/ui/deferred-toaster.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client" - -import dynamic from "next/dynamic" -import { useDeferredMount } from "@/hooks/use-deferred-mount" - -const Toaster = dynamic( - () => import("./sonner").then((mod) => mod.Toaster), - { ssr: false }, -) - -export function DeferredToaster() { - const isMounted = useDeferredMount({ delayMs: 2000, activateOnInteraction: true }) - - return isMounted ? : null -} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 2043ca52..c6736089 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -8,61 +8,63 @@ import { TriangleAlertIcon, } from "lucide-react" import * as React from "react" -import { Toaster as Sonner, useSonner, type ToasterProps } from "sonner" +import { Toaster as Sonner, toast, type ToasterProps } from "sonner" +import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" import { useThemePreference } from "@/hooks/use-theme-preference" -function toastNodeToText(value: React.ReactNode | (() => React.ReactNode)): string { - if (value === null || value === undefined || typeof value === "boolean") return "" - if (typeof value === "function") return toastNodeToText(value()) - if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") return String(value) - if (Array.isArray(value)) return value.map(toastNodeToText).filter(Boolean).join(" ") - if (React.isValidElement(value)) { - return toastNodeToText((value.props as { children?: React.ReactNode }).children) - } - return "" -} +const Toaster = ({ ...props }: ToasterProps) => { + const { resolvedTheme } = useThemePreference() + const sonnerRef = React.useRef(null) -function ToastLiveRegion() { - const { toasts } = useSonner() - const latestToast = toasts.find((toast) => !toast.delete) - const message = latestToast - ? [toastNodeToText(latestToast.title), toastNodeToText(latestToast.description)].filter(Boolean).join(". ") - : "" + React.useEffect(() => { + const liveRegion = sonnerRef.current + const queuedFeedback = drainQueuedToastFeedback() + const previousLiveMode = liveRegion?.getAttribute("aria-live") || "polite" - return ( -
- {message} -
- ) -} + if (queuedFeedback.length > 0) { + liveRegion?.setAttribute("aria-live", "off") + for (const feedback of queuedFeedback) { + const options = { id: feedback.id, description: feedback.description } + if (feedback.type === "error") { + toast.error(feedback.message, options) + } else { + toast.success(feedback.message, options) + } + } + } -const Toaster = ({ ...props }: ToasterProps) => { - const { resolvedTheme } = useThemePreference() + setToastLiveRegionReady(true) + if (queuedFeedback.length > 0) { + globalThis.setTimeout(() => liveRegion?.setAttribute("aria-live", previousLiveMode), 0) + } + + return () => { + setToastLiveRegionReady(false) + } + }, []) return ( - <> - - , - info: , - warning: , - error: , - loading: , - }} - style={ - { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", - "--border-radius": "var(--radius)", - } as React.CSSProperties - } - {...props} - /> - + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> ) } diff --git a/src/core/feedback/toast-live-region-state.ts b/src/core/feedback/toast-live-region-state.ts new file mode 100644 index 00000000..7e440438 --- /dev/null +++ b/src/core/feedback/toast-live-region-state.ts @@ -0,0 +1,27 @@ +export type QueuedToastFeedback = { + id: string | number + type: "success" | "error" + message: string + description?: string +} + +let liveRegionReady = false +const queuedFeedback = new Map() + +export function isToastLiveRegionReady() { + return liveRegionReady +} + +export function setToastLiveRegionReady(ready: boolean) { + liveRegionReady = ready +} + +export function queueToastFeedback(feedback: QueuedToastFeedback) { + queuedFeedback.set(feedback.id, feedback) +} + +export function drainQueuedToastFeedback() { + const feedback = [...queuedFeedback.values()] + queuedFeedback.clear() + return feedback +} diff --git a/src/features/tool-shell/external-request-status.tsx b/src/features/tool-shell/external-request-status.tsx index 815c5943..884f32cc 100644 --- a/src/features/tool-shell/external-request-status.tsx +++ b/src/features/tool-shell/external-request-status.tsx @@ -63,17 +63,16 @@ export function ExternalRequestStatus({ const Icon = STATUS_ICON[status] const labels = t.common.external_request_status const role = ASSERTIVE_STATUSES.has(status) ? "alert" : "status" + const boundary = `${labels.boundary_label}: ${t.common.external_network_notice.consent_required_message} ${hosts.join(", ")}` + const announcement = `${labels[status]}. ${message} ${labels.next_step_label}: ${nextStep} ${boundary}` return (
-
+
) } diff --git a/src/features/tool-shell/tool-action-bar.tsx b/src/features/tool-shell/tool-action-bar.tsx index b70a7ff5..3adac4d3 100644 --- a/src/features/tool-shell/tool-action-bar.tsx +++ b/src/features/tool-shell/tool-action-bar.tsx @@ -31,6 +31,7 @@ export type ToolActionResult = { status: ToolActionResultStatus message?: string description?: string + announce?: boolean } export type ToolAction = { @@ -48,6 +49,10 @@ export type ToolAction = { } type AnalyticsAction = "tool_run" | "copy_output" | "download_output" | null +type ActionAnnouncement = { + actionId: string + message: string +} const ACTION_BASE_CLASS = "inline-flex min-h-11 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 lg:min-h-9" @@ -161,8 +166,9 @@ export function ToolActionBar({ }) { const pathname = usePathname() const { t, lang } = useLang() - const [pendingActionId, setPendingActionId] = React.useState(null) - const [actionAnnouncement, setActionAnnouncement] = React.useState("") + const pendingActionIdsRef = React.useRef>(new Set()) + const [pendingActionIds, setPendingActionIds] = React.useState>(() => new Set()) + const [actionAnnouncement, setActionAnnouncement] = React.useState(null) const [lastActionStatus, setLastActionStatus] = React.useState>({}) const toolKey = React.useMemo(() => { const context = getRouteContext(pathname) @@ -191,7 +197,7 @@ export function ToolActionBar({ const triggerAction = React.useCallback( async (action: ToolAction) => { - if (action.disabled || pendingActionId === action.id) return + if (action.disabled || pendingActionIdsRef.current.has(action.id)) return const analyticsAction = classifyAnalyticsAction(action.id) if (toolKey && analyticsAction) { if (analyticsAction === "tool_run") { @@ -211,32 +217,54 @@ export function ToolActionBar({ ...current, [action.id]: isActionResult(maybeResult) ? maybeResult.status : "success", })) - setActionAnnouncement(getResultAnnouncement(action, maybeResult, t)) + if (isActionResult(maybeResult) && maybeResult.announce === false) { + setActionAnnouncement((current) => current?.actionId === action.id ? null : current) + } else { + setActionAnnouncement({ + actionId: action.id, + message: getResultAnnouncement(action, maybeResult, t), + }) + } return } - setPendingActionId(action.id) + pendingActionIdsRef.current.add(action.id) + setPendingActionIds(new Set(pendingActionIdsRef.current)) setLastActionStatus((current) => ({ ...current, [action.id]: "pending" })) - setActionAnnouncement(formatActionStatus(t.common.action_status_pending, action.label, "in progress")) + setActionAnnouncement({ + actionId: action.id, + message: formatActionStatus(t.common.action_status_pending, action.label, "in progress"), + }) const awaitedResult = await maybeResult if (isActionResult(awaitedResult)) { setLastActionStatus((current) => ({ ...current, [action.id]: awaitedResult.status, })) - setActionAnnouncement(getResultAnnouncement(action, awaitedResult, t)) + if (awaitedResult.announce === false) { + setActionAnnouncement((current) => current?.actionId === action.id ? null : current) + } else { + setActionAnnouncement({ + actionId: action.id, + message: getResultAnnouncement(action, awaitedResult, t), + }) + } } else { setLastActionStatus((current) => ({ ...current, [action.id]: "idle" })) - setActionAnnouncement("") + setActionAnnouncement((current) => current?.actionId === action.id ? null : current) } } catch { setLastActionStatus((current) => ({ ...current, [action.id]: "failed" })) - setActionAnnouncement(formatActionStatus(t.common.action_status_failed, action.label, "failed")) + setActionAnnouncement({ + actionId: action.id, + message: formatActionStatus(t.common.action_status_failed, action.label, "failed"), + }) } finally { - setPendingActionId((current) => (current === action.id ? null : current)) + pendingActionIdsRef.current.delete(action.id) + setPendingActionIds(new Set(pendingActionIdsRef.current)) } }, - [lang, pendingActionId, t, toolKey], + [lang, t, toolKey], ) const handoffDisabledReason = !handoffPayload?.trim() ? t.common.action_disabled_no_output : undefined @@ -256,7 +284,7 @@ export function ToolActionBar({ const title = disabledReason ? accessibleLabel : action.title || action.label const disabledDescriptionId = disabledReason ? getActionDescriptionId(action.id) : undefined const isDestructive = isDestructiveAction(action) - const isPending = pendingActionId === action.id + const isPending = pendingActionIds.has(action.id) const runtimeStatus: ToolActionRuntimeStatus = action.disabled ? "disabled" : isPending @@ -393,7 +421,7 @@ export function ToolActionBar({ )} {actionAnnouncement ? ( - {actionAnnouncement} + {actionAnnouncement.message} ) : null} diff --git a/src/features/tool-shell/tool-action-feedback.ts b/src/features/tool-shell/tool-action-feedback.ts index f4d0e419..ffdc3ed8 100644 --- a/src/features/tool-shell/tool-action-feedback.ts +++ b/src/features/tool-shell/tool-action-feedback.ts @@ -3,6 +3,7 @@ import { toast } from "sonner" import type { TranslationType } from "@/core/i18n/lang-provider" import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { isToastLiveRegionReady, queueToastFeedback } from "@/core/feedback/toast-live-region-state" import type { ToolActionResult } from "./tool-action-bar" type ActionFeedbackKind = "copy" | "download" | "export" | "share" @@ -14,8 +15,8 @@ type ActionFeedbackOptions = { description?: string } -function result(status: "success" | "failed", message: string, description?: string): ToolActionResult { - return { status, message, description } +function result(status: "success" | "failed", message: string, description: string | undefined, announce: boolean): ToolActionResult { + return { status, message, description, announce } } export function notifyToolActionSuccess( @@ -28,8 +29,12 @@ export function notifyToolActionSuccess( const message = title || fallbackTitle const detail = description || (kind === "copy" ? `${label}: ${t.common.copied_desc}` : undefined) - toast.success(message, detail ? { description: detail } : undefined) - return result("success", message, detail) + const announceInToolbar = !isToastLiveRegionReady() + const toastId = toast.success(message, detail ? { description: detail } : undefined) + if (announceInToolbar) { + queueToastFeedback({ id: toastId, type: "success", message, description: detail }) + } + return result("success", message, detail, announceInToolbar) } export function notifyToolActionFailure( @@ -39,8 +44,12 @@ export function notifyToolActionFailure( const message = title || t.common.copy_failed const detail = description || label - toast.error(message, detail ? { description: detail } : undefined) - return result("failed", message, detail) + const announceInToolbar = !isToastLiveRegionReady() + const toastId = toast.error(message, detail ? { description: detail } : undefined) + if (announceInToolbar) { + queueToastFeedback({ id: toastId, type: "error", message, description: detail }) + } + return result("failed", message, detail, announceInToolbar) } export async function copyTextWithToolFeedback( diff --git a/src/features/tools/json-formatter/page.tsx b/src/features/tools/json-formatter/page.tsx index e6e5b496..efe3e1c0 100644 --- a/src/features/tools/json-formatter/page.tsx +++ b/src/features/tools/json-formatter/page.tsx @@ -18,7 +18,7 @@ import { useLang } from "@/core/i18n/lang-provider" import { useThemePreference } from "@/hooks/use-theme-preference" import { ensureByteflowMonacoThemes, getByteflowMonacoThemeName } from "@/core/utils/monaco-theme" import { MonacoEditor } from "@/features/tool-shell/monaco-editors" -import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { ToolActionBar, type ToolAction, type ToolActionResult } from "@/features/tool-shell/tool-action-bar" import { copyTextWithToolFeedback, downloadedFileFeedback } from "@/features/tool-shell/tool-action-feedback" import type { OutputWrapMode } from "@/features/tool-shell/text-output-panel" import { ToolEmptyState } from "@/features/tool-shell/tool-empty-state" @@ -52,6 +52,7 @@ import { downloadJsonOutput } from "./browser-actions" import type { JsonPath, JsonValue, ViewMode } from "./types" import { WideToolPageContainer } from "@/components/layout/page-container" import { useJsonTreeDialog } from "./use-json-tree-dialog" +import { useJsonFormatterShortcuts } from "./use-json-formatter-shortcuts" export function JsonFormatterPage() { const { t, lang } = useLang() @@ -97,10 +98,19 @@ export function JsonFormatterPage() { const monacoTheme = getByteflowMonacoThemeName(resolvedTheme) const fileInputRef = React.useRef(null) const outputPaneRef = React.useRef(null) + const toolHeaderRef = React.useRef(null) const appliedHandoffRef = React.useRef(null) const formatRequestIdRef = React.useRef(0) const formatAbortControllerRef = React.useRef(null) const [lastFormatMode, setLastFormatMode] = React.useState("format") + + const cancelPendingFormat = React.useCallback(() => { + formatRequestIdRef.current += 1 + formatAbortControllerRef.current?.abort() + formatAbortControllerRef.current = null + setIsFormatting(false) + }, []) + React.useEffect(() => { const savedMode = readStorageString(VIEW_MODE_STORAGE_KEY) if (savedMode === "text" || savedMode === "tree") { @@ -154,14 +164,15 @@ export function JsonFormatterPage() { } }, [text]) - const applyTreeValue = React.useCallback((nextValue: JsonValue) => { - const pretty = JSON.stringify(nextValue, null, 2) - setTreeData(nextValue) + const applyTreeValue = React.useCallback((nextValue: JsonValue) => { + cancelPendingFormat() + const pretty = JSON.stringify(nextValue, null, 2) + setTreeData(nextValue) setInput(pretty) setOutput(pretty) setError(null) setErrorDetails(null) - }, []) + }, [cancelPendingFormat]) const buildTreeFromCurrentText = React.useCallback(() => { const source = input.trim() ? input : output @@ -190,6 +201,7 @@ export function JsonFormatterPage() { }, []) const handleInputChange = React.useCallback((nextInput: string) => { + cancelPendingFormat() setInput(nextInput) if (output || treeData !== null) { setOutput("") @@ -198,9 +210,9 @@ export function JsonFormatterPage() { } setError(null) setErrorDetails(null) - }, [output, treeData]) + }, [cancelPendingFormat, output, treeData]) - const runJsonFormat = React.useCallback(async (source: string, mode: JsonFormatMode) => { + const runJsonFormat = React.useCallback(async (source: string, mode: JsonFormatMode): Promise => { const requestId = formatRequestIdRef.current + 1 formatRequestIdRef.current = requestId formatAbortControllerRef.current?.abort() @@ -211,7 +223,7 @@ export function JsonFormatterPage() { setTreeData(null) setError(null) setErrorDetails(null) - return + return undefined } const controller = new AbortController() @@ -219,21 +231,23 @@ export function JsonFormatterPage() { setIsFormatting(true) try { const result = await runJsonFormatTask(source, mode, { signal: controller.signal }) - if (formatRequestIdRef.current !== requestId) return + if (formatRequestIdRef.current !== requestId) return undefined setOutput(result.output) setTreeData(result.parsed) setLastFormatMode(mode) setError(null) setErrorDetails(null) scrollOutputIntoViewOnMobile() + return { status: "success" } } catch (err) { - if (formatRequestIdRef.current !== requestId) return + if (formatRequestIdRef.current !== requestId) return undefined const details = buildJsonParseErrorDetails(source, err, text) setOutput("") setTreeData(null) setExpanded(new Set(["$"])) setError(details.message) setErrorDetails(details) + return undefined } finally { if (formatRequestIdRef.current === requestId) { formatAbortControllerRef.current = null @@ -243,17 +257,17 @@ export function JsonFormatterPage() { }, [scrollOutputIntoViewOnMobile, text]) const formatJsonSource = React.useCallback((source: string) => { - void runJsonFormat(source, "format") + return runJsonFormat(source, "format") }, [runJsonFormat]) const formatJson = React.useCallback(() => { - void runJsonFormat(input, "format") + return runJsonFormat(input, "format") }, [input, runJsonFormat]) const minifyJson = React.useCallback(() => { - if (!input.trim()) return + if (!input.trim()) return undefined - void runJsonFormat(input, "minify") + return runJsonFormat(input, "minify") }, [input, runJsonFormat]) const handleCopy = React.useCallback(async () => { @@ -276,9 +290,10 @@ export function JsonFormatterPage() { return downloadedFileFeedback(t, filename) }, [error, lastFormatMode, output, t, treeData, viewMode]) - const handleClear = () => { - setInput("") - setOutput("") + const handleClear = () => { + cancelPendingFormat() + setInput("") + setOutput("") setTreeData(null) setExpanded(new Set(["$"])) setError(null) @@ -296,10 +311,11 @@ export function JsonFormatterPage() { setExpanded(new Set(["$"])) } - const handleImportFile = async (file: File) => { - try { - const content = await importTextFile(file) - setInput(content) + const handleImportFile = async (file: File) => { + try { + const content = await importTextFile(file) + cancelPendingFormat() + setInput(content) setOutput("") setTreeData(null) setExpanded(new Set(["$"])) @@ -314,6 +330,14 @@ export function JsonFormatterPage() { setErrorDetails(null) } } + + const triggerToolbarFormatAction = React.useCallback((actionId: JsonFormatMode) => { + toolHeaderRef.current + ?.querySelector(`button[data-analytics-id="${actionId}"]`) + ?.click() + }, []) + + const openTreeSearch = React.useCallback(() => setIsSearchOpen(true), []) const openImportPicker = () => { fileInputRef.current?.click() @@ -343,44 +367,20 @@ export function JsonFormatterPage() { setViewMode(nextMode) } - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const withModifier = event.metaKey || event.ctrlKey - if (!withModifier) return - - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - formatJson() - return - } - - if (event.key === "Enter" && event.shiftKey) { - event.preventDefault() - minifyJson() - return - } - - if ((event.key === "c" || event.key === "C") && event.shiftKey && (output || treeData !== null)) { - event.preventDefault() - void handleCopy() - } - - if ((event.key === "f" || event.key === "F") && withModifier && viewMode === "tree") { - event.preventDefault() - setIsSearchOpen(true) - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [output, treeData, formatJson, minifyJson, handleCopy, viewMode]) + useJsonFormatterShortcuts({ + canCopy: Boolean(output || treeData !== null), + onCopy: handleCopy, + onFormatAction: triggerToolbarFormatAction, + onOpenTreeSearch: openTreeSearch, + treeViewActive: viewMode === "tree", + }) const handleUseSample = () => { setInput(SAMPLE_JSON_SOURCE) setExpanded(new Set(["$"])) setError(null) setErrorDetails(null) - formatJsonSource(SAMPLE_JSON_SOURCE) + return formatJsonSource(SAMPLE_JSON_SOURCE) } const actions: ToolAction[] = [ @@ -571,7 +571,7 @@ export function JsonFormatterPage() { onSubmit={confirmTreeDialog} text={text} /> -
+

diff --git a/src/features/tools/json-formatter/use-json-formatter-shortcuts.ts b/src/features/tools/json-formatter/use-json-formatter-shortcuts.ts new file mode 100644 index 00000000..7a8264bd --- /dev/null +++ b/src/features/tools/json-formatter/use-json-formatter-shortcuts.ts @@ -0,0 +1,49 @@ +"use client" + +import * as React from "react" +import type { JsonFormatMode } from "./format-json-task" + +type JsonFormatterShortcutOptions = { + canCopy: boolean + onCopy: () => void | Promise + onFormatAction: (actionId: JsonFormatMode) => void + onOpenTreeSearch: () => void + treeViewActive: boolean +} + +export function useJsonFormatterShortcuts({ + canCopy, + onCopy, + onFormatAction, + onOpenTreeSearch, + treeViewActive, +}: JsonFormatterShortcutOptions) { + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey && !event.ctrlKey) return + + if (event.key === "Enter") { + event.preventDefault() + event.stopPropagation() + onFormatAction(event.shiftKey ? "minify" : "format") + return + } + + if ((event.key === "c" || event.key === "C") && event.shiftKey && canCopy) { + event.preventDefault() + event.stopPropagation() + void onCopy() + return + } + + if ((event.key === "f" || event.key === "F") && treeViewActive) { + event.preventDefault() + event.stopPropagation() + onOpenTreeSearch() + } + } + + window.addEventListener("keydown", handleKeyDown, true) + return () => window.removeEventListener("keydown", handleKeyDown, true) + }, [canCopy, onCopy, onFormatAction, onOpenTreeSearch, treeViewActive]) +} diff --git a/src/generated/route-width-inventory.json b/src/generated/route-width-inventory.json index b6e821f1..fcbcfa53 100644 --- a/src/generated/route-width-inventory.json +++ b/src/generated/route-width-inventory.json @@ -70,7 +70,7 @@ ] }, "summary": { - "scannedFiles": 945, + "scannedFiles": 946, "pageRoots": 369, "toolSurfaces": 131, "standardTools": 82, diff --git a/tests/component/json-formatter-actions.test.tsx b/tests/component/json-formatter-actions.test.tsx index 59a2cfe0..da34d418 100644 --- a/tests/component/json-formatter-actions.test.tsx +++ b/tests/component/json-formatter-actions.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" import { JsonFormatterPage } from "@/features/tools/json-formatter/page" @@ -56,6 +56,15 @@ function installMatchMedia(matches: boolean) { }) } +class IdleJsonWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + onmessageerror: ((event: MessageEvent) => void) | null = null + + postMessage() {} + terminate() {} +} + describe("JsonFormatterPage actions", () => { beforeEach(() => { downloadJsonOutputMock.mockClear() @@ -71,6 +80,10 @@ describe("JsonFormatterPage actions", () => { window.history.replaceState(null, "", "/en/json-formatter") }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it("clears stale output when invalid JSON replaces valid output", async () => { renderJsonFormatter() @@ -100,6 +113,74 @@ describe("JsonFormatterPage actions", () => { expect(screen.getAllByRole("button", { name: "Download JSON" }).some((button) => !button.hasAttribute("disabled"))).toBe(true) }) + it.each(["Format", "Minify"])("does not announce %s success when invalid JSON raises an alert", async (action) => { + renderJsonFormatter() + + fireEvent.change(inputEditor(), { target: { value: '{"name":}' } }) + fireEvent.click(screen.getByRole("button", { name: action })) + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/Unexpected token|Invalid JSON/) + }) + await waitFor(() => { + expect(screen.getByRole("button", { name: action })).not.toHaveAttribute("aria-busy") + }) + expect(screen.queryByText(`${action} completed.`)).not.toBeInTheDocument() + }) + + it.each([ + { action: "Format", shiftKey: false, output: "{\n \"ok\": true\n}" }, + { action: "Minify", shiftKey: true, output: "{\"ok\":true}" }, + ])("announces $action success when triggered by its keyboard shortcut", async ({ action, shiftKey, output }) => { + renderJsonFormatter() + + const editor = inputEditor() + fireEvent.change(editor, { target: { value: '{"ok":true}' } }) + const editorKeyDown = vi.fn((event: KeyboardEvent) => event.stopPropagation()) + editor.addEventListener("keydown", editorKeyDown, { once: true }) + fireEvent.keyDown(editor, { key: "Enter", ctrlKey: true, shiftKey }) + + await waitFor(() => expect(outputEditor()).toHaveValue(output)) + expect(screen.getByText(`${action} completed.`)).toBeInTheDocument() + expect(editorKeyDown).not.toHaveBeenCalled() + }) + + it.each([ + { announcement: null, label: "input editing", mutate: () => fireEvent.change(inputEditor(), { target: { value: '{"next":true}' } }) }, + { announcement: "Clear completed.", label: "Clear", mutate: () => fireEvent.click(screen.getByRole("button", { name: "Clear" })) }, + ])("cancels a pending format after $label without restoring stale output", async ({ announcement, mutate }) => { + vi.stubGlobal("Worker", IdleJsonWorker) + renderJsonFormatter() + + fireEvent.change(inputEditor(), { target: { value: '{"stale":true}' } }) + fireEvent.click(screen.getByRole("button", { name: "Format" })) + await waitFor(() => expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("aria-busy", "true")) + + mutate() + + await waitFor(() => expect(screen.getByRole("button", { name: "Format" })).not.toHaveAttribute("aria-busy")) + expect(screen.queryByRole("textbox", { name: "Output" })).not.toBeInTheDocument() + expect(screen.queryByText(/"stale"/)).not.toBeInTheDocument() + expect(screen.queryByText("Format completed.")).not.toBeInTheDocument() + if (announcement) expect(screen.getByRole("status")).toHaveTextContent(announcement) + }) + + it("cancels a pending format before applying a tree edit", async () => { + vi.stubGlobal("Worker", IdleJsonWorker) + renderJsonFormatter() + + fireEvent.change(inputEditor(), { target: { value: '{"stale":true}' } }) + fireEvent.click(screen.getByRole("button", { name: "Tree" })) + fireEvent.click(screen.getByRole("button", { name: "Format" })) + await waitFor(() => expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("aria-busy", "true")) + + fireEvent.click(screen.getByRole("button", { name: "Delete node" })) + + await waitFor(() => expect(screen.getByRole("button", { name: "Format" })).not.toHaveAttribute("aria-busy")) + expect(inputEditor()).toHaveValue("{}") + expect(screen.queryByText("Format completed.")).not.toBeInTheDocument() + }) + it.each([ { label: "mobile", matches: true }, { label: "desktop", matches: false }, @@ -110,6 +191,7 @@ describe("JsonFormatterPage actions", () => { fireEvent.change(inputEditor(), { target: { value: '{"ok":true}' } }) fireEvent.click(screen.getByRole("button", { name: "Format" })) await waitFor(() => expect(screen.getAllByRole("button", { name: "Download JSON" }).some((button) => !button.hasAttribute("disabled"))).toBe(true)) + expect(screen.getByText("Format completed.")).toBeInTheDocument() act(() => { screen.getAllByRole("button", { name: "Download JSON" }).at(-1)?.click() @@ -118,6 +200,7 @@ describe("JsonFormatterPage actions", () => { fireEvent.click(screen.getByRole("button", { name: "Minify" })) await waitFor(() => expect(outputEditor()).toHaveValue("{\"ok\":true}")) + expect(screen.getByText("Minify completed.")).toBeInTheDocument() act(() => { screen.getAllByRole("button", { name: "Download JSON" }).at(-1)?.click() diff --git a/tests/component/oauth-jwks-workbench-page.test.tsx b/tests/component/oauth-jwks-workbench-page.test.tsx index 0f5832a5..42ac9632 100644 --- a/tests/component/oauth-jwks-workbench-page.test.tsx +++ b/tests/component/oauth-jwks-workbench-page.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" +import { AppToaster } from "@/components/ui/app-toaster" import { OauthJwksWorkbenchPage } from "@/features/tools/oauth-jwks-workbench/page" const clipboardWriteMock = vi.hoisted(() => vi.fn()) @@ -22,17 +23,11 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/en/oauth-jwks-workbench", })) -vi.mock("sonner", () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - }, -})) - function renderOauthJwksWorkbench() { return render( + , ) } @@ -107,7 +102,7 @@ describe("OauthJwksWorkbenchPage", () => { } }) - it("returns clipboard failures through the shared toolbar status path", async () => { + it("returns clipboard failures through the shared toast live region", async () => { const originalCrypto = globalThis.crypto clipboardWriteMock.mockResolvedValue({ ok: false, method: "none" }) @@ -135,7 +130,7 @@ describe("OauthJwksWorkbenchPage", () => { fireEvent.click(screen.getByRole("button", { name: "Copy" })) await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent("Unable to copy. Please copy manually.") + expect(document.querySelector('section[aria-live="polite"]')).toHaveTextContent("Unable to copy. Please copy manually.") }) expect(screen.getByRole("button", { name: "Copy" })).toHaveAttribute("data-tool-action-state", "failed") } finally { diff --git a/tests/component/toaster-live-region.test.tsx b/tests/component/toaster-live-region.test.tsx index cf0af518..d95e8051 100644 --- a/tests/component/toaster-live-region.test.tsx +++ b/tests/component/toaster-live-region.test.tsx @@ -1,7 +1,17 @@ -import { render, screen, waitFor } from "@testing-library/react" -import { beforeEach, describe, expect, it } from "vitest" +import { StrictMode } from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" import { toast } from "sonner" +import { AppToaster } from "@/components/ui/app-toaster" import { Toaster } from "@/components/ui/sonner" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { notifyToolActionSuccess } from "@/features/tool-shell/tool-action-feedback" + +vi.mock("next/navigation", () => ({ + usePathname: () => "/en/json-formatter", +})) describe("Toaster live region", () => { beforeEach(() => { @@ -13,14 +23,70 @@ describe("Toaster live region", () => { toast.success("Copied", { description: "Output copied to clipboard." }) - await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent("Copied. Output copied to clipboard.") + const liveRegion = await waitFor(() => { + const region = document.querySelector('section[aria-live="polite"]') + expect(region).toHaveTextContent(/Copied\s*Output copied to clipboard\./) + return region! }) + expect(document.querySelector("[data-toast-live-region]")).not.toBeInTheDocument() toast.error("Copy failed") await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent("Copy failed") + expect(liveRegion).toHaveTextContent("Copy failed") + }) + }) + + it("replays pre-mount feedback visually without duplicating the toolbar announcement", async () => { + const translations = getTranslation("en") + const actions: ToolAction[] = [{ + id: "copy", + label: "Copy", + onClick: () => notifyToolActionSuccess(translations, { + kind: "copy", + label: "Output", + description: "Output copied to clipboard.", + }), + }] + const view = render( + + + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Copy" })) + expect(screen.getByRole("status")).toHaveTextContent("Copied to clipboard. Output copied to clipboard.") + + const replayLiveModes = new Set() + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const addedNode of record.addedNodes) { + if (!(addedNode instanceof Element)) continue + const toastNode = addedNode.matches("[data-sonner-toast]") + ? addedNode + : addedNode.querySelector("[data-sonner-toast]") + if (toastNode) replayLiveModes.add(toastNode.closest("section")?.getAttribute("aria-live") ?? null) + } + } + }) + observer.observe(document.body, { childList: true, subtree: true }) + + view.rerender( + + + + + + , + ) + + await waitFor(() => { + expect(document.querySelector('section[aria-live="polite"]')).toHaveTextContent(/Copied to clipboard\s*Output copied to clipboard\./) }) + observer.disconnect() + expect(replayLiveModes).toEqual(new Set(["off"])) + expect(screen.getAllByRole("status")).toHaveLength(1) }) }) diff --git a/tests/component/tool-action-bar.test.tsx b/tests/component/tool-action-bar.test.tsx index 6f05a5e6..ee887dc9 100644 --- a/tests/component/tool-action-bar.test.tsx +++ b/tests/component/tool-action-bar.test.tsx @@ -119,6 +119,41 @@ describe("ToolActionBar", () => { expect(screen.getByText("Format failed")).toBeInTheDocument() }) + it("tracks concurrent actions independently and prevents the same action from re-entering", async () => { + let resolveCopy: (value: { status: "success" }) => void = () => undefined + let resolveFormat: (value: { status: "success" }) => void = () => undefined + const copyAction = vi.fn(() => new Promise<{ status: "success" }>((resolve) => { + resolveCopy = resolve + })) + const formatAction = vi.fn(() => new Promise<{ status: "success" }>((resolve) => { + resolveFormat = resolve + })) + + render() + + fireEvent.click(screen.getByRole("button", { name: "Format" })) + fireEvent.click(screen.getByRole("button", { name: "Copy" })) + + expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("data-tool-action-state", "pending") + expect(screen.getByRole("button", { name: "Copy" })).toHaveAttribute("data-tool-action-state", "pending") + fireEvent.click(screen.getByRole("button", { name: "Format" })) + expect(formatAction).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveCopy({ status: "success" }) + }) + expect(screen.getByRole("button", { name: "Copy" })).toHaveAttribute("data-tool-action-state", "success") + expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("data-tool-action-state", "pending") + + await act(async () => { + resolveFormat({ status: "success" }) + }) + expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("data-tool-action-state", "success") + }) + it("describes disabled handoff actions without changing their accessible name", () => { render() diff --git a/tests/component/tool-shell-status-output.test.tsx b/tests/component/tool-shell-status-output.test.tsx index 0bc7713b..93e5755f 100644 --- a/tests/component/tool-shell-status-output.test.tsx +++ b/tests/component/tool-shell-status-output.test.tsx @@ -73,6 +73,54 @@ describe("shared tool shell status and output components", () => { expect(screen.getByRole("button", { name: "Verify" })).toHaveAttribute("data-tool-action-state", "idle") }) + it("keeps a newer action announcement when an earlier Promise action settles", async () => { + let resolveFormat: () => void = () => undefined + const actions: ToolAction[] = [ + { + id: "format", + label: "Format", + onClick: () => new Promise((resolve) => { + resolveFormat = resolve + }), + }, + { + id: "clear", + label: "Clear", + onClick: () => undefined, + }, + ] + + renderEnglish() + + fireEvent.click(screen.getByRole("button", { name: "Format" })) + fireEvent.click(screen.getByRole("button", { name: "Clear" })) + expect(screen.getByRole("status")).toHaveTextContent("Clear completed.") + + resolveFormat() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Format" })).toHaveAttribute("data-tool-action-state", "idle") + }) + expect(screen.getByRole("status")).toHaveTextContent("Clear completed.") + }) + + it("does not duplicate announcements for results already announced by a toast", async () => { + const actions: ToolAction[] = [{ + id: "copy", + label: "Copy", + onClick: async () => ({ status: "success", message: "Copied", announce: false }), + }] + + renderEnglish() + + fireEvent.click(screen.getByRole("button", { name: "Copy" })) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Copy" })).toHaveAttribute("data-tool-action-state", "success") + }) + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + it("lets long output switch between wrap and horizontal scroll modes without changing the output text", () => { const output = `https://example.com/${"a".repeat(180)}` @@ -119,8 +167,10 @@ describe("shared tool shell status and output components", () => { ) const permissionStatus = screen.getByRole("status") - expect(permissionStatus).toHaveAttribute("data-external-request-status", "permission") + expect(permissionStatus.closest('[data-external-request-status="permission"]')).not.toBeNull() expect(permissionStatus).toHaveAttribute("aria-live", "polite") + expect(permissionStatus.childNodes).toHaveLength(1) + expect(permissionStatus.firstChild?.nodeType).toBe(Node.TEXT_NODE) rerender( @@ -133,7 +183,7 @@ describe("shared tool shell status and output components", () => { , ) - expect(screen.getByRole("alert")).toHaveAttribute("data-external-request-status", "offline") + expect(screen.getByRole("alert").closest('[data-external-request-status="offline"]')).not.toBeNull() expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive") }) @@ -147,7 +197,7 @@ describe("shared tool shell status and output components", () => { />, ) - expect(screen.getByRole("alert")).toHaveAttribute("data-external-request-status", "offline") + expect(screen.getByRole("alert").closest('[data-external-request-status="offline"]')).not.toBeNull() expect(screen.getByRole("alert")).toHaveTextContent("What to do next: Reconnect and retry.") expect(screen.getByRole("alert")).toHaveTextContent("i.ytimg.com") expect(screen.getByRole("alert")).toHaveTextContent("Network access starts only after you choose the external-request action.") diff --git a/tests/guards/a11y-mobile-baseline.test.ts b/tests/guards/a11y-mobile-baseline.test.ts index 48fb50f4..fb3f23b2 100644 --- a/tests/guards/a11y-mobile-baseline.test.ts +++ b/tests/guards/a11y-mobile-baseline.test.ts @@ -21,10 +21,12 @@ describe("BF-016 accessibility and mobile baseline", () => { it("keeps toast feedback exposed through a polite live region", () => { const source = readSource("src/components/ui/sonner.tsx") + const testSource = readSource("tests/component/toaster-live-region.test.tsx") - expect(source).toContain('role="status"') - expect(source).toContain('aria-live="polite"') - expect(source).toContain("data-toast-live-region") + expect(source).toContain("Toaster as Sonner") + expect(source).not.toContain("data-toast-live-region") + expect(testSource).toContain('section[aria-live="polite"]') + expect(testSource).toContain("announces the latest toast title and description") }) it("keeps coarse-pointer controls at the WCAG target-size baseline", () => { diff --git a/tests/guards/bf-037-a11y-qa-guard.test.ts b/tests/guards/bf-037-a11y-qa-guard.test.ts index e12321e2..c120b88c 100644 --- a/tests/guards/bf-037-a11y-qa-guard.test.ts +++ b/tests/guards/bf-037-a11y-qa-guard.test.ts @@ -19,7 +19,8 @@ describe("BF-037 accessibility QA guard", () => { expect(source).toContain("") expect(source).toContain(" { diff --git a/tests/guards/root-layout-performance-guard.test.ts b/tests/guards/root-layout-performance-guard.test.ts index f9b2a274..31688fc9 100644 --- a/tests/guards/root-layout-performance-guard.test.ts +++ b/tests/guards/root-layout-performance-guard.test.ts @@ -3,22 +3,19 @@ import path from "node:path" import { describe, expect, it } from "vitest" describe("root layout performance guard", () => { - it("keeps the root layout wired to the deferred toaster wrapper", () => { + it("starts loading the toast subscriber without waiting for idle time or interaction", () => { const source = fs.readFileSync(path.join(process.cwd(), "src/app/layout.tsx"), "utf8") + const toasterSource = fs.readFileSync(path.join(process.cwd(), "src/components/ui/app-toaster.tsx"), "utf8") - expect(source).toContain('import { DeferredToaster } from "@/components/ui/deferred-toaster";') - expect(source).toContain("") - expect(source).not.toContain('import { Toaster } from "@/components/ui/sonner";') + expect(source).toContain('import { AppToaster } from "@/components/ui/app-toaster";') + expect(source).toContain("") + expect(toasterSource).toContain('import dynamic from "next/dynamic"') + expect(toasterSource).toContain('import("./sonner")') + expect(toasterSource).toContain("{ ssr: false }") + expect(toasterSource).toContain("return ") + expect(toasterSource).not.toContain("useDeferredMount") + expect(toasterSource).not.toContain("delayMs") expect(source).not.toContain('import { ThemeProvider } from "@/components/theme-provider";') expect(source).not.toContain(" { - const source = fs.readFileSync(path.join(process.cwd(), "src/components/ui/deferred-toaster.tsx"), "utf8") - - expect(source).toContain('import dynamic from "next/dynamic"') - expect(source).toContain('import("./sonner")') - expect(source).toContain("{ ssr: false }") - expect(source).toContain('useDeferredMount({ delayMs: 2000, activateOnInteraction: true })') - }) }) diff --git a/tests/guards/tool-action-consistency-guard.test.ts b/tests/guards/tool-action-consistency-guard.test.ts index d370364c..4d2b9ac3 100644 --- a/tests/guards/tool-action-consistency-guard.test.ts +++ b/tests/guards/tool-action-consistency-guard.test.ts @@ -83,13 +83,14 @@ describe("tool action consistency guard", () => { it("keeps copy/download feedback exposed through an accessible live region", () => { const toaster = read("src/components/ui/sonner.tsx") - - expect(toaster).toContain('role="status"') - expect(toaster).toContain('aria-live="polite"') - expect(toaster).toContain("data-toast-live-region") - expect(toaster).toContain("useSonner") - expect(toaster).toContain("toastNodeToText") - expect(read("tests/component/toaster-live-region.test.tsx")).toContain("announces the latest toast title and description") + const toasterTest = read("tests/component/toaster-live-region.test.tsx") + + expect(toaster).toContain("Toaster as Sonner") + expect(toaster).not.toContain("useSonner") + expect(toaster).not.toContain("data-toast-live-region") + expect(toasterTest).toContain("announces the latest toast title and description") + expect(toasterTest).toContain('section[aria-live=\"polite\"]') + expect(toasterTest).toContain('querySelector("[data-toast-live-region]")') }) it("keeps audited copy, download, and share actions on visible success/failure feedback paths", () => {