Current Datasette — main branch
Baseline
Live example
How it works
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-expandedsync (relies on AT to readopen)
Source
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); });