Basic Dropdown
A menu button with the full keyboard model - arrows, Home/End, Escape, focus restore.
6 個のフレームワーク中級
A menu with a submenu that flies out sideways, driven by Left and Right.
<!--
A submenu opens SIDEWAYS, and that direction is the whole keyboard model:
ArrowRight on a parent item opens the submenu, focus onto its first item
ArrowLeft inside a submenu closes it, focus BACK on the parent item
ArrowUp/Down inside a submenu move within it, not the parent
Escape inside a submenu closes just the submenu - one level, not all
Left/Right map onto the direction the panel physically travels. That is why
the flyout goes to the side rather than expanding in place: the geometry is
teaching the shortcut.
The parent item is itself a menuitem with its own aria-haspopup="menu" and
aria-expanded - it is both a row in this menu and the trigger for the next.
Escape closing only ONE level is the detail most implementations miss; a user
who opened three levels expects to walk back out, not be ejected.
-->
<div class="ndd">
<button
class="ndd__trigger"
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="ndd-menu"
>
Insert
</button>
<ul class="ndd__menu" id="ndd-menu" role="menu" aria-label="Insert" hidden>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Text block</button></li>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Image</button></li>
<li class="ndd__sub" role="none">
<button
class="ndd__item ndd__item--parent"
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="ndd-submenu"
>
Embed
<svg class="ndd__caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<ul class="ndd__submenu" id="ndd-submenu" role="menu" aria-label="Embed" hidden>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Video</button></li>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Code sandbox</button></li>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Figma frame</button></li>
</ul>
</li>
<li role="none"><button class="ndd__item" type="button" role="menuitem">Divider</button></li>
</ul>
</div>
<script>
document.querySelectorAll('.ndd').forEach(function (root) {
var trigger = root.querySelector('.ndd__trigger');
var menu = root.querySelector('.ndd__menu');
var parent = root.querySelector('.ndd__item--parent');
var submenu = root.querySelector('.ndd__submenu');
// Top-level ring EXCLUDES the submenu's own items.
var top = Array.prototype.filter.call(root.querySelectorAll('.ndd__item'), function (el) {
return !submenu.contains(el);
});
var subItems = Array.prototype.slice.call(submenu.querySelectorAll('.ndd__item'));
function setOpen(open) {
menu.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (!open) setSubOpen(false);
}
function setSubOpen(open) {
submenu.hidden = !open;
parent.setAttribute('aria-expanded', String(open));
}
function ring(items, from, delta) {
var next = from + delta;
items[(next + items.length) % items.length].focus();
}
trigger.addEventListener('click', function () {
var next = trigger.getAttribute('aria-expanded') !== 'true';
setOpen(next);
if (next) top[0].focus();
});
parent.addEventListener('click', function () {
var next = parent.getAttribute('aria-expanded') !== 'true';
setSubOpen(next);
if (next) subItems[0].focus();
});
submenu.addEventListener('keydown', function (event) {
var index = subItems.indexOf(document.activeElement);
if (index === -1) return;
// Left and Escape both mean "one level out" - never "close everything".
if (event.key === 'ArrowLeft' || event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setSubOpen(false);
parent.focus();
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
event.stopPropagation();
ring(subItems, index, event.key === 'ArrowDown' ? 1 : -1);
});
menu.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setOpen(false);
trigger.focus();
return;
}
var index = top.indexOf(document.activeElement);
if (index === -1) return;
if (event.key === 'ArrowRight' && document.activeElement === parent) {
event.preventDefault();
setSubOpen(true);
subItems[0].focus();
return;
}
if (event.key === 'Home') {
event.preventDefault();
top[0].focus();
return;
}
if (event.key === 'End') {
event.preventDefault();
top[top.length - 1].focus();
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
// Moving off the parent row closes its flyout - a submenu hanging beside
// an unfocused row is a ghost.
setSubOpen(false);
ring(top, index, event.key === 'ArrowDown' ? 1 : -1);
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
});
</script>| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
label必須 | string | - | 読み込み中に読み上げられるアクセシブルなラベル。 |
items必須 | NestedItem[] | - | レンダリングする項目の配列。 |
onSelect | (id: string) => void | - | ユーザーが選択したメニュー項目を引数に呼ばれます。 |
The direction is the model. ArrowRight on a parent opens the flyout and lands on its first item; ArrowLeft closes it and puts focus back on the parent; Up/Down stay inside whichever level you are on. Left and Right map onto the direction the panel physically travels - the geometry is teaching the shortcut, which is why the submenu goes sideways rather than expanding in place. The parent row is both a `menuitem` and a menu trigger, carrying its own `aria-haspopup` and `aria-expanded`. The detail most implementations miss is that Escape inside a submenu closes ONE level: `stopPropagation` is load-bearing there, or the event also reaches the parent handler and the whole stack collapses - the user asked to step back, not to be ejected. The flyout overlaps its parent by 4px so the pointer cannot fall through a diagonal dead zone on the way over.