Datasette / Accessibility lab

Cog menu, five ways

Datasette's homepage, database, and table pages all surface a "cog" action menu built on the native <details> / <summary> elements. This page lays out five working variants of that menu so they can be tested side-by-side against screen readers, keyboards, and assistive tech.

Source simonw/datasette File templates/_action_menu.html Last reviewed 2026-05-24

The native <details> element is a built-in disclosure widget — it handles keyboard activation, click-to-toggle, and basic state for free. Datasette layers a dropdown menu on top of it, which is a clever use of platform primitives but introduces some accessibility friction: screen readers announce it as a "disclosure" rather than a "menu", focus doesn't move into the panel when it opens, and there's no arrow-key navigation between items.

Each section below contains a working, rendered example, a detailed description of how it behaves, and the full source needed to reproduce it. The CSS is identical across variants so the differences are purely in markup and JavaScript.

№ 01

Current Datasette — main branch

Baseline

This is a verbatim copy of the markup that Datasette ships today. A native <details> wraps a <summary> containing the cog icon, and the dropdown panel sits inside the details element as a sibling of summary. The browser handles all open/close behaviour automatically — clicking, pressing Enter or Space while focus is on the summary toggles the open attribute.

A small global script listens for clicks anywhere on body and closes any details.details-menu the click happened outside of. There is no other JavaScript involved.

Keyboard behaviour: Tab focuses the summary, Enter/Space toggles it. Tab again moves focus into the first link inside the panel (because the panel is open and contains tabbable elements). Tab continues sequentially through the list, then leaves the menu. There is no Escape support, no Arrow-key navigation, and focus is not trapped or returned to the summary on close.

Screen reader behaviour: NVDA, JAWS, and VoiceOver announce the summary as a "button" with "collapsed" or "expanded" state, because that is how <summary> is mapped in the accessibility tree. The dropdown contents are announced as a plain list of links inside an article/group — not as a menu. This may actually be appropriate (see Variant 04), but it is at odds with the visual treatment that looks like a classic menu.

Click-outside: Clicking anywhere outside an open menu closes it. Clicking a link inside the menu navigates as normal; the menu stays open until navigation completes (or the user clicks elsewhere).

Strengths

  • Tiny markup, no JS framework needed
  • Works without JavaScript at all
  • Native focus + activation handling for free
  • Print-friendly and progressively enhanced

Weaknesses

  • No Escape, no Arrow keys, no focus management
  • Announced as "button collapsed" not "menu"
  • Tab-through requires two presses to enter the panel
  • No aria-expanded sync (relies on AT to read open)
HTML — templates/_action_menu.html
<!-- datasette/templates/_action_menu.html -->
<div class="page-action-menu">
  <details class="actions-menu-links details-menu">
    <summary>
      <div class="icon-text">
        <svg class="icon" aria-labelledby="actions-menu-links-title" role="img" …>
          <title id="actions-menu-links-title">{{ action_title }}</title>
          <!-- cog SVG paths -->
        </svg>
        <span>{{ action_title }}</span>
      </div>
    </summary>
    <div class="dropdown-menu">
      <div class="hook"></div>
      <ul>
        {% for link in action_links %}
        <li><a href="{{ link.href }}">{{ link.label }}
          {% if link.description %}
          <p class="dropdown-description">{{ link.description }}</p>
          {% endif %}</a></li>
        {% endfor %}
      </ul>
    </div>
  </details>
</div>
JavaScript — templates/_close_open_menus.html
// datasette/templates/_close_open_menus.html
document.body.addEventListener('click', (ev) => {
  /* Close any open details elements that this click is outside of */
  var target = ev.target;
  var detailsClickedWithin = null;
  while (target && target.tagName != 'DETAILS') {
    target = target.parentNode;
  }
  if (target && target.tagName == 'DETAILS') {
    detailsClickedWithin = target;
  }
  Array.from(document.querySelectorAll('details.details-menu')).filter(
    (details) => details.open && details != detailsClickedWithin
  ).forEach(details => details.open = false);
});
№ 02

pintaste fork — fix/cog-menu-keyboard-accessibility

Proposed PR

This is the markup and JS from pintaste:fix/cog-menu-keyboard-accessibility. It keeps the <details>/<summary> structure intact but layers ARIA on top and adds keyboard handlers. Specifically:

  • aria-haspopup="menu" and aria-expanded on the summary, kept in sync via the toggle event
  • role="menu" on the <ul>
  • role="none" on each <li> (to remove the implicit list semantics that would otherwise conflict with the menu role)
  • role="menuitem" and tabindex="-1" on each link
  • When the menu opens, focus moves to the first menuitem
  • Escape closes the menu and returns focus to the summary
  • ArrowDown / ArrowUp cycle through items

