免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

astryx 覆盖层关闭契约(Overlay Dismissal Family Contract)全解析:一套共享栈如何让一次 Escape 只关闭一个弹层

astryx 覆盖层关闭契约(Overlay Dismissal Family Contract)全解析:一套共享栈如何让一次 Escape 只关闭一个弹层 astryx 覆盖层关闭契约Overlay Dismissal Family Contract全解析一套共享栈如何让一次 Escape 只关闭一个弹层【免费下载链接】astryxAn open source design system thats fully customizable and agent ready项目地址: https://gitcode.com/GitHub_Trending/as/astryxastryx 是一个完全可定制、面向 Agent 的开源设计系统。本文聚焦其overlay-dismissal组件家族契约一个由useLayerDismissal、layerStack、LayerDepthProvider与useFocusTrap组成的共享关闭栈shared dismissal stack它保证了「一次 Escape 只影响最顶层的相关表面」并统一处理平台级关闭请求与 IME 输入法组合场景。读完本文你将掌握该契约的成员资格判定、跨组件不变量 FR1–FR7、三层拓扑排序规则、close/block行为语义以及从组件私有监听器迁移到共享栈的完整路径。为什么需要一个关闭栈问题的原点在引入共享栈之前astryx 的每一个浮层组件都各自监听自己的 EscapePopover 关 Popover、Dialog 关 Dialog。这带来两个典型故障一次按下关闭所有层Popover 嵌套在 Dialog 里时一个 Escape 同时关闭了两者一个 Modal 里再开一个 Modal同样会被一起关掉。并行注册表碎片化为了修补上述问题每个原语各自维护一套注册表——focus-trap 的 Escape 栈、Drawer 的 LIFO 注册表、useScrollLock的计数器。它们彼此不知道对方的存在协调只能靠层层stopPropagation()编排而一旦某层忘记调用行为就静默出错。layerStack.ts的模块头注释layerStack.ts明确记录了这段历史这个模块就是把那些并行注册表合并为一个单一注册表的地方。它的设计信条是——一次 Escape 恰好关闭一个层没有例外。该家族契约family contract的意图Intent进一步界定了行为边界A person dismissing layered UI should affect only the topmost relevant surface. A nested surface must not close its host on the same Escape press or platform close request when logical depth or DOM containment establishes that ordering.用户关闭分层 UI 时只应影响最顶层的相关表面当逻辑深度或 DOM 包含关系确立了顺序时嵌套表面不得在同一次 Escape 按下或平台关闭请求中关闭其宿主。成员资格规则谁属于这个家族契约规定overlay-dismissal.mdA component belongs when it owns a layered surface for which Escape or a platform close request is a valid dismissal command.只要组件拥有一个「Escape 或平台关闭请求是其合法关闭命令」的分层表面它就必须在该表面存在期间加入共享关闭栈。成员资格跟随可观察的责任而不是当前实现是否已完成接入——一个本地 Escape 监听器是待迁移的偏差deviation而不是有意的排除intentional exclusion。具体判定规则拥有自有弹层通过usePopover、useLayer、focus trap 或原生dialog创建自有弹出层的组件只要它拥有该弹层的关闭权就是成员。仅组合他人仅把另一个成员作为附属内容组合进来的组件继承该成员的行为但不因此加入。开放成员制每个新发布的、满足上述规则的表面都必须加入共享栈并同步更新本记录的成员快照。当前成员快照核心成员Dialog、AlertDialog、Popover、DropdownMenu、DropdownMenuSubMenu、MoreMenu、Tooltip、HoverCard、Lightbox、MobileNav、BottomSheet、BottomSheetSwitcher、CommandPalette、ContextMenu、PowerSearchEditPopover、Lab Drawer。组件自有输入弹层ChatComposerInput、ComplexSelector、DateInput、DateRangeInput、DateTimeInput、Selector、MultiSelector、PowerSearch、BaseTypeahead、Typeahead、Tokenizer。而 TextInput、TextArea、NumberInput、TimeInput、FileInput不拥有弹层——仅为辅助文字或禁用态文本渲染一个 Tooltip不会让输入组件本身成为成员。其他组件自有弹层BreadcrumbItem、SideNavHeading、SideNavItem、TabMenu、TopNavHeading、TopNavMenu、TopNavMegaMenu、Table 过滤、Lab TourStep、Lab ChatEmojiPicker。协作者CollaboratorsuseLayerDismissal、layerStack、LayerDepthProvider、useFocusTrap拥有或适配共享协议。非参与层Nonparticipating layers没有任何关闭行为的层不是成员。典型例子是通过 Layer 渲染的纯视觉轮廓visual-only outline——它既不处理 Escape也不接收平台关闭请求。这一成员资格判定由决策 DEC-1Decider:cixzhang2026-08-30正式确立详见 决策链接一节。共享所有者的四个核心构件契约将协议的所有权集中到四个构件上overlay-dismissal.md它们各自承担明确职责。layerStack唯一的文档级 Escape 监听器layerStack.ts是整个机制的地基layerStack.ts。它拥有一个模块级数组entries作为注册表一个挂载在document上的keydown监听器bubble 阶段而非 capture 阶段compositionstart/compositionend/blur监听器用于跟踪页面级 IME 组合状态。设计要点只有最顶层动作栈把按键路由到最顶层的已注册层自己覆盖层不监听只注册并声明它们想要的行为。备选方案——每层都监听再自我过滤——正是导致stopPropagation()编排并且静默失效的根源。Bubble 阶段而非 capture这样层内的内容可以先认领这次按下——要么stopPropagation()事件到不了 document 监听器要么preventDefault()栈看到了但选择退让。编辑器场景是preventDefault()保留为显式取消信号的原因编辑器自己的 Escape 动作必须获胜。栈而不是浏览器做决定当栈处理一次按下时它调用preventDefault()从而抑制浏览器自身的 close-watcher 行为——包括dialog的cancel事件和popoverauto的 light-dismiss在 Chromium 中验证过默认动作在传播之后运行bubble 阶段的preventDefault依然先于它生效。这是刻意的原生 top layer 只覆盖showModal()和popoverauto依赖它会让非模态show()抽屉、popovermanual层和旧浏览器走第二条行为不同的代码路径。一套代码路径、一套顺序处处一致。useLayerDismissal组件加入栈的公开 APIuseLayerDismissal是覆盖层实际调用的 APIuseLayerDismissal.ts它包装layerStack注册 唯一 Escape 监听器并从LayerDepthContext读取嵌套深度。其选项如下选项类型默认值含义isActiveboolean—必填该层当前是否参与。通常是层的打开状态。若打开状态滞后于 DOM应全程传true改由isPresent从 DOM 回答onDismiss() void—必填关闭该层。仅当栈选中该层响应 Escape 时调用escapeBehavior: block时不调用escapeBehaviorclose \| blockclose该层对到达它的 Escape 按下做什么getContainer() HTMLElement \| null无该层的容器元素按下时惰性读取。当层无法用LayerDepthProvider包裹内容裸 focus trap 不渲染任何东西时提供以便从 DOM 恢复嵌套isPresent() boolean无按下时询问层是否真的在屏幕上。当isActive在层整个生命周期内都是true打开状态滞后 DOM 一帧时提供栈跳过回答false的层isEnabledbooleantrue该层是否参与共享栈。false时该层对关闭不可见按下会穿过它流到下面的层如同它没打开。与block刻意分离——block是在场并吞掉按下这是根本不在场。唯一使用场景是 Dialog 的内联渲染模式钩子返回一个shouldDismissOnCloseRequest: () boolean供层回答浏览器自主发起的关闭请求dialog的cancel、Android 返回手势、平台关闭观察器是否应当关闭它。除了栈对 Escape 按下的顶层规则外它还拒绝在 IME 组合进行期间到达的请求——这是cancel处理器自身无法判断的因为该事件不携带组合状态。最小用法示例与源码 docblock 一致useLayerDismissal({ isActive: isOpen, onDismiss: () onOpenChange(false), });实现细节值得注意栈在事件期间、React 渲染之外调用回调因此钩子用onDismissRef/getContainerRef/isPresentRef三个 ref 保存最新闭包并用一个稳定的tokenRef作为该层栈条目的身份跨重新注册保持稳定。LayerDepthProviderReact 树中的逻辑深度嵌套顺序的来源是React 树而非 DOMLayerDepthContext.tsx原因有二Portal嵌套覆盖层通常渲染到document.body或原生 top layerDOM 包含关系会把它们报告为逻辑上所属层的兄弟。而 React context 会流过createPortal树保住了 DOM 丢失的关系。同一次提交挂载same-commit mounts深度在渲染期间就已固定而 effect 顺序在渲染时不可得且具有误导性——React 先跑子 effect 再跑父 effect于是内层先注册、看起来更老。LayerDepthProvider故意不接收 depth 属性它读取环境深度并加一depth 1让嵌套自动组合、无需任何人跟踪绝对数字。它不渲染任何 DOM 元素只是一个 context 边界。应用根无需挂载任何东西context 默认为 0。useFocusTrap没有 DOM 容器的成员focus trap 同样加入共享栈useFocusTrap.ts但有一个关键差别陷阱不渲染任何元素无法用LayerDepthProvider包裹自己的内容。因此它通过getContainer: () containerRef.current把容器交给栈让两个 DOM 嵌套的陷阱仍能以正确顺序解析。加入条件是isEscapeTrap isActive onEscape ! null——没有onEscape的 focus trap 不是可关闭表面不注册按下会穿过它流到下方。注释明确陷阱不再自己监听 Escape栈拥有唯一监听器并把每次按下路由到最顶层层so a popover inside a Dialog, a submenu inside a menu, and a modal inside a modal all peel off one at a timePopover 在 Dialog 里、子菜单在菜单里、Modal 在 Modal 里都一次剥一层。规范概念Canonical Concepts契约用一张表固化所有核心概念的状态空间与默认语义overlay-dismissal.md概念取值或状态默认语义稳定性membership成员资格member / nonparticipant具有 Escape/平台关闭行为的表面即成员currentadoption采纳状态shared / partial / local本地处理是需要迁移的偏差而非成员豁免currentregistration注册active / inactive只有激活且启用的表面参与currentpresence在场present / absent缺席的已注册表面会被跳过currentEscape behaviorclose/blockclose调用成员的关闭回调block吞掉而不关闭currentlogical depth逻辑深度非负嵌套层级provider 把后代表面标记为比其宿主更深currentclose request关闭请求Escape / 平台请求最顶层的已注册在场成员决定结果current跨组件不变量 FR1–FR7这是契约的核心条款每个不变量都能在源码与测试中找到对应实现。FR1 — 每个可关闭的分层表面都必须参与。成员在场时必须加入共享关闭栈。组件私有的 Escape 监听器或注册表不满足此不变量。layerStack头注释中一次按下恰好关闭一个层以及registerLayer的强制注册即其实现采纳表中标记 partial/local 的行是当前对 FR1 的偏差缺口见下文。FR2 — 一次请求只影响一个表面。未被认领的 Escape 按下被路由到恰好一个最顶层已注册在场成员。该成员要么调用其关闭回调要么 block 该请求请求不会继续传到它身后的成员。dispatchEscape()layerStack.ts在topPresentEntry()上直接结束即是实现——命中即返回绝无二次分发。FR3 — 平台关闭请求使用同样的最顶层规则。收到浏览器/平台关闭请求的成员仅在它是顶层已注册在场成员时才关闭指向更底层成员的请求会被拒绝。shouldDismissOnCloseRequest内部即isRegistered !isTextComposing() isTopmostLayer(token)useLayerDismissal.ts。layerDismissalInvariants.test.tsx中有直接验证对不在顶层的Outer对话框触发cancel事件断言defaultPrevented true且屏幕上仍是[Inner, Outer]。FR4 — 可用的嵌套信号优先于宿主摆放位置。当成员提供LayerDepthProvider时React 树的深度会把它的后代层排在成员之前——即使两者在同一提交中挂载。DOM 包含关系可以解决相等深度的嵌套稳定的注册顺序解决无关表面。没有 provider 的成员不保证对不再被 DOM 包含的后代内容提供此排序。这直接对应compareEntries的三级比较见下节。FR5 — 内容可以优先认领 Escape。共享监听器跑在 bubble 阶段当内容已处理该事件时它退让。dispatchLayerEscapeKeyDown中if (event.defaultPrevented) return;layerStack.ts即是退让分支测试 stands down when content already handled the press 用层内一个preventDefaultEscape 的按钮验证了它。FR6 — 文本组合不是层关闭。用于取消进行中 IME 组合的 Escape 被吞掉但不关闭任何成员且组合进行期间平台关闭请求被拒绝。实现是双重的dispatchLayerEscapeKeyDown中isImeKeyEvent(event)分支会preventDefault()认领按下但不派发关闭isTextComposing()则供关闭请求查询layerStack.ts。为什么认领如此重要源码注释点明一个未被认领的 Escape 恰好会让浏览器发起关闭请求该请求在同一按键上通过cancel处理器关闭层——所以守卫必须吞掉按下而不只是跳过关闭。FR7 — 注册不定义打开状态的所有权。栈调用被选中成员的关闭回调该请求是否立即关闭表面由成员所属组件的契约与调用方决定。受控controlled组件照常注册并领取按下通过调用消费者的变更处理器来应答是否因此关闭是消费者的决定。顶层判定compareEntries 的三级排序layerStack.ts的compareEntrieslayerStack.ts是谁在顶层的唯一裁决者三个键各司其职逻辑深度Depth嵌套在另一层之内的层在顶层。这正是让同一提交中挂载的内外对inner-and-outer pair得到正确结果的原因——仅凭注册顺序会搞反因为 React 先跑子 effect。DOM 包含DOM containment深度只有在层用LayerDepthProvider包裹内容时才会变化而裸useFocusTrap无法做到。对它们包含关系恢复了树没有报告的嵌套。注册顺序Registration order / seq对于无关层后注册的在顶层与浏览器自身 top layer 的堆叠一致。一个层在身份存续期间保留其首次排序位置——因此关闭后重新打开不会升到顶层。两个容易被误读的设计决策Modality模态性刻意不是排序键。把模态层排在一切之上听起来正确实则不对模态内显示的悬浮提示渲染在它之上若按模态优先规则按键会交给对话框而困住提示。嵌套已经覆盖了模态性本要覆盖的场景——从另一层内部打开的层更深无论模态与否。这是偏序partial order包含关系只关联嵌套对。调用方必须用**逐对最大扫描pairwise max scan**解析顶层绝不能用Array.sort——其比较器不传递结果未定义。topPresentEntry()layerStack.ts正是这样实现的线性遍历 两两比较。还有一个防重排细节seq绑定到token而非注册动作。层在行为或深度变化时会重新注册比如打开的 Dialogpurpose翻转、focus trap 移动、StrictMode 双挂载 effect若按注册次数计数会给层一个全新且更高的 seq把它提升到后来打开的层之上。WeakMap的seqByToken保证层永远守住自己的位置。Escape 行为只有 close 与 block 两种LayerEscapeBehavior只有两个值layerStack.tsclose关闭该层并吞掉按下。一次 Escape 恰好关闭这一层。这是默认值对每个可关闭层都正确模态框、弹出层、菜单、组合框、悬浮层皆然。block吞掉按下但不关闭。用于需要显式选择的层Dialog purposerequiredEscape 不得关闭它也不得穿过它关闭身后之物。源码注释点明了刻意不存在第三种关闭但让按下继续传播变体Escape affects exactly one layer, always——a rule with no per-component exceptions is one users can predict.Escape 永远恰好影响一个层——没有逐组件例外的规则才是用户可以预测的。悬浮层曾是最诱人的例外用户从未主动打开提示为何要吃掉其按键但那样猜错是破坏性的某人想关掉表单上乱入的 tooltip却会失去整个对话框。而反过来猜错只多花一次按键。允许的组件差异 AV1–AV6契约在六个维度上把自由留给成员组件overlay-dismissal.md关闭栈本身不越权AV1 — Focus成员拥有焦点进入、包含、移动与归还。AV2 — Modality成员可以是模态或非模态并拥有任何 inertness 或背景幕行为。AV3 — Positioning and hosting成员可用原生 dialog 定位、CSS anchor positioning、固定坐标或组件自有宿主。AV4 — Outside dismissal成员自己决定指针、悬浮、失焦、背景幕、触摸或手势交互是否/如何关闭它们。AV5 — Open-state API成员拥有受控与非受控状态、默认值、回调命名与命令式命令。AV6 — Presentation成员拥有动画、视觉处理与主题化解剖结构关闭注册表不暴露任何主题目标或渲染包装器。代表性矩阵成员 × 不变量的组合契约用矩阵展示共享不变量与刻意变化如何共存overlay-dismissal.md成员与状态共享不变量刻意变化Dialog 套 Dialog内层 Dialog 处理第一次请求Dialog 提供原生模态性与焦点行为Dialog 内的 PopoverPopover 处理第一次 EscapePopover 提供锚定、portal 放置与焦点策略Dialog 内的 Tooltip / HoverCard在场的悬浮表面处理第一次 Escape悬浮家族决定自己何时在场、受控状态如何响应必需的 Dialog 之上的 Lightbox / MobileNav顶层成员处理请求必需的 Dialog 仅在自身是顶层时使用block单独的必需 Dialog请求被吞掉而不关闭Dialog 的目的选择block行为自有输入弹层弹层在场时参与输入组件拥有选择、焦点、打开状态、定位与外部关闭两个无关的已注册表面后注册者稳定排序在顶层两个表面都不被视为嵌套发给下层 Dialog 的平台关闭请求下层 Dialog 保持打开平台针对宿主共享检查决定它能否关闭采纳现状与迁移路径契约用采纳表记录每一类组件当前的接入状态overlay-dismissal.md。标记 partial 或 local 的行是当前对 FR1 的采纳缺口adoption gaps不是被批准的例外组件或表面采纳状态当前偏差或限制Dialog、AlertDialog、Popover、DropdownMenu root、MoreMenu、Lightbox、MobileNavshared owner无Tooltip、HoverCardshared owner DOM 在场报告两者都不向后代层提供嵌套深度带onEscape的 focus trap经useFocusTrap走 shared owner提供 DOM 包含不提供后代深度BreadcrumbItem、ChatComposerInput、ComplexSelector、DateInput、DateRangeInput、DateTimeInput、Selector、MultiSelector、PowerSearch、BaseTypeahead、Typeahead、Tokenizer、SideNavHeading、SideNavItem、TabMenu、TopNavHeading、TopNavMenu、TopNavMegaMenu、Table、Lab TourStep、Lab ChatEmojiPicker经usePopover或组合 Popover owner 走 shared ownerAdaptive BottomSheet 路径继承 BottomSheet 的采纳缺口Table 过滤拥有受控 Popover 状态并在关闭时丢弃草稿TourStep 把 Popover 关闭路由给 TourChatEmojiPicker 拥有受控 Popover 状态BottomSheetSwitchershared owner无BottomSheet、CommandPalette、ContextMenu、DropdownMenuSubMenu、PowerSearchEditPopover、Lab Drawerlocal only必须从组件私有的监听器/注册表迁移到共享 owneruseLayerDismissal.ts头注释解释了这些 local-only 组件当下为何仍然安全它们各自在元素级认领按下而栈对已defaultPrevented的按下会退让——但在它们内部打开的已注册层得不到这次按下宿主会拿走并关闭。迁移它们正是修复之道。契约同时要求迁移必须保留每个组件现有的焦点、模态性、定位、外部关闭与打开状态契约。验证映射测试如何钉死契约契约自带一张验证映射表overlay-dismissal.md每一行不变量都有对应测试与明确的失败预期契约验证代表性成员与状态突变或失败预期FR1、FR2、FR4、FR5useLayerDismissal.test.tsxBottomSheetSwitcher.test.tsx合成 provider 深度、同 DOM 包含、非模态 switcher 嵌套、无关、阻塞、内容已处理、在场/缺席移除深度、在场过滤或事件延迟会把 Escape 送给错误的表面FR2、FR3、FR4、FR6layerDismissalInvariants.test.tsx同 DOM 嵌套 Dialog、Lightbox 叠 Dialog、平台 cancel、IME一次请求关闭两个表面、在子层之前关闭宿主、或在组合期间关闭FR1、FR2、FR3、FR7layerDismissalFamilies.test.tsxDialog、Lightbox、MobileNav、Tooltip、HoverCard受控与阻塞状态某家族绕过栈、更底层成员处理请求、或受控所有权被忽略FR1useFocusTrap.test.tsx带onEscape的激活、嵌套、停用陷阱可关闭陷阱游离在共享栈之外或嵌套陷阱一起响应三个测试文件各自承担不同职责值得分别说明useLayerDismissal.test.tsx用合成层回答一次 Escape 按下哪些层做出了反应——这是栈存在的唯一问题。覆盖了同提交挂载的嵌套、外层回退、无关层后者优先、DOM 包含平级解析、close/block、重新注册不重排含 StrictMode 双挂载、在场过滤、isEnabled退出、内容已处理退让、IME 组合认领与平台关闭请求的 IME 拒绝。layerDismissalInvariants.test.tsx对真实覆盖层提问一次 Escape 后屏幕上还剩什么它直接读取 DOM哪些 dialog 还 open而非 dismiss spy——spy 在栈路由错层但确实调用了某回调时也会通过而 open-dialog 普查不会。jsdom 不建模 close watcher 与 IME 组合因此相关行通过cancel事件、携带isComposing的 keydown 驱动端到端行为在 Chromium 中测量probe-kit。layerDismissalFamilies.test.tsx验证每个覆盖层家族共享同一个关闭栈。文件头点明其来历Lightbox 与 MobileNav 曾是缺口——两者只通过原生cancel事件关闭而底下的requiredDialog 会吞掉所有先到的按下。决策链接DEC-1 —— 成员资格跟随关闭责任契约记录了一条正式决策overlay-dismissal.md每个「Escape 或平台关闭请求是合法关闭命令」的分层表面都属于这个家族且必须参与共享栈。成员资格是开放的现有组件私有监听器与注册表是采纳缺口而非有意排除。拥有自有输入弹层的组件是成员仅组合另一成员作为附属内容的组件不因此成为成员。members 元数据与采纳表是经过审计的当前快照而非封闭集合。没有关闭行为的层不参与——通过 Layer 渲染的纯视觉轮廓是标准的非参与示例。开放问题与内容边界契约当前没有开放问题焦点、模态性、定位、外部关闭与打开状态 API 被明确排除在本家族契约之外。唯一的显式保留是映射的测试没有通过真实 portal 渲染嵌套层——在声明已验证 portal 覆盖之前需要一个真实 portal 夹具real portal fixture。内容边界Content boundary则精确划定了本文件的所有权overlay-dismissal.md本文件拥有家族成员资格、最顶层已注册 Escape 与平台关闭行为以及当前用于排序这些请求的逻辑深度与 DOM 包含信号。它不定义焦点进入或归还、模态性、定位或锚定、portal 放置、外部交互、程序化组件命令、打开状态 API、动画或主题化——那些仍是组件、家族或架构层的责任。小结给使用者的三条行动指南新浮层加入时只要你的组件拥有一个可被 Escape 或平台关闭请求关闭的分层表面就通过useLayerDismissal注册必要时用getContainer补上 DOM 信号并为自己的内容包裹LayerDepthProvider——同时更新 overlay-dismissal.md 的成员快照与采纳表。写业务代码时永远假设一次 Escape 只关一个层需要强制选择时用purposerequiredblock需要内容如编辑器优先时靠 bubble 阶段的preventDefault不要自己再加 document 级 Escape 监听器。排查关闭类 Bug 时先从 layerStack.ts 的compareEntries三级排序与 layerDismissalInvariants.test.tsx 的屏幕普查式断言入手——它们覆盖了深度、在场、IME 与平台请求的全部边界。【免费下载链接】astryxAn open source design system thats fully customizable and agent ready项目地址: https://gitcode.com/GitHub_Trending/as/astryx创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表