如何构建自定义表单控件
在某些情况下,可用的原生 HTML 表单控件可能看起来不够用。例如,如果你需要对某些控件(如 <select> 元素)执行高级样式设置,或者如果你想提供自定义行为,你可能会考虑构建自己的控件。
在本文中,我们将讨论如何构建一个自定义控件。为此,我们将使用一个示例:重新构建 <select> 元素。我们还将讨论构建自己的控件何时、何地以及是否有意义,以及在需要构建控件时需要考虑什么。
注意: 我们将重点关注控件的构建,而不是如何使代码通用和可重用;这将涉及一些非简单的 JavaScript 代码和在未知上下文中的 DOM 操作,这超出了本文的范围。
设计、结构和语义
在构建自定义控件之前,你应该首先弄清楚你到底想要什么。这将为你节省宝贵的时间。特别是,明确定义控件的所有状态很重要。为此,最好从一个已知其状态和行为的现有控件开始,以便尽可能地模仿它们。
在我们的示例中,我们将重新构建 <select> 元素。这是我们想要实现的结果

此截图显示了我们控件的三个主要状态:正常状态(左侧);活动状态(中间)和打开状态(右侧)。
在行为方面,我们正在重新创建一个原生的 HTML 元素。因此,它应该具有与原生 HTML 元素相同的行为和语义。我们要求我们的控件能够通过鼠标和键盘使用,并且像任何原生控件一样,屏幕阅读器能够理解它。让我们从定义控件如何达到每个状态开始
控件处于正常状态时
- 页面加载。
- 控件处于活动状态,用户在控件外部任意位置点击。
- 控件处于活动状态,用户使用键盘(例如,Tab 键)将焦点移动到另一个控件。
控件处于活动状态时
- 用户点击它或在触摸屏上触摸它。
- 用户按下 Tab 键并获得焦点。
- 控件处于打开状态,用户点击它。
控件处于打开状态时
- 控件处于非打开状态,用户点击它。
一旦我们知道如何更改状态,定义如何更改控件的值就很重要
值在以下情况下更改
- 当控件处于打开状态时,用户点击一个选项。
- 当控件处于活动状态时,用户按下向上或向下箭头键。
值在以下情况下不更改
- 当第一个选项被选中时,用户按下向上箭头键。
- 当最后一个选项被选中时,用户按下向下箭头键。
最后,让我们定义控件选项的行为方式
- 当控件打开时,选定的选项会被高亮显示
- 当鼠标悬停在一个选项上时,该选项会被高亮显示,并且之前高亮显示的选项会恢复到其正常状态
出于本例的目的,我们就到此为止;但是,如果你是一个细心的读者,你会注意到缺少一些行为。例如,如果你认为用户在控件处于打开状态时按下 Tab 键会发生什么?答案是什么都不会发生。好吧,正确的行为似乎很明显,但事实是,因为它没有在我们的规范中定义,所以很容易忽略这种行为。在团队环境中尤其如此,当设计控件行为的人与实现它的人不同时。
另一个有趣的例子:如果用户在控件处于打开状态时按下向上或向下箭头键会发生什么?这个有点棘手。如果你认为活动状态和打开状态完全不同,那么答案又将是“什么都不会发生”,因为我们没有为打开状态定义任何键盘交互。另一方面,如果你认为活动状态和打开状态有些重叠,那么值可能会改变,但选项肯定不会相应地高亮显示,再次是因为我们没有在控件处于打开状态时定义任何选项上的键盘交互(我们只定义了控件打开时应该发生什么,但之后就没有了)。
我们必须再思考一下:那么 Esc 键呢?按下 Esc 键会关闭一个打开的 select。请记住,如果你想提供与现有原生 <select> 相同的功能,它应该对所有用户(从键盘到鼠标,从触摸到屏幕阅读器,以及任何其他输入设备)都以完全相同的方式运行。
在我们的示例中,缺少的规范是显而易见的,所以我们会处理它们,但这对于奇特的新控件来说可能是一个真正的问题。当涉及到标准化元素时,其中 <select> 就是其中之一,规范作者花费了大量时间来指定每个用例的每个输入设备的所有交互。创建新控件并不那么容易,特别是如果你正在创建以前从未做过的事情,因此没有人对预期的行为和交互有一点概念。至少 select 以前做过,所以我们知道它应该如何表现!
设计新的交互通常只适用于足够大的行业参与者,他们拥有足够的影响力,可以将其创建的交互变为标准。例如,Apple 在 2001 年随 iPod 推出了滚轮。他们拥有市场份额,成功引入了一种全新的设备交互方式,这是大多数设备公司做不到的。
最好不要发明新的用户交互。对于你添加的任何交互,在设计阶段花费时间至关重要;如果你定义行为不佳,或忘记定义行为,一旦用户习惯了,就很难重新定义它。如果你有疑问,请征求他人的意见,如果预算允许,请毫不犹豫地进行用户测试。这个过程称为 UX 设计。如果你想了解更多关于这个主题的信息,你应该查看以下有用的资源
定义 HTML 结构和(部分)语义
现在控件的基本功能已经确定,是时候开始构建它了。第一步是定义其 HTML 结构并赋予其一些基本语义。以下是我们需要重新构建 <select> 元素所需的内容
<!-- This is our main container for our control.
The tabindex attribute is what allows the user to focus on the control.
We'll see later that it's better to set it through JavaScript. -->
<div class="select" tabindex="0">
<!-- This container will be used to display the current value of the control -->
<span class="value">Cherry</span>
<!-- This container will contain all the options available for our control.
Because it's a list, it makes sense to use the ul element. -->
<ul class="optList">
<!-- Each option only contains the value to be displayed, we'll see later
how to handle the real value that will be sent with the form data -->
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>
注意类名的使用;这些类名无论实际底层使用的 HTML 元素如何,都能识别每个相关部分。这很重要,以确保我们不会将 CSS 和 JavaScript 绑定到强 HTML 结构,这样我们以后可以在不破坏使用该控件的代码的情况下进行实现更改。例如,如果你以后希望实现 <optgroup> 元素的等效功能怎么办?
然而,类名不提供语义值。在这种当前状态下,屏幕阅读器用户只“看到”一个无序列表。我们将在稍后添加 ARIA 语义。
使用 CSS 创建外观
现在我们有了结构,我们可以开始设计我们的控件。构建这个自定义控件的全部意义在于能够完全按照我们想要的方式进行样式设置。为此,我们将把我们的 CSS 工作分成两部分:第一部分是使我们的控件表现得像 <select> 元素绝对必要的 CSS 规则,第二部分将由用于使其看起来像我们想要的精美样式组成。
必需样式
所需样式是处理控件三种状态所必需的。
.select {
/* This will create a positioning context for the list of options;
adding this to `.select:focus-within` will be a better option when fully supported
*/
position: relative;
/* This will make our control become part of the text flow and sizable at the same time */
display: inline-block;
}
我们需要一个额外的 active 类来定义控件处于活动状态时的外观。因为我们的控件是可聚焦的,所以我们将此自定义样式与 :focus 伪类结合使用,以确保它们表现相同。
.select.active,
.select:focus {
outline-color: transparent;
/* This box-shadow property is not exactly required, however it's imperative to ensure
active state is visible, especially to keyboard users, that we use it as a default value. */
box-shadow: 0 0 3px 1px #227755;
}
现在,让我们处理选项列表
/* The .select selector here helps to make sure we only select
element inside our control. */
.select .optList {
/* This will make sure our list of options will be displayed below the value
and out of the HTML flow */
position: absolute;
top: 100%;
left: 0;
}
我们需要一个额外的类来处理选项列表隐藏的情况。这对于管理活动状态和打开状态之间不完全匹配的差异是必要的。
.select .optList.hidden {
/* This is a simple way to hide the list in an accessible way;
we will talk more about accessibility in the end */
max-height: 0;
visibility: hidden;
}
注意: 我们也可以使用 transform: scale(1, 0) 使选项列表没有高度和全宽。
美化
现在我们已经具备了基本功能,乐趣就可以开始了。以下只是一个可能的例子,并将与本文开头的屏幕截图相符。但是,您应该随意尝试并看看您能想出什么。
.select {
/* The computations are made assuming 1em equals 16px which is the default value in most browsers.
If you are lost with px to em conversion, try https://nekocalc.com/px-to-em-converter */
font-size: 0.625em; /* this (10px) is the new font size context for em value in this context */
font-family: "Verdana", "Arial", sans-serif;
box-sizing: border-box;
/* We need extra room for the down arrow we will add */
padding: 0.1em 2.5em 0.2em 0.5em;
width: 10em; /* 100px */
border: 0.2em solid black;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);
}
.select .value {
/* Because the value can be wider than our control, we have to make sure it will not
change the control's width. If the content overflows, we display an ellipsis */
display: inline-block;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: top;
}
我们不需要额外的元素来设计向下箭头;相反,我们使用 ::after 伪元素。它也可以通过在 select 类上使用简单的背景图像来实现。
.select::after {
content: "▼"; /* We use the unicode character U+25BC; make sure to set a charset meta tag */
position: absolute;
z-index: 1; /* This will be important to keep the arrow from overlapping the list of options */
top: 0;
right: 0;
box-sizing: border-box;
height: 100%;
width: 2em;
padding-top: 0.1em;
border-left: 0.2em solid black;
border-radius: 0 0.1em 0.1em 0;
background-color: black;
color: white;
text-align: center;
}
接下来,让我们设置选项列表的样式
.select .optList {
z-index: 2; /* We explicitly said the list of options will always be on top of the down arrow */
/* this will reset the default style of the ul element */
list-style: none;
margin: 0;
padding: 0;
box-sizing: border-box;
/* If the values are smaller than the control, the list of options
will be as wide as the control itself */
min-width: 100%;
/* In case the list is too long, its content will overflow vertically
(which will add a vertical scrollbar automatically) but never horizontally
(because we haven't set a width, the list will adjust its width automatically.
If it can't, the content will be truncated) */
max-height: 10em; /* 100px */
overflow-y: auto;
overflow-x: hidden;
border: 0.2em solid black;
border-top-width: 0.1em;
border-radius: 0 0 0.4em 0.4em;
box-shadow: 0 0.2em 0.4em rgb(0 0 0 / 40%);
background: #f0f0f0;
}
对于选项,我们需要添加一个 highlight 类,以便能够识别用户将选择(或已选择)的值。
.select .option {
padding: 0.2em 0.3em; /* 2px 3px */
}
.select .highlight {
background: black;
color: white;
}
所以这是我们三种状态的结果(在此处查看源代码)
基本状态
活动状态
打开状态
用 JavaScript 让你的控件活跃起来
现在我们的设计和结构都已准备就绪,我们可以编写 JavaScript 代码使控件真正工作。
警告: 以下是教学代码,不是生产代码,不应按原样使用。它既不是面向未来的,也不会在旧版浏览器上工作。它还有一些冗余部分,应在生产代码中进行优化。
为什么它不工作?
在开始之前,重要的是要记住浏览器中的 JavaScript 是一种不可靠的技术。自定义控件依赖 JavaScript 将所有内容连接起来。但是,在某些情况下,JavaScript 无法在浏览器中运行
- 用户已关闭 JavaScript:这不常见;现在很少有人关闭 JavaScript。
- 脚本未加载:这是最常见的情况之一,尤其是在网络不可靠的移动世界中。
- 脚本有 bug:您应该始终考虑这种可能性。
- 脚本与第三方脚本冲突:这可能发生在跟踪脚本或用户使用的任何书签上。
- 脚本与浏览器扩展冲突或受其影响(例如 Firefox 的 NoScript 扩展或 Chrome 的 ScriptBlock 扩展)。
- 用户正在使用旧版浏览器,并且您需要的某个功能不受支持:当您使用尖端 API 时,这种情况会经常发生。
- 在 JavaScript 完全下载、解析和执行之前,用户正在与内容交互。
由于存在这些风险,认真考虑如果您的 JavaScript 不起作用会发生什么非常重要。我们将在示例中讨论需要考虑的选项并涵盖基础知识(全面讨论解决所有场景的这个问题将需要一本书)。请记住,使您的脚本通用和可重用至关重要。
在我们的示例中,如果我们的 JavaScript 代码没有运行,我们将退回到显示一个标准的 <select> 元素。我们包含我们的控件和 <select>;显示哪一个取决于 body 元素的类,body 元素的类在控件功能脚本成功加载时由该脚本更新。
为了实现这一点,我们需要两样东西
首先,我们需要在每个自定义控件实例之前添加一个常规的 <select> 元素。即使我们的 JavaScript 按预期工作,拥有这个“额外”的 select 也有好处:我们将使用这个 select 来发送来自我们的自定义控件的数据以及其余的表单数据。我们稍后将更深入地讨论这个问题。
<body class="no-widget">
<form>
<select name="myFruit">
<option>Cherry</option>
<option>Lemon</option>
<option>Banana</option>
<option>Strawberry</option>
<option>Apple</option>
</select>
<div class="select">
<span class="value">Cherry</span>
<ul class="optList hidden">
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>
</form>
</body>
其次,我们需要两个新类来让我们隐藏不需要的元素:如果我们的脚本没有运行,我们会在视觉上隐藏自定义控件;如果脚本正在运行,则会隐藏“真实”的 <select> 元素。请注意,默认情况下,我们的 HTML 代码会隐藏我们的自定义控件。
.widget select,
.no-widget .select {
/* This CSS selector basically says:
- either we have set the body class to "widget" and thus we hide the actual <select> element
- or we have not changed the body class, therefore the body class is still "no-widget",
so the elements whose class is "select" must be hidden */
position: absolute;
left: -5000em;
height: 0;
overflow: hidden;
}
此 CSS 在视觉上隐藏了其中一个元素,但它仍然可供屏幕阅读器使用。
现在我们需要一个 JavaScript 开关来确定脚本是否正在运行。这个开关只有几行:如果在页面加载时我们的脚本正在运行,它将删除 no-widget 类并添加 widget 类,从而交换 <select> 元素和自定义控件的可见性。
document.body.classList.remove("no-widget");
document.body.classList.add("widget");
无 JS
查看完整源代码。
有 JS
查看完整源代码。
简化工作
在我们将要构建的代码中,我们将使用标准的 JavaScript 和 DOM API 来完成所有我们需要的工作。我们计划使用的功能如下
构建事件回调
基础工作已经完成。我们现在可以开始定义每次用户与我们的控件交互时将使用的所有函数。
// This function will be used each time we want to deactivate a custom control
// It takes one parameter
// select : the DOM node with the `select` class to deactivate
function deactivateSelect(select) {
// If the control is not active there is nothing to do
if (!select.classList.contains("active")) return;
// We need to get the list of options for the custom control
const optList = select.querySelector(".optList");
// We close the list of option
optList.classList.add("hidden");
// and we deactivate the custom control itself
select.classList.remove("active");
}
// This function will be used each time the user wants to activate the control
// (which, in turn, will deactivate other select controls)
// It takes two parameters:
// select : the DOM node with the `select` class to activate
// selectList : the list of all the DOM nodes with the `select` class
function activeSelect(select, selectList) {
// If the control is already active there is nothing to do
if (select.classList.contains("active")) return;
// We have to turn off the active state on all custom controls
// Because the deactivateSelect function fulfills all the requirements of the
// forEach callback function, we use it directly without using an intermediate
// anonymous function.
selectList.forEach(deactivateSelect);
// And we turn on the active state for this specific control
select.classList.add("active");
}
// This function will be used each time the user wants to open/closed the list of options
// It takes one parameter:
// select : the DOM node with the list to toggle
function toggleOptList(select) {
// The list is kept from the control
const optList = select.querySelector(".optList");
// We change the class of the list to show/hide it
optList.classList.toggle("hidden");
}
// This function will be used each time we need to highlight an option
// It takes two parameters:
// select : the DOM node with the `select` class containing the option to highlight
// option : the DOM node with the `option` class to highlight
function highlightOption(select, option) {
// We get the list of all option available for our custom select element
const optionList = select.querySelectorAll(".option");
// We remove the highlight from all options
optionList.forEach((other) => {
other.classList.remove("highlight");
});
// We highlight the right option
option.classList.add("highlight");
}
你需要这些来处理自定义控件的各种状态。
接下来,我们将这些函数绑定到适当的事件
const selectList = document.querySelectorAll(".select");
// Each custom control needs to be initialized
selectList.forEach((select) => {
// as well as all its `option` elements
const optionList = select.querySelectorAll(".option");
// Each time a user hovers their mouse over an option, we highlight the given option
optionList.forEach((option) => {
option.addEventListener("mouseover", () => {
// Note: the `select` and `option` variable are closures
// available in the scope of our function call.
highlightOption(select, option);
});
});
// Each times the user clicks on or taps a custom select element
select.addEventListener("click", (event) => {
// Note: the `select` variable is a closure
// available in the scope of our function call.
// We toggle the visibility of the list of options
toggleOptList(select);
});
// In case the control gains focus
// The control gains the focus each time the user clicks on it or each time
// they use the tabulation key to access the control
select.addEventListener("focus", (event) => {
// Note: the `select` and `selectList` variable are closures
// available in the scope of our function call.
// We activate the control
activeSelect(select, selectList);
});
// In case the control loses focus
select.addEventListener("blur", (event) => {
// Note: the `select` variable is a closure
// available in the scope of our function call.
// We deactivate the control
deactivateSelect(select);
});
// Loose focus if the user hits `esc`
select.addEventListener("keyup", (event) => {
// deactivate on keyup of `esc`
if (event.key === "Escape") {
deactivateSelect(select);
}
});
});
此时,我们的控件将根据我们的设计改变状态,但其值尚未更新。我们接下来将处理这个问题。
实时示例
查看完整源代码。
处理控件的值
现在我们的控件正在工作,我们必须添加代码以根据用户输入更新其值,并使其能够随表单数据一起发送值。
最简单的方法是在底层使用原生控件。这样的控件将跟踪值,并带有浏览器提供的所有内置控件,并且在提交表单时值将照常发送。既然我们可以让这一切都为我们完成,就没有必要重新发明轮子。
如前所述,我们已经使用原生 select 控件作为可访问性原因的备用;我们可以将其值与我们的自定义控件的值同步
// This function updates the displayed value and synchronizes it with the native control.
// It takes two parameters:
// select : the DOM node with the class `select` containing the value to update
// index : the index of the value to be selected
function updateValue(select, index) {
// We need to get the native control for the given custom control
// In our example, that native control is a sibling of the custom control
const nativeWidget = select.previousElementSibling;
// We also need to get the value placeholder of our custom control
const value = select.querySelector(".value");
// And we need the whole list of options
const optionList = select.querySelectorAll(".option");
// We set the selected index to the index of our choice
nativeWidget.selectedIndex = index;
// We update the value placeholder accordingly
value.textContent = optionList[index].textContent;
// And we highlight the corresponding option of our custom control
highlightOption(select, optionList[index]);
}
// This function returns the current selected index in the native control
// It takes one parameter:
// select : the DOM node with the class `select` related to the native control
function getIndex(select) {
// We need to access the native control for the given custom control
// In our example, that native control is a sibling of the custom control
const nativeWidget = select.previousElementSibling;
return nativeWidget.selectedIndex;
}
有了这两个函数,我们就可以将原生控件绑定到自定义控件
const selectList = document.querySelectorAll(".select");
// Each custom control needs to be initialized
selectList.forEach((select) => {
const optionList = select.querySelectorAll(".option");
const selectedIndex = getIndex(select);
// We make our custom control focusable
select.tabIndex = 0;
// We make the native control no longer focusable
select.previousElementSibling.tabIndex = -1;
// We make sure that the default selected value is correctly displayed
updateValue(select, selectedIndex);
// Each time a user clicks on an option, we update the value accordingly
optionList.forEach((option, index) => {
option.addEventListener("click", (event) => {
updateValue(select, index);
});
});
// Each time a user uses their keyboard on a focused control, we update the value accordingly
select.addEventListener("keyup", (event) => {
let index = getIndex(select);
// When the user hits the Escape key, deactivate the custom control
if (event.key === "Escape") {
deactivateSelect(select);
}
// When the user hits the down arrow, we jump to the next option
if (event.key === "ArrowDown" && index < optionList.length - 1) {
index++;
// Prevent the default action of the ArrowDown key press.
// Without this, the page would scroll down when the ArrowDown key is pressed.
event.preventDefault();
}
// When the user hits the up arrow, we jump to the previous option
if (event.key === "ArrowUp" && index > 0) {
index--;
// Prevent the default action of the ArrowUp key press.
event.preventDefault();
}
if (event.key === "Enter" || event.key === " ") {
// If Enter or Space is pressed, toggle the option list
toggleOptList(select);
}
updateValue(select, index);
});
});
在上面的代码中,值得注意的是 tabIndex 属性的使用。使用此属性对于确保原生控件永远不会获得焦点,并确保当用户使用键盘或鼠标时,我们的自定义控件获得焦点是必要的。
有了这些,我们就完成了!
实时示例
在此处查看源代码。
等等,我们真的完成了吗?
使其可访问
我们构建了一个可以工作的东西,尽管它离功能齐全的 select 框还很远,但它运行良好。但我们所做的不过是修改 DOM。它没有真正的语义,即使它看起来像一个 select 框,从浏览器的角度来看它也不是,因此辅助技术将无法理解它是一个 select 框。简而言之,这个漂亮的 select 框是不可访问的!
幸运的是,有一个解决方案,它叫做 ARIA。ARIA 代表“可访问富互联网应用”,它是 W3C 规范,专门为我们正在做的事情而设计:使 Web 应用和自定义控件可访问。它基本上是一组扩展 HTML 的属性,以便我们能够更好地描述角色、状态和属性,就好像我们刚刚设计的元素是它试图模仿的原生元素一样。使用这些属性可以通过编辑 HTML 标记来完成。我们还会随着用户更新其选定值,通过 JavaScript 更新 ARIA 属性。
role 属性
ARIA 使用的关键属性是 role 属性。role 属性接受一个值,该值定义元素的用途。每个角色都定义了自己的要求和行为。在我们的示例中,我们将使用 listbox 角色。它是一个“复合角色”,这意味着具有该角色的元素期望具有子元素,每个子元素都具有特定角色(在本例中,至少一个子元素具有 option 角色)。
同样值得注意的是,ARIA 定义了默认应用于标准 HTML 标记的角色。例如,<table> 元素匹配角色 grid,<ul> 元素匹配角色 list。因为我们使用 <ul> 元素,所以我们希望确保控件的 listbox 角色会取代 <ul> 元素的 list 角色。为此,我们将使用角色 presentation。此角色旨在让我们指示一个元素没有特殊含义,仅用于呈现信息。我们将它应用于我们的 <ul> 元素。
为了支持 listbox 角色,我们只需要像这样更新我们的 HTML
<!-- We add the role="listbox" attribute to our top element -->
<div class="select" role="listbox">
<span class="value">Cherry</span>
<!-- We also add the role="presentation" to the ul element -->
<ul class="optList" role="presentation">
<!-- And we add the role="option" attribute to all the li elements -->
<li role="option" class="option">Cherry</li>
<li role="option" class="option">Lemon</li>
<li role="option" class="option">Banana</li>
<li role="option" class="option">Strawberry</li>
<li role="option" class="option">Apple</li>
</ul>
</div>
注意: 同时包含 role 属性和 class 属性是不必要的。你可以直接在 CSS 中使用 [role="option"] 属性选择器,而不是使用 .option。
aria-selected 属性
仅仅使用 role 属性是不够的。ARIA 还提供了许多状态和属性。你使用它们越多越好,辅助技术就越能更好地理解你的控件。在我们的例子中,我们将用法限制在一个属性上:aria-selected。
aria-selected 属性用于标记当前选定的选项;这允许辅助技术通知用户当前的选择是什么。我们将使用 JavaScript 动态地在用户每次选择选项时标记选定的选项。为此,我们需要修改我们的 updateValue() 函数
function updateValue(select, index) {
const nativeWidget = select.previousElementSibling;
const value = select.querySelector(".value");
const optionList = select.querySelectorAll('[role="option"]');
// We make sure that all the options are not selected
optionList.forEach((other) => {
other.setAttribute("aria-selected", "false");
});
// We make sure the chosen option is selected
optionList[index].setAttribute("aria-selected", "true");
nativeWidget.selectedIndex = index;
value.textContent = optionList[index].textContent;
highlightOption(select, optionList[index]);
}
让屏幕阅读器关注屏幕外的 select 并忽略我们样式化的 select 可能看起来更简单,但这不是一个可访问的解决方案。屏幕阅读器不仅限于盲人;视力低下甚至视力正常的人也会使用它们。因此,您不能让屏幕阅读器关注屏幕外的元素。
以下是所有这些更改的最终结果(通过使用 NVDA 或 VoiceOver 等辅助技术尝试一下,您会获得更好的体验)。
实时示例
在此处查看完整源代码。
如果你想继续前进,这个例子中的代码需要一些改进才能变得通用和可重用。这是一个你可以尝试练习的任务。给你两个提示:我们所有函数的第一个参数都相同,这意味着这些函数需要相同的上下文。构建一个对象来共享该上下文会很明智。
另一种方法:使用单选按钮
在上面的示例中,我们使用非语义 HTML、CSS 和 JavaScript 重新创建了一个 <select> 元素。这个 select 从有限数量的选项中选择一个选项,这与同名的一组 radio 按钮的功能相同。
因此,我们可以改用单选按钮来重新创建它;让我们看看这个选项。
我们可以从一个完全语义化、可访问的无序 radio 按钮列表开始,并带有一个关联的 <label>,用语义适当的 <fieldset> 和 <legend> 对来标记整个组。
<fieldset>
<legend>Pick a fruit</legend>
<ul class="styledSelect">
<li>
<input
type="radio"
name="fruit"
value="Cherry"
id="fruitCherry"
checked />
<label for="fruitCherry">Cherry</label>
</li>
<li>
<input type="radio" name="fruit" value="Lemon" id="fruitLemon" />
<label for="fruitLemon">Lemon</label>
</li>
<li>
<input type="radio" name="fruit" value="Banana" id="fruitBanana" />
<label for="fruitBanana">Banana</label>
</li>
<li>
<input
type="radio"
name="fruit"
value="Strawberry"
id="fruitStrawberry" />
<label for="fruitStrawberry">Strawberry</label>
</li>
<li>
<input type="radio" name="fruit" value="Apple" id="fruitApple" />
<label for="fruitApple">Apple</label>
</li>
</ul>
</fieldset>
我们将对单选按钮列表(而不是 legend/fieldset)进行一点样式设置,使其看起来有点像之前的示例,只是为了表明这是可以做到的
.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-left: 4px;
}
.styledSelect:not(:focus-within) input:not(:checked) + label {
height: 0;
outline-color: transparent;
overflow: hidden;
}
.styledSelect:not(:focus-within) input:checked + label {
border: 0.2em solid black;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(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 black;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
}
.styledSelect:focus-within input:checked + label {
background-color: #333333;
color: white;
width: 100%;
}
在没有 JavaScript 的情况下,只需少量 CSS,我们就可以对单选按钮列表进行样式设置,使其仅显示选中的项目。当焦点在 <fieldset> 中的 <ul> 内时,列表会展开,并且向上和向下(以及向左和向右)箭头可以用来选择上一个和下一个项目。试试看
这在一定程度上无需 JavaScript 即可工作。我们创建了一个类似于我们的自定义控件的控件,即使 JavaScript 失败也能工作。看起来是一个很棒的解决方案,对吧?嗯,并非 100%。它确实可以通过键盘操作,但鼠标点击操作不如预期。将 Web 标准作为自定义控件的基础,而不是依赖框架创建没有原生语义的元素,可能更有意义。但是,我们的控件不具备 <select> 原生具有的相同功能。
从好的方面来看,这个控件对于屏幕阅读器来说完全可访问,并且可以通过键盘完全导航。但是,这个控件不能替代 <select>。它有不同的和/或缺失的功能。例如,所有四个箭头都可以导航选项,但是当用户在最后一个按钮上时点击向下箭头会将其带到第一个按钮;它不像 <select> 那样停在选项列表的顶部和底部。
我们将把添加这些缺失的功能作为读者练习。
总结
我们已经了解了构建自定义表单控件的所有基础知识,但正如你所看到的,这并非易事。在创建自己的自定义控件之前,请考虑 HTML 是否提供了可以充分支持你的需求的替代元素。如果你确实需要创建自定义控件,通常依靠第三方库而不是自己构建会更容易。但是,如果你自己创建、修改现有元素或使用框架来实现预制的控件,请记住创建可用且可访问的表单控件比看起来更复杂。
以下是一些你应该在自己编码之前考虑的库
如果您通过单选按钮、您自己的 JavaScript 或第三方库创建替代控件,请确保它具有可访问性和功能完备性;也就是说,它需要能够更好地与各种浏览器配合使用,这些浏览器与它们使用的 Web 标准的兼容性各不相同。玩得开心!