普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月26日首页

构建无障碍组件之Spinbutton Pattern

作者 anOnion
2026年4月26日 16:58

Spinbutton Pattern 详解:构建无障碍数字输入控件

Spinbutton(旋转按钮,也称为 Number InputStepperNumeric SpinnerCounter)是一种输入控件,用于在预定义范围内选择离散数值。本文基于 W3C WAI-ARIA Spinbutton Pattern 规范,详解如何构建无障碍的数字输入组件。

一、Spinbutton 的定义与核心概念

1.1 什么是 Spinbutton

Spinbutton 是一种受限的数字输入控件,具有以下特征:

  • 值被限制在一组或一个范围内的离散值
  • 通常包含三个组件:
    • 文本输入框:显示当前值,通常是唯一可聚焦的组件
    • 增加按钮:用于增加数值
    • 减少按钮:用于减少数值
  • 支持直接编辑按钮调整两种方式
  • 支持小步长大步长调整

1.2 核心术语

术语 说明
Text Field 显示当前值的文本输入框
Increase Button 增加数值的按钮
Decrease Button 减少数值的按钮
Small Step 小步长调整(如按 1 增减)
Large Step 大步长调整(如按 10 增减)
Valid Value 允许范围内的有效值
┌─────────────────────────────────────────────────────────────┐
│                      Spinbutton Container                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────┐  ┌──────┐  ┌──────┐            │    │
│  │  │                 │  │  ▲   │  │  ▼   │            │    │
│  │  │   Value: 30     │  │  +   │  │  -   │            │    │
│  │  │                 │  │      │  │      │            │    │
│  │  └─────────────────┘  └──────┘  └──────┘            │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  role="spinbutton"                          │    │    │
│  │  │  aria-valuenow="30"                         │    │    │
│  │  │  aria-valuemin="0"                          │    │    │
│  │  │  aria-valuemax="100"                        │    │    │
│  │  │  aria-label="Quantity"                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Keyboard: ↑↓ (±1) | Page Up/Down (±10) | Home/End (Min/Max)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 数量选择器:购物车商品数量、酒店预订人数
  • 时间选择器:小时、分钟选择
  • 日期选择器:日、月、年选择
  • 数值调节:音量控制、亮度调节
  • 评分输入:1-5 星评分

二、WAI-ARIA 角色与属性

2.1 基本角色

Spinbutton 使用 role="spinbutton" 标记。

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="1"
  aria-valuemin="0"
  aria-valuemax="10"
  value="1" />

2.2 必需属性

属性 说明 示例值
role="spinbutton" 标记为旋转按钮角色 -
aria-valuenow 当前值 "1"
aria-valuemin 最小值(如果有) "0"
aria-valuemax 最大值(如果有) "10"
aria-labelaria-labelledby 可访问标签 "数量"

2.3 可选属性

属性 说明 示例值
aria-valuetext 用户友好的值描述 "Monday"
aria-invalid 值是否无效 "true" / "false"

2.4 属性详解

aria-valuetext

aria-valuenow 的值不够友好时,使用 aria-valuetext 提供更易理解的描述:

<!-- 星期选择器:数值 1 显示为 "Monday" -->
<input
  type="text"
  role="spinbutton"
  aria-label="星期"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="7"
  aria-valuetext="Monday"
  value="Monday" />
aria-invalid

当值超出允许范围时,设置 aria-invalid="true"

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="15"
  aria-valuemin="0"
  aria-valuemax="10"
  aria-invalid="true"
  value="15" />

注意:大多数实现会阻止输入无效值,但在某些场景下可能无法完全阻止。

三、键盘交互规范

3.1 基本键盘交互

按键 功能
↑ Up Arrow 增加数值(小步长)
↓ Down Arrow 减少数值(小步长)
Home 设置值为最小值
End 设置值为最大值
Page Up(可选) 增加数值(大步长)
Page Down(可选) 减少数值(大步长)

3.2 文本编辑键盘交互