Caveat to test for: there's a subtle conflict between the native <summary> mapping (which is typically exposed as button) and the explicit aria-haspopup. Some screen reader / browser combinations announce "button menu collapsed", others ignore aria-haspopup on summary entirely. This variant exists specifically to find out which.

Also worth testing: because all menuitems have tabindex="-1", they're removed from the natural tab order. That's correct for a menu pattern — Arrow keys are the way to move within a menu, and Tab should exit it — but it changes the behaviour from Variant 01 where Tab cycled through links.

Strengths

  • Keeps tiny native-element scaffolding
  • Adds Escape and Arrow keys
  • Focus moves into menu on open
  • Focus returns to summary on Escape

Weaknesses

  • Mixed semantics: summary + aria-haspopup="menu" is non-standard
  • No Home/End/typeahead
  • No focus trap (still possible to Tab out)
  • Menu doesn't close after activating an item
HTML — templates/_action_menu.html (pintaste diff)
<details class="actions-menu-links details-menu">
  <summary aria-haspopup="menu" aria-expanded="false">
    <!-- icon + label unchanged -->
  </summary>
  <div class="dropdown-menu">
    <div class="hook"></div>
    <ul role="menu">
      {% for link in action_links %}
      <li role="none">
        <a href="{{ link.href }}" role="menuitem" tabindex="-1">
          {{ link.label }}
        </a>
      </li>
      {% endfor %}
    </ul>
  </div>
</details>
JavaScript — templates/_close_open_menus.html (pintaste diff)
/* click-outside-to-close logic (unchanged from main) */
document.body.addEventListener('click', (ev) => {
  var target = ev.target;
  var detailsClickedWithin = null;
  while (target && target.tagName != 'DETAILS') {
    target = target.parentNode;
  }
  if (target && target.tagName == 'DETAILS') {
    detailsClickedWithin = target;
  }
  Array.from(document.querySelectorAll('details.details-menu')).filter(
    (details) => details.open && details != detailsClickedWithin
  ).forEach(details => details.open = false);
});

/* Sync aria-expanded and move focus on toggle */
document.querySelectorAll('details.details-menu').forEach(function(details) {
  var summary = details.querySelector('summary');
  details.addEventListener('toggle', function() {
    if (summary) {
      summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
    }
    if (details.open) {
      var firstItem = details.querySelector('[role="menuitem"]');
      if (firstItem) { firstItem.focus(); }
    }
  });
});

/* Escape + Arrow keys for open menus */
document.body.addEventListener('keydown', function(ev) {
  var openDetails = Array.from(
    document.querySelectorAll('details.details-menu[open]')
  );
  if (!openDetails.length) { return; }

  if (ev.key === 'Escape') {
    openDetails.forEach(function(details) {
      details.open = false;
      var summary = details.querySelector('summary');
      if (summary) { summary.focus(); }
    });
    return;
  }

  if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
    var focused = document.activeElement;
    openDetails.forEach(function(details) {
      var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
      if (!items.length) { return; }
      var idx = items.indexOf(focused);
      if (idx === -1) { return; }
      ev.preventDefault();
      if (ev.key === 'ArrowDown') {
        items[(idx + 1) % items.length].focus();
      } else {
        items[(idx - 1 + items.length) % items.length].focus();
      }
    });
  }
});
№ 03

Pure JS button menu — no <details>

JS-only

This variant abandons <details> entirely. It uses a plain <button> as the trigger and a sibling <div> as the panel, controlled in JavaScript. The markup follows the WAI-ARIA Authoring Practices "menu button" pattern as closely as a real-world menu reasonably can.

  • <button> with aria-haspopup="menu", aria-expanded, and aria-controls pointing at the panel
  • Panel is hidden via CSS display:none until data-open="true"
  • <ul role="menu"> with aria-labelledby referencing the button
  • Each item is a link with role="menuitem" and roving tabindex

Keyboard support is full WAI-ARIA APG menu spec:

  • Enter, Space, ArrowDown on the button → opens and focuses first item
  • ArrowUp on the button → opens and focuses last item
  • ArrowDown / ArrowUp in the menu → wrap-around navigation
  • Home / End → first / last item
  • Typing a printable character → focuses the next item starting with that letter (typeahead)
  • Escape → closes and returns focus to the button
  • Tab from inside the menu → closes the menu, lets browser move focus naturally

