聊聊Web中的下拉选项的事情

发布于 大漠

在Web的开发中,对于下拉选项的称谓各有不同,比如下拉菜单,下拉选择框等等。不同的场景之下,采用的方式也有所不同,比如在表单中会使用<select>元素,在自动输入框时会使用HTML的<datalist><input>结合使用。而表单中的控件在Web中是件复杂的事情,特别是CSS样式方面更是如此。在这篇文章中,我们就一起来聊聊Web下拉选项相关的事情。

HTML的<select>

<select>的使用不是件复杂的事情,比如:

<select>
    <option>Default</option>
    <option>CSS</option>
    <option>HTML</option>
    <option>JavaScript</option>
    <option>React</option>
</select>

在浏览器中看到的默认效果如下:

<select>中子元素<option>是用来设置选择框的选项,除此之外,还可以由<optgroup>元素对选项进行分组:

<select>
    <optgroup label="前端">
        <option value="HTML">HTML</option>
        <option value="CSS">CSS</option>
        <option value="JavaScript">JavaScript</option>
    </optgroup>
    <optgroup label="JavaScript框架">
        <option value="React">React</option>
        <option value="Vue">Vue</option>
        <option value="Angular">Angular</option>
    </optgroup>
</select>

效果如下:

上面看到的示例都是单选项,有的时候我们希望<select>能让用户同时可以选择多项。在原生的<select>控件中,只需要显式设置multiple属性即可:

<select multiple name="lange">
    <option value="" diabled hidden>Lange</option>
    <option value="html">HTML</option>
    <option value="css">CSS</option>
    <option value="javascript">JavaScript</option>
    <option value="react">React</option>
    <option value="vue">Vue</option>
</select>

浏览器渲染的效果如下:

HTML的<select>元素除了具有的HTML全局属性之外,还有一些常见的属性:

  • autocompleteDOMString提供用户代理自动完成功能的提示
  • autofocus:能够让一个对象在页面加载的时候获得焦点。该属性的值是一个布尔值(truefalse),另外在整个页面的上下文中只有一个对象可以设置autofocus
  • disabled:用来设置用户是否可以对该控件进行操作,如果元素上未显式设置该属性,但它的父元素(比如<fieldset>)显式设置了disabled的话,<select>也会继承该属性
  • formselect所关联的form表单。如果这个属性被明确定义,那么它的值一定是在同一个document中表单ID。这样能够让你把select标签放在任何的位置,不仅限于作为form表单的后代元素
  • multiple:设置select是否可以多选,默认是单选
  • name:控件名称
  • required:规定select的值不能为空
  • size:如果控件显示为滚动列表框,则此属性表示为控件中同时可见的行数。浏览器不需要将选择元素渲染为滚动列表框。默认值为0

有关于HTML的<select>更详细的介绍,还可以阅读HTML规范中<select>元素一节

HTML的<datalist>

在HTML5中新增加了一个<datalist>元素,该元素有点类似于<select>元素,也可以包含一组<option>元素,它们表示其他控件的预定义选项。在渲染过程中,<datalist>元素什么也不表示,它及其子元素应该被隐藏。

<datalist>元素有两种使用方式。在最简单地情况下,<datalist>元素只有<option>子元素:

<datalist>
    <option>Default</option>
    <option>CSS</option>
    <option>HTML</option>
    <option>JavaScript</option>
    <option>React</option>
</datalist>

在你的浏览器上看不到任何的东西渲染出来:

但它确实存在于HTML中:

<datalist>元素和<input>结合在一起使用的话,可以实现一个类似于下拉选择框的效果。这两个元素结合起来使用的话,最重要的是 <input>list属性的值要和<datalist>id的值相匹配,其使用方式有点类似于labelfor和表单控件的id相匹配:

<input type="text" list="lange" name="lange" placeholder="选择你喜欢的语言" />
<datalist id="lange">
    <option name="html">HTML</option>
    <option name="css">CSS</option>
    <option name="javascript">JavaScript</option>
    <option name="react">React</option>
    <option name="vue">Vue</option>
</datalist>

浏览器中看到的效果如下:

另外一种方式是在更复杂的情况下,可以在<datalist>元素中内嵌<select>元素,这个时候<datalist>元素中的选项是由<select><option>提供:

