プルダウン メニューを作成する

プルダウン メニューを作成する

このガイドでは、プルダウン、コンテキスト、オーバーフローなどのポップアップ メニューと、それらを開くメニューバーについて説明します。この例は、アプリケーションのコマンド メニューで、項目によってインターフェースが変更されます(作成、保存、設定)。

2 つの独立した選択肢。それ以外の内容はすべて、両方に適用されます。

  • 商品アイテムごとの要素。コマンドは <button type="button"> です。リンク先は <a href> で、新しいタブで開き、URL としてコピーされ、スクリプトなしで動作します。プルダウンでは、これらを自由に組み合わせることができます。2 つのリンクと 6 つのコマンド、または 1 つのコマンドと 6 つのリンクを組み合わせても、以下の内容は変わりません。
  • インタラクション モデル(プルダウンごと)。矢印キー、Home/End、Esc を使用してメニューにします。focusgroup を追加し、ボタンかリンクかにかかわらず、すべての項目で menuitem を推測させ、メニューのように見せます(連続したバーまたはドロップダウン インジケーター付きのトリガー。下記の「メニューバーまたは個別のメニューボタン」を参照)。ユーザーが Tab するリンクの短いリストはナビゲーションです。focusgroup、role="menu"、role="menuitem"、aria-haspopup="menu" は使用せず、ポップオーバー、Esc、フォーカスの復元を維持します。ナビゲーション バーのガイドをご覧ください。

コンテンツが役割を決定することはありません。リンクの [商品] プルダウンはメニューにできますが、コマンドの [アカウント] プルダウンはナビゲーションにできません。

メニュー パターンは、1 つのタブストップと矢印キーによるナビゲーションを備えた複合ウィジェットにのみ使用します。HTML の <menu> 要素ではなく、<div focusgroup="menu nomemory"> を使用します。共有マークアップに role="menu" または role="menuitem" を追加しないでください。ネイティブのフォーカス グループはこれらのロールを推測し、明示的なロールはその推論をブロックします。以下のサポートされていないブラウザの代替にのみ追加してください。nomemory を指定すると、再度開いたときに最初のアイテムから開始されます。focusgroup はフォーカスを移動し、スクリプトはプレビュー、有効化、フォーカスの復元を処理します。

メニューを開いて配置する

commandfor と command="toggle-popover" を使用して、各トリガーをコマンド呼び出し元にします。ブラウザは、クリック、Enter、Space でポップオーバーを開閉し、トリガーをポップオーバーのライトディスミス グループ内に保持するため、開いているトリガーをクリックすると閉じます。popovertarget または popovertargetaction は使用しないでください。このパターンでは代わりに commandfor を使用します。

各ポップオーバーは、トリガーの直後に配置します。ブラウザは、ポップオーバーが次の兄弟でない場合にのみ、暗黙的な aria-details 関係を追加します。この配置により、追加の属性なしでこれを回避できます。

<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
  commandfor="file-menu" command="toggle-popover">
  File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
  <!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
  <div focusgroup="menu nomemory" aria-labelledby="file-trigger">
    <button type="button">New file</button>
    <button type="button">Save</button>
    <button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
      commandfor="export-menu" command="toggle-popover">
      Export <span aria-hidden="true">›</span>
    </button>
    <!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
    <div id="export-menu" popover="auto">
      <div focusgroup="menu nomemory" aria-labelledby="export-trigger">
        <button type="button">PDF</button>
        <button type="button">Plain Text</button>
      </div>
    </div>
  </div>
</div>

各メニューに名前を付け、aria-labelledby でトリガーを指定します。装飾的なサブメニュー インジケーターを CSS に保持するか、aria-hidden="true" を追加します。aria-expanded="true" を含むトリガーに、表示されるアクティブなスタイルを適用して、どのメニューが開いているか、またはプレビューされているかをユーザーが確認できるようにします。

