构建多选组件

关于如何为排序和筛选用户体验构建响应式、自适应和无障碍的多选组件的基础概述。

在这篇文章中,我想分享关于构建多选组件的一些想法。尝试演示

演示

如果您喜欢视频,这里是这篇文章的 YouTube 版本

概述

用户经常会看到项目,有时是大量的项目,在这些情况下,提供一种减少列表的方法以防止选择过载可能是一个好主意。这篇博文探讨了筛选 UI 作为减少选择的一种方式。它通过呈现用户可以选择或取消选择的项目属性来实现这一点,从而减少结果,进而减少选择过载。

交互

目标是让所有用户及其不同的输入类型能够快速遍历筛选选项。这将通过一对适应性强且响应迅速的组件来实现。传统的复选框侧边栏适用于桌面、键盘和屏幕阅读器,而<select multiple>适用于触摸用户。

Comparison screenshot showing desktop light and dark with a sidebar of
checkboxes vs mobile iOS and Android with a multi-select element.

对于触摸设备使用内置多选而不是桌面设备的这个决定,既节省了工作,也创造了工作,但我相信,与在一个组件中构建整个响应式体验相比,它能够以更少的代码债务提供适当的体验。

触摸

触摸组件节省了空间,并有助于提高移动设备上用户交互的准确性。它通过将整个复选框侧边栏折叠成<select>内置叠加触摸体验来节省空间。它通过显示系统提供的大型触摸叠加体验来帮助提高输入准确性。

A
screenshot preview of the multi-select element in Chrome on Android, iPhone and
iPad. The iPad and iPhone have the multi-select toggled open, and each get a
unique experience optimized for the screen size.

键盘和游戏手柄

以下是如何从键盘使用<select multiple>的演示。

这种内置多选无法进行样式设置,并且仅以紧凑的布局提供,不适合呈现大量选项。看到您无法真正在那个小框中看到选项的广度吗?虽然您可以更改其大小,但它仍然不如复选框侧边栏那样易于使用。

标记

这两个组件都将包含在同一个<form>元素中。此表单的结果(无论是复选框还是多选)都将被观察并用于筛选网格,但也可以提交到服务器。

<form>

</form>

复选框组件

复选框组应包装在<fieldset>元素中,并给定一个<legend>。当 HTML 以这种方式构造时,屏幕阅读器和FormData将自动理解元素之间的关系。

<form>
  <fieldset>
    <legend>New</legend>
    … checkboxes …
  </fieldset>
</form>

完成分组后,为每个筛选器添加一个<label><input type="checkbox">。我选择将它们包装在一个<div>中,以便 CSS gap属性可以均匀地间隔它们,并在标签变为多行时保持对齐。

<form>
  <fieldset>
    <legend>New</legend>
    <div>
      <input type="checkbox" id="last 30 days" name="new" value="last 30 days">
      <label for="last 30 days">Last 30 Days</label>
    </div>
    <div>
      <input type="checkbox" id="last 6 months" name="new" value="last 6 months">
      <label for="last 6 months">Last 6 Months</label>
    </div>
   </fieldset>
</form>

A screenshot with an informative overlay for the legend and
  fieldset elements, shows color and element name.

<select multiple> 组件

<select> 元素的一个很少使用的功能是multiple。当该属性与<select>元素一起使用时,用户可以从列表中选择多个选项。这就像将交互从单选列表更改为复选框列表。

<form>
  <select multiple="true" title="Filter results by category">
    …
  </select>
</form>

要在<select>内部标记和创建组,请使用<optgroup>元素并为其提供label属性和值。此元素和属性值类似于<fieldset><legend>元素。

<form>
  <select multiple="true" title="Filter results by category">
    <optgroup label="New">
      …
    </optgroup>
  </select>
</form>

现在为筛选器添加<option>元素。

<form>
  <select multiple="true" title="Filter results by category">
    <optgroup label="New">
      <option value="last 30 days">Last 30 Days</option>
      <option value="last 6 months">Last 6 Months</option>
    </optgroup>
  </select>
</form>

A screenshot of the desktop rendering of a multi-select element.

使用计数器跟踪输入以通知辅助技术

此用户体验中使用了status role技术,用于跟踪和维护屏幕阅读器和其他辅助技术的筛选器计数。YouTube 视频演示了该功能。集成从 HTML 和属性role="status"开始。

<div role="status" class="sr-only" id="applied-filters"></div>

此元素将朗读对内容所做的更改。我们可以使用CSS 计数器在用户与复选框交互时更新内容。为此,我们首先需要在输入和状态元素的父元素上创建一个具有名称的计数器。

aside {
  counter-reset: filters;
}

默认情况下,计数将为0,这很棒,因为在此设计中默认情况下没有:checked

接下来,为了递增我们新创建的计数器,我们将定位<aside>元素的作为:checked的子元素。当用户更改输入的状态时,filters计数器将进行统计。

aside :checked {
  counter-increment: filters;
}

CSS 现在知道复选框 UI 的大致计数,并且状态角色元素为空,等待值。由于 CSS 在内存中维护计数,因此counter()函数允许从伪元素内容访问值

aside #applied-filters::before {
  content: counter(filters) " filters ";
}

