HOME> 天宫盛宴> 如何构建自定义表单控件

如何构建自定义表单控件

2026-06-24 09:41:00

设计、结构和语义

在构建一个自定义控件之前,首先你要确切的知道你要什么。这将为你节省宝贵的时间。特别地,清楚地定义控件的所有状态非常重要。要做到这一点,从状态和行为表现都为人所熟知的现有控件开始是很好的选择,这样你可以充分地模仿这些控件。

在我们的示例中,我们将重建 相同的功能,那么它应该对所有用户都有相同的行为,不论是键盘、鼠标、触摸、屏幕阅读器,还是其他任何输入设备。

在我们的示例中,规范的缺失是显而易见的,所以我们将着手处理它们,但是对于一些没有人想到去定义正确行为的控件而言,这的确是一个问题。所以在元素(例如 元素来观察其所有的选项(这和用鼠标点击 元素需要这样做:

html

Cherry

  • Cherry
  • Lemon
  • Banana
  • Strawberry
  • Apple

注意类名的使用:不管实际使用了哪种底层 HTML 元素,它们都标识每个相关的部分。这很重要,因为这样做能确保我们的 CSS 和 JavaScript 不会和 HTML 结构强绑定,这样我们就可以在不破坏使用控件的代码的情况下进行实现更改。比如,如果你希望增加一个等价的 元素。

然而,类名并不提供语义值。到现在为止,屏幕阅读器的用户只能“看到”无序列表。我们后面会为其添加 ARIA 语义。

使用 CSS 创建外观

现在我们有了结构,我们可以开始设计我们的控件了。构建自定义控件的重点是能够完全按照我们的期望设置它的样式。为了达到这个目的,我们将 CSS 部分的工作分为两部分:第一部分是让我们的控件表现得像一个 元素。包括我们的控件和 元素。即使我们的 JavaScript 按预期工作,这个“额外”的 select 也是有好处的:我们可以使用这个 select 来将来自我们自定义的表单控件以及其他部分的表单数据发送出去。我们随后会详细的解释这一点。

html

Cherry

第二,我们需要两个新的类来隐藏不需要的元素:如果脚本未运行,我们会在视觉上隐藏自定义控件;如果脚本正常运行,则隐藏“真正”的 元素

- 或是我们没有改变 body 的类,这样 body 的类还是“no-widget”,

因此类为“select”的元素需要被隐藏 */

position: absolute;

left: -5000em;

height: 0;

overflow: hidden;

}

上面的 CSS 在视觉上隐藏了其中的一个元素,但对屏幕阅读器仍然“可见”。

接下来我们需要一个 JavaScript 开关来决定脚本是否运行。这个开关非常简单:如果页面加载时,我们的脚本运行了,它将会移除 no-widget 类,并添加 widget 类,由此切换

Cherry

.widget select,

.no-widget .select {

position: absolute;

left: -5000em;

height: 0;

overflow: hidden;

}

使用 JS

查看完整源代码。

Cherry

.widget select,

.no-widget .select {

position: absolute;

left: -5000em;

height: 0;

overflow: hidden;

}

.select {

position: relative;

display: inline-block;

}

.select.active,

.select:focus {

box-shadow: 0 0 3px 1px #227755;

outline: none;

}

.select .optList {

position: absolute;

top: 100%;

left: 0;

}

.select .optList.hidden {

max-height: 0;

visibility: hidden;

}

.select {

font-size: 0.625em; /* 10px */

font-family: Verdana, Arial, sans-serif;

box-sizing: border-box;

padding: 0.1em 2.5em 0.2em 0.5em; /* 1px 25px 2px 5px */

width: 10em; /* 100px */

border: 0.2em solid #000; /* 2px */

border-radius: 0.4em; /* 4px */

box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45); /* 0 1px 2px */

background: #f0f0f0;

background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);

}

.select .value {

display: inline-block;

width: 100%;

overflow: hidden;

white-space: nowrap;

text-overflow: ellipsis;

vertical-align: top;

}