<input type="text" name="lang" list="lang" />
<datalist id="lang">
    <label>
        <select name="lang">
            <optiop value="">选择你喜欢的语言</option>
            <option name="html">HTML</option>
            <option name="css">CSS</option>
            <option name="javascript">JavaScript</option>
            <option name="react">React</option>
            <option name="vue">Vue</option>
        </select>
    </label>
</datalist>

浏览器渲染出来的效果如下:

如果将<datalist>inputtype值配合起来使用的话,还可以得到不一样的效果,比如type="color"type="range"

有关于<datalist><input>type配合起来使用的更多介绍,还可以阅读:

不管是<select>还是<datalist>,都可以说是HTML的原生元素,默认情况之下都具有一定的交互行为和UI效果。比如下面这个示例:

尝试着在上面的示例中操作:

不难发现,<select>只能选择已提供的选项,而<datalist>除了能选择已提供的选项之外,用户还能手动输入。而且<datalist>还带有点过滤的效果:

用CSS美化

众所周知,<select>元素在不同的设备终端上渲染出来的UI效果是不一样的:

Web开发者也知道,很难用CSS对<select>元素做出好看的UI风格。即使使用CSS的appearance属性可以重置客户端对<select>元素框进行样式化,但是在很大程度上,CSS仍然无法对<option>样式化。

@SCOTT JEHL的《Styling a Select Like It’s 2019》一文中介绍了如何使用CSS来美化<select>,比如下面这个示例:

虽然框样式能达到UI的设计需求,但是下拉选项目前是无法使用CSS来美化:

也正基于这个原因,很多时候在制作下拉选项时更多的时候会采用自定义的方式。

注意,<datalist>中的<option>同样无法使用CSS来美化。

自定义下拉选项

很多时候为了让<option>的样式能满足设计师的需求,在制作下拉选项时,不再考虑<select>或者<datalist>,而会选择其他的HTML结构,比如<ul><li>元素,或者是<div><span>元素。

<label for="combox">语言:</label>
<div class="combo-wrap">
    <input type="text" class="combo__box" id="combox" />
    <i class="arrow"></i>
    <ul class="listbox">
        <li>CSS</li>
        <li>HTML</li>
        <li>JavaScript</li>
        <li>React</li>
        <li>Vue</li>
    </ul>
</div>

在未添加任何CSS的时候,它的样子看上去像下面这样:

添加一些CSS:

label {
    padding: 5px;
    font-size: 18px;
    font-weight: 500;
    display: block;
}

.combo-wrap {
    display: inline-flex;
    align-items: center;
    position: relative;
}

.combo__box {
    width: 30vw;
    padding: 10px 35px 10px 5px;
    border: 1px solid #fff;
    box-sizing: border-box;
    font-size: 14px;
    background-color: #fff;
    color: #212e59;
    box-shadow: 0 0 5px #000;
}

.arrow {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 32px;
    background-color: #212e59;

    &::after {
        content: "";
        position: absolute;
        top: calc(50% + 4px);
        left: 50%;
        transform: translate(-50%, -50%);
        border: 6px solid #fff;
        border-color: #fff transparent transparent;
    }
}

.listbox {
    position: absolute;
    list-style: none outside none;
    z-index: 99;

    max-height: 30vh;
    overflow-y: auto;
    background-color: #fff;
    top: calc(100% + 0px);
    left: 0;
    right: 0;
    box-shadow: 1px 2px 2px #000;
    color: #333;

    li {
        background-color: #fff;
        padding: 4px 6px;

        &:hover {
            background-color: #212e59;
            color: #fff;
        }
    }
}

这个时候你会看到自定义的下拉选择框有一些UI效果:

在默认状态下,<select>的下拉选择项都是处于被隐藏状态,因此需要在.listbox上设置:

.listbox {
    display: none;
}

这个时候,下拉选择项被隐藏了,即使input得处于聚焦状态下也不会显式:

如果你希望input处于焦点状态下让下拉选择项显示出来的话,可以使用CSS的:focus-within选择器让下拉选择项显示:

.combo-wrap:focus-within .listbox {
    display: block;
}

效果如下:

到目前为止,自定义下拉选项框的效果如下:

注意,在这个示例中采用了CSS的:focus-within选择器,如果你从未接触过的话,建议你花点时间阅读:

