GPUI Base Select 原语:构建锚定、键盘可导航的选择控件
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
导读
Select是 GPUI Base 提供的一个“行为原语(primitive)”:它以受控根节点(controlled root)的形式,为下拉选择场景提供展开/收起语义、键盘导航与焦点转移、以及完整的可访问性暴露,而把触发按钮、弹层外观与视觉样式完全留给应用层。本文以 website/zh-CN/base/primitives/select.md 为主线,结合gpui-kit仓库内 Base 层的真实实现与示例,讲解如何运行示例、组合Select与Popup、管理受控状态与事件,以及落实可访问性与稳定性细节,帮助你快速把“选择控件”接入自己的设计系统。
Select 是什么:行为与语义,而非视觉
和 GPUI Base 的所有原语一样,Select只提供行为与语义结构,不规定产品视觉语言。它不会替你画一个按钮、也不会决定选项列表长什么样——这些都由你使用 GPUI 的样式系统与导出的部件自由组合,使其符合你的设计系统。
从源码看,Select在 crates/base/src/select.rs 中被定义为一个“未样式化的受控选择根节点”:
Applications own the trigger and popup presentation, the option collection, and the selected value. This root owns combobox accessibility semantics, keyboard opening and dismissal, and focus transfer between the trigger and popup content.
翻译过来即:应用拥有触发按钮、弹层呈现、选项集合与选中值;根节点拥有组合框(combobox)的可访问性语义、键盘打开/关闭行为,以及触发按钮与弹层内容之间的焦点转移。
因此Select的核心职责可以归纳为三件事:
- 暴露组合框语义(
Role::ComboBox、aria_expanded、aria_label、aria_value); - 处理键盘打开、导航与关闭(绑定在
Select键上下文中的动作); - 在 trigger 与 popup content 之间转移焦点。
Select与锚定弹层Popup是配套使用的:Popup(crates/base/src/popup.rs)负责 trigger 测量、锚点计算、首帧同步、延迟渲染(deferred rendering)与窗口边缘吸附;Select负责语义与交互结构。二者组合即是完整的选择控件骨架。
运行官方示例
原生示例与页面上的 WASM 预览共用同一份实现,运行命令:
cargo run -p gpui-base-examples -- select该命令会初始化应用、创建窗口并挂载共享的BaseShowcase状态。同一个 showcase 会被分别编译为原生程序与浏览器(WASM)预览,因此原生与浏览器端看到的是完全一致的实现。
导入
use gpui_kit::base::{Select};Select由 Base 库公开导出(见 crates/base/src/lib.rs 中的pub use select::Select;),因此只需从gpui_kit::base引入即可。示例中的其他组合件(Popup、Combobox)同样来自该命名空间。
结构与 API
示例组合了公开类型:Select(或Combobox)作为根,Popup作为锚定弹层宿主,div与 GPUI 标准样式 trait(Styled、InteractiveElement等)负责表现。GPUI 的标准样式和事件 trait 负责呈现,Base 类型负责交互结构——表现与交互彻底分离。
权威示例实现位于 crates/base/examples/showcase/components/select.rs,原生与浏览器预览编译的是同一文件。
核心构造与构建器方法
Select::new(id)接收一个ElementId,返回无样式根节点。常用构建器方法(均可在 crates/base/src/select.rs 中确认):
| 方法 | 作用 |
|---|---|
open(bool) | 设置应用控制的展开状态 |
disabled(bool) | 禁用键盘与可访问性激活,并将 trigger 移出 Tab 遍历 |
focus_handle(&FocusHandle) | 提供 trigger 的焦点句柄 |
content_focus_handle(&FocusHandle) | 提供展开后接收键盘导航的内容焦点句柄 |
accessibility_label(impl Into<SharedString>) | 设置根节点暴露的可访问名称 |
accessibility_value(impl Into<SharedString>) | 设置根节点暴露的已提交值(可读的选中标题) |
on_open_change(handler) | 处理展开状态变更请求 |
on_dismiss(handler) | 处理任何方式发起的关闭(Cancel 动作或可访问激活) |
on_confirm(handler) | 处理展开状态下收到的 Confirm 动作 |
由于Select实现了Styled、ParentElement与RenderOnce,你可以在其上直接调用.w_56()、.child(trigger)等标准 GPUI 方法,根节点本身也是一个可组合元素。
键盘交互背后的键绑定
Select通过init(cx)在App上注册一组键绑定,键上下文(key context)为"Select"(源码中const CONTEXT: &str = "Select";):
| 按键 | 动作 | 行为 |
|---|---|---|
up | SelectUp | 未展开时请求展开并转移焦点到内容 |
down | SelectDown | 未展开时请求展开并转移焦点到内容 |
enter | Confirm { secondary: false } | 展开时触发确认回调;关闭时改为请求展开 |
secondary-enter | Confirm { secondary: true } | 同上,携带 secondary 标记 |
escape | Cancel | 展开时执行统一关闭路径(见下文) |
这些动作定义于 crates/base/src/actions.rs:Confirm是带secondary: bool负载的结构体动作,SelectUp/SelectDown/Cancel等通过actions!宏注册。注意SelectUp/SelectDown在关闭状态下也会请求展开并转移焦点,这保证键盘用户只需按方向键即可打开列表继续导航。
统一的关闭路径
Select的所有关闭方式(Escape、可访问激活点击)走同一条close闭包:先执行on_dismiss,再调用on_open_change(false)请求关闭,最后把焦点还给 trigger 的focus_handle。源码注释明确说明这样设计的原因:
Every way of closing runs the same steps. A caller that tracks dismissal has to see one however the popup was closed, and the accessible activation closes exactly what Escape closes.
也就是说:无论用户按 Escape 还是通过屏幕阅读器激活关闭,消费方都会收到一致的on_dismiss→ 关闭 → 焦点回移序列。on_dismiss在请求关闭之前执行,因此调用方若在关闭时提交暂存值,仍能在此回调中读到该值。
状态与事件
Select是受控(controlled)组件:应用保存当前值;打开状态、焦点项与选择行为由控件协调。
受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新状态并调用cx.notify(),不要在每次渲染时重建持久 entity。下面是一个典型的状态回路:
- 用户点击 trigger(或按方向键/回车),
Select通过on_open_change(true, ...)请求展开; - 应用在回调中把
select_open置为true并cx.notify()触发重渲染; - 选项点击回调把选中索引写入 entity、把
select_open置为false并cx.notify(); - 重渲染后
Select::open(false),弹层内容随Popup的条件渲染消失。
从测试 crates/base/src/select.rs 中的用例可以印证这一交互协议:
arrows_open_and_transfer_focus_to_content:按下方向键后open == true,且内容焦点句柄获得焦点;confirm_opens_a_closed_select:对关闭状态的Select按回车等价于打开;escape_closes_and_restores_trigger_focus:down escape之后open == false,焦点回到 trigger;every_close_dismisses_before_it_closes:事件序列为open → dismiss → close,验证统一关闭路径;disabled_select_is_not_keyboard_interactive:禁用后键盘动作不再产生任何状态变更;projects_application_owned_accessible_state:启用控件暴露Click激活操作,禁用控件不暴露,且禁用的控件仍如实报告其所处的展开状态。
完整 Rust 示例
以下代码来自 crates/base/examples/showcase/components/select.rs,是可运行 showcase 的真实实现(combobox参数切换Select与Combobox两种根节点):
use super::*; impl BaseShowcase { pub(in super::super) fn select( &self, combobox: bool, cx: &mut Context<Self>, ) -> impl IntoElement { let open = self.select_open; let selected = self.select_index.min(3); let labels = ["GPUI", "React", "SwiftUI", "Vue"]; let entity = cx.entity().downgrade(); let trigger_entity = entity.clone(); let trigger = div() .id("select-trigger") .h_7() .px_2() .text_xs() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0x171717)) .on_click(move |_, _, cx| { _ = trigger_entity.update(cx, |this, cx| { this.select_open = !open; cx.notify(); }); }) .child(labels[selected]) .child(if open { "⌃" } else { "⌄" }); let options = div() .mt_1() .p_1() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .children(labels.into_iter().enumerate().map(|(ix, label)| { let entity = entity.clone(); div() .id(("select-option", ix)) .px_2() .py_1() .flex() .justify_between() .hover(|this| this.bg(super::example_rgb(0xf5f5f5))) .child(label) .when(ix == selected, |this| this.child("✓")) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.select_index = ix; this.select_open = false; cx.notify(); }); }) })); if combobox { let root = Combobox::new("example-combobox") .open(open) .w_56() .child(trigger); Popup::new("example-combobox-options", root) .when(open, |this| this.content(options)) .into_any_element() } else { let root = Select::new("example-select") .open(open) .on_open_change({ let entity = entity.clone(); move |next, _, cx| { _ = entity.update(cx, |this, cx| { this.select_open = next; cx.notify(); }); } }) .accessibility_label("Framework") .w_56() .child(trigger); Popup::new("example-select-options", root) .when(open, |this| this.content(options)) .into_any_element() } } }关键点解读:
- 受控展开:
select_open保存在BaseShowcase(父渲染类型/entity)中,通过Select::open(open)与on_open_change形成双向受控回路; - 组合式弹层:
Popup::new("example-select-options", root).when(open, |this| this.content(options))在展开时才挂载内容;Popup负责锚定与延迟渲染,Select负责语义与键盘; - 选择提交:选项
on_click中写入select_index并关闭弹层,cx.notify()驱动重渲染,勾选标记✓由when(ix == selected, ...)条件渲染; - 焦点管理:若需键盘导航,可再为 trigger 与内容分别提供
FocusHandle,分别传给focus_handle与content_focus_handle,使Select在展开/关闭时自动转移焦点。
可访问性
- 在受控根节点上设置
.accessibility_label(...),并把.accessibility_value(...)设为已提交的选中项,而不是临时的搜索游标; - 根节点会暴露展开状态(
aria_expanded)与可访问的激活操作(AccessibleAction::Click)。激活会请求切换展开状态,并在 trigger 与内容之间移动焦点; - 禁用的控件不暴露激活操作,同时从 Tab 遍历中移除;
- 带样式的
Select(指封装后的组件层)会自动提供已提交的值,未选中时回退到 placeholder。
从源码看,根节点被渲染为Role::ComboBox并设置aria_expanded(open);accessibility_label映射为aria_label,accessibility_value映射为aria_value。禁用状态下track_focus不再挂载(focus_handle.clone().filter(|_| !disabled)),且on_a11y_action(AccessibleAction::Click, ...)只在非禁用时注册——这就是“禁用控件不暴露激活操作”的底层实现。
另外,crates/base/src/select.rs 的文档注释特别提醒:GPUI 会把激活选项标记在选项元素自身而非容器上,因此应用需要自行在自高亮的选项上调用aria_active_descendant(),根节点无法替调用方完成这一点。
注意事项
- 使用稳定元素 ID:
Select::new("example-select")与Popup::new("example-select-options")的 ID 在消费端应保持稳定,以保证状态(如 Popup 的锚点缓存)与测试定位可靠;选项 ID 可使用元组(如("select-option", ix))保持唯一; - 受控状态勿在渲染中重建:把打开状态、选中索引保存在 entity 或稳定的父类型字段中,回调内更新并
cx.notify(); - 在设计系统中验证状态外观:在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果(reduced-motion)和高对比度(high-contrast)状态——这些视觉层职责属于你的设计系统,而非本原语;
- 区分
Select与Combobox:示例同时展示了两种根节点——Select用于纯选择,Combobox(见 crates/base/src/combobox.rs)用于带输入框的组合框场景,按交互需求选用。
延伸阅读
- 原语文档:website/zh-CN/base/primitives/select.md(英文版:website/base/primitives/select.md)
- 核心实现:crates/base/src/select.rs
- 锚定弹层宿主:crates/base/src/popup.rs
- 动作定义:crates/base/src/actions.rs
- 可运行示例:crates/base/examples/showcase/components/select.rs
- 组件层带样式的 Select:crates/component/src/select.rs
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考