# useMenuPress

## 使用方法

菜单表面的按压模型，鼠标和触摸都适用。从某一行开始的按压会被接管：按住手指时高亮跟着手指走，**松开**时手指下面是哪一行就由哪一行执行——替它派发一次 click，带上松开时的修饰键。触摸结束时浏览器补发的那次 click（目标是手指落下的那一行）会被吞掉，所以不会执行两次。指针按 `pointerId` 在文档层面跟踪，所以手指从落下那一行滑走之后仍然跟着走。鼠标按下时高亮就停在落下的那一行，由那一行自己的 click 执行；鼠标在菜单外松开时调用 `onDismiss`；手指在菜单外松开则让菜单继续开着；`pointercancel` 或者第二根手指按下，这次手势就以什么都不执行结束。

`ContextMenu` 已经挂上了。只有自己搭类似菜单的表面时才需要直接用它：把菜单根的 ref 传进来，菜单打开时启用，把 `menuProps` 摊到根上。`onPointerDown` 是同一个处理函数，不好摊对象时单独绑它。

行就是根里面可用的 `[role="menuitem"]`、`[role="menuitemradio"]`、`[role="menuitemcheckbox"]` 元素，层边界是 `[role="menu"]`。想用别的选择器挑行就传 `itemSelector`；不想用 DOM 焦点表示高亮就传 `onHighlight`；想自己执行那一行就传 `onActivate`。`resetMenuPress()` 用来清掉已经布好的「吞掉多余 click」，卸载和测试里用。

触发器那一侧是可选能力：给它 `triggerRef` 和 `onTriggerPress`，把 `triggerProps` 摊到触发器上，主键一按下就开，手指要按满 `longPressDelayMs` 才开；右键不碰，它自己的 `contextmenu` 照旧能用。两种都能一路滑进菜单里：手指下面那一行跟着高亮，松开时手指下面是哪一行就由哪一行执行。结束这次手势的那次 click 用 `isTriggerClickFromPress()` 问出来，触发器自己的 click 就不会再切换或重开。`triggerProps.onContextMenu` 只管挡掉手指长按时浏览器自带的那套菜单，鼠标右键仍然交给应用自己处理。

```ts
import { useMenuPress } from '@astryx-vue/core'
```

## 建议

| 指引 | 实践 |
| --- | --- |
| 推荐 | 传的选择器只挑可用的行，这样指针压在分割线、标题或禁用行上时什么都不挑，而不是亮一下。 |
| 推荐 | 让高亮就是 DOM 焦点：hook 会把指针下面那一行聚焦（`preventScroll`）。当前行用 `:focus` 画出来。只有列表要把焦点留在组合框上时，才自己传 `onHighlight`。 |
| 推荐 | 在菜单根上声明 `touch-action`：行放得下时用 `pinch-zoom`，会滚动时用 `pan-y pinch-zoom`，让浏览器（而不是 hook）判断手指是不是在滚动，双指捏合也仍然能缩放页面。 |
| 避免 | 又在自己行的 `pointerdown` 或 `pointerup` 里执行一次；hook 已经按松开位置执行那一行，两条路会执行两遍。 |
| 避免 | 把 `detail` 为 0 的行 click 当成键盘触发；hook 派发的指针激活 `detail` 也是 0。派发出来的 click 不是可信事件，用 `event.isTrusted` 区分。 |

## 参数

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `options` | `MenuPressOptions` | — | 配置对象。必填。 |
| `options.menuRef` | `Ref` | — | 带 `role="menu"` 的菜单根，也就是按压要从哪些行里挑的表面。必填。 |
| `options.isEnabled` | `MaybeRefOrGetter<boolean>` | `true` | 这套按压模型当前是否生效，每次按下时读。传菜单的打开状态即可。 |
| `options.onDismiss` | `() => void` | — | 鼠标在菜单外松开、且什么都没执行时调用：关闭菜单。手指在菜单外松开则让菜单继续开着，那种情况不会调用它。 |
| `options.itemSelector` | `string` | 可用的 `[role="menuitem"]`、`menuitemradio`、`menuitemcheckbox` | 只匹配可用行的选择器。指针压在菜单里别的东西上（分割线、标题、禁用行）就什么都不挑。挑出来的行仍然得落在 `[role="menu"]` 边界内。 |
| `options.triggerRef` | `Ref` | — | 打开菜单的那个控件：按压可以从它开始。 |
| `options.onTriggerPress` | `(pointerType: MenuPressPointerType) => boolean` | — | 鼠标按下了触发器，或者手指在触发器上按满了 `longPressDelayMs`：在按住的指针下面打开菜单，并返回是否打开了。这一次按压其实是关掉已开的菜单时返回 false。 |
| `options.onHighlight` | `(row: HTMLElement \| null) => void` | — | 挪动高亮。不传就把 DOM 焦点用 `preventScroll` 挪到那一行。靠 `aria-activedescendant` 表示高亮的列表自己传。 |
| `options.onActivate` | `(row: HTMLElement, release: PointerEvent) => void` | — | 执行松开时指针下面的那一行。不传就替它派发一次 click，带上松开时的按钮和修饰键。 |
| `options.getScroller` | `() => HTMLElement \| null` | 溢出的菜单根 | 按住时指针停在上下边缘 24 像素内要自动滚动的元素。返回 null 表示不滚。 |
| `options.hitTest` | `(x: number, y: number) => Element \| null` | `document.elementFromPoint` | 按视口坐标找元素。手指的事件一直发给它落下的元素，所以靠坐标判断现在在哪一行。测试里用来替换。 |
| `options.longPressDelayMs` | `number` | `500` | 手指在触发器上要按多久，菜单才在手指下面打开。 |

## 返回值

| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `menuProps` | `{ onPointerdown, 'data-astryx-menu-press': '' }` | 摊到菜单根上（`v-bind="press.menuProps"`）。接下从根内开始的按压，并在根上标记「这里用了这套按压模型」。 |
| `onPointerDown` | `(event: PointerEvent) => void` | 和 `menuProps.onPointerdown` 是同一个函数，单独拿出来。不好摊对象时直接绑到菜单根的 `pointerdown` 上。 |
| `triggerProps` | `{ onPointerdown, onContextmenu }` | 摊到触发器上：主键一按下就打开菜单；手指按满延时之后，手指还按着的时候打开。右键不碰，它自己的 `contextmenu` 照旧能用。`contextmenu` 那个只挡掉手指长按时浏览器自带的菜单，鼠标右键仍然交给应用。 |
| `isTriggerClickFromPress` | `() => boolean` | 到达触发器的这次 click 是不是就是刚按下它的那一次手势。那次按压已经开或关过菜单了，所以这次 click 不能再切换、也不能再打开。 |
| `cancel` | `() => void` | 结束正在进行的这次手势，什么都不执行。菜单在手势中途关掉时用。 |

模块还导出：

- `resetMenuPress()`：清掉已经布好的「吞掉多余 click」。表面在 hook 自己的拆卸之外被移除时调用它，测试里也用得上。
- `isMenuPressActivation()`：按压模型派发的那次行激活正在同步进行时为 true。把激活转交给里面链接的行用它分清这是模型的 click 还是浏览器多出来的那个。
- `MENU_PRESS_MARKER`：菜单根上的标记属性名。
- `menuPressStep`、`MENU_PRESS_SETTLE_MS` 和相关类型：不碰 DOM 的纯状态机。打开菜单的那次按压，只有指针进过菜单、或者按满 300 毫秒，松开时才执行下面那一行；否则菜单留着、什么都不执行。
