Fixing Greenhouse's focus-management bug taught me the difference between a dialog and a page
A UI/UX review of Greenhouse flagged a real accessibility bug: the app has four “full-window views” (a project’s detail page, the vault browser, the harvest browser, a kanban board) that swap in over the dashboard’s main zones. Open one with a keyboard or a screen reader and focus just falls off the edge of the world, it lands on <body>, because the whole zones subtree unmounted and nothing claimed focus in its place. The suggested fix in the ticket was reasonable-sounding: put tabindex="-1" on each view’s heading, focus it when it mounts, and when the user backs out, restore focus to whatever button they clicked to get there.
That third part turned out to be impossible to build the way it was written, and figuring out why was the actual interesting part of this ticket.
Greenhouse’s dashboard swaps these views using one big {#if selectedItem}...{:else if vaultOpen}...{:else}<zones/>{/if} chain. Svelte doesn’t do clever diffing across branches like that - when the condition changes, the old branch’s entire DOM gets torn down and the new one gets built fresh. Which means the button that opened, say, the vault view doesn’t just lose focus when the view closes, it doesn’t exist anymore. It got destroyed the instant the view opened. Holding onto a reference to it and calling .focus() later just… does nothing. No error, no warning, it’s a focus call on a ghost.
Once I understood that, the real question became: what’s the correct pattern here, not just “what workaround gets around the ghost-node problem.” And it turns out there already IS a correct answer, I just had to stop assuming this was a dialog problem. Restoring focus to the trigger is the right move for an actual modal, something WAI-ARIA’s dialog pattern spells out clearly, because the content behind the modal never went anywhere. It’s still there, waiting. But these views aren’t overlays, they’re full replacements, more like a page navigation in a single-page app than a dialog opening. And the established convention for that case is completely different: focus the new page’s heading, not the old trigger. React Router does this. GOV.UK’s design guidance for AJAX page loads does this. That’s no workaround, it’s just the correct pattern for content that’s replaced instead of layered.
So the fix ended up being simpler than the ticket asked for, not more complex. Every view gets its own heading focused once, guarded so an internal reload (renaming a project, advancing its stage) doesn’t yank focus back and interrupt whatever the user’s doing. And the dashboard’s zones get the same treatment on the way back, since that branch is just as much a fresh remount as any of the views swapping into it.
I verified it by actually driving the built app through a real WebDriver session, clicking through every open and close and reading document.activeElement afterward, including the gnarlier nested case: open the board, open a project from a card on it, close back out. Focus correctly lands on the re-mounted board’s own heading, not on nothing. A unit test can check that tabindex="-1" exists on an element. It can’t tell you whether focus actually landed there after a real transition. For a bug that’s entirely about where focus goes, that distinction is the whole ballgame.
Related reading
A focus ring that shouldn't have been full-strength
A bright green line under the topbar on every launch, a screenshot attempt that captured the wrong windows entirely, and a swatch that proved the color math but not the answer.
Navigation redesign: topbar tabs and a Settings dialog
Eight footer controls at identical visual weight, an i18n key doing double duty, and the ARIA pattern the project's own a11y test refused to let me half-build.
The outline was right, the box it was drawn around wasn't
A focus ring spanning an entire header row, a WebDriver window that macOS never made key, and the inline-span trick that shrinks an outline to the words inside it.