到此而言,我们只是在形上完成了<select>,但在行为上来说离真正的<select>还很完。

如果使用非<select>来做的自定义下拉选择框,不具有任何的交互行为和相应的功能

给自定义下拉选择框添加交互功能

虽然上面的示例中借助:focus-within能让下拉选择框显示和隐藏。但该选择器在部分浏览器中还未得到支持。因此,我们还需要借助JavaScript的能力,让其变得更强大。另外就是无法选择下拉项。这些都是需要JavaScript方面的能力。

我们先来实现下拉选择项显式、隐藏的功能。实现该功能并不难,只需要在下拉列表项ul.listbox的父容器上.custom-select添加一个类名,比如open。当.combo-select添加.open类名时,ul.listbox显式,当.combo-select移除.open时,ul.listbox隐藏。

const combox = document.getElementById("js__combox");

combox.addEventListener("click", function() {
    console.log(this);
    this.querySelector(".combo-wrap").classList.toggle("open");
});

这个时候只要在div.custom-select区域捕获了鼠标的点击事件(click),就会给div.combo-wrap元素上添加open类名,其中.classList.toggle()返回的是一个布尔值:

你可能已经发现了,.custom-select第一次捕获click事件时,.combo-wrap会添加open类名,再一次捕获时,.combo-wrap就会删除open类名,依此类推。

类似于:focus-within,在.combo-wrap具有open类名时,设置.listbox的为可见:

.combo-wrap.open .listbox,
.combo-wrap:focus-within .listbox {
    display: block;
}

<select>正常的交互行为是”获得焦点时,下拉列表项会显示,失去焦点时,下拉列表项会隐藏“。我们上面只是通过JavaScript脚本让模拟的列表项显式,但还需要在非下拉选择框其他区域点击时隐藏列表项。实现该功能,需要添加另一段脚本代码:

window.addEventListener("click", function(e) {
    const select = document.getElementById("js__select");

    if (!select.contains(e.target)) {
        select.classList.remove("open");
    }
});

这个时候离真正的<select>越来越近了:

到目前为止,我们虽然能很好的控制下拉列表项的显示隐藏功能,但还有一个最大的缺陷,就是选中的列表项值还无法真正地在input中显示。接下来,继续为示例添加一段脚本代码,就是给每个列表项添加点击事件对应的行为:

const options = document.querySelectorAll(".option");
const comboxVal =  document.getElementById("combox");

for (const option of options) {
    option.addEventListener('click', function(){
        if(!this.classList.contains('selected')) {
            this.parentNode.querySelector('.option.selected').classList.remove('selected')
            this.classList.add('selected')
            comboxVal.value = this.textContent;
        }
    })
}

这个时候基本上能满足我们所需要的自定义下拉选择框的效果和功能:

创建可访问的自定义下拉选择框

在《A11Y 101:编写HTML时要考虑可访问性》一文中提到过,我们在Web开发过程中应该尽可能的采用原生的HTML元素来构建Web中所需的元素。这样做的好处是我们可以避免很多额外的工作量。就比如我们所说的下拉选择框,如果采用非原生HTML元素<select><datalist>,除了要额外的编写功能性的JavaScript脚本之外,还需要编写很多有关于可访问性方面的脚本。

虽然说使用JavaScript脚本和ARIA相关的特性,我们可以构建一个更具可访问性的下拉选择框,但是这个过程是复杂而且痛苦的。为什么这么说呢?主要原因之一是,ARIA规范本身在关于combobox角色的使用规范并不一致,每个ARIA的版本中都有不同的变化。

ARIA 1.0中的combobox

ARIA 1.0是WAI-ARIA最早的一个版本,规范中提供了combobox角色的使用示例

<input 
    type="text" 
    aria-label="Tag" 
    role="combobox" 
    aria-expanded="true"
    aria-autocomplete="list" 
    aria-owns="owned_listbox" 
    aria-activedescendant="selected_option" />

<ul role="listbox" id="owned_listbox">
    <li role="option">Zebra</li>
    <li role="option" id="selected_option">Zoom</li>
</ul>

但这个版本中的aria-owns中遇到了麻烦,在所有平台上,不能较好的将一个textbox元素映射为另一个元素的父元素。

ARIA 1.1中的combobox

