跳到主要内容

组件约 10 分钟

焦点与键盘导航

tab 顺序、键盘事件的派发路径、默认动作的取消方式,以及命令式聚焦 API。

焦点是一个原生 GPUI 概念。GPUIX 会把稳定的 React 元素 ID 连接到持久的 gpui::FocusHandle 值上,因此焦点在 React 重新渲染后依然保留:

React <div tabIndex={0}>
            │
            ▼
Retained element ID ► persistent gpui::FocusHandle ► keyboard/action dispatch
            ▲
            │
      React rerenders

<input> 与 <textarea> 会自动成为 tab 停靠点。当一个 div 需要参与显式的焦点遍历时,给它加上 tabIndex:

<div
  tabIndex={0}
  onFocus={() => setActive(true)}
  onBlur={() => setActive(false)}
  onKeyDown={(event) => {
    if (event.key === 'enter') submit()
  }}
>
  Submit
</div>
属性 行为
tabIndex={0} 加入正常的焦点遍历顺序
tabIndex={n} 使用 n 作为它的 GPUI tab 顺序索引
tabIndex={-1} 被焦点遍历跳过,但可通过点击或 renderer API 获得焦点
autoFocus 在它的原生焦点句柄被创建时获取一次焦点

元素键盘回调

onKeyDown 会先为获得焦点的元素触发,然后沿着 GPUI 的焦点派发路径,为声明了 onKeyDown 的祖先元素触发。onKeyUp 在按键释放时沿着同样的路径触发。添加这两个回调中的任意一个都会创建该元素的原生焦点句柄。

<div
  autoFocus
  tabIndex={0}
  onKeyDown={(event) => {
    console.log(event.key, event.keyChar, event.modifiers, event.isHeld)
  }}
  onKeyUp={(event) => {
    console.log(`${event.key} released`)
  }}
>
  Focused target
</div>

GPUI 会在原始键盘回调之前派发匹配的按键动作(key action)。如果一个动作消费了那个键,onKeyDown 就不会触发。

Tab 默认移动焦点

Tab 与 Shift+Tab 会沿 tab 顺序移动焦点,就像浏览器一样。该默认行为在该按键的每一个 onKeyDown 处理器之后运行,因此任何处理器都可以取消它:

<div
  tabIndex={0}
  onKeyDown={(event) => {
    if (event.key !== 'tab') return
    event.preventDefault() // 这个 Tab 停留在此处
    insertIndent()
  }}
/>

按键事件的工作方式类似于一个冒泡到 window 的 DOM 事件:

调用 效果
event.preventDefault() 取消默认行为。Tab 不移动焦点
event.stopPropagation() 跳过祖先的 onKeyDown 与 window 的 onKeyDown。默认行为仍然运行
event.defaultPrevented 当该按键更早的处理器已阻止它时为真
keystroke ► GPUI actions ► element onKeyDown (focused → ancestors) ► render({ onKeyDown }) ► default
                                   preventDefault() anywhere here cancels ─────────────────────┘

Tab 也永远不会向 <input> 或 <textarea> 中输入一个 tab 字符,同样与浏览器一致。一个想要输入 tab 的编辑器会调用 preventDefault() 并自行插入它。

用 tabNavigation: false 为整个窗口关闭该默认行为:

render(<App />, { tabNavigation: false })

渲染器键盘回调

把 onKeyDown 或 onKeyUp 传给 render(),即可获得一个窗口级监听器。它会在元素回调之后、针对那些没有被任何 GPUI 动作消费的原始按键触发,并且位于 Tab 默认行为之前。它以 renderer 作为第二个参数:

render(<App />, {
  onKeyDown(event, renderer) {
    if (event.key === 'k' && event.modifiers?.cmd) openPalette()
  },
})

命令式焦点

focusNext() 与 focusPrevious() 直接映射到 GPUI 的 window.focus_next() 与 window.focus_prev()。focusNextWithin(id) / focusPreviousWithin(id) 在该子树内部包裹遍历。getFocusedElementId() 返回宿主 id,或 null。在 WebGPU 打开期间发出的浏览器焦点请求会被排队,并在首个焦点句柄存在之后应用。如果在那次渲染之前到达了多个请求,以最新的请求为准。

使用 ref 来进行命令式聚焦:

const buttonRef = useRef<{ id: number }>(null)

function focusButton() {
  if (buttonRef.current) renderer.focusElement(buttonRef.current.id)
}

<div ref={buttonRef} tabIndex={-1}>Focused on demand</div>

添加 onKeyDown、onKeyUp、onFocus 或 onBlur 会创建一个持久的焦点句柄。当元素必须通过焦点遍历可达时,同样要加上 tabIndex。移除 tabIndex 会把该元素从那个顺序中删除。

在自定义面板内捕获 Tab

Dialog.Popup 已经做了这件事。对于你自己的面板,请阻止默认的 Tab,然后用 focusNextWithin / focusPreviousWithin 在其内部包裹遍历。

function onKeyDown(event: KeyEvent) {
  if (event.key !== 'tab' || !panel) return
  event.preventDefault()
  if (event.modifiers?.shift) renderer.focusPreviousWithin?.(panel.id)
  else renderer.focusNextWithin?.(panel.id)
}

<div ref={setPanel} onKeyDown={onKeyDown}>
  <div tabIndex={0} autoFocus>Ok</div>
  <div tabIndex={0}>Cancel</div>
</div>

焦点样式

GPUIX 的默认行为是:当某个控件拥有键盘焦点时,其余可聚焦元素变暗。你可以用 focusVisible 覆盖这一点,详见支持的样式。