Nested roots

A group inside a group — the nearest root wins, so an inner list navigates by its own keys while the list around it keeps its.

Composite widgets nest: a menu with a row of reactions along the top, a settings list with a colour grid inside one of its rows. data-keyrove-root marks a group, and the root keyrove navigates by is the nearest one above the focused element — so an inner group can sit inside an outer one and keep its own keys, columns, and page size.

walk the menu, and from Reply steps into the reaction row. Inside it, move between reactions and Escape returns to the menu. The inner group is a root with its own attributes; nothing else about it is special — the same data-keyrove-item marks its items.

  • Reply
  • Reply in thread
  • Copy link
  • Pin to channel
  • Delete message
Waiting for a keypress…
<ul id="message-actions">
  <li
    data-keyrove-root
    data-keyrove-next-key="ArrowRight"
    data-keyrove-prev-key="ArrowLeft"
  >
    <button data-keyrove-item tabindex="0">👍</button>
    <button data-keyrove-item tabindex="0">❤️</button>
    <!-- … -->
    <button data-keyrove-item tabindex="0">🎉</button>
  </li>
  <li data-keyrove-item tabindex="0">Reply</li>
  <li data-keyrove-item tabindex="0">Reply in thread</li>
  <li data-keyrove-item tabindex="0">Copy link</li>
  <!-- … -->
  <li data-keyrove-item tabindex="0">Delete message</li>
</ul>
document
  .querySelector('#message-actions')
  .addEventListener('keydown', (e) => keyRove(e));

One listener, on the outer list. The inner group needs none of its own: the keydown bubbles up to that listener, and keyRove resolves the root from the event's target rather than from the element the listener sits on.

That is also why the outer list here carries no data-keyrove-root — it is the listener's element, which keyrove falls back to. Move the listener further up (to a panel, or to document) and the outer group needs the attribute too.

The nearest root wins

Root attributes are read off the group focus is currently in, and nothing is inherited across the boundary:

<div id="panel" data-keyrove-root data-keyrove-page-length="5">
  <div data-keyrove-item tabindex="0">Row</div>

  <!-- A grid inside a list: 4 columns and 2-row pages apply in here only. -->
  <div
    data-keyrove-root
    data-keyrove-cols-length="4"
    data-keyrove-page-length="2"
  >
    <button data-keyrove-item tabindex="0">1</button>
    <button data-keyrove-item tabindex="0">2</button>
  </div>

  <div data-keyrove-item tabindex="0">Row</div>
</div>

PageDown moves five rows in the panel and two grid rows in the grid; move a cell inside the grid and nothing outside it. Home and End follow the same rule: while focus is in the grid they land on its first and last cell, not on the panel's.

The outer group still sees the inner items

data-keyrove-item is matched anywhere below the root, nested groups included. The outer group's order therefore runs straight through the inner one: in the demo, from Reply lands on the last reaction rather than skipping the row.

That is usually what you want — the outer group's keys reach the inner one instead of it being reachable by Tab alone — but an inner group is not hidden from the list around it, and there is no attribute that hides it. If a group should be unreachable that way, put it outside the outer root.

Getting back out

Once focus is inside the inner group, no key reaches the outer one. A key the inner root does not bind — in the reaction row above — does nothing at all: keyrove leaves it to the browser rather than passing it on to the group outside. Leaving is yours to wire, and there are two ways to do it.

Tab is the first, and it needs no code beyond roving tabindex. keyrove never binds Tab, so the browser's own focus order is always a way out of a nested group — roving tabindex just makes it a tidy one. Give each group its own roving items and each becomes a single tab stop, so Tab moves group to group and Shift+Tab back — the arrangement the ARIA authoring practices describe for composite widgets.

The second is a key of your own. The demo above binds Escape on the reaction row, handing focus to the item beside it:

reactions.addEventListener('keydown', (e) => {
  if (e.code !== 'Escape') return;

  reactions.nextElementSibling.focus();
});

keyrove never acts on Escape, so nothing collides — the same holds for any code the roots involved have not bound. Which also means the exit key is yours to choose on the same terms as the navigation keys.

Nesting, or two roots side by side

Nest only when the inner group genuinely sits inside the outer one's flow. Groups that are merely on the same page do not need to be nested at all: marking each of them data-keyrove-root under one delegated listener keeps them fully independent, with no group's items in another's order. See several groups, one listener.