.select::after {

content: "▼";

position: absolute;

z-index: 1;

height: 100%;

width: 2em; /* 20px */

top: 0;

right: 0;

padding-top: 0.1em;

box-sizing: border-box;

text-align: center;

border-left: 0.2em solid #000;

border-radius: 0 0.1em 0.1em 0;

background-color: #000;

color: #fff;

}

.select .optList {

z-index: 2;

list-style: none;

margin: 0;

padding: 0;

background: #f0f0f0;

border: 0.2em solid #000;

border-top-width: 0.1em;

border-radius: 0 0 0.4em 0.4em;

box-shadow: 0 0.2em 0.4em rgba(0, 0, 0, 0.4);

box-sizing: border-box;

min-width: 100%;

max-height: 10em; /* 100px */

overflow-y: auto;

overflow-x: hidden;

}

.select .option {

padding: 0.2em 0.3em;

}

.select .highlight {

background: #000;

color: #ffffff;

}

window.addEventListener("load", () => {

const form = document.querySelector("form");

form.classList.remove("no-widget");

form.classList.add("widget");

});

备注:如果你真的想让你的代码变得通用和可复用,最好不要仅仅是添加一个类选择器开关,而是通过添加一个控件类的方式来隐藏 元素后面添加代表页面中自定义控件的 DOM 树。

让工作变得更简单

在我们将要构建的代码之中,我们将会使用标准的 DOM API 和 JavaScript 来完成要做的所有工作。我们准备使用的特性如下所示:

classList

addEventListener()

NodeList.forEach()

querySelector() 和 querySelectorAll()

构造事件回调

基础已经准备好了。我们现在可以开始定义用户每次同我们的控件交互时会用到的所有函数了。

js// 这个函数会用在每当我们想要停用一个自定义控件的时候

// 它需要一个参数:

// select:要停用的带有 `select` 类的节点

function deactivateSelect(select) {

// 如果控件没有运行,不用进行任何操作

if (!select.classList.contains("active")) return;

// 我们需要获取自定义控件的选项列表

const optList = select.querySelector(".optList");

// 关闭选项列表

optList.classList.add("hidden");

// 然后停用控件本身

select.classList.remove("active");

}

// 每当用户想要激活(或停用)这个控件的时候,会调用这个函数

// 它需要 2 个参数:

// select:要激活的带有 `select` 类的 DOM 节点

// selectList:包含所有带 `select` 类的 DOM 节点的列表

function activeSelect(select, selectList) {

// 如果控件已经激活了,不进行任何操作

if (select.classList.contains("active")) return;

// 我们需要关闭所有自定义控件的活动状态

// 因为 deactiveselect 函数满足 forEach 回调函数的所有请求,

// 我们直接使用它,不使用中间匿名函数

selectList.forEach(deactivateSelect);

// 然后我们激活指定的控件

select.classList.add("active");

}

// 每当用户想要打开/关闭选项列表的时候,会调用这个函数

// 它需要一个参数:

// select:要反转状态的列表的 DOM 节点

function toggleOptList(select) {

// 该列表不包含在控件中

const optList = select.querySelector(".optList");

// 我们改变列表的类去显示/隐藏它

optList.classList.toggle("hidden");

}

// 每当我们要高亮一个选项的时候,会调用该函数

// 它需要两个参数:

// select:带有 `select` 类的 DOM 节点,包含了需要高亮强调的选项

// option:需要高亮强调的带有'option'类的 DOM 节点

function highlightOption(select, option) {

// 为我们的自定义 select 元素获取所有有效选项的列表

const optionList = select.querySelectorAll(".option");

// 我们移除所有选项的高亮强调

optionList.forEach(function (other) {

other.classList.remove("highlight");

});

// 我们高亮强调正确的选项

option.classList.add("highlight");

}

这是你需要用来处理控件不同状态的所有代码。

接下来,我们将这些函数绑定到合适的事件上:

js// 我们处理文档加载时的事件绑定。

