Merge pull request #1756 from deepseek-harness/worktree/fix-settings-focus
fix(web): restore focus after closing settings
This commit is contained in:
commit
375af94454
3 changed files with 28 additions and 12 deletions
|
|
@ -54,7 +54,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
|||
return () => { document.removeEventListener('keydown', onKeyDown) }
|
||||
}, [onClose])
|
||||
|
||||
// Baseline focus management: entering the dialog lands on the close button.
|
||||
// Entering the dialog focuses the close button; the root restores its trigger on close.
|
||||
const closeButton = useRef<HTMLButtonElement | null>(null)
|
||||
useEffect(() => { closeButton.current?.focus() }, [])
|
||||
|
||||
|
|
@ -106,10 +106,17 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
|||
const [open, setOpen] = useState(false)
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const triggerButton = useRef<HTMLButtonElement | null>(null)
|
||||
const wasOpen = useRef(open)
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setActiveId(undefined)
|
||||
}, [])
|
||||
// Restore after the close commit, when the dialog can no longer own focus.
|
||||
useEffect(() => {
|
||||
if (wasOpen.current && !open) triggerButton.current?.focus()
|
||||
wasOpen.current = open
|
||||
}, [open])
|
||||
const openSection = useCallback((id: string) => {
|
||||
setActiveId(id)
|
||||
setOpen(true)
|
||||
|
|
@ -142,6 +149,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
|||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerButton}
|
||||
type="button"
|
||||
className={clsx(css.trigger, !wide && css.rail)}
|
||||
aria-haspopup="dialog"
|
||||
|
|
|
|||
|
|
@ -81,7 +81,10 @@ function mount({
|
|||
}
|
||||
|
||||
function openPanel() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
const trigger = screen.getByRole('button', { name: 'Settings' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
return trigger
|
||||
}
|
||||
|
||||
describe('SettingsRoot trigger', () => {
|
||||
|
|
@ -131,26 +134,29 @@ describe('SettingsPanel chrome seats', () => {
|
|||
})
|
||||
|
||||
describe('SettingsPanel close paths', () => {
|
||||
it('closes via the header button', () => {
|
||||
it('closes via the header button and restores trigger focus', async () => {
|
||||
mount()
|
||||
openPanel()
|
||||
const trigger = openPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
})
|
||||
|
||||
it('closes via a mask click', () => {
|
||||
it('closes via a mask click and restores trigger focus', async () => {
|
||||
mount()
|
||||
openPanel()
|
||||
const trigger = openPanel()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(dialog.parentElement!.firstElementChild!)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
})
|
||||
|
||||
it('closes via document-level Escape and unhooks the listener with the panel', () => {
|
||||
it('closes via document-level Escape, restores trigger focus, and unhooks the listener', async () => {
|
||||
mount()
|
||||
openPanel()
|
||||
const trigger = openPanel()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
// Ignored while closed (listener removed with the panel) and non-Escape
|
||||
// keys are ignored while open.
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
|
|
|||
|
|
@ -442,15 +442,17 @@ describe('SessionProjectionCache cold-read seeding', () => {
|
|||
// Host-only unit: folded but not served; the refreshed row is written
|
||||
// back (fail-soft, fire-and-forget) once the write lands.
|
||||
expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
|
||||
await settle()
|
||||
expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
|
||||
await vi.waitFor(async () => {
|
||||
expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
|
||||
})
|
||||
// No cached row yet: the first cold read folds from init over the full
|
||||
// log and creates the cache row (the `?? {}` seed path).
|
||||
const fresh = headerOf(SessionId('cold-fresh'), 10)
|
||||
cache.coldSnapshot(fresh, events)
|
||||
expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
|
||||
await settle()
|
||||
expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
|
||||
await vi.waitFor(async () => {
|
||||
expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue