如何构建自定义表单控件
在某些情况下,可用的原生 HTML 表单控件可能看起来不够用。例如,如果您需要对某些控件(例如 <select>
元素)执行高级样式设置,或者您想要提供自定义行为,则可以考虑构建自己的控件。
在本文中,我们将讨论如何构建自定义控件。为此,我们将以一个示例为例:重建 <select>
元素。我们还将讨论何时以及是否构建自己的控件是有意义的,以及在构建控件成为必要条件时需要考虑哪些因素。
注意:我们将重点关注构建控件,而不是如何使代码通用和可重用;这将涉及一些非简单的 JavaScript 代码和在未知上下文中进行的 DOM 操作,这超出了本文的范围。
设计、结构和语义
在构建自定义控件之前,您应该首先确定自己想要什么。这将为您节省宝贵的时间。特别是,清楚地定义控件的所有状态非常重要。为此,最好从一个状态和行为众所周知的现有控件开始,以便您可以尽可能地模仿它们。
在我们的示例中,我们将重建 <select>
元素。以下是我们想要达到的效果
此屏幕截图显示了我们控件的三个主要状态:正常状态(左侧);活动状态(中间)和打开状态(右侧)。
在行为方面,我们正在重新创建原生 HTML 元素。因此,它应该具有与原生 HTML 元素相同的行为和语义。我们要求我们的控件既可以使用鼠标也可以使用键盘,并且像任何原生控件一样,屏幕阅读器可以理解它。让我们从定义控件如何到达每个状态开始
控件处于其正常状态时
- 页面加载。
- 控件处于活动状态,用户点击了控件外部的任何位置。
- 控件处于活动状态,用户使用键盘(例如 Tab 键)将焦点移动到另一个控件。
控件处于其活动状态时
- 用户点击它或在触摸屏上触摸它。
- 用户按下 Tab 键并获得焦点。
- 控件处于其打开状态,用户点击它。
控件处于其打开状态时
- 控件处于除打开状态以外的任何状态,并且用户点击它。
一旦我们知道如何更改状态,定义如何更改控件的值就非常重要
值在以下情况下更改
- 用户在控件处于打开状态时点击选项。
- 用户在控件处于活动状态时按下向上或向下箭头键。
值在以下情况下不会更改
- 用户在选中第一个选项时按下向上箭头键。
- 用户在选中最后一个选项时按下向下箭头键。
最后,让我们定义控件的选项将如何运行
- 当控件打开时,选定的选项会被突出显示
- 当鼠标悬停在选项上时,该选项会被突出显示,先前突出显示的选项将恢复到其正常状态
出于我们示例的目的,我们将就此停止;但是,如果您是一位细心的读者,您会注意到缺少一些行为。例如,您认为如果用户在控件处于打开状态时按下 Tab 键会发生什么?答案是什么也不会发生。好的,正确的行为似乎很明显,但事实是,由于它在我们规范中没有定义,因此很容易忽略这种行为。在团队环境中尤其如此,因为设计控件行为的人员与实施控件的人员不同。
另一个有趣的例子:如果用户在控件处于打开状态时按下向上或向下箭头键会发生什么?这有点棘手。如果您认为活动状态和打开状态完全不同,则答案再次是“什么也不会发生”,因为我们没有为打开状态定义任何键盘交互。另一方面,如果您认为活动状态和打开状态有一点重叠,则值可能会更改,但选项肯定不会相应地突出显示,这再次是因为我们没有在控件处于打开状态时定义任何键盘与选项的交互(我们只定义了控件打开时应该发生什么,但之后什么也没有)。
我们必须进一步思考:Esc 键怎么样?按下 Esc 键关闭打开的 select。请记住,如果您想提供与现有原生 <select>
相同的功能,它应该对所有用户(从键盘到鼠标到触摸到屏幕阅读器,以及任何其他输入设备)的行为与 select 完全相同。
在我们的示例中,缺少的规范很明显,因此我们将处理它们,但这对于奇特的全新控件来说可能是一个真正的问题。当涉及到 <select>
这样的标准化元素时,规范作者花费了大量时间来指定每个用例中每个输入设备的所有交互。创建新控件并非易事,尤其是在您创建以前从未做过的事情时,因此没有人知道预期的行为和交互是什么。至少 select 以前做过,所以我们知道它应该如何运行!
设计新的交互通常只适用于拥有足够影响力的非常大型的行业参与者,他们创建的交互可以成为标准。例如,苹果公司于 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 #000;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
/* The first declaration is for browsers that do not support linear gradients. */
background: #f0f0f0;
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 #000;
border-radius: 0 0.1em 0.1em 0;
background-color: #000;
color: #fff;
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 #000;
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: #000;
color: #ffffff;
}
因此,以下是我们三种状态的结果(在此处查看源代码)
基本状态
活动状态
打开状态
使用 JavaScript 使您的控件栩栩如生
现在我们的设计和结构已准备就绪,我们可以编写 JavaScript 代码以使控件真正起作用。
警告:以下是教育代码,而不是生产代码,不应按原样使用。它既不是面向未来的,也不会在旧版浏览器上运行。它还包含在生产代码中应优化的冗余部分。
为什么不起作用?
在开始之前,务必记住**浏览器中的 JavaScript 是一种不可靠的技术**。自定义控件依靠 JavaScript 将所有内容联系在一起。但是,在某些情况下,JavaScript 无法在浏览器中运行
- 用户已关闭 JavaScript:这很少见;如今很少有人关闭 JavaScript。
- 脚本未加载:这是最常见的情况之一,尤其是在网络不太可靠的移动领域。
- 脚本有错误:您应该始终考虑这种可能性。
- 脚本与第三方脚本冲突:这可能发生在跟踪脚本或用户使用的任何书签中。
- 脚本与浏览器扩展冲突或受其影响(例如 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>
元素和自定义控件的可见性。
window.addEventListener("load", () => {
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");
}
您需要这些来处理自定义控件的各种状态。
接下来,我们将这些函数绑定到相应的事件
// We handle the event binding when the document is loaded.
window.addEventListener("load", () => {
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;
}
使用这两个函数,我们可以将原生控件绑定到自定义控件
// We handle event binding when the document is loaded.
window.addEventListener("load", () => {
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
属性都是没有必要的。不要使用 .option
,而是在 CSS 中使用 [role="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 按钮重新发明它;让我们看看这个选项。
我们可以从一个完全语义化、可访问的、无序的 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>
我们将对 radio 按钮列表(而不是 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: 0 0 0 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 #000;
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 #000;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
}
.styledSelect:focus-within input:checked + label {
background-color: #333;
color: #fff;
width: 100%;
}
无需 JavaScript,只需一点 CSS,我们就可以设置 radio 按钮列表的样式,使其仅显示选中的项目。当焦点在 <fieldset>
中的 <ul>
内时,列表会展开,向上和向下(以及向左和向右)箭头可以用来选择前一项和下一项。试试看
在某种程度上,这在没有 JavaScript 的情况下也能工作。我们创建了一个与自定义控件类似的控件,即使 JavaScript 失败也能工作。看起来像一个很棒的解决方案,对吧?好吧,不是 100%。它确实可以与键盘一起使用,但鼠标点击的效果并不如预期。依靠 Web 标准作为自定义控件的基础,而不是依赖框架来创建没有原生语义的元素,可能更有意义。但是,我们的控件没有 <select>
本身具有的相同功能。
从好的方面来说,此控件对屏幕阅读器完全可访问,并且可以通过键盘完全导航。但是,此控件不是 <select>
的替代品。存在不同和/或缺少的功能。例如,所有四个箭头都在选项中导航,但是当用户位于最后一个按钮上时点击向下箭头会将他们带到第一个按钮;它不会像 <select>
一样停止在选项列表的顶部和底部。
我们将添加此缺失功能作为读者练习留给读者。
结论
我们已经了解了构建自定义表单控件的基本知识,但正如你所看到的,这并非易事。在创建自己的自定义控件之前,请考虑 HTML 是否提供了可用于充分满足您需求的替代元素。如果您确实需要创建自定义控件,通常依靠第三方库比自己构建更容易。但是,如果您确实要创建自己的控件、修改现有元素或使用框架来实现预制控件,请记住,创建可用且可访问的表单控件比看起来要复杂得多。
以下是一些您在编写自己的代码之前应该考虑的库
如果您确实通过单选按钮、您自己的 JavaScript 或第三方库创建了替代控件,请确保它是可访问的且功能完善的;也就是说,它需要能够更好地与各种浏览器配合使用,这些浏览器与它们使用的 Web 标准的兼容性各不相同。玩得开心!
另请参阅
学习路径
高级主题
- 通过 JavaScript 发送表单
- 如何构建自定义表单控件
- 旧版浏览器中的 HTML 表单
- HTML 表单的高级样式
- 表单控件的属性兼容性表