发布时间:2025 年 3 月 7 日
按钮对于构建动态 Web 应用至关重要。按钮用于打开菜单、切换操作和提交表单。它们为 Web 上的互动性奠定了基础。让按钮简单易用可能会带来一些意想不到的挑战。构建微前端或组件系统的开发者可能会遇到过于复杂的解决方案。虽然框架有用,但 Web 可以提供更多帮助。
Chrome 135 引入了新功能,可通过新的 command
和 commandfor
属性提供声明式行为,从而增强和替换 popovertargetaction
和 popovertarget
属性。这些新属性可添加到按钮中,让浏览器能够解决一些与简单性和无障碍性相关的核心问题,并提供内置的常用功能。
传统模式
随着生产代码的演变,在不使用框架的情况下构建按钮行为可能会带来一些有趣的挑战。虽然 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 模式
借助 command
和 commandfor
属性,按钮现在可以声明式地对其他元素执行操作,从而实现框架的人体工学设计,而不会牺牲灵活性。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>
将 command
和 commandfor
与 popovertargetaction
和 popovertarget
进行比较
如果您之前使用过 popover
,可能对 popovertarget
和 popovertargetaction
属性很熟悉。它们的运作方式分别与 commandfor
和 command
类似,但仅适用于弹出式窗口。command
和 commandfor
属性完全取代了这些旧属性。新属性支持旧属性支持的所有功能,同时还添加了新功能。
内置命令
command
属性具有一组内置行为,这些行为会映射到互动元素的各种 API:
show-popover
:映射到el.showPopover()
。hide-popover
:映射到el.hidePopover()
。toggle-popover
:映射到el.togglePopover()
。show-modal
:映射到dialogEl.showModal()
。close
:映射到dialogEl.close()
。
这些命令会映射到其 JavaScript 对应项,同时简化无障碍功能(例如提供 aria-details
和 aria-expanded
等效关系)、焦点管理等。
示例:包含 command
和 commandfor
的确认对话框
<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 按钮会以模态方式打开对话框,而点击 Close、Cancel 或 Delete 按钮会关闭对话框,同时还会向对话框分派 "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 问题跟踪器中提交问题。