构建下拉菜单

构建下拉菜单

本指南涵盖所有弹出式菜单:下拉菜单、上下文菜单、溢出菜单以及打开这些菜单的菜单栏。此示例是一个应用命令菜单,其中的各项会更改界面(创建、保存、偏好设置)。

两个独立的选择。此处的其他所有内容均适用于这两种情况。

  • 元素,每件商品。命令是 <button type="button">;目标页面是 <a href>,仍会在新标签页中打开、作为网址复制,并且无需脚本即可运行。下拉菜单可以自由混合使用这两种类型:两个链接和六个命令,或一个命令和六个链接,都不会改变以下内容。
  • 互动模型,每个下拉菜单。方向键、Home/End 和 Esc 使其成为菜单:添加 focusgroup,让它推断每个项(无论是按钮还是链接)上的 menuitem,并使其看起来像一个菜单(连续的栏或带有下拉指示器的触发器;请参阅下文的“菜单栏或单独的菜单按钮”)。用户通过 Tab 浏览的简短链接列表属于导航:没有 focusgroup、role="menu"、role="menuitem" 或 aria-haspopup="menu";保留 popover、Esc 和焦点恢复,并参阅导航栏指南。

内容永远无法决定角色。“产品”下拉链接列表可以作为菜单;“账号”下拉命令列表不能作为导航。

仅对具有一个 Tab 键停靠位和箭头键导航功能的复合 widget 使用菜单模式。请使用 <div focusgroup="menu nomemory">,而不是 HTML <menu> 元素。请勿在共享标记中添加 role="menu" 或 role="menuitem":原生 focusgroup 会推断这些角色,而显式角色会阻止这种推断。请仅在下方的“不支持的浏览器后备”部分中添加这些内容。nomemory 会使重新打开时从第一项开始。焦点小组负责移动焦点;脚本负责处理预览、激活和焦点恢复。

打开并定位菜单

使用 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 回退优先。

打开和关闭动画

可选。在 @media (prefers-reduced-motion: no-preference) 内,仅 opacity 和小 translate 需在 150 毫秒内完成,子菜单也是如此。焦点会在 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 中添加第二个 roving-tabindex 系统。脚本仅涵盖调用器和 focusgroup 未涵盖的内容:

  • 菜单栏有一个 Tab 键停靠位。Tab 会在第一个顶级触发器处进入。ArrowLeft 和 ArrowRight 可在顶级触发器之间移动,并在任一端换行。在触发器上按 Home 和 End 可进入其菜单的第一项或最后一项。
    • 注意:Tab 会返回到哪个触发器。APG 允许使用第一个触发器或最后一个触发器。指南中指定的是第一个,但演示中通过 ArrowLeft 和 ArrowRight 移动了制表位,因此在键盘导航后,Shift+Tab 会返回到最后到达的触发器。使用指针不会移动它,因此相同的序列可能会落在不同的触发器上。这两种行为都是可以接受的;选择一种并保持一致。
  • 预览版。聚焦于顶层触发器会显示其菜单并设置 aria-expanded="true"。焦点仍停留在触发器上,没有任何内容获得焦点。模糊处理触发器会关闭仅预览菜单。
  • 在预览中按 Enter 键和空格键。他们关闭预览,并继续聚焦于触发器。原生 toggle-popover 操作会处理此问题,因此脚本不得拦截这些键。
  • 在预览上按向下键。将其转换为打开的菜单,并聚焦于第一项。
  • 指针点击已打开或预览的触发器。直接关闭,不进入菜单。
    • 子菜单触发器的已知差异。点击子菜单触发器也会原生切换,因此可以关闭悬停时已预览的子菜单,或打开尚未显示的子菜单。原生菜单只能打开它,而不能关闭它。这是无害的,因此本指南接受了它。
  • 指针点击已关闭的触发器。以原生方式打开。焦点仍停留在触发器上;没有任何内容获得焦点。只有通过键盘打开的操作才会进入菜单。
  • 指针悬停在某个内容上。聚焦时没有聚焦环,因此悬停和聚焦是同一个项。
  • 菜单打开时,指针悬停在另一个顶级触发器上。关闭当前预览,并预览光标悬停的预览,聚焦其触发器(无环)。未打开任何内容:悬停不会触发任何操作。
  • 指针悬停在子菜单触发器上。在短暂延迟后预览其子菜单(指针焦点会运行相同的“聚焦时预览”)。同级项会将其关闭。
  • 在菜单中。ArrowUp 和 ArrowDown 可在已启用的内容项之间移动。Home 和 End 会移至第一个和最后一个已启用的项,由脚本处理。
  • 在打开的顶级菜单中按向左键或向右键。关闭该菜单并预览相邻菜单,焦点位于其触发器上,在任一端换行。ArrowDown 随后进入该模式。“向左”和“向右”操作绝不会进入菜单。
  • 在子菜单触发器上按 ArrowRight。打开子菜单并将焦点置于其第一项。
  • 在没有子菜单的子菜单项上按 ArrowRight。关闭层次结构并预览下一个顶级菜单,同时焦点仍停留在其触发器上。ArrowDown 随后进入该状态。
  • 在子菜单中按向左键或 Esc 键。仅关闭该子菜单,并将焦点恢复到其触发器。父级菜单保持打开状态。
  • 在命令上按 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 和 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,如上所述。
  • 子菜单触发器绝不会同时运行命令:一个控件不能同时执行激活和打开操作,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,它们位于同一个 focusgroup 中。
  • 脚本切换开关 aria-checked。
  • 在复选框上按 Enter 会切换状态并关闭菜单;按 Space 会切换状态并保持打开状态 (APG)。按任一键即可关闭单选按钮。
  • 从 [aria-checked="true"]::before 渲染对勾标记,而不是单独的图标元素(请参阅下面的 CSS)。

此处未涵盖

  • 激活反馈。原生菜单会在关闭前闪烁所选项。如果需要,请在 hidePopover() 之前显示约 100 毫秒的视觉确认;请勿延迟焦点恢复或语音播报。
  • 预输入。焦点小组没有。如有需要,在可打印的 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,请勿安装该 polyfill。无论哪种方式,左、右、预览、打开和关闭都是作者脚本。

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,则需要手动后备方案。

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 锚点定位,弹出式窗口会回退到 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)的早期实现,请通过脚本为每个触发器指定唯一的 anchor-name,并为每个菜单指定匹配的 position-anchor,而不是为每个菜单编写 CSS 规则。

对于完全不支持锚点定位的浏览器,请参阅视口感知下拉菜单,了解 polyfill 方案。

调用器命令的后备方案和浏览器支持

如果没有调用程序,command="toggle-popover" 将不会执行任何操作,因此脚本必须切换菜单。

  • 检测接口(而非实例)的功能:'commandForElement' in HTMLButtonElement.prototype。
  • 在每个触发器 click 上,调用 preventDefault(),以便具有调用器的浏览器无法切换两次。
  • 如果菜单已打开,请使用 closeAndRestore(menu) 将其关闭。否则,请致电 menu.showPopover({ source: trigger })。
  • 在 enterOnOpen 中标记菜单,除非点击来自按下的触发器 (pointerTrigger),因此只有键盘打开操作会进入菜单。
  • Enter 和 Space 仍然有效,因为它们会生成对按钮的点击。