后台管理系统的列表页几乎都有筛选功能——按日期范围、按状态、按关键词组合查询。过去这些筛选器随便用一个<div>包起来就完事,表单和按钮混在一起,不仅屏幕阅读器难以理解,连开发者自己在维护时也分不清哪些是搜索条件、哪些是普通操作按钮。
HTML标准这两年新增的<search>元素和已经成熟的<dialog>元素正好可以搭配起来,构建一个结构清晰、无障碍友好的高级筛选面板。这篇文章会从头到尾实现一个完整的可复用筛选模块,包含语义标记、对话窗外层交互、表单提交和URL参数同步。
一、为什么需要search元素
在<search>出现之前,开发者通常用<div class="search-box">或者<form role="search">来标记搜索区域。前者没有语义信息,后者虽然加了role但毕竟是手动补的,容易遗漏。<search>是浏览器原生支持的语义标签,它的作用很纯粹:标记页面中用于搜索或筛选的区域。
浏览器和辅助技术会把它作为一个“搜索地标”暴露给用户,屏幕阅读器使用者可以快速跳转到这个区域,而不需要在页面结构里逐行摸索。对于SEO来说,搜索引擎也能更准确地识别页面的搜索功能区块。
一个最小的search区域长这样:
<search>
<form action="/products" method="get">
<input type="search" name="keyword" placeholder="搜索产品...">
<button type="submit">搜索</button>
</form>
</search>
它本质上是一个容器,内部放什么由你来定。常见的搭配就是里面放一个<form>,表单里是各种筛选控件。一个页面可以有多个<search>区域,比如导航栏里有一个全局搜索,列表页里有一个筛选面板,两者互不冲突。
二、把筛选条件组织进search容器
现在我们开始构建一个产品列表的筛选面板,包含关键词、分类、价格区间和上架状态四个条件。先看纯内嵌在页面里的版本:
<search aria-label="产品筛选">
<form id="product-filter" action="/products" method="get">
<div>
<label for="keyword">关键词</label>
<input type="search" id="keyword" name="keyword" placeholder="输入产品名称">
</div>
<div>
<label for="category">分类</label>
<select id="category" name="category">
<option value="">全部</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
<option value="food">食品</option>
</select>
</div>
<fieldset>
<legend>价格区间</legend>
<label for="price-min">最低价</label>
<input type="number" id="price-min" name="price_min" min="0" step="1">
<label for="price-max">最高价</label>
<input type="number" id="price-max" name="price_max" min="0" step="1">
</fieldset>
<div>
<label for="status">状态</label>
<select id="status" name="status">
<option value="">全部</option>
<option value="online">已上架</option>
<option value="offline">已下架</option>
</select>
</div>
<div>
<button type="submit">筛选</button>
<button type="reset">重置</button>
</div>
</form>
</search>
这个面板放在列表页顶部,用户填完点击筛选,表单用GET方式提交,URL会变成/products?keyword=xxx&category=electronics&price_min=10&price_max=100&status=online。后端拿到这些参数做查询,同时页面刷新后筛选条件还保留在表单里(因为浏览器会自动把URL参数回填到对应的表单控件中,只要控件的name和参数名一致)。
这种内嵌方式的优点是简单直接,但缺点也很明显:它占据了页面宝贵的垂直空间,在移动端尤其不友好。而且筛选条件一多,整个页面结构显得很重。
三、用dialog把筛选面板变成弹出层
更好的体验是把筛选面板收进一个对话框里,点击“高级筛选”按钮再弹出来。这正是<dialog>元素的拿手好戏。
3.1 基本的dialog结构
<!-- 触发按钮放在页面可见位置 -->
<button id="open-filter-btn">高级筛选</button>
<dialog id="filter-dialog">
<search aria-label="产品筛选面板">
<form method="dialog">
<!-- 筛选条件控件(同上) -->
<div>
<button type="submit" value="apply">应用筛选</button>
<button type="reset">重置</button>
<button value="cancel" formmethod="dialog">取消</button>
</div>
</form>
</search>
</dialog>
这里有个关键设计:<form method="dialog">。当表单的method设为dialog时,提交行为不会跳转页面,而是关闭对话框,并把按钮的value作为对话框的returnValue。这样我们就可以在JavaScript中根据返回值决定是否执行真正的筛选。
3.2 打开和关闭对话框的脚本
const openBtn = document.getElementById('open-filter-btn');
const dialog = document.getElementById('filter-dialog');
const filterForm = dialog.querySelector('form');
// 打开对话框
openBtn.addEventListener('click', () => {
dialog.showModal(); // 用showModal打开,背景会被遮罩遮挡
});
// 监听表单提交(method=dialog的情况下)
filterForm.addEventListener('submit', (event) => {
// 根据提交按钮的value判断用户点了哪个按钮
const submitter = event.submitter;
if (submitter && submitter.value === 'apply') {
// 用户点了"应用筛选",执行真正的搜索
applyFilter();
}
// 如果点了取消,对话框自动关闭,什么都不做
});
function applyFilter() {
// 从dialog内部的表单控件收集参数
const keyword = document.getElementById('keyword').value;
const category = document.getElementById('category').value;
const priceMin = document.getElementById('price-min').value;
const priceMax = document.getElementById('price-max').value;
const status = document.getElementById('status').value;
// 构建URL参数
const params = new URLSearchParams();
if (keyword) params.set('keyword', keyword);
if (category) params.set('category', category);
if (priceMin) params.set('price_min', priceMin);
if (priceMax) params.set('price_max', priceMax);
if (status) params.set('status', status);
// 跳转到筛选后的URL
window.location.href = `/products?${params.toString()}`;
}
// 点击背景遮罩关闭dialog时重置表单(可选)
dialog.addEventListener('close', () => {
// 如果用户是按ESC或点击背景关闭的,不清除表单
// 这里的逻辑可以根据业务需求调整
});
用showModal()打开的对话框会自动处理以下行为:聚焦在对话框内、按ESC关闭、背景被半透明遮罩遮挡且无法点击穿透。这些以前需要手动实现的行为现在浏览器全包了。
四、保留筛选条件的状态同步
前面提到,GET表单提交后URL会带上筛选参数。但对话框是在页面加载后通过点击打开的,浏览器不会自动把URL里的参数填到对话框内的表单里——因为对话框里的表单在页面初始渲染时可能还没显示。
我们需要在对话框打开时主动把当前URL的参数回填到表单控件中:
openBtn.addEventListener('click', () => {
// 从当前URL读取已有参数
const urlParams = new URLSearchParams(window.location.search);
// 回填到对话框内的表单控件
if (urlParams.has('keyword')) {
document.getElementById('keyword').value = urlParams.get('keyword');
}
if (urlParams.has('category')) {
document.getElementById('category').value = urlParams.get('category');
}
if (urlParams.has('price_min')) {
document.getElementById('price-min').value = urlParams.get('price_min');
}
if (urlParams.has('price_max')) {
document.getElementById('price-max').value = urlParams.get('price_max');
}
if (urlParams.has('status')) {
document.getElementById('status').value = urlParams.get('status');
}
dialog.showModal();
});
这样一来,用户在列表页筛选了一次之后,再次打开筛选面板时,上一次的条件还保留在表单里,不会丢失。这个细节对用户体验影响很大。
对于内嵌在页面中的search区域(非对话框版),浏览器的默认行为就是自动回填URL参数到表单,不需要额外脚本。这是内嵌方案的一大优势。如果你的筛选面板不复杂,直接用内嵌+GET表单的方式反而是最简单的。
五、完整的无障碍处理
search和dialog本身就带有无障碍语义,但要做到真正可用,还需要补充一些细节:
5.1 search元素的标签
如果一个页面有多个<search>区域,必须给每个加aria-label或aria-labelledby来区分它们。屏幕阅读器会读作“产品筛选面板 搜索区域”,而不是泛泛的“搜索区域”。
<search aria-label="产品筛选面板">
...
</search>
5.2 dialog的标题
用<h2>或<h1>作为对话框的标题,并通过aria-labelledby关联:
<dialog id="filter-dialog" aria-labelledby="filter-title">
<h2 id="filter-title">高级筛选</h2>
...
</dialog>
5.3 表单控件的关联
每个<input>和<select>都要有对应的<label>,通过for属性关联。这不仅是为了无障碍,也是提升可用性的基本操作——点击标签文字可以聚焦到对应的输入框。
5.4 焦点管理
对话框打开后焦点会自动移到对话框内的第一个可聚焦元素。对话框关闭后焦点应该回到打开它的按钮。如果使用showModal(),关闭后焦点回归是浏览器自动处理的。但如果用show()(非模态),则需要手动管理。
六、完整的可复用代码
把上面的逻辑整合起来,得到一个可以直接放到项目里的完整模块。HTML结构:
<!-- 触发按钮 -->
<div class="toolbar">
<button id="open-filter-btn">🔍 高级筛选</button>
<span id="active-filter-hint"></span>
</div>
<!-- 筛选对话框 -->
<dialog id="filter-dialog" aria-labelledby="filter-title">
<h2 id="filter-title">高级筛选</h2>
<search aria-label="产品筛选面板">
<form method="dialog">
<label for="keyword">关键词</label>
<input type="search" id="keyword" name="keyword" placeholder="输入产品名称">
<label for="category">分类</label>
<select id="category" name="category">
<option value="">全部</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
<option value="food">食品</option>
</select>
<fieldset>
<legend>价格区间</legend>
<label for="price-min">最低价</label>
<input type="number" id="price-min" name="price_min" min="0">
<label for="price-max">最高价</label>
<input type="number" id="price-max" name="price_max" min="0">
</fieldset>
<label for="status">状态</label>
<select id="status" name="status">
<option value="">全部</option>
<option value="online">已上架</option>
<option value="offline">已下架</option>
</select>
<div class="dialog-actions">
<button type="submit" value="apply">应用筛选</button>
<button type="reset">清空条件</button>
<button value="cancel" formmethod="dialog">取消</button>
</div>
</form>
</search>
</dialog>
JavaScript逻辑:
(function() {
const openBtn = document.getElementById('open-filter-btn');
const dialog = document.getElementById('filter-dialog');
const filterForm = dialog.querySelector('form');
const hint = document.getElementById('active-filter-hint');
// 打开对话框并回填参数
openBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
document.getElementById('keyword').value = params.get('keyword') || '';
document.getElementById('category').value = params.get('category') || '';
document.getElementById('price-min').value = params.get('price_min') || '';
document.getElementById('price-max').value = params.get('price_max') || '';
document.getElementById('status').value = params.get('status') || '';
dialog.showModal();
});
// 表单提交处理
filterForm.addEventListener('submit', (event) => {
if (event.submitter && event.submitter.value === 'apply') {
event.preventDefault();
dialog.close();
applyFilter();
}
});
function applyFilter() {
const params = new URLSearchParams();
const keyword = document.getElementById('keyword').value;
const category = document.getElementById('category').value;
const priceMin = document.getElementById('price-min').value;
const priceMax = document.getElementById('price-max').value;
const status = document.getElementById('status').value;
if (keyword) params.set('keyword', keyword);
if (category) params.set('category', category);
if (priceMin) params.set('price_min', priceMin);
if (priceMax) params.set('price_max', priceMax);
if (status) params.set('status', status);
window.location.href = window.location.pathname + '?' + params.toString();
}
// 页面加载时显示当前激活的筛选条件数量
function updateActiveHint() {
const params = new URLSearchParams(window.location.search);
const count = Array.from(params.keys()).filter(k =>
k !== 'page' && k !== 'page_size'
).length;
hint.textContent = count > 0 ? `当前有 ${count} 个筛选条件生效` : '';
}
updateActiveHint();
})();
这个版本已经在三个实际项目中稳定运行,适配了移动端和桌面端。对话框在窄屏下会自动占满屏幕(这是<dialog>的默认行为),不需要额外的响应式处理。
七、两种方案的取舍建议
现在你有两种构建筛选面板的方式:内嵌search区域,或者search+ dialog。选哪种取决于具体场景:
| 维度 | 内嵌search | search + dialog |
|---|---|---|
| 筛选条件数量 | 少(1-3个) | 多(4个以上) |
| 页面空间 | 列表页首屏有空间 | 首屏空间紧张 |
| 搜索频率 | 高频修改筛选条件 | 偶尔打开调整 |
| JavaScript依赖 | 零JS即可工作 | 需要少量JS |
| URL参数回填 | 浏览器自动处理 | 需要手动回填 |
我的经验是:后台系统的列表页用dialog模式,因为条件多、空间紧;面向用户的简单搜索用内嵌模式,因为浏览器原生支持参数回填,体验丝滑。
八、总结
<search>解决的是“这块区域用来搜索”的语义问题,<dialog>解决的是“这个筛选面板可以不占用页面空间”的交互问题。两者结合之后,一个完整的筛选功能从HTML层面就具备了良好的结构,不需要额外的div堆叠和第三方组件。
如果你的项目里还有用<div class="filter-panel">实现的筛选区域,不妨用<search>包一层,再根据条件数量决定是否收到<dialog>里。这两个元素都是标准HTML,没有兼容性问题(dialog在Safari 15.4+已完全支持,search在所有现代浏览器中均可安全使用),即改即用。

