ネストメニュー

「ファイル」メニューからサブメニューが右へ多階層に展開します。ホバーでの自然な開閉に加え、矢印キーの→←での階層移動やEscでの一括終了にも対応しています。

#css#js#menu

ライブデモ

コード

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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。