fix(host): review round 5 nits — refocus covers Enter/Escape exits; trailing pressed check; narrowed v8 ignores

This commit is contained in:
creatixchu
2026-07-29 15:38:52 +08:00
parent 9c9c32ed59
commit 23b74c5d7d
2 changed files with 62 additions and 15 deletions

View File

@@ -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<HTMLInputElement | null>(null)
const editZoneRef = useRef<HTMLButtonElement | null>(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<HTMLButtonElement>('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<HTMLButtonElement>('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 && <IconCheckOutline16 size={14} />}
{t('browser.showHidden')}
{/* Trailing check (Menu's selected vocabulary): the label never
* shifts when the pressed state toggles. */}
{showHidden && <IconCheckOutline16 size={14} />}
</button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>

View File

@@ -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<HTMLInputElement>('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)