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
<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.