DEV Community

Cover image for A tablist that looked finished until a keyboard asked it a question
ANIRUDDHA ADAK
ANIRUDDHA ADAK

Posted on

A tablist that looked finished until a keyboard asked it a question

The bug was polite enough to sit in the interface without crashing anything. That made it easier to miss and more important to fix.

Project overview

Comfort Paws Lab is an interactive field guide that helps a person move through a short dog-comfort ritual. Its three ritual steps are presented as a tablist, because only one detail panel should be active at a time and the control needs to be quick to scan.

The first implementation looked correct with a mouse. A person could click a step, the detail panel changed, and the selected state was visible. The problem appeared when I treated the component as the tab interface it claimed to be. Arrow keys, Home, and End did not move the active choice or focus. A keyboard user had to tab through every button and activate each one manually.

Bug fix

The issue was a mismatch between semantics and behavior. The component exposed role="tab", aria-selected, and a tab panel, but it did not implement the keyboard model those semantics promise. I added a small handleRitualTabKeyDown function that handles horizontal and vertical arrow navigation, Home, and End. It updates the selected ritual and deliberately moves focus to the new tab.

The result is simple. A keyboard user can now press Arrow Right or Arrow Down to move forward, Arrow Left or Arrow Up to move back, Home to reach the first step, and End to reach the last step. Each movement changes the active panel and leaves focus exactly where the user expects it.

Code

function handleRitualTabKeyDown(
  event: React.KeyboardEvent<HTMLButtonElement>,
  index: number,
) {
  const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
  if (keys.includes(event.key) === false) return

  event.preventDefault()
  const nextIndex = event.key === "Home"
    ? 0
    : event.key === "End"
      ? ritualOptions.length - 1
      : event.key === "ArrowRight" || event.key === "ArrowDown"
        ? (index + 1) % ritualOptions.length
        : (index - 1 + ritualOptions.length) % ritualOptions.length

  setSelectedRitual(nextIndex)
  document.getElementById(`ritual-tab-${nextIndex}`)?.focus()
}
Enter fullscreen mode Exit fullscreen mode

The tab buttons now identify their controlled panel and participate in roving tab order.

<button
  id={`ritual-tab-${index}`}
  role="tab"
  aria-selected={selectedRitual === index}
  aria-controls="ritual-tab-panel"
  tabIndex={selectedRitual === index ? 0 : -1}
  onKeyDown={(event) => handleRitualTabKeyDown(event, index)}
>
Enter fullscreen mode Exit fullscreen mode

The panel is explicitly labelled by the currently active tab.

<div
  id="ritual-tab-panel"
  role="tabpanel"
  aria-labelledby={`ritual-tab-${selectedRitual}`}
>
Enter fullscreen mode Exit fullscreen mode

My improvements

Before the patch, the component had the appearance of an accessible tablist without the navigation behavior a tablist requires. After the patch, the interaction has a coherent focus model, a visible selected state, an announced relationship between tab and panel, and a direct path through all three choices without requiring a pointer.

I also ran pnpm check and a production build after the change. The TypeScript check passed and the Vite production build completed successfully. The live demo is available at Comfort Paws Lab, where the first interactive field note is the fixed component. The source-and-asset archive includes the patched component and the visual assets.

This was a small patch, but it changed the interface from something that only looked finished into something that answers a keyboard with the same care it gives a mouse.

Top comments (0)