window.addEventListener("load", function () {

const selectList = document.querySelectorAll(".select");

// 每个自定义控件都需要初始化

selectList.forEach(function (select) {

// 它的 `option` 元素也需要

const optionList = select.querySelectorAll(".option");

// 每当用户的鼠标悬停在一个选项上时,我们高亮这个指定的选项

optionList.forEach((option) => {

option.addEventListener("mouseover", () => {

// 注意:`select` 和 `option` 变量是我们函数调用范围内有效的闭包。

highlightOption(select, option);

});

});

// 每当用户点击一个自定义的 select 元素时

select.addEventListener("click", (event) => {

// 注意:`select` 变量是我们函数调用范围内有效的闭包。

// 我们改变选项列表的可见性

toggleOptList(select);

});

// 如果控件获得了焦点

// 每当用户点击它或是用 tab 键访问这个控件时,其获得焦点

select.addEventListener("focus", (event) => {

// 注意:`select` 和 `selectlist` 变量是我们函数调用范围内有效的闭包。

// 我们激活这个控件

activeSelect(select, selectList);

});

// 如果控件失去焦点

select.addEventListener("blur", (event) => {

// 注意:`select` 变量是我们函数调用范围内有效的闭包。

// 我们关闭这个控件

deactivateSelect(select);

});

// 如果用户按下 `esc` 键,失去焦点

select.addEventListener("keyup", (event) => {

// 在松开 `esc` 键时关闭控件

if (event.key === "Escape") {

deactivateSelect(select);

}

});

});

});

现在,我们的控件会根据我们的设计改变状态,但是它的值仍然没有更新。我们接下来会处理这件事。

实时示例

查看完整源代码。

Cherry

.widget select,

.no-widget .select {

position: absolute;

left: -5000em;

height: 0;

overflow: hidden;

}

.select {

position: relative;

display: inline-block;

}

.select.active,

.select:focus {

box-shadow: 0 0 3px 1px #227755;

outline: none;

}

.select .optList {

position: absolute;

top: 100%;

left: 0;

}

.select .optList.hidden {

max-height: 0;

visibility: hidden;

}

.select {

font-size: 0.625em; /* 10px */

font-family: Verdana, Arial, sans-serif;

box-sizing: border-box;

padding: 0.1em 2.5em 0.2em 0.5em; /* 1px 25px 2px 5px */

width: 10em; /* 100px */

border: 0.2em solid #000; /* 2px */

border-radius: 0.4em; /* 4px */

box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45); /* 0 1px 2px */

background: #f0f0f0;

background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);

}

.select .value {

display: inline-block;

width: 100%;

overflow: hidden;

white-space: nowrap;

text-overflow: ellipsis;

vertical-align: top;

}

.select::after {

content: "▼";

position: absolute;

z-index: 1;

height: 100%;

width: 2em; /* 20px */

top: 0;

right: 0;

padding-top: 0.1em;

box-sizing: border-box;

text-align: center;

border-left: 0.2em solid #000;

border-radius: 0 0.1em 0.1em 0;

background-color: #000;

color: #fff;

}

.select .optList {

z-index: 2;

list-style: none;

margin: 0;

padding: 0;

background: #f0f0f0;

border: 0.2em solid #000;

border-top-width: 0.1em;

border-radius: 0 0 0.4em 0.4em;

box-shadow: 0 0.2em 0.4em rgba(0, 0, 0, 0.4);

box-sizing: border-box;

min-width: 100%;

max-height: 10em; /* 100px */

overflow-y: auto;

overflow-x: hidden;

}

.select .option {

padding: 0.2em 0.3em;

}

.select .highlight {

background: #000;

color: #ffffff;

}

function deactivateSelect(select) {

if (!select.classList.contains("active")) return;

const optList = select.querySelector(".optList");

optList.classList.add("hidden");

select.classList.remove("active");

}

function activeSelect(select, selectList) {

if (select.classList.contains("active")) return;

selectList.forEach(deactivateSelect);

select.classList.add("active");

}

function toggleOptList(select, show) {

const optList = select.querySelector(".optList");

optList.classList.toggle("hidden");

}

function highlightOption(select, option) {

const optionList = select.querySelectorAll(".option");

optionList.forEach((other) => {

other.classList.remove("highlight");

});

option.classList.add("highlight");

}

window.addEventListener("load", () => {

const form = document.querySelector("form");

form.classList.remove("no-widget");

form.classList.add("widget");

});

window.addEventListener("load", () => {

const selectList = document.querySelectorAll(".select");

selectList.forEach((select) => {

const optionList = select.querySelectorAll(".option");

optionList.forEach((option) => {

option.addEventListener("mouseover", () => {

highlightOption(select, option);

});

});

select.addEventListener(

"click",

(event) => {

toggleOptList(select);

},

false,

);

select.addEventListener("focus", (event) => {

activeSelect(select, selectList);

});

select.addEventListener("blur", (event) => {

deactivateSelect(select);

});

select.addEventListener("keyup", (event) => {

if (event.keyCode === 27) {

deactivateSelect(select);

}

});

});

});

处理控件的值

既然我们的控件已经开始工作了,我们必须添加代码,使其能够根据用户的输入更新取值,并且能将取值随表单数据一同发送。

实现这一点最简单的方法是在后台使用原生控件。这样的控件会使用浏览器提供的所有内置控件来跟踪值,并且在表单提交时,取值也会像往常一样发送。当有现成的功能时,我们再做重复工作就毫无意义了。

像前面所看到的那样,出于无障碍的原因,我们已经使用了一个原生的选择(select)控件作为回退显示内容;我们可轻松的将它的值与我们的自定义控件之间的值同步。

js// 这个函数更新显示的值并将其通过原生控件同步

// 它需要 2 个参数:

// select:含有要更新的值的 `select` 类的 DOM 节点

// index:要被选择的值的索引

function updateValue(select, index) {

// 我们需要为了给定的自定义控件获取原生控件

// 在我们的示例中,原生控件是自定义控件的“兄弟”

const nativeWidget = select.previousElementSibling;

// 我们也需要得到自定义控件的值占位符

const value = select.querySelector(".value");

// 还有整个选项列表

const optionList = select.querySelectorAll(".option");

// 我们将被选择的索引设定为我们的选择的索引

nativeWidget.selectedIndex = index;

// 更新相应的值占位符

value.innerHTML = optionList[index].innerHTML;

// 然后高亮我们自定义控件里对应的选项

highlightOption(select, optionList[index]);

}

// 这个函数返回原生控件里当前选定的索引

// 它需要 1 个参数:

// select:跟原生控件有关的 `select` 类 DOM 节点

function getIndex(select) {

// 我们需要为了给定的自定义控件访问原生控件

// 在我们的示例中,原生控件是自定义控件的“兄弟”

const nativeWidget = select.previousElementSibling;

return nativeWidget.selectedIndex;

}

通过这两个函数,我们可以将原生控件绑定到自定义的控件上。

js// 我们在文档加载时处理事件的绑定。

window.addEventListener("load", () => {

const selectList = document.querySelectorAll(".select");

// 每个自定义控件都需要初始化

selectList.forEach((select) => {

const optionList = select.querySelectorAll(".option");

const selectedIndex = getIndex(select);

// 使我们的自定义控件可以获得焦点

select.tabIndex = 0;

// 我们让原生控件无法获得焦点

select.previousElementSibling.tabIndex = -1;

// 确保默认选中的值正确显示

updateValue(select, selectedIndex);

// 每当用户点击一个选项的时候,更新相应的值

optionList.forEach((option, index) => {

option.addEventListener("click", (event) => {

updateValue(select, index);

});

});

// 每当用户在获得焦点的控件上用键盘操作时,更新相应的值

select.addEventListener("keyup", (event) => {

let index = getIndex(select);

// 当用户点击 esc 键时,关闭自定义控件

if (event.key === "Escape") {

deactivateSelect(select);

}

// 当用户点击向下箭头时,跳转到下一个选项

if (event.key === "ArrowDown" && index < optionList.length - 1) {

index++;

}

// 当用户点击向上箭头时,跳转到上一个选项

if (event.key === "ArrowUp" && index > 0) {

index--;

}

updateValue(select, index);

});

});

});

