Astryx ContextMenu 组件合同深度解读:光标定位、双呈现模式与主题所有权边界
2026/9/15 14:03:51 网站建设 项目流程

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-surfaceinteraction-modalitylayer-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"的弹层,触控路径则通过懒加载的LazyMenuBottomSheetSuspense包裹)渲染 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导入并使用renderDropdownItemsDropdownMenuContextMenuBottomSheetActionListLazyMenuBottomSheetuseLayer,但 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 区域或光标锚点上当前源码、文档与测试已验证当前清单;无目标变更
FR3Pointer 操作行保留 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(呈现策略)popoverbottom-sheet与解析后的adaptive呈现可以选择当前的指针或触控解剖学。
  • AV3 — Content mode(内容模式):数据驱动的指针内容通过 DropdownMenu 辅助函数渲染;复合内容由调用方自行组合。数据驱动的触控内容通过 List 渲染。

代表性状态(Representative states)

状态必需不变量允许变化
Pointer data menuTrigger 区域打开 Pointer 菜单表面,行由 DropdownMenu 拥有光标位置、行内容、宽度、嵌套 flyout 可变化
Pointer compound menuTrigger 区域在调用方提供的菜单内容周围打开同一表面调用方选择受支持的 DropdownMenu 内部组件
Touch data menuTrigger 区域打开 Touch 面板框架与 Touch 菜单表面,其中包含 List 拥有的操作标题、分区、分隔线、图标、钻取深度可变化
Touch compound menuTrigger 区域围绕 ContextMenu 表面与调用方提供的内部内容打开 Touch 面板框架调用方内容自行负责其结构
Disabled triggerTrigger 区域保持渲染,ContextMenu 不抑制原生浏览器 context menu调用方提供的内容不变

变换与优先级 / 性能

文档明确:没有引入任何新的呈现解析、光标位置、长按、条目渲染或样式优先级规则也没有引入任何新的加载、监听器、测量或渲染约束。这是一个纯记录性(additive)合同。

六、源码级原理:光标锚点定位(为什么菜单"跟着内容走")

ContextMenu 最精巧的实现细节是光标锚点。源码顶部注释(ContextMenu.tsx#L11-L17)完整解释了设计意图:

光标点被捕获为Trigger 内部的偏移,并被物化为一个零尺寸锚点元素,因此菜单是相对于 Trigger 的上下文(通过 CSS anchor positioning)定位的,而不是相对于视口。菜单随内容滚动,并在视口边缘自动翻转,同时仍出现在光标下方。

具体实现链路:

  1. Trigger 样式(ContextMenu.tsx#L102-L107):position: relative为绝对定位的锚点建立包含块;WebkitTouchCallout: none/userSelect: none抑制 iOS 长按的原生文本/呼出 UI。
  2. 零尺寸锚点(ContextMenu.tsx#L112-L117):position: absolute; width: 0; height: 0; pointerEvents: none,渲染为<span aria-hidden="true">并合并layer.ref
  3. 坐标换算openAtLocalPoint,ContextMenu.tsx#L496-L518):把视口光标坐标转换成 Trigger 本地坐标写入positionRef,再设置锚点style.left/top
  4. 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()并换算坐标打开菜单。菜单容器自身也preventDefaultcontextmenu(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 contextmenuanchors 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)、ContextMenuDividerContextMenuCheckboxItemContextMenuRadioGroupContextMenuRadioItemContextMenuSubMenu

<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/menuitemcheckboxaria-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 路径下,还有两个值得注意的实现细节:

  1. 懒加载LazyMenuBottomSheet通过React.lazy动态导入 MenuBottomSheet.tsx,用<Suspense fallback={null}>包裹——触控路径不参与时不会拉取 BottomSheet 代码(ContextMenu.tsx#L91-L95、#L672-L680)。
  2. 钻取导航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:

  1. target—— 组件承诺稳定公共目标:Pointer 菜单表面与 Touch 菜单表面都映射到context-menu(FR2 的"目标出现在两个备选菜单表面上"即由此而来)。
  2. delegatesTo—— 其他 Astryx 组件拥有该部分及其目标:Pointer 行 → DropdownMenu 的dropdown-menu-item;Touch 面板框架 → BottomSheet 的bottom-sheet;Touch 列表/行 → List 的list/list-item
  3. 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, FR4ContextMenu.test.tsx 的 pointer、long-press、bottom-sheet、adaptive、compound、drill-in 套件Pointer/compound、Touch data/compound折叠呈现特定部分或内容所有者会破坏结构、角色或行为测试
FR2ContextMenu 目标测试、源码检查、主题目标清单Pointer 与 Touch 菜单表面context-menu移到 Trigger 或从已绘制分支移除会破坏证据
FR3ContextMenu、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)。

十六、实战要点总结

  1. Trigger 区域是调用方内容children决定可右键区域;需要整块区域可右键时(如表单单元格),通过triggerXstyle传入铺满样式。
  2. 数据驱动优先:静态菜单用items,可复选/单选/钻取;动态或有状态菜单用menuContent复合组件,两者互斥。
  3. 移动端用 adaptivepresentation="adaptive"在 ≤768px 粗指针视口自动切换 BottomSheet;重要操作还需提供可见入口(如 MoreMenu),长按不能是唯一路径(ContextMenu.doc.mjs 的 bestPractices 明确给出这条反建议)。
  4. 不要滥用:避免把 ContextMenu 作为重要操作的唯一入口,单菜单项超过 10–12 个时应使用 sections 分组。
  5. 主题化边界:自定义指针/触控菜单表面用astryx-context-menu目标;行样式请通过 DropdownMenu/List 的目标修改,不要试图给 ContextMenu 增加并行目标。
  6. 可访问性:默认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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询