发布于 2026-01-06 2 阅读
0

无障碍功能优先:下拉菜单(选择)

无障碍功能优先:下拉菜单(选择)

我一直在思考如何将这个无法自定义的select元素变成一个易于使用的、类似 jQuery 的选择菜单。当然,如果不需要自定义,那就尽量select使用原生实现,原生实现总是更胜一筹。

jQuery 的实现:
jQuery 选择菜单

我当时已经有了一个不错的“概念验证”,正打算把它完善成一篇文章,结果就在这时,@emmabostian发表了一篇类似的精彩文章。我建议你也读读她的文章,真的很不错。

这是我的成品,下面我将详细介绍我的制作过程和原因:

要求

为此,我们需要同时支持键盘和鼠标,所以让我们看看规范,了解一下预期行为是什么。

键盘

  • 按 Enter 键,切换列表框可见性
  • 按 Esc 键,隐藏列表框
  • 向下箭头,选择列表中的下一个选项
  • 按向上箭头,选择列表中的上一个选项
  • 主屏幕钥匙,选择列表中的第一个选项
  • 按结束键,选择列表中的最后一个选项

老鼠

  • 点击输入框,切换列表框可见性
  • 点击输入框外,隐藏列表框
  • 单击该选项,将其设置为活动状态并隐藏列表框。

根据规格来看,我认为这就是我们需要的全部内容,但我随时欢迎指正。

标记

为此,我使用了<details>内置了我想要的显示和隐藏功能的元素。

我还使用了一组单选按钮,以便存储哪个值是正确的。checked如果您需要设置默认选项,只需将其添加到默认选项即可。

<details id="example_select" class="select_container">
  <summary>--</summary>
  <div class="select">
    <label class="select__option">
      <input type="radio" name="example" value="slower">Slower
    </label>
    <label class="select__option">
      <input type="radio" name="example" value="slow">Slow
    </label>
    <label class="select__option">
      <input type="radio" name="example" value="medium">Medium
    </label>
    <label class="select__option">
      <input type="radio" name="example" value="fast">Fast
    </label>
    <label class="select__option">
      <input type="radio" name="example" value="faster">Faster
    </label>
  </div>
</details>
Enter fullscreen mode Exit fullscreen mode

如果没有样式,你就能真正看出它的工作原理了。我们只有一列单选按钮,就这么简单。

款式

和往常一样,我不会赘述太多细节,这只是我个人的偏好。你可能已经注意到我把它做得像 jQuery 版本,但你可以随意发挥。

details.select_container {
  display: inline-block;
  width: 200px;
  border: 1px solid #c5c5c5;
  border-radius: 3px;
  position: relative;
  color: #454545;
}

details.select_container[open] {
  border-radius: 3px 3px 0 0;
}

details.select_container summary::after {
  content: "\00203A";
  position: absolute;
  right: 12px;
  top: calc(50%);
  transform: translateY(-50%) rotate(90deg);
  pointer-events: none;
}

details.select_container[open] summary::after {
  content: "\002039";
}

details.select_container summary {
  cursor: pointer;
  padding: 6px 12px;
  background: #f6f6f6;
  list-style: none;
}

details.select_container summary::-webkit-details-marker {
  display: none;
}

details.select_container summary:hover {
  background: #ededed;
}

details.select_container .select {
  position: absolute;
  display: flex;
  flex-direction: column;
  border: 1px solid #c5c5c5;
  width: 100%;
  left: -1px;
  border-radius: 0 0 3px 3px;
  background: #fff;
}

details.select_container .select__option {
  cursor: pointer;
  padding: 6px 12px;
}

details.select_container .select:hover .select__option.active {
  background: #fff;
  color: #454545;
}

details.select_container .select__option.active,
details.select_container .select:hover .select__option.active:hover,
details.select_container .select__option:hover {
  background: #007fff;
  color: #fff;
}

