ネストメニュー
「ファイル」メニューからサブメニューが右へ多階層に展開します。ホバーでの自然な開閉に加え、矢印キーの→←での階層移動やEscでの一括終了にも対応しています。
ライブデモ
コード
HTML
<!-- ネストメニュー: 「ファイル」メニューからサブメニューが右へ多階層に展開する。hoverと矢印キー(→/←)の両方に対応 -->
<div class="dnm-stage">
<div class="dnm-anchor">
<button type="button" class="dnm-trigger" id="dnmTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="dnmMenu">
<span class="dnm-trigger__icon" aria-hidden="true">🗂</span>
<span>ファイル</span>
<span class="dnm-caret" aria-hidden="true">▾</span>
</button>
<ul class="dnm-menu dnm-menu--root" id="dnmMenu" role="menu" aria-labelledby="dnmTrigger" hidden>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubNew">
<span class="dnm-item__icon" aria-hidden="true">✦</span>
<span class="dnm-item__label">新規作成</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubNew" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="new-doc">
<span class="dnm-item__icon" aria-hidden="true">📄</span>
<span class="dnm-item__label">ドキュメント</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="new-sheet">
<span class="dnm-item__icon" aria-hidden="true">📊</span>
<span class="dnm-item__label">スプレッドシート</span>
</button>
</li>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubFolder">
<span class="dnm-item__icon" aria-hidden="true">📁</span>
<span class="dnm-item__label">フォルダ</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubFolder" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="folder-empty">
<span class="dnm-item__icon" aria-hidden="true">▢</span>
<span class="dnm-item__label">空のフォルダ</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="folder-template">
<span class="dnm-item__icon" aria-hidden="true">▤</span>
<span class="dnm-item__label">テンプレートから</span>
</button>
</li>
</ul>
</li>
</ul>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="open">
<span class="dnm-item__icon" aria-hidden="true">📂</span>
<span class="dnm-item__label">開く</span>
</button>
</li>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubSave">
<span class="dnm-item__icon" aria-hidden="true">💾</span>
<span class="dnm-item__label">名前を付けて保存</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubSave" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="save-pdf">
<span class="dnm-item__icon" aria-hidden="true">📄</span>
<span class="dnm-item__label">PDF</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="save-word">
<span class="dnm-item__icon" aria-hidden="true">📝</span>
<span class="dnm-item__label">Word</span>
</button>
</li>
</ul>
</li>
<li class="dnm-sep" role="separator"></li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="print">
<span class="dnm-item__icon" aria-hidden="true">🖨</span>
<span class="dnm-item__label">印刷</span>
</button>
</li>
</ul>
</div>
<p class="dnm-hint" id="dnmStatus" aria-live="polite">クリック/ホバーで開き、→キーでサブメニューへ、←キーで一つ戻ります。</p>
</div>
CSS
/* 温かみのあるパーチメント地に、バーガンディのアクセントを添えたデスクトップ風ファイルメニュー */
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 64px 24px 160px;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background:
radial-gradient(circle at 18% 12%, rgba(190, 76, 60, .07) 0%, transparent 45%),
radial-gradient(circle at 85% 85%, rgba(217, 168, 91, .10) 0%, transparent 45%),
#faf6ef;
color: #2c2620;
}
.dnm-stage { width: 100%; max-width: 420px; display: grid; justify-items: center; gap: 16px; }
.dnm-anchor { position: relative; display: inline-block; }
.dnm-trigger {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 20px;
font: inherit;
font-size: 14px;
font-weight: 700;
color: #3a2f26;
background: #fff;
border: 1px solid #e8ddc9;
border-radius: 11px;
cursor: pointer;
box-shadow: 0 12px 26px -16px rgba(60, 42, 20, .3);
transition: border-color .18s ease, box-shadow .18s ease;
}
.dnm-trigger:hover { border-color: #d9a85b; }
.dnm-trigger:focus-visible { outline: 2px solid #d9a85b; outline-offset: 2px; }
.dnm-trigger[aria-expanded="true"] { border-color: #be4c3c; box-shadow: 0 14px 30px -14px rgba(190, 76, 60, .3); }
.dnm-caret { font-size: 10px; display: inline-block; transition: transform .22s ease; }
.dnm-trigger[aria-expanded="true"] .dnm-caret { transform: rotate(180deg); }
.dnm-menu {
position: absolute;
z-index: 20;
list-style: none;
margin: 0;
padding: 6px;
min-width: 216px;
background: #fffdf9;
border: 1px solid #ece2d0;
border-radius: 13px;
box-shadow: 0 28px 56px -20px rgba(44, 32, 16, .32);
opacity: 0;
transform: translateY(-6px) scale(.97);
transition: opacity .15s ease, transform .15s ease;
}
.dnm-menu[hidden] { display: none; }
.dnm-menu.is-open { opacity: 1; transform: translateY(0) scale(1); }
.dnm-menu--root { top: calc(100% + 10px); left: 0; transform-origin: top left; }
.dnm-menu--root.dnm-menu--flip { left: auto; right: 0; transform-origin: top right; }
.dnm-has-sub { position: relative; }
.dnm-menu--sub { top: -7px; left: calc(100% + 6px); transform-origin: top left; }
.dnm-menu--sub.dnm-menu--flip { left: auto; right: calc(100% + 6px); transform-origin: top right; }
.dnm-sep { height: 1px; margin: 6px 4px; background: #ece2d0; }
.dnm-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
font: inherit;
font-size: 13.5px;
color: #3a2f26;
background: none;
border: none;
border-radius: 8px;
text-align: left;
cursor: pointer;
transition: background .13s ease, color .13s ease;
}
.dnm-item:hover,
.dnm-item.is-active,
.dnm-has-sub.is-open > .dnm-item { background: #fbeee0; color: #9a3a2c; }
.dnm-item:focus-visible { outline: 2px solid #be4c3c; outline-offset: -2px; }
.dnm-item__icon { flex: none; width: 18px; text-align: center; font-size: 13px; color: #a9825a; }
.dnm-item__label { flex: 1; white-space: nowrap; }
.dnm-item__arrow { flex: none; font-size: 10px; color: #b79a78; }
.dnm-item[aria-expanded="true"] .dnm-item__arrow { color: #9a3a2c; }
.dnm-hint { margin: 0; font-size: 12px; color: #8a7c68; text-align: center; max-width: 340px; line-height: 1.7; }
@media (prefers-reduced-motion: reduce) {
.dnm-menu, .dnm-caret { transition: none; }
}
JavaScript
// ネストメニュー: 直下の項目だけを対象にした矢印キー操作とホバーの短い遅延で、
// 何階層になってもサブメニューを右へ正しく展開・収納する(画面端では自動的に左へ反転)
(() => {
const stage = document.querySelector('.dnm-stage');
const trigger = document.getElementById('dnmTrigger');
const rootMenu = document.getElementById('dnmMenu');
const status = document.getElementById('dnmStatus');
if (!stage || !trigger || !rootMenu || !status) return;
// メニュー直下の項目だけを取得する(孫以下の入れ子メニューは対象外)
const getItems = (menuEl) => Array.from(menuEl.querySelectorAll(':scope > li > .dnm-item'));
const positionMenu = (menuEl) => {
menuEl.classList.remove('dnm-menu--flip');
menuEl.style.marginTop = '';
let rect = menuEl.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) menuEl.classList.add('dnm-menu--flip');
rect = menuEl.getBoundingClientRect();
const overflowBottom = rect.bottom - (window.innerHeight - 8);
if (overflowBottom > 0) menuEl.style.marginTop = `${-overflowBottom}px`;
};
function closeSubmenuFor(itemBtn, focusBack) {
if (!itemBtn) return;
const sub = document.getElementById(itemBtn.getAttribute('aria-controls') || '');
if (sub) {
// 内側で開いている孫サブメニューも一緒に畳む
sub.querySelectorAll('li.is-open').forEach((li) => {
li.classList.remove('is-open');
const innerBtn = li.querySelector(':scope > .dnm-item');
if (innerBtn) innerBtn.setAttribute('aria-expanded', 'false');
const innerSub = li.querySelector(':scope > .dnm-menu');
if (innerSub) {
innerSub.hidden = true;
innerSub.classList.remove('is-open');
}
});
sub.hidden = true;
sub.classList.remove('is-open');
}
itemBtn.setAttribute('aria-expanded', 'false');
if (itemBtn.parentElement) itemBtn.parentElement.classList.remove('is-open');
if (focusBack) itemBtn.focus();
}
function closeSiblingSubmenus(itemBtn) {
const li = itemBtn.parentElement;
const menu = li ? li.parentElement : null;
if (!menu) return;
Array.from(menu.children).forEach((sibLi) => {
if (sibLi !== li && sibLi.classList.contains('is-open')) {
const btn = sibLi.querySelector(':scope > .dnm-item');
closeSubmenuFor(btn, false);
}
});
}
const openSubmenuFor = (itemBtn, focusFirst) => {
if (!itemBtn) return;
const sub = document.getElementById(itemBtn.getAttribute('aria-controls') || '');
if (!sub) return;
closeSiblingSubmenus(itemBtn);
sub.hidden = false;
void sub.offsetHeight; // 強制リフローで実測してから配置を決める
positionMenu(sub);
sub.classList.add('is-open');
itemBtn.setAttribute('aria-expanded', 'true');
if (itemBtn.parentElement) itemBtn.parentElement.classList.add('is-open');
if (focusFirst) {
const first = getItems(sub)[0];
if (first) first.focus();
}
};
const setActive = (menuEl, index) => {
const menuItems = getItems(menuEl);
menuItems.forEach((it, i) => it.classList.toggle('is-active', i === index));
};
const isRootOpen = () => trigger.getAttribute('aria-expanded') === 'true';
const openRoot = () => {
rootMenu.hidden = false;
void rootMenu.offsetHeight; // 強制リフローで実測してから配置を決める
positionMenu(rootMenu);
rootMenu.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
const first = getItems(rootMenu)[0];
setActive(rootMenu, 0);
if (first) first.focus();
};
const closeAll = (focusTrigger) => {
rootMenu.querySelectorAll('li.is-open').forEach((li) => {
li.classList.remove('is-open');
const btn = li.querySelector(':scope > .dnm-item');
if (btn) btn.setAttribute('aria-expanded', 'false');
});
rootMenu.querySelectorAll('.dnm-menu--sub').forEach((sub) => {
sub.hidden = true;
sub.classList.remove('is-open');
});
rootMenu.hidden = true;
rootMenu.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
if (focusTrigger) trigger.focus();
};
trigger.addEventListener('click', () => {
if (isRootOpen()) closeAll(false); else openRoot();
});
trigger.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openRoot();
}
});
const activate = (item) => {
const label = item.querySelector('.dnm-item__label');
const text = label ? label.textContent : item.textContent.trim();
status.textContent = `「${text}」を実行しました`;
closeAll(true);
};
rootMenu.addEventListener('keydown', (e) => {
const currentItem = e.target.closest ? e.target.closest('.dnm-item') : null;
if (!currentItem) return;
const currentMenu = currentItem.closest('.dnm-menu');
if (!currentMenu) return;
const menuItems = getItems(currentMenu);
const idx = menuItems.indexOf(currentItem);
const focusByIndex = (i) => {
const len = menuItems.length;
const target = menuItems[((i % len) + len) % len];
if (!target) return;
setActive(currentMenu, menuItems.indexOf(target));
target.focus();
};
switch (e.key) {
case 'ArrowDown': e.preventDefault(); focusByIndex(idx + 1); break;
case 'ArrowUp': e.preventDefault(); focusByIndex(idx - 1); break;
case 'Home': e.preventDefault(); focusByIndex(0); break;
case 'End': e.preventDefault(); focusByIndex(menuItems.length - 1); break;
case 'ArrowRight':
e.preventDefault();
if (currentItem.getAttribute('aria-haspopup') === 'true') openSubmenuFor(currentItem, true);
break;
case 'ArrowLeft': {
e.preventDefault();
if (currentMenu !== rootMenu) {
const parentLi = currentMenu.closest('li');
const parentBtn = parentLi ? parentLi.querySelector(':scope > .dnm-item') : null;
if (parentBtn) closeSubmenuFor(parentBtn, true);
}
break;
}
case 'Enter':
case ' ':
e.preventDefault();
if (currentItem.getAttribute('aria-haspopup') === 'true') openSubmenuFor(currentItem, true);
else activate(currentItem);
break;
case 'Escape':
e.preventDefault();
closeAll(true);
break;
case 'Tab':
closeAll(false);
break;
default:
break;
}
});
Array.from(rootMenu.querySelectorAll('.dnm-item')).forEach((item) => {
const hasSub = item.getAttribute('aria-haspopup') === 'true';
item.addEventListener('click', () => {
if (hasSub) openSubmenuFor(item, true);
else activate(item);
});
item.addEventListener('mouseenter', () => {
const menuEl = item.closest('.dnm-menu');
if (menuEl) setActive(menuEl, getItems(menuEl).indexOf(item));
});
});
// ホバーでの開閉(隣に一瞬かすっただけで閉じないよう短い遅延を挟む)
const timers = new Map();
Array.from(rootMenu.querySelectorAll('.dnm-has-sub')).forEach((li) => {
const btn = li.querySelector(':scope > .dnm-item');
if (!btn) return;
li.addEventListener('mouseenter', () => {
clearTimeout(timers.get(li));
timers.set(li, setTimeout(() => openSubmenuFor(btn, false), 90));
});
li.addEventListener('mouseleave', () => {
clearTimeout(timers.get(li));
timers.set(li, setTimeout(() => closeSubmenuFor(btn, false), 200));
});
});
document.addEventListener('click', (e) => {
if (isRootOpen() && !stage.contains(e.target)) closeAll(false);
});
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ネストメニュー」の効果を追加してください。
# 追加してほしい効果
ネストメニュー(ツールチップ & メニュー)
「ファイル」メニューからサブメニューが右へ多階層に展開します。ホバーでの自然な開閉に加え、矢印キーの→←での階層移動やEscでの一括終了にも対応しています。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ネストメニュー: 「ファイル」メニューからサブメニューが右へ多階層に展開する。hoverと矢印キー(→/←)の両方に対応 -->
<div class="dnm-stage">
<div class="dnm-anchor">
<button type="button" class="dnm-trigger" id="dnmTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="dnmMenu">
<span class="dnm-trigger__icon" aria-hidden="true">🗂</span>
<span>ファイル</span>
<span class="dnm-caret" aria-hidden="true">▾</span>
</button>
<ul class="dnm-menu dnm-menu--root" id="dnmMenu" role="menu" aria-labelledby="dnmTrigger" hidden>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubNew">
<span class="dnm-item__icon" aria-hidden="true">✦</span>
<span class="dnm-item__label">新規作成</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubNew" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="new-doc">
<span class="dnm-item__icon" aria-hidden="true">📄</span>
<span class="dnm-item__label">ドキュメント</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="new-sheet">
<span class="dnm-item__icon" aria-hidden="true">📊</span>
<span class="dnm-item__label">スプレッドシート</span>
</button>
</li>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubFolder">
<span class="dnm-item__icon" aria-hidden="true">📁</span>
<span class="dnm-item__label">フォルダ</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubFolder" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="folder-empty">
<span class="dnm-item__icon" aria-hidden="true">▢</span>
<span class="dnm-item__label">空のフォルダ</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="folder-template">
<span class="dnm-item__icon" aria-hidden="true">▤</span>
<span class="dnm-item__label">テンプレートから</span>
</button>
</li>
</ul>
</li>
</ul>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="open">
<span class="dnm-item__icon" aria-hidden="true">📂</span>
<span class="dnm-item__label">開く</span>
</button>
</li>
<li role="none" class="dnm-has-sub">
<button type="button" class="dnm-item" role="menuitem" aria-haspopup="true" aria-expanded="false" aria-controls="dnmSubSave">
<span class="dnm-item__icon" aria-hidden="true">💾</span>
<span class="dnm-item__label">名前を付けて保存</span>
<span class="dnm-item__arrow" aria-hidden="true">▸</span>
</button>
<ul class="dnm-menu dnm-menu--sub" id="dnmSubSave" role="menu" hidden>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="save-pdf">
<span class="dnm-item__icon" aria-hidden="true">📄</span>
<span class="dnm-item__label">PDF</span>
</button>
</li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="save-word">
<span class="dnm-item__icon" aria-hidden="true">📝</span>
<span class="dnm-item__label">Word</span>
</button>
</li>
</ul>
</li>
<li class="dnm-sep" role="separator"></li>
<li role="none">
<button type="button" class="dnm-item" role="menuitem" data-action="print">
<span class="dnm-item__icon" aria-hidden="true">🖨</span>
<span class="dnm-item__label">印刷</span>
</button>
</li>
</ul>
</div>
<p class="dnm-hint" id="dnmStatus" aria-live="polite">クリック/ホバーで開き、→キーでサブメニューへ、←キーで一つ戻ります。</p>
</div>
【CSS】
/* 温かみのあるパーチメント地に、バーガンディのアクセントを添えたデスクトップ風ファイルメニュー */
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 64px 24px 160px;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background:
radial-gradient(circle at 18% 12%, rgba(190, 76, 60, .07) 0%, transparent 45%),
radial-gradient(circle at 85% 85%, rgba(217, 168, 91, .10) 0%, transparent 45%),
#faf6ef;
color: #2c2620;
}
.dnm-stage { width: 100%; max-width: 420px; display: grid; justify-items: center; gap: 16px; }
.dnm-anchor { position: relative; display: inline-block; }
.dnm-trigger {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 20px;
font: inherit;
font-size: 14px;
font-weight: 700;
color: #3a2f26;
background: #fff;
border: 1px solid #e8ddc9;
border-radius: 11px;
cursor: pointer;
box-shadow: 0 12px 26px -16px rgba(60, 42, 20, .3);
transition: border-color .18s ease, box-shadow .18s ease;
}
.dnm-trigger:hover { border-color: #d9a85b; }
.dnm-trigger:focus-visible { outline: 2px solid #d9a85b; outline-offset: 2px; }
.dnm-trigger[aria-expanded="true"] { border-color: #be4c3c; box-shadow: 0 14px 30px -14px rgba(190, 76, 60, .3); }
.dnm-caret { font-size: 10px; display: inline-block; transition: transform .22s ease; }
.dnm-trigger[aria-expanded="true"] .dnm-caret { transform: rotate(180deg); }
.dnm-menu {
position: absolute;
z-index: 20;
list-style: none;
margin: 0;
padding: 6px;
min-width: 216px;
background: #fffdf9;
border: 1px solid #ece2d0;
border-radius: 13px;
box-shadow: 0 28px 56px -20px rgba(44, 32, 16, .32);
opacity: 0;
transform: translateY(-6px) scale(.97);
transition: opacity .15s ease, transform .15s ease;
}
.dnm-menu[hidden] { display: none; }
.dnm-menu.is-open { opacity: 1; transform: translateY(0) scale(1); }
.dnm-menu--root { top: calc(100% + 10px); left: 0; transform-origin: top left; }
.dnm-menu--root.dnm-menu--flip { left: auto; right: 0; transform-origin: top right; }
.dnm-has-sub { position: relative; }
.dnm-menu--sub { top: -7px; left: calc(100% + 6px); transform-origin: top left; }
.dnm-menu--sub.dnm-menu--flip { left: auto; right: calc(100% + 6px); transform-origin: top right; }
.dnm-sep { height: 1px; margin: 6px 4px; background: #ece2d0; }
.dnm-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
font: inherit;
font-size: 13.5px;
color: #3a2f26;
background: none;
border: none;
border-radius: 8px;
text-align: left;
cursor: pointer;
transition: background .13s ease, color .13s ease;
}
.dnm-item:hover,
.dnm-item.is-active,
.dnm-has-sub.is-open > .dnm-item { background: #fbeee0; color: #9a3a2c; }
.dnm-item:focus-visible { outline: 2px solid #be4c3c; outline-offset: -2px; }
.dnm-item__icon { flex: none; width: 18px; text-align: center; font-size: 13px; color: #a9825a; }
.dnm-item__label { flex: 1; white-space: nowrap; }
.dnm-item__arrow { flex: none; font-size: 10px; color: #b79a78; }
.dnm-item[aria-expanded="true"] .dnm-item__arrow { color: #9a3a2c; }
.dnm-hint { margin: 0; font-size: 12px; color: #8a7c68; text-align: center; max-width: 340px; line-height: 1.7; }
@media (prefers-reduced-motion: reduce) {
.dnm-menu, .dnm-caret { transition: none; }
}
【JavaScript】
// ネストメニュー: 直下の項目だけを対象にした矢印キー操作とホバーの短い遅延で、
// 何階層になってもサブメニューを右へ正しく展開・収納する(画面端では自動的に左へ反転)
(() => {
const stage = document.querySelector('.dnm-stage');
const trigger = document.getElementById('dnmTrigger');
const rootMenu = document.getElementById('dnmMenu');
const status = document.getElementById('dnmStatus');
if (!stage || !trigger || !rootMenu || !status) return;
// メニュー直下の項目だけを取得する(孫以下の入れ子メニューは対象外)
const getItems = (menuEl) => Array.from(menuEl.querySelectorAll(':scope > li > .dnm-item'));
const positionMenu = (menuEl) => {
menuEl.classList.remove('dnm-menu--flip');
menuEl.style.marginTop = '';
let rect = menuEl.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) menuEl.classList.add('dnm-menu--flip');
rect = menuEl.getBoundingClientRect();
const overflowBottom = rect.bottom - (window.innerHeight - 8);
if (overflowBottom > 0) menuEl.style.marginTop = `${-overflowBottom}px`;
};
function closeSubmenuFor(itemBtn, focusBack) {
if (!itemBtn) return;
const sub = document.getElementById(itemBtn.getAttribute('aria-controls') || '');
if (sub) {
// 内側で開いている孫サブメニューも一緒に畳む
sub.querySelectorAll('li.is-open').forEach((li) => {
li.classList.remove('is-open');
const innerBtn = li.querySelector(':scope > .dnm-item');
if (innerBtn) innerBtn.setAttribute('aria-expanded', 'false');
const innerSub = li.querySelector(':scope > .dnm-menu');
if (innerSub) {
innerSub.hidden = true;
innerSub.classList.remove('is-open');
}
});
sub.hidden = true;
sub.classList.remove('is-open');
}
itemBtn.setAttribute('aria-expanded', 'false');
if (itemBtn.parentElement) itemBtn.parentElement.classList.remove('is-open');
if (focusBack) itemBtn.focus();
}
function closeSiblingSubmenus(itemBtn) {
const li = itemBtn.parentElement;
const menu = li ? li.parentElement : null;
if (!menu) return;
Array.from(menu.children).forEach((sibLi) => {
if (sibLi !== li && sibLi.classList.contains('is-open')) {
const btn = sibLi.querySelector(':scope > .dnm-item');
closeSubmenuFor(btn, false);
}
});
}
const openSubmenuFor = (itemBtn, focusFirst) => {
if (!itemBtn) return;
const sub = document.getElementById(itemBtn.getAttribute('aria-controls') || '');
if (!sub) return;
closeSiblingSubmenus(itemBtn);
sub.hidden = false;
void sub.offsetHeight; // 強制リフローで実測してから配置を決める
positionMenu(sub);
sub.classList.add('is-open');
itemBtn.setAttribute('aria-expanded', 'true');
if (itemBtn.parentElement) itemBtn.parentElement.classList.add('is-open');
if (focusFirst) {
const first = getItems(sub)[0];
if (first) first.focus();
}
};
const setActive = (menuEl, index) => {
const menuItems = getItems(menuEl);
menuItems.forEach((it, i) => it.classList.toggle('is-active', i === index));
};
const isRootOpen = () => trigger.getAttribute('aria-expanded') === 'true';
const openRoot = () => {
rootMenu.hidden = false;
void rootMenu.offsetHeight; // 強制リフローで実測してから配置を決める
positionMenu(rootMenu);
rootMenu.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
const first = getItems(rootMenu)[0];
setActive(rootMenu, 0);
if (first) first.focus();
};
const closeAll = (focusTrigger) => {
rootMenu.querySelectorAll('li.is-open').forEach((li) => {
li.classList.remove('is-open');
const btn = li.querySelector(':scope > .dnm-item');
if (btn) btn.setAttribute('aria-expanded', 'false');
});
rootMenu.querySelectorAll('.dnm-menu--sub').forEach((sub) => {
sub.hidden = true;
sub.classList.remove('is-open');
});
rootMenu.hidden = true;
rootMenu.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
if (focusTrigger) trigger.focus();
};
trigger.addEventListener('click', () => {
if (isRootOpen()) closeAll(false); else openRoot();
});
trigger.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openRoot();
}
});
const activate = (item) => {
const label = item.querySelector('.dnm-item__label');
const text = label ? label.textContent : item.textContent.trim();
status.textContent = `「${text}」を実行しました`;
closeAll(true);
};
rootMenu.addEventListener('keydown', (e) => {
const currentItem = e.target.closest ? e.target.closest('.dnm-item') : null;
if (!currentItem) return;
const currentMenu = currentItem.closest('.dnm-menu');
if (!currentMenu) return;
const menuItems = getItems(currentMenu);
const idx = menuItems.indexOf(currentItem);
const focusByIndex = (i) => {
const len = menuItems.length;
const target = menuItems[((i % len) + len) % len];
if (!target) return;
setActive(currentMenu, menuItems.indexOf(target));
target.focus();
};
switch (e.key) {
case 'ArrowDown': e.preventDefault(); focusByIndex(idx + 1); break;
case 'ArrowUp': e.preventDefault(); focusByIndex(idx - 1); break;
case 'Home': e.preventDefault(); focusByIndex(0); break;
case 'End': e.preventDefault(); focusByIndex(menuItems.length - 1); break;
case 'ArrowRight':
e.preventDefault();
if (currentItem.getAttribute('aria-haspopup') === 'true') openSubmenuFor(currentItem, true);
break;
case 'ArrowLeft': {
e.preventDefault();
if (currentMenu !== rootMenu) {
const parentLi = currentMenu.closest('li');
const parentBtn = parentLi ? parentLi.querySelector(':scope > .dnm-item') : null;
if (parentBtn) closeSubmenuFor(parentBtn, true);
}
break;
}
case 'Enter':
case ' ':
e.preventDefault();
if (currentItem.getAttribute('aria-haspopup') === 'true') openSubmenuFor(currentItem, true);
else activate(currentItem);
break;
case 'Escape':
e.preventDefault();
closeAll(true);
break;
case 'Tab':
closeAll(false);
break;
default:
break;
}
});
Array.from(rootMenu.querySelectorAll('.dnm-item')).forEach((item) => {
const hasSub = item.getAttribute('aria-haspopup') === 'true';
item.addEventListener('click', () => {
if (hasSub) openSubmenuFor(item, true);
else activate(item);
});
item.addEventListener('mouseenter', () => {
const menuEl = item.closest('.dnm-menu');
if (menuEl) setActive(menuEl, getItems(menuEl).indexOf(item));
});
});
// ホバーでの開閉(隣に一瞬かすっただけで閉じないよう短い遅延を挟む)
const timers = new Map();
Array.from(rootMenu.querySelectorAll('.dnm-has-sub')).forEach((li) => {
const btn = li.querySelector(':scope > .dnm-item');
if (!btn) return;
li.addEventListener('mouseenter', () => {
clearTimeout(timers.get(li));
timers.set(li, setTimeout(() => openSubmenuFor(btn, false), 90));
});
li.addEventListener('mouseleave', () => {
clearTimeout(timers.get(li));
timers.set(li, setTimeout(() => closeSubmenuFor(btn, false), 200));
});
});
document.addEventListener('click', (e) => {
if (isRootOpen() && !stage.contains(e.target)) closeAll(false);
});
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。