推出 command 和 commandfor

发布时间:2025 年 3 月 7 日

按钮对于构建动态 Web 应用至关重要。按钮用于打开菜单、切换操作和提交表单。它们为 Web 上的互动性奠定了基础。让按钮简单易用可能会带来一些意想不到的挑战。构建微前端或组件系统的开发者可能会遇到过于复杂的解决方案。虽然框架有用,但 Web 可以提供更多帮助。

Chrome 135 引入了新功能,可通过新的 commandcommandfor 属性提供声明式行为,从而增强和替换 popovertargetactionpopovertarget 属性。这些新属性可添加到按钮中,让浏览器能够解决一些与简单性和无障碍性相关的核心问题,并提供内置的常用功能。

传统模式

随着生产代码的演变,在不使用框架的情况下构建按钮行为可能会带来一些有趣的挑战。虽然 HTML 为按钮提供了 onclick 处理脚本,但由于内容安全政策 (CSP) 规则,通常不允许在演示或教程中以外的地方使用这些脚本。虽然这些事件会分派给按钮元素,但按钮通常会放置在页面上以控制其他元素,这需要代码同时控制两个元素。您还需要确保辅助技术用户可以访问此互动。这通常会导致代码看起来如下所示:

<div class="menu-wrapper">
  <button class="menu-opener" aria-expanded="false">
    Open Menu
  </button>
  <div popover class="menu-content">
    <!-- ... -->
  </div>
</div>
<script type="module">
document.addEventListener('click', e => {  
  const button = e.target;
  if (button.matches('.menu-opener')) {
    const menu = button
      .closest('.menu-wrapper')
      .querySelector('.menu-content');
    if (menu) {
      button.setAttribute('aria-expanded', 'true');
      menu.showPopover();
      menu.addEventListener('toggle', e => {
        // reset back to aria-expanded=false on close
        if (e.newState == 'closed') {
          button.setAttribute('aria-expanded', 'false');
        }
      }, {once: true})
    }
  }
});
</script>

这种方法可能有点脆弱,而框架旨在改善工效学。 使用 React 等框架的常见模式可能涉及将点击映射到状态更改:

function MyMenu({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const handleToggle = useCallback((e) => {
      // popovers have light dismiss which influences our state
     setIsOpen(e.newState === 'open')
  }, []);
  const popoverRef = useRef(null);
  useEffect(() => {
    if (popoverRef.current) {
      if (isOpen) {
        popoverRef.current.showPopover();
      } else {
        popoverRef.current.hidePopover();
      }
    }
  }, [popoverRef, isOpen]);
  return (
    <>
      <button onClick={open} aria-expanded={isOpen}>
        Open Menu
      </button>
      <div popover onToggle={handleToggle} ref={popoverRef}>
        {children}
      </div>
    </>
  );
}

许多其他框架也旨在提供类似的人体工学体验,例如,这段代码在 AlpineJS 中可以写为:

<div x-data="{open: false}">
  <button @click="open = !open; $refs.popover.showPopover()" :aria-expanded="open">
    Open Menu
  </button>
  <div popover x-ref="popover" @toggle="open = $event.newState === 'open'">
    <!-- ... -->
  </div>
</div>

在 Svelte 中编写此代码时,代码可能如下所示:

<script>
  let popover;
  let open = false;
  function togglePopover() {
    open ? popover.hidePopover() : popover.showPopover();
    open = !open;
  }
</script>
<button on:click={togglePopover} aria-expanded={open}>
  Open Menu
</button>
<div bind:this={popover} popover>
  <!-- ... -->
</div>

某些设计系统或库可能会更进一步,在按钮元素周围提供封装状态更改的封装容器。这会提取触发器组件背后的状态更改,以牺牲一点灵活性为代价来改进人体工学:

import {MenuTrigger, MenuContent} from 'my-design-system';
function MyMenu({children}) {
  return (
    <MenuTrigger>
      <button>Open Menu</button>
    </MenuTrigger>
    <MenuContent>{children}</MenuContent>
  );
}

command 和 commandfor 模式

借助 commandcommandfor 属性,按钮现在可以声明式地对其他元素执行操作,从而实现框架的人体工学设计,而不会牺牲灵活性。commandfor 按钮接受 ID(类似于 for 属性),而 command 接受内置值,从而实现更便携、更直观的方法。

示例:带有 command 和 commandfor 的打开菜单按钮

以下 HTML 会在按钮和菜单之间建立声明式关系,以便浏览器为您处理逻辑和无障碍功能。您无需管理 aria-expanded 或添加任何其他 JavaScript。

<button commandfor="my-menu" command="show-popover">
Open Menu
</button>
<div popover id="my-menu">
  <!-- ... -->
</div>

commandcommandforpopovertargetactionpopovertarget 进行比较

如果您之前使用过 popover,可能对 popovertargetpopovertargetaction 属性很熟悉。它们的运作方式分别与 commandforcommand 类似,但仅适用于弹出式窗口。commandcommandfor 属性完全取代了这些旧属性。新属性支持旧属性支持的所有功能,同时还添加了新功能。

内置命令

command 属性具有一组内置行为,这些行为会映射到互动元素的各种 API:

  • show-popover:映射到 el.showPopover()
  • hide-popover:映射到 el.hidePopover()
  • toggle-popover:映射到 el.togglePopover()
  • show-modal:映射到 dialogEl.showModal()
  • close:映射到 dialogEl.close()

这些命令会映射到其 JavaScript 对应项,同时简化无障碍功能(例如提供 aria-detailsaria-expanded 等效关系)、焦点管理等。

示例:包含 commandcommandfor 的确认对话框

<button commandfor="confirm-dialog" command="show-modal">
  Delete Record
</button>
<dialog id="confirm-dialog">
  <header>
    <h1>Delete Record?</h1>
    <button commandfor="confirm-dialog" command="close" aria-label="Close" value="close">
      <img role="none" src="/close-icon.svg">
    </button>
  </header>
  <p>Are you sure? This action cannot be undone</p>
  <footer>
    <button commandfor="confirm-dialog" command="close" value="cancel">
      Cancel
    </button>
    <button commandfor="confirm-dialog" command="close" value="delete">
      Delete
    </button>
  </footer>
</dialog>

点击 Delete Record 按钮会以模态方式打开对话框,而点击 CloseCancelDelete 按钮会关闭对话框,同时还会向对话框分派 "close" 事件,该事件具有与按钮值匹配的 returnValue 属性。这样一来,除了对话框上的单个事件监听器之外,就无需再使用 JavaScript 来确定后续要执行的操作:

dialog.addEventListener("close", (event) => {
  if (event.target.returnValue == "cancel") {
    console.log("cancel was clicked");
  } else if (event.target.returnValue == "close") {
    console.log("close was clicked");
  } else if (event.target.returnValue == "delete") {
    console.log("delete was clicked");
  }
});

自定义命令

除了内置命令之外,您还可以使用 -- 前缀定义自定义命令。自定义命令会在目标元素上分派 "command" 事件(就像内置命令一样),但除此之外,绝不会像内置值那样执行任何其他逻辑。这样,您就可以灵活地构建可以以各种方式响应按钮的组件,而无需提供封装容器组件、遍历目标元素的 DOM 或将按钮点击映射到状态更改。这样,您就可以在 HTML 为组件提供 API:

<button commandfor="the-image" command="--rotate-landscape">
 Landscape
</button>
<button commandfor="the-image" command="--rotate-portrait">
 Portrait
</button>

<img id="the-image" src="photo.jpg">

<script type="module">
  const image = document.getElementById("the-image");
  image.addEventListener("command", (event) => {
   if ( event.command == "--rotate-landscape" ) {
    image.style.rotate = "-90deg"
   } else if ( event.command == "--rotate-portrait" ) {
    image.style.rotate = "0deg"
   }
  });
</script>

ShadowDOM 中的命令

由于 commandfor 属性采用 ID,因此在穿越 shadow DOM 时存在限制。在这些情况下,您可以使用 JavaScript API 设置 .commandForElement 属性,该属性可跨阴影根设置任何元素:

<my-element>
  <template shadowrootmode=open>
    <button command="show-popover">Show popover</button>
    <slot></slot>
  </template>
  <div popover><!-- ... --></div>
</my-element>
<script>
customElements.define("my-element", class extends HTMLElement {
  connectedCallback() {
    const popover = this.querySelector('[popover]');
    // The commandForElement can set cross-shadow root elements.
    this.shadowRoot.querySelector('button').commandForElement = popover;
  }
});
</script>

未来的提案可能会提供一种声明式方式,用于跨阴影边界共享引用,例如引用目标提案

后续操作

我们将继续探索新的内置命令的可能性,以涵盖网站使用的常用功能。开放式界面提案中介绍了建议的想法。我们已经探索了一些想法:

  • 打开和关闭 <details> 元素。
  • 适用于 <input><select> 元素的 "show-picker" 命令,映射到 showPicker()
  • <video><audio> 元素的播放命令。
  • 复制元素中的文本内容。

我们欢迎社区提供反馈。如果您有任何建议,请随时在 Open UI 问题跟踪器中提交问题。

了解详情

如需详细了解 commandcommandfor,请参阅规范MDN