最上位のトリガーの形式を 1 つ選択します。ロール、キーボード モデル、スタイル設定を一致させる必要があります。視覚的に分離されたボタンは、それぞれ 1 つの Tab ストップを示します。

  • メニューバー。role="menubar" はコンテナに配置します(ポップオーバーには配置しません)。トリガー間に隙間のない 1 つの連続したバーとしてスタイル設定します。Tab で停止、ArrowLeft / ArrowRight でトリガー間を移動、ホバーでメニューを開く。アプリケーションのコマンドバーに使用します。
  • 個別のメニューボタン。各トリガーは独自の Tab 停止であり、スタンドアロン ボタンのように見えます。role="menubar" なし、トリガー間の矢印キーなし、フォーカス時のプレビューなし、ホバー切り替えなし。ナビゲーションを行う場合は <nav> でラップし、それ以外の場合はロールなし。ツールバーとページヘッダーに使用します。

このガイドの残りの部分では、メニューバーのフォームについて説明します。ボタンを分離する場合は、各メニュー内にすべてを保持し、メニューバー レベルの動作を削除します。

CSS アンカー ポジショニングを使用してメニューを配置する

JavaScript 座標ではなく、CSS アンカーの配置を使用します。command="toggle-popover" と showPopover({ source: trigger }) の両方が暗黙的なアンカーを設定するため、メニューごとの anchor-name ルールまたは position-anchor ルールを追加しないでください。メニューの動作がアンカーの配置に依存しないようにする必要があります。

/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
  position-area: block-end span-inline-end;
  inset: auto;
}

/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
  position-area: inline-end span-block-end;
  position-try-fallbacks: flip-inline, flip-block, --below;
}

/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
   covers its trigger. */
@position-try --below {
  position-area: block-end span-inline-end;
  margin-inline-start: 1rem;
  /* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
  min-inline-size: 0;
  max-inline-size: calc(100% - 1rem);
}

--below を最後に配置して、どちらの側にも収まらない場合にのみ適用されるようにします。トリガーのすぐ下に開くため、幅の狭い画面ではトリガーの下にある兄弟アイテムが覆われます。これは、トリガーの横に留まるためのトレードオフとして許容されます。min-inline-size: 0、max-inline-size からインデントを引いた領域にサブメニューを縮小します。適合しないフォールバックはスキップされ、適合するフォールバックがない場合、ブラウザはベースの配置を維持します。そのため、数ピクセル幅が広すぎるサブメニューは、トリガーを暗黙的に覆います。

プレーンなポップオーバーでフリップ フォールバックがどのように機能するかについては、ビューポート対応のプルダウンをご覧ください。このガイドでは、メニューではなくボタングループを配置します。メニューの場合は、サブメニューの配置と上記の --below フォールバックが優先されます。

開閉をアニメーション化する

省略可。150 ミリ秒未満、opacity と小さな translate のみ、サブメニューも同様、@media (prefers-reduced-motion: no-preference) 内。フォーカスが toggle に移動するため、キーボード ユーザーの操作が遅れることはありません。

@media (prefers-reduced-motion: no-preference) {
  /* Closing: this rule is in effect once :popover-open is removed. */
  [popover] {
    opacity: 0;
    translate: 0 -.25rem;
    transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
  }
  /* Opening: faster. */
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
    transition-duration: .1s;
    @starting-style { opacity: 0; translate: 0 -.25rem; }
  }
  /* Switching between open menubar menus is instant, as in native menubars. */
  [role="menubar"]:has(> [popover]:popover-open) > [popover] {
    transition: none;
  }
}

@starting-style は [popover] の繰り返しではありません。閉じたポップオーバーは display: none であり、レンダリングされていない状態から遷移は実行されないため、開く前の値が提供されます。これがないと、開く動作はスナップし、閉じる動作のみがアニメーション化されます。display と overlay を allow-discrete と組み合わせると、閉じる遷移が終了するまでポップオーバーがレンダリングされ、最上位レイヤに保持されます。

キーボードとフォーカス

