Astryx ContextMenu 组件合同深度解读:光标定位、双呈现模式与主题所有权边界
【免费下载链接】astryxAn open source design system that's fully customizable and agent ready项目地址: https://gitcode.com/GitHub_Trending/as/astryx
导读
本文围绕 Astryx 设计系统中 ContextMenu 组件合同 展开,剖析这个在光标处弹出的右键菜单组件如何同时承担指针态(Pointer)与触控态(Touch)两种呈现、如何与 DropdownMenu / BottomSheet / List 三个子组件划分职责、以及它的可主题化(theming)边界落在哪里。读完本文,你将理解 ContextMenu 的完整行为契约(FR1–FR4)、允许变化的维度(AV1–AV3)、底层"零尺寸光标锚点"定位原理,以及如何用items数据驱动或menuContent复合组件两种模式构建实战菜单。
一、这份文档是什么:draft 状态的组件合同
ContextMenu.spec.md是 Astryx 知识库(schema_version 3)中的一份component类型合同记录,其 frontmatter 明确标注:
- id:
component:ContextMenu - authority:
draft(草案状态,记录当前事实而非新决策) - owners:
cixzhang - review_triggers:
theming - families:
family:overlay-dismissal - 架构关联:
component-theming-surface、interaction-modality、layer-runtime三份架构记录 - verified_by: ContextMenu.test.tsx、DropdownMenu.test.tsx、BottomSheet.test.tsx、List.test.tsx 以及 scripts/check-knowledge.mjs
该文档遵循 组件合同模板 的结构,属于"知识记录"而非消费者 API 文档:它不重复 prop 表格与代码示例(那些归 ContextMenu.doc.mjs 所有),只记录组件当前的语义契约、呈现所有权边界与主题化可达性。
二、Intent:一个触发器、两条呈现路径
合同在Intent中给出了组件的系统级职责:
ContextMenu 为一个调用方提供的区域(caller-provided region)呈现操作项,形态要么是光标定位的指针菜单(cursor-positioned pointer menu),要么是BottomSheet 承载的触控操作列表(touch action list)。
也就是说,ContextMenu 不是"只有一个弹层的右键菜单",而是一个**双呈现(dual presentation)**组件:
| 呈现路径 | 承载表面 | 面向输入方式 |
|---|---|---|
| Pointer 菜单 | 光标处弹层(popover) | 右键 / 键盘 context-menu 调用 |
| Touch 菜单 | BottomSheet 动作列表 | 长按(long-press) |
源码 ContextMenu.tsx 中这两条路径真实存在:指针路径通过layer.render(renderedMenu, ...)渲染role="menu"的弹层,触控路径则通过懒加载的LazyMenuBottomSheet(Suspense包裹)渲染 BottomSheet(见 ContextMenu.tsx#L672-L687)。
该合同明确声明:它只是记录现状,不改变任何运行时行为、样式、目标或公共 API——这是理解全文的基调。
三、兼容性与迁移承诺
文档记录了四行兼容性结论:
- Released default preserved:
yes - Compatibility class: 仅增量文档(additive documentation only),运行时、DOM、样式、目标、公共 API 均不变
- Controlled/uncontrolled behavior: 不变
- Migration decision: 无
消费者迁移指引属于消费者文档和发布说明的职责,本文件不越界。
四、所有权边界:Owns 与 Does not own
所有权是这份合同最核心的内容之一,它精确划定了"ContextMenu 自己管什么、把什么委托给谁":
Owns(自己拥有)
- Trigger 区域的 context-menu 调用接线(invocation wiring)
- Pointer 菜单表面与 Touch 菜单表面(两个备选呈现)及其共享的当前
context-menu主题目标 - 光标锚点(cursor-anchor)放置,以及两条呈现路径间的打开状态协调
Does not own / non-goals(不拥有/非目标)
- 调用方提供的 Trigger 区域内容
- Pointer 操作行(action row)呈现 ——委托给
component:DropdownMenu - Touch 面板框架(sheet frame)——委托给
component:BottomSheet - 数据驱动的 Touch 操作列表与行呈现 ——委托给
component:List - 共享的 Layer 生命周期与关闭策略
这个边界与源码一一对应:ContextMenu.tsx导入并使用renderDropdownItems、DropdownMenuContext、MenuBottomSheetActionList、LazyMenuBottomSheet、useLayer,但 ContextMenu 自身不实现行组件、面板框架或 Layer 生命周期,全部复用 DropdownMenu、BottomSheet、List 三个既有组件。
为什么这样划分?
从 公共组件 API 架构 的视角看,这种划分遵循INV5 — Composition preserves ownership(组合保持所有权):由共享 Astryx 原语渲染的部分必须委托给该原语的目标,除非父组件承诺一个不同的公共视觉契约。ContextMenu 复用 DropdownMenu 的行渲染与键盘导航、复用 BottomSheet 的面板机制,因此这些部分的主题目标仍归各自组件所有,ContextMenu 只对真正属于自己的"菜单表面"承担主题责任。
五、行为与布局合同:FR1–FR4
文档给出四条候选不变量(Candidate invariant),每一条都标注了 Basis(依据)与 Draft review state(当前行为已验证、无新行为决策):
| ID | 候选不变量 | 依据 | 审查状态 |
|---|---|---|---|
| FR1 | 每次渲染都包含调用方提供的 Trigger 区域。指针呈现时在光标锚点处打开 Pointer 菜单表面;触控呈现时打开包含 Touch 菜单表面、以及数据模式下 Touch 操作列表与操作行的 Touch 面板框架 | 当前源码、文档与测试 | 已验证当前行为;无新行为决策 |
| FR2 | 当前context-menu目标保持在已绘制的 Pointer 菜单表面与备选 Touch 菜单表面之上,而非 Trigger 区域或光标锚点上 | 当前源码、文档与测试 | 已验证当前清单;无目标变更 |
| FR3 | Pointer 操作行保留 DropdownMenu 所有权;触控面板框架保留 BottomSheet 所有权;数据驱动的触控列表与行保留 List 所有权 | 当前源码与所有者文档 | 已验证当前委托;无所有权变更 |
| FR4 | 复合menuContent仍是调用方提供的指针菜单内部内容,也可在触控面板内渲染;数据驱动触控路径则将同一份条目数据转换为 List 与 ListItem 呈现 | 当前源码与测试 | 已验证当前分支;无行为变更 |
FR2 的源码证据:菜单表面的themeProps('context-menu')出现在renderedMenu的 div 上(ContextMenu.tsx#L586),触控分支同样在renderedBottomSheetContent上打themeProps('context-menu')(ContextMenu.tsx#L604)。而 Trigger 包装 div 与零尺寸光标锚点(<span aria-hidden="true">)都不携带该目标,这正符合 FR2 的"目标不在 Trigger/锚点上"要求。
FR4 的源码证据:resolvedMenuContent的分支逻辑(ContextMenu.tsx#L572-L573)——当itemsProp !== undefined时用renderDropdownItems(items)转换数据;否则直接渲染调用方传入的menuContentJSX。而触控数据路径使用MenuBottomSheetActionList把同一份 items 变成 List 风格的操作列表(ContextMenu.tsx#L633-L637)。
允许的变化(Allowed variation)
- AV1 — Invocation(调用方式):右键、键盘 context-menu 调用、长按都可能打开当前解析出的呈现,而不改变解剖学所有权。
- AV2 — Presentation(呈现策略):
popover、bottom-sheet与解析后的adaptive呈现可以选择当前的指针或触控解剖学。 - AV3 — Content mode(内容模式):数据驱动的指针内容通过 DropdownMenu 辅助函数渲染;复合内容由调用方自行组合。数据驱动的触控内容通过 List 渲染。
代表性状态(Representative states)
| 状态 | 必需不变量 | 允许变化 |
|---|---|---|
| Pointer data menu | Trigger 区域打开 Pointer 菜单表面,行由 DropdownMenu 拥有 | 光标位置、行内容、宽度、嵌套 flyout 可变化 |
| Pointer compound menu | Trigger 区域在调用方提供的菜单内容周围打开同一表面 | 调用方选择受支持的 DropdownMenu 内部组件 |
| Touch data menu | Trigger 区域打开 Touch 面板框架与 Touch 菜单表面,其中包含 List 拥有的操作 | 标题、分区、分隔线、图标、钻取深度可变化 |
| Touch compound menu | Trigger 区域围绕 ContextMenu 表面与调用方提供的内部内容打开 Touch 面板框架 | 调用方内容自行负责其结构 |
| Disabled trigger | Trigger 区域保持渲染,ContextMenu 不抑制原生浏览器 context menu | 调用方提供的内容不变 |
变换与优先级 / 性能
文档明确:没有引入任何新的呈现解析、光标位置、长按、条目渲染或样式优先级规则;也没有引入任何新的加载、监听器、测量或渲染约束。这是一个纯记录性(additive)合同。
六、源码级原理:光标锚点定位(为什么菜单"跟着内容走")
ContextMenu 最精巧的实现细节是光标锚点。源码顶部注释(ContextMenu.tsx#L11-L17)完整解释了设计意图:
光标点被捕获为Trigger 内部的偏移,并被物化为一个零尺寸锚点元素,因此菜单是相对于 Trigger 的上下文(通过 CSS anchor positioning)定位的,而不是相对于视口。菜单随内容滚动,并在视口边缘自动翻转,同时仍出现在光标下方。
具体实现链路:
- Trigger 样式(ContextMenu.tsx#L102-L107):
position: relative为绝对定位的锚点建立包含块;WebkitTouchCallout: none/userSelect: none抑制 iOS 长按的原生文本/呼出 UI。 - 零尺寸锚点(ContextMenu.tsx#L112-L117):
position: absolute; width: 0; height: 0; pointerEvents: none,渲染为<span aria-hidden="true">并合并layer.ref。 - 坐标换算(
openAtLocalPoint,ContextMenu.tsx#L496-L518):把视口光标坐标转换成 Trigger 本地坐标写入positionRef,再设置锚点style.left/top。 - CSS 锚定:锚点携带由
useLayer生成的anchor-name(测试断言anchor.style.anchorName匹配/^--astryx-layer-/),弹层通过position-anchor与其关联,获得滚动跟随与边缘自动翻转。
这一点在测试中有专门的cursor anchor positioning (#3465)套件验证(ContextMenu.test.tsx#L659-L729):例如视口光标 (170, 90) 相对 Trigger (100, 50, 300, 150) 换算为本地 (70, 40) 的断言。该设计来自 Layer 运行时的context / anchor 定位模式(见 Layer 运行时架构)。
七、三种调用方式:右键、键盘与长按
合同 AV1 允许三种调用方式,源码中三种均有实现与测试覆盖:
1. 右键(contextmenu 事件)
handleContextMenu(ContextMenu.tsx#L520-L542)在isDisabled时直接返回(保留原生菜单);否则preventDefault()并换算坐标打开菜单。菜单容器自身也preventDefault了contextmenu(ContextMenu.tsx#L584),避免在菜单上再右键触发原生菜单——测试prevents default context menu on the opened menu container验证了这一点。
2. 键盘调用(Shift+F10 / Menu 键)
一个值得注意的细节:键盘触发的contextmenu事件在多数浏览器中坐标为 (0, 0)。源码通过e.clientX === 0 && e.clientY === 0 && e.detail === 0检测键盘调用,此时把锚点放在Trigger 左下角(localY = rect.height),保证无指针也能打开菜单(见 ContextMenu.tsx#L528-L538,注释标注为 menus-8)。测试opens from a keyboard-invoked contextmenu与anchors a keyboard-invoked menu to the trigger bottom-left分别验证。
3. 长按(touch)
useLongPress钩子(ContextMenu.tsx#L548-L561)处理触控调用。源码注释解释原因:iOS Safari 在长按时从不合成contextmenu事件,因此长按是触控端打开上下文菜单的唯一途径。测试opens on touch long-press用假定时器验证 500ms 阈值后打开,cancels the long-press when the finger moves past the threshold验证移动超过阈值(10px)视为滚动而取消。
八、两种内容模式:items 数据驱动与 menuContent 复合组件
合同 FR4 与 AV3 区分了两种内容模式,公共 API 用TypeScript union 判别保证互斥(ContextMenu.tsx#L225-L237):
interface ContextMenuDataProps extends ContextMenuBaseProps { items: ContextMenuOption[]; // 数据驱动模式 menuContent?: undefined; } interface ContextMenuCompoundProps extends ContextMenuBaseProps { items?: undefined; menuContent: ReactNode; // 复合组件模式 }数据驱动模式(items)
items数组支持三种条目类型(定义于 ContextMenu.doc.mjs):
- 动作项:
{ label, onClick?, icon?, isDisabled?, variant?, items? }。嵌套items在 popover 呈现中打开 flyout,在 bottom-sheet 呈现中钻取到新视图;variant: "destructive"以错误色渲染。 - 分隔线:
{ type: "divider" }。 - 分区:
{ type: "section", title?, items: [...] }。
一个最小可运行示例(来自源码文档注释,ContextMenu.tsx#L253-L264):
<ContextMenu items={[ { label: 'Cut', onClick: () => handleCut() }, { label: 'Copy', onClick: () => handleCopy() }, { type: 'divider' }, { label: 'Paste', onClick: () => handlePaste() }, ]} > <div>Right-click this area</div> </ContextMenu>数据项类型ContextMenuItemData / ContextMenuDividerData / ContextMenuSection / ContextMenuOption全部直接复用 DropdownMenu 的同名类型(ContextMenu.tsx#L176-L182),印证了 FR3 的"行由 DropdownMenu 拥有"。
复合组件模式(menuContent)
传入任意 JSX 作为菜单内部内容,适合动态或有状态的菜单。可以在内部使用从 index.ts 重导出的别名组件:ContextMenuItem(=DropdownMenuItem)、ContextMenuDivider、ContextMenuCheckboxItem、ContextMenuRadioGroup、ContextMenuRadioItem、ContextMenuSubMenu。
<ContextMenu menuContent={ <> <ContextMenuItem label="Cut" onClick={() => {}} endContent={<span>⌘X</span>} /> <ContextMenuDivider /> <ContextMenuRadioGroup value="name" onChange={...} label="Sort by"> <ContextMenuRadioItem value="name" label="Sort by name" /> <ContextMenuRadioItem value="date" label="Sort by date" /> </ContextMenuRadioGroup> </> } > <div>Right-click me</div> </ContextMenu>测试套件分别验证了两种模式下可选项(checkbox/radio)的角色与状态(menuitemradio/menuitemcheckbox、aria-checked)以及复合分隔线仍落在astryx-dropdown-menu-divider目标上——后者正是 FR3 委托所有权的直接证据。
九、呈现策略:popover / bottom-sheet / adaptive
presentationprop 控制两条呈现路径的选择(默认'popover',见 ContextMenu.tsx#L221):
| 值 | 行为 |
|---|---|
popover | 在指针位置旁打开(默认) |
bottom-sheet | 始终以动作面板(action sheet)打开 |
adaptive | 在≤768px 且主指针为 coarse的紧凑触控视口上使用 BottomSheet,其余场景使用光标定位 popover |
useAdaptivePresentation的实际媒体查询是'(max-width: 768px) and (pointer: coarse)'(useAdaptivePresentation.ts#L24)。测试uses the BottomSheet for adaptive presentation on compact touch通过 stubmatchMedia精确模拟该查询验证。
在 bottom-sheet 路径下,还有两个值得注意的实现细节:
- 懒加载:
LazyMenuBottomSheet通过React.lazy动态导入 MenuBottomSheet.tsx,用<Suspense fallback={null}>包裹——触控路径不参与时不会拉取 BottomSheet 代码(ContextMenu.tsx#L91-L95、#L672-L680)。 - 钻取导航:
submenuPath状态管理 sheet 内的视图栈,进入子菜单时渲染返回按钮与 H3 标题(sheetHeadingRef聚焦),标题在打开时获得焦点(ContextMenu.tsx#L372-L380)。测试drills into nested data items inside the BottomSheet完整验证了这条链路。
十、焦点管理与键盘导航:一套路径服务两种呈现
合同强调"单一键盘/焦点路径",源码通过useListFocus(DOM 选择器驱动)与useTypeahead实现:
- useListFocus:以
MENU_ITEM_SELECTOR/MENU_BOUNDARY_SELECTOR收集菜单项,支持方向键导航、wrap: false不循环、onEscape关闭。 - 首字符输入(typeahead):
useTypeahead复用钩子的作用域集合,保证内联子菜单 flyout 的条目不会被扫入当前菜单的输入匹配(ContextMenu.tsx#L399-L407,测试注释标注为 menus-11)。 - Enter / Space 激活:聚焦到
MENU_ITEM_ROLES中的角色元素时执行focused.click()。 - Tab 关闭(APG menu 模式):Tab 键关闭菜单且不
preventDefault,关闭时焦点恢复到先前元素,浏览器默认 Tab 行为从那里继续(ContextMenu.tsx#L472-L481)。 - IME 守卫:文档级 Escape 监听器通过
isImeKeyEvent(e)忽略正在提交/取消 IME 组合的 Escape(ContextMenu.tsx#L433-L453),测试ignores Escape during IME composition验证。 - 焦点恢复:
triggerFocusRef记录打开前焦点元素,关闭时(Escape 或外部点击)恢复,避免焦点掉到<body>(ContextMenu.tsx#L334-L343),测试restores focus to the trigger on close验证。 - 无障碍命名:菜单
role="menu"、aria-label默认'Context menu',可通过labelprop 覆盖(测试 menus-13);Trigger 包装元素不添加aria-haspopup(测试 menus-15)。
十一、关闭行为:本地监听与 Layer 协调
合同在 Family relationships 中明确记录:ContextMenu 目前保留本地 outside-click 与 Escape 监听器,触控托管委托给 BottomSheet。源码印证:
- 指针路径用
useLayer({ mode: 'context', lightDismiss: false }),因此需要本地mousedown外部点击监听器(ContextMenu.tsx#L413-L427)。源码注释解释了为什么不用popover="auto"的原生 light dismiss:原生机制会把打开菜单的那次右键 mouseup 当作 dismiss 事件,本地mousedown处理避免了该竞态。 - 文档级 Escape 监听器是菜单内
onKeyDown的兜底:焦点移出菜单时仍能可靠关闭。 - 在 overlay-dismissal 家族合同 中,ContextMenu 被列为local only(本地实现)的采用差距,与 BottomSheet、CommandPalette 等并列——它们尚未迁移到共享的关闭栈。这是当前记录的事实性差距,并非有意排除。
十二、主题化解剖:Theming anatomy 映射
合同包含机器可读的解剖学-主题目标映射块(<!-- anatomy-theming:v1 -->),这是它作为知识记录最具技术含量的一部分:
{ "Trigger area": { "none": { "reason": "intentional: Caller-provided trigger content remains caller-owned and the ContextMenu wrapper has no public target." } }, "Pointer menu surface": {"target": "context-menu"}, "Pointer action row": { "delegatesTo": { "owner": "component:DropdownMenu", "target": "dropdown-menu-item" } }, "Touch sheet frame": { "delegatesTo": {"owner": "component:BottomSheet", "target": "bottom-sheet"} }, "Touch menu surface": {"target": "context-menu"}, "Touch action list": { "delegatesTo": {"owner": "component:List", "target": "list"} }, "Touch action row": { "delegatesTo": {"owner": "component:List", "target": "list-item"} } }这份映射严格遵循 组件主题化表面架构 定义的四种 disposition:
target—— 组件承诺稳定公共目标:Pointer 菜单表面与 Touch 菜单表面都映射到context-menu(FR2 的"目标出现在两个备选菜单表面上"即由此而来)。delegatesTo—— 其他 Astryx 组件拥有该部分及其目标:Pointer 行 → DropdownMenu 的dropdown-menu-item;Touch 面板框架 → BottomSheet 的bottom-sheet;Touch 列表/行 → List 的list/list-item。none+ 分类原因—— 无公共目标可达:Trigger area 的原因以intentional:开头,说明"调用方提供的 Trigger 内容仍归调用方所有,ContextMenu 包装器没有公共目标"。
消费者文档侧,ContextMenu.doc.mjs 的theming段记录了实际可用的目标astryx-context-menu以及两个私有变量--_dropdown-menu-radius(默认var(--radius-container))与--_dropdown-menu-padding(默认var(--spacing-1))。根据合同的设计关系表:context-menu目标有意同时出现在两个备选已绘制菜单表面上,Trigger 内容保持调用方所有,零尺寸光标锚点是定位基础设施而非消费者解剖学。
十三、家族与系统关系
合同列出了四份关联记录及其职责边界:
- component-theming-surface:拥有解剖学资格、本地目标映射、呈现特定行与委托规则。
- interaction-modality:拥有共享的右键、键盘、长按、指针、触控与 adaptive 呈现边界——本草案不改变其中任何一条。
- layer-runtime:拥有当前 context 模式的
useLayerhost 与光标锚点定位。ContextMenu 当前保留本地 outside-click 与 Escape 监听器;触控托管委托给 BottomSheet。 - overlay-dismissal:拥有共享 Escape 与平台关闭顺序,并将 ContextMenu 与 BottomSheet 记录为当前 local-only 采用差距——本次解剖学回填不迁移任何一条路径。
十四、验证映射:合同如何被证明
合同的 Verification map 把每条不变量绑定到具体验证手段与失败期望:
| 合同 | 验证 | 代表性状态 | 变异/失败期望 |
|---|---|---|---|
| FR1, FR4 | ContextMenu.test.tsx 的 pointer、long-press、bottom-sheet、adaptive、compound、drill-in 套件 | Pointer/compound、Touch data/compound | 折叠呈现特定部分或内容所有者会破坏结构、角色或行为测试 |
| FR2 | ContextMenu 目标测试、源码检查、主题目标清单 | Pointer 与 Touch 菜单表面 | 把context-menu移到 Trigger 或从已绘制分支移除会破坏证据 |
| FR3 | ContextMenu、DropdownMenu、BottomSheet、List 的所有者测试 | Pointer 行、Touch 框架、Touch 列表/行 | 组合部分丢失所有者目标或被记录为新的 ContextMenu 目标 |
| Layer 关系 | ContextMenu 测试与当前 layer/dismissal 架构记录 | 外部点击、Escape、context 锚点、sheet | 文档宣称共享关闭而源码保持本地行为 |
| Theming anatomy 映射 | scripts/check-knowledge.mjs | 规范解剖学与当前目标 | 缺失、多余、带前缀、过期或未分类的映射导致仓库校验失败 |
值得注意,scripts/check-knowledge.mjs会校验解剖学映射的完整性,这与 component-theming-surface 架构 中"每个参与组件都需双向验证"的要求一致。
十五、决策日志、开放问题与内容边界
- Decision log:None。本文档记录当前事实,不引入组件本地设计、API、主题化、模态或 Layer 系统的任何决策。
- Open questions:None。
- Content boundary: 本文件不重复消费者 prop 表格、菜单示例、光标与长按算法、实现步骤,以及共享的模态、Layer、关闭与主题化规则;它们分别由各自的负责人文档承载(消费级内容见 ContextMenu.doc.mjs,组件实现见 ContextMenu.tsx,公共导出见 index.ts)。
十六、实战要点总结
- Trigger 区域是调用方内容:
children决定可右键区域;需要整块区域可右键时(如表单单元格),通过triggerXstyle传入铺满样式。 - 数据驱动优先:静态菜单用
items,可复选/单选/钻取;动态或有状态菜单用menuContent复合组件,两者互斥。 - 移动端用 adaptive:
presentation="adaptive"在 ≤768px 粗指针视口自动切换 BottomSheet;重要操作还需提供可见入口(如 MoreMenu),长按不能是唯一路径(ContextMenu.doc.mjs 的 bestPractices 明确给出这条反建议)。 - 不要滥用:避免把 ContextMenu 作为重要操作的唯一入口,单菜单项超过 10–12 个时应使用 sections 分组。
- 主题化边界:自定义指针/触控菜单表面用
astryx-context-menu目标;行样式请通过 DropdownMenu/List 的目标修改,不要试图给 ContextMenu 增加并行目标。 - 可访问性:默认
aria-label="Context menu"可覆盖;键盘调用(Shift+F10/Menu 键)、长按、方向键导航、首字符输入、IME 守卫均已内置并测试覆盖。
ContextMenu 的这份 draft 合同,本质上是把"一个组件、两条呈现路径、三个委托组件"的复杂职责关系用机器可校验的形式固定下来——它既是对当前实现的忠实记录,也是未来任何主题化或架构迁移的准绳。
【免费下载链接】astryxAn open source design system that's fully customizable and agent ready项目地址: https://gitcode.com/GitHub_Trending/as/astryx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考