ARIA 1.1是WAI-ARIA第二个版本。在这一版中,将textboxlistbox包装在单独的combobox父类中,解决了ARIA 1.0中aria-owns遇到的麻烦,但同时也引入了其他的问题

ARIA 1.1版本中combobox的使用示例:

<div 
    aria-label="Tag" 
    role="combobox" 
    aria-expanded="true" 
    aria-owns="owned_listbox" 
    aria-haspopup="listbox"
>
    <input 
        type="text" 
        aria-autocomplete="list" 
        aria-controls="owned_listbox" 
        aria-activedescendant="selected_option" />
</div>
<ul role="listbox" id="owned_listbox">
    <li role="option">Zebra</li>
    <li role="option" id="selected_option">Zoom</li>
</ul>

ARIA 1.2中的combobox

由于前面版本中的combobox实现存在问题,因此在ARIA 1.2版本中对combobox做了较大的更改

<label for="tag_combo">Tag</label>
<input 
    type="text" 
    id="tag_combo"
    role="combobox" 
    aria-autocomplete="list"
    aria-haspopup="listbox" 
    aria-expanded="true"
    aria-controls="popup_listbox" 
    aria-activedescendant="selected_option" />
<ul role="listbox" id="popup_listbox">
    <li role="option">Zebra</li>
    <li role="option" id="selected_option">Zoom</li>
</ul>

ARIA 1.2中的combobox几乎和ARIA 1.0的模式相同,但是在<input>上使用的是aria-controls,而不是aria-owns

从上面的三个版本示例中我们可以获得:

  • ARIA 1.0的combobox模式,其中瞎了眼有组合框角色的<input>元素引用的是aria-owns弹出元素,但aria-owns存在一定的问题
  • ARIA 1.1的combobox模式,其中具有combobox角色的元素是一个复合容器,而不是可聚焦的<input>
  • ARIA 1.2的combobox模式几乎和ARIA 1.0是完全相同,只是在<input>元素上使用了aria-controls引用弹出元素,就目前而言,ARIA 1.2模式取代了ARIA 1.1模式(用户代理和辅助技术未充分支持ARIA 1.1模式)

ARIA中的combobox模式实现的特性和行为千差万别。因此,在使用ARIA的combobox时有很多注意事项。具体的可以参阅WAI-ARIA实践操作指南中关于combobox模式的实现一节

注意:ARIA 1.2还只是个工作草案,还不是W3C推荐标准,所以目前该草案中的一切都有可能还会有所变化,在使用ARIA 1.2中combobox模式时,还需要时刻关注它的相关变更

弹出按钮

