网格键盘导航与焦点管理
用电话拨号盘说明行列循环、字符定位、局部键盘事件、roving tabindex 与监听器清理。
网格键盘导航与焦点管理
电话拨号盘看似只是十二个按钮,真正的难点是让坐标、键盘、鼠标与浏览器焦点保持一致。这个模型也适用于固定网格中的工具按钮;菜单、列表框等控件有各自的无障碍交互约定,不能直接套用全部行为。
1. 先明确交互契约
1 2 3
4 5 6
7 8 9
* 0 #
| 输入 | 本题行为 |
|---|---|
| 上/下方向键 | 同一列移动,第一行向上到最后一行,最后一行向下到第一行 |
| 左/右方向键 | 同一行移动,最左向左到最右,最右向右到最左 |
0–9、*、# |
将真实焦点移到对应按钮,不自动拨号 |
| Enter / Space | 保留原生按钮的激活行为,由 click 处理业务动作 |
| Tab / Shift+Tab | 进入当前按钮或离开整组,不形成焦点陷阱 |
| 鼠标点击 | 更新当前按钮,和键盘共享同一个状态 |
示例只处理拨号盘内部的事件。页面其他输入框中的数字、方向键和浏览器组合键不应被拦截。高亮表示“拥有焦点”,不是可多选状态,因此不使用 aria-pressed。
2. 用行列计算循环,避免意外跨行
设三列、四行,线性索引为 index:row = Math.floor(index / 3),column = index % 3。左右只修改列,上下只修改行,再用 row * 3 + column 还原索引。
直接使用 (index - 1 + 12) % 12 会让 4 向左到 3,而题目要求到同一行的 6。JavaScript 的 % 是余数运算,向前移动时先加维度大小,避免负数。
const keypadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
function keypadTarget(index, key) {
if (!Number.isInteger(index) || index < 0 || index >= keypadKeys.length) {
throw new RangeError('invalid keypad index');
}
const direct = keypadKeys.indexOf(key);
if (direct !== -1) return direct;
let row = Math.floor(index / 3);
let column = index % 3;
switch (key) {
case 'ArrowUp': row = (row + 3) % 4; break;
case 'ArrowDown': row = (row + 1) % 4; break;
case 'ArrowLeft': column = (column + 2) % 3; break;
case 'ArrowRight': column = (column + 1) % 3; break;
default: return -1;
}
return row * 3 + column;
}
3. 保留原生按钮与可见焦点
role="group" 仅给按钮组命名,不假装实现完整的 ARIA grid。这里有意把整组设计为一个 Tab 停靠点:只有当前按钮的 tabIndex 为 0,其他按钮为 -1,仍可由脚本聚焦。这称为 roving tabindex。
<div class="dial-pad" role="group" aria-label="电话拨号盘">
<button type="button" value="1">1</button>
<button type="button" value="2">2</button>
<button type="button" value="3">3</button>
<button type="button" value="4">4</button>
<button type="button" value="5">5</button>
<button type="button" value="6">6</button>
<button type="button" value="7">7</button>
<button type="button" value="8">8</button>
<button type="button" value="9">9</button>
<button type="button" value="*" aria-label="星号">*</button>
<button type="button" value="0">0</button>
<button type="button" value="#" aria-label="井号">#</button>
</div>
.dial-pad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: min(100%, 264px);
}
.dial-pad button {
min-width: 0;
min-height: 44px;
aspect-ratio: 1;
border: 1px solid #737373;
border-radius: 6px;
background: #fff;
color: #171717;
font-size: 24px;
}
.dial-pad button:focus {
background: #e0f2fe;
}
.dial-pad button:focus-visible {
outline: 3px solid #0369a1;
outline-offset: 2px;
}
4. 焦点、点击与键盘共用状态
接着上面的 keypadKeys、keypadTarget 定义,挂载控制器。契约是固定的十二个启用按钮,顺序与上面的 HTML 相同;挂载期间不插入、删除或禁用按钮。动态网格需要重新计算坐标并跳过不可用项,不能直接用固定维度取模。
function mountKeypad(root) {
const buttons = [...root.querySelectorAll('button')];
if (buttons.length !== keypadKeys.length || buttons.some((button, index) =>
button.value !== keypadKeys[index] || button.disabled
)) {
throw new Error('expected twelve enabled keypad buttons in row order');
}
const originalTabIndexes = buttons.map(button => button.getAttribute('tabindex'));
function indexOfTarget(target) {
return buttons.indexOf(target?.closest?.('button'));
}
function setActive(index, moveFocus) {
buttons.forEach((button, position) => {
button.tabIndex = position === index ? 0 : -1;
});
if (moveFocus) buttons[index].focus();
}
function onFocusIn(event) {
const index = indexOfTarget(event.target);
if (index !== -1) setActive(index, false);
}
function onClick(event) {
const index = indexOfTarget(event.target);
if (index !== -1) setActive(index, true);
}
function onKeyDown(event) {
if (event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return;
const index = indexOfTarget(event.target);
if (index === -1) return;
const next = keypadTarget(index, event.key);
if (next === -1) return;
event.preventDefault();
setActive(next, true);
}
const focusedIndex = buttons.indexOf(root.ownerDocument.activeElement);
setActive(focusedIndex === -1 ? 0 : focusedIndex, false);
root.addEventListener('focusin', onFocusIn);
root.addEventListener('click', onClick);
root.addEventListener('keydown', onKeyDown);
return function unmountKeypad() {
root.removeEventListener('focusin', onFocusIn);
root.removeEventListener('click', onClick);
root.removeEventListener('keydown', onKeyDown);
buttons.forEach((button, index) => {
const value = originalTabIndexes[index];
if (value === null) button.removeAttribute('tabindex');
else button.setAttribute('tabindex', value);
});
};
}
DOM 就绪后调用 const dispose = mountKeypad(document.querySelector('.dial-pad')),组件卸载时调用 dispose()。同一个根节点只挂载一次;重新挂载前先清理。初始化只设置 Tab 顺序,不抢走搜索框等当前控件的焦点;外部代码聚焦某个按钮时,focusin 同样同步 Tab 停靠点。
不要对其他十一个按钮逐个调用 blur()。直接聚焦目标按钮,浏览器会切换旧焦点;靠 CSS 高亮一个按钮却把真实焦点留在另一个按钮上,会让读屏和键盘操作脱节。event.key 按实际字符匹配,允许用 Shift 输入 *、#;parseInt 会漏掉这两个字符,也不能代替按键集合校验。
5. 验证与追问
- 遍历十二个按钮的四个方向,验证左右不跨行、上下不跨列,每次操作都只留下一个
tabIndex=0。 - 按
0、*、#后核对document.activeElement;点击和外部focus()后再按方向键,起点必须是当前焦点。 - Tab/Shift+Tab 可以离开;Enter/Space 只产生原生激活,不重复手动触发
click()。 - 组外输入框、Ctrl/Meta/Alt 组合键、输入法组合过程中不拦截;仅对已处理的按键调用
preventDefault()。 - 卸载后不再拦截事件并恢复原有 tabindex。多个拨号盘各自独立,不在
document上共用一个索引。
按钮语义与自然焦点顺序复用 HTML 无障碍基础。触摸、跨端事件取消等更广泛的场景见 跨端交互与发布治理。