focusgroup 内に 2 つ目の roving-tabindex システムを追加しないでください。スクリプトは、invoker と focusgroup が行わないことのみをカバーします。

  • メニューバーに 1 つのタブストップ。Tab は、最初の最上位トリガーで入力されます。ArrowLeft と ArrowRight を使用すると、最上位のトリガー間を移動し、どちらかの端で折り返します。トリガーで Home と End を押すと、最初または最後の項目でメニューが開きます。
    • 注: Tab が戻るトリガー。APG では、最初に使用されたトリガーまたは最後に使用されたトリガーのいずれかを使用できます。このガイドでは最初のトリガーを指定していますが、デモでは ArrowLeft と ArrowRight を使用してタブストップを移動するため、キーボード ナビゲーションの後、Shift+Tab で最後に到達したトリガーに戻ります。ポインタを使用しても移動しないため、同じシーケンスが別のトリガーに到達する可能性があります。どちらの動作も許容されます。どちらかを選択し、一貫性を保ってください。
  • プレビュー。最上位のトリガーにフォーカスすると、メニューが表示され、aria-expanded="true" が設定されます。フォーカスはトリガーに留まり、どのアイテムにもフォーカスされません。トリガーをぼかすと、プレビュー専用メニューが閉じます。
  • プレビューで Enter キーと Space キーを押します。プレビューを閉じ、トリガーにフォーカスを戻します。ネイティブの toggle-popover アクションがこれを処理するため、スクリプトはこれらのキーをインターセプトしてはなりません。
  • プレビューで下矢印キーを押します。開いたメニューに変換し、最初の項目にフォーカスします。
  • 開いているトリガーまたはプレビューされたトリガーをポインタでクリックします。ネイティブに閉じます。メニューには入りません。
    • サブメニュー トリガーに関する既知の違い。サブメニュー トリガーをポインタでクリックすると、ネイティブに切り替えることもできます。これにより、ホバーでプレビューされたサブメニューを閉じたり、まだ表示されていないサブメニューを開いたりできます。ネイティブ メニューは開くだけで、閉じることはありません。これは無害なので、ガイドは受け入れます。
  • 閉じているトリガーをポインタでクリックします。ネイティブで開きます。フォーカスはトリガーに留まり、アイテムはフォーカスされません。キーボードのみでメニューを開く。
  • アイテムにポインタを合わせます。リングなしでフォーカスされるため、ホバーとフォーカスは 1 つのアイテムになります。
  • メニューが開いているときに、別の最上位トリガーにポインタを合わせる。ウィンドウを閉じ、カーソルを合わせたウィンドウをプレビューします。トリガーにフォーカスが移動しますが、リングは表示されません。何も開いていない場合: ホバーしても何も起こりません。
  • サブメニュー トリガーにポインタを合わせます。少し遅れてサブメニューをプレビューします(ポインタ フォーカスは同じフォーカス時のプレビューを実行します)。兄弟アイテムが閉じます。
  • メニュー内。ArrowUp と ArrowDown で、有効なアイテム間を移動します。Home と End は、有効になっている最初と最後の項目に移動します。これはスクリプトによって処理されます。
  • 開いている最上位メニューで左矢印キーまたは右矢印キーを押す。メニューを閉じ、隣接するメニューをプレビューします。フォーカスはトリガーに置かれ、両端で折り返されます。ArrowDown が入力します。左ボタンと右ボタンはメニュー内では機能しません。
  • サブメニュー トリガーで右矢印キーを押す。サブメニューを開き、最初の項目にフォーカスします。
  • 子サブメニューのないサブメニュー項目で ArrowRight を押す。階層を閉じ、フォーカスをトリガーに置いたまま、次の最上位メニューをプレビューします。ArrowDown が入力されます。
  • サブメニューで左矢印キーまたは Esc キーを押す。そのサブメニューのみを閉じ、トリガーにフォーカスを戻します。保護者向けメニューは開いたままになります。
  • コマンドで Enter キーまたは Space キーを押します。有効にして、開いているメニューをすべて閉じます。
  • タブ。メニュー システムを終了します。フォーカスをトラップしたり、すべてのコマンドをステップ実行したりしないでください。閉じる際に、隠しコンテンツ内にフォーカスが残らないようにする必要があります。

command イベントは、toggle-popover がメニューを開く前に発生するため、フォーカスをメニューに移動できません。代わりに toggle イベントでフォーカスを移動します。HTML はスタック内の最初の popover="auto" のみにフォーカスを戻すため、ネストされたレベルではトリガーに明示的にフォーカスを戻す必要があります。

インタラクションの実装

独自のマークアップに上記の動作を実装します。矢印、Home/End、ラップアラウンド、Tab で終了する処理は、通常の keydown コードです。間違いやすい部分を、入力タイプ別に以下にまとめました。