这种模式和macOS、iOS和Android上的<select>最相似。这种模式有一些独特的挑战,

  • 它使用<button>元素作为表单输入,
  • 它不能同时公开标签和值
  • 所选的值不会作为<form>元素的一部分提交
  • 不能标记为只读(readonly)、必需(required)和无效(invalid

来看一个简单示例:

<label id="label">Dropdown select without text input</label>

<button 
    id="button"
    type="button"
    aria-haspopup="listbox"
    aria-expanded="true"
    aria-labelledby="label button">Zebra</button>

<ul id="list" role="listbox" aria-activedescendant="selected_option" tabindex="-1">
    <li id="selected_option" role="option">Zebra</li>
    <li role="option">Zoom</li>
</ul>

扩展listbox

在ARIA规范或ARIA实践指南中没有明确提到此模式。

listbox模式在一些组件库有出现过,比如Kendo UIOffice Fabric。在ARIA 1.01.11.2版本中都有对listbox做过定义。

ARIA 1.1版本的listbox角色上添加了aria-expanded状态,这在一定程度上是为了替代ARIA 1.1版本中combobox模式的缺点。

<label id="label">Listbox-only version</label>

<div 
    role="listbox"
    aria-labelledby="label"
    aria-expanded="true"
    aria-activedescendant="option1"
    tabindex="0">
    <div id="option1" role="option">option 1</div>
    <!-- more options -->
</div>

在Web开发过程中,特别是对于comboboxlistbox的使用时,不同的开发者都有着不同的看法,有些开发者认为两个模式做的是相同的事情。不同的开发者也会根据自己的喜好来做出相应的选择。不幸运的是,这些模式还在不停的变化,这也意味着许多自定义的<select>在可访问性方面的特性已经过时了,甚至还会给有障碍的人士带来更大的困惑。毕竟是:

错误的使用ARIA还不如不使用任何ARIA

行为选择

上面我们讨论了,正确的使用好ARIA对于我们自定义的<select>是有着很大帮助的,主要是指可访问性方面。但是,<select>控件是个比较复杂的东东(表单中的任何一个控件都是比较复杂的),比如说,在不同的平台和终端上,其交互模式都有所不同,而且实现的方式也有多种。但值得注意的是,iOS和Android中原生的<select>中的option具有不同的视觉表示和交互模式,并且和传统的桌面类下拉选择框相比,它们的性能相对较差。但是,到目前为止还没有较好的方案在Web上创建原生的效果,而且很多时候为了使移动和平板用户在一定程度上UX能趋于统一或做相应的降级(对比原生的UX),会采用我们提到过的自定义<select>方式。

自定义<select>在可访问性方面是件复杂的事情,接下来的内容主要以PC端为主,因为移动端上涉及到的相关事项太过复杂

就PC端而言,如果采用非<select>元素来构建下拉选择框,除了ARIA方面的需要优化之外,还会涉及到键盘方面的事项。毕竟有部分用户会重度的依赖于键盘的操作。就自定义的下拉选择框而言,键盘操作的相关行为可能会涉及到:

  • 向上(Up)和向下(Down)箭头可以让用户更改所选选项,但不展开”选项“菜单
  • Alt + UpAlt + Down打开菜单”选项“并突出当前选项
  • Enter回车键不执行任何操作,包括在适用时不提交父表单(就此项而言,不同的客户端表现形式可能会有所不同,比如Chrome,Enter键会打开下拉选项项)
  • 空格键Space将打开下拉选项并突出显示当前项
  • 可要打印字符:在不展开下拉选项的情况下选择任何匹配的选项

下拉选项展开的行为也可以通过键盘来操作:

  • 向上(Up)和向下(Down)箭头键可以移动选择下拉选项,同时更改下拉选项
  • 空格键(Space)不做任何事情
  • 确认键(Enter)会关闭下拉选项,保持选中的当前项突出显示
  • 取消键(Escape)会关闭下拉选项,保持选中的当前项突出显示

注意,不同的系统,不同的客户端,键盘的操作行为都可能存在差异。其中最大的区别集中在哪些键会展开下拉选项,哪些键会选当前项并关闭下拉选项,以及按下空格键是否应该将选择恢复到以前选择的选项。

也就是说,如果采用一个HTML非原生的<select>元素制作一个下拉选择框,除了需要通过JavaScript脚本完成一定的交互行为之外,还需要考虑ARIA角色,状态等变化,另外还需要处理键盘带来的操作行为。即,自定义的下拉选择框需要满足名称、角色、值、键盘、信息和关系。事实上,即使通过JavaScript脚本也不一定能满足所有的需求,或者说可访问性能满足所有有障碍人士的需求

衡量任何一个自定义的<select>是否完全没问题,并不是通过技术的手段来检测,应该是通过真实的环境,真实的人来检测。其中最简单的方式就是让有访问有障碍的人士来操作自定义的<select>,并且观察在实际操作中的具本表现

简单地来说,不管是采用原生的HTML元素,还是使用WAI-ARIA特性,最直接的检测就是让访问Web存有障碍的人士来操作你的应用,并观察应用给他们带来的不便。从而检测出Web应用在可访问性中存在的问题。

构建具有可访问性下拉选择框

有了上面的认识和基础,我们就可以基于前面的示例基础上来构建一个更具可访问性的下拉选择框。

首先构建具有语义化的HTML标签,并且在相应的HTML标签元素上添加合适的ARIA角色和状态,比如ARIA 1.2规范中提供的HTML示例:

<div class="custom__container">
    <label for="language__combo">语言:</label>
    <div class="custom__select">
        <input type="text" class="select__input" id="language__combo" role="combobox" aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="true" aria-controls="a11y__language__listbox" aria-activedescendant="selected_option" aria-describedby="custom__select__info" tabindex="0" />
        <span id="custom__select__info" class="sr-only">Arrow down for options or start typing to filter</span>
        <span class="custom__select__icons">
            <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" focusable="false" aria-hidden="true" id="icon__circle__down" class="icon" role="img" fill="currentColor">
                <path d="M16 8c0-4.418-3.582-8-8-8s-8 3.582-8 8 3.582 8 8 8 8-3.582 8-8zM1.5 8c0-3.59 2.91-6.5 6.5-6.5s6.5 2.91 6.5 6.5-2.91 6.5-6.5 6.5-6.5-2.91-6.5-6.5z"></path>
                <path d="M4.957 5.543l-1.414 1.414 4.457 4.457 4.457-4.457-1.414-1.414-3.043 3.043z"></path>
            </svg>
            <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" focusable="false" aria-hidden="true" id="icon__circle__up" class="icon hidden-all" role="img" fill="currentColor">
                <path d="M0 8c0 4.418 3.582 8 8 8s8-3.582 8-8-3.582-8-8-8-8 3.582-8 8zM14.5 8c0 3.59-2.91 6.5-6.5 6.5s-6.5-2.91-6.5-6.5 2.91-6.5 6.5-6.5 6.5 2.91 6.5 6.5z"></path>
                <path d="M11.043 10.457l1.414-1.414-4.457-4.457-4.457 4.457 1.414 1.414 3.043-3.043z"></path>
            </svg>
        </span>
        <ul role="listbox" id="a11y__language__listbox" class="select" aria-expanded="false">
            <li role="option" tabindex="0" class="option">CSS</li>
            <li role="option" tabindex="0" class="option active" id="selected_option">HTML</li>
            <li role="option" tabindex="0" class="option">JavaScript</li>
            <li role="option" tabindex="0" class="option">React</li>
            <li role="option" tabindex="0" class="option">Vue</li>
        </ul>
    </div>  
</div>

这个时候看到的效果是最粗造的一个:

添加美化下拉选择框相关的样式:

.custom__container {
    label {
        display: block;
        margin-bottom: 2vh;
    }
}

.custom__select {
    border: 1px solid currentColor;
    border-radius: 3px;
    position: relative;
    color: #fff;

    &.open {
        border-radius: 3px 3px 0 0;
    }
}

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    -webkit-clip-path: inset(50%);
    clip-path: inset(50%);
    border: 0;
}

