harden terminal ui states and drawer focus handling #16
3 changed files with 534 additions and 34 deletions
|
|
@ -24,6 +24,7 @@
|
||||||
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"islandflow-wtg","title":"Harden drawer dialog focus behavior","description":"Fix terminal drawers so they expose modal dialog semantics, trap keyboard focus while open, and restore focus to the invoking control after close.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:55:25Z","created_by":"dirtydishes","updated_at":"2026-05-29T23:09:45Z","started_at":"2026-05-29T22:56:22Z","closed_at":"2026-05-29T23:09:45Z","close_reason":"Implemented modal dialog semantics, focus trapping, Escape dismissal, focus restoration, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-833","title":"Improve narrow options table responsiveness","description":"Adapt the Options route for narrow screens so dense tape tables remain contained in their panes, preserve row identity while horizontally panning, and keep the mobile ticker/filter controls readable.","acceptance_criteria":"Options tape panes have bounded heights on narrow screens; table body scrolls internally; first table column remains visible while panning; mobile topbar and filter controls have adequate spacing; web production build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:34:05Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:36:20Z","started_at":"2026-05-29T22:34:24Z","closed_at":"2026-05-29T22:36:20Z","close_reason":"Implemented narrow-screen options pane containment, sticky row context, touch-scroll affordances, and mobile control spacing. Validated with web build and in-browser narrow viewport checks.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-833","title":"Improve narrow options table responsiveness","description":"Adapt the Options route for narrow screens so dense tape tables remain contained in their panes, preserve row identity while horizontally panning, and keep the mobile ticker/filter controls readable.","acceptance_criteria":"Options tape panes have bounded heights on narrow screens; table body scrolls internally; first table column remains visible while panning; mobile topbar and filter controls have adequate spacing; web production build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:34:05Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:36:20Z","started_at":"2026-05-29T22:34:24Z","closed_at":"2026-05-29T22:36:20Z","close_reason":"Implemented narrow-screen options pane containment, sticky row context, touch-scroll affordances, and mobile control spacing. Validated with web build and in-browser narrow viewport checks.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
type RefObject,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SetStateAction
|
type SetStateAction
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
@ -391,6 +392,115 @@ const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = [];
|
||||||
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
|
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
|
||||||
const EMPTY_NEWS_STORIES: NewsStory[] = [];
|
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 CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
|
||||||
|
|
||||||
type EquityOverlayPoint = {
|
type EquityOverlayPoint = {
|
||||||
|
|
@ -4894,6 +5004,8 @@ const formatOptionalMs = (value: unknown): string | null => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
|
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
|
||||||
|
const drawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
const primary = alert.hits[0];
|
const primary = alert.hits[0];
|
||||||
const direction = deriveAlertDirection(alert);
|
const direction = deriveAlertDirection(alert);
|
||||||
const severity = normalizeAlertSeverity(alert);
|
const severity = normalizeAlertSeverity(alert);
|
||||||
|
|
@ -4901,13 +5013,14 @@ const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: Al
|
||||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||||
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
|
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
|
||||||
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
|
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
|
||||||
|
useModalFocusTrap(true, drawerRef, onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="drawer-eyebrow">Alert details</p>
|
<p className="drawer-eyebrow">Alert details</p>
|
||||||
<h3>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
|
<h3 id={titleId}>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
|
||||||
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
|
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close" type="button" onClick={onClose}>
|
<button className="drawer-close" type="button" onClick={onClose}>
|
||||||
|
|
@ -5052,14 +5165,17 @@ type NewsDrawerProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
|
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
|
||||||
|
const drawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
const body = sanitizeNewsHtml(story.content_html);
|
const body = sanitizeNewsHtml(story.content_html);
|
||||||
|
useModalFocusTrap(true, drawerRef, onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="drawer-eyebrow">News wire</p>
|
<p className="drawer-eyebrow">News wire</p>
|
||||||
<h3>{story.headline}</h3>
|
<h3 id={titleId}>{story.headline}</h3>
|
||||||
<p className="drawer-subtitle">
|
<p className="drawer-subtitle">
|
||||||
{story.source} · Published {formatDateTime(story.published_ts)}
|
{story.source} · Published {formatDateTime(story.published_ts)}
|
||||||
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
|
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
|
||||||
|
|
@ -5117,16 +5233,19 @@ type ClassifierHitDrawerProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {
|
const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {
|
||||||
|
const drawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
const direction = normalizeDirection(hit.direction);
|
const direction = normalizeDirection(hit.direction);
|
||||||
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
||||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||||
|
useModalFocusTrap(true, drawerRef, onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="drawer-eyebrow">Classifier hit</p>
|
<p className="drawer-eyebrow">Classifier hit</p>
|
||||||
<h3>{humanizeClassifierId(hit.classifier_id)}</h3>
|
<h3 id={titleId}>{humanizeClassifierId(hit.classifier_id)}</h3>
|
||||||
<p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p>
|
<p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close" type="button" onClick={onClose}>
|
<button className="drawer-close" type="button" onClick={onClose}>
|
||||||
|
|
@ -5225,19 +5344,22 @@ type SmartMoneyDrawerProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
|
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
|
||||||
|
const drawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
const primaryScore =
|
const primaryScore =
|
||||||
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
|
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
|
||||||
event.profile_scores[0];
|
event.profile_scores[0];
|
||||||
const direction = normalizeDirection(event.primary_direction);
|
const direction = normalizeDirection(event.primary_direction);
|
||||||
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
||||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||||
|
useModalFocusTrap(true, drawerRef, onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="drawer-eyebrow">Smart money profile</p>
|
<p className="drawer-eyebrow">Smart money profile</p>
|
||||||
<h3>{smartMoneyProfileLabel(event.primary_profile_id)}</h3>
|
<h3 id={titleId}>{smartMoneyProfileLabel(event.primary_profile_id)}</h3>
|
||||||
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
|
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close" type="button" onClick={onClose}>
|
<button className="drawer-close" type="button" onClick={onClose}>
|
||||||
|
|
@ -5328,19 +5450,22 @@ type DarkDrawerProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
|
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
|
||||||
|
const drawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
const joinEvidence = evidence.filter(
|
const joinEvidence = evidence.filter(
|
||||||
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
|
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
|
||||||
);
|
);
|
||||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||||
const traceRefs = event.evidence_refs.slice(0, 6);
|
const traceRefs = event.evidence_refs.slice(0, 6);
|
||||||
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
|
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
|
||||||
|
useModalFocusTrap(true, drawerRef, onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer">
|
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="drawer-eyebrow">Inferred dark</p>
|
<p className="drawer-eyebrow">Inferred dark</p>
|
||||||
<h3>{humanizeClassifierId(event.type)}</h3>
|
<h3 id={titleId}>{humanizeClassifierId(event.type)}</h3>
|
||||||
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
|
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close" type="button" onClick={onClose}>
|
<button className="drawer-close" type="button" onClick={onClose}>
|
||||||
|
|
@ -8856,6 +8981,15 @@ function SyntheticControlDock() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
const savedRef = useRef<SyntheticControlState | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
|
@ -9000,22 +9134,31 @@ function SyntheticControlDock() {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
aria-label="Synthetic control"
|
aria-label="Synthetic control"
|
||||||
className={`synthetic-control-gear${open ? " is-open" : ""}`}
|
className={`synthetic-control-gear${open ? " is-open" : ""}`}
|
||||||
onClick={() => setOpen((current) => !current)}
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="synthetic-control-gear-mark">+</span>
|
<span className="synthetic-control-gear-mark">+</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<aside className="synthetic-control-drawer" aria-label="Synthetic control drawer">
|
<aside
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-modal="true"
|
||||||
|
className="synthetic-control-drawer"
|
||||||
|
ref={drawerRef}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<div className="synthetic-control-header">
|
<div className="synthetic-control-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="synthetic-control-kicker">Synthetic Control</p>
|
<p className="synthetic-control-kicker">Synthetic Control</p>
|
||||||
<h3>Hosted tape operator rail</h3>
|
<h3 id={titleId}>Hosted tape operator rail</h3>
|
||||||
</div>
|
</div>
|
||||||
<button className="drawer-close" onClick={() => setOpen(false)} type="button">
|
<button className="drawer-close" onClick={closeDrawer} type="button">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -9182,29 +9325,20 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const tickerFieldId = useId();
|
const tickerFieldId = useId();
|
||||||
const tickerHintId = useId();
|
const tickerHintId = useId();
|
||||||
|
const navTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const navDrawerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const navDrawerTitleId = useId();
|
||||||
const activeNavHref = getTerminalNavCurrentHref(pathname);
|
const activeNavHref = getTerminalNavCurrentHref(pathname);
|
||||||
|
const closeNavDrawer = useCallback(() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}, [pathname]);
|
}, [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 (
|
return (
|
||||||
<TerminalContext.Provider value={state}>
|
<TerminalContext.Provider value={state}>
|
||||||
<div className="terminal-shell">
|
<div className="terminal-shell">
|
||||||
|
|
@ -9220,6 +9354,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
||||||
aria-expanded={drawerOpen}
|
aria-expanded={drawerOpen}
|
||||||
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
|
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||||
className="terminal-button terminal-menu-trigger"
|
className="terminal-button terminal-menu-trigger"
|
||||||
|
ref={navTriggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDrawerOpen((current) => !current)}
|
onClick={() => setDrawerOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
|
|
@ -9300,15 +9435,19 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
||||||
aria-label="Close navigation drawer"
|
aria-label="Close navigation drawer"
|
||||||
className="terminal-drawer-backdrop"
|
className="terminal-drawer-backdrop"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={closeNavDrawer}
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
aria-label="Primary navigation"
|
aria-labelledby={navDrawerTitleId}
|
||||||
|
aria-modal="true"
|
||||||
className="terminal-nav-drawer"
|
className="terminal-nav-drawer"
|
||||||
id="terminal-nav-drawer"
|
id="terminal-nav-drawer"
|
||||||
|
ref={navDrawerRef}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="terminal-drawer-head">
|
<div className="terminal-drawer-head">
|
||||||
<div className="terminal-brand">
|
<div className="terminal-brand" id={navDrawerTitleId}>
|
||||||
<span className="terminal-brand-kicker">IF</span>
|
<span className="terminal-brand-kicker">IF</span>
|
||||||
<span className="terminal-brand-name">islandflow</span>
|
<span className="terminal-brand-name">islandflow</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -9316,7 +9455,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
||||||
aria-label="Close navigation drawer"
|
aria-label="Close navigation drawer"
|
||||||
className="terminal-button terminal-drawer-close"
|
className="terminal-button terminal-drawer-close"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={closeNavDrawer}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
360
docs/turns/2026-05-29-harden-drawer-dialog-focus.html
Normal file
360
docs/turns/2026-05-29-harden-drawer-dialog-focus.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue