オーバーレイ/アクション

オーバーフローアクションメニュー

項目数が増えた際に主動作を圧縮して表示する、アクションの省略メニューを確認するレシピです。

動きを試す

プレビュー

プレビュー設定

プレビューを準備しています。

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • アクション名と権限制御表示を運用仕様へ合わせる

<!doctype html>
<html lang="ja" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>オーバーフローアクションメニュー</title>
    <style>
      :root { --bg:#f7f8fa; --surface:#ffffff; --text:#18212f; --muted:#5d6b7a; --border:#d7dee8; --focus:#0077cc; --danger:#b42318; }
      :root[data-theme="dark"] { --bg:#0f141a; --surface:#171d26; --text:#f2f5f8; --muted:#aab4c0; --border:#354152; --focus:#65c7ff; --danger:#ff8a80; }
      * { box-sizing: border-box; }
      body { margin: 0; padding: 16px; background: var(--bg); color: var(--text); font-family: Arial, sans-serif; }
      .card { max-width: 640px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px; }
      .primary { display: flex; align-items: center; gap: 8px; }
      .primary button { min-height: 44px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 8px; padding: 8px 12px; }
      .overflow {
        position: relative;
        display: inline-block;
      }
      .menu {
        position: absolute;
        top: 48px;
        left: 0;
        min-width: 190px;
        border: 1px solid var(--border);
        border-radius: 8px;
        background: var(--surface);
        box-shadow: 0 8px 16px rgba(0,0,0,.12);
      }
      .menu[hidden] { display: none; }
      .menu button { width: 100%; border: none; border-bottom: 1px solid var(--border); text-align: left; background: transparent; border-radius: 0; }
      .menu button:last-child { border-bottom: none; }
      [data-state-panel][hidden] { display: none; }
      .status { min-height: 1.3em; }
      button:focus-visible { outline: 3px solid var(--focus); }
      .danger { color: var(--danger); }
      @media (max-width: 360px) { body { padding: 10px; } }
      @media (max-width: 240px) {
        body, .card { padding: 8px; }
        .card { overflow-wrap: anywhere; }
        .primary { align-items: stretch; flex-direction: column; }
        .overflow, .primary button { width: 100%; }
        .menu { left: 0; min-width: 0; right: 0; width: 100%; }
      }
      @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
    </style>
  </head>
  <body>
    <main class="card">
      <h1>アクション省略メニュー</h1>
      <div class="primary">
        <button type="button">主操作</button>
        <div class="overflow">
          <button type="button" data-overflow-toggle aria-controls="overflowMenu" aria-expanded="false">その他</button>
          <div class="menu" id="overflowMenu" hidden>
            <button type="button" data-menu-action="edit">編集</button>
            <button type="button" data-menu-action="copy">コピー</button>
            <button type="button" data-menu-action="remove" class="danger">削除(確認あり)</button>
          </div>
        </div>
      </div>
      <div data-state-panel="closed">
        <p>閉じた状態。補助アクションは隠しています。</p>
      </div>
      <div data-state-panel="open" hidden>
        <p>開いた状態。補助アクションを表示しています。</p>
      </div>
      <div data-state-panel="disabled" hidden>
        <p>一部アクションは無効化された状態です。</p>
      </div>
      <p class="status" role="status" data-status></p>
    </main>
    <script>
      (() => {
        const params = new URLSearchParams(window.location.search);
        const instance = params.get('instance') ?? 'missing-instance';
        const menu = document.getElementById('overflowMenu');
        const toggle = document.querySelector('[data-overflow-toggle]');
        const status = document.querySelector('[data-status]');
        const statePanels = Array.from(document.querySelectorAll('[data-state-panel]'));
        const actionButtons = Array.from(document.querySelectorAll('[data-menu-action]'));
        const destructiveAction = document.querySelector('[data-menu-action="remove"]');
        const validStates = new Set(['closed', 'open', 'disabled']);
        const setTheme = (theme) => {
          if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
        };
        const updateStateDisplay = (nextState, message) => {
          document.documentElement.dataset.state = nextState;
          for (const panel of statePanels) panel.hidden = panel.dataset.statePanel !== nextState;
          if (status) status.textContent = message;
        };

        const openMenu = ({ disabled = false } = {}) => {
          menu.hidden = false;
          toggle.setAttribute('aria-expanded', 'true');
          for (const button of actionButtons) button.disabled = false;
          destructiveAction.disabled = disabled;
          updateStateDisplay(disabled ? 'disabled' : 'open', disabled ? '削除操作は現在利用できません。' : '補助操作を表示しました。');
          actionButtons[0]?.focus();
        };

        const closeMenu = ({ restoreFocus = true, message = 'メニューを閉じました。' } = {}) => {
          menu.hidden = true;
          toggle.setAttribute('aria-expanded', 'false');
          for (const button of actionButtons) button.disabled = false;
          updateStateDisplay('closed', message);
          if (restoreFocus) toggle.focus();
        };

        const setState = (nextState, { restoreFocus = false } = {}) => {
          if (!validStates.has(nextState)) return;
          if (nextState === 'open') openMenu();
          else if (nextState === 'disabled') openMenu({ disabled: true });
          else closeMenu({ restoreFocus });
        };

        toggle.addEventListener('click', () => {
          if (menu.hidden) openMenu();
          else closeMenu();
        });
        for (const action of actionButtons) {
          action.addEventListener('click', () => {
            if (!action.disabled) {
              closeMenu({ message: `${action.textContent?.trim()}を選択しました。` });
            }
          });
        }
        document.addEventListener('pointerdown', (event) => {
          if (!menu.hidden && !event.target.closest('.overflow')) closeMenu();
        });
        document.addEventListener('keydown', (event) => {
          if (event.key === 'Escape' && !menu.hidden) {
            event.preventDefault();
            closeMenu();
          }
        });
        window.addEventListener('message', (event) => {
          if (event.source !== window.parent || typeof event.data !== 'object' || event.data === null) return;
          if (event.data.source !== 'wpl-host' || event.data.instance !== instance) return;
          if (event.data.type === 'wpl-preview-state') setState(event.data.value);
          if (event.data.type === 'wpl-preview-theme') setTheme(event.data.value);
        });
        setTheme(params.get('theme') === 'dark' ? 'dark' : 'light');
        setState(validStates.has(params.get('state')) ? (params.get('state') ?? 'closed') : 'closed', { restoreFocus: false });
        window.parent.postMessage({ source: 'wpl-preview', type: 'wpl-preview-ready', instance }, '*');
      })();
    </script>
  </body>
</html>
プレビューで試せる動き
  • 閉じる: オーバーフローは閉じたまま主要操作のみ表示
  • 開く: 追加アクションをリスト化して表示
  • 無効: 一部アクションを利用不可で示す

使い分けを確認する

このレシピが合う場面

適する場面

  • 複数アクションを整理して重要操作を優先したい
  • テーブルやカードで補助アクションを隠して保持したい
  • 主アクションと補助アクションの優先順位を示す

適さない場面

  • アクション数が常に1〜2個しかない場合
  • 複雑な権限条件で遷移先が大きく変わる場合