建立下拉式選單

建立下拉式選單

本指南涵蓋所有彈出式選單:下拉式選單、內容選單、溢位選單,以及開啟這些選單的選單列。這個範例是應用程式指令選單,其中的項目會變更介面 (建立、儲存、偏好設定)。

兩個獨立選項。這裡的其他內容則適用於這兩者。

  • 每個項目的元素。指令是 <button type="button">;目的地是 <a href>,仍會在新分頁中開啟、複製為網址,且不需指令碼即可運作。下拉式選單可以自由混合這兩者:兩個連結和六個指令,或一個指令和六個連結,不會改變下方的任何內容。
  • 互動模式 (每個下拉式選單)。方向鍵、Home/End 和 Esc 可將其設為選單:新增 focusgroup,讓它推斷每個項目 (無論是按鈕還是連結) 的 menuitem,並使其看起來像一個選單 (連續列或帶有下拉式指標的觸發器;請參閱下方的「選單列或獨立選單按鈕」)。使用者Tab瀏覽的簡短連結清單是導覽列:沒有焦點群組、role="menu"、role="menuitem" 或 aria-haspopup="menu";保留彈出式視窗、Esc 和焦點還原,並參閱導覽列指南。

內容絕不會決定角色。「產品」下拉式連結清單可以做為選單,「帳戶」下拉式指令清單則不能做為導覽。

只有在複合小工具具有一個定位點,且可透過方向鍵瀏覽時,才使用選單模式。請使用 <div focusgroup="menu nomemory">,而非 HTML <menu> 元素。請勿在共用標記中加入 role="menu" 或 role="menuitem":原生焦點群組會推論這些角色,而明確角色會阻擋該推論。請只在下方不支援瀏覽器的備用廣告中加入這些標記。nomemory 會從第一個項目開始重新開啟。Focusgroup 會移動焦點;指令碼會處理預覽、啟用和焦點還原。

開啟並定位選單

使用 commandfor 和 command="toggle-popover",將每個觸發條件設為指令叫用器。瀏覽器會在使用者點選、按下 Enter 鍵和空格鍵時開啟和關閉彈出式視窗,並將觸發條件保留在彈出式視窗的輕觸關閉群組中,因此點選開啟觸發條件會關閉該視窗。請勿使用 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" 的觸發條件設定明顯的啟用樣式,讓使用者知道哪個選單已開啟或預覽。

為頂層觸發條件選擇一種形式。角色、鍵盤模型和樣式必須相符:視覺上分隔的按鈕代表每個按鈕都有一個 Tab 停靠點。

  • 選單列。容器上的 role="menubar" (絕不位於彈出式視窗),樣式為一條連續長條,觸發條件之間沒有間隔。一個Tab停靠站;ArrowLeft/ArrowRight在觸發條件之間移動;懸停切換開啟選單。用於應用程式指令列。
  • 獨立選單按鈕。每個觸發條件都是獨立的 Tab 停靠點,看起來像獨立按鈕。沒有 role="menubar"、觸發條件之間沒有方向鍵、焦點沒有預覽、懸停時不會切換。如果可導覽,請用 <nav> 包裝,否則不需角色。適用於工具列和網頁標頭。

本指南其餘部分將說明選單列形式。如果是獨立按鈕,請將所有內容保留在各個選單中,並捨棄選單列層級的行為。

使用 CSS 錨點定位功能設定選單位置

使用 CSS 錨定位置,而非 JavaScript 座標。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 備用方案。

開啟和關閉動畫

(選用步驟) Under 150 ms, opacity and a small translate only, same for submenus, inside @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,可讓彈出式視窗保持顯示在頂層,直到關閉轉場效果結束。

鍵盤和焦點