.select__input {
    display: block;
    font-size: 1em;
    font-weight: 700;
    padding: 0.6em 46px 0.5em 0.8em;
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    margin: 0;
    border: none 0;
    box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.04);
    appearance: none;
    background-color: #fff;
    z-index: 10;
    color: #333;
}

.custom__select__icons {
    position: absolute;
    top: 0;
    right: 0;
    width: 46px;
    color: #333;
    height: 46px;

    svg {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
    }
}

#icon__circle__up {
    display: none;
}

.select {
    display: none;
    position: absolute;
    display: flex;
    flex-direction: column;
    border: 1px solid #fff;
    left: -1px;
    right: -1px;
    border-radius: 0 0 3px 3px;
    background: #fff;
    margin: 0;
    padding: 0;
    list-style: none outside none;
    color: #333;

    li {
        cursor: pointer;
        padding: 6px 12px;
        display: flex;
        align-items: center;

        &.active,
        &:hover {
            background: #007fff;
            color: #fff;
        }
    }
}

.open .select {
    border-radius: 0 0 3px 3px;
    display: block;
}

这个时候,你在浏览器中看到的效果如下:

在没有任何JavaScript下,我们自定义的<select>并没有任何的功能,做不了任何事情。这个时候需要通过JavaScript脚本帮助我们做一些事情:

const customSelect = function(element, overrides) {
    const defaults = {
        inputSelector: "input",
        listSelector: "ul",
        optionSelector: "li",
        statusSelector: '[aria-live="polite"]'
    };

    const options = Object.assign({}, defaults, overrides);

    const csSelector = document.querySelector(element); // the input, svg and ul as a group
    const csInput = csSelector.querySelector(options.inputSelector);
    const csList = csSelector.querySelector(options.listSelector);
    const csOptions = csList.querySelectorAll(options.optionSelector);
    const csStatus = document.querySelector(options.statusSelector);
    const aOptions = Array.from(csOptions);

    let csState = "initial";

    csSelector.addEventListener("click", function(e) {
        const currentFocus = findFocus();
        switch (csState) {
        case "initial":
            toggleList("Open");
            setState("opened");
            break;
        case "opened":
            if (currentFocus === csInput) {
                toggleList("Shut");
                setState("initial");
            } else if (currentFocus.tagName === "LI") {
                makeChoice(currentFocus);
                toggleList("Shut");
                setState("closed");
            }
            break;
        case "filtered":
            if (currentFocus.tagName === "LI") {
                makeChoice(currentFocus);
                toggleList("Shut");
                setState("closed");
            }

            break;
        case "closed":
            toggleList("Open");
            setState("filtered");
            break;
        }
    });

    csSelector.addEventListener("keyup", function(e) {
        doKeyAction(e.key);
    });

    document.addEventListener("click", function(e) {
        if (!e.target.closest(element)) {
            toggleList("Shut");
            setState("initial");
        }
    });

    function toggleList(whichWay) {
        if (whichWay === "Open") {
            csList.classList.remove("hidden-all");
            csSelector.setAttribute("aria-expanded", "true");
        } else {
            csList.classList.add("hidden-all");
            csSelector.setAttribute("aria-expanded", "false");
        }
    }

    function findFocus() {
        const focusPoint = document.activeElement;
        return focusPoint;
    }

    function moveFocus(fromHere, toThere) {
        const aCurrentOptions = aOptions.filter(function(option) {
            if (option.style.display === "") {
                return true;
            }
        });
        if (aCurrentOptions.length === 0) {
            return;
        }
        if (toThere === "input") {
            csInput.focus();
        }
        switch (fromHere) {
            case csInput:
                if (toThere === "forward") {
                    aCurrentOptions[0].focus();
                } else if (toThere === "back") {
                    aCurrentOptions[aCurrentOptions.length - 1].focus();
                }
                break;
            case csOptions[0]:
                if (toThere === "forward") {
                    aCurrentOptions[1].focus();
                } else if (toThere === "back") {
                    csInput.focus();
                }
                break;
            case csOptions[csOptions.length - 1]:
                if (toThere === "forward") {
                    aCurrentOptions[0].focus();
                } else if (toThere === "back") {
                    aCurrentOptions[aCurrentOptions.length - 2].focus();
                }
                break;
            default:
                const currentItem = findFocus();
                const whichOne = aCurrentOptions.indexOf(currentItem);
                if (toThere === "forward") {
                    const nextOne = aCurrentOptions[whichOne + 1];
                    nextOne.focus();
                } else if (toThere === "back" && whichOne > 0) {
                    const previousOne = aCurrentOptions[whichOne - 1];
                    previousOne.focus();
                } else {
                    csInput.focus();
                }
                break;
        }
    }

    function doFilter() {
        const terms = csInput.value;
        const aFilteredOptions = aOptions.filter(function(option) {
            if (option.innerText.toUpperCase().startsWith(terms.toUpperCase())) {
                return true;
            }
        });
        csOptions.forEach(option => (option.style.display = "none"));
        aFilteredOptions.forEach(function(option) {
            option.style.display = "";
        });
        setState("filtered");
        updateStatus(aFilteredOptions.length);
    }

    function updateStatus(howMany) {
        csStatus.textContent = howMany + " options available.";
    }

    function makeChoice(whichOption) {
        csInput.value = whichOption.textContent;
        moveFocus(document.activeElement, "input");
    }

    function setState(newState) {
        switch (newState) {
        case "initial":
            csState = "initial";
            break;
        case "opened":
            csState = "opened";
            break;
        case "filtered":
            csState = "filtered";
            break;
        case "closed":
            csState = "closed";
        }
    }

    function doKeyAction(whichKey) {
        const currentFocus = findFocus();
        switch (whichKey) {
            case "Enter":
                if (csState === "initial") {
                    toggleList("Open");
                    setState("opened");
                } else if (csState === "opened" && currentFocus.tagName === "LI") {
                    makeChoice(currentFocus);
                    toggleList("Shut");
                    setState("closed");
                } else if (csState === "opened" && currentFocus === csInput) {
                    toggleList("Shut");
                    setState("closed");
                } else if (csState === "filtered" && currentFocus.tagName === "LI") {
                    makeChoice(currentFocus);
                    toggleList("Shut");
                    setState("closed");
                } else if (csState === "filtered" && currentFocus === csInput) {
                    toggleList("Open");
                    setState("opened");
                } else {
                    toggleList("Open");
                    setState("filtered");
                }
                break;

            case "Escape":
                if (csState === "opened" || csState === "filtered") {
                    toggleList("Shut");
                    setState("initial");
                }
                break;

            case "ArrowDown":
                if (csState === "initial" || csState === "closed") {
                    toggleList("Open");
                    moveFocus(csInput, "forward");
                    setState("opened");
                } else {
                    toggleList("Open");
                    moveFocus(currentFocus, "forward");
                }
                break;
            case "ArrowUp":
                if (csState === "initial" || csState === "closed") {
                    toggleList("Open");
                    moveFocus(csInput, "back");
                    setState("opened");
                } else {
                    moveFocus(currentFocus, "back");
                }
                break;
            default:
                if (csState === "initial") {
                    toggleList("Open");
                    doFilter();
                    setState("filtered");
                } else if (csState === "opened") {
                    doFilter();
                    setState("filtered");
                } else if (csState === "closed") {
                    doFilter();
                    setState("filtered");
                } else {
                    doFilter();
                }
                break;
            }
        }
};

