前端

Odoo 下拉菜单为什么不是“点一下展开就完了”:Dropdown 嵌套、组行为与导航契约讲透

很多人觉得下拉菜单只是一个按钮加一个列表,但 Odoo `dropdown.js`、`dropdown_nesting.js` 和相关 hooks 说明,真正稳定的下拉系统必须同时处理嵌套层级、兄弟菜单互斥、键盘导航、RTL 行为、触屏 bottom sheet 和 click-away 边界。它本质上是一套微型运行时,而不是一个样式组件。

前端
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多人第一次写下拉菜单时,心里的模型特别简单:

  • 按钮被点一下;
  • 菜单显示出来;
  • 再点外面关闭。

这个模型不是不能用,但只适合最轻的场景。

一旦系统里开始出现:

  • 多层子菜单;
  • 一排相邻菜单;
  • 键盘上下左右导航;
  • 移动端触屏;
  • RTL 界面;
  • 弹层容器与激活元素边界;

你就会发现,下拉菜单早就不是“一个列表显示/隐藏”这么简单。

Odoo 在 dropdown.jsdropdown_hooks.jsdropdown_nesting.jsdropdown_group_hook.js 里的做法,非常适合拿来回答这个问题。

Dropdown 组件可以接收外部 state,也可以内部通过 useDropdownState() 自己创建。

这个 DropdownState 很朴素:

  • isOpen
  • open()
  • close()
  • onOpen
  • onClose

但它的重要性在于:

菜单开关不再只是某个 DOM class 的副作用,而是一个可观察、可组合、可注入的状态对象。

一旦你有了明确状态对象,后面的:

  • 嵌套;
  • 分组;
  • 键盘导航;
  • 手动控制;
  • 外部协同;

才有共同语言。

二、真正的难点不在“展开”,而在“别的菜单怎么反应”

useDropdownNesting() 很精彩。

它内部维护一个 DropdownNestingState,包含:

  • parent
  • children
  • close()
  • closeChildren()
  • closeAllParents()

并且用一根模块级 EventBus 广播:

  • dropdown-opened

这里最妙的不是“谁打开了”,而是打开之后别的 dropdown 如何判断:

  • 我是不是它自己;
  • 我是不是它的父级;
  • 我是不是它的子级;
  • 我们是不是同一个 active element 世界。

这说明官方早就知道,下拉菜单真正棘手的不是某一个菜单自身,而是:

一群菜单同时存在时,谁该保持、谁该让路。

三、active element 边界,是避免“跨弹窗互相误伤”的关键

dropdown_nesting.js 里有一个很容易错过的细节:

  • 会把 uiService.activeElement 记录进当前 dropdown;
  • 只有 active element 语境一致时,菜单之间才互相影响。

这特别重要。

因为在大型 Web Client 里,不同 dialog、不同 overlay、不同局部宿主中都可能存在 dropdown。

如果你只要听见“有别的菜单开了”就立刻全关,很容易出现:

  • A 对话框里的菜单,误关掉 B 对话框里的菜单;
  • 一个局部面板打开子菜单,别处菜单也跟着异常收缩;
  • 调试时觉得像随机 bug,其实是作用域没切开。

Odoo 在这里表达得非常明确:

  • dropdown 的互斥不是全局绝对互斥;
  • 它要服从当前活跃宿主边界。

四、嵌套菜单不是“多画几层”,而是要有关闭契约

DropdownNestingState 提供:

  • closeChildren()
  • closeAllParents()

这说明嵌套菜单不是单纯视觉层级,而是一棵有方向的交互树。

用户在子菜单按 Escape,应该只关当前层还是一路往上关? 左箭头、右箭头在 RTL 下是不是反过来? 切到另一个兄弟菜单时,要不要顺手收掉旧子树?

这些都不是 CSS 能解决的,而是交互契约。

Odoo 把它们放进 nesting 行为层,而不是散落在每个业务菜单里重复写。

五、分组行为解决的是“菜单条”的一致性

useDropdownGroup() 会把当前 dropdown 加入父级 DropdownGroup,并暴露:

  • isInGroup
  • isOpen

配合 handleMouseEnter() 的逻辑,就能实现一个很常见但很容易写烂的体验:

  • 菜单条里已经有一个菜单打开时;
  • 鼠标移到同组另一个菜单;
  • 它应当自动接棒打开。

这类体验用户觉得理所当然,但如果没有 group 层,很容易每个菜单各写各的 hover 逻辑,最后细节不一致。

六、键盘导航不是附属能力,而是主协议之一

Dropdown 内部调用 useNavigation(),并把:

  • Escape
  • ArrowLeft
  • ArrowRight

以及嵌套时的左右逻辑一起纳入。

尤其值得注意的是 RTL 支持:

  • 在 RTL 环境里,左右箭头行为会反转。

这点非常能体现 Odoo 的工程成熟度。

很多组件库说自己支持 RTL,实际上只是把样式镜像一下;但真正完整的 RTL 支持,连键盘语义都要跟着翻。

七、click-away 也不是“document 上挂个点击事件”

dropdown.js 会把 click-away 判断委托给 popover 逻辑,再结合 uiService.getActiveElementOf(target) 与当前 activeEl 比较。

这说明判断“点击外部关闭”时,官方考虑的不只是 DOM 包含关系,还包括:

  • 当前激活元素属于哪个宿主;
  • Shadow DOM 主机怎么处理;
  • 不同浮层上下文怎么隔离。

成熟组件最怕的,就是把 click-away 写得过于天真。

八、移动端 bottom sheet 不是另做一套,而是 Dropdown 的变体

源码里还有一个很务实的判断:

  • 小屏 + 触屏时,可启用 bottomSheet
  • 同一个 Dropdown 逻辑切到 bottom sheet 表现。

这特别聪明。

因为移动端用户需要的通常不是“缩小版桌面下拉”,而是更好点按、更稳定位的底部抽屉式交互。

Odoo 没有把它拆成完全独立组件,而是把它纳入同一套 Dropdown 运行时里,让:

  • 状态;
  • 嵌套;
  • 关闭;
  • 交互契约;

尽量保持一致。

把这些点串起来,你就会发现 Dropdown 真正管理的是一整套小系统:

  • 自身开关状态;
  • 菜单与子菜单树;
  • 与兄弟菜单的互斥关系;
  • 活跃宿主边界;
  • 键盘导航与 RTL 语义;
  • 点击外部关闭;
  • 移动端展示策略。

这已经远远超出“一个按钮 + 一个 ul”的范畴了。

十、总结:稳定下拉菜单靠的不是动画,而是规则清楚

Odoo 的 Dropdown 之所以值得研究,不是因为它动画多炫,而是它把最容易被业务代码重复踩坑的规则都前置到了基础层:

  • 开关状态对象化;
  • 嵌套树显式化;
  • 同组行为统一化;
  • 键盘协议完整化;
  • RTL 与移动端纳入主链路;
  • click-away 服从宿主边界。

所以真正成熟的下拉系统,从来不是“点一下展开就完了”,而是:

任何时候打开、关闭、切层、换方向、换设备,系统都知道谁该响应、谁该关闭、谁该保持。

这才是 Dropdown 在大型 Web Client 里真正值钱的地方。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。