キーボードとフォーカスの処理

  1. 1 つのオープンパス。キーボードで開くと trigger.click() が呼び出されるため、メニューを開くには、呼び出し元の toggle-popover を使用するしかありません。toggle ハンドラがメニューに入るようにメニューをマークします。キーボードのみが開きます。ポインタが開いている場合、toggle はリングなしでトリガーにフォーカスします。これは、pointerdown がフォーカスを妨げ、矢印キーが引き続き機能する必要があるためです。
  2. フォーカス時にプレビュー。トリガーにフォーカスすると showPopover({ source: trigger }) が呼び出され、メニューがトリガーに固定されます。トリガー自身のメニュー内から、または別のウィンドウから戻るフォーカスを無視します(ウィンドウ blur のフラグ、ウィンドウ focus の後にチェックをクリア、またはアプリの切り替えで最後のメニューを再度開く)。フォーカスが移動しない限り、blur でプレビューされたメニューを非表示にします。
  3. command ではなく toggle でフォーカスを移動する。command は、メニューが開く前に呼び出されます。
  4. トリガーにフォーカスを戻す。hidePopover() の前にトリガーにフォーカスします。ポインタの有効化後に、リングなしでフォーカスを復元する(focus({ focusVisible: false }))。または、マウスクリック後にトリガーがキーボード フォーカスの輪郭を表示する。キーボードの有効化ではリングが保持される。event.detail === 0 を伴うクリックはキーボードから発生したものです。
  5. 1 つずつレベルを閉じます。サブメニューの Escape と ArrowLeft で stopPropagation() を呼び出すか、親のリスナーでもサブメニューを閉じます。

ポインタの処理

  1. ポインタのクリックで切り替える。pointerdown で preventDefault() を呼び出して、クリックによってフォーカスが移動したり、クリックの途中でプレビューが開始されたりしないようにします。どのトリガーを押したかを覚えておきます。閉じているトリガーまたはプレビューされているトリガーをクリックすると、ネイティブに開閉し、メニューには入りません。キーボードで開いた場合のみ、メニューに入ります。クリックをインターセプトしないでください。
  2. ホバーはフォーカスに追従します。:hover と :focus-visible のスタイルを個別に設定します。アイテム上で pointermove が発生したときに focus({ focusVisible: false }) を呼び出します。このオプションを無視するブラウザは、ポインタ フォーカスにリングを描画しません。
  3. ホバーするとメニューが開きます。別の最上位メニューが開いているときに、最上位トリガーの pointerenter で、そのメニューを非表示にし、ホバーされたトリガーにリングなしでフォーカスを合わせ、フォーカス時のプレビュー(上記参照)でプレビューします。開いているものがない: 何もしない。
  4. 安全な三角形。固定の CSS ブリッジは狭すぎるため、ポインタが親の端の近くから移動する場合にしか機能しません。CSS でブリッジを描画し、スクリプトはポインタの報告にのみ使用します。
    • matchMedia('(hover: hover)').matches の場合にのみ実行します。タッチやキーボードには関係ありません。
    • サブメニューが開いているサブメニュー トリガーからポインタが離れたときに開始します。--below サブメニューをスキップします。このサブメニューにはサイドエッジがありません。
    • --bridge-x と --bridge-y(ポインタ、サブメニューを基準としたピクセル単位)、--bridge-edge(サブメニューの端に近い x: ポインタが左にある場合は 0、反転した場合は幅)、--bridge-height をサブメニューに設定し、data-bridge を設定してブリッジをオンにします。
    • 約 300 ミリ秒後、ポインタがサブメニューの項目に到達したとき、またはサブメニューが閉じたときに停止します。リスナーと属性を削除します。それ以降は何も実行されません。
    • CSS は、ポインタから近接する角まで三角形にクリップされた ::before をサブメニューに付与します。この要素はポインタのパス上の兄弟アイテムの上に配置されるため、サブメニューを閉じるホバーイベントが兄弟アイテムに届くことはありません。clip-path はヒットテストも制限するため、保護者向けメニューの残りの部分はインタラクティブなままになります。
    • サブメニューには overflow: visible が必要です(ポップオーバーはデフォルトでクリップされます)。疑似要素はそれを超えて拡張されるため、シェイプのポイントをその分だけオフセットします。
    • フォローアップ: 曲線パス(shape() 曲線コマンド)は、直線三角形よりも実際のポインタの弧に沿って移動します。
