Skip to content
Development

Fixing Greenhouse's focus-management bug taught me the difference between a dialog and a page

By Victor Da Luz
svelteaccessibilitydev-loggreenhouse

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