請勿在焦點群組中新增第二個 roving-tabindex 系統。指令碼只涵蓋叫用器和焦點群組未執行的動作:

  • 選單列有一個定位點。Tab會在第一個頂層觸發條件進入。ArrowLeft 和 ArrowRight 可在頂層觸發條件之間移動,並在任一端換行。在觸發條件上,Home 和 End 會進入選單的第一個或最後一個項目。
    • 注意:Tab 會傳回哪個觸發條件。APG 允許使用第一個或最後一個觸發條件。指南指定第一個,但示範使用 ArrowLeft 和 ArrowRight 移動定位停駐點,因此在鍵盤導覽後,Shift+Tab 會返回最後到達的觸發條件。使用指標不會移動指標,因此相同的序列可能會落在不同的觸發條件上。兩種行為皆可接受,請擇一並保持一致。
  • 預覽。聚焦頂層觸發條件會顯示其選單,並設定 aria-expanded="true"。焦點會停留在觸發條件上,不會聚焦在任何項目。模糊處理觸發條件會關閉僅限預覽的選單。
  • 在預覽畫面上按下 Enter 鍵和空格鍵。關閉預覽畫面,並將焦點移回觸發條件。原生 toggle-popover 動作會處理這項操作,因此指令碼不得攔截這些按鍵。
  • 在預覽畫面上按下向下鍵。轉換為開啟的選單,並聚焦於第一個項目。
  • 用滑鼠點選開啟或預覽的觸發條件。直接關閉,不會進入選單。
    • 子選單觸發條件的已知差異。用指標點選子選單觸發條件,也會原生切換,因此可以關閉懸停時已預覽的子選單,或開啟尚未顯示的子選單。原生選單只會開啟,不會關閉。這項要求無害,因此導遊接受了。
  • 游標點選關閉的觸發條件。原生開啟。焦點會停留在觸發條件上,不會聚焦在任何項目。只有鍵盤開啟時,才能進入選單。
  • 將滑鼠游標懸停在項目上。聚焦時不顯示圓環,因此懸停和聚焦是同一個項目。
  • 選單開啟時,將指標移到另一個頂層觸發條件上。關閉視窗並預覽滑鼠游標懸停的視窗,聚焦在觸發條件上,不顯示圓環。未開啟任何項目:懸停不會觸發任何動作。
  • 將滑鼠游標懸停在子選單觸發條件上。短暫延遲後預覽子選單 (指標焦點會執行相同的預覽焦點)。同層項目會關閉該項目。
  • 在選單中。ArrowUp 和 ArrowDown 可在已啟用的項目之間移動。Home 和 End 會移至第一個和最後一個啟用的項目,由指令碼處理。
  • 在開啟的頂層選單中,按向左鍵或向右鍵。關閉選單並預覽相鄰選單,焦點會放在觸發條件上,並在任一端換行。ArrowDown接著進入該房間。向左和向右鍵不會進入選單。
  • 在子選單觸發條件上按下向右鍵。開啟子選單,並將焦點移至第一個項目。
  • 在沒有子選單的子選單項目上按下向右鍵。關閉階層並預覽下一個頂層選單,同時焦點仍停留在觸發條件上。ArrowDown接著進入該空間。
  • 在子選單中按下向左鍵或 Escape 鍵。只關閉該子選單,並將焦點還原至觸發條件。家長專用選單會保持開啟。
  • 在指令上按下 Enter 鍵或空格鍵。啟用該項目並關閉所有開啟的選單。
  • Tab 鍵。離開選單系統。請勿鎖定焦點或逐步執行每個指令。關閉時,焦點不得留在隱藏的內容中。

command 事件會在 toggle-popover 開啟選單前觸發,因此焦點無法移至選單。請改為在 toggle 事件中移動焦點。HTML 只會還原堆疊中第一個 popover="auto" 的焦點,因此巢狀層級必須明確將焦點還原至觸發條件。

導入互動

請為自己的標記實作上述行為。箭頭、Home/End、環繞和 Tab 結束處理是普通的 keydown 程式碼。以下依輸入類型分組,列出幾個容易出錯的部分。

鍵盤和焦點處理

  1. 一條開放路徑。透過鍵盤開啟會呼叫 trigger.click(),因此叫用器的 toggle-popover 是開啟選單的唯一方式。標示選單,讓 toggle 處理常式進入選單,只開啟鍵盤。指標開啟時,toggle 會聚焦在沒有圓環的觸發條件上,因為 pointerdown 阻止了焦點,且方向鍵仍須運作。
  2. 預覽對焦。聚焦觸發條件會呼叫 showPopover({ source: trigger }),因此選單會錨定在該觸發條件。忽略從觸發條件本身選單內或另一個視窗傳回的焦點 (視窗 blur 上的標記、視窗 focus 後清除勾號,或切換應用程式重新開啟最後一個選單)。除非焦點移至 blur,否則隱藏預覽選單。
  3. 將焦點移至 toggle,而非 command。command 會在選單開啟前觸發。
  4. 自行將焦點還原至觸發條件。在 hidePopover() 之前聚焦觸發條件。指標啟用後,還原焦點時不顯示圓環 (focus({ focusVisible: false })),或在滑鼠點擊後,觸發條件顯示鍵盤焦點外框;鍵盤啟用時則保留圓環。event.detail === 0 的點擊來自鍵盤。
  5. 一次關閉一個層級。在子選單中呼叫 Escape 的 stopPropagation() 和 ArrowLeft,或由父項的事件監聽器關閉。

指標處理

  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 });
}