如果文本框允许直接编辑,还支持以下标准单行文本编辑键:

  • 可打印字符:在文本框中输入字符
  • 光标移动键:左右箭头、Home、End
  • 选择键:Shift + 方向键
  • 文本操作键:复制、粘贴、删除等

重要提示:确保 JavaScript 不干扰浏览器提供的文本编辑功能。

3.3 焦点行为

  • 操作过程中焦点始终保持在文本框
  • 不需要将焦点移到增减按钮上

四、实现方式

4.1 基础 Spinbutton 结构

<div class="spinbutton-container">
  <label for="quantity">数量</label>
  <div class="spinbutton-wrapper">
    <input
      type="text"
      id="quantity"
      class="spinbutton"
      role="spinbutton"
      aria-label="数量"
      aria-valuenow="1"
      aria-valuemin="0"
      aria-valuemax="10"
      value="1" />
    <div class="spinbutton-buttons">
      <button
        type="button"
        class="spinbutton-up"
        aria-label="增加"
        tabindex="-1"></button>
      <button
        type="button"
        class="spinbutton-down"
        aria-label="减少"
        tabindex="-1"></button>
    </div>
  </div>
</div>

4.2 JavaScript 实现

class Spinbutton {
  constructor(element) {
    this.input = element;
    this.min = parseFloat(this.input.getAttribute('aria-valuemin')) || 0;
    this.max = parseFloat(this.input.getAttribute('aria-valuemax')) || 100;
    this.smallStep = 1;
    this.largeStep = 10;

    this.init();
  }

  init() {
    // 键盘事件
    this.input.addEventListener('keydown', this.handleKeyDown.bind(this));

    // 直接编辑
    this.input.addEventListener('change', this.handleChange.bind(this));
    this.input.addEventListener('blur', this.handleBlur.bind(this));

    // 按钮点击
    const container = this.input.closest('.spinbutton-wrapper');
    const upButton = container.querySelector('.spinbutton-up');
    const downButton = container.querySelector('.spinbutton-down');

    if (upButton) {
      upButton.addEventListener('click', () => this.increment(this.smallStep));
    }
    if (downButton) {
      downButton.addEventListener('click', () =>
        this.decrement(this.smallStep),
      );
    }
  }

  handleKeyDown(e) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.increment(this.smallStep);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.decrement(this.smallStep);
        break;
      case 'PageUp':
        e.preventDefault();
        this.increment(this.largeStep);
        break;
      case 'PageDown':
        e.preventDefault();
        this.decrement(this.largeStep);
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(this.min);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(this.max);
        break;
    }
  }

  handleChange() {
    const value = parseFloat(this.input.value);
    if (!isNaN(value)) {
      this.setValue(value);
    }
  }

  handleBlur() {
    // 失去焦点时验证并修正值
    const value = parseFloat(this.input.value);
    if (isNaN(value)) {
      this.setValue(this.min);
    } else {
      this.setValue(value);
    }
  }

  increment(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue + step);
  }

  decrement(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue - step);
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);

    // 更新显示值
    this.input.value = value;

    // 更新有效性状态
    const isValid = value >= this.min && value <= this.max;
    this.input.setAttribute('aria-invalid', !isValid);
  }
}

// 初始化
const spinbuttons = document.querySelectorAll('[role="spinbutton"]');
spinbuttons.forEach((spinbutton) => new Spinbutton(spinbutton));

4.3 带 aria-valuetext 的示例

class WeekdaySpinbutton extends Spinbutton {
  constructor(element) {
    super(element);
    this.weekdays = [
      '',
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    this.smallStep = 1;
    this.largeStep = 1; // 星期没有大步长
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);
    this.input.setAttribute('aria-valuetext', this.weekdays[value]);

    // 显示星期名称
    this.input.value = this.weekdays[value];
  }
}

五、最佳实践

5.1 提供清晰的标签

始终为 Spinbutton 提供描述性的标签:

<!-- 好的示例 -->
<label for="adults">成人数量</label>
<input
  type="text"
  id="adults"
  role="spinbutton"
  aria-label="成人数量"
  ... />

<!-- 不好的示例 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.2 设置合理的范围

根据实际场景设置最小值和最大值:

<!-- 好的示例:酒店预订成人数量 -->
<input
  type="text"
  role="spinbutton"
  aria-label="成人数量"
  aria-valuemin="1"
  aria-valuemax="10"
  ... />

<!-- 不好的示例:没有限制 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.3 使用 aria-valuetext 增强可读性

当数值不够直观时,使用 aria-valuetext

<!-- 好的示例:月份选择 -->
<input
  type="text"
  role="spinbutton"
  aria-label="月份"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="12"
  aria-valuetext="January"
  value="January" />

5.4 验证用户输入

阻止无效字符输入,或在失去焦点时修正值:

// 阻止非数字输入
spinbutton.addEventListener('keypress', (e) => {
  if (!/\d/.test(e.key)) {
    e.preventDefault();
  }
});

// 失去焦点时验证
spinbutton.addEventListener('blur', () => {
  const value = parseInt(spinbutton.value);
  if (isNaN(value) || value < min || value > max) {
    // 修正为有效值
    setValue(Math.max(min, Math.min(max, value || min)));
  }
});

5.5 考虑移动端体验

在移动设备上,考虑使用数字键盘:

<input
  type="number"
  inputmode="numeric"
  pattern="[0-9]*"
  role="spinbutton"
  ... />

5.6 提供视觉反馈

  • 无效值时显示错误状态
  • 焦点状态清晰可见
  • 按钮悬停效果
[role='spinbutton'][aria-invalid='true'] {
  border-color: #ef4444;
  background-color: #fef2f2;
}

[role='spinbutton']:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

六、常见错误

6.1 忘记设置 aria-valuenow

<!-- 错误 -->
<input
  type="text"
  role="spinbutton"
  value="5" />

<!-- 正确 -->
<input
  type="text"
  role="spinbutton"
  aria-valuenow="5"
  value="5" />

6.2 按钮可聚焦

<!-- 错误:按钮不应该可聚焦 -->
<button class="spinbutton-up"></button>

<!-- 正确:按钮设置 tabindex="-1" -->
<button
  class="spinbutton-up"
  tabindex="-1"></button>

6.3 忽略键盘交互

只实现按钮点击,不实现键盘支持(方向键、Home/End)。

6.4 不验证输入值

允许用户输入超出范围的值或无效字符。

七、Spinbutton vs 其他输入控件

7.1 Spinbutton vs Slider

特性 Spinbutton Slider
输入方式 键盘输入 + 按钮 拖拽滑块
适用场景 精确数值、离散值 连续范围、粗略选择
精度 中等
典型用例 数量、时间 音量、亮度

7.2 Spinbutton vs 普通文本输入

特性 Spinbutton 普通文本输入
值限制 有最小/最大值 无限制
步长调整 支持 不支持
辅助技术 读出当前值和范围 只读出文本
典型用例 年龄、评分 姓名、地址

八、总结

构建无障碍的 Spinbutton 组件需要关注:

  1. 正确的角色:使用 role="spinbutton"
  2. 必需的属性aria-valuenowaria-valueminaria-valuemaxaria-label
  3. 可选属性aria-valuetextaria-invalid
  4. 完整的键盘支持:方向键调整、Page Up/Down 大步长、Home/End 快捷键
  5. 直接编辑支持:允许用户直接输入值
  6. 输入验证:阻止无效字符,修正超出范围的值
  7. 清晰的标签:帮助用户理解控件用途
  8. 按钮不可聚焦:只有文本框可聚焦

遵循 W3C Spinbutton Pattern 规范,我们能够创建既实用又无障碍的数字输入控件,为所有用户提供便捷的数值选择体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

昨天以前首页

构建无障碍组件之Window Splitter Pattern

作者 anOnion
2026年4月19日 22:02

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