[popover] [popover] {
  overflow: visible;

  &[data-bridge]::before {
    content: "";
    position: absolute;
    inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
    clip-path: shape(
      from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
      line to calc(100vw + var(--bridge-edge)) 100vh,
      line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
      close
    );
  }
}
const canHover = matchMedia('(hover: hover)');

// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
  if (!canHover.matches || !submenu.matches(':popover-open')) return;
  const rect = submenu.getBoundingClientRect();
  if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
  const style = submenu.style;
  style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
  style.setProperty('--bridge-height', `${rect.height}px`);
  const update = ({ clientX, clientY, target }) => {
    if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
    style.setProperty('--bridge-x', `${clientX - rect.left}px`);
    style.setProperty('--bridge-y', `${clientY - rect.top}px`);
  };
  const timer = setTimeout(() => stop(), 300);
  const stop = () => {
    clearTimeout(timer);
    document.removeEventListener('pointermove', update);
    submenu.removeEventListener('toggle', stop);
    submenu.removeAttribute('data-bridge');
  };
  update(event);
  submenu.dataset.bridge = '';
  document.addEventListener('pointermove', update);
  submenu.addEventListener('toggle', stop, { once: true });
}

ホバーがフォーカスに追従する(項目 2)は、単純な pointermove ハンドラのままです。三角形のロジックは必要ありません。

また、

  • フォーカスが外れている。フォーカスがメニュー システムの外に移動したときに、開いているすべてのメニューを閉じます(document focusin リスナー)。
  • コマンドの有効化。読み上げ、ルートメニューを非表示にして、フォーカスをトリガーに戻します。ポインタからアクティブ化された場合は、リングは表示されません。チェック可能なアイテム(menuitemcheckbox、menuitemradio)の場合、変更されたチェックマークが表示されるように、閉じる前に 300 ミリ秒ほど待ちます。閉じたトリガーを :focus-visible でのみアクティブとしてスタイル設定します。そうしないと、ポインタ選択後に復元された(リングなしの)フォーカスがハイライト表示されたままになります。
  • aria-expanded。toggle リスナーでのみ設定します。プレビュー状態と開いた状態をカバーし、メニューがプレビューされているか開いている間、トリガーの aria-expanded は "true" のままになります。
  • ヘルパー。以下のフォールバックは、マークアップ用に定義したヘルパーを想定しています。triggers(すべての [commandfor] ボタン)、menuFor(trigger)、isOpen(menu)、closeAndRestore(menu, { fromPointer })(トリガーにフォーカス。fromPointer の場合はリングなし、その後 menu.hidePopover())、enterOnOpen(開いたときに入るメニューの WeakSet)、pointerTrigger(ポインタで最後に押されたトリガー)。
  • サブメニューには popover="auto" を使用し、親の focusgroup 内のサブメニュー トリガーの後に配置します。popover="manual" は不可。
  • ネストを使用すると、ブラウザは親を開いたままにし、兄弟サブメニューを閉じ、ルートメニューが閉じられたときに子サブメニューを閉じ、外側をクリックしたときにライト ディスミスを処理できます。
  • サブメニュー トリガーは aria-haspopup="menu" と aria-expanded を保持し、toggle リスナーは aria-expanded を同期します。
  • キーボード スクリプトは、前述のようにネストされた Escape と ArrowLeft を処理します。
  • サブメニュー トリガーがコマンドを実行することはありません。1 つのコントロールでアクティブ化と開く操作の両方を行うことはできず、Enter が両方を意味することはありません。デフォルトのアクションを最初のサブメニュー項目として配置するか、トリガーの横に別のコマンド項目を追加します。複合コントロールが強制される場合は、ユーザー補助のトレードオフとして文書化します。