Screen readers should announce this as "Table actions, menu button, collapsed" — the canonical menu-button phrasing. When opened, focus moves into the menu and items are announced as "menu item".

Strengths

  • Unambiguous semantics — it's a button, not a disclosure
  • Full APG-compliant keyboard support including typeahead
  • Works identically across browsers (no summary quirks)
  • Easy to extend with submenus or separators

Weaknesses

  • Requires JS to function at all
  • Substantially more code to maintain
  • Easy to get wrong — many bespoke menu impls have bugs
  • Debate: should a list of links even be a "menu"? (See V04)
HTML
<div class="js-menu-wrapper" data-js-menu>
  <button type="button"
          class="js-menu-button"
          aria-haspopup="menu"
          aria-expanded="false"
          aria-controls="v3-panel"
          id="v3-trigger">
    <!-- icon + label -->
  </button>
  <div class="js-menu-panel" id="v3-panel" data-open="false">
    <div class="dropdown-menu">
      <ul role="menu" aria-labelledby="v3-trigger">
        <li role="none">
          <a href="#edit" role="menuitem" tabindex="-1">Edit schema</a>
        </li>
        <!-- … -->
      </ul>
    </div>
  </div>
</div>
JavaScript
// Full APG menu-button implementation
document.querySelectorAll('[data-js-menu]').forEach(function(wrapper) {
  const btn   = wrapper.querySelector('.js-menu-button');
  const panel = wrapper.querySelector('.js-menu-panel');
  const items = () => Array.from(panel.querySelectorAll('[role="menuitem"]'));

  function open(focusIndex = 0) {
    panel.setAttribute('data-open', 'true');
    btn.setAttribute('aria-expanded', 'true');
    const list = items();
    const idx = focusIndex === -1 ? list.length - 1 : focusIndex;
    list[idx]?.focus();
  }
  function close(returnFocus = true) {
    panel.setAttribute('data-open', 'false');
    btn.setAttribute('aria-expanded', 'false');
    if (returnFocus) btn.focus();
  }
  const isOpen = () => panel.getAttribute('data-open') === 'true';

  btn.addEventListener('click', () => isOpen() ? close() : open(0));

  btn.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault(); open(0);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault(); open(-1);
    }
  });

  panel.addEventListener('keydown', (e) => {
    const list = items();
    const idx = list.indexOf(document.activeElement);
    if (e.key === 'Escape') { e.preventDefault(); close(true); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); list[(idx + 1) % list.length].focus(); }
    else if (e.key === 'ArrowUp')   { e.preventDefault(); list[(idx - 1 + list.length) % list.length].focus(); }
    else if (e.key === 'Home')      { e.preventDefault(); list[0].focus(); }
    else if (e.key === 'End')       { e.preventDefault(); list[list.length - 1].focus(); }
    else if (e.key === 'Tab')       { close(false); }
    else if (e.key.length === 1 && /[\S]/.test(e.key)) {
      // typeahead: jump to next item starting with this letter
      const ch = e.key.toLowerCase();
      for (let i = 1; i <= list.length; i++) {
        const next = list[(idx + i) % list.length];
        if (next.textContent.trim().toLowerCase().startsWith(ch)) {
          next.focus(); break;
        }
      }
    }
  });

  document.addEventListener('click', (e) => {
    if (isOpen() && !wrapper.contains(e.target)) close(false);
  });
});
№ 04

Disclosure pattern — a list of links, not a "menu"

Heydon / Roselli

This variant exists to test a contrarian argument: that the Datasette cog menu shouldn't be a "menu" in the ARIA sense at all. The ARIA menu role is designed for application menus — File, Edit, View — where items are commands you execute. A list of links to other pages is conceptually navigation, and the appropriate pattern for "button reveals navigation" is the disclosure widget.

Accessibility practitioners Adrian Roselli and Heydon Pickering have both written extensively that the ARIA menu pattern is over-used and frequently misused. The disclosure pattern is simpler, better-supported, and matches user expectations for a list of links.

Markup is minimal:

  • <button aria-expanded="false" aria-controls="..."> — no aria-haspopup, no role="menu"
  • A plain <ul> of <a> links — no menuitem roles, no roving tabindex
  • Tab works naturally through the links once they're visible