在上面的代码里,值得注意的是 tabIndex 属性的使用。使用这个属性是很有必要的,这可以确保原生控件将永远不会获得焦点,而且还可以确保当用户使用键盘和鼠标时,我们的自定义控件能够获得焦点。

做完上面这些后,我们就完成了!

实时示例

查看完整源代码。

Cherry

.widget select,

.no-widget .select {

position: absolute;

left: -5000em;

height: 0;

overflow: hidden;

}

.select {

position: relative;

display: inline-block;

}

.select.active,

.select:focus {

box-shadow: 0 0 3px 1px #227755;

outline: none;

}

.select .optList {

position: absolute;

top: 100%;

left: 0;

}

.select .optList.hidden {

max-height: 0;

visibility: hidden;

}

.select {

font-size: 0.625em; /* 10px */

font-family: Verdana, Arial, sans-serif;

box-sizing: border-box;

padding: 0.1em 2.5em 0.2em 0.5em; /* 1px 25px 2px 5px */

width: 10em; /* 100px */

border: 0.2em solid #000; /* 2px */

border-radius: 0.4em; /* 4px */

box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45); /* 0 1px 2px */

background: #f0f0f0;

background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);

}

.select .value {

display: inline-block;

width: 100%;

overflow: hidden;

white-space: nowrap;

text-overflow: ellipsis;

vertical-align: top;

}

.select::after {

content: "▼";

position: absolute;

z-index: 1;

height: 100%;

width: 2em; /* 20px */

top: 0;

right: 0;

padding-top: 0.1em;

box-sizing: border-box;

text-align: center;

border-left: 0.2em solid #000;

border-radius: 0 0.1em 0.1em 0;

background-color: #000;

color: #fff;

}

.select .optList {

z-index: 2;

list-style: none;

margin: 0;

padding: 0;

background: #f0f0f0;

border: 0.2em solid #000;

border-top-width: 0.1em;

border-radius: 0 0 0.4em 0.4em;

box-shadow: 0 0.2em 0.4em rgba(0, 0, 0, 0.4);

box-sizing: border-box;

min-width: 100%;

max-height: 10em; /* 100px */

overflow-y: auto;

overflow-x: hidden;

}

.select .option {

padding: 0.2em 0.3em;

}

.select .highlight {

background: #000;

color: #ffffff;

}

function deactivateSelect(select) {

if (!select.classList.contains("active")) return;

const optList = select.querySelector(".optList");

optList.classList.add("hidden");

select.classList.remove("active");

}

function activeSelect(select, selectList) {

if (select.classList.contains("active")) return;

selectList.forEach(deactivateSelect);

select.classList.add("active");

}

function toggleOptList(select, show) {

const optList = select.querySelector(".optList");

optList.classList.toggle("hidden");

}

function highlightOption(select, option) {

const optionList = select.querySelectorAll(".option");

optionList.forEach((other) => {

other.classList.remove("highlight");

});

option.classList.add("highlight");

}

function updateValue(select, index) {

const nativeWidget = select.previousElementSibling;

const value = select.querySelector(".value");

const optionList = select.querySelectorAll(".option");

nativeWidget.selectedIndex = index;

value.innerHTML = optionList[index].innerHTML;

highlightOption(select, optionList[index]);

}

function getIndex(select) {

const nativeWidget = select.previousElementSibling;

return nativeWidget.selectedIndex;

}

window.addEventListener("load", () => {

const form = document.querySelector("form");

form.classList.remove("no-widget");

form.classList.add("widget");

});

window.addEventListener("load", () => {

const selectList = document.querySelectorAll(".select");

selectList.forEach((select) => {

const optionList = select.querySelectorAll(".option");

optionList.forEach((option) => {

option.addEventListener("mouseover", () => {

highlightOption(select, option);

});

});

select.addEventListener("click", (event) => {

toggleOptList(select);

});

select.addEventListener("focus", (event) => {

activeSelect(select, selectList);

});

select.addEventListener("blur", (event) => {

deactivateSelect(select);

});

});

});