details.select_container .select__option input {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

所有的智能功能都是通过 JavaScript 实现的。

JavaScript

与以往的项目不同,这次我使用 JS 来设置所有 aria 属性,这意味着您无需记住手动设置,这无疑是一大优势。和以往一样,我使用了类,这并非强制要求,而是因为我喜欢类。

我不会赘述太多细节,您可以自己阅读。如果您有任何不明白的地方,请随时提问。但我会提及它this.mouseDown以及它存在的原因。

我原本打算focusout在选择菜单失去焦点时关闭它,但发现我的click事件处理程序不再起作用了。经过一番研究,我意识到焦点是在鼠标按下时丢失的,而点击事件却是在鼠标抬起时触发的。为了解决这个问题,我必须监听选项菜单的鼠标按下事件,以防止focusout出现问题。

class detailSelect {
  constructor(container) {
    this.container = document.querySelector(container);
    this.options = document.querySelectorAll(`${container} > .select > .select__option`);
    this.value = this.container.querySelector('summary').textContent;
    this.mouseDown = false;
    this._addEventListeners();
    this._setAria();
    this.updateValue();
  }

  // Private function to set event listeners
  _addEventListeners() {
    this.container.addEventListener('toggle', () => {
      if (this.container.open) return;
      this.updateValue();
    })

    this.container.addEventListener('focusout', e => {
      if (this.mouseDown) return;
      this.container.removeAttribute('open');
    })

    this.options.forEach(opt => {
      opt.addEventListener('mousedown', () => {
        this.mouseDown = true;
      })
      opt.addEventListener('mouseup', () => {
        this.mouseDown = false;
        this.container.removeAttribute('open');
      })
    })

    this.container.addEventListener('keyup', e => {
      const keycode = e.which;
      const current = [...this.options].indexOf(this.container.querySelector('.active'));
      switch (keycode) {
        case 27: // ESC
          this.container.removeAttribute('open');
          break;
        case 35: // END
          e.preventDefault();
          if (!this.container.open) this.container.setAttribute('open', '');
          this.setChecked(this.options[this.options.length - 1].querySelector('input'))
          break;
        case 36: // HOME
          e.preventDefault();
          if (!this.container.open) this.container.setAttribute('open', '');
          this.setChecked(this.options[0].querySelector('input'))
          break;
        case 38: // UP
          e.preventDefault();
          if (!this.container.open) this.container.setAttribute('open', '');
          this.setChecked(this.options[current > 0 ? current - 1 : 0].querySelector('input'));
          break;
        case 40: // DOWN
          e.preventDefault();
          if (!this.container.open) this.container.setAttribute('open', '');
          this.setChecked(this.options[current < this.options.length - 1 ? current + 1 : this.options.length - 1].querySelector('input'));
          break;
      }
    })
  }

  _setAria() {
    this.container.setAttribute('aria-haspopup', 'listbox');
    this.container.querySelector('.select').setAttribute('role', 'listbox');
    const summary = this.container.querySelector('summary');
    summary.setAttribute('aria-label', `unselected listbox`);
    summary.setAttribute('aria-live', `polite`);
    this.options.forEach(opt => {
      opt.setAttribute('role', 'option');
    });
  }

  updateValue(e) {
    const that = this.container.querySelector('input:checked');
    if (!that) return;
    this.setValue(that)
  }

  setChecked(that) {
    that.checked = true;
    this.setValue(that)
  }

  setValue(that) {
    if (this.value == that.value) return;

    const summary = this.container.querySelector('summary');
    const pos = [...this.options].indexOf(that.parentNode) + 1;
    summary.textContent = that.parentNode.textContent;
    summary.setAttribute('aria-label', `${that.value}, listbox ${pos} of ${this.options.length}`);
    this.value = that.value;

    this.options.forEach(opt => {
      opt.classList.remove('active');
      opt.setAttribute('aria-selected', 'false');
    })
    that.parentNode.classList.add('active');
    that.parentNode.setAttribute('aria-selected', 'true');

    this.container.dispatchEvent(new Event('change'));
  }
}

const details = new detailSelect('#example_select');

Enter fullscreen mode Exit fullscreen mode

然后我们创建一个实例

const details = new detailSelect('#example_select');
Enter fullscreen mode Exit fullscreen mode

如果我有什么可以改进的地方,请告诉我,我很想听听您的意见。

本系列文章又更新完毕Accessibility first。感谢阅读,如有任何疑问,欢迎随时提问,没有愚蠢的问题。和往常一样,您可以随意使用文中提到的任何技巧,如果我说错了什么,也请随时指正。

再次感谢
。❤🦄🦄🧠❤🦄❤❤🦄

文章来源:https://dev.to/link2twenty/accessibility-first-dropdown-select-3ji5