Menu 下拉菜单
点触发元素,在它旁边弹出一列命令:编辑 / 复制 / 删除、切换视图、账户操作。选一项就执行,菜单随即关闭。 要从一组值里选定一个值并显示在控件上用 Select;弹出的是表单或说明卡片用 Popover。 在一块区域上右键弹出的菜单用 ContextMenu,下面这些菜单内容元素两者通用。
触发元素放进 trigger 插槽;菜单项的 end 插槽放快捷键提示,pt-menu-label 与 pt-separator 给项分段。选中一项后菜单关闭。
<pt-menu aria-label="文件">
<pt-button slot="trigger" variant="secondary">文件</pt-button>
<pt-menu-label>文件</pt-menu-label>
<pt-menu-item value="new">
新建
<span slot="end">⌘N</span>
</pt-menu-item>
<pt-menu-item value="save" disabled>
保存
<span slot="end">⌘S</span>
</pt-menu-item>
<pt-separator></pt-separator>
<pt-menu-item value="delete">删除</pt-menu-item>
</pt-menu>type="checkbox" 的项选中时切换 checked;type="radio" 的项放进 type="radio" 的 pt-menu-group,由组的 value 决定选中哪一项。
<pt-menu aria-label="视图">
<pt-button slot="trigger" variant="secondary">视图</pt-button>
<pt-menu-item type="checkbox" checked>状态栏</pt-menu-item>
<pt-menu-item type="checkbox">活动栏</pt-menu-item>
<pt-separator></pt-separator>
<pt-menu-group type="radio" value="comfortable" label="密度">
<pt-menu-item type="radio" value="compact">紧凑</pt-menu-item>
<pt-menu-item type="radio" value="comfortable">默认</pt-menu-item>
</pt-menu-group>
</pt-menu>pt-menu-sub 的 trigger 插槽是这一行的文字,默认插槽是子菜单的内容。指针停留或按 → 打开,← / Esc 回到上一层。
<pt-menu align="start" aria-label="分享">
<pt-button slot="trigger" variant="secondary">分享</pt-button>
<pt-menu-item value="link">复制链接</pt-menu-item>
<pt-menu-sub>
<span slot="trigger">发送给</span>
<pt-menu-item value="email">邮件</pt-menu-item>
<pt-menu-item value="message">消息</pt-menu-item>
</pt-menu-sub>
</pt-menu>组成
| 元素 | 作用 |
|---|---|
pt-menu | 根:trigger 插槽放触发元素,默认插槽放菜单内容 |
pt-menu-item | 一项。type 为 default(命令)/ checkbox(勾选项)/ radio(单选项) |
pt-menu-group | 一组项,可带组标题(label);type="radio" 时管理组内单选项的选中态 |
pt-menu-label | 一行不可选的小标题 |
pt-menu-sub | 子菜单:trigger 插槽是这一行的文字,默认插槽是子菜单的内容,可以再嵌 pt-menu-sub |
pt-separator | 分隔线,直接放进菜单或组里即可(菜单会把它的颜色换成更淡的 bg-secondary,并贯通到面板两边) |
菜单项的 start 插槽放 16px 图标,end 插槽放快捷键提示(⌘K)。没有图标、也不是勾选项的项想与它们的文字对齐, 加 inset(pt-menu-label 与 pt-menu-sub 同样支持)。
选中与拦截
点击一项、或聚焦时按 Enter / Space,该项先发 ptSelect(冒泡,detail 为它的 value), 然后整棵菜单关闭。ptSelect 是可取消的:event.preventDefault() 后菜单保持打开 —— 常用于勾选项, 让用户连续勾好几项再关(对应 Radix 在 onSelect 里 preventDefault())。
在菜单上统一监听,按 detail 分派命令:<PtMenu onPtSelect={…}>(React)、<PtMenu @pt-select="…">(Vue)、 <pt-menu (ptSelect)="…">(Angular)、原生 HTML 里 menu.addEventListener('ptSelect', …)。ptSelect 由菜单项发出、 冒泡上来,pt-menu 自己不发,只是声明了同名事件好让包装层生成绑定 —— 所以每次选择只收到一次,event.target 是那个菜单项, 在菜单上 preventDefault() 同样让菜单保持打开。写在单个 PtMenuItem 上也照样可以。
类型提示:生成的事件类型把
event.target标成pt-menu(Stencil 按声明事件的元素定target类型),运行时它是菜单项。 要读菜单项的属性时写event.target as HTMLPtMenuItemElement;只按detail分派命令则不受影响。
- 勾选项(
type="checkbox"):选中时切换自己的checked并发ptChange(detail为新的checked)。 - 单选项(
type="radio"):放进type="radio"的pt-menu-group,选中态由组按它的value管理; 用户选了另一项时组改写value并发ptChange(detail为新的value)。组登记在受控值清单里, Vue 可以v-model、Angular 可以[(ngModel)]。 - 禁用项(
disabled)可以用方向键停留(读屏能读到它),但不会被选中。
pt-menu-item 与 pt-menu-group 的 ptChange 都不冒泡:两者同名、detail 类型不同(布尔 / 字符串), 勾选项又常常放在组里,冒泡的话组上的 onPtChange 会收到子项的布尔值;组还能经子菜单嵌套,内层的选择会改写外层的绑定。 要在外层统一监听,用捕获阶段(addEventListener('ptChange', fn, true))。
打开、关闭与焦点
- 点触发元素切换。打开后焦点落在菜单面板上,此时按 ↓ 到第一项、↑ 到最后一项。
- 触发元素聚焦时按 ↓ / Enter / Space 打开并聚焦第一项,按 ↑ 打开并聚焦最后一项。
- 菜单里 ↑ ↓ 在项之间移动、到头回绕,Home / End 跳首尾, 按字母跳到以它开头的项。指针悬停在哪一项,焦点就跟到哪一项,键盘接着从那里走。
- 以下操作关闭菜单并发
ptClose,detail.reason分别是:选中一项select、按 Escescape、 在外面按下指针outside、再点一次触发元素trigger、按 Tabtab。 - 关闭时焦点若在菜单里,回到触发元素;点外面关闭时不抢焦点;按 Tab 时焦点照常移到触发元素之后的元素 (Shift+Tab 停在触发元素上)。
ptOpen在面板每次渲染出来之后发;外部把open写成false只关闭,不发ptClose(与 Popover / Modal 一致)。
与其它弹层共用层栈:Modal 里的菜单按 Esc 只关菜单,第二下才关 Modal。
子菜单
- 指针停在子菜单那一行上约 100ms 打开,点击立即打开;触发行聚焦时按 → / Enter / Space 打开并聚焦子菜单的第一项。
- 子菜单里按 ← 或 Esc 只关子菜单,焦点回到那一行。RTL 下左右方向键对调,子菜单开在左侧。
- 选中子菜单里的项,整棵菜单一起关闭。同一层同时只开一个子菜单。
- 指针从那一行斜着移进子菜单时,途中扫过父菜单的其它项不会立刻把子菜单关掉:离开后约 300ms 才关,宽限期内进了子菜单就取消。 已知限制:没有做 Radix 的「安全三角形」判定,慢慢斜移并在别的项上停超过 300ms 时子菜单会先关掉。
位置
placement 是首选方位(默认 bottom),视口放不下时翻到对面;align 是交叉轴对齐(默认 center,与源组件 DropdownMenu 一致),offset 是面板与触发元素的间距(默认 4px)。子菜单固定贴着那一行的行尾一侧、顶边对齐,放不下时翻到另一侧。
面板经 popover API 进顶层:放在 overflow: hidden 的容器、表格单元格或带 transform 的祖先里都不会被裁切。 窄屏下不切换成底部抽屉(与 Select / Popover 不同):菜单项少、面板窄,贴着触发元素弹出在手机上照样好点。
无障碍
按 WAI-ARIA Menu Button 模式:触发元素上由组件维护 aria-haspopup="menu" 与 aria-expanded; 面板 role="menu",用 aria-label 给它一个名称(包装层里属性名是 ariaLabelText)。菜单项宿主本身就是可聚焦的 menuitem / menuitemcheckbox / menuitemradio(后两种带 aria-checked),不进 Tab 序列,由方向键移动。
面板在 pt-menu 的影子树里、触发元素在 light DOM,IDREF 形式的 aria-controls 跨不过影子边界,所以组件不写这个 attribute,而是在支持 ARIA 元素反射的浏览器里把触发元素的 ariaControlsElements 指向面板。 子菜单那一行与子面板在同一棵影子树里,aria-controls / aria-labelledby 照常生效。
API
属性
| 属性 | Attribute | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
align | align | "center" | "end" | "start" | 'center' | 交叉轴上的对齐:top / bottom 时 start 是行首一侧(RTL 下是右),left / right 时 start 是上。 默认 center(源包 DropdownMenuContent 未设 align,取 Radix 的默认值) |
ariaLabelText | aria-label | string | undefined | — | 菜单面板(role="menu")的无障碍名称:原生 HTML 里写 aria-label, React / Vue / Angular 包装层里属性名是 ariaLabelText。 |
offset | offset | number | 4 | 面板与触发元素之间的距离(px) |
open | open | boolean | false | 是否展开。用户交互时组件自己改写;外部直接赋值立即生效,不发 ptClose |
placement | placement | "bottom" | "left" | "right" | "top" | 'bottom' | 相对触发元素的方位。视口放不下时翻到对面,反射的属性仍是这里的首选值 |
事件
| 事件 | detail 类型 | 说明 |
|---|---|---|
ptClose | { reason: PtMenuCloseReason; } | 用户交互关闭后触发,detail 说明是怎么关的。外部把 open 写成 false **不发** **不冒泡**:弹层常互相嵌套(弹窗里的菜单、气泡卡片里的下拉),冒泡的话外层上的监听会收到内层的开关。 |
ptOpen | void | 面板渲染出来之后发(交互或属性打开都发,与 pt-popover 一致) **不冒泡**:弹层常互相嵌套(弹窗里的菜单、气泡卡片里的下拉),冒泡的话外层上的监听会收到内层的开关。 |
ptSelect | string | undefined | 某个菜单项被选中,detail 为它的 `value`,`event.target` 是那个菜单项。 这是 `pt-menu-item` 发出、冒泡上来的事件,本元素**自己不发**;声明在这里只为让 React / Vue / Angular 包装层生成绑定(`onPtSelect` / `@pt-select` / `(ptSelect)`),可以在菜单上统一监听,不必写在每一项上。 在这里 `preventDefault()` 同样阻止菜单关闭(菜单项在事件派发完之后才判断要不要关)。 静态类型的限制:Stencil 生成的 `PtMenuCustomEvent` 把 `target` 标成声明事件的元素(本元素),运行时却是菜单项; 要读菜单项时写 `event.target as HTMLPtMenuItemElement`。只按 `detail` 分派命令则无需关心。 |
插槽
| 名称 | 说明 |
|---|---|
(默认) | 菜单内容:`pt-menu-item`、`pt-menu-group`、`pt-menu-label`、`pt-menu-sub`、`pt-separator` |
trigger | 触发元素,点击切换展开(通常是 `pt-button` 或 `<button>`) |
可定制的内部元素(::part())
| part | 说明 |
|---|---|
panel | 菜单面板(role="menu") |
pt-menu-item
属性
| 属性 | Attribute | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
checked | checked | boolean | false | 是否勾选,checkbox / radio 型才有意义。checkbox 型选中时组件自己切换它并发 `ptChange`; radio 型由所属 `pt-menu-group` 按它的 `value` 回写,不要手动设置 |
disabled | disabled | boolean | false | 禁用:方向键仍可停留(读屏能读到它),但点击、Enter / Space 都不会选中 |
inset | inset | boolean | false | 行首缩进 32px,与带勾选指示位的项、带图标的项对齐文字 |
type | type | "checkbox" | "default" | "radio" | 'default' | 类型:普通项 / 勾选项 / 单选项(单选项放在 `type="radio"` 的 pt-menu-group 里) |
value | value | string | undefined | — | 这一项的值:`ptSelect` 的 detail;radio 型时与所属组的 `value` 比较决定是否选中 |
事件
| 事件 | detail 类型 | 说明 |
|---|---|---|
ptChange | boolean | checkbox 型被用户切换后触发,detail 为新的 `checked`。外部改 `checked` 不发。 **不冒泡**(与其它组件的默认相反):`pt-menu-group` 也有同名的 `ptChange`(type="radio" 时,detail 是字符串), 勾选项常常就放在组里。冒泡的话组上的 `onPtChange`(React)/ `@pt-change`(Vue)会收到子项的布尔值, 双向绑定挂在组上时还会被写坏。要在外层统一监听,用捕获阶段(`addEventListener('ptChange', fn, true)`)。 |
ptSelect | string | undefined | 选中时触发(点击,或聚焦时按 Enter / Space),detail 为 `value`。冒泡:pt-menu / pt-context-menu 声明了同名事件,可以在菜单上统一监听(框架包装层里同样能绑)。 可取消:`event.preventDefault()` 后菜单不关闭(勾选项仍会切换)。 |
插槽
| 名称 | 说明 |
|---|---|
(默认) | 菜单项文字 |
end | 行尾的快捷键提示,如 `⌘K` |
start | 文字前的图标(16px) |
可定制的内部元素(::part())
| part | 说明 |
|---|---|
base | 菜单项这一行 |
indicator | checkbox / radio 型的勾选指示位 |
pt-menu-group
属性
| 属性 | Attribute | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
label | label | string | undefined | — | 组标题(样式同 `pt-menu-label`),同时是这一组的无障碍名称。不给时不渲染标题行 |
type | type | "default" | "radio" | 'default' | 类型:`default` 只是分组;`radio` 时组内的单选项按 `value` 互斥选中 |
value | value | string | '' | `type="radio"` 时选中项的 `value`。用户选中时组件自己改写它并发 `ptChange`;外部直接赋值不发 |
事件
| 事件 | detail 类型 | 说明 |
|---|---|---|
ptChange | string | `type="radio"` 时用户选中了另一项后触发,detail 为新的 `value`。外部改 `value`、再选一次已选中的项都不发。 **不冒泡**(与其它组件的默认相反):组能经子菜单嵌套(组 → pt-menu-sub → 另一个组),而三个框架的双向绑定 都挂在宿主上 —— Vue 的 v-model 只按 tagName 过滤冒泡事件,内外层同是 PT-MENU-GROUP 拦不住, 冒泡的话选中内层的项会连带改写外层绑定。要在外层统一监听,用捕获阶段(`addEventListener('ptChange', fn, true)`)。 |
方法
| 方法 | 说明 |
|---|---|
refresh() => Promise<void> | 按 `value` 重新同步单选项的选中态。组里的项增删、改 value 时组件自己会调,一般不需要手动调用 |
插槽
| 名称 | 说明 |
|---|---|
(默认) | 这一组的菜单项(可夹 `pt-separator`) |
可定制的内部元素(::part())
| part | 说明 |
|---|---|
base | 分组容器(role="group") |
label | 组标题 |
pt-menu-label
属性
| 属性 | Attribute | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
inset | inset | boolean | false | 行首缩进 32px,与带勾选指示位或 `inset` 的菜单项文字对齐 |
插槽
| 名称 | 说明 |
|---|---|
(默认) | 标题文字 |
可定制的内部元素(::part())
| part | 说明 |
|---|---|
base | 标题行 |
pt-menu-sub
属性
| 属性 | Attribute | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
disabled | disabled | boolean | false | 禁用:触发行仍可用方向键停留,但不会打开子菜单 |
inset | inset | boolean | false | 触发行行首缩进 32px,与带勾选指示位或 `inset` 的菜单项文字对齐 |
open | open | boolean | false | 子菜单是否展开。由菜单在交互时维护(外层菜单关闭时一并收起),一般不需要手动设置 |
插槽
| 名称 | 说明 |
|---|---|
(默认) | 子菜单的内容 |
trigger | 触发行的文字(可带图标) |
可定制的内部元素(::part())
| part | 说明 |
|---|---|
chevron | 触发行尾部的箭头 |
panel | 子菜单面板(role="menu") |
trigger | 触发行(role="menuitem") |