行为原语
@xihan-ui/core 中的行为原语收纳跨组件复用的交互机制:对话框、抽屉、气泡、菜单、上下文菜单、悬浮卡片等的差别在触发方式和视觉,但点击外部关闭、焦点陷入、背景禁止滚动是同一套机制。在这里实现一次,组件只负责调用。
配套的层栈与背景失活也在 @xihan-ui/core 中(它们是结构原语,比行为更底层)。
createScope(node, idGenerator) 从节点自己的 realm 解析 Document、Window、Element 与 ShadowRoot。传入 iframe 或画中画窗口中的节点时,getRootNode/getDoc/getWin 不会因当前页面的 instanceof 失败而回落到主文档。把这份显式 Scope 传给 createRuntimeConfig({ scope }) 后,默认 locale、LayerRegistry、PortalRoot 与 reduced-motion 都从该 Scope 的 document/window 派生;显式传入的配置仍然优先,其中 layerRegistry.ownerDocument 必须与 Scope Document 相同。
无全局 DOM 时必须提供有效 Scope;只提供 layer registry 不会得到一个伪造的空 Scope。Scope 的 root、document、window 必须互相归属,离线 Document 没有活动 Window 时直接报错。默认 PortalRoot 保持惰性创建,但 Document 没有 body 时会给出明确错误;需要其他容器就显式传 portalContainer。
createScope(null, ...) 本身保持惰性,便于在 CSR 中先建立机器再挂载组件节点;这份 Scope 始终表示 ambient Document,不会在之后改绑节点。真正读取 root/document 时若宿主没有有效全局 Document,会抛出稳定错误。传入离线 Document 的节点时 getDoc() 仍返回该 Document,但 getWin() 明确失败,不会借用主页面 Window 伪造一组混合 realm。
浮层第一次打开时才加载
消隐层、焦点域、滚动锁、背景失活、Portal 视觉桥与多选标签动效只在浮层打开时才用得上。库内的浮层组件不从主入口同步引入它们,而是第一次要用时经 loadOverlayKit() 取一块按需块;定位引擎(@xihan-ui/position 的 createDeferredPositionEngine,见浮层定位)与浮层里的自绘滚动条同样按需。使用者的打包器(Vite、Rollup、webpack)会把这几块切成单独的文件,首屏不下载:只用一个选择器时首屏从 53 kB 降到 33 kB(gzip,见体积棘轮)。
- 何时取:浮层挂载后,文档上第一次有交互(指针移进来、按下、按键、聚焦、触摸)就在后台取。键盘打开前焦点先到,指针点开前先移进来,真打开时多半已经到了。挂载时就开着的浮层立刻取。
- 没到时打开:展开态当场生效,状态、ARIA、
data-state与事件照常;资源到了再接上。靠定位的浮层落位前本就藏着;对话框、抽屉、图片查看器、命令面板这类不靠落位藏面板的,内容等按需块到了才露出来。等的这段时间按 Escape、点外面不收起,焦点留在原处;等的时候已经关掉就不再接,不抢焦点。 - 第一次打开也要同步:应用启动时调一次适配器导出的
preloadOverlay()(@xihan-ui/vue、@xihan-ui/react、@xihan-ui/web-components都有),之后与静态引入完全一样。 - 加载失败不缓存,下一次打开重新取。
自己写浮层时:whenOverlayKit(kit => …) 在按需块到了时回调(已经到了当场调),返回的函数撤掉还在排的那一次;overlayKit() 已到返回它,没到返回 null;prefetchOverlayKit(document) 登记交互预取,底下用的是 onFirstInteraction(document, run)。给 Portal 壳接来源用 connectPortalVisualBridge({ source, shell }):逻辑来源当场登记,搬进壳的控件马上就能沿逻辑祖先链找到所在位置的配置与语言包;视觉桥等按需块到了再建,返回的函数一并撤掉。主入口照旧导出 createDismissLayer、createFocusScope 等,直接引入就是同步的,下文的示例都这样写。
import { prefetchOverlayKit, whenOverlayKit } from "@xihan-ui/core";
prefetchOverlayKit(document);
let dismiss = null;
const cancel = whenOverlayKit((kit) => {
dismiss = kit.createDismissLayer({ config, layer, onDismiss: close });
});
// 收起或卸载时:还没到就撤掉排队,到了就释放
cancel();
dismiss?.dispose();Portal 的视觉环境
Vue 浮层与 React XhPortal 为每个实例建立独立的 display: contents 容器,把逻辑来源的视觉环境带到浮层落点。主题、品牌、密度、对比度、动效、透明度、材质和方向分别读取最近的显式声明。语气 data-tone 不带:语气是组件自己的属性,不是来源所处的环境。浮层要用语气的组件(Select、Menu、Tooltip 等)把自己的 tone 写到定位层或内容部件上,没写的用组件自己的缺省语气,不接触发器外层容器的语气(见语气不跨组件继承);浮层里按在自定义节点上使用语气读 --xh-tone-* 的节点,要在自己身上写 data-tone。来源祖先链上局部覆盖的 CSS 自定义属性也会投影到实例壳。局部深色或局部令牌覆盖的弹层因此仍保持来源外观,同一落点下的其他实例不受影响。来源没有声明的轴与变量继续继承落点容器。
自定义属性只带属于视觉环境的那部分。作者自己的属性(不以 --xh- 开头)照投;--xh- 命名空间只投文档根上有声明的名字,即令牌与按覆盖样式的三种粒度写在 :root 上的组件槽覆盖。皮肤写在组件或家族元素上的公开槽(--xh-<组件>-*、--xh-collection-* 等)、私有槽 --xh-_* 与挂在 [data-tone] 上的 --xh-tone-* 是组件内部级联,不跨 Portal:斑马行改写的行底不会顺着行内的触发器进到菜单项,列表项里的下拉、卡片里的提示同理。判定按每次同步时的文档根计算样式做,不维护名单。
实例壳自己就能解析出来的那些不复制。只由文档根(:root、html)或桥复制过去的那些属性选中的声明——令牌层的 :where(:root)、:where([data-density='compact'])、:where([data-theme='dark']),以及作者按同样形状写的规则——在壳上会被同一条规则再命中一次,壳因此只拿属性、不拿逐条 inline 声明;祖先上密度或主题的局部切换仍然完整生效,浮层内解析到的令牌与来源处一致。来源没有显式声明该轴时壳也不写该属性,按既有语义继续继承落点容器。
框架外可使用 createPortalVisualBridge({ source, shell }):source 是逻辑来源元素,shell 是该实例独占的容器,两者必须属于同一 Document。返回的 sync() 可立即重读,dispose() 停止观察并恢复接管前的容器属性与自定义属性。普通属性、style 改动、祖先移动及 Shadow DOM 插槽重新分配会在 MutationObserver 或 slotchange 通知后同步;需要同一调用栈内更新时显式调用 sync()。普通计算样式不会复制。
class 改动只在增删的名字出现在「声明了自定义属性的选择器」里时才重算:页面级过渡类、展开态与加载态每帧都在祖先链上增删 class,它们与自定义属性无关,链下每个浮层不必为此各读一遍整张令牌表。判定来自一份按文档缓存的样式表索引,样式表增删或整段替换会让它失效;通过 CSSOM 往已有分组规则内部插入声明自定义属性的规则不改变索引指纹,这种改动后需要显式 sync()。样式表跨域读不到规则、或选择器里出现 [class] 时,索引整体让位,按原来的方式重算。
import { createPortalVisualBridge } from "@xihan-ui/core";
const bridge = createPortalVisualBridge({ source, shell });
// 卸载该实例时释放观察与属性。
bridge.dispose();桥接的属性为 data-theme、data-brand、data-density、data-contrast、data-motion、data-transparency、data-material 与 dir;语气 data-tone 不在其列。三端物理 Portal 都从逻辑来源的 composed 祖先逐项取最近显式声明,并把它们与上述自定义属性投影到实例壳;来源变化、跨 realm 与退场回收均由 Core 桥接生命周期处理。
逻辑父子
浮层打开后内容搬进 Portal 落点,在 DOM 里已经不在来源的子树中,沿 parentElement 往上找不到原来包着它的作用域。视觉桥活着期间把实例壳登记为来源的逻辑子节点,dispose() 时撤销;三端的 Portal 都经视觉桥,所以 Vue、React 与 Web Components 的浮层都有这层关系。
logicalParentElement(node):走到实例壳时返回它的来源,其余节点返回parentElement,到文档根为null。logicallyContains(ancestor, node):node在 DOM 上落在ancestor里,或沿logicalParentElement穿过实例壳后落在里面;node就是ancestor时也算。
沿 DOM 找作用域、判定子树的一方用它们穿过 Portal。Web Components 的 <xh-config> 与字段集作用域就是这样管到搬进落点的浮层内容的,与 Vue / React 浮层沿组件树取配置一致;自己写的作用域元素照同一写法:
import { logicallyContains, logicalParentElement } from "@xihan-ui/core";
// 从节点往上找最近的作用域,浮层里的节点也能找到包着浮层来源的那一层
function closestScope(node: Element): Element | null {
for (let el: Element | null = node; el; el = logicalParentElement(el)) {
if (el.matches("my-scope"))
return el;
}
return null;
}
// 作用域变化时只通知它逻辑子树里的订阅者,含已经搬进 Portal 的
for (const [listener, host] of subscribers) {
if (logicallyContains(scope, host))
listener();
}登记挂在 Document 上,页面上装了几份 core 时读到的是同一份。
在 Vue 中使用
原语都是框架无关的:接收一份配置与几个元素 getter,返回一个需要自行释放的句柄。接入 Vue 只是把释放挂到作用域结束,这层包装位于 @xihan-ui/vue/behavior:
import { useHoverIntent, useScrollLock } from "@xihan-ui/vue/behavior";
useScrollLock(() => open.value, config);
useHoverIntent({
getTriggerEl: () => triggerRef.value,
getContentEl: () => (open.value ? contentRef.value : null),
onOpenIntent: () => (open.value = true),
onCloseIntent: () => (open.value = false),
});Vue 与 React 包装在 DOM 提交后解析 getTriggerEl():节点为 null 时表示这一帧没有绑定目标,旧绑定会立即释放;节点重新出现、换代或三个计时参数变化时自动重建。Vue 的 getContentEl 与意图回调读取当前响应式选项,React 读取最近一次已提交选项;浮层内容或普通回调换代都不会取消正在进行的安全三角会话。
直接使用 core 的 trackHoverIntent() 时传入创建时已经在场的 trigger 元素。该元素是明确的创建快照,订阅期间不得跨 Document 移动;需要换触发器时先调用 cleanup 再重建。浮层 content 可以动态换代,但必须始终与 trigger 属于同一 Document;安全三角会按原离开点和新面板位置重算。多级浮层经 Portal 分离时,用 getHoverBranches 显式返回属于同一悬停树的后代区域;它们必须是同一 Document 的原生 HTMLElement,不会按全局浮层顺序猜测归属。所有计时器与文档监听都取自 trigger 创建时所属的活动 Window;非法数值、离线 Document、跨 Document content 或 branch 会直接报错。
另有 useScrollTracker / useStickToBottom / useTypeahead,接法相同。useStickToBottom 除状态外还返回句柄上的两个动作,回到底部按钮使用的是前者:
const { state, scrollToBottom } = useStickToBottom({
config,
scrollEl: () => viewportRef.value,
contentEl: () => contentRef.value,
});
// state.value?.atBottom 为假时显示回到底部按钮,点击调用 scrollToBottom()两个 getter 中读取的是 ref 时不必自行 retarget:节点更换后这层包装会重新绑定。
需要层栈流程的原语不在这里:消隐层、焦点域、背景失活要按顺序接入多个部分,接错的表现是点击子菜单导致父层关闭这类不报错的异常。该场景直接使用库内的浮层组件;确需自建时按下文各节的顺序接入。
层栈
浮层不是孤立的,它们叠成一个栈。LayerRegistry 是这个栈的登记表:
export type LayerKind = "modal" | "popover" | "inline";
export interface Layer {
readonly id: string;
readonly kind: LayerKind;
readonly node: () => HTMLElement | null; // 层的根节点
readonly branches: () => Element[]; // 逻辑属于本层、DOM 却在别处的节点
readonly isModal: () => boolean;
readonly surfaces: () => Element[]; // 点了就该关本层的表面,如遮罩
readonly visuals?: () => Element[]; // 特殊结构显式声明真正消费 z-index 的宿主
readonly opensOnHover?: boolean; // 悬停就出现、不经按下打开:挡下层外交互时只护着它开在其内容里的层;缺省内联层为 true
}
export interface LayerRegistry {
readonly ownerDocument: Document;
// register / list / top / elementsAbove / nestedAbove / subscribe ...
visualOf: (layer: Layer) => {
visualIndex: number;
visualLane: number;
visualLayer: string;
};
sync: (layer: Layer) => void; // isModal 等动态 getter 改值后显式通知
}两个概念需要单独说明:
branches(分支):嵌套 portal 出去的子层。菜单开在对话框中、子菜单再 portal 到 body,DOM 上它们是兄弟,逻辑上是父子。漏登记分支会使点击子菜单被判定为点击外部,父层随之关闭。surfaces(表面):遮罩这类点击即应关闭的元素。它属于本层,但点击它的语义是关闭而不是点击层内。
默认情况下同一 Document 共用一个注册表,Vue、React 与 Web Components 的浮层都登记在这里;注册表挂在 Document 上,页面上同时装了几份 @xihan-ui/core(应用与设计器各自打包、或装了两个版本)时取到的也是同一个。Esc 与外部点击的派发、焦点域、滚动锁与 aria-hidden 计数同样是每个 Document 一份。自定义注册表也会在创建时固化唯一的 ownerDocument,公共记录本身被冻结。不同 Document(iframe、画中画窗口)的注册表不能混用。
栈的先后缺省按登记先后排;新登记的层如果开在另一层的内容里(它的根节点或某个分支,比如触发器、输入框,落在那一层的根节点之内),就排在那一层之上,不管谁先登记:对话框和里面缺省展开的下拉同一刻打开、下拉先登记时,下拉照样在上,Esc 先关它。
层可以从栈中间释放。浮层逻辑收起之后还要播退场,层留在栈里直到退场播完;这期间打开的新层(比如点在下拉外面的那一下正好打开了抽屉)压在它上面,它播完退场就从新层下面释放,这是正常的先后。真正乱序的是父层先走:开在它内容里的层还压在上面时就释放它,注册表报 core.layer.dispose-not-top,明细里的 nested 列出还压着的那几层。谁开在谁里面按登记那一刻的 DOM 记下,释放时节点已经卸掉也照样判得出。
库内的浮层在退场播完要释放时,会等开在它里面的层先走。一次层外按下可以把提示和它所在的弹出层一并收起(见消隐层),两层各播各的退场,谁先播完不一定;弹出层播完退场时,先用 nestedAbove(layer) 问开在它里面的层走了没有,还压着就留在栈里等,层栈每变一次复查一次,都走了才释放层、消隐层、焦点域与滚动锁,Dialog / Drawer 的 onExitComplete 也在这之后发出。等的这一小段里它已经关闭、内容已经失活,只占栈位,这时重开沿用原资源,与退场中重开一样。只等开在它里面的层:与它无关的层压在上面时照常从中间释放。组件卸载不等,资源立即释放。自建浮层按同一做法:退场结束后 registry.nestedAbove?.(layer) 非空就订阅 subscribe,在通知之后的微任务里复查(通知期间不能改栈)。
视觉层级不再由各组件各自拿静态 z-index 猜。注册表按当前 Document 的逻辑栈派生 visualIndex,再让 kind 与实时 isModal() 占用该序号内的 visualLane;后登记层的序号 权重大于 lane,所以嵌套 popover 一定高于所属 modal,后来打开的 modal 也能压住更早的 popover。公共绑定把 visualLayer 写入私有 --xh-_layer,皮肤仍以公开 --xh-<component>-layer 为第一优先级,再回落这份私有值和旧全局令牌。动态 modal 通过 sync(layer) 发布同一冻结快照,不改写 Layer,也不在适配器保留 setModal 空实现。
list() 与订阅回调拿到的都是冻结状态快照,Layer 记录本身也被冻结;节点、分支和模态性仍由记录里的 getter 返回当前值。注册与释放会固定这一轮的订阅者名单并通知完所有人,单个订阅者抛错不会截断后续通知,多项异常会按订阅顺序聚合。
登记与释放采用不同提交点。register() 的通知失败表示登记失败:注册表先恢复登记前的同一份快照,再向见过临时新状态的同一批订阅者发布补偿通知;中途退订的人仍会收到补偿,中途新增的人不会凭空收到补偿。变更通知和补偿通知都失败时,两阶段异常会一起上抛,失败登记已经分配的 layer id 不会复用。dispose() 会先在旧状态上报告乱序诊断(上面还压着开在它里面的层时),再永久移除 Layer、终结 cleanup,并发布通知;即使诊断输出或通知抛错,移除也不会撤销,两个阶段的异常会完整聚合。这样不会把已经释放且上层不再持有 cleanup 的 Layer 复活;重复释放保持幂等。
订阅回调可以读取当前快照、订阅或退订,但不能同步嵌套调用 register(),也不能释放仍在注册表里的 Layer。嵌套状态变更会明确抛错,避免外层通知观察到一半又被另一轮变更改写;已经成功终结的 cleanup 仍可重复调用并保持无操作。
注册表还给出 elementsAbove(layer):栈中位于该层之上的各层的全部节点(node + branches + surfaces)。背景失活要用它把上层排除在自己的管辖之外。
nestedAbove(layer) 给出栈中位于该层之上、开在它内容里的层,按登记时记下的嵌套判,与乱序诊断同一份记录;返回冻结数组,层未登记或已释放时为空。这个方法在类型上是可选的:注册表是页面级共享的,页面上先装的那份 @xihan-ui/core 早于它时,取到的实例没有这个方法,调用方按没有这样的层处理。
单个 Headless 浮层 layer effect 会把“登记 layer → 建消解层 → 建焦点域 → 加滚动锁/背景失活”放在同一初始化事务里。同步步骤与宿主 flush 后才执行的背景失活都经过事务守卫;任一步抛错都会把已经取得的资源按逆序全部释放,最后移除 layer。effect 成功后交给机器的也是同一份幂等逆序 cleanup。单项清理失败原样抛出,多项失败则聚合报告,且两者都会继续清完其余项,避免错误层永久占着栈顶。
窗口叠放序
import { getWindowStack } from "@xihan-ui/core";
const stack = getWindowStack(document);
const panel = {};
const stop = stack.subscribe(() => render(stack.depthOf(panel)));
stack.raise(panel); // 展开、按下、聚焦时提到最上面
stack.remove(panel); // 收起时移出getWindowStack(doc) 给出一份页面级的窗口叠放序,管非模态、能同时开着几个的浮动窗口谁在上面,比如浮动面板(Floating Panel)。它与层栈分开:不登记成层,不参与 Esc、外部点击、焦点域与背景失活,模态浮层照旧由层栈排在所有浮动窗口之上。
- 键是调用方自己的任意对象,叠放序只记先后。
raise(key)把它放到最上面,还不在栈里就加进来;已经在最上面时不变、不通知。remove(key)移出,不在栈里时不做事。 depthOf(key)从上往下数:最上面是 0,往下 1、2…,增删之后序号收紧;不在栈里为 -1。叠放序每变一次通知订阅者一次,单个订阅者抛错不截断后面的订阅者。- 按 Document 各一份,存在 Document 上:页面上装了几份
@xihan-ui/core、Vue / React / Web Components 混用时,所有浮动面板排在同一个序里。
浮动面板展开时提到最上面,在面板上按下或焦点进到面板里再提一次,收起或卸载时移出;定位层的私有层号 --xh-_layer 取抽屉层减去 depthOf:最上面的面板就在抽屉层上,往下每一扇减一,所以不会高过模态层,面板里弹出的下拉、提示照旧盖在面板上面。皮肤照层级链取 --xh-floating-panel-layer → --xh-_layer → --xh-layer-drawer,写了 --xh-floating-panel-layer 就按写的来,不再排叠放序。
消隐层
import { createDismissLayer, createEscapeFallback } from "@xihan-ui/core";
const layer = createDismissLayer({
config, // RuntimeConfig:scope + 层注册表 + 豁免配置
layer, // 已注册的层
onDismiss: (reason) => { /* 'escape-key' | 'pointer-down-outside' | 'focus-outside' | 'programmatic' */ },
onEscapeKeyDown: (e) => { /* preventDefault() 即这次别关 */ },
onPointerDownOutside: (e) => {},
onFocusOutside: (e) => {},
onInteractOutside: (e) => {}, // 上面两者任一发生时也派发一次
});
const fallback = createEscapeFallback({
config,
isEnabled: () => sheetOpen,
onEscape: event => closeSheet(event),
});两条约束:
- 只有栈顶层响应
Escape。否则一次按键会关闭整个栈。 - 四个回调都是可取消的表决票。它们收到的是
cancelable的CustomEvent,preventDefault()即否决本次关闭。四种票均通过detail.originalEvent保留同一原生事件对象:Escape 为 KeyboardEvent,Pointer 为 PointerEvent,Focus 为 FocusEvent,Interact 为 PointerEvent 或 FocusEvent。DOM 监听与选项回调收到同一张可取消票;specific 与 interact 两票送达后统一检查取消结果。这使表单未填完时按 Esc 先弹出确认这类需求不必绕开组件实现。
DismissableLayer 的监听 Document、CustomEvent、微任务与动画帧均取自 config.scope 的同一个 Window,config.layerRegistry.ownerDocument 也必须逐字指向该 Document。传入的 layer 必须已经登记在这份注册表里;动态 layer.node() 可以暂时为 null,非空时必须是真实 HTMLElement 且属于该 Document。从其他窗口返回节点会立即报错,不会把一张文档里的交互票派到另一张文档。所属 Window 缺少 CustomEvent、queueMicrotask 或动画帧能力时创建即失败,不借 ambient 全局。
同一 Document 的 DismissableLayer 与 EscapeFallback 共用一套 Hub;每一份显式 LayerRegistry 按对象身份形成独立 lane,不同应用或子树的自定义层栈互不干扰。同一 lane 的同一 Layer 只能有一个消解参与者,重复创建会明确失败。首个租约同步事务化安装四个监听:keydown capture、pointerdown capture、focusin capture、keydown bubble;最后一个租约释放时按 keydown bubble、focus、pointer、keydown capture 的严格逆序完整卸载。DismissableLayer 参与者仍延后一枚所属 Window 的微任务武装,以避开打开浮层的同一次 pointerdown;EscapeFallback 注册后同步生效。派发处理期间最后一个租约离场时,Hub 把卸载延到当前处理阶段的 finally,回调里同步建立的新租约会复用原 Hub,不会出现两套 Document 监听。
pointer 与 focus 先为本次事件冻结一份 composed path,并在执行任何业务回调前冻结所有 lane 的 LayerRegistry snapshot。每条 lane 从事件开始时的真实栈顶向下生成纯计划:命中层内部即停;命中 surface 时计划关闭该层后停止;没有 DismissableLayer 参与者、参与者尚未武装或节点尚未在场时都是屏障(悬停就出现的层例外,见下文)。计划阶段每次读取 node、branches 或 surfaces 后都会复核原 snapshot;getter 改变层栈时整条 lane 当场作废,不再读取更低层 getter。执行每个候选前都会确认当前栈严格等于原计划去掉已退栈各层之后剩下的那几层、候选之上只留着悬停就出现的层、参与者 token 与动态节点仍相同;DOM 表决、选项表决与 interact 表决之后逐阶段复核。票被否决、回调改变 snapshot/node、动态 branch 把原目标纳入层内,都会停止该 lane;动态 surface 命中会关闭其所属层,随后停止,不继续触碰更低层。
onDismiss 完整返回后,只有候选 Layer 确实退栈、当前栈严格变成原计划去掉已退栈各层之后剩下的那几层时,pointer/focus 才继续处理下一层。受控组件只发关闭意图却未退栈、关闭开关让 onDismiss 原地返回、额外移除旧层或登记新层都会形成屏障。正常逐层退栈产生的新冻结 snapshot 会成为下一候选的 stage token;成功变更后再恢复相同内容仍不能冒充原 snapshot,失败登记由 LayerRegistry 补偿回原对象则保持有效。Escape 每条 lane 只处理事件开始时的原始栈顶,一次按键不会沿栈连续关闭。
上面的屏障按栈序挡住下面每一层:经按下、聚焦打开的层,打开它的那次交互没有收掉下面的层,说明它开在里面,栈序即嵌套。悬停就出现的层不在此列:内联层(kind: 'inline',提示与导航面板)都是这样,别的层悬停就出现时在登记时写 opensOnHover: true,库内是悬停卡片、引用的悬停预览、悬停展开的菜单(openOnHover,子菜单缺省如此)、悬停展开的浮动按钮(expandTrigger="hover")和侧边导航图标栏的浮出面板;点击展开的菜单与浮动按钮不写,照旧按栈序。这样的层压在别的层上面不代表开在那一层里,所以它留在栈里时不按栈序挡住下面每一层。交互落在它里面,或者它还判不了这次交互(没有参与者、尚未武装、节点不在场)时,它只护着它开在其内容里的层:那一层的根节点装着它的根节点或某个分支,按事件那一刻的 DOM 判;被护住的层不消解,被护住的层若经按下、聚焦打开,照旧挡住自己下面每一层,若也是悬停就出现的,把保护再往上传。交互落在它外面时它谁也不护:它否决了层外票、收到关闭意图却没退栈(多半正在播退场),下面的层连同它开在其内容里的那一层照常求值、照常消解,执行时它留在候选之上也不算栈变了。下拉开着时指针移到带提示的按钮上,提示压在下拉上面:按这颗按钮、按空白处、提示正在播退场,下拉都照常收起。对话框、弹出层里一颗按钮的提示被按中时,外面那层不收;按在层外时,提示可见也好、正在播退场也好,提示和它所在的那一层在这一下一并收起,那一层等提示的层先释放(见层栈)。悬停卡片、悬停展开的菜单与浮动按钮、图标栏的浮出面板同理:下拉开着时它们压在上面,按它们的触发器、按在它们里面、按空白处,下拉都照常收起;它们开在弹出层、对话框里时,按在它们里面外面那层不收。悬停展开的菜单还会把焦点拿进自己的内容里,下面与它无关的下拉在它出现后随即因失焦收起,不用等按下。Escape 不分层的种类,仍只交给真实栈顶,提示在场时先收提示。
createEscapeFallback() 给同一 lane 的空层栈提供 Escape 后备出口,适合覆盖侧栏这类不登记为 Layer 的界面。keydown capture 会在任何 Layer getter、表决或业务回调前冻结全部 lane 的 registry snapshot 与 fallback token snapshot;当时只要 lane 存在任意 Layer,该 lane 的本次按键就已消费,即使 Layer 没有消解参与者、尚未武装、否决关闭或同步退栈,bubble 也不会接着调用 fallback。空栈 lane 从最新 token 反向查找第一个 isEnabled() 为真的出口,最新出口已启用后不会再读取更早 getter。
原生事件到达 Document bubble 且在进入 Hub 前尚未 defaultPrevented 时,Hub 才复核 capture 计划。registry 必须仍是原来的空快照,fallback 列表、所选 token 以及为选出它而读过的启用状态也必须逐项相同;capture 后新注册或新启用出口、dispose 后重建、Layer 空栈成功经历非空再回空,都会让旧计划失效。失败 Layer 登记补偿回同一 snapshot 则保持有效。同一 lane 每次只调最新的一个出口;它不释放时会持续占位,释放发生在 capture 之后时本键也不会降级到旧出口。不同 registry lane 各自执行一个,某个 fallback 收到同一原生 KeyboardEvent 后调用 preventDefault() 不会反向否决其他 lane。目标节点在事件到达 Document bubble 前调用 preventDefault() 或阻止传播,则全部 fallback 都不执行。
触摸在 pointerdown 时只建立关闭候选,不立即派发层外票;匹配 pointerup 仅标记触摸完成,随后同目标的 click 才提交原计划。鼠标与笔仍在 pointerdown 时处理。触摸提交的 detail.originalEvent 仍为最初的 PointerEvent,click 只作为内部完成证据。
滚动、指针取消、长按菜单、新指针、窗口失焦、文档可见性变化或新的键盘交互会取消候选。参与者、节点或 Layer 快照失效也不会提交;等待期间焦点事件不抢先关闭浮层。这条路径没有固定超时或 pointerup 自动关闭,某一 registry lane 校验抛错只剔除该 lane,其余有效 lane 仍可完成 click。
Hub 在每个 capture 或 bubble 处理阶段持有重入锁,getter、表决、关闭或 fallback 回调同步派出的事件不会嵌套进入该阶段;原事件的 target 阶段仍按浏览器传播模型正常运行。某条 registry lane 的 getter、表决或关闭回调抛错时,其他 lane 仍照常执行;末尾单错原样抛出,多错按 lane 与清理的发生顺序聚合,首错保留为 cause。Hub 的 add、queue、动画帧和 remove 都使用所属 Window/Document,并遵守先终态、LIFO、全量尝试的清理规则。
焦点域
import { createFocusScope } from "@xihan-ui/core";
const scope = createFocusScope({
config,
layer,
container: () => contentEl,
trapped: () => isModal, // 焦点不得逃逸,生命周期内可变
loop: true, // Tab 到边界回绕,与 trapped 正交
branches: () => nestedPortals,
initialFocus: () => firstInputEl,
restoreFocus: () => true, // 卸载时把焦点归还给创建前的元素,默认开启
onMountAutoFocus: (e) => {}, // 可 preventDefault 接管首次聚焦
onUnmountAutoFocus: (e) => {},
});trapped 与 loop 是两件事:陷住(逃不出去)和回绕(Tab 到末尾回到开头)。模态对话框两者都要;非模态气泡通常只要回绕。
容器上的 xh.focusScope.mountAutoFocus / xh.focusScope.unmountAutoFocus DOM 事件与两个选项回调收到同一个 cancelable CustomEvent。成功绑定容器后,每个生命周期只派发一次,DOM 监听器先执行,选项回调随后执行;两条通道都会收到通知。任一通道调用 preventDefault(),FocusScope 都不再执行对应的默认聚焦或焦点归还。scope 从未取得容器时不会伪造 body 事件。
restoreFocus: () => false 或同一 Document 中更新的焦点域仍在场时,unmount 通知仍会发出,只跳过默认归还。不同 Document 的焦点域互不抑制;跨窗口交接若不应归还,调用方必须用 preventDefault() 或 restoreFocus: () => false 明确表达。回调抛错会直接暴露;挂载回调抛错时,FocusScope 会先撤销监听、哨兵和层订阅,不留下拿不到句柄的半成品。
挂载回调是同步表决点;要让 FocusScope 在 DOM 稳定后聚焦指定节点,使用 initialFocus,它会沿既定帧预算重试。回调取消后自行安排异步焦点时,调度与目标有效性由调用方负责。
非栈顶的焦点域自动暂停:上方新开一层时,下层不再争抢焦点。
focusSafely 与 focusFirst 按候选节点所属的 Document/ShadowRoot 判断焦点是否真正落下;可选中文本控件通过严格 HTMLElement 身份与 HTML 节点名识别,不依赖可能因 adoptNode 改变的 owner realm 构造器。Shadow DOM 中 document.activeElement 只指向 host,不能拿它判断内部候选失败或重复聚焦。
陷焦点域会精确观察最后一个域内焦点的 DOM/ShadowRoot 祖先路径。该路径被移除后,FocusScope 等到所属 Window 的下一动画帧再确认最终焦点;只有域仍在场、仍为活动层、trapped 仍开启且业务没有把焦点交给其他有效节点时,才恢复到重新插回的原节点、主容器首个有效项或容器。暂停中的域保留待复核资格,重新取得同一 Document 的焦点所有权后再继续;每次尝试候选后都会重新仲裁,候选的 focus 回调同步打开新层时不会再触碰后续节点。动态 branches、closed ShadowRoot 与容器替换走同一判据,普通 DOM 删除不会触发补焦。
公开 FocusableElement 统一表示 Element & HTMLOrSVGElement,即具备浏览器原生焦点能力的 Element。getTabbables、focusSafely、focusFirst、focusItem、Scope 活动元素和 FocusScope 的显式焦点目标都使用这份合同;带 tabindex 的 SVG/MathML 不会再被类型系统遗漏。
滚动锁
import { acquireScrollLock } from "@xihan-ui/core";
const lock = acquireScrollLock({ config });
lock.dispose();锁是引用计数的:叠加三层浮层即加锁三次,全部释放后才真正解锁并还原滚动位置。
锁定的元素由 config.scrollRoot() 明确决定。返回 null、body、documentElement 或 scrollingElement 都锁定页面;返回其他节点则锁定该容器,节点必须是所属 Scope Document 内已连接的原生 HTMLElement。内容容器承担滚动时应显式返回它,滚动锁不扫描页面猜测目标。
同一 Document 里同一个规范化目标的并行锁共用一把锁、按句柄计数,最后一个句柄释放时才还原;不同目标各锁各的,比如外层对话框锁着布局里的滚动容器、里层对话框(另一份配置没给 scrollRoot)锁页面,两处都锁住,谁先释放都只还原自己那处。每轮保存双轴滚动位置、内联样式值与优先级及原 gutter 变量。初始化失败逆序回滚,最终释放先终结状态再尝试全部恢复;业务期间主动改写的样式不会被旧锁覆盖。页面位置按 instant 行为恢复,避免受 smooth 滚动影响。
加锁期间让出来的滚动条宽度写在文档根的 --xh-scroll-lock-gutter 上,供 fixed 定位的元素让位。没让出宽度(页面没有滚动条、或已用 scrollbar-gutter: stable 预留)且根上原本没有这个变量时不写——根上的自定义属性一变整份文档都要重算样式,所以消费时总要带 0px 回退值:
.my-fixed-header {
padding-inline-end: var(--xh-scroll-lock-gutter, 0px);
}浮层内部需要滚动的场景不依赖白名单:锁修改的是滚动容器本身,浮层是 portal 出去的独立子树,其内部滚动不受影响。
背景失活
import { hideOutside } from "@xihan-ui/core";
const restore = hideOutside(() => [
contentEl,
...branches,
...config.layerRegistry.elementsAbove(layer),
], config, {
exemptSelectors: [".my-portal-root"],
});沿每个目标到 body 的祖先链,给链上每一层里既不在链上、也不豁免的兄弟加 aria-hidden="true",背景内容从无障碍树里消失;实时区域(aria-live、role="status" / alert / log)与脚本、样式这类节点不藏,模态开着时播报照旧。焦点还留在被藏起的背景里时照 inert 的样子放掉(blur()),之后再往背景里塞焦点由焦点域拉回。
只打 aria-hidden、不打 inert:inert 会让浏览器把被罩住的整棵子树(通常是整个应用根)的样式重算一遍,几千个节点的页面上开、关各要几十毫秒;aria-hidden 不进样式计算,开销与页面大小无关。键盘由焦点域收在浮层里,指针由遮罩(或模态底板)拦下。
第一个参数是函数而不是数组:藏起背景的时机横跨整个展开期(MutationObserver 观察后续新增到 body 的节点),晚于调用时刻挂载的节点也必须能计入目标。目标必须包含全部分支节点,以及栈中位于自己之上的层(config.layerRegistry.elementsAbove(layer)),漏传会把 portal 出去的嵌套浮层一起藏起:看得见,读屏却跳过。
第二个参数必须同时提供 Scope 和计算 elementsAbove 的同一份 LayerRegistry,通常直接传 RuntimeConfig。hideOutside 只订阅该实例的层栈变化,并校验注册表的 ownerDocument 与 Scope Document 相同;自定义注册表、iframe 与画中画窗口都不再暗中切换到按 Document 获取的默认注册表。
带 data-xh-inert-exempt 的元素默认豁免。
hideOutside 的目标、body、层栈、MutationObserver 与 aria-hidden 引用计数严格属于 Scope 的同一 Document。iframe 与画中画窗口中的后挂节点会由各自 Window 的观察器重新计算;从其他窗口 adopt 进来的豁免节点也按当前所属 Document 生效。注册表或目标来自其他 Document、Document 没有活动 Window 或宿主缺少 MutationObserver 时会明确报错,初始化失败不会留下半施加的 aria-hidden。
页面级播报
import { announce } from "@xihan-ui/core";
announce("属性面板已移到左栏第 2 位");announce(message, { document? }) 以礼貌级读一句话,不打断读屏正在读的内容。整页只有一个播报区:第一次播报时在 body 末尾建一个视觉隐藏的 role="status"(aria-live="polite"、aria-atomic="true",带 data-xh-announcer),之后的播报都写进它。
- 每次先清空、隔一拍再写,同一句话连播两次读屏也会再读;一拍之内连着播几句只留最后一句;写下约 7 秒后清空,读屏翻到页尾时读不到过时的话。
message为空字符串时只清空。 - 播报区挂在
body下,不在任何组件里:模态浮层打开、背景被hideOutside藏起时它照旧可读。被宿主从 DOM 里摘掉了,下次播报时挂回去。 - 按 Document 各一个,iframe 或画中画窗口里的播报传
document;页面上装了几份@xihan-ui/core也共用同一个。没有document(服务端渲染)时不做事。
分工:组件自己的状态变化由组件自带的播报区负责,比如剪贴板(Clipboard)复制后的「已复制」、走马灯(Carousel)的换页、审批(Approval)的批准与拒绝,读的时机和文案跟着组件走,不经这里。不属于某一个组件的页面级播报用 announce,比如设计器外框挪动面板后的提示、历史面板跳转后的「跳到哪一步」。
进出场
退场动画与何时可以从 DOM 移除是一对常见问题。presence 用租约解决:
export interface PresenceHandle {
readonly open: boolean; // 逻辑状态:该开着吗
readonly rendered: boolean; // 渲染状态:DOM 还该留着吗
readonly state: "open" | "closed"; // 直接绑到 data-state
claimExit: (reason: string) => ExitLease;
onBeforeExit: (fn: () => void) => Cleanup;
onExitComplete: (fn: () => void) => Cleanup;
update: (open: boolean) => void;
}关闭时先同步触发 onBeforeExit,动画探测器在此申领租约;所有租约归还之前 rendered 保持 true,DOM 不移除。退场中途又被打开则取消旧租约,不卸载。租约不设猜测时限:CSS 观察器等待浏览器实际创建的有限动画对象完成或取消,同名的多个动画也分别计入;没有实际动画对象时不等待,无限装饰动画不阻塞退出。减弱动效下退场关键帧去掉位移、只剩 150ms 淡出,租约照样等它播完。自定义动画租约由创建方明确完成或取消。
Dialog 与共用其机器的 Drawer 在逻辑关闭时立即给内容设置 inert 和 aria-hidden,浮层登记与滚动锁保留到内容及遮罩完成退场,然后通知 onExitComplete(Vue/Web Components 为 exit-complete)。退场中重开保留原资源,旧完成不影响新状态;必要时通过原焦点域的 reactivate() 恢复域内焦点,不重复派发挂载自动聚焦通知。卸载立即释放资源,不等 CSS。
模态浮层(Dialog、Drawer、Command、ImageViewer、模态 Popover)的背景失活由两件事组成,都不碰背景节点的样式:背景打 aria-hidden(hideOutside),对读屏隐藏;没有遮罩的层(模态 Popover,或没放遮罩的对话框)在自己的定位层里垫一块铺满视口的透明底板(data-xh-modal-underlay,z-index: -1,画在本层自己的层叠上下文里),点在底板上即层外交互,由消解层照常收起,背景上的控件收不到这次点击。两件事都与页面大小无关,展开时随宿主提交当场施加;关闭时当场撤下,再把焦点归还触发器,焦点不会落进还对读屏藏着的背景。之后又重开即补回;没有退场动画、资源当场释放时背景失活随之撤下。
适配器必须在 data-state 已提交到 DOM 之后才调用 update(open):先改属性再让 CSS 过渡开始,顺序颠倒时动画不会播放。
集合导航
列表型组件(菜单、列表框、组合框、树、标签页)共用一套条目导航:
import { focusItem, navigateItems, navIntentFromKey, queryItems } from "@xihan-ui/core";
const items = queryItems(rootEl, { scope: "menu", part: "item" });
const intent = navIntentFromKey(event, { axis: "vertical", dir: "ltr" });
if (intent) {
event.preventDefault();
focusItem(navigateItems(items, currentValue, intent, { loop: true }));
}navIntentFromKey 把按键翻译为方向意图(next / prev / first / last),并处理两件容易出错的事:轴向(垂直列表不响应左右键)与书写方向(RTL 下左右键语义互换)。不属于导航的按键返回 null,此时不得 preventDefault,否则会拦截输入法、快捷键和浏览器默认行为。
条目的禁用与身份通过统一的 data 标记读取,因此判定逻辑对所有组件一致。
Typeahead
import { createTypeahead } from "@xihan-ui/core";
const typeahead = createTypeahead({ timeout: 350 });
const query = typeahead.push(event.key); // 不参与检索的键返回 null
typeahead.clear(); // 收起浮层、切换焦点组时丢弃缓冲连续按键在超时窗口内累积成查询串,超时后重新开始。空格只在缓冲区非空时参与检索,否则会拦截空格选中。
贴底
流式输出的消息列表需要新内容到达时自动滚到底部,用户向上翻阅后不再争抢:
import { createStickToBottom } from "@xihan-ui/core";
const stick = createStickToBottom({
config,
scrollEl: () => viewportEl,
contentEl: () => contentEl, // 尺寸变化的观察目标
threshold: 64, // 距底部多少 px 视为在底部
onChange: (state) => {},
});
stick.scrollToBottom(); // 减弱动态效果开启时自动改为 'instant'
stick.retarget(); // 节点更换后解绑重绑会话线程组件使用的就是它。
光标矩形
浮层要贴着输入框里的光标弹出时(如表达式补全),把光标矩形当作虚拟锚点:
import { caretClientRect } from "@xihan-ui/core";
const anchor = {
getBoundingClientRect: () =>
caretClientRect(textarea, textarea.selectionEnd ?? 0) ?? textarea.getBoundingClientRect(),
};矩形是视口坐标,宽度为 0,纵向是光标所在那一行,已经扣掉输入框自己的滚动。<textarea> 按当前内容宽度软换行,<input> 取内容区里居中的那一行。元素不在文档里或没有布局时返回 null。
其他
| 导出 | 用途 |
|---|---|
prefersReducedMotion() / onReducedMotionChange() | 读取与订阅减弱动态效果的系统偏好 |
easing | 一组具名缓动函数 |
applySelection() / toggleSelectAll() / rangeBetween() | 选中集合运算:带锚点的范围选、全选与切换,不涉及 DOM |
getTabbables() / focusFirst() / focusSafely() | 可聚焦元素查询与安全聚焦 |
