ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Remix UI 无头 listbox 原语:受控选中与高亮的完整实现指南

Remix UI 无头 listbox 原语:受控选中与高亮的完整实现指南 Remix UI 无头 listbox 原语受控选中与高亮的完整实现指南【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixlistbox是 Remix UIremix-run/ui包名remix/ui提供的一个无头headless选项列表原语专门用于受控的选中selection与高亮highlighting状态管理。它既可被select、combobox等高层组件复用为底层行为层也可直接用于需要自定义 listbox 标记的任意场景。读完本文你将掌握listbox.Context、listbox.list()、listbox.option()三个核心原语的全部 API、键盘导航与输入搜索typeahead行为以及flashSelection选中闪烁与ListboxRef实时对象等进阶能力并能在自己的组件中直接落地。什么是 listbox 原语在 packages/ui/README.md 的定位中remix-run/ui提供无头headless的一等行为原语覆盖菜单、listbox、popover、select、combobox 等控件。listbox 正是其中之一它不提供任何视觉样式只负责把标准 listbox 的交互行为——角色标记ARIA role、键盘导航、输入搜索、鼠标高亮与点击选中——以可组合 mixin 的形式注入到你的自定义标记中。从源码结构看listbox 是 select 与 combobox 的共同底座packages/ui/src/select/primitives.tsx 直接import * as listbox from ../listbox/index.ts并将listbox.option原样再导出L496packages/ui/src/combobox/index.tsx 复用了 packages/ui/src/shared/listbox-popover-styles.ts 中的listboxListStyle、listboxOptionStyle等共享样式。因此理解 listbox 原语等于理解了 select/combobox 的行为内核。安装与导入路径ui包通过exports映射暴露子路径见 packages/ui/package.jsonnpm i remiximport type { Handle } from remix/ui import * as listbox from remix/ui/listbox import type { ListboxValue } from remix/ui/listbox原语基础用法Primitive Usage原语的使用方式是受控 组合由你在组件闭包中保存value选中值与activeValue高亮值通过listbox.Context向下分发再通过listbox.list()与listbox.option(option)两个 mixin 把行为附加到任意 DOM 元素上。以下代码来自 packages/ui/src/listbox/README.md其中mix是 Remix UI 的组合机制handle.update()通知运行时重渲染import type { Handle } from remix/ui import * as listbox from remix/ui/listbox import type { ListboxValue } from remix/ui/listbox import { listStyle, optionStyle } from ./listbox.styles function FrameworkListbox(handle: Handle) { let value: ListboxValue remix let activeValue: ListboxValue remix return () ( listbox.Context value{value} activeValue{activeValue} onSelect{(nextValue) { value nextValue void handle.update() }} onHighlight{(nextValue) { activeValue nextValue void handle.update() }} div aria-labelFrameworks tabIndex{0} mix{[listStyle, listbox.list()]} {frameworks.map((option) ( div key{option.value} mix{[optionStyle, listbox.option(option)]} {option.label} /div ))} /div /listbox.Context ) } let frameworks [ { label: Remix, value: remix }, { disabled: true, label: React Router, value: react-router }, { label: React, value: react }, { label: Preact, value: preact }, ]三个核心要点的解释value与activeValue是受控状态必须由父组件持有并在回调中更新DOM 上体现的aria-selected、data-highlighted都取决于这两个值listbox.list()挂在容器元素上负责rolelistbox、键盘事件与输入搜索listbox.option({ label, value, ... })挂在每个选项元素上负责注册选项、roleoption、鼠标与点击行为。仓库中的 packages/ui/src/listbox/listbox.demo.tsx 是一个可直接运行的完整演示它在选中后同时更新activeValue并在页面上实时打印value...且包含完整的样式 mixin选中项反色、高亮项浅色背景、禁用项降透明度可作为落地时的样式参考。用 textValue 优化输入搜索当可见标签并不是输入搜索typeahead的最佳匹配字符串时可以为 option 提供textValue。例如选项显示为 Staging但希望用户输入bbeta就能命中它div mix{[ optionStyle, listbox.option({ label: Staging, textValue: beta, value: staging, }), ]} Staging /div从实现看textValue的类型是SearchValue string | string[]packages/ui/src/shared/typeahead.ts因此既可以是单个字符串也可以是一个字符串数组满足任一前缀即命中。匹配时使用option.textValue ?? option.label作为搜索文本packages/ui/src/listbox/index.ts。API 参考remix/ui/listbox导出清单原语的核心实现集中在 packages/ui/src/listbox/index.ts导出如下导出类型说明listbox.Context组件ListboxProvider受控value/activeValue的 provider负责选项注册、选中、高亮、可选 ref 访问、flashSelection、selectionFlashAttribute与onSelectSettledlistbox.list()mixin挂载rolelistbox、默认tabIndex{-1}、键盘导航、焦点滚动与输入搜索高亮listbox.option(options)mixin注册一个选项必填label、value可选disabled、textValue并挂载roleoption、id、选中/禁用/高亮状态、鼠标与点击行为ListboxValue类型选中值或高亮值即string \| nullL17ListboxContext类型provider 上下文包含value、activeValue、registerOption、select、highlight、highlightSearchMatch、navigate、scrollActiveOptionIntoViewListboxProviderProps类型provider 的 propsvalue、activeValue、children、ref、flashSelection、selectionFlashAttribute、onSelect、onSelectSettled、onHighlightListboxOption类型选项输入形状label、value、可选disabled、可选textValue另有自动生成的idListboxRegisteredOption类型已注册选项的元数据包含hidden、node真实 DOM 节点会传给回调与 refListboxRef类型实时 ref 对象暴露 active/selected 选项、导航、搜索匹配、滚动与选中辅助方法ListboxProviderProps 完整签名interface ListboxProviderProps { value: ListboxValue // 受控选中值 activeValue: ListboxValue // 受控高亮值 children?: RemixNode ref?: (ref: ListboxRef) void flashSelection?: boolean // 是否启用选中闪烁默认 false selectionFlashAttribute?: string // 闪烁属性名默认 data-listbox-flash onSelect: (value: ListboxValue, option?: ListboxRegisteredOption) void onSelectSettled?: (value: ListboxValue, option?: ListboxRegisteredOption) void | Promisevoid onHighlight: (value: ListboxValue, option?: ListboxRegisteredOption) void }源码位于 packages/ui/src/listbox/index.ts。注意onSelectSettled是可选回调且可以是异步的——它会在选中流程尘埃落定后被调用。ListboxRef 实时对象ListboxRef在 provider 挂载时通过handle.queueTask(() handle.props.ref?.(ref))只调用一次L154-L156其属性全部是实时读取的getter 形式因此拿到 ref 之后无需重新订阅即可读到最新状态interface ListboxRef { active: ListboxRegisteredOption | undefined // 当前高亮选项按 activeValue 查找 options: ReadonlyArrayListboxRegisteredOption selected: ListboxRegisteredOption | undefined // 当前选中选项按 value 查找 highlight: (value: ListboxValue) void highlightSearchMatch: (text: string) void matchSearchText: (text: string, fromValue?: ListboxValue) ListboxRegisteredOption | null navigateFirst: () void navigateLast: () void navigateNext: () void navigatePrevious: () void scrollActiveOptionIntoView: () void select: (value: ListboxValue) Promisevoid selectActive: () Promisevoid }当activeValue/value不对应任何已注册选项时active/selected返回undefined。select 组件正是借助listboxRef.matchSearchText(text, value)在触发器中实现输入搜索的packages/ui/src/select/primitives.tsx。行为细节Behavior Notes与源码级原理受控模型回调先行DOM 后行选中与高亮都是完全受控的。onSelect与onHighlight只负责通知父组件DOM 状态aria-selected、data-highlighted要等父组件用新值重渲染之后才会更新。这一点在 index.test.tsx 中有直接验证按下方向键后onHighlight被调用一次但选项的data-highlighted仍为false直到父组件重渲染。实现上select是一个带状态机保护的异步方法state从idle进入selecting先调用onSelect若启用闪烁则执行 flash再等待onSelectSettled最后回到idleL171-L191。禁用选项全程被跳过disabled选项被键盘导航、输入搜索、鼠标移动mousemove与点击选中全部跳过。核心判断是isInteractableOption——既要可见node.isConnected且!option.hidden又不能是禁用态L77-L87function isVisibleOption(option) { return !!option?.node?.isConnected !option.hidden } function isInteractableOption(option) { return isVisibleOption(option) !option?.disabled }对禁用选项option()mixin 甚至不会挂载 click/mousemove/mouseleave 事件L339-L351但会写入aria-disabledtrue。测试 L641-L664 验证了禁用选项的 mousemove 与 click 都不会产生任何回调。键盘导航循环、边界与确认list()mixin 的 keydown 处理L267-L294实现了如下映射按键行为实现ArrowDown高亮下一个可用选项到达末尾时循环到第一个navigate(next)取interactableOptions[activeIndex 1] ?? interactableOptions[0]ArrowUp高亮上一个可用选项无高亮时回到最后一个navigate(previous)Home跳到第一个可用选项navigate(first)End跳到最后一个可用选项navigate(last)TabpreventDefault并高亮第一个可用选项navigate(first)注意这里 Tab 被拦截作为快速回到列表头的快捷键Enter/Space选中当前高亮项context.select(context.activeValue)字母键触发输入搜索见下文hiddenTypeahead其中navigate(next)与navigate(previous)会对方向键调用event.preventDefault()测试 L405-L419 断言了这一点并且在可用选项数组上循环——被跳过的禁用项不会占据位置。每次高亮变化后都会调用scrollOptionIntoView把新高亮项滚动到可视区域block: nearest, inline: nearest。箭头键的循环语义在测试中有明确断言连续按三次ArrowDown依次高亮remix → react → preactReact Router禁用从未被高亮L347-L376当没有任何高亮时按ArrowUp会直接高亮最后一个可用项L378-L403。输入搜索typeahead只高亮不选中listbox 内置了隐藏式输入搜索在列表获得焦点时连续键入字符会累积成一个搜索串并高亮下一个匹配的可用选项。它只更新高亮不会选中选项。底层复用 packages/ui/src/shared/typeahead.ts 的hiddenTypeaheadmixin单字符键排除 Ctrl/Alt/Meta 组合键会追加到搜索串并转为小写搜索串在750msHIDDEN_TYPEAHEAD_TIMEOUT内无新输入后自动清空Backspace支持逐字符回退Escape立即清空focusout到列表外部时也会清空搜索串。匹配逻辑matchNextItemBySearchText从当前高亮项之后环形扫描用startsWith前缀匹配搜索文本取自textValue ?? label。测试验证初始高亮remix时按r会高亮下一个以r开头的可用项react且remix仍保持选中、react未被选中L518-L547对textValue: beta的 Staging 选项按b即可命中L549-L574。flashSelection选中闪烁与 onSelectSettledflashSelection是可选行为用于给选中项一个短暂的视觉反馈选中时在选项节点上临时设置selectionFlashAttribute默认data-listbox-flash为true持续60ms后移除闪烁期间会延迟onSelectSettled的触发并且忽略新的高亮/选中交互直到闪烁完成。实现位于 packages/ui/src/listbox/flash-attribute.tsexport async function flashAttribute(node: HTMLElement, attributeName: string, duration: number) { node.setAttribute(attributeName, true) await wait(duration) node.removeAttribute(attributeName) }配合 CSS 即可实现闪烁样式例如[data-listbox-flashtrue] { animation: flash 60ms ease-out; }测试 L726-L782 用假定时器完整验证了该流程按下Enter后onSelect立即触发、data-listbox-flashtrue出现、onSelectSettled尚未触发随后按End或移动鼠标都不会产生新的高亮60ms 后属性被移除、onSelectSettled被调用、aria-selectedtrue生效。焦点与滚动list()mixin 默认设置tabIndex{-1}可被显式tabIndex覆盖并挂载focus事件处理列表获得焦点时自动把当前高亮项滚动进视口context.scrollActiveOptionIntoView()。测试通过 stub 掉scrollIntoView断言了 focus 与方向键导航时的滚动参数均为{ block: nearest, inline: nearest }L421-L448。在 select / combobox 中的实际整合listbox 原语不仅是独立组件更是 select、combobox 的行为内核。在 packages/ui/src/select/primitives.tsx 中SelectOptionProps直接复用了Omitlistbox.ListboxOption, idL73select 内部保存listboxRef并把listbox.Context、listbox.list()、listbox.option应用到自己的下拉面板上L373-L386、L473、L496触发器上的输入搜索通过listboxRef.matchSearchText(text, value)实现L246。这意味着当你理解了本文的 listbox 原语select/combobox 的受控选中、键盘导航、输入搜索行为便不再神秘——它们共享同一套实现。测试验证与可复现实验listbox 的行为契约全部由 packages/ui/src/listbox/index.test.tsx 覆盖主要用例包括ARIA 角色、自动生成 option id、默认tabIndex-1契约受控回调先于 DOM 更新的时序ref 只回调一次、active/selected实时更新、无匹配时返回undefined方向键循环、禁用项跳过、Home/End边界、Tab快速回到首个可用项、Enter/Space 选中typeahead 高亮含textValue匹配与matchSearchText的fromValue起始位置鼠标mousemove/mouseleave/click行为及禁用项忽略flashSelection全流程。在仓库根目录运行pnpm --filter remix-run/ui test见 packages/ui/package.json即可执行该模块的全部测试交互式演示位于 packages/ui/src/listbox/listbox.demo.tsx可通过 ui 包的 demo 环境直接预览。小结remix/ui/listbox以三个原语 一组类型定义了完整的无头 listbox 契约Context负责受控状态与行为调度list()注入列表容器行为option()注入选项行为。它坚持回调先行、DOM 后行的严格受控模型把禁用过滤、循环导航、typeahead、焦点滚动与可选选中闪烁全部内置同时把样式和标记完全交给使用者。无论你是想直接构建自定义下拉列表还是想理解 select/combobox 的行为内核这份实现都是值得精读的参考。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表