diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bb0c4c5d7c..52211bed4a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -233,10 +233,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) - // An editing-time pick parks focus on the selection after commit; the - // flag is set by select() and consumed by the refocus effect below the - // miller-row ref. + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { @@ -373,17 +378,33 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // An editing-time pick unmounts the focused input, and a right-pane pick - // additionally replaces the picked button's whole column (advance swaps - // both panes): park focus on the selection's row — aria-current in the - // freshly rendered left pane — after commit, so keyboard traversal stays - // inside the dialog (the Modal has no focus trap). + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { - if (!refocusPick.current) return - refocusPick.current = false - /* v8 ignore next 2 -- narrowing guard: the pick that set the flag just rendered its aria-current row inside the miller row. */ - const row = millerRowRef.current?.querySelector('button[aria-current="true"]') - row?.focus() + if (pathDraft !== null) return + if (refocusPick.current) { + refocusPick.current = false + refocusEditZone.current = false + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ + if (rowHost === null) return + const row = rowHost.querySelector('button[aria-current="true"]') + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return + } + if (refocusEditZone.current) { + refocusEditZone.current = false + const zone = editZoneRef.current + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + if (zone === null) return + zone.focus() + } }) if (!open) return null @@ -424,6 +445,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. + if (document.activeElement === pathInputRef.current) refocusEditZone.current = true cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -442,6 +467,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */ if (card === null) return if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return + // The user moved focus out of the card themselves: cancel without + // re-parking (a lingering Enter-failure flag must not yank focus + // back either). + refocusEditZone.current = false cancelPathEdit() }} > @@ -475,6 +504,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // listing itself fails, typing an absolute path is the one // remaining way forward. disabled={parentInert} + ref={editZoneRef} onClick={() => { // Opening the editor supersedes any pending listing: a // settlement landing before the first keystroke would @@ -502,6 +532,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -521,7 +552,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Trim only detects a blank draft; the Host gets the // original text — a real directory name may end in // whitespace, and trimming would list its sibling. - if (pathDraft.trim() !== '') navigate(pathDraft) + if (pathDraft.trim() !== '') { + // Success will unmount the still-focused input; park + // focus on the returning crumb edit zone (a failure + // keeps the editor, so the flag waits until close). + refocusEditZone.current = true + navigate(pathDraft) + } } }} /> @@ -586,8 +623,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={() => { setShowHidden(prev => !prev) }} > - {showHidden && } {t('browser.showHidden')} + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 217ffa481e..06f8b78a3f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -227,6 +227,9 @@ describe('DirectoryBrowser', () => { fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) expect(columns()).toHaveLength(1) + // The submitted navigation unmounted the focused input; focus parks on + // the crumb edit zone that replaced it. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const again = screen.getByLabelText('browser.editPath') fireEvent.change(again, { target: { value: ' ' } }) @@ -234,6 +237,8 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory).toHaveBeenCalledTimes(2) fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Escape with focus in the input parks focus on the returning edit zone. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { @@ -324,9 +329,12 @@ describe('DirectoryBrowser', () => { // Tab parked focus on the result row; Escape must still mean "leave // path editing", not "close the whole dialog". const row = rowButton(screen.getByRole('listitem')) + row.focus() fireEvent.keyDown(row, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() expect(b.onClose).not.toHaveBeenCalled() + // Focus was already on a surviving row, so nothing re-parks it. + expect(document.activeElement).toBe(row) // With no draft left, Escape falls through to the Modal and closes. fireEvent.keyDown(row, { key: 'Escape' }) expect(b.onClose).toHaveBeenCalledTimes(1)