セパレータとチェック可能なアイテム

  • focusgroup 内の <hr> で商品アイテム グループを分離します。ネイティブの separator はフォーカス不可で、focusgroup とフォールバックによってスキップされます。スタイル設定された <div> ではない。
  • ブール値オプションは、aria-checked と明示的な role="menuitemcheckbox" を持つ <button type="button"> です。Focusgroup はこれらのロールを推測しないため、共有マークアップがアイテムロールを設定する唯一の場所です。
  • セットの 1 つは、同じフォーカス グループ内の aria-label を含む role="group" 内の role="menuitemradio" と aria-checked を含む <button type="button"> です。
  • スクリプトの切り替え aria-checked。
  • チェックボックスで Enter を押すと、メニューが切り替わり、閉じます。Space を押すと、メニューが切り替わり、開いたままになります(APG)。ラジオはどちらのキーでも閉じます。
  • チェックマークは、別のアイコン要素ではなく [aria-checked="true"]::before からレンダリングします(以下の CSS を参照)。

ここで取り上げないこと

  • 有効化に関するフィードバック。ネイティブ メニューでは、選択した項目が点滅してからメニューが閉じます。必要に応じて、hidePopover() の前に約 100 ミリ秒の視覚的な確認を表示します。フォーカスの復元やアナウンスを遅らせないでください。
  • 予測入力。フォーカス グループにはありません。必要に応じて、印刷可能な keydown で、現在のレベルの有効なアイテムラベルを照合し、現在のアイテムの後の次の照合にフォーカスを移動します。Space をインターセプトしないでください。

完了する前に確認する

指示は動作を保証するものではないため、結果をテストします。ページを開き、実際のキーボードとマウスでそれぞれを確認します。失敗したものは修正して再テストします。

  • キーボード。Tab は最初のトリガーに 1 回移動します。ArrowDown は入力します。Escape は 1 つのレベルを閉じて、フォーカスをトリガーに戻します。Tab はすべてのメニューを閉じます。
  • ポインタが開いている。トリガーをクリックすると、トリガーにフォーカスが置かれ、リングなしでメニューが開きます。もう一度クリックすると、メニューが閉じます。
  • ポインタ コマンド。コマンドをクリックすると、すべてのメニューが閉じ、トリガーにフォーカスリングが表示されなくなります。
  • サブメニュー。サブメニュー トリガーの中央からサブメニュー項目に斜めに移動すると、サブメニューは開いたままになります。関連のない兄弟項目に移動すると、サブメニューは閉じます。
  • 画面が狭い。375 px では、サブメニューがトリガーを覆うことはありません。
  • チェックマーク。チェックしたアイテムにはチェックマークが表示されます。
  • 残り物なし。コンソール エラーがなく、トリガーに popovertarget、popovertargetaction、aria-details がない。

メニュー項目のコンテンツを揃える

行にアイコン、ラベル、ショートカットまたはサブメニュー インジケーターがある場合は、アイコンまたはチェックマーク用に固定サイズのスロットを先頭に確保し、末尾のコンテンツをインラインの末尾に配置します。アイコンのサイズは既知であるため、固定スロットを使用すると、アイテムにアイコンがあるかどうかにかかわらず、ラベルを揃えることができます。行に subgrid を使用しないでください。追加のインライン子(バッジなど)が独自のグリッド項目になり、レイアウトが崩れます。アイテムを操作するキーを繰り返すだけのショートカット テキストを、支援技術から非表示にします。

[focusgroup~="menu"] {
  --menu-icon-size: 1.25rem;
}

/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
  display: flex;
  align-items: center;
  gap: .5rem;
}

/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
  content: "";
  flex: none;
  inline-size: var(--menu-icon-size);
}

