diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx index 411d30bf87..072fa6d4d9 100644 --- a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx +++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx @@ -8,6 +8,10 @@ import { fetchMarkdownDocBytes, isMediaTooLargeError, } from "@/shared/api/tauriMedia"; +import { + focusMarkdownDocPanelClose, + restoreFocusToMarkdownDocOpener, +} from "@/features/channels/ui/markdownDocFocus"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { @@ -74,6 +78,18 @@ export function MarkdownDocPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const [view, setView] = React.useState("preview"); + // Opening can unmount the section holding the focused attachment card + // (narrow layout swaps the whole channel out), and closing unmounts this + // panel — move focus in on mount and hand it back to the opener card on + // unmount so keyboard users never fall to . + React.useEffect(() => { + const cancel = focusMarkdownDocPanelClose(); + return () => { + cancel(); + restoreFocusToMarkdownDocOpener(url); + }; + }, [url]); + // Blob URLs are content-addressed (`/media/{sha256}.{ext}`), so a fetched // document never changes under its URL — cache it for the session. const docQuery = useQuery({ @@ -126,17 +142,6 @@ export function MarkdownDocPanel({ - {decoded?.kind === "ok" ? ( - - ) : null} - - ) : decoded?.kind === "ok" ? ( - view === "preview" ? ( - + {/* The view picker gets its own pinned row below the title: sharing + the title row squeezed the filename out, and the header chrome + band overlays anything placed directly after it in the header + slot — so the row lives inside the chrome-padded body instead. */} + {decoded?.kind === "ok" ? ( +
+ - ) : ( -
-              {/* Shiki's synchronous-tokenization guard caps highlighting at
-                  150 lines; longer documents render as plain text here. */}
-              
-            
- ) +
) : null} +
+ {docQuery.isPending ? ( +
+ +
+ ) : errorMessage !== null ? ( +
+

{errorMessage}

+ +
+ ) : decoded?.kind === "ok" ? ( + view === "preview" ? ( + + ) : ( +
+                {/* Shiki's synchronous-tokenization guard caps highlighting at
+                  150 lines; longer documents render as plain text here. */}
+                
+              
+ ) + ) : null} +
); diff --git a/desktop/src/features/channels/ui/markdownDocFocus.ts b/desktop/src/features/channels/ui/markdownDocFocus.ts new file mode 100644 index 0000000000..8615493763 --- /dev/null +++ b/desktop/src/features/channels/ui/markdownDocFocus.ts @@ -0,0 +1,78 @@ +/** + * Focus choreography for the markdown document panel (PR #6731 P2). + * + * In the narrow single-panel layout, opening a document unmounts the channel + * section containing the focused attachment card, and closing unmounts the + * panel that held focus — in both directions focus falls to `` and + * keyboard/screen-reader users lose their place. On open, focus moves to the + * panel's close control; on close, it returns to the attachment card that + * opened the document (matched by URL, since the original element was + * unmounted meanwhile). + */ + +const PANEL_CLOSE_SELECTOR = + '[data-testid="markdown-doc-panel"] [data-testid="auxiliary-panel-close"]'; + +/** Frames to wait for the target to (re)mount before giving up. */ +const FOCUS_SEARCH_FRAMES = 12; + +function scheduleFocusSearch( + find: () => HTMLElement | null, + shouldAbort: () => boolean, +): () => void { + let frame = 0; + let attempts = 0; + const tick = () => { + if (shouldAbort()) return; + const target = find(); + if (target) { + target.focus(); + return; + } + attempts += 1; + if (attempts < FOCUS_SEARCH_FRAMES) frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); +} + +/** + * True when moving focus is a restoration, not a steal. ``/null means + * focus fell off an unmounted subtree. The composer counts as free too: the + * remounting channel autofocuses it, which is exactly the "lands on the + * composer rather than the invoking attachment" behavior being fixed — + * anything else (another panel's control, a clicked button) keeps focus. + */ +function focusIsFree(): boolean { + const active = document.activeElement; + if (active === null || active === document.body) return true; + return active.closest('[data-testid="message-composer"]') !== null; +} + +/** + * Move focus onto the open panel's close control. Returns a canceler for + * effect cleanup so an unmounting panel stops hunting for its own button. + */ +export function focusMarkdownDocPanelClose(): () => void { + return scheduleFocusSearch( + () => document.querySelector(PANEL_CLOSE_SELECTOR), + // Never abort: the open was user-initiated, the panel is the destination. + () => false, + ); +} + +/** + * After the panel closes, return focus to the attachment card that opened + * `url` once the channel section has remounted. Aborts if focus has already + * landed on a real control (e.g. a different panel claimed it), and gives up + * quietly when the card no longer exists. + */ +export function restoreFocusToMarkdownDocOpener(url: string): void { + scheduleFocusSearch( + () => + document.querySelector( + `[data-testid="file-card"][data-doc-url="${CSS.escape(url)}"]`, + ), + () => !focusIsFree(), + ); +} diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 48318ada17..ba0555d25b 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -82,6 +82,10 @@ export function FileCard({ ); }} aria-label={opensInViewer ? `Open ${filename}` : `Download ${filename}`} + // Focus-return anchor: when the viewer panel closes, focus comes back + // to the card that opened this URL (the element itself may have been + // unmounted and remounted in the narrow layout meanwhile). + data-doc-url={opensInViewer ? href : undefined} data-testid="file-card" className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70" style={{ borderRadius: "1rem" }} diff --git a/desktop/tests/e2e/markdown-doc-viewer.spec.ts b/desktop/tests/e2e/markdown-doc-viewer.spec.ts index c362a617bd..e3f35039ee 100644 --- a/desktop/tests/e2e/markdown-doc-viewer.spec.ts +++ b/desktop/tests/e2e/markdown-doc-viewer.spec.ts @@ -126,6 +126,32 @@ test("markdown attachment opens the in-app viewer with Preview/Code toggle", asy await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); }); +test("narrow layout moves focus into the panel and returns it to the card", async ({ + page, +}) => { + await sendMarkdownAttachment(page); + + // Below the split-pane threshold the panel replaces the channel section, + // unmounting the focused attachment card — the focus contract under test. + await page.setViewportSize({ width: 560, height: 720 }); + + const card = page.getByTestId("file-card").last(); + await card.focus(); + await expect(card).toBeFocused(); + await page.keyboard.press("Enter"); + + // Focus lands on the panel's close control, not . + const panel = page.getByTestId("markdown-doc-panel"); + await expect(panel).toBeVisible(); + await expect(page.getByTestId("auxiliary-panel-close")).toBeFocused(); + + // Escape closes the panel and hands focus back to the invoking card — + // overriding the remounted channel's composer autofocus. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); + await expect(page.getByTestId("file-card").last()).toBeFocused(); +}); + test("open document survives reload and back/forward navigation", async ({ page, }) => {