Harden Drawer Dialog Focus
Added consistent modal dialog semantics, keyboard focus trapping, Escape dismissal, and focus restoration for terminal drawers.
Summary
The terminal drawers now behave as modal dialogs for keyboard and assistive technology users. Opening a drawer moves focus into it, Tab and Shift+Tab stay inside it, Escape closes it, and focus returns to the invoking control when practical.
Changes Made
- Added a shared
useModalFocusTraphelper inapps/web/app/terminal.tsx. - Added
role="dialog",aria-modal="true", stable labels, andtabIndex={-1}to alert, news, classifier, smart-money, inferred-dark, synthetic-control, and nav drawers. - Restored keyboard focus to the source trigger for nav and synthetic-control drawers after closing.
- Replaced the nav drawer's standalone Escape listener with the shared modal focus handler.
Context
Islandflow is an operational trading terminal where drawer content contains evidence, feed controls, and navigation. These overlays previously closed on Escape or outside click, but they did not consistently identify as modal dialogs or keep keyboard focus inside the active layer.
Important Implementation Details
- The helper discovers tabbable descendants from the active drawer and cycles keyboard focus at the first and last controls.
- Drawers without tabbable descendants focus their root element so Escape handling and screen-reader context still work.
- The helper stores the previously focused element when a drawer opens, then restores it after unmount if the element is still connected.
- Existing outside-click dismissal for evidence drawers was preserved.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr using preloadPatchDiff against the real
apps/web/app/terminal.tsx patch. The SSR output is embedded directly below.
15 unmodified lines161718192021369 unmodified lines3913923933943953964497 unmodified lines4894489548964897489848991 unmodified line4901490249034904490549064907490849094910491149124913138 unmodified lines5052505350545055505650575058505950605061506250635064506551 unmodified lines511751185119512051215122512351245125512651275128512951305131513292 unmodified lines522552265227522852295230523152325233523452355236523752385239524052415242524384 unmodified lines53285329533053315332533353345335533653375338533953405341534253435344534553463509 unmodified lines885688578858885988608861138 unmodified lines9000900190029003900490059006900790089009901090119012901390149015901690179018901990209021160 unmodified lines918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109 unmodified lines92209221922292239224922574 unmodified lines9300930193029303930493059306930793089309931093119312931393141 unmodified line931693179318931993209321932215 unmodified linestype CSSProperties,type Dispatch,type MouseEvent as ReactMouseEvent,type ReactNode,type SetStateAction} from "react";369 unmodified linesconst EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];const EMPTY_NEWS_STORIES: NewsStory[] = [];type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;type EquityOverlayPoint = {4497 unmodified lines};const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {const primary = alert.hits[0];const direction = deriveAlertDirection(alert);const severity = normalizeAlertSeverity(alert);1 unmodified lineconst unknownCount = evidence.filter((item) => item.kind === "unknown").length;const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];return (<aside className="drawer"><div className="drawer-header"><div><p className="drawer-eyebrow">Alert details</p><h3>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3><p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>138 unmodified lines};const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {const body = sanitizeNewsHtml(story.content_html);return (<aside className="drawer"><div className="drawer-header"><div><p className="drawer-eyebrow">News wire</p><h3>{story.headline}</h3><p className="drawer-subtitle">{story.source} · Published {formatDateTime(story.published_ts)}{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}51 unmodified lines};const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {const direction = normalizeDirection(hit.direction);const evidencePrints = evidence.filter((item) => item.kind === "print");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;return (<aside className="drawer"><div className="drawer-header"><div><p className="drawer-eyebrow">Classifier hit</p><h3>{humanizeClassifierId(hit.classifier_id)}</h3><p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>92 unmodified lines};const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {const primaryScore =event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??event.profile_scores[0];const direction = normalizeDirection(event.primary_direction);const evidencePrints = evidence.filter((item) => item.kind === "print");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;return (<aside className="drawer"><div className="drawer-header"><div><p className="drawer-eyebrow">Smart money profile</p><h3>{smartMoneyProfileLabel(event.primary_profile_id)}</h3><p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>84 unmodified lines};const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {const joinEvidence = evidence.filter((item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;const traceRefs = event.evidence_refs.slice(0, 6);const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);return (<aside className="drawer"><div className="drawer-header"><div><p className="drawer-eyebrow">Inferred dark</p><h3>{humanizeClassifierId(event.type)}</h3><p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>3509 unmodified linesconst [error, setError] = useState<string | null>(null);const dirtyRef = useRef(false);const savedRef = useRef<SyntheticControlState | null>(null);useEffect(() => {if (!visible) {138 unmodified lines<><buttonaria-expanded={open}aria-label="Synthetic control"className={`synthetic-control-gear${open ? " is-open" : ""}`}onClick={() => setOpen((current) => !current)}type="button"><span className="synthetic-control-gear-mark">+</span></button>{open ? (<aside className="synthetic-control-drawer" aria-label="Synthetic control drawer"><div className="synthetic-control-header"><div><p className="synthetic-control-kicker">Synthetic Control</p><h3>Hosted tape operator rail</h3></div><button className="drawer-close" onClick={() => setOpen(false)} type="button">Close</button></div>160 unmodified linesconst [drawerOpen, setDrawerOpen] = useState(false);const tickerFieldId = useId();const tickerHintId = useId();const activeNavHref = getTerminalNavCurrentHref(pathname);useEffect(() => {setDrawerOpen(false);}, [pathname]);useEffect(() => {if (!drawerOpen) {return;}const handleKeyDown = (event: KeyboardEvent) => {if (event.key === "Escape") {setDrawerOpen(false);}};document.addEventListener("keydown", handleKeyDown);return () => {document.removeEventListener("keydown", handleKeyDown);};}, [drawerOpen]);return (<TerminalContext.Provider value={state}><div className="terminal-shell">9 unmodified linesaria-expanded={drawerOpen}aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}className="terminal-button terminal-menu-trigger"type="button"onClick={() => setDrawerOpen((current) => !current)}>74 unmodified linesaria-label="Close navigation drawer"className="terminal-drawer-backdrop"type="button"onClick={() => setDrawerOpen(false)}/><asidearia-label="Primary navigation"className="terminal-nav-drawer"id="terminal-nav-drawer"><div className="terminal-drawer-head"><div className="terminal-brand"><span className="terminal-brand-kicker">IF</span><span className="terminal-brand-name">islandflow</span></div>1 unmodified linearia-label="Close navigation drawer"className="terminal-button terminal-drawer-close"type="button"onClick={() => setDrawerOpen(false)}>Close</button>15 unmodified lines16171819202122369 unmodified lines3923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055064497 unmodified lines500450055006500750085009501050111 unmodified line50135014501550165017501850195020502150225023502450255026138 unmodified lines5165516651675168516951705171517251735174517551765177517851795180518151 unmodified lines523352345235523652375238523952405241524252435244524552465247524852495250525192 unmodified lines534453455346534753485349535053515352535353545355535653575358535953605361536253635364536584 unmodified lines54505451545254535454545554565457545854595460546154625463546454655466546754685469547054713509 unmodified lines898189828983898489858986898789888989899089918992899389948995138 unmodified lines9134913591369137913891399140914191429143914491459146914791489149915091519152915391549155915691579158915991609161916291639164160 unmodified lines932593269327932893299330933193329333933493359336933793389339934093419342934393449 unmodified lines935493559356935793589359936074 unmodified lines94359436943794389439944094419442944394449445944694479448944994509451945294531 unmodified line945594569457945894599460946115 unmodified linestype CSSProperties,type Dispatch,type MouseEvent as ReactMouseEvent,type RefObject,type ReactNode,type SetStateAction} from "react";369 unmodified linesconst EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];const EMPTY_NEWS_STORIES: NewsStory[] = [];const TABBABLE_SELECTOR = ["a[href]","button:not([disabled])","input:not([disabled]):not([type='hidden'])","select:not([disabled])","textarea:not([disabled])","[tabindex]:not([tabindex='-1'])"].join(",");export const isElementTabbable = (element: HTMLElement): boolean => {if (element.hasAttribute("disabled") || element.getAttribute("aria-hidden") === "true") {return false;}const tabIndex = element.getAttribute("tabindex");if (tabIndex && Number(tabIndex) < 0) {return false;}return Boolean(element.offsetParent || element.getClientRects().length > 0);};export const getTabbableElements = (root: HTMLElement): HTMLElement[] => {return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(isElementTabbable);};const useModalFocusTrap = (active: boolean,rootRef: RefObject<HTMLElement | null>,onClose: () => void,restoreFocusRef?: RefObject<HTMLElement | null>) => {const fallbackFocusRef = useRef<HTMLElement | null>(null);useLayoutEffect(() => {if (!active) {return;}fallbackFocusRef.current =restoreFocusRef?.current ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);const root = rootRef.current;if (!root) {return;}const focusTarget = getTabbableElements(root)[0] ?? root;focusTarget.focus({ preventScroll: true });return () => {const restoreTarget = restoreFocusRef?.current ?? fallbackFocusRef.current;if (restoreTarget?.isConnected) {restoreTarget.focus({ preventScroll: true });}fallbackFocusRef.current = null;};}, [active, restoreFocusRef, rootRef]);useEffect(() => {if (!active) {return;}const handleKeyDown = (event: KeyboardEvent) => {const root = rootRef.current;if (!root) {return;}if (event.key === "Escape") {event.preventDefault();onClose();return;}if (event.key !== "Tab") {return;}const tabbable = getTabbableElements(root);if (tabbable.length === 0) {event.preventDefault();root.focus({ preventScroll: true });return;}const first = tabbable[0];const last = tabbable[tabbable.length - 1];const activeElement = document.activeElement;if (event.shiftKey && activeElement === first) {event.preventDefault();last.focus({ preventScroll: true });} else if (!event.shiftKey && activeElement === last) {event.preventDefault();first.focus({ preventScroll: true });} else if (!root.contains(activeElement)) {event.preventDefault();first.focus({ preventScroll: true });}};document.addEventListener("keydown", handleKeyDown, true);return () => {document.removeEventListener("keydown", handleKeyDown, true);};}, [active, onClose, rootRef]);};type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;type EquityOverlayPoint = {4497 unmodified lines};const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const primary = alert.hits[0];const direction = deriveAlertDirection(alert);const severity = normalizeAlertSeverity(alert);1 unmodified lineconst unknownCount = evidence.filter((item) => item.kind === "unknown").length;const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];useModalFocusTrap(true, drawerRef, onClose);return (<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}><div className="drawer-header"><div><p className="drawer-eyebrow">Alert details</p><h3 id={titleId}>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3><p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>138 unmodified lines};const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const body = sanitizeNewsHtml(story.content_html);useModalFocusTrap(true, drawerRef, onClose);return (<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}><div className="drawer-header"><div><p className="drawer-eyebrow">News wire</p><h3 id={titleId}>{story.headline}</h3><p className="drawer-subtitle">{story.source} · Published {formatDateTime(story.published_ts)}{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}51 unmodified lines};const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const direction = normalizeDirection(hit.direction);const evidencePrints = evidence.filter((item) => item.kind === "print");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;useModalFocusTrap(true, drawerRef, onClose);return (<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}><div className="drawer-header"><div><p className="drawer-eyebrow">Classifier hit</p><h3 id={titleId}>{humanizeClassifierId(hit.classifier_id)}</h3><p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>92 unmodified lines};const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const primaryScore =event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??event.profile_scores[0];const direction = normalizeDirection(event.primary_direction);const evidencePrints = evidence.filter((item) => item.kind === "print");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;useModalFocusTrap(true, drawerRef, onClose);return (<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}><div className="drawer-header"><div><p className="drawer-eyebrow">Smart money profile</p><h3 id={titleId}>{smartMoneyProfileLabel(event.primary_profile_id)}</h3><p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>84 unmodified lines};const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const joinEvidence = evidence.filter((item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join");const unknownCount = evidence.filter((item) => item.kind === "unknown").length;const traceRefs = event.evidence_refs.slice(0, 6);const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);useModalFocusTrap(true, drawerRef, onClose);return (<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}><div className="drawer-header"><div><p className="drawer-eyebrow">Inferred dark</p><h3 id={titleId}>{humanizeClassifierId(event.type)}</h3><p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p></div><button className="drawer-close" type="button" onClick={onClose}>3509 unmodified linesconst [error, setError] = useState<string | null>(null);const dirtyRef = useRef(false);const savedRef = useRef<SyntheticControlState | null>(null);const triggerRef = useRef<HTMLButtonElement | null>(null);const drawerRef = useRef<HTMLElement | null>(null);const titleId = useId();const closeDrawer = useCallback(() => {setOpen(false);}, []);useModalFocusTrap(open, drawerRef, closeDrawer, triggerRef);useEffect(() => {if (!visible) {138 unmodified lines<><buttonaria-expanded={open}aria-haspopup="dialog"aria-label="Synthetic control"className={`synthetic-control-gear${open ? " is-open" : ""}`}onClick={() => setOpen((current) => !current)}ref={triggerRef}type="button"><span className="synthetic-control-gear-mark">+</span></button>{open ? (<asidearia-labelledby={titleId}aria-modal="true"className="synthetic-control-drawer"ref={drawerRef}role="dialog"tabIndex={-1}><div className="synthetic-control-header"><div><p className="synthetic-control-kicker">Synthetic Control</p><h3 id={titleId}>Hosted tape operator rail</h3></div><button className="drawer-close" onClick={closeDrawer} type="button">Close</button></div>160 unmodified linesconst [drawerOpen, setDrawerOpen] = useState(false);const tickerFieldId = useId();const tickerHintId = useId();const navTriggerRef = useRef<HTMLButtonElement | null>(null);const navDrawerRef = useRef<HTMLElement | null>(null);const navDrawerTitleId = useId();const activeNavHref = getTerminalNavCurrentHref(pathname);const closeNavDrawer = useCallback(() => {setDrawerOpen(false);}, []);useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef);useEffect(() => {setDrawerOpen(false);}, [pathname]);return (<TerminalContext.Provider value={state}><div className="terminal-shell">9 unmodified linesaria-expanded={drawerOpen}aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}className="terminal-button terminal-menu-trigger"ref={navTriggerRef}type="button"onClick={() => setDrawerOpen((current) => !current)}>74 unmodified linesaria-label="Close navigation drawer"className="terminal-drawer-backdrop"type="button"onClick={closeNavDrawer}/><asidearia-labelledby={navDrawerTitleId}aria-modal="true"className="terminal-nav-drawer"id="terminal-nav-drawer"ref={navDrawerRef}role="dialog"tabIndex={-1}><div className="terminal-drawer-head"><div className="terminal-brand" id={navDrawerTitleId}><span className="terminal-brand-kicker">IF</span><span className="terminal-brand-name">islandflow</span></div>1 unmodified linearia-label="Close navigation drawer"className="terminal-button terminal-drawer-close"type="button"onClick={closeNavDrawer}>Close</button>
Expected Impact for End-Users
Keyboard users can now open a drawer, move through its controls without falling into the page behind it, close it with Escape, and continue from the control they used to open it. Screen-reader users also get clearer modal dialog boundaries and labels.
Validation
- Passed:
bun test apps/web/app/terminal.test.ts - Passed:
bun --cwd=apps/web run build - Attempted Playwright browser verification against
localhost:3001; the app did not become stable enough for a reliable scripted assertion before timeout, so automated browser validation is documented as incomplete.
Issues, Limitations, and Mitigations
- The current unit test setup is logic-focused and does not include DOM interaction tests for Tab cycling.
- A follow-up issue already exists for terminal navigation drawer interaction coverage:
islandflow-3by. - The helper uses standard focusable element selectors and visibility checks; custom focusable widgets should still expose normal tab stops.
Follow-up Work
islandflow-3by: add interaction coverage for terminal navigation drawer.- Consider adding a lightweight DOM test harness for drawer focus restoration and Tab wrap behavior.