window.addEventListener("load", () => {

const selectList = document.querySelectorAll(".select");

selectList.forEach((select) => {

const optionList = select.querySelectorAll(".option");

const selectedIndex = getIndex(select);

select.tabIndex = 0;

select.previousElementSibling.tabIndex = -1;

updateValue(select, selectedIndex);

optionList.forEach((option, index) => {

option.addEventListener("click", (event) => {

updateValue(select, index);

});

});

select.addEventListener("keyup", (event) => {

let index = getIndex(select);

if (event.key === "Escape") {

deactivateSelect(select);

}

if (event.key === "ArrowDown" && index < optionList.length - 1) {

index++;

}

if (event.key === "ArrowUp" && index > 0) {

index--;

}

updateValue(select, index);

});

});

});

但是等等,我们真的做完了嘛?

使其变得无障碍

我们构建了一个能够生效的东西,尽管这离一个特性齐全的选择框还差得远,但是它效果不错。但是我们已经完成的事情只不过是摆弄 DOM。这个控件并没有真正的语义,即使它看起来像一个选择框,但是从浏览器的角度来看并不是,所以辅助技术并不能明白这是一个选择框。简单来说,这个全新的选择框并不具备无障碍!

幸运的是,有一种解决方案叫做 ARIA。ARIA 代表“无障碍富互联网应用”。这是一个专为我们现在做的事情设计的 W3C 规范:使 web 应用和自定义控件可以无障碍访问,它本质上是一组用来拓展 HTML 的属性集,以便我们能够更好的描述角色、状态和属性,就像我们刚才设计的元素是它试图传递的原生元素一样。只要编辑 HTML 标记就可以使用这些属性。我们也可以通过 JavaScript 在用户更新选择的值时更新 ARIA 属性。

role 属性

ARIA 使用的关键属性是角色(role)属性。role 属性接受一个值,该值定义了一个元素的用途。每一个角色定义了它自己的需求和行为。在我们的示例中,我们会使用 listbox 这一角色。这是一个“复合角色(composite role)”,表示具有该角色的元素应该有子元素,每个子元素都有特定的角色。(在这个案例中,至少有一个具有option 角色的子元素)。

同样值得注意的是,ARIA 定义了默认应用于标准 HTML 标记的角色。例如,