/* Check mark for checked items. This selector must be at least as specific as the base
   ::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
  content: "✓";
}

/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
  margin-inline-start: auto;
}

ボタン内の <span class="hint" aria-hidden="true"> に末尾のインジケーターまたはショートカットを配置します。サブメニュー トリガーはすでにポップアップをアナウンスしています。

プログレッシブ エンハンスメントとフォールバック

focusgroup のフォールバックとブラウザ サポート

マークアップに focusgroup="menu nomemory" を含めます。'focusGroup' in HTMLElement.prototype が false の場合は、Up、Down、Home、End をインストールし、各メニュー コンテナに role="menu" を設定し、ロールのないボタンに role="menuitem" を設定します。チェック可能なアイテム、<hr>、role="group" はそのままにします。ネイティブの focusgroup が存在する場合は、インストールしないでください。左、右、プレビュー、開く、閉じるは、いずれにしても作成者スクリプトです。

if (!('focusGroup' in HTMLElement.prototype)) {
  document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
    group.setAttribute('role', 'menu');
    group.querySelectorAll('button:not([role])').forEach(item => {
      item.setAttribute('role', 'menuitem');
    });
    group.addEventListener('keydown', event => {
      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
      // Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
      const items = [...group.querySelectorAll('button')]
        .filter(item => item.closest('[focusgroup]') === group)
        .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
      const current = items.indexOf(document.activeElement);
      if (current < 0) return;
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
        (current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
      event.preventDefault();
      event.stopImmediatePropagation();
      items[next].focus({ preventScroll: true });
    }, true);
  });
}

Popover のフォールバックとブラウザ サポート

Popover API は主にプログレッシブ エンハンスメントですが、その定義動作である最上位レイヤのプロモーション、ライトディスミス、popovertarget 呼び出しには、CSS のみの同等の機能がありません。古いブラウザでは、ポリフィルが必要です。ポリフィルを配布したくない場合は、手動でフォールバックする必要があります。

ポリフィル: 古いブラウザで popover 属性をサポートするには、条件付きで @oddbird/popover-polyfill を読み込みます。必須: HTMLElement.prototype の popover プロパティをチェックして機能を検出し、ネイティブ サポートがない場合にのみポリフィルを読み込みます。無条件に読み込まないでください。

バンドラまたはインポート マップを使用する場合:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

バンドラを使用しない場合は、<script type="module"> 内の CDN からインポートします。

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

スタイリングに関する注意事項: ポリフィルは実際の :popover-open 疑似クラスを定義できないため、代わりに .\:popover-open クラスを適用します。必須: :is() または :where() を使用して 2 つを結合します。そうしないと、:popover-open がないブラウザではルール全体が破棄されます。

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

または、ポリフィルを使用しないレガシー フォールバックの場合は、position: fixed を使用して getBoundingClientRect() で座標を手動で計算するか、ユースケースで許容される場合は inset: auto でデフォルトの配置を使用します。

アンカー配置のフォールバックとブラウザ サポート

CSS アンカー ポジショニングを使用しない場合、ポップオーバーは UA の中央に配置されたビューポートにフォールバックします。メニューにこれを設定しないでください。メニューがトリガーから切り離され、中央に配置されたサブメニューが親メニューを覆ってしまいます。アンカー付きルールを @supports (position-area: block-end) の背後に配置し、失敗した場合はスクリプトからメニューを配置します。

  • メニューをトリガーの下(最上位)または横(サブメニュー)に配置し、オーバーフローする場合は反対側に反転してビューポートに固定します。
  • メニューが開いたときに toggle ハンドラで実行し、結果をメニューの left と top として適用します。position: fixed を inset: auto および margin: 0 とともに使用して、座標がビューポートを基準とするようにします。
  • これらのオーバーライドは @supports not (position-area: block-end) 内に保持されるため、アンカー配置を使用するブラウザには影響しません。

showPopover({ source }) が暗黙的なアンカーを確立しない(ToggleEvent.prototype に source がない)以前の実装をターゲットとする場合は、メニューごとの CSS ルールを記述する代わりに、各トリガーに一意の anchor-name を、各メニューに一致する position-anchor をスクリプトから指定します。

アンカー ポジショニングがまったくないブラウザについては、ポリフィル ルートのビューポート対応ドロップダウンをご覧ください。

Invoker コマンドのフォールバックとブラウザ サポート

呼び出し元がないと command="toggle-popover" は何も行わないため、スクリプトでメニューを切り替える必要があります。

  • インスタンスではなく、インターフェースを機能検出します('commandForElement' in HTMLButtonElement.prototype)。
  • 各トリガー click で preventDefault() を呼び出し、呼び出し元を持つブラウザが 2 回切り替えられないようにします。
  • メニューが開いている場合は、closeAndRestore(menu) で閉じます。それ以外の場合は menu.showPopover({ source: trigger }) を呼び出します。
  • クリックが押されたトリガー(pointerTrigger)から発生したものでない限り、メニューを enterOnOpen でマークします。これにより、キーボードで開いた場合のみメニューに入ります。
  • Enter と Space は、ボタンのクリックを生成するため、引き続き機能します。