From d22e59dbd74d6c5e06ea68c526a911f06549b1b7 Mon Sep 17 00:00:00 2001 From: Smith the Agent Date: Tue, 11 Aug 2026 22:04:49 +0000 Subject: [PATCH] GOAL-328: ImportArticlesModal and UploadDocumentModal have no focus trap or initial focus, so opening either leaves keyboard focus outside the dialog. Worked by Smith the Agent. Evidence: https://smith-reports.thecodefoundry.dev/GOAL-328 --- .../dashboard/field-context/[id]/page.tsx | 9 ++- .../fields/import-articles-modal.tsx | 44 ++++++++++++- .../studio/field-context-upload-action.tsx | 65 ++++++++++++++++--- src/components/ui/upload-document-modal.tsx | 42 +++++++++++- src/lib/simulation/pulse-creation-events.ts | 23 +++++++ 5 files changed, 168 insertions(+), 15 deletions(-) diff --git a/src/app/protected/dashboard/field-context/[id]/page.tsx b/src/app/protected/dashboard/field-context/[id]/page.tsx index e9f8718b..4646e05a 100644 --- a/src/app/protected/dashboard/field-context/[id]/page.tsx +++ b/src/app/protected/dashboard/field-context/[id]/page.tsx @@ -85,6 +85,7 @@ import { import { emitOpenAssistantThread } from '@/lib/simulation/assistant-panel-events' import { chatApiAuthHeaders } from '@/lib/simulation/conversation-thread-client' import { + emitImportArticlesModalClosed, onOpenAddPulseModal, onOpenImportArticlesModal, } from '@/lib/simulation/pulse-creation-events' @@ -1679,7 +1680,13 @@ export default function FieldContextDetailsPage() { setIsImportArticlesModalOpen(false)} + onClose={() => { + setIsImportArticlesModalOpen(false) + // Lets the studio action bar — which suppressed Radix's focus + // restore so this dialog could take focus — put focus back on + // its trigger (GOAL-328). No-op when opened from this page. + emitImportArticlesModalClosed() + }} onImported={() => { // Imported pulses land in the Pulses section; new/matched authors // land in the People section. diff --git a/src/components/fields/import-articles-modal.tsx b/src/components/fields/import-articles-modal.tsx index 17cb304f..726d4b85 100644 --- a/src/components/fields/import-articles-modal.tsx +++ b/src/components/fields/import-articles-modal.tsx @@ -1,7 +1,8 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useFocusTrap } from '@/hooks/useFocusTrap' import { chatApiAuthHeaders } from '@/lib/simulation/conversation-thread-client' import { ARTICLE_TEMPLATE_COLUMNS, @@ -84,6 +85,13 @@ export function ImportArticlesModal({ const [error, setError] = useState(null) const [isDragging, setIsDragging] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const dialogRef = useRef(null) + + // Keep Tab / Shift+Tab inside the dialog and move focus into it on open — + // WAI-ARIA modal dialog pattern, same hook EntityInfoDrawer / PersonPanel use. + // Especially load-bearing here: the modal is portalled to `document.body`, so + // without a trap Tab walks straight into the field-context page behind it. + useFocusTrap(dialogRef, isOpen) const reset = useCallback(() => { setStep('pick') @@ -188,6 +196,25 @@ export function ImportArticlesModal({ } }, [fieldContextId, onImported, validRows]) + // Esc closes — `handleClose` already no-ops mid-import, so an in-flight + // batch can't be dismissed out from under the user. + // + // Capture phase on `document`, plus stopPropagation: StudioShell binds its + // single-key shortcuts (Escape closes the floating chat / exits fullscreen) + // on `window` in the bubble phase and only ignores them for text inputs. + // Initial focus here lands on a button, so a bubbling Escape would close + // the chat panel *and* this dialog. Capture runs first, so the dialog wins. + useEffect(() => { + if (!isOpen) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.stopPropagation() + handleClose() + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [isOpen, handleClose]) + if (!isOpen) return null // `dynamic(..., { ssr: false })` means this only ever runs in the browser, // but guard anyway so the component stays safe to mount eagerly. @@ -195,14 +222,25 @@ export function ImportArticlesModal({ return createPortal(
-
+
-

+

Import Articles