DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Electron UI has 37 buttons, 8 aria attributes and one Escape handler, and the modal is not a dialog

Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. Its window is a browser pointed at an Express server in the same process, so the UI is a React app in every respect except that it has no URL anybody can visit.

That last part is why I had never audited it. I have run accessibility passes over the marketing site and over a React Native app, because both felt like public surfaces. The desktop renderer felt like a tool, and a tool you can see is a tool you assume is fine.

It is 3,107 lines across 11 components. Here is what counting found.

The good half, and I was surprised by it

All 37 buttons have a visible text label. I wrote a script to pull every <button> block out of the renderer and check whether anything inside it was a word. Thirty-seven buttons, thirty-seven labels, and aria-label appears on exactly zero of them because none of them needs one. There are no icon-only controls anywhere in the app, which is the single most common defect I find in my own work and the easiest to ship by accident.

The tabs are real tabs. The three sections are not divs with click handlers:

<div role="tablist" aria-label="Sections" className="mb-5 flex gap-1 border-b border-[#262626]">
  {TABS.map((t) => (
    <button
      key={t.id}
      role="tab"
      id={`tab-${t.id}`}
      aria-selected={tab === t.id}
      aria-controls={`panel-${t.id}`}
      onClick={() => setTab(t.id)}
    >
Enter fullscreen mode Exit fullscreen mode

role="tab" on a real button, aria-selected tracking state, aria-controls pointing at the panel, and each panel carrying aria-labelledby back to its tab.

The monitor switch is a switch. role="switch" with aria-checked, rather than a styled checkbox or a div.

The notice line changes role with severity. The one status line in the monitor panel does this:

role={app.notice.level === "error" ? "alert" : "status"}
Enter fullscreen mode Exit fullscreen mode

An error interrupts, a status waits its turn. I wrote about that choice when the notice got its own channel, and it is the proof that the knowledge exists in this codebase. Which makes the rest of the audit more annoying, not less.

The modal

Every search that needs you to sign in to a site opens one component. Here is its root:

<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-6">
  <div className="w-full max-w-[520px] rounded-lg border border-[#404040] bg-[#262626] p-8 shadow-xl">
    <h2 className="mb-2 text-xl font-bold tracking-tight text-[#fafafa]">
      {heading}
    </h2>
Enter fullscreen mode Exit fullscreen mode

No role="dialog". No aria-modal. No aria-labelledby pointing at that h2. Nothing moves focus into the box when it opens, nothing returns focus to the row you clicked when it closes, and everything behind the dim overlay stays in the tab order, so the first Tab press takes you to a control under a sheet of 70 per cent black.

There is also no way to dismiss it from the keyboard. Not Escape, not a close button in the corner, and clicking the overlay does nothing. The way out is the Skip or Cancel button, which is the first button in the action row, so it is reachable, just not where anybody's hand goes first.

Escape appears once in the entire renderer, and it is not here:

onKeyDown={(e) => {
  if (e.key === "Enter") onSaveEdit();
  if (e.key === "Escape") onCancelEdit();
}}
autoFocus
Enter fullscreen mode Exit fullscreen mode

That is the inline rename field on a saved search. One textbox in the app has a proper keyboard contract, and the modal that blocks the whole window does not.

The flow whose feedback nobody hears

The login flow is three steps and it reports every one of them into this:

{statusMsg && (
  <p className="mb-3 min-h-[20px] text-[13px] text-[#a3a3a3]">
    {statusMsg}
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

statusMsg moves through "Opening browser...", then "Browser is open. Log in there, then click I'm logged in below.", then either "Session saved." or an error explaining what to try. It is the only thing telling you whether the step you just took worked.

It is a plain paragraph. No aria-live, no role="status". A screen reader user clicks Open browser and hears nothing at all, in the one flow in the app where the next action depends entirely on the feedback from the last one. The component next door already picks between alert and status for exactly this job.

The fix is one attribute. The reason it is one attribute and not a rewrite is that the paragraph is conditionally rendered, so I will also mount the container unconditionally, because a live region has to be in the document before the text lands in it or there is nothing for the browser to notice changing.

The toast nobody hears either

export default function Toast({ message }: Props) {
  return (
    <div className={`fixed bottom-8 right-8 z-[200] ... ${
      message ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
    }`}>
      {message}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fifteen lines, and every confirmation in the app goes through it on a 2,800 millisecond timer. No role, so none of those confirmations is announced.

This one is nearly free to fix, and for a nice reason: the element is always mounted and only its text changes, which is exactly the shape a live region needs. Adding role="status" to that div turns the whole app's confirmation channel on in one line, with no restructuring at all. The accident of keeping the div mounted for the fade-out transition is the thing that makes it correct.

Two aria-controls that point at nothing

Back to the tabs, because the markup is right and the wiring is half wrong. The monitor panel stays mounted and hides itself:

<div role="tabpanel" id="panel-monitor" aria-labelledby="tab-monitor" hidden={tab !== "monitor"}>
Enter fullscreen mode Exit fullscreen mode

The other two mount only when selected:

{tab === "finds" && (
  <div role="tabpanel" id="panel-finds" aria-labelledby="tab-finds">
Enter fullscreen mode Exit fullscreen mode

So while you are on the monitor tab, two of the three tabs have aria-controls pointing at element ids that are not in the document. It is not a disaster, assistive technology tends to ignore a dangling reference rather than break, but it is a promise the DOM does not keep, and the reason for the asymmetry is real: the monitor panel holds the activity log and the live per-search state, and unmounting it would throw those away. The fix is to render all three panels and use hidden on all three, which also removes the hidden ones from the accessibility tree properly.

The smaller numbers

  • 8 aria attributes in 3,107 lines. Three aria-labelledby, two aria-label, one aria-selected, one aria-controls, one aria-checked. That is low, and most of it is accounted for by the fact that 37 buttons label themselves.
  • 8 inputs, 6 labels, 4 htmlFor. Two labels sit next to their field without being tied to it, so clicking the label does not focus the input and a screen reader does not pair them. One of the two unlabelled fields does carry an aria-label, and it is the only aria-label in the app doing real work: aria-label={Listing URL on ${site.name}}.
  • 6 outline-none, all six on text inputs, each paired with a focus:border- colour change. So focus is visible, but it is indicated by colour alone, and the thing it replaced was the one indicator the operating system guarantees. The buttons were never touched, so they keep Chromium's own focus ring, which is the right outcome by not doing anything.
  • Two headings in the whole UI. One h1 on the activation screen, one h2 in the modal. Every panel title in the main window is styled text in a div, so the heading map a screen reader user would navigate by is empty. For a single-window tool this matters less than it would on a page, but four panels with four headings would cost nothing.

What I am doing about it

In the order I will actually do them, which is cheapest first:

  1. role="status" on the toast. One line, turns on every confirmation in the app.
  2. role="status" on the modal's status paragraph, with the container always mounted.
  3. role="dialog", aria-modal="true" and aria-labelledby on the modal, plus an Escape handler, focus moved to the first action on open and restored on close. The focus trap is the only part of this that is more than an afternoon.
  4. All three tab panels rendered with hidden, so no aria-controls points at nothing.
  5. htmlFor on the two loose labels, and an h2 per panel.

None of that is interesting work, and none of it would have been found by looking at the app, because the app looks fine. It was found by counting things: buttons, labels, roles, Escape handlers. If you ship an Electron app, the renderer is a web page with all of the same obligations and none of the usual reminders, because no Lighthouse run, no crawler and no accessibility browser extension is ever pointed at a window that has no address.

The app is a one-time licence with installers for Windows and both Mac architectures, the per-site setup instructions are at notifio.app/alerts including Kamernet and WG-Gesucht, and the help page is where the keyboard questions will end up once there are answers worth writing down.

Top comments (0)