customSelect("#js__custom__select", {
    statusSelector: "#custom-select-status"
});

有关于JavaScript代码的具体介绍可以阅读@Julie Grundy的《Making a Better Custom Select Element》一文。示例中的JavaScript代码在教程的基础上做了相应的修改,原示例中的JavaScript可以在Github上查看到

有关于这方面的介绍,还可以阅读:

HTML的<details><summary>

在快速结束该文的时候,让我想起了HTML的<details><summary>两个元素。去年年初看到@张鑫旭老师的《借助HTML5 details,summary无JS实现各种交互效果》教程,让我想起了,使用这两个元素可以在没有任何JavaScript脚本下实现一些带有交互行为的Web组件,比如手风琴效果:

HTMl的<details><summary>元素除了实现手风琴组件之外,还可以实现其他的Web组件,比如下拉菜单,甚至是我们今天在聊的下拉选项。如果你从未接触过这两个元素的话,建议你花点时间阅读下面这些文章:

HTML的<details><summary>一般会同时使用,而且使用也不复杂。

接下来,我们来看看<details><summary>如何来构建一个自定义的<select>。在继续往下之前,需要提一下,这里为什么要使用这两个元素来构建自定义的<select>呢?简单地说:

我们先来看看HTML模板:

<details class="custom__select">
    <summary>选择你喜欢的语言</summary>
    <ul class="select"> 
        <li>CSS</li>
        <li>HTML</li>
        <li>JavaScript</li>
        <li>React</li>
        <li>Vue</li>
    </ul>
</details>

在浏览器中你可以看到,它带有最基础的交互行为:

是不是和<select>很相似。为了让其在UI上更像<select>,可以给它添加一些样式代码。看到的效果可能会像下面这样:

上图左侧是收缩状态的效果,右侧是展开状态的效果。当然,你可以根据自己的喜好来做相应的美化。

你或许已经发现了,即使使用了<details><summary>,如果没有JavaScript脚本帮助下,用户无法像<select>一样选中下拉选项。因此,我们需要使用一些JavaScript脚本帮助我们实现。

@Andrew Bone 在《Accessibility first: DropDown (Select)》一文中用了一种更灵活的方法,在每个option中内嵌了一个<input type="radio">用来更好的取值。具体的示例如下:

小结

文章中主要和大家聊了聊Web中的下拉选择框。到目前为止,希望定制化下拉选择框<select>,除了大家常见的一些HTML标签元素之外,还可以采用<datalist><details><summary>。其中<datalist><input>配合天然具有自动过滤功能;<details><summary>配合天然具有展开、隐藏功能。虽然自定义<select>在UI上通过CSS可以更好的达到UI的需要,但它却额外的增加了很多工作内容,比如功能,可访问性等。在文章中,我们通过几个示例向大家演示了如何借助JavaScript脚本实现<select>功能和实现一个更具可访问性的自定义<select>

最后感谢大家花时间阅读,希望对你有所帮助。如果你在这方面有更好的建议或经验,欢迎在下面的评论中与我们共享。