Harden Drawer Dialog Focus

Added consistent modal dialog semantics, keyboard focus trapping, Escape dismissal, and focus restoration for terminal drawers.

2026-05-29 19:06 Issue islandflow-wtg apps/web

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

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

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.

apps/web/app/terminal.tsx
-34+173
15 unmodified lines
16
17
18
19
20
21
369 unmodified lines
391
392
393
394
395
396
4497 unmodified lines
4894
4895
4896
4897
4898
4899
1 unmodified line
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
138 unmodified lines
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
51 unmodified lines
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
92 unmodified lines
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
84 unmodified lines
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
3509 unmodified lines
8856
8857
8858
8859
8860
8861
138 unmodified lines
9000
9001
9002
9003
9004
9005
9006
9007
9008
9009
9010
9011
9012
9013
9014
9015
9016
9017
9018
9019
9020
9021
160 unmodified lines
9182
9183
9184
9185
9186
9187
9188
9189
9190
9191
9192
9193
9194
9195
9196
9197
9198
9199
9200
9201
9202
9203
9204
9205
9206
9207
9208
9209
9210
9 unmodified lines
9220
9221
9222
9223
9224
9225
74 unmodified lines
9300
9301
9302
9303
9304
9305
9306
9307
9308
9309
9310
9311
9312
9313
9314
1 unmodified line
9316
9317
9318
9319
9320
9321
9322
15 unmodified lines
type CSSProperties,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type SetStateAction
} from "react";
369 unmodified lines
const 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 line
const 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 lines
const [error, setError] = useState<string | null>(null);
const dirtyRef = useRef(false);
const savedRef = useRef<SyntheticControlState | null>(null);
useEffect(() => {
if (!visible) {
138 unmodified lines
<>
<button
aria-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 lines
const [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 lines
aria-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 lines
aria-label="Close navigation drawer"
className="terminal-drawer-backdrop"
type="button"
onClick={() => setDrawerOpen(false)}
/>
<aside
aria-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 line
aria-label="Close navigation drawer"
className="terminal-button terminal-drawer-close"
type="button"
onClick={() => setDrawerOpen(false)}
>
Close
</button>
15 unmodified lines
16
17
18
19
20
21
22
369 unmodified lines
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
4497 unmodified lines
5004
5005
5006
5007
5008
5009
5010
5011
1 unmodified line
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
138 unmodified lines
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
51 unmodified lines
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
92 unmodified lines
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
84 unmodified lines
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
3509 unmodified lines
8981
8982
8983
8984
8985
8986
8987
8988
8989
8990
8991
8992
8993
8994
8995
138 unmodified lines
9134
9135
9136
9137
9138
9139
9140
9141
9142
9143
9144
9145
9146
9147
9148
9149
9150
9151
9152
9153
9154
9155
9156
9157
9158
9159
9160
9161
9162
9163
9164
160 unmodified lines
9325
9326
9327
9328
9329
9330
9331
9332
9333
9334
9335
9336
9337
9338
9339
9340
9341
9342
9343
9344
9 unmodified lines
9354
9355
9356
9357
9358
9359
9360
74 unmodified lines
9435
9436
9437
9438
9439
9440
9441
9442
9443
9444
9445
9446
9447
9448
9449
9450
9451
9452
9453
1 unmodified line
9455
9456
9457
9458
9459
9460
9461
15 unmodified lines
type CSSProperties,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type RefObject,
type ReactNode,
type SetStateAction
} from "react";
369 unmodified lines
const 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 line
const 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 lines
const [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
<>
<button
aria-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 ? (
<aside
aria-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 lines
const [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 lines
aria-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 lines
aria-label="Close navigation drawer"
className="terminal-drawer-backdrop"
type="button"
onClick={closeNavDrawer}
/>
<aside
aria-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 line
aria-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

Issues, Limitations, and Mitigations

Follow-up Work