mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Keep overlays open when focusing the chat composer (#37403)
* Preserve open overlays when focusing the chat composer * Fix chat composer focus after launch menus close * Fix chat composer focus handoff after overlays close * Fix composer focus after custom overlays close * Prevent chat composer from stealing overlay focus Scope recovery overlays to their own pane and let menus dismiss before returning focus to the composer. This keeps the quick launcher open when delayed chat actions complete. * Fix composer focus across nested overlays Track focus ownership through portaled overlays so the launch prompt can regain focus while its model list is open. Close the context menu before inserting a quote so the caret lands after it. * Fix overlay focus CI regressions * Complete overlay focus ownership for inline and retained surfaces * Declare public overlay focus hook return type
This commit is contained in:
@@ -102,6 +102,7 @@ function LoudRestartOverlay({
|
||||
<div
|
||||
ref={rootRef}
|
||||
role="dialog"
|
||||
data-overlay-focus-scope="parent"
|
||||
// Allow programmatic focus while keeping Tab navigation on Restart/Keep.
|
||||
tabIndex={-1}
|
||||
aria-live="assertive"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import de from '../../i18n/locales/de.json'
|
||||
import { agentLaunchPromptScopeKey } from './AgentLaunchPromptComposer'
|
||||
import { focusMountedNativeChatEditor } from '../native-chat/native-chat-mounted-editor-view'
|
||||
import { changePrompt, promptEditor } from '../native-chat/native-chat-prompt-editor.test-support'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
@@ -372,3 +373,54 @@ it.each(['claude', 'codex'] as const)(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['inline', 'menu'])(
|
||||
'focuses the setup prompt imperatively with the model list open in %s configuration',
|
||||
async (surface) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
{surface === 'menu' ? (
|
||||
<DropdownMenu defaultOpen modal={false}>
|
||||
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<AgentLaunchPickerMenu entries={[launchEntry]} runLaunch={() => false} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<AgentLaunchSetupPanel entry={launchEntry} onUse={onUse} />
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
if (surface === 'menu') {
|
||||
fireEvent.click(getByRole(document.body, 'button', { name: 'Configure Claude' }))
|
||||
}
|
||||
const editor = document.querySelector<HTMLElement>('[contenteditable][aria-multiline="true"]')!
|
||||
await waitFor(() => expect(promptEditor(editor)).toBeDefined())
|
||||
const models = document.querySelector<HTMLInputElement>('input[aria-label="Search models"]')!
|
||||
act(() => models.focus())
|
||||
expect(document.activeElement).toBe(models)
|
||||
expect(document.querySelector('[cmdk-list]')).not.toBeNull()
|
||||
if (surface === 'menu') {
|
||||
expect(document.querySelector('[role="menu"][data-state="open"]')).not.toBeNull()
|
||||
}
|
||||
|
||||
act(() => focusMountedNativeChatEditor(promptEditor(editor)))
|
||||
expect(document.activeElement).toBe(editor)
|
||||
|
||||
// A sibling popup still owns focus above the setup prompt.
|
||||
const popup = document.createElement('div')
|
||||
popup.setAttribute('role', 'dialog')
|
||||
const input = document.createElement('input')
|
||||
popup.append(input)
|
||||
document.body.append(popup)
|
||||
try {
|
||||
input.focus()
|
||||
act(() => focusMountedNativeChatEditor(promptEditor(editor)))
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
popup.remove()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ export function AgentLaunchSetupPanel({
|
||||
agent: entry.label
|
||||
})}
|
||||
/>
|
||||
<CommandList className="max-h-56">
|
||||
<CommandList className="max-h-56" data-focus-behavior="page">
|
||||
<CommandEmpty>
|
||||
{translate('agentPicker.noModels', 'No models match your search.')}
|
||||
</CommandEmpty>
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ export function BrowserMobileDriverOverlay({ driver, onTakeBack }: Props): React
|
||||
<div
|
||||
ref={setOverlayRef}
|
||||
role="dialog"
|
||||
data-overlay-focus-scope="parent"
|
||||
aria-modal="true"
|
||||
aria-labelledby={OVERLAY_TITLE_ID}
|
||||
aria-live="assertive"
|
||||
|
||||
@@ -200,6 +200,7 @@ export function bindFocusedFloatingPanelKeydown(element: unknown): {
|
||||
Object.setPrototypeOf(panelElement, HTMLElement.prototype)
|
||||
attachRef(panel.props.ref, panelElement)
|
||||
vi.stubGlobal('document', {
|
||||
...document,
|
||||
activeElement: panelElement,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
|
||||
@@ -248,11 +248,20 @@ export function setupFloatingTerminalPanelTest(): void {
|
||||
})
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
vi.stubGlobal('HTMLElement', class {})
|
||||
vi.stubGlobal(
|
||||
'MutationObserver',
|
||||
class {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('localStorage', localStorage)
|
||||
// Provide focus/listener defaults; tests may replace activeElement while retaining effect-safe listeners.
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: null,
|
||||
addEventListener: vi.fn(),
|
||||
documentElement: {},
|
||||
querySelectorAll: vi.fn(() => []),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ function PickerListbox({
|
||||
<div
|
||||
id={id}
|
||||
role="listbox"
|
||||
data-focus-behavior="composer-suggestions"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: bounds.left,
|
||||
@@ -371,15 +372,14 @@ export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({
|
||||
}): React.JSX.Element {
|
||||
useUiLanguageRevision()
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { files } = mention
|
||||
const selectedIndex = Math.min(activeIndex, files.length - 1)
|
||||
const selectedIndex = Math.min(activeIndex, mention.files.length - 1)
|
||||
|
||||
useEffect(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex, files])
|
||||
}, [selectedIndex, mention.files])
|
||||
|
||||
const statusText =
|
||||
files.length > 0
|
||||
mention.files.length > 0
|
||||
? null
|
||||
: mention.loading
|
||||
? translate('components.native-chat.composer.loadingFiles', 'Loading files...')
|
||||
@@ -389,7 +389,7 @@ export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({
|
||||
|
||||
return (
|
||||
<PickerListbox id={listboxId}>
|
||||
{files.map((path, index) => (
|
||||
{mention.files.map((path, index) => (
|
||||
<PickerOptionButton
|
||||
key={path}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { Copy, ExternalLink, FileText, Globe, Quote, Search, Undo2 } from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
|
||||
import { formatOptionalPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
@@ -6,9 +5,7 @@ import { useAppStore } from '@/store'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import type { LinkAction } from '@/components/link-actions/link-action-request'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import type { NativeChatContextMenuTarget } from './native-chat-context-menu-target'
|
||||
import { formatNativeChatQuote } from './native-chat-quote-selection'
|
||||
import type { NativeChatRewindSurface } from './use-native-chat-rewind'
|
||||
|
||||
function copyText(text: string): void {
|
||||
@@ -19,11 +16,11 @@ function copyText(text: string): void {
|
||||
/** The rows for what was right-clicked: each reuses the action its hover or click surface runs. */
|
||||
export function NativeChatContextMenuTargetItems({
|
||||
target,
|
||||
composerRef,
|
||||
onQuote,
|
||||
rewind
|
||||
}: {
|
||||
target: NativeChatContextMenuTarget
|
||||
composerRef: RefObject<NativeChatComposerHandle | null>
|
||||
onQuote: (text: string) => void
|
||||
rewind: NativeChatRewindSurface | undefined
|
||||
}): React.JSX.Element {
|
||||
const { quoteText, link, codeText, messageText, rewindItemId } = target
|
||||
@@ -31,9 +28,7 @@ export function NativeChatContextMenuTargetItems({
|
||||
return (
|
||||
<>
|
||||
{quoteText ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => composerRef.current?.appendText(formatNativeChatQuote(quoteText))}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => onQuote(quoteText)}>
|
||||
<Quote />
|
||||
{translate('components.native-chat.addSelectionToChat', 'Add to chat')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import {
|
||||
writeNativeChatComposerText,
|
||||
type NativeChatComposerInput
|
||||
} from './native-chat-composer-input'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function editor(inputRef: React.RefObject<NativeChatComposerInput | null>) {
|
||||
return (
|
||||
<NativeChatPromptEditor
|
||||
inputRef={inputRef}
|
||||
initialValue="Draft"
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function setup() {
|
||||
vi.useFakeTimers()
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
render(
|
||||
<>
|
||||
{editor(inputRef)}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
|
||||
<DropdownMenuContent id="launcher-menu">
|
||||
<input
|
||||
role="combobox"
|
||||
aria-label="Search launcher"
|
||||
aria-controls="launcher-menu"
|
||||
aria-expanded="true"
|
||||
/>
|
||||
<DropdownMenuItem>New Terminal</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
act(() => inputRef.current!.focus())
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'New tab' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
pointerType: 'mouse'
|
||||
})
|
||||
act(() => screen.getByRole('combobox', { name: 'Search launcher' }).focus())
|
||||
return inputRef.current!
|
||||
}
|
||||
|
||||
describe('composer focus while the quick launcher is open', () => {
|
||||
it('keeps launcher search focused when a late composer focus call arrives', async () => {
|
||||
const input = setup()
|
||||
const search = screen.getByRole('combobox', { name: 'Search launcher' })
|
||||
expect(document.activeElement).toBe(search)
|
||||
|
||||
await act(async () => {
|
||||
input.focus()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
})
|
||||
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(document.activeElement).toBe(search)
|
||||
})
|
||||
|
||||
it('updates a returned draft without taking focus from the launcher', async () => {
|
||||
const input = setup()
|
||||
const search = screen.getByRole('combobox', { name: 'Search launcher' })
|
||||
|
||||
await act(async () => {
|
||||
writeNativeChatComposerText(input, 'Returned message', 16)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
})
|
||||
|
||||
expect(input.value).toBe('Returned message')
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(document.activeElement).toBe(search)
|
||||
})
|
||||
|
||||
it('allows composer focus again after the launcher closes', async () => {
|
||||
const input = setup()
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
await act(async () => vi.runOnlyPendingTimersAsync())
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
act(() => input.focus())
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Message' }))
|
||||
})
|
||||
|
||||
it('allows a launch prompt inside its own popover to receive focus', () => {
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
render(
|
||||
<Popover defaultOpen>
|
||||
<PopoverTrigger>Launch agent</PopoverTrigger>
|
||||
<PopoverContent>{editor(inputRef)}</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
act(() => inputRef.current!.focus())
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Message' }))
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
NativeChatPromptEditorProps
|
||||
} from './native-chat-composer-input'
|
||||
import { NativeChatImageRowProvider } from './NativeChatImageReferencePill'
|
||||
import { mountedEditorView } from './native-chat-mounted-editor-view'
|
||||
import { focusMountedNativeChatEditor, mountedEditorView } from './native-chat-mounted-editor-view'
|
||||
import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations'
|
||||
|
||||
import { useNativeChatComposerMount } from './native-chat-composer-mount'
|
||||
@@ -314,16 +314,7 @@ function NativePromptEditor({
|
||||
get selectionEnd() {
|
||||
return promptTextOffset(editor.state.doc, editor.state.selection.to)
|
||||
},
|
||||
focus: () => {
|
||||
// A focus request can arrive before the view mounts (a chat opened while its
|
||||
// workspace switches in); tiptap throws then, so wait for the next reveal instead.
|
||||
const view = mountedEditorView(editor)
|
||||
// Focus the editor view so an inserted caret replaces the stale DOM selection.
|
||||
view?.focus()
|
||||
if (view && !editor.isEditable) {
|
||||
view.dom.focus()
|
||||
}
|
||||
},
|
||||
focus: () => focusMountedNativeChatEditor(editor),
|
||||
contains: (node) => mountedEditorView(editor)?.dom.contains(node) === true,
|
||||
isCaretOnVisualEdge: (edge) => {
|
||||
const { selection, doc } = editor.state
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
captureNativeChatComposerFocusIntent,
|
||||
registerNativeChatComposerReclaim,
|
||||
type NativeChatComposerFocusIntent
|
||||
} from './native-chat-composer-focus-intent'
|
||||
|
||||
let intent: NativeChatComposerFocusIntent | undefined
|
||||
let unregister: (() => void) | undefined
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
|
||||
afterEach(() => {
|
||||
intent?.cancel()
|
||||
unregister?.()
|
||||
document.body.replaceChildren()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountDestination(): HTMLTextAreaElement {
|
||||
const composer = document.createElement('textarea')
|
||||
document.body.append(composer)
|
||||
unregister = registerNativeChatComposerReclaim('destination', () => composer.focus(), composer)
|
||||
return composer
|
||||
}
|
||||
|
||||
function mountSource(role: string): HTMLDivElement {
|
||||
const source = document.createElement('div')
|
||||
source.setAttribute('role', role)
|
||||
source.tabIndex = -1
|
||||
document.body.append(source)
|
||||
source.focus()
|
||||
intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('destination')
|
||||
expect(document.activeElement).toBe(source)
|
||||
return source
|
||||
}
|
||||
|
||||
describe('custom overlay launch handoff', () => {
|
||||
it.each(['dialog', 'menu'])(
|
||||
'delivers to an already registered destination when a custom %s is removed',
|
||||
async (role) => {
|
||||
const composer = mountDestination()
|
||||
const source = mountSource(role)
|
||||
|
||||
source.remove()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(document.activeElement).toBe(composer)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['display', 'visibility', 'class', 'hidden'])(
|
||||
'delivers when a retained source is hidden via %s',
|
||||
async (dismissal) => {
|
||||
const stylesheet = document.createElement('style')
|
||||
stylesheet.textContent = '.dismissed-overlay, [hidden] { display: none; }'
|
||||
document.body.append(stylesheet)
|
||||
const composer = mountDestination()
|
||||
const source = mountSource('dialog')
|
||||
|
||||
if (dismissal === 'display') {
|
||||
source.style.display = 'none'
|
||||
}
|
||||
if (dismissal === 'visibility') {
|
||||
source.style.visibility = 'hidden'
|
||||
}
|
||||
if (dismissal === 'class') {
|
||||
source.className = 'dismissed-overlay'
|
||||
}
|
||||
if (dismissal === 'hidden') {
|
||||
source.hidden = true
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(source.isConnected).toBe(true)
|
||||
expect(document.activeElement).toBe(composer)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for a source that is still painted during its exit animation', async () => {
|
||||
const composer = mountDestination()
|
||||
const source = mountSource('dialog')
|
||||
source.dataset.state = 'closed'
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).toBe(source)
|
||||
|
||||
source.remove()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).toBe(composer)
|
||||
})
|
||||
|
||||
it('releases a hidden source when delivery is requested before mutation notification', () => {
|
||||
const composer = mountDestination()
|
||||
const source = mountSource('menu')
|
||||
|
||||
source.style.display = 'none'
|
||||
intent?.request('destination')
|
||||
|
||||
expect(source.isConnected).toBe(true)
|
||||
expect(document.activeElement).toBe(composer)
|
||||
})
|
||||
|
||||
it('keeps an accessibility-hidden parent source until it actually dismisses', async () => {
|
||||
const composer = mountDestination()
|
||||
const source = mountSource('dialog')
|
||||
source.setAttribute('aria-hidden', 'true')
|
||||
source.className = 'still-open'
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).toBe(source)
|
||||
|
||||
source.style.display = 'none'
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).toBe(composer)
|
||||
})
|
||||
|
||||
it('cancels when a newer custom overlay appears before dismissal', async () => {
|
||||
const composer = mountDestination()
|
||||
const source = mountSource('menu')
|
||||
const newer = document.createElement('div')
|
||||
newer.setAttribute('role', 'dialog')
|
||||
newer.tabIndex = -1
|
||||
document.body.append(newer)
|
||||
newer.focus()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).toBe(newer)
|
||||
|
||||
source.remove()
|
||||
newer.remove()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(document.activeElement).not.toBe(composer)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
getOverlayFocusOwners,
|
||||
getOverlayFocusSources,
|
||||
isOverlayFocusSourceDismissed,
|
||||
overlayFocusAppliesTo,
|
||||
overlayFocusContains,
|
||||
subscribeOverlayFocusChanges
|
||||
} from '@/lib/overlay-focus-ownership'
|
||||
|
||||
const PENDING_FOCUS_MS = 1000
|
||||
|
||||
type ComposerReclaim = (
|
||||
onlyIfFocusUnclaimed?: boolean,
|
||||
sourceOverlays?: readonly HTMLElement[]
|
||||
) => void
|
||||
const revealedComposerReclaims = new Map<
|
||||
string,
|
||||
{ reclaim: ComposerReclaim; root: HTMLElement | null }
|
||||
>()
|
||||
type PendingComposerFocusRequest = {
|
||||
tabId: string | null
|
||||
cancel: () => void
|
||||
deliver: () => void
|
||||
}
|
||||
let pendingFocusRequest: PendingComposerFocusRequest | null = null
|
||||
let focusRequestGeneration = 0
|
||||
|
||||
export type NativeChatComposerFocusIntent = {
|
||||
request: (tabId: string) => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function getNativeChatComposerFocusRequestGeneration(): number {
|
||||
return focusRequestGeneration
|
||||
}
|
||||
|
||||
export function registerNativeChatComposerReclaim(
|
||||
tabId: string,
|
||||
reclaim: ComposerReclaim,
|
||||
root: HTMLElement | null
|
||||
): () => void {
|
||||
const destination = { reclaim, root }
|
||||
revealedComposerReclaims.set(tabId, destination)
|
||||
if (pendingFocusRequest?.tabId === tabId) {
|
||||
pendingFocusRequest.deliver()
|
||||
}
|
||||
return () => {
|
||||
if (revealedComposerReclaims.get(tabId) === destination) {
|
||||
revealedComposerReclaims.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a new-chat action's focus intent through source dismissal, destination claim, or expiry. */
|
||||
export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent {
|
||||
focusRequestGeneration += 1
|
||||
pendingFocusRequest?.cancel()
|
||||
if (typeof document === 'undefined') {
|
||||
return { request: () => undefined, cancel: () => undefined }
|
||||
}
|
||||
const sourceOverlays = getOverlayFocusSources()
|
||||
const cancel = (): void => {
|
||||
if (pendingFocusRequest !== pending) {
|
||||
return
|
||||
}
|
||||
pendingFocusRequest = null
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
document.removeEventListener('pointerdown', cancel, true)
|
||||
document.removeEventListener('keydown', cancelForNavigation, true)
|
||||
}
|
||||
const cancelForNavigation = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Tab') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
const deliver = (): void => {
|
||||
if (pendingFocusRequest !== pending) {
|
||||
return
|
||||
}
|
||||
const destination = pending.tabId ? revealedComposerReclaims.get(pending.tabId) : undefined
|
||||
const sources = sourceOverlays.filter(
|
||||
(owner) =>
|
||||
!destination?.root ||
|
||||
(!overlayFocusContains(owner, destination.root) &&
|
||||
overlayFocusAppliesTo(owner, destination.root))
|
||||
)
|
||||
if (
|
||||
getOverlayFocusOwners(destination?.root ?? undefined).some(
|
||||
(owner) => !sourceOverlays.includes(owner)
|
||||
)
|
||||
) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (sources.some((owner) => !isOverlayFocusSourceDismissed(owner))) {
|
||||
return
|
||||
}
|
||||
if (destination) {
|
||||
cancel()
|
||||
destination.reclaim(false, sources)
|
||||
}
|
||||
}
|
||||
const pending: PendingComposerFocusRequest = { tabId: null, cancel, deliver }
|
||||
const unsubscribe = subscribeOverlayFocusChanges(deliver)
|
||||
const timeout = setTimeout(cancel, PENDING_FOCUS_MS)
|
||||
pendingFocusRequest = pending
|
||||
document.addEventListener('pointerdown', cancel, true)
|
||||
document.addEventListener('keydown', cancelForNavigation, true)
|
||||
return {
|
||||
request: (tabId) => {
|
||||
if (pendingFocusRequest !== pending) {
|
||||
return
|
||||
}
|
||||
pending.tabId = tabId
|
||||
deliver()
|
||||
},
|
||||
cancel
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */
|
||||
export function requestNativeChatComposerFocus(
|
||||
tabId: string,
|
||||
options: { onlyIfFocusUnclaimed?: boolean } = {}
|
||||
): void {
|
||||
if (options.onlyIfFocusUnclaimed) {
|
||||
// A rename return is one-shot, not an activation to replay after a hidden composer reveals.
|
||||
revealedComposerReclaims.get(tabId)?.reclaim(true)
|
||||
return
|
||||
}
|
||||
captureNativeChatComposerFocusIntent().request(tabId)
|
||||
}
|
||||
+379
-3
@@ -1,16 +1,24 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { act, createElement, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, createElement, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import {
|
||||
captureNativeChatComposerFocusIntent,
|
||||
requestNativeChatComposerFocus,
|
||||
useNativeChatComposerRevealFocus
|
||||
} from './use-native-chat-composer-reveal-focus'
|
||||
import {
|
||||
handleOverlayCloseAutoFocus,
|
||||
registerOverlayFocusOwner
|
||||
} from '@/lib/overlay-focus-ownership'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||
import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus'
|
||||
import type { NativeChatComposerHandle } from './NativeChatComposer'
|
||||
|
||||
type HarnessProps = {
|
||||
tabId?: string
|
||||
suggestions?: 'slash' | 'mention'
|
||||
isVisible: boolean
|
||||
isFocusedGroup: boolean
|
||||
composerReady?: boolean
|
||||
@@ -79,7 +87,35 @@ function Harness(props: HarnessProps): React.JSX.Element {
|
||||
createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }),
|
||||
createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }),
|
||||
createElement('input', { 'data-testid': 'find-input' }),
|
||||
createElement('input', { 'data-testid': 'question-input' })
|
||||
createElement('input', { 'data-testid': 'question-input' }),
|
||||
props.suggestions === 'slash' ? (
|
||||
<NativeChatPickerMenu
|
||||
autocomplete={{
|
||||
mode: 'slash',
|
||||
prefix: '/',
|
||||
query: '',
|
||||
triggerKey: '/',
|
||||
items: [],
|
||||
dispatchable: true,
|
||||
grouped: false,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: false,
|
||||
skillStatus: 'ready'
|
||||
}}
|
||||
activeIndex={0}
|
||||
listboxId="slash-suggestions"
|
||||
onChoose={() => {}}
|
||||
onRetry={() => {}}
|
||||
/>
|
||||
) : null,
|
||||
props.suggestions === 'mention' ? (
|
||||
<NativeChatMentionMenu
|
||||
mention={{ files: [], loading: false, failed: false }}
|
||||
activeIndex={0}
|
||||
listboxId="mention-suggestions"
|
||||
onChoose={() => {}}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,6 +125,26 @@ function render(props: HarnessProps): void {
|
||||
})
|
||||
}
|
||||
|
||||
function mountOverlay(role: 'menu' | 'dialog' = 'menu'): HTMLDivElement {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.setAttribute('role', role)
|
||||
overlay.setAttribute('data-state', 'open')
|
||||
overlay.tabIndex = -1
|
||||
document.body.appendChild(overlay)
|
||||
overlay.focus()
|
||||
return overlay
|
||||
}
|
||||
|
||||
function dismissOverlay(overlay: HTMLElement, remove = true): void {
|
||||
overlay.setAttribute('data-state', 'closed')
|
||||
const event = new Event('focusScope.autoFocusOnUnmount')
|
||||
overlay.addEventListener(event.type, handleOverlayCloseAutoFocus, { once: true })
|
||||
overlay.dispatchEvent(event)
|
||||
if (remove) {
|
||||
overlay.remove()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
@@ -315,6 +371,326 @@ describe('useNativeChatComposerRevealFocus', () => {
|
||||
outside.remove()
|
||||
})
|
||||
|
||||
it.each(['menu', 'dialog'] as const)(
|
||||
'preserves a newer %s when the composer becomes ready',
|
||||
(role) => {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('chat-tab')
|
||||
const overlay = mountOverlay(role)
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: true })
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(overlay)
|
||||
expect(focusCalls).toBe(0)
|
||||
overlay.remove()
|
||||
render({ isVisible: true, isFocusedGroup: true, nonce: 1 })
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ dismissal: 'removed', readyAfterAttempts: 0 },
|
||||
{ dismissal: 'closed', readyAfterAttempts: 0 },
|
||||
{ dismissal: 'closed', readyAfterAttempts: 1 }
|
||||
])(
|
||||
'hands launch focus to a ready composer ($dismissal, retries: $readyAfterAttempts)',
|
||||
async ({ dismissal, readyAfterAttempts }) => {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const menu = mountOverlay()
|
||||
const search = document.createElement('input')
|
||||
menu.appendChild(search)
|
||||
search.focus()
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('chat-tab')
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true, readyAfterAttempts })
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(focusCalls).toBe(0)
|
||||
|
||||
await act(async () => {
|
||||
dismissOverlay(menu, dismissal === 'removed')
|
||||
})
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'hands launch focus to a late composer (tab published after dismissal: %s)',
|
||||
async (lateTab) => {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const menu = mountOverlay()
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
if (!lateTab) {
|
||||
intent.request('chat-tab')
|
||||
}
|
||||
await act(async () => dismissOverlay(menu))
|
||||
if (lateTab) {
|
||||
intent.request('chat-tab')
|
||||
}
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for both the launch menu and its portaled setup to dismiss', async () => {
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
const menu = mountOverlay()
|
||||
const setup = mountOverlay('dialog')
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
act(() => intent.request('chat-tab'))
|
||||
drainFrames()
|
||||
const before = focusCalls
|
||||
|
||||
await act(async () => dismissOverlay(menu))
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(setup)
|
||||
expect(focusCalls).toBe(before)
|
||||
|
||||
await act(async () => dismissOverlay(setup))
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
})
|
||||
|
||||
it.each(['menu', 'dialog'] as const)(
|
||||
'cancels a launch handoff when a newer %s opens before the source closes',
|
||||
async (role) => {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const source = mountOverlay()
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('chat-tab')
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
|
||||
let newer: HTMLDivElement
|
||||
await act(async () => {
|
||||
newer = mountOverlay(role)
|
||||
registerOverlayFocusOwner(newer)
|
||||
})
|
||||
await act(async () => {
|
||||
source.remove()
|
||||
newer.remove()
|
||||
})
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(focusCalls).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['pointer', 'Tab', 'expiry'] as const)(
|
||||
'cancels a source menu handoff after %s',
|
||||
async (cancellation) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const menu = mountOverlay()
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('chat-tab')
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
|
||||
if (cancellation === 'pointer') {
|
||||
document.dispatchEvent(new Event('pointerdown'))
|
||||
} else if (cancellation === 'Tab') {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
|
||||
} else {
|
||||
act(() => vi.advanceTimersByTime(1000))
|
||||
}
|
||||
await act(async () => dismissOverlay(menu))
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(focusCalls).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels queued focus recovery when a menu opens between frames', () => {
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames(1)
|
||||
const menu = mountOverlay()
|
||||
|
||||
drainFrames(1)
|
||||
|
||||
expect(document.activeElement).toBe(menu)
|
||||
expect(focusCalls).toBe(1)
|
||||
menu.remove()
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'yields an immediate focus request to a menu (onlyIfFocusUnclaimed: %s)',
|
||||
(onlyIfFocusUnclaimed) => {
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
const menu = mountOverlay()
|
||||
menu.blur()
|
||||
|
||||
act(() => requestNativeChatComposerFocus('chat-tab', { onlyIfFocusUnclaimed }))
|
||||
menu.remove()
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(focusCalls).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['slash', 'mention'] as const)(
|
||||
'reclaims focus after switching away from %s suggestions',
|
||||
(mode) => {
|
||||
render({ isVisible: true, isFocusedGroup: true, suggestions: mode })
|
||||
drainFrames()
|
||||
expect(document.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
render({ isVisible: false, isFocusedGroup: true, suggestions: mode })
|
||||
const tab = document.createElement('button')
|
||||
document.body.append(tab)
|
||||
tab.focus()
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true, suggestions: mode })
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
tab.focus()
|
||||
act(() => requestNativeChatComposerFocus('chat-tab'))
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
}
|
||||
)
|
||||
|
||||
it('launches a floating chat above persistent Skills content while protecting a nested popup', async () => {
|
||||
const page = document.createElement('div')
|
||||
page.setAttribute('role', 'listbox')
|
||||
page.dataset.focusBehavior = 'page'
|
||||
document.body.append(page)
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
const intent = captureNativeChatComposerFocusIntent()
|
||||
intent.request('chat-tab')
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
|
||||
const popup = mountOverlay()
|
||||
page.append(popup)
|
||||
popup.focus()
|
||||
const before = focusCalls
|
||||
render({ isVisible: false, isFocusedGroup: true })
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(popup)
|
||||
expect(focusCalls).toBe(before)
|
||||
await act(async () => dismissOverlay(popup))
|
||||
drainFrames()
|
||||
expect(focusCalls).toBe(before)
|
||||
})
|
||||
|
||||
it('delivers an active-tab handoff after both dashboard scopes dismiss and trigger focus restores', async () => {
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
const sheet = mountOverlay('dialog')
|
||||
registerOverlayFocusOwner(sheet)
|
||||
const preview = mountOverlay('dialog')
|
||||
registerOverlayFocusOwner(preview)
|
||||
sheet.setAttribute('aria-hidden', 'true')
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
act(() => requestNativeChatComposerFocus('chat-tab'))
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(preview)
|
||||
|
||||
await act(async () => {
|
||||
dismissOverlay(preview)
|
||||
sheet.removeAttribute('aria-hidden')
|
||||
})
|
||||
drainFrames()
|
||||
expect(document.activeElement).not.toBe(container.querySelector('textarea'))
|
||||
await act(async () => {
|
||||
dismissOverlay(sheet)
|
||||
trigger.focus()
|
||||
})
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
})
|
||||
|
||||
it('hands composer focus through real sheet and dialog dismissal callbacks', async () => {
|
||||
vi.useFakeTimers()
|
||||
function Dashboard(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Harness isVisible isFocusedGroup />
|
||||
<button onClick={() => setOpen(true)}>Dashboard preview</button>
|
||||
<Sheet open={open} onOpenChange={setOpen} modal={false}>
|
||||
<SheetContent aria-describedby={undefined}>
|
||||
<SheetTitle>Dashboard</SheetTitle>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogTitle>Preview</DialogTitle>
|
||||
<button
|
||||
onClick={() => {
|
||||
requestNativeChatComposerFocus('chat-tab')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Open worktree
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
try {
|
||||
act(() => root.render(<Dashboard />))
|
||||
drainFrames()
|
||||
const composer = container.querySelector('textarea')!
|
||||
await act(async () => {
|
||||
;[...container.querySelectorAll('button')]
|
||||
.find((button) => button.textContent === 'Dashboard preview')!
|
||||
.click()
|
||||
})
|
||||
const action = [...document.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Open worktree'
|
||||
)!
|
||||
await act(async () => action.click())
|
||||
await act(async () => vi.advanceTimersByTime(0))
|
||||
drainFrames()
|
||||
expect(document.activeElement).toBe(composer)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows focus recovery while a dismissed menu animates out', () => {
|
||||
const menu = mountOverlay()
|
||||
menu.setAttribute('data-state', 'closed')
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
})
|
||||
|
||||
it('allows a chat hosted inside a dialog to claim its composer', () => {
|
||||
const dialog = mountOverlay('dialog')
|
||||
dialog.appendChild(container)
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames()
|
||||
|
||||
expect(document.activeElement).toBe(container.querySelector('textarea'))
|
||||
})
|
||||
|
||||
it('does not chase focus after the user moves to a control outside the pane', () => {
|
||||
render({ isVisible: true, isFocusedGroup: true })
|
||||
drainFrames(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership'
|
||||
|
||||
/** The editor's view once mounted; null before mount or after destroy, when tiptap's view throws. */
|
||||
export function mountedEditorView(editor: Editor): EditorView | null {
|
||||
@@ -14,3 +15,15 @@ export function mountedEditorView(editor: Editor): EditorView | null {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function focusMountedNativeChatEditor(editor: Editor): void {
|
||||
const view = mountedEditorView(editor)
|
||||
if (!view || getOverlayFocusOwners(view.dom).length > 0) {
|
||||
return
|
||||
}
|
||||
// Focus the view so an inserted caret replaces the stale DOM selection.
|
||||
view.focus()
|
||||
if (!editor.isEditable) {
|
||||
view.dom.focus()
|
||||
}
|
||||
}
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef, useRef, type RefObject } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { MobileDriverOverlay } from '@/components/terminal-pane/MobileDriverOverlay'
|
||||
import { BrowserMobileDriverOverlay } from '@/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion'
|
||||
import {
|
||||
requestNativeChatComposerFocus,
|
||||
useNativeChatComposerRevealFocus
|
||||
} from './use-native-chat-composer-reveal-focus'
|
||||
|
||||
let frames: (() => void)[] = []
|
||||
const scheduleFrame = (callback: () => void): void => {
|
||||
frames.push(callback)
|
||||
}
|
||||
const noop = (): void => {}
|
||||
|
||||
afterEach(() => {
|
||||
document.dispatchEvent(new Event('pointerdown'))
|
||||
cleanup()
|
||||
frames = []
|
||||
})
|
||||
|
||||
function drainFrames(): void {
|
||||
act(() => {
|
||||
while (frames.length) {
|
||||
const queued = frames
|
||||
frames = []
|
||||
queued.forEach((frame) => frame())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function Chat({
|
||||
inputRef,
|
||||
revealed
|
||||
}: {
|
||||
inputRef: RefObject<NativeChatComposerInput | null>
|
||||
revealed: boolean
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const insertion = useNativeChatTypedInsertion({
|
||||
textareaRef: inputRef,
|
||||
isComposing: () => false,
|
||||
setDraft: noop,
|
||||
setCaret: noop,
|
||||
setActiveSuggestion: noop
|
||||
})
|
||||
const composerRef = useRef<NativeChatComposerHandle>({
|
||||
...insertion,
|
||||
insertAgentReferences: () => false,
|
||||
handlePasteEvent: noop,
|
||||
pasteFromClipboard: noop
|
||||
})
|
||||
useNativeChatComposerRevealFocus({
|
||||
tabId: 'chat',
|
||||
rootRef,
|
||||
composerRef,
|
||||
isVisible: revealed,
|
||||
isFocusedGroup: true,
|
||||
composerReady: true,
|
||||
scheduleFrame
|
||||
})
|
||||
return (
|
||||
<div ref={rootRef}>
|
||||
<NativeChatPromptEditor
|
||||
inputRef={inputRef}
|
||||
initialValue=""
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={noop}
|
||||
onSelect={noop}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Recovery({ browser }: { browser: boolean }) {
|
||||
return browser ? (
|
||||
<BrowserMobileDriverOverlay driver={{ kind: 'mobile', clientId: 'phone' }} onTakeBack={noop} />
|
||||
) : (
|
||||
<MobileDriverOverlay driver={{ kind: 'idle' }} hasFitOverride onAction={noop} />
|
||||
)
|
||||
}
|
||||
|
||||
function setup(browser: boolean, samePane = false) {
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
function Split({ revealed }: { revealed: boolean }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div>
|
||||
<Recovery browser={browser} />
|
||||
{samePane ? <Chat inputRef={inputRef} revealed={revealed} /> : null}
|
||||
</div>
|
||||
{!samePane ? <Chat inputRef={inputRef} revealed={revealed} /> : null}
|
||||
<button onClick={() => requestNativeChatComposerFocus('chat')}>Activate chat</button>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const view = render(<Split revealed={false} />)
|
||||
const restore = screen.getByRole('button', {
|
||||
name: browser ? 'Take back' : 'Restore'
|
||||
})
|
||||
act(() => restore.focus())
|
||||
return {
|
||||
inputRef,
|
||||
restore,
|
||||
reveal: () => view.rerender(<Split revealed />)
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([false, true])('chat focus beside phone recovery (browser: %s)', (browser) => {
|
||||
it('automatically focuses a revealed chat in another pane', () => {
|
||||
const chat = setup(browser)
|
||||
chat.reveal()
|
||||
drainFrames()
|
||||
|
||||
expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true)
|
||||
expect(chat.restore.isConnected).toBe(true)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'activates chat without waiting for another pane’s Restore (already revealed: %s)',
|
||||
(revealed) => {
|
||||
const chat = setup(browser)
|
||||
if (revealed) {
|
||||
chat.reveal()
|
||||
drainFrames()
|
||||
act(() => chat.restore.focus())
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Activate chat' }))
|
||||
if (!revealed) {
|
||||
chat.reveal()
|
||||
}
|
||||
drainFrames()
|
||||
|
||||
expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true)
|
||||
expect(chat.restore.isConnected).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('allows a direct editor focus request from another pane', () => {
|
||||
const chat = setup(browser)
|
||||
|
||||
act(() => chat.inputRef.current?.focus())
|
||||
|
||||
expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps recovery ownership within its own pane', () => {
|
||||
const chat = setup(browser, true)
|
||||
chat.reveal()
|
||||
drainFrames()
|
||||
act(() => chat.inputRef.current?.focus())
|
||||
|
||||
expect(document.activeElement).toBe(chat.restore)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { useRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AgentLaunchPromptComposer } from '../agent/AgentLaunchPromptComposer'
|
||||
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import {
|
||||
emptyNativeChatContextMenuActions,
|
||||
useNativeChatContextMenu
|
||||
} from './use-native-chat-context-menu'
|
||||
import { clearNativeChatComposerDraftsForTests } from './native-chat-composer-draft-store'
|
||||
import { promptEditor, promptValue } from './native-chat-prompt-editor.test-support'
|
||||
|
||||
const entry: AgentPickerEntry = {
|
||||
agent: 'claude',
|
||||
label: 'Claude',
|
||||
rows: [{ agent: 'claude', kind: 'cli', executionHostId: 'local' }],
|
||||
surfaces: ['chat'],
|
||||
defaultSurface: 'chat',
|
||||
terminal: {},
|
||||
structured: {},
|
||||
catalog: { origin: 'unknown' }
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle>(null)
|
||||
const { menu, onContextMenuCapture, onPointerDownCapture } = useNativeChatContextMenu({
|
||||
rootRef,
|
||||
composerRef,
|
||||
showTerminalPaneActions: false,
|
||||
actions: { ...emptyNativeChatContextMenuActions, onPaste: () => {} }
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
onContextMenuCapture={onContextMenuCapture}
|
||||
onPointerDownCapture={onPointerDownCapture}
|
||||
>
|
||||
<div data-native-chat-scroll>
|
||||
<p data-native-chat-quote-source="">Reply to quote</p>
|
||||
</div>
|
||||
<AgentLaunchPromptComposer entry={entry} composerRef={composerRef} onLaunch={() => false} />
|
||||
{menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('places the caret after the quote when Add to chat closes the context menu', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(0)
|
||||
render(<Chat />, { wrapper: TooltipProvider })
|
||||
const editor = document.querySelector<HTMLElement>('[contenteditable][aria-multiline="true"]')!
|
||||
await waitFor(() => expect(promptEditor(editor)).toBeDefined())
|
||||
const reply = screen.getByText('Reply to quote')
|
||||
window.getSelection()!.selectAllChildren(reply)
|
||||
fireEvent.contextMenu(reply)
|
||||
await screen.findByRole('menu')
|
||||
now.mockReturnValue(1000)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Add to chat' }))
|
||||
|
||||
expect(promptValue(editor)).toBe('> Reply to quote\n\n')
|
||||
expect(document.activeElement).toBe(editor)
|
||||
await act(async () => {})
|
||||
expect(document.activeElement).toBe(editor)
|
||||
expect(promptEditor(editor).state.selection.empty).toBe(true)
|
||||
expect(promptEditor(editor).state.selection.to).toBe(
|
||||
promptEditor(editor).state.doc.content.size - 1
|
||||
)
|
||||
})
|
||||
@@ -2,77 +2,22 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers'
|
||||
import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
|
||||
import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership'
|
||||
import type { NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import { registerNativeChatPane } from './native-chat-composer-mount'
|
||||
import {
|
||||
getNativeChatComposerFocusRequestGeneration,
|
||||
registerNativeChatComposerReclaim
|
||||
} from './native-chat-composer-focus-intent'
|
||||
export {
|
||||
captureNativeChatComposerFocusIntent,
|
||||
requestNativeChatComposerFocus,
|
||||
type NativeChatComposerFocusIntent
|
||||
} from './native-chat-composer-focus-intent'
|
||||
|
||||
/** Focus attempts per reveal, including recovery from delayed programmatic restoration. */
|
||||
const REVEAL_FOCUS_FRAMES = 6
|
||||
const PENDING_FOCUS_MS = 1000
|
||||
|
||||
const revealedComposerReclaims = new Map<string, (onlyIfFocusUnclaimed?: boolean) => void>()
|
||||
const mountedComposers = new Set<RefObject<NativeChatComposerHandle | null>>()
|
||||
let pendingFocusRequest: { tabId: string | null; cancel: () => void } | null = null
|
||||
let focusRequestGeneration = 0
|
||||
|
||||
export type NativeChatComposerFocusIntent = {
|
||||
request: (tabId: string) => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/** Keep a new-chat action's focus intent through destination claim, navigation, or expiry. */
|
||||
export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent {
|
||||
focusRequestGeneration += 1
|
||||
pendingFocusRequest?.cancel()
|
||||
if (typeof document === 'undefined') {
|
||||
return { request: () => undefined, cancel: () => undefined }
|
||||
}
|
||||
const cancel = (): void => {
|
||||
if (pendingFocusRequest !== pending) {
|
||||
return
|
||||
}
|
||||
pendingFocusRequest = null
|
||||
clearTimeout(timeout)
|
||||
document.removeEventListener('pointerdown', cancel, true)
|
||||
document.removeEventListener('keydown', cancelForNavigation, true)
|
||||
}
|
||||
const cancelForNavigation = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Tab') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel }
|
||||
const timeout = setTimeout(cancel, PENDING_FOCUS_MS)
|
||||
pendingFocusRequest = pending
|
||||
document.addEventListener('pointerdown', cancel, true)
|
||||
document.addEventListener('keydown', cancelForNavigation, true)
|
||||
return {
|
||||
request: (tabId) => {
|
||||
if (pendingFocusRequest !== pending) {
|
||||
return
|
||||
}
|
||||
pending.tabId = tabId
|
||||
const reclaim = revealedComposerReclaims.get(tabId)
|
||||
if (reclaim) {
|
||||
cancel()
|
||||
reclaim()
|
||||
}
|
||||
},
|
||||
cancel
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */
|
||||
export function requestNativeChatComposerFocus(
|
||||
tabId: string,
|
||||
options: { onlyIfFocusUnclaimed?: boolean } = {}
|
||||
): void {
|
||||
if (options.onlyIfFocusUnclaimed) {
|
||||
// A rename return is one-shot, not an activation to replay after a hidden composer reveals.
|
||||
revealedComposerReclaims.get(tabId)?.(true)
|
||||
return
|
||||
}
|
||||
captureNativeChatComposerFocusIntent().request(tabId)
|
||||
}
|
||||
|
||||
export type NativeChatComposerRevealFocusArgs = {
|
||||
/** The tab whose activation re-runs the claim (the terminal tab for a chat view over a TUI). */
|
||||
@@ -113,6 +58,7 @@ export function useNativeChatComposerRevealFocus({
|
||||
const claimedRef = useRef(false)
|
||||
// An explicit tab activation takes focus from inside the pane too, short of a text field.
|
||||
const explicitRef = useRef(false)
|
||||
const sourceOverlaysRef = useRef<readonly HTMLElement[]>([])
|
||||
const [reclaimCount, setReclaimCount] = useState(0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -126,7 +72,16 @@ export function useNativeChatComposerRevealFocus({
|
||||
if (!revealed || !composerReady) {
|
||||
return
|
||||
}
|
||||
const reclaim = (onlyIfFocusUnclaimed = false): void => {
|
||||
const reclaim = (
|
||||
onlyIfFocusUnclaimed = false,
|
||||
sourceOverlays: readonly HTMLElement[] = []
|
||||
): void => {
|
||||
if (getOverlayFocusOwners(rootRef.current ?? undefined).length > 0) {
|
||||
claimedRef.current = true
|
||||
explicitRef.current = false
|
||||
setReclaimCount((count) => count + 1)
|
||||
return
|
||||
}
|
||||
if (onlyIfFocusUnclaimed) {
|
||||
const root = rootRef.current
|
||||
const active = root?.ownerDocument.activeElement
|
||||
@@ -137,34 +92,29 @@ export function useNativeChatComposerRevealFocus({
|
||||
}
|
||||
claimedRef.current = false
|
||||
explicitRef.current = true
|
||||
sourceOverlaysRef.current = sourceOverlays
|
||||
const active = rootRef.current?.ownerDocument.activeElement ?? null
|
||||
const siblingComposer = [...mountedComposers].some(
|
||||
(ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active)
|
||||
)
|
||||
if (
|
||||
(!shouldPreserveEditableFocus(active) || siblingComposer) &&
|
||||
(!shouldPreserveEditableFocus(active) ||
|
||||
siblingComposer ||
|
||||
sourceOverlays.some((overlay) => overlay.contains(active))) &&
|
||||
composerRef.current?.focus()
|
||||
) {
|
||||
explicitRef.current = false
|
||||
}
|
||||
setReclaimCount((count) => count + 1)
|
||||
}
|
||||
revealedComposerReclaims.set(tabId, reclaim)
|
||||
if (pendingFocusRequest?.tabId === tabId) {
|
||||
pendingFocusRequest.cancel()
|
||||
reclaim()
|
||||
}
|
||||
return () => {
|
||||
if (revealedComposerReclaims.get(tabId) === reclaim) {
|
||||
revealedComposerReclaims.delete(tabId)
|
||||
}
|
||||
}
|
||||
return registerNativeChatComposerReclaim(tabId, reclaim, rootRef.current)
|
||||
}, [composerReady, composerRef, revealed, rootRef, tabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed || !composerReady) {
|
||||
claimedRef.current = false
|
||||
explicitRef.current = false
|
||||
sourceOverlaysRef.current = []
|
||||
return
|
||||
}
|
||||
if (claimedRef.current) {
|
||||
@@ -173,7 +123,7 @@ export function useNativeChatComposerRevealFocus({
|
||||
let cancelled = false
|
||||
let userInteracted = false
|
||||
let attempts = 0
|
||||
const requestGeneration = focusRequestGeneration
|
||||
const requestGeneration = getNativeChatComposerFocusRequestGeneration()
|
||||
const ownerDocument = rootRef.current?.ownerDocument
|
||||
const cancelForPointer = (): void => {
|
||||
userInteracted = true
|
||||
@@ -190,6 +140,7 @@ export function useNativeChatComposerRevealFocus({
|
||||
const finish = (): void => {
|
||||
claimedRef.current = true
|
||||
explicitRef.current = false
|
||||
sourceOverlaysRef.current = []
|
||||
stopWatchingUserIntent()
|
||||
}
|
||||
ownerDocument?.addEventListener('pointerdown', cancelForPointer, true)
|
||||
@@ -198,11 +149,11 @@ export function useNativeChatComposerRevealFocus({
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (requestGeneration !== focusRequestGeneration) {
|
||||
if (requestGeneration !== getNativeChatComposerFocusRequestGeneration()) {
|
||||
stopWatchingUserIntent()
|
||||
return
|
||||
}
|
||||
if (userInteracted) {
|
||||
if (userInteracted || getOverlayFocusOwners(rootRef.current ?? undefined).length > 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -221,7 +172,9 @@ export function useNativeChatComposerRevealFocus({
|
||||
explicitRef.current &&
|
||||
rootRef.current?.contains(active) !== true &&
|
||||
[...mountedComposers].some((ownerRef) => ownerRef.current?.contains(active))
|
||||
if (shouldPreserveEditableFocus(active) && !editingSiblingComposer) {
|
||||
const editingSourceOverlay =
|
||||
explicitRef.current && sourceOverlaysRef.current.some((overlay) => overlay.contains(active))
|
||||
if (shouldPreserveEditableFocus(active) && !editingSiblingComposer && !editingSourceOverlay) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PointerEventHandler,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { chatMathSelectionText } from '../sidebar/chat-markdown-math-selection'
|
||||
import { Clipboard, Copy, GitFork, Image as ImageIcon, SquareTerminal } from 'lucide-react'
|
||||
import {
|
||||
@@ -30,7 +31,10 @@ import { isEditableTarget } from '@/lib/editable-target'
|
||||
import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste'
|
||||
import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem'
|
||||
import { NativeChatSelectionQuote } from './NativeChatSelectionQuote'
|
||||
import { readNativeChatTranscriptSelection } from './native-chat-quote-selection'
|
||||
import {
|
||||
formatNativeChatQuote,
|
||||
readNativeChatTranscriptSelection
|
||||
} from './native-chat-quote-selection'
|
||||
import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems'
|
||||
import { NativeChatStructuredPaneMenuItems } from './NativeChatStructuredPaneMenuItems'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
@@ -295,7 +299,10 @@ export function useNativeChatContextMenu({
|
||||
) : null}
|
||||
<NativeChatContextMenuTargetItems
|
||||
target={target}
|
||||
composerRef={composerRef}
|
||||
onQuote={(text) => {
|
||||
flushSync(() => setState((prev) => ({ ...prev, open: false })))
|
||||
composerRef.current?.appendText(formatNativeChatQuote(text))
|
||||
}}
|
||||
rewind={rewind}
|
||||
/>
|
||||
{state.canPaste ? (
|
||||
|
||||
@@ -15,7 +15,8 @@ export function SmartWorkspaceSourceResultList({
|
||||
isLinked,
|
||||
beforeResults,
|
||||
emptyHint,
|
||||
isRowDisabled
|
||||
isRowDisabled,
|
||||
focusBehavior
|
||||
}: {
|
||||
model: Pick<
|
||||
WorkItemSourceSearch,
|
||||
@@ -40,6 +41,7 @@ export function SmartWorkspaceSourceResultList({
|
||||
beforeResults?: React.ReactNode
|
||||
emptyHint?: string
|
||||
isRowDisabled?: (row: RowEntry) => boolean
|
||||
focusBehavior?: 'page'
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
mode,
|
||||
@@ -82,7 +84,7 @@ export function SmartWorkspaceSourceResultList({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<CommandList className="!max-h-none min-h-0 flex-1">
|
||||
<CommandList className="!max-h-none min-h-0 flex-1" data-focus-behavior={focusBehavior}>
|
||||
{beforeResults}
|
||||
{typedTextActionRow ? (
|
||||
<div
|
||||
|
||||
@@ -43,7 +43,11 @@ export function CrewRowMenu({
|
||||
onPointerDownOutside={menu.onPointerDownOutside}
|
||||
onCloseAutoFocus={menu.onCloseAutoFocus}
|
||||
>
|
||||
<DropdownMenuItem disabled={!canMention || !row.sendAddress} onSelect={mention}>
|
||||
<DropdownMenuItem
|
||||
disabled={!canMention || !row.sendAddress}
|
||||
// Let Radix dismiss the source before the composer claims focus.
|
||||
onSelect={() => queueMicrotask(mention)}
|
||||
>
|
||||
{translate('components.orchestrationCrew.mentionInMessage', 'Mention in message')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={open}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { createRef, type ReactNode } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership'
|
||||
import type { JiraConnectionStatus, JiraIssue, JiraSite } from '../../../../shared/jira/jira-types'
|
||||
import type { LinearIssue } from '../../../../shared/linear/issue-types'
|
||||
import type {
|
||||
@@ -186,6 +187,14 @@ describe('workspace reference URL resolution and recovery', () => {
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
it('keeps inline attachment results from claiming composer focus', () => {
|
||||
render(composer(jiraUrl))
|
||||
|
||||
const results = screen.getByRole('listbox')
|
||||
expect(results.getClientRects().length).toBeGreaterThan(0)
|
||||
expect(getOverlayFocusOwners()).not.toContain(results)
|
||||
})
|
||||
|
||||
it('blocks pasted Jira URLs while loading, including plain Enter', () => {
|
||||
Object.assign(sourceMock.jiraState, { intent: true, loading: true })
|
||||
render(composer(jiraUrl))
|
||||
|
||||
@@ -258,6 +258,7 @@ export function WorkspaceAttachmentSearch({
|
||||
{query.trim() ? (
|
||||
<div className="mt-2 flex max-h-60 min-h-0 shrink flex-col overflow-hidden">
|
||||
<SmartWorkspaceSourceResultList
|
||||
focusBehavior="page"
|
||||
// Jira URL status owns its message; a Linear read of that URL cannot diagnose it.
|
||||
model={
|
||||
taskUrlProvider === 'jira' ? { ...controller, linearReadError: null } : controller
|
||||
|
||||
@@ -148,6 +148,7 @@ export function SkillsList({
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
data-focus-behavior="page"
|
||||
aria-multiselectable={selectionMode !== null || undefined}
|
||||
aria-label={translate('auto.components.skills.SkillsList.listLabel', 'Skills')}
|
||||
aria-orientation="vertical"
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useSkillsPageKeyboardNavigation({
|
||||
return
|
||||
}
|
||||
// Why: menus and dialogs own Escape before page-level navigation.
|
||||
if (hasVisibleOverlay({ ignoreSelector: '[data-skills-page-list="true"]' })) {
|
||||
if (hasVisibleOverlay()) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
|
||||
@@ -215,6 +215,7 @@ function LoudOverlay({
|
||||
<div
|
||||
ref={setRootRef}
|
||||
role="dialog"
|
||||
data-overlay-focus-scope="parent"
|
||||
aria-live="assertive"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SearchIcon } from 'lucide-react'
|
||||
import * as DialogPrimitive from 'radix-ui/dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
import { useImeTextFieldProps } from '@/lib/ime-text-field'
|
||||
import { DialogOverlay } from '@/components/ui/dialog'
|
||||
@@ -74,40 +76,44 @@ function CommandDialog({
|
||||
surface?: 'default' | 'palette'
|
||||
commandProps?: React.ComponentProps<typeof CommandPrimitive>
|
||||
}) {
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope()
|
||||
const { className: commandClassName, ...commandRootProps } = commandProps ?? {}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root {...props}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="command-dialog-content"
|
||||
// Match DialogContent’s surface/border/shadow recipe so the palette remains distinct in dark mode.
|
||||
className={cn(
|
||||
'fixed top-[20%] left-[50%] z-50 w-[660px] max-w-[90vw] translate-x-[-50%] rounded-lg border border-black/14 bg-dialog-surface text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] outline-none dark:border-white/14 dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
surface === 'palette' &&
|
||||
'rounded-xl border-border/70 shadow-[0_26px_84px_rgba(0,0,0,0.32)]',
|
||||
contentClassName
|
||||
)}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event)}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
<Command
|
||||
shouldFilter={shouldFilter}
|
||||
<OverlayFocusScope value={scope}>
|
||||
<DialogPrimitive.Content
|
||||
data-slot="command-dialog-content"
|
||||
ref={focusScopeRef}
|
||||
// Match DialogContent’s surface/border/shadow recipe so the palette remains distinct in dark mode.
|
||||
className={cn(
|
||||
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3',
|
||||
commandClassName
|
||||
'fixed top-[20%] left-[50%] z-50 w-[660px] max-w-[90vw] translate-x-[-50%] rounded-lg border border-black/14 bg-dialog-surface text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] outline-none dark:border-white/14 dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
surface === 'palette' &&
|
||||
'rounded-xl border-border/70 shadow-[0_26px_84px_rgba(0,0,0,0.32)]',
|
||||
contentClassName
|
||||
)}
|
||||
{...commandRootProps}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event)}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, onCloseAutoFocus)}
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
<Command
|
||||
shouldFilter={shouldFilter}
|
||||
className={cn(
|
||||
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3',
|
||||
commandClassName
|
||||
)}
|
||||
{...commandRootProps}
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</OverlayFocusScope>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import * as ContextMenuPrimitive from 'radix-ui/context-menu'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
import { keepFocusWhileMenuCloses } from './menu-item-closing-focus'
|
||||
import { useMenuPointerHighlight } from './menu-pointer-highlight'
|
||||
@@ -72,23 +74,27 @@ function ContextMenuSubContent({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent> &
|
||||
VariantProps<typeof contentAnimationVariants>) {
|
||||
const pointerHighlight = useMenuPointerHighlight(props)
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
// Level the first submenu row with its trigger (border + padding).
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
|
||||
contentAnimationVariants({ animation }),
|
||||
className
|
||||
)}
|
||||
// Portal submenus outside the scrollable parent to prevent overflow clipping.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
// Level the first submenu row with its trigger (border + padding).
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
|
||||
contentAnimationVariants({ animation }),
|
||||
className
|
||||
)}
|
||||
// Portal submenus outside the scrollable parent to prevent overflow clipping.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
ref={focusScopeRef}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
</OverlayFocusScope>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
@@ -101,21 +107,26 @@ function ContextMenuContent({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> &
|
||||
VariantProps<typeof contentAnimationVariants>) {
|
||||
const pointerHighlight = useMenuPointerHighlight(props)
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'z-[70] max-h-(--radix-context-menu-content-available-height) min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
|
||||
contentAnimationVariants({ animation }),
|
||||
className
|
||||
)}
|
||||
// Disable native dragging so overlapping titlebar regions cannot intercept menu clicks.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'z-[70] max-h-(--radix-context-menu-content-available-height) min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
|
||||
contentAnimationVariants({ animation }),
|
||||
className
|
||||
)}
|
||||
// Disable native dragging so overlapping titlebar regions cannot intercept menu clicks.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
{...pointerHighlight}
|
||||
ref={focusScopeRef}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
</OverlayFocusScope>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Minus, XIcon } from 'lucide-react'
|
||||
import * as DialogPrimitive from 'radix-ui/dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -106,6 +108,7 @@ function DialogContent({
|
||||
/** The whole dialog scrolls within the viewport instead of growing past it. */
|
||||
scrollable?: boolean
|
||||
}) {
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref)
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
@@ -120,10 +123,14 @@ function DialogContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={focusScopeRef}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && <DialogContentCloseButton variant={closeButtonVariant} />}
|
||||
<OverlayFocusScope value={scope}>
|
||||
{children}
|
||||
{showCloseButton && <DialogContentCloseButton variant={closeButtonVariant} />}
|
||||
</OverlayFocusScope>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
import { keepFocusWhileMenuCloses } from './menu-item-closing-focus'
|
||||
import { useMenuPointerHighlight } from './menu-pointer-highlight'
|
||||
@@ -30,21 +32,26 @@ function DropdownMenuContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
const pointerHighlight = useMenuPointerHighlight(props)
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
|
||||
className
|
||||
)}
|
||||
// Disable native dragging: titlebar drag regions intercept menu clicks regardless of z-index.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
|
||||
className
|
||||
)}
|
||||
// Disable native dragging: titlebar drag regions intercept menu clicks regardless of z-index.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
{...pointerHighlight}
|
||||
ref={focusScopeRef}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
</OverlayFocusScope>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
@@ -220,24 +227,28 @@ function DropdownMenuSubContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent> & { scrollable?: boolean }) {
|
||||
const pointerHighlight = useMenuPointerHighlight(props)
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
// Level the first submenu row with its trigger (border + padding).
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
|
||||
scrollable &&
|
||||
'max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto scrollbar-sleek',
|
||||
className
|
||||
)}
|
||||
// Disable native dragging where submenu content overlaps the titlebar.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
// Level the first submenu row with its trigger (border + padding).
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
|
||||
scrollable &&
|
||||
'max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto scrollbar-sleek',
|
||||
className
|
||||
)}
|
||||
// Disable native dragging where submenu content overlaps the titlebar.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
{...pointerHighlight}
|
||||
ref={focusScopeRef}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
</OverlayFocusScope>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as React from 'react'
|
||||
import * as PopoverPrimitive from 'radix-ui/popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
|
||||
// React delegates wheel passively, so native defaultPrevented may not reflect synthetic cancellation.
|
||||
@@ -156,31 +158,36 @@ function PopoverContent({
|
||||
[forwardedRef, portalContainer]
|
||||
)
|
||||
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(setContentRef)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
// Match dropdown surface/border/shadow tokens so popovers remain distinct from the dark canvas.
|
||||
className={cn(
|
||||
'z-[60] overflow-hidden rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl outline-none dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
wheelScroll && 'popover-wheel-scroll',
|
||||
className
|
||||
)}
|
||||
ref={setContentRef}
|
||||
// Disable native dragging: titlebar drag regions intercept overlapping popover clicks regardless of z-index.
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
WebkitAppRegion: 'no-drag'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onWheel={handleConsumerWheel}
|
||||
onWheelCapture={handleConsumerWheelCapture}
|
||||
{...props}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
// Match dropdown surface/border/shadow tokens so popovers remain distinct from the dark canvas.
|
||||
className={cn(
|
||||
'z-[60] overflow-hidden rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl outline-none dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
wheelScroll && 'popover-wheel-scroll',
|
||||
className
|
||||
)}
|
||||
ref={focusScopeRef}
|
||||
// Disable native dragging: titlebar drag regions intercept overlapping popover clicks regardless of z-index.
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
WebkitAppRegion: 'no-drag'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onWheel={handleConsumerWheel}
|
||||
onWheelCapture={handleConsumerWheelCapture}
|
||||
{...props}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
/>
|
||||
</OverlayFocusScope>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as SelectPrimitive from 'radix-ui/select'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
@@ -78,33 +80,38 @@ function SelectContent({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
portalContainer?: HTMLElement | null
|
||||
}) {
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref)
|
||||
return (
|
||||
<SelectPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
// Match dropdown surface/border/shadow tokens so options remain distinct from the dark canvas.
|
||||
className={cn(
|
||||
'relative z-[70] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
<OverlayFocusScope value={scope}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
// Match dropdown surface/border/shadow tokens so options remain distinct from the dark canvas.
|
||||
className={cn(
|
||||
'p-1',
|
||||
'relative z-[70] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
ref={focusScopeRef}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</OverlayFocusScope>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as SheetPrimitive from 'radix-ui/dialog'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership'
|
||||
import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -75,30 +77,35 @@ function SheetContent({
|
||||
overlayClassName?: string
|
||||
overlayStyle?: React.CSSProperties
|
||||
}) {
|
||||
const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref)
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay className={overlayClassName} style={overlayStyle} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(sheetContentVariants({ side }), className)}
|
||||
// Disable native dragging where portaled sheet headers overlap the titlebar.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">
|
||||
{translate('auto.components.ui.sheet.1189e9fe0a', 'Close')}
|
||||
</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
<OverlayFocusScope value={scope}>
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(sheetContentVariants({ side }), className)}
|
||||
// Disable native dragging where portaled sheet headers overlap the titlebar.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
ref={focusScopeRef}
|
||||
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
|
||||
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">
|
||||
{translate('auto.components.ui.sheet.1189e9fe0a', 'Close')}
|
||||
</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</OverlayFocusScope>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import {
|
||||
getOverlayFocusOwners,
|
||||
getOverlayFocusSources,
|
||||
handleOverlayCloseAutoFocus,
|
||||
isOverlayFocusSourceDismissed
|
||||
} from '@/lib/overlay-focus-ownership'
|
||||
import { useOverlayFocusScope } from './use-overlay-focus-scope'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function RetainedOverlay({ mounted, open }: { mounted: boolean; open: boolean }) {
|
||||
const { ref } = useOverlayFocusScope()
|
||||
return mounted ? <div ref={ref} role="menu" data-state={open ? 'open' : 'closed'} /> : null
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'restores focus ownership when a retained overlay reopens (initially mounted: %s)',
|
||||
async (initiallyMounted) => {
|
||||
const view = render(<RetainedOverlay mounted={initiallyMounted} open />)
|
||||
if (!initiallyMounted) {
|
||||
view.rerender(<RetainedOverlay mounted open />)
|
||||
}
|
||||
const owner = screen.getByRole('menu')
|
||||
expect(getOverlayFocusOwners()).toContain(owner)
|
||||
|
||||
view.rerender(<RetainedOverlay mounted open={false} />)
|
||||
await act(async () => {
|
||||
const event = new Event('focusScope.autoFocusOnUnmount')
|
||||
owner.addEventListener(event.type, handleOverlayCloseAutoFocus, { once: true })
|
||||
owner.dispatchEvent(event)
|
||||
})
|
||||
expect(getOverlayFocusOwners()).not.toContain(owner)
|
||||
expect(getOverlayFocusSources()).not.toContain(owner)
|
||||
expect(isOverlayFocusSourceDismissed(owner)).toBe(true)
|
||||
|
||||
view.rerender(<RetainedOverlay mounted open />)
|
||||
expect(screen.getByRole('menu')).toBe(owner)
|
||||
await waitFor(() => {
|
||||
expect(getOverlayFocusOwners()).toContain(owner)
|
||||
expect(getOverlayFocusSources()).toContain(owner)
|
||||
expect(isOverlayFocusSourceDismissed(owner)).toBe(false)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Ref,
|
||||
type RefCallback,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { useComposedRefs } from 'radix-ui/internal'
|
||||
import { registerOverlayFocusOwner } from '@/lib/overlay-focus-ownership'
|
||||
|
||||
export const OverlayFocusScope = createContext<RefObject<HTMLDivElement | null> | null>(null)
|
||||
|
||||
export function useOverlayFocusScope(forwardedRef?: Ref<HTMLDivElement>): {
|
||||
ref: RefCallback<HTMLDivElement>
|
||||
scope: RefObject<HTMLDivElement | null>
|
||||
} {
|
||||
const parent = useContext(OverlayFocusScope)
|
||||
const scope = useRef<HTMLDivElement>(null)
|
||||
const [owner, setOwner] = useState<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (owner) {
|
||||
registerOverlayFocusOwner(owner, () => parent?.current ?? null)
|
||||
const observer = new MutationObserver(() => {
|
||||
if (owner.dataset.state === 'open') {
|
||||
registerOverlayFocusOwner(owner, () => parent?.current ?? null)
|
||||
}
|
||||
})
|
||||
observer.observe(owner, { attributes: true, attributeFilter: ['data-state'] })
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
return undefined
|
||||
}, [owner, parent])
|
||||
const ref = useComposedRefs(forwardedRef, scope, setOwner)
|
||||
return { ref, scope }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getVisibleOverlays, isVisibleOverlay } from './visible-overlay'
|
||||
|
||||
const NON_OWNING_SURFACES =
|
||||
'[data-focus-behavior="page"], [data-focus-behavior="composer-suggestions"]'
|
||||
const overlayParents = new WeakMap<HTMLElement, () => HTMLElement | null>()
|
||||
const dismissedOwners = new WeakSet<HTMLElement>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
type FocusEventHandler = (event: Event) => void
|
||||
|
||||
/** Pane recovery cards cover their parent surface; portaled menus keep window ownership. */
|
||||
export function overlayFocusAppliesTo(owner: HTMLElement, target?: Element | null): boolean {
|
||||
return (
|
||||
owner.dataset.overlayFocusScope !== 'parent' ||
|
||||
(target != null && owner.parentElement?.contains(target) === true)
|
||||
)
|
||||
}
|
||||
|
||||
/** Portaled descendants belong to the same focus scope as their containing surface. */
|
||||
export function overlayFocusContains(owner: HTMLElement, target: Element): boolean {
|
||||
for (let scope: Element | null = target; scope;) {
|
||||
if (owner.contains(scope)) {
|
||||
return true
|
||||
}
|
||||
scope =
|
||||
(scope instanceof HTMLElement ? overlayParents.get(scope)?.() : undefined) ??
|
||||
scope.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Page lists and editor suggestions do not take focus; their nested popups still do. */
|
||||
export function getOverlayFocusOwners(containing?: Element): HTMLElement[] {
|
||||
return getVisibleOverlays({
|
||||
ignoreMatches: NON_OWNING_SURFACES,
|
||||
ignoreContaining: containing,
|
||||
ignoreDismissed: true
|
||||
}).filter(
|
||||
(owner) =>
|
||||
!hasDismissedFocusScope(owner) &&
|
||||
(!containing || !overlayFocusContains(owner, containing)) &&
|
||||
overlayFocusAppliesTo(owner, containing)
|
||||
)
|
||||
}
|
||||
|
||||
/** Include parent scopes hidden from accessibility by a nested modal. */
|
||||
export function getOverlayFocusSources(): HTMLElement[] {
|
||||
return getVisibleOverlays({
|
||||
ignoreMatches: NON_OWNING_SURFACES,
|
||||
ignoreDismissed: true,
|
||||
includeAriaHidden: true
|
||||
}).filter(
|
||||
(owner) =>
|
||||
!hasDismissedFocusScope(owner) && overlayFocusAppliesTo(owner, document.activeElement)
|
||||
)
|
||||
}
|
||||
|
||||
export function isOverlayFocusSourceDismissed(owner: HTMLElement): boolean {
|
||||
return (
|
||||
!owner.isConnected ||
|
||||
hasDismissedFocusScope(owner) ||
|
||||
!isVisibleOverlay(owner, { includeAriaHidden: true })
|
||||
)
|
||||
}
|
||||
|
||||
function hasDismissedFocusScope(owner: HTMLElement): boolean {
|
||||
for (let scope: HTMLElement | null = owner; scope; scope = scope.parentElement) {
|
||||
if (dismissedOwners.has(scope)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function subscribeOverlayFocusChanges(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
// Custom role-based overlays do not emit the primitives' focus-scope callbacks.
|
||||
const observer = new MutationObserver(listener)
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
'class',
|
||||
'style',
|
||||
'hidden',
|
||||
'role',
|
||||
'data-state',
|
||||
'data-focus-behavior',
|
||||
'data-overlay-focus-scope'
|
||||
]
|
||||
})
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOverlayFocusOwner(
|
||||
owner: HTMLElement,
|
||||
parent?: () => HTMLElement | null
|
||||
): void {
|
||||
if (parent) {
|
||||
overlayParents.set(owner, parent)
|
||||
} else {
|
||||
overlayParents.delete(owner)
|
||||
}
|
||||
dismissedOwners.delete(owner)
|
||||
queueMicrotask(() => listeners.forEach((listener) => listener()))
|
||||
}
|
||||
|
||||
export function handleOverlayCloseAutoFocus(event: Event, handler?: FocusEventHandler): void {
|
||||
const owner = event.target
|
||||
handler?.(event)
|
||||
// Radix restores the trigger after this callback returns; deliver the handoff afterward.
|
||||
queueMicrotask(() => {
|
||||
if (owner instanceof HTMLElement) {
|
||||
dismissedOwners.add(owner)
|
||||
}
|
||||
listeners.forEach((listener) => listener())
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { focusNativeChatCover } from '@/components/terminal-pane/native-chat-covered-pane'
|
||||
import { hasVisibleOverlay } from '../visible-overlay'
|
||||
import { getOverlayFocusOwners } from '../overlay-focus-ownership'
|
||||
import type { ManagedPane } from './pane-manager-types'
|
||||
|
||||
export function focusPanePreservingOverlays(
|
||||
@@ -8,11 +8,7 @@ export function focusPanePreservingOverlays(
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
(document.activeElement?.matches('[data-worktree-sidebar][data-keyboard-navigation]') ||
|
||||
hasVisibleOverlay({
|
||||
ignoreMatches: '[role="listbox"][data-worktree-sidebar]',
|
||||
ignoreContaining: pane.container,
|
||||
ignoreDismissed: true
|
||||
}))
|
||||
getOverlayFocusOwners(pane.container).length > 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// The always-mounted worktree sidebar is page chrome, not an Escape-owning popup.
|
||||
const OVERLAY_SELECTOR =
|
||||
'[role="dialog"], [role="alertdialog"], [role="listbox"]:not([data-worktree-sidebar]), [role="menu"]'
|
||||
'[role="dialog"], [role="alertdialog"], [role="listbox"]:not([data-worktree-sidebar]):not([data-focus-behavior="page"]), [role="menu"]'
|
||||
|
||||
type VisibleOverlayOptions = {
|
||||
/** Capture source scopes even when a nested modal hides them from accessibility. */
|
||||
includeAriaHidden?: boolean
|
||||
/** Overlays inside a match are treated as page content, not as a layer above it. */
|
||||
ignoreSelector?: string
|
||||
/** Ignore matching chrome itself while retaining overlays nested within it. */
|
||||
@@ -13,35 +15,43 @@ type VisibleOverlayOptions = {
|
||||
ignoreDismissed?: boolean
|
||||
}
|
||||
|
||||
/** Live overlays own Escape before page handlers and outrank queued terminal focus. Ignore page chrome while retaining nested layers. */
|
||||
/** Live overlays own Escape before page handlers and outrank queued terminal focus. */
|
||||
export function hasVisibleOverlay(options?: VisibleOverlayOptions): boolean {
|
||||
return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
if (element.closest('[aria-hidden="true"]')) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreSelector && element.closest(options.ignoreSelector)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreMatches && element.matches(options.ignoreMatches)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreContaining && element.contains(options.ignoreContaining)) {
|
||||
return false
|
||||
}
|
||||
// Dismissed overlays remain painted during ~300ms exit animation; exclude them from focus ownership. Escape checks run before closed attributes update and must opt out.
|
||||
if (options?.ignoreDismissed && element.getAttribute('data-state') === 'closed') {
|
||||
return false
|
||||
}
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
element.getClientRects().length > 0
|
||||
)
|
||||
})
|
||||
return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some((element) =>
|
||||
isVisibleOverlay(element, options)
|
||||
)
|
||||
}
|
||||
|
||||
export function getVisibleOverlays(options?: VisibleOverlayOptions): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(OVERLAY_SELECTOR)).filter((element) =>
|
||||
isVisibleOverlay(element, options)
|
||||
)
|
||||
}
|
||||
|
||||
export function isVisibleOverlay(element: Element, options?: VisibleOverlayOptions): boolean {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
if (!options?.includeAriaHidden && element.closest('[aria-hidden="true"]')) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreSelector && element.closest(options.ignoreSelector)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreMatches && element.matches(options.ignoreMatches)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreContaining && element.contains(options.ignoreContaining)) {
|
||||
return false
|
||||
}
|
||||
// Dismissed overlays remain painted during ~300ms exit animation; exclude them from focus ownership. Escape checks run before closed attributes update and must opt out.
|
||||
if (options?.ignoreDismissed && element.getAttribute('data-state') === 'closed') {
|
||||
return false
|
||||
}
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
// These don't own Escape, but still paint above pages: popper surfaces (tooltips, hover cards),
|
||||
|
||||
@@ -47,6 +47,56 @@ async function openChat(page: Page, claudeCommand: string) {
|
||||
test.describe('native chat composer focus', () => {
|
||||
test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only')
|
||||
|
||||
test('quick launcher keeps focus when Copy Context completes', async ({
|
||||
electronApp,
|
||||
orcaPage: page,
|
||||
claudeStub
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
const { composer, send, reply } = await openChat(page, claudeStub.command)
|
||||
await page.keyboard.type('hello')
|
||||
await send.click()
|
||||
await expect(reply).toBeVisible({ timeout: 30_000 })
|
||||
await electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('clipboard:writeTerminalText')
|
||||
ipcMain.handle(
|
||||
'clipboard:writeTerminalText',
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
const gate: { pid: number; releaseContextCopy?: () => void } = process
|
||||
gate.releaseContextCopy = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
await reply.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Copy Context', exact: true }).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
electronApp.evaluate(() => {
|
||||
const gate: { pid: number; releaseContextCopy?: () => void } = process
|
||||
return typeof gate.releaseContextCopy === 'function'
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
await page.getByRole('button', { name: 'New tab', exact: true }).click()
|
||||
const menu = page.getByRole('menu', { name: 'New tab', exact: true })
|
||||
const search = menu.getByRole('combobox')
|
||||
await expect(search).toBeFocused()
|
||||
|
||||
await electronApp.evaluate(() => {
|
||||
const gate: { pid: number; releaseContextCopy?: () => void } = process
|
||||
gate.releaseContextCopy?.()
|
||||
})
|
||||
|
||||
await expect(page.getByText('Context copied', { exact: true })).toBeVisible()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type('terminal')
|
||||
await expect(search).toHaveValue('terminal')
|
||||
await expect(composer).toHaveText('')
|
||||
await page.screenshot({ path: testInfo.outputPath('quick-launch-focus.png') })
|
||||
})
|
||||
|
||||
test('keyboard tab rename returns input to the native chat composer', async ({
|
||||
orcaPage: page,
|
||||
claudeStub
|
||||
|
||||
Reference in New Issue
Block a user