「Hover follows focus」(項目 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。
  • 子選單觸發條件絕不會同時執行指令:一個控制項無法同時啟用和開啟,Enter 也無法同時代表這兩者。將預設動作設為第一個子選單項目,或在觸發條件旁新增獨立的指令項目。如果必須使用合併控制項,請將其記錄為無障礙功能的折衷方案。

分隔符和可勾選項目

  • 使用 focusgroup 內的 <hr> 分隔項目群組:原生 separator,不可聚焦,由 focusgroup 和備用項目略過。絕不使用樣式化的 <div>。
  • 布林值選項是具有 aria-checked 和明確 role="menuitemcheckbox" 的 <button type="button">。Focusgroup 不會推論這些角色,因此這是共用標記設定項目角色的唯一位置。
  • 其中一個組合是 <button type="button">,內含 role="menuitemradio" 和 aria-checked,位於 role="group" 內,內含 aria-label,位於同一個焦點群組中。
  • 指令碼切換按鈕 aria-checked。
  • 在核取方塊上按 Enter 鍵會切換狀態並關閉選單;按 Space 鍵會切換狀態並保持開啟 (APG)。按下任一按鍵即可關閉收音機。
  • 從 [aria-checked="true"]::before 算繪勾號,而非獨立的圖示元素 (請參閱下方的 CSS)。

不在本文討論範圍內

  • 啟用意見回饋。原生選單會在關閉前閃爍所選項目。如有需要,可在 hidePopover() 前顯示約 100 毫秒的視覺確認,但請勿延遲焦點還原或朗讀。
  • 預測輸入。Focusgroup 則沒有。如有需要,請在可列印的 keydown 上比對目前層級啟用的項目標籤,並在目前項目後聚焦下一個相符項目,然後換行。請勿攔截 Space。

完成前請先驗證

指令無法保證行為,因此請測試結果。開啟網頁,並使用實體鍵盤和滑鼠檢查下列各項;修正並重新測試任何失敗的項目:

  • 鍵盤。Tab 會停在第一個觸發條件上一次;ArrowDown 會進入;Escape 會關閉一個層級,並將焦點返回其觸發條件;Tab 會關閉所有選單。
  • 指標開啟。點選觸發條件會開啟選單,焦點會放在觸發條件上,且不會顯示圓環;再次點選則會關閉選單。
  • 指標指令。點選指令會關閉所有選單,且觸發條件上不會顯示焦點環。
  • 子選單。從子選單觸發條件的中間斜向移動到子選單項目,可讓子選單保持開啟;移動到不相關的同層級項目,則會關閉子選單。
  • 窄螢幕:在 375 像素時,沒有任何子選單會覆蓋自己的觸發條件。
  • 勾號。已勾選的項目會顯示勾號。
  • 不留任何痕跡。沒有控制台錯誤,且觸發條件上沒有 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 的對等項目。舊版瀏覽器需要 polyfill,如果不想提供,則需要手動備用方案。

Polyfill:如要在舊版瀏覽器中支援 popover 屬性,請視情況載入 @oddbird/popover-polyfill。必要:檢查 HTMLElement.prototype 上的 popover 屬性,偵測功能,並僅在缺少原生支援時載入 polyfill,請勿無條件載入。

使用套件組合器或匯入對應:

// 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>

樣式注意事項:Polyfill 無法定義實際的 :popover-open 虛擬類別,因此會改為套用 .\:popover-open 類別。必要:請使用 :is() 或 :where() 結合兩者,否則不支援 :popover-open 的瀏覽器會捨棄整個規則:

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

或者,如要使用不含 polyfill 的舊版備用方案,請使用 position: fixed,並透過 getBoundingClientRect() 手動計算座標,或在用途允許的情況下,使用 inset: auto 預設定位。

錨定定位的備用方案和瀏覽器支援

如果沒有 CSS 錨定定位,彈出式視窗會改用使用者代理程式的置中可視區域位置。請勿將這種設計用於選單,因為這會導致選單與觸發條件分離,且置中的子選單會遮蓋父項。在 @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) 的早期實作,請透過指令碼為每個觸發條件提供不重複的 anchor-name,並為每個選單提供相符的 position-anchor,而不是為每個選單編寫 CSS 規則。

如果瀏覽器完全不支援錨點定位,請參閱可視區域感知下拉式選單,瞭解 polyfill 做法。

Invoker 指令的備用方案和瀏覽器支援

如果沒有叫用器,command="toggle-popover" 就不會執行任何動作,因此指令碼必須切換選單。

  • 偵測介面功能,而非例項:'commandForElement' in HTMLButtonElement.prototype。
  • 在每個觸發條件 click 上,呼叫 preventDefault(),讓具有叫用器的瀏覽器無法切換兩次。
  • 如果選單已開啟,請按 closeAndRestore(menu) 關閉。否則請呼叫 menu.showPopover({ source: trigger })。
  • 除非點擊來自按下的觸發條件 (pointerTrigger),否則請將選單標示為 enterOnOpen,這樣只有鍵盤開啟時才會進入選單。
  • Enter 和 Space 仍可運作,因為這兩個指令會產生按鈕點擊。