Keyboard behaviour: Click or Enter/Space to toggle. Tab moves through the links sequentially (because they're real, focusable links, not tabindex="-1" menuitems). Escape closes and returns focus to the button. No Arrow keys — that would be the menu pattern, and we explicitly chose not to be a menu.

Screen readers announce "Table actions, button, collapsed". When opened, they announce "expanded". As the user Tabs in, each link is announced as just that — a link.

Strengths

  • Matches user mental model — these are links, not commands
  • Smallest possible JavaScript surface area
  • Tab works naturally, no roving tabindex bookkeeping
  • Universally well-supported by screen readers

Weaknesses

  • No Arrow-key navigation — may surprise power users
  • Loses the "menu" affordance some sighted users expect
  • Items in the panel are part of the regular Tab order
  • Doesn't fit if any item is a command (e.g. a destructive action button)
HTML
<button type="button"
        aria-expanded="false"
        aria-controls="v4-panel"
        id="v4-trigger">
  <!-- icon + label -->
</button>
<div id="v4-panel" hidden>
  <ul aria-labelledby="v4-trigger">
    <li><a href="#edit">Edit schema</a></li>
    <li><a href="#export">Export as CSV</a></li>
    <!-- … -->
  </ul>
</div>
JavaScript
document.querySelectorAll('[data-disclosure]').forEach(function(wrapper) {
  const btn = wrapper.querySelector('button');
  const panel = wrapper.querySelector('.js-menu-panel');
  const isOpen = () => btn.getAttribute('aria-expanded') === 'true';

  function set(open) {
    btn.setAttribute('aria-expanded', String(open));
    panel.setAttribute('data-open', String(open));
  }

  btn.addEventListener('click', () => set(!isOpen()));

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && isOpen()) {
      set(false); btn.focus();
    }
  });

  document.addEventListener('click', (e) => {
    if (isOpen() && !wrapper.contains(e.target)) set(false);
  });
});
№ 05

Native HTML Popover API

Platform 2024+

The Popover API is a relatively recent HTML feature (Chrome 114, Safari 17, Firefox 125) that adds a popover attribute and a popovertarget attribute. The browser handles open/close, light dismiss (clicks outside, Escape), and a so-called top layer render that escapes overflow clipping.

  • <button popovertarget="v5-panel"> wires up the trigger declaratively
  • <div id="v5-panel" popover> is the popover content
  • The browser toggles visibility; no JavaScript required for the open/close mechanics
  • Escape and outside-clicks dismiss the popover natively
  • Items inside still need role="menu" and menuitem if you want menu semantics

Keyboard caveats: the Popover API gives you light dismiss for free but does not give you arrow-key navigation inside the panel — for that, you still need the JS keyboard handlers from Variant 03. The example above uses simple Tab navigation; a production version would layer the V03 keyboard logic on top.

Anchor positioning is a separate emerging spec (CSS Anchor Positioning, anchor-name / position-anchor) that lets the popover position itself relative to the trigger without any JS. Browser support is more limited (Chrome 125+), so for now absolute positioning is the pragmatic choice.

Strengths

  • Open/close, light dismiss, and Escape are platform-native
  • Top-layer rendering escapes overflow:hidden ancestors
  • Declarative trigger via popovertarget
  • Pairs nicely with future CSS anchor positioning

Weaknesses

  • Still need JS for arrow keys and roving tabindex
  • Browser support only stable since 2024
  • Positioning logic still bespoke until anchor-positioning lands
  • aria-expanded sync is not automatic on the trigger
HTML (no JS required for basic open/close)
<button type="button"
        popovertarget="v5-panel"
        aria-haspopup="menu"
        id="v5-trigger">
  <!-- icon + label -->
</button>

<div id="v5-panel" popover>
  <ul role="menu" aria-labelledby="v5-trigger">
    <li role="none">
      <a href="#edit" role="menuitem">Edit schema</a>
    </li>
    <!-- … -->
  </ul>
</div>
Optional JS — sync aria-expanded + anchor positioning
// Without anchor positioning we position manually below the button.
// And we sync aria-expanded via the 'toggle' event the API dispatches.
document.querySelectorAll('[popovertarget]').forEach((btn) => {
  const panel = document.getElementById(btn.getAttribute('popovertarget'));
  if (!panel) return;

  panel.addEventListener('toggle', (e) => {
    const open = e.newState === 'open';
    btn.setAttribute('aria-expanded', String(open));
    if (open) {
      const r = btn.getBoundingClientRect();
      panel.style.top  = (r.bottom + window.scrollY + 10) + 'px';
      panel.style.left = (r.left   + window.scrollX) + 'px';
      // move focus into the menu
      panel.querySelector('[role="menuitem"]')?.focus();
    }
  });
});