← Dialog / Popover · 浮层与「关闭」的现代解法 / 可定制的 <select>:下拉也是 popover 待审核 9 / 9
select · appearance: base-select

可定制的 <select>:下拉也是 popover

<select> 是出了名的「难以定制样式」:按钮部分尚可调整,展开后那张选项列表历来在浏览器原生层渲染——CSS 触及不到。想做「带图标 / 副标题 / 头像的富下拉」,过去只能将整个控件从零实现一套:一个按钮 + 一个浮层,再自行补齐键盘上下选、type-ahead、焦点、Esc 关闭、点外部关闭、读屏语义、以及定位……也就是把本系列 dialogpopoverclosedby 各页讲的工作全部实现一遍。

现在有了官方解法——Customizable Select:一句 appearance: base-select<select> 交出渲染权,你可以在 <option> 里放入任意 HTML、自由地为那张列表编写 CSS。而那张列表正是一个 popover:浏览器把它放进 top layer、用 anchor positioning 自动钉在按钮下方,键盘 / type-ahead / 无障碍原样保留。换言之:Popover API 一页讲的 popover、anchor-positioning 系列的锚定,浏览器已替你在 <select> 里组装完毕。

1 · 动手:一个完全自定义的下拉

下面是一个原生 <select>——并非 div 模拟。展开查看:选项带色块 + 名称 + 十六进制副标题,按冷 / 暖色分成两组(<optgroup>),选中项打勾,列表是一张贴纸卡片。键盘 ↑↓ / 回车 / 首字母都可用,而这些一行代码都未写。

2 · 它是怎么搭的

三件事:其一,两处 appearance: base-select 交出渲染权;其二,新的标记结构;其三,几个新伪元素。

<selectedcontent> 是镜子。它把当前选中 <option> 的子节点克隆一份显示在按钮里。所以按钮和选项天然一致;想让按钮更精简(比如只留名称、不要 #hex 副标题),只需单独给 selectedcontent 里那部分写 display:none——上面那个开关演示的正是这件事。

3 · 像 macOS:让选中项对齐按钮

系统原生下拉(尤其 macOS)有一个交互细节:展开时当前选中项正好盖在按钮上,整张列表围着它上下铺开——指针几乎不用移动就能换选。::picker(select) 的默认行为却是「掉在按钮下方」。想要 macOS 那种对齐,分两步:先用 anchor positioning 把列表顶边贴住按钮顶边(top: anchor(top)),再按「选中项在列表里的位置」把整张列表上移。这个偏移量取决于选中第几项,纯 CSS 无法测量,需补几行 JS:

两处要点。其一,可定制 <select> <select> 上派发 toggle(那是 popover 的事件)——只能依靠 pointerdown / keydown + requestAnimationFrame 等列表布局完成后再测量。其二,UA 默认会给 picker 一套 position-try 自动上下翻转,会与手写定位冲突,需先用 position-try-fallbacks: none 关闭。

位移为什么不加过渡?原生菜单是在对齐位置「就地出现」的,位移带动画反而像普通下拉;这里只让列表淡入(opacity),而首帧 @starting-style 已是 opacity:0,重定位那一下看不见。选中第一项时偏移为 0,自然退化成「贴着按钮顶部展开」。

4 · 分组与分隔:optgroup / hr

上面那个下拉已经按冷 / 暖色分成了两组——用的还是原生 <optgroup>。可定制 select 里它有个新写法:组标题不再写在 label 属性上,而是放进一个 <legend> 子元素——这样标题能被 CSS 选中、随意定制,语义不变。降级时反过来:不支持的浏览器忽略 <legend>、回头读 label 属性,所以两者都写最稳妥。

另一种分隔:<hr>。除了 <optgroup>,可定制 select 还允许在选项之间直接插 <hr> 当分隔线(轻量、无标题)。两者都可写 CSS。

对齐那段 JS 仍然成立:<legend> 撑出的高度会算进「选中项比按钮顶边低多少」,macOS 式偏移自动把它一并计入,无需改动。

5 · 新伪元素 / 伪类速查

浏览器原生提供的部分(无须自行重写):键盘 ↑↓ 选项、首字母 type-ahead、回车确认、Esc / 点外部关闭、焦点管理、role=listbox/option 等读屏语义、表单提交时的 name=valuechange 事件——全由浏览器维护。你只需关注外观。这正是「自行实现 popover 下拉」中最难处理、最容易在无障碍上出错的部分。

6 · 那么,还需要自行实现的 popover 吗?

路线图上的 split button(分裂按钮)。规范还想更进一步:把按钮拆成两半——主区执行默认动作(例如直接「保存」),旁边一截箭头区只负责开 picker(展开「另存为 / 导出…」那张列表)。这就是工具栏里常见的「主操作 + 下拉补充项」控件。

为何还没法用:实现它要在 <select> 里塞两个可聚焦的 <button>,而前面说过「<option> / <select> 内暂不允许可交互元素」正是为了焦点与读屏语义不出错——split button 的无障碍方案仍在完善,目前处于实验阶段、无稳定标记,生产里要这种控件仍需用 button + popover 自行组合。

兼容与降级:appearance: base-select 目前是实验阶段(Chrome 135+ 已在正式版,Firefox / Safari 未跟进)。不支持的浏览器会忽略它,渲染回系统原生下拉:<option> 里的富文本被压平成纯文本<button> / <selectedcontent> 被忽略——功能一点不缺,只是样式回到默认。所以写 <option> 时应让文本本身就说清选项(不要只依赖色块/图标),降级才得体。

另有几个限制:base-select 目前不支持 <select multiple>size 属性(多选 / 多行列表还得自行用 popover 实现);<option> 里也不能放可聚焦 / 可交互的元素(按钮、链接)——这是为保住键盘与读屏语义而设的硬约束,不是样式偏好。

延伸阅读:Customizable <select> · appearance: base-select · developer.chrome.com——appearance:base-select 让原生 <select> 完全可定制,以及 <selectedcontent> / ::picker(select) / ::checkmark 的用法;它的下拉本质就是一个自动锚定的 popover。