← ✦ UX 交互模式 · 那些有名字的交互细节 / Safe Triangle:斜着滑向子菜单,不要半路关掉它 待审核 4 / 7
safe-triangle · 安全三角形

Safe Triangle:斜着滑向子菜单,不要半路关掉它

多级菜单有个经典 bug:hover 左侧某一项弹出右侧子菜单后,用户想去子菜单里点链接,鼠标自然地斜向移过去——可这条斜线会扫过下面几个父项。天真实现(谁被 hover 就切谁)会在中途立刻切走子菜单,让目标随即消失。Amazon Mega Menu 的解法:用光标当前位置与子菜单朝向用户那条边的上下两角连成一个三角形,只要指针还朝三角内移动,就暂缓切换。把鼠标从父项斜着移向子菜单,对比开 / 关的差别。

判定只用两帧鼠标位置:对子菜单靠近父列表那条边的上角 (upperLeft) 与下角 (lowerLeft),分别算「上一帧→该角」和「当前帧→该角」的斜率。若当前帧两条斜率同时被收拢进上一帧的范围内(上边界斜率变小、下边界斜率变大),说明指针正朝三角内去——这时不切换,只设一个短延迟稍后再判;一旦指针偏离这个锥形(比如直接往下挑别的父项),立刻切换。上图绿色三角即「保护生效」的瞬间。

指向意图判定——两帧斜率,判断是否朝子菜单去:

function towardSubmenu(loc, prev, menu) {
  const ul = { x: menu.left, y: menu.top };     // 朝向用户那条边的上角
  const ll = { x: menu.left, y: menu.bottom };  // 下角
  const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
  return slope(loc, ul) < slope(prev, ul)       // 上边界收拢
      && slope(loc, ll) > slope(prev, ll);      // 下边界收拢
}

function onEnterOtherItem(i) {
  if (towardSubmenu(now, last, submenuRect))
    setTimeout(() => onEnterOtherItem(i), DELAY); // 暂缓
  else
    activate(i);                                  // 立即切
}

它和 Hover Intent 是一对:Hover Intent 解决该不该打开(区分路过与停留),Safe Triangle 解决打开之后该不该关(区分穿堂而过与真的离开)。成熟的菜单组件(Radix UI、Base UI 的 Menu)内置了类似逻辑,但其内核就是上面这个两帧斜率的小判定。

1 · 相关链接