fix(host): review round 11 — every displacing exit re-parks focus; full trailing-separator trim
This commit is contained in:
@@ -69,9 +69,17 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string {
|
||||
return value => (sep === '\\' ? value.toLowerCase() : value)
|
||||
}
|
||||
|
||||
/** Drops one trailing separator (`HOME=/home/u/` ships verbatim while resolve() strips it) unless the path IS the bare root. */
|
||||
/**
|
||||
* Drops every trailing separator (`HOME=/home/u//` ships verbatim while
|
||||
* resolve() strips them) down to, but never past, one leading character —
|
||||
* which keeps the POSIX root `/` intact. A backslash drive root (`C:\`)
|
||||
* does lose its separator; that stays safe only because every comparison
|
||||
* trims both sides symmetrically.
|
||||
*/
|
||||
function trimTrailingSeparator(path: string, sep: '\\' | '/'): string {
|
||||
return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path
|
||||
let end = path.length
|
||||
while (end > sep.length && path.endsWith(sep, end)) end -= sep.length
|
||||
return path.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,14 +261,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const millerRowRef = useRef<HTMLDivElement | null>(null)
|
||||
// Focus parking (consumed by the refocus effect below): a pick — and a
|
||||
// parent-leg upgrade that displaces focused rows — parks on the
|
||||
// selection's row; Enter, an input-focused Escape, and a failed pick
|
||||
// whose row unmounts park on the crumb edit zone (the latter only when
|
||||
// focus actually fell to body). Pointer-out cancels never set (or clear)
|
||||
// these — yanking focus back from wherever the user clicked would be
|
||||
// worse than the fall.
|
||||
// selection's row; every other displacing exit (Enter, Escape, a landing
|
||||
// whose new level dropped the focused row, a failed pick or relist, and
|
||||
// the nested create dialog closing) parks on the crumb edit zone, each
|
||||
// only when focus actually fell to body. 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)
|
||||
|
||||
/**
|
||||
@@ -300,6 +308,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setError(null)
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// The landing replaces every row key; a slow jump leaves the OLD
|
||||
// rows tabbable meanwhile (parentInert excludes loading), so focus
|
||||
// may live among them. With no selection yet the edit zone is the
|
||||
// park target (body-guarded, like every other exit).
|
||||
const rowHost = millerRowRef.current
|
||||
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
|
||||
if (rowHost === null) return
|
||||
if (rowHost.contains(document.activeElement)) refocusEditZone.current = true
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
@@ -343,6 +359,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
/**
|
||||
* Close the nested create dialog. Its unmount drops focus to body (the
|
||||
* Modal has no focus trap), so every exit — Escape, mask, Cancel, and a
|
||||
* successful create — arms the body-guarded edit-zone parking; a create
|
||||
* landing's later select() re-parks on the created row instead.
|
||||
*/
|
||||
const closeCreateDialog = useCallback(() => {
|
||||
setFolderDraft(null)
|
||||
refocusEditZone.current = true
|
||||
}, [])
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const { seq, scan } = launchListing(entry.path)
|
||||
@@ -449,7 +476,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// the fresh dialog or issue a relist against the stale target.
|
||||
if (generation !== openGeneration.current) return
|
||||
setCreatingFolder(false)
|
||||
setFolderDraft(null)
|
||||
closeCreateDialog()
|
||||
// Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
|
||||
// create target becomes the listed level and the new folder its selection.
|
||||
const { seq, scan } = launchListing(targetPath)
|
||||
@@ -572,11 +599,13 @@ 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. Assignment (not a conditional set) also
|
||||
// The cancel may unmount whatever holds focus — the input, or a
|
||||
// dot-revealed row the cleared draft re-hides. Arm the parking
|
||||
// unconditionally: the refocus effect's body guard already
|
||||
// distinguishes a surviving focused row (left alone) from focus
|
||||
// that actually fell. Assignment (not a conditional set) also
|
||||
// retires a stale flag a failed or still-upgrading Enter left.
|
||||
refocusEditZone.current = document.activeElement === pathInputRef.current
|
||||
refocusEditZone.current = true
|
||||
cancelPathEdit()
|
||||
}}
|
||||
// Focus leaving THIS dialog card while editing cancels like Escape.
|
||||
@@ -660,7 +689,6 @@ 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:
|
||||
@@ -770,7 +798,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
|
||||
<Modal
|
||||
open={folderDraft !== null}
|
||||
onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
|
||||
onClose={() => { if (!creatingFolder) closeCreateDialog() }}
|
||||
title={t('browser.newFolder')}
|
||||
className={clsx(css.createDialog)}
|
||||
headless
|
||||
@@ -794,13 +822,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
if (!creatingFolder) setFolderDraft(null)
|
||||
if (!creatingFolder) closeCreateDialog()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{createError !== null && <div className={css.error} role="alert">{createError}</div>}
|
||||
<div className={css.createActions}>
|
||||
<Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
|
||||
<Button variant="outline" disabled={creatingFolder} onClick={() => { closeCreateDialog() }}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}
|
||||
|
||||
@@ -236,6 +236,71 @@ describe('DirectoryBrowser', () => {
|
||||
expect(listDirectory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a home with several trailing separators is still the display root', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}//` }))
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||
expect(listDirectory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a landing whose new level dropped the focused row parks on the edit zone', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
// Keyboard is on the right-pane harness row; the home landing has no
|
||||
// such key, so the focused node unmounts and focus re-parks.
|
||||
rowButton(within(columns()[1]!).getByRole('listitem')).focus()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('closing the create dialog parks focus on the edit zone', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const nameInput = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.keyDown(nameInput, { key: 'Escape' })
|
||||
// The nested dialog unmounted with focus inside it: re-park.
|
||||
expect(screen.queryByLabelText('browser.folderName')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('a failed create relist parks focus on the edit zone with the error shown', async () => {
|
||||
let relists = 0
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === HOME && ++relists > 0) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'gone', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
|
||||
await screen.findByRole('alert')
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('Escape over a dot-revealed focused row re-parks on the edit zone as the row re-hides', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
expect(row.textContent).toBe('.config')
|
||||
row.focus()
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
// The cleared draft re-hides the revealed row under the focused cursor;
|
||||
// the body guard sees the fall and parks on the edit zone.
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('a navigation to the filesystem root keeps the single wide level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
|
||||
Reference in New Issue
Block a user