Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/protected/dashboard/field-context/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1679,7 +1680,13 @@ export default function FieldContextDetailsPage() {
<ImportArticlesModal
isOpen
fieldContextId={contextId}
onClose={() => 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.
Expand Down
44 changes: 41 additions & 3 deletions src/components/fields/import-articles-modal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -84,6 +85,13 @@ export function ImportArticlesModal({
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const dialogRef = useRef<HTMLDivElement>(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')
Expand Down Expand Up @@ -188,21 +196,51 @@ 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.
if (typeof document === 'undefined') return null

return createPortal(
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gp-surface dark:bg-gp-surface-dark border border-gp-glass-border rounded-2xl shadow-2xl max-w-lg sm:max-w-2xl w-full max-h-[85vh] p-4 sm:p-6 flex flex-col gap-4">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="import-articles-title"
tabIndex={-1}
className="bg-gp-surface dark:bg-gp-surface-dark border border-gp-glass-border rounded-2xl shadow-2xl max-w-lg sm:max-w-2xl w-full max-h-[85vh] p-4 sm:p-6 flex flex-col gap-4 focus:outline-none"
>
<div className="flex items-center justify-between shrink-0">
<h3 className="text-lg font-semibold text-gp-ink-strong dark:text-white">
<h3
id="import-articles-title"
className="text-lg font-semibold text-gp-ink-strong dark:text-white"
>
Import Articles
</h3>
<button
onClick={handleClose}
disabled={isSubmitting}
aria-label="Close"
type="button"
className="cursor-pointer text-gp-ink-muted hover:text-gp-ink-strong transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Expand Down
65 changes: 57 additions & 8 deletions src/components/studio/field-context-upload-action.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState, type FC } from 'react'
import { useCallback, useEffect, useRef, useState, type FC } from 'react'
import { useApolloClient } from '@apollo/client/react'
import { FileUp } from 'lucide-react'
import { toast } from 'sonner'
Expand All @@ -20,7 +20,10 @@ import { GET_FIELD_CONTEXT_DETAILS } from '@/app/graphql/queries/FIELD_CONTEXT_D
import { GET_FIELD_CONTEXT_PEOPLE } from '@/app/graphql/queries/FIELD_CONTEXT_PEOPLE_QUERIES'
import { useFieldContextCanEditContent } from '@/hooks/use-field-context-permissions'
import { emitOpenAssistantThread } from '@/lib/simulation/assistant-panel-events'
import { emitOpenImportArticlesModal } from '@/lib/simulation/pulse-creation-events'
import {
emitOpenImportArticlesModal,
onImportArticlesModalClosed,
} from '@/lib/simulation/pulse-creation-events'
import { chatApiAuthHeaders } from '@/lib/simulation/conversation-thread-client'

/**
Expand Down Expand Up @@ -58,6 +61,31 @@ export const FieldContextUploadAction: FC = () => {
string | null
>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Both menu items open a modal that installs its own focus trap. Radix
// restores focus to this menu's trigger ~300ms after the item is chosen —
// i.e. *after* the dialog has already focused its first control — which
// dumps keyboard focus onto the button behind the overlay (GOAL-328).
// Suppress that restore, but only when an item was actually chosen:
// dismissing the menu with Escape or an outside click must still return
// focus to the trigger.
const restoreFocusOnCloseRef = useRef(true)
const triggerRef = useRef<HTMLButtonElement>(null)

// Because that restore is suppressed, this component owns putting focus
// back on the trigger once the dialog goes away — otherwise the dialog's
// trap has nothing live to restore to and focus lands on <body>. The rAF
// waits for the dialog's own cleanup to run first, so it can't be undone.
const refocusTrigger = useCallback(() => {
requestAnimationFrame(() => triggerRef.current?.focus())
}, [])

// The bulk import dialog is owned by the field-context page, so this is the
// only way to learn it closed. Must sit above the early returns below —
// hooks cannot be called conditionally.
useEffect(
() => onImportArticlesModalClosed(refocusTrigger),
[refocusTrigger]
)

// Only treat the focal as a live FieldContext when it came from the
// current route. A 'persisted' source means the user navigated away to
Expand Down Expand Up @@ -212,9 +240,17 @@ export const FieldContextUploadAction: FC = () => {

return (
<>
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
// Re-arm on every open rather than relying on `onCloseAutoFocus`
// having fired to reset it — that would make the guard depend on
// Radix internals to stay in sync.
if (open) restoreFocusOnCloseRef.current = true
}}
>
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
disabled={!focalFieldContextId}
className="cursor-pointer flex items-center gap-1.5 md:gap-2 pl-3 pr-2 md:pl-5 md:pr-3 h-10 md:h-11 rounded-full gp-glass dark:gp-glass border border-gp-glass-border hover:bg-gp-ink-strong/10 dark:hover:bg-white/20 hover:border-gp-ink-strong/20 dark:hover:border-white/20 hover:shadow-[0_0_50px_color-mix(in_srgb,var(--gp-primary)_35%,transparent)] transition-all group disabled:opacity-40 disabled:cursor-not-allowed"
Expand All @@ -237,9 +273,18 @@ export const FieldContextUploadAction: FC = () => {
align="center"
sideOffset={10}
className="gp-glass w-56 rounded-xl border-gp-glass-border p-1.5"
onCloseAutoFocus={(event) => {
if (restoreFocusOnCloseRef.current) return
restoreFocusOnCloseRef.current = true
event.preventDefault()
}}
>
<DropdownMenuItem
onSelect={() => setPinnedFieldContextId(focalFieldContextId)}
onSelect={() => {
if (!focalFieldContextId) return
restoreFocusOnCloseRef.current = false
setPinnedFieldContextId(focalFieldContextId)
}}
// `.gp-menu-item` owns the hover/focus highlight (per the design
// skill), so neutralise the primitive's own `focus:bg-accent` /
// `focus:text-accent-foreground` — otherwise the label lands on
Expand All @@ -252,10 +297,11 @@ export const FieldContextUploadAction: FC = () => {
<span className="text-sm font-medium">Upload document</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
focalFieldContextId &&
onSelect={() => {
if (!focalFieldContextId) return
restoreFocusOnCloseRef.current = false
emitOpenImportArticlesModal(focalFieldContextId)
}
}}
// `.gp-menu-item` owns the hover/focus highlight (per the design
// skill), so neutralise the primitive's own `focus:bg-accent` /
// `focus:text-accent-foreground` — otherwise the label lands on
Expand All @@ -273,7 +319,10 @@ export const FieldContextUploadAction: FC = () => {
<UploadDocumentModal
isOpen={pinnedFieldContextId !== null}
isSubmitting={isSubmitting}
onClose={() => setPinnedFieldContextId(null)}
onClose={() => {
setPinnedFieldContextId(null)
refocusTrigger()
}}
onSubmit={handleSubmit}
/>
</>
Expand Down
42 changes: 39 additions & 3 deletions src/components/ui/upload-document-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useCallback, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import {
INGEST_ACCEPT_ATTRIBUTE,
MAX_INGEST_BYTES,
Expand Down Expand Up @@ -76,6 +77,11 @@ export function UploadDocumentModal({
const [hint, setHint] = useState('')
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const dialogRef = useRef<HTMLDivElement>(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.
useFocusTrap(dialogRef, isOpen)

const reset = useCallback(() => {
setFile(null)
Expand Down Expand Up @@ -133,18 +139,48 @@ export function UploadDocumentModal({
}
}, [file, hint, onSubmit, reset])

// Esc closes — `handleClose` already no-ops mid-upload, so an in-flight
// submit 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

return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gp-surface dark:bg-gp-surface-dark border border-gp-glass-border rounded-2xl shadow-2xl max-w-lg w-full p-6 space-y-4">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="upload-document-title"
tabIndex={-1}
className="bg-gp-surface dark:bg-gp-surface-dark border border-gp-glass-border rounded-2xl shadow-2xl max-w-lg w-full p-6 space-y-4 focus:outline-none"
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gp-ink-strong dark:text-white">
<h3
id="upload-document-title"
className="text-lg font-semibold text-gp-ink-strong dark:text-white"
>
Upload Document
</h3>
<button
onClick={handleClose}
disabled={isSubmitting}
aria-label="Close"
type="button"
className="cursor-pointer text-gp-ink-muted hover:text-gp-ink-strong transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Expand Down
23 changes: 23 additions & 0 deletions src/lib/simulation/pulse-creation-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const OPEN_ADD_PULSE_EVENT = 'gp:open-add-pulse-modal' as const
const OPEN_ADD_FIELD_CONTEXT_EVENT = 'gp:open-add-field-context-modal' as const
const OPEN_ADD_SPACE_MEMBERS_EVENT = 'gp:open-add-space-members-modal' as const
const OPEN_IMPORT_ARTICLES_EVENT = 'gp:open-import-articles-modal' as const
const CLOSED_IMPORT_ARTICLES_EVENT = 'gp:import-articles-modal-closed' as const

export interface OpenAddPulseModalDetail {
/** FieldContext.id the caller expects the modal to attach to. */
Expand Down Expand Up @@ -136,6 +137,28 @@ export function onOpenImportArticlesModal(
return () => window.removeEventListener(OPEN_IMPORT_ARTICLES_EVENT, wrapped)
}

/**
* Announce that the bulk import-articles modal closed (GOAL-328).
*
* The modal is owned by the field-context page but can be launched from the
* studio action bar, whose menu suppresses Radix's own focus restore so the
* dialog can take focus. That leaves the launcher as the only component able
* to return focus to its trigger — but it has no other way to learn the
* dialog went away. This is that signal.
*/
export function emitImportArticlesModalClosed(): void {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(CLOSED_IMPORT_ARTICLES_EVENT))
}

/** Subscribe to import-articles close notifications. Returns an unsubscribe. */
export function onImportArticlesModalClosed(handler: () => void): () => void {
if (typeof window === 'undefined') return () => {}
const wrapped = () => handler()
window.addEventListener(CLOSED_IMPORT_ARTICLES_EVENT, wrapped)
return () => window.removeEventListener(CLOSED_IMPORT_ARTICLES_EVENT, wrapped)
}

/** Emit a request to open the add-members modal for `spaceId`. */
export function emitOpenAddSpaceMembersModal(spaceId: string): void {
if (typeof window === 'undefined') return
Expand Down