现在,状态角色元素的 HTML 将向屏幕阅读器宣布“2 个筛选器”。这是一个好的开始,但我们可以做得更好,例如共享筛选器已更新的结果计数。我们将从 JavaScript 完成这项工作,因为它超出了计数器可以完成的范围。

A screenshot of the MacOS screen reader announcing number of active filters.

嵌套的兴奋

计数器算法在CSS nesting-1中感觉很棒,因为我能够将所有逻辑放入一个块中。感觉便携且集中,便于阅读和更新。

aside {
  counter-reset: filters;

  & :checked {
    counter-increment: filters;
  }

  & #applied-filters::before {
    content: counter(filters) " filters ";
  }
}

布局

本节介绍两个组件之间的布局。大多数布局样式都用于桌面复选框组件。

表单

为了优化用户的易读性和可扫描性,表单的最大宽度设置为 30 个字符,实际上为每个筛选器标签设置了光学行宽。表单使用网格布局和gap属性来间隔 fieldset。

form {
  display: grid;
  gap: 2ch;
  max-inline-size: 30ch;
}

<select> 元素

标签和复选框列表在移动设备上都占用太多空间。因此,布局检查以查看用户的主要指点设备,以更改触摸体验。

@media (pointer: coarse) {
  select[multiple] {
    display: block;
  }
}

coarse值表示用户将无法使用其主要输入设备以高精度与屏幕进行交互。在移动设备上,指针值通常为coarse,因为主要交互是触摸。在桌面设备上,指针值通常为fine,因为通常连接鼠标或其他高精度输入设备。

Fieldset

带有<legend><fieldset>的默认样式和布局是独一无二的

A screenshot of the default styles for a fieldset and legend.

通常,为了间隔我的子元素,我会使用gap属性,但是<legend>的独特定位使其难以创建均匀间隔的子元素集。与其使用gap,不如使用相邻兄弟选择器margin-block-start

fieldset {
  padding: 2ch;

  & > div + div {
    margin-block-start: 2ch;
  }
}

这会跳过<legend>,使其空间不会通过仅定位<div>子元素来调整。

Screenshot showing the margin spacing between inputs but not the legend.

筛选器标签和复选框

作为<fieldset>的直接子元素,并且在表单的30ch最大宽度内,标签文本可能会在太长时换行。文本换行很棒,但文本和复选框之间的未对齐则不然。Flexbox 非常适合这种情况。

fieldset > div {
  display: flex;
  gap: 2ch;
  align-items: baseline;
}
Screenshot showing how the checkmark aligns to
    the first line of text in a multi-line wrapping scenario.
在这个 Codepen 中进行更多尝试

动画网格

布局动画由 Isotope 完成。一个用于交互式排序和筛选的高性能且强大的插件。

JavaScript

除了帮助编排整洁的动画交互式网格外,JavaScript 还用于润色一些粗糙的边缘。

规范化用户输入

此设计有一个表单,具有两种不同的输入方式,并且它们的序列化方式不同。但是,借助一些 JavaScript,我们可以规范化数据。

Screenshot of the DevTools JavaScript console which
  shows the goal, normalized data results.

我选择将<select>元素数据结构与分组复选框结构对齐。为此,将一个input事件侦听器添加到<select>元素,此时会映射其selectedOptions

document.querySelector('select').addEventListener('input', event => {
  // make selectedOptions iterable then reduce a new array object
  let selectData = Array.from(event.target.selectedOptions).reduce((data, opt) => {
    // parent optgroup label and option value are added to the reduce aggregator
    data.push([opt.parentElement.label.toLowerCase(), opt.value])
    return data
  }, [])
})

现在可以安全地提交表单,或者在本演示的示例中,指示 Isotope 按什么筛选。

完成状态角色元素

该元素仅根据复选框交互来统计和宣布筛选器计数,但我认为最好额外共享结果数量,并确保<select>元素选择也被计算在内。

<select> 元素选择反映在 counter()

在数据规范化部分,已在输入上创建了一个侦听器。在此函数结束时,已知所选筛选器的数量和这些筛选器的结果数量。这些值可以像这样传递到状态角色元素。

let statusRoleElement = document.querySelector('#applied-filters')
statusRoleElement.style.counterSet = selectData.length

结果反映在 role="status" 元素中

:checked 提供了一种将所选筛选器数量传递到状态角色元素的内置方法,但缺乏对筛选结果数量的可见性。JavaScript 可以监视与复选框的交互,并在筛选网格后,添加像<select>元素那样的textContent

document
  .querySelector('aside form')
  .addEventListener('input', e => {
    // isotope demo code
    let filterResults = IsotopeGrid.getFilteredItemElements().length
    document.querySelector('#applied-filters').textContent = `giving ${filterResults} results`
})

总而言之,这项工作完成了“2 个筛选器,给出 25 个结果”的公告。

A screenshot of the MacOS screen reader announcing results.

现在,我们将为所有用户提供出色的辅助技术体验,无论他们如何与之交互。

结论

现在您知道我是如何做到的了,您会怎么做呢?🙂

让我们使我们的方法多样化,并学习在 Web 上构建的所有方法。创建一个演示,在 Twitter 上给我发链接,我会将其添加到下面的社区混音部分!

社区混音

这里目前没有任何内容!