元素与角色 grid 相匹配,而
    元素与角色 list 相匹配。由于我们使用了一个
      元素,我们想要确保我们控件的 listbox 角色能替代
        元素的 list 角色。为此,我们会使用角色 presentation。这个角色被设计成让我们来表示一个元素没有特殊的含义,并且仅仅用于提供信息。我们会将其应用到
          元素上。

          为了支持 listbox 角色,我们只需要将我们的 HTML 改成这样:

          html

          Cherry

          备注:不需要同时包含 role 属性和 class 属性。你可以在 CSS 中使用 [role="option"] 属性选择器来代替 .option 类。

          aria-selected 属性

          仅仅使用 role 属性是不够的。ARIA 还提供了许多状态和属性特征。你能更好更充分的利用它们,你的控件就会能够被辅助技术更好地理解。在我们的示例中,我们会把使用限制在一个属性上:aria-selected。

          aria-selected 属性被用来标记当前被选中的选项;这可以让辅助技术告知用户当前的选项是什么。我们会通过 JavaScript 动态地使用该属性,每当用户选择一个选项时标记选中的选项。为了达到这一目的,我们需要修正我们的 updateValue() 函数:

          jsfunction updateValue(select, index) {

          const nativeWidget = select.previousElementSibling;

          const value = select.querySelector(".value");

          const optionList = select.querySelectorAll('[role="option"]');

          // 我们确保所有的选项都没有被选中

          optionList.forEach((other) => {

          other.setAttribute("aria-selected", "false");

          });

          // 我们确保选定的选项被选中了

          optionList[index].setAttribute("aria-selected", "true");

          nativeWidget.selectedIndex = index;

          value.innerHTML = optionList[index].innerHTML;

          highlightOption(select, optionList[index]);

          }

          让屏幕阅读器聚焦于不可见的 select 而忽略我们的添加样式后的 select 似乎更简单,但这不是一个无障碍的解决方案。屏幕阅读器的用户不仅限于盲人;弱视甚至视力没问题的人也使用它们。因此,你不能让屏幕阅读器聚焦于不可见的元素。

          下面是经过所有的改变之后的最终结果(藉由 NVDA 或 VoiceOver 这样的辅助技术尝试它,你会对此有更好的体会)。

          实时示例

          查看完整源代码。

          Cherry

          .widget select,

          .no-widget .select {

          position: absolute;

          left: -5000em;

          height: 0;

          overflow: hidden;

          }

          .select {

          position: relative;

          display: inline-block;

          }

          .select.active,

          .select:focus {

          box-shadow: 0 0 3px 1px #227755;

          outline: none;

          }

          .select .optList {

          position: absolute;

          top: 100%;

          left: 0;

          }

          .select .optList.hidden {

          max-height: 0;

          visibility: hidden;

          }

          .select {

          font-size: 0.625em; /* 10px */

          font-family: Verdana, Arial, sans-serif;

          box-sizing: border-box;

          padding: 0.1em 2.5em 0.2em 0.5em; /* 1px 25px 2px 5px */

          width: 10em; /* 100px */

          border: 0.2em solid #000; /* 2px */

          border-radius: 0.4em; /* 4px */

          box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45); /* 0 1px 2px */

          background: #f0f0f0;

          background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);

          }

          .select .value {

          display: inline-block;

          width: 100%;

          overflow: hidden;

          white-space: nowrap;

          text-overflow: ellipsis;

          vertical-align: top;

          }

          .select::after {

          content: "▼";

          position: absolute;

          z-index: 1;

          height: 100%;

          width: 2em; /* 20px */

          top: 0;

          right: 0;

          padding-top: 0.1em;

          box-sizing: border-box;

          text-align: center;

          border-left: 0.2em solid #000;

          border-radius: 0 0.1em 0.1em 0;

          background-color: #000;

          color: #fff;

          }

          .select .optList {

          z-index: 2;

          list-style: none;

          margin: 0;

          padding: 0;

          background: #f0f0f0;

          border: 0.2em solid #000;

          border-top-width: 0.1em;

          border-radius: 0 0 0.4em 0.4em;

          box-shadow: 0 0.2em 0.4em rgba(0, 0, 0, 0.4);

          box-sizing: border-box;

          min-width: 100%;

          max-height: 10em; /* 100px */

          overflow-y: auto;

          overflow-x: hidden;

          }

          .select .option {

          padding: 0.2em 0.3em;

          }

          .select .highlight {

          background: #000;

          color: #ffffff;

          }

          function deactivateSelect(select) {

          if (!select.classList.contains("active")) return;

          const optList = select.querySelector(".optList");

          optList.classList.add("hidden");

          select.classList.remove("active");

          }

          function activeSelect(select, selectList) {

          if (select.classList.contains("active")) return;

          selectList.forEach(deactivateSelect);

          select.classList.add("active");

          }

          function toggleOptList(select, show) {

          const optList = select.querySelector(".optList");

          optList.classList.toggle("hidden");

          }

          function highlightOption(select, option) {

          const optionList = select.querySelectorAll(".option");

          optionList.forEach((other) => {

          other.classList.remove("highlight");

          });

          option.classList.add("highlight");

          }

          function updateValue(select, index) {

          const nativeWidget = select.previousElementSibling;

          const value = select.querySelector(".value");

          const optionList = select.querySelectorAll(".option");

          optionList.forEach((other) => {

          other.setAttribute("aria-selected", "false");

          });

          optionList[index].setAttribute("aria-selected", "true");

          nativeWidget.selectedIndex = index;

          value.innerHTML = optionList[index].innerHTML;

          highlightOption(select, optionList[index]);

          }

          function getIndex(select) {

          const nativeWidget = select.previousElementSibling;

          return nativeWidget.selectedIndex;

          }

          window.addEventListener("load", () => {

          const form = document.querySelector("form");

          form.classList.remove("no-widget");

          form.classList.add("widget");

          });

          window.addEventListener("load", () => {

          const selectList = document.querySelectorAll(".select");

          selectList.forEach((select) => {

          const optionList = select.querySelectorAll(".option");

          const selectedIndex = getIndex(select);

          select.tabIndex = 0;

          select.previousElementSibling.tabIndex = -1;

          updateValue(select, selectedIndex);

          optionList.forEach((option, index) => {

          option.addEventListener("mouseover", () => {

          highlightOption(select, option);

          });

          option.addEventListener("click", (event) => {

          updateValue(select, index);

          });

          });

          select.addEventListener("click", (event) => {

          toggleOptList(select);

          });

          select.addEventListener("focus", (event) => {

          activeSelect(select, selectList);

          });

          select.addEventListener("blur", (event) => {

          deactivateSelect(select);

          });

          select.addEventListener("keyup", (event) => {

          let index = getIndex(select);

          if (event.keyCode === 27) {

          deactivateSelect(select);

          }

          if (event.keyCode === 40 && index < optionList.length - 1) {

          index++;

          }

          if (event.keyCode === 38 && index > 0) {

          index--;

          }

          updateValue(select, index);

          });

          });

          });

          如果你想继续前进,此示例中的代码需要进行一些改进才能变得通用和可复用。你可以尝试进行这方面的练习。有两个提示可以帮助你:我们所有函数的第一个参数都是相同的,这意味着这些函数需要相同的上下文。构建一个对象来共享该上下文是明智的。

          代替方法:使用单选按钮

          在上面的示例中,我们使用非语义化的 HTML、CSS 和 JavaScript 重新构建了一个

        • type="radio"

          name="fruit"

          value="Strawberry"

          id="fruitStrawberry" />

        我们将对单选按钮列表(不是 legend/fieldset)设置一些样式,使其看起来有点像前面的示例,这里只是为了表明它可以实现:

        css.styledSelect {

        display: inline-block;

        padding: 0;

        }

        .styledSelect li {

        list-style-type: none;

        padding: 0;

        display: flex;

        }

        .styledSelect [type="radio"] {

        position: absolute;

        left: -100vw;

        top: -100vh;

        }

        .styledSelect label {

        margin: 0;

        line-height: 2;

        padding: 0 0 0 4px;

        }

        .styledSelect:not(:focus-within) input:not(:checked) + label {

        height: 0;

        outline: none;

        overflow: hidden;

        }

        .styledSelect:not(:focus-within) input:checked + label {

        border: 0.2em solid #000;

        border-radius: 0.4em;

        box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45);

        }

        .styledSelect:not(:focus-within) input:checked + label::after {

        content: "▼";

        background: black;

        float: right;

        color: white;

        padding: 0 4px;

        margin: 0 -4px 0 4px;

        }

        .styledSelect:focus-within {

        border: 0.2em solid #000;

        border-radius: 0.4em;

        box-shadow: 0 0.1em 0.2em rgba(0, 0, 0, 0.45);

        }

        .styledSelect:focus-within input:checked + label {

        background-color: #333;

        color: #fff;

        width: 100%;

        }

        无需 JavaScript,只需一点点 CSS,我们就可以为单选按钮列表添加样式,使其只显示选中的项目。当焦点在

        中的
          内时,列表会打开,上下(和左右)箭头可以选择上一个和下一个项目。尝试一下:

          在某种程度上,这不需要 JavaScript 就可以工作。我们创建了一个与自定义控件类似的控件,即使 JavaScript 运行失败也能正常工作。看起来是一个很好的解决方案,对吧?嗯,但它不是完美的。它确实可以与键盘配合使用,但不能按预期使用鼠标单击。使用 Web 标准作为自定义控件的基础而不是依赖框架来创建没有原生语义的元素,可能更有意义。然而,我们的控件不具有与 的替代。有一些功能是不同或者缺失的。例如,所有的四个箭头都可以导航到选项,但是当用户在最后一个按钮上点击向下箭头时,它选到第一个按钮;它不会像