来源：https://ui.docs.xihanfun.com/guide/behavior

# 行为原语

`@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 的视觉环境

Vue 浮层与 React `XhPortal` 为每个实例建立独立的 `display: contents` 容器，把逻辑来源的视觉环境带到浮层落点。主题、品牌、密度、对比度、动效、透明度和方向分别读取最近的显式声明；语气经 `data-tone` 属性一并带过去，浮层内按[在自定义节点上使用语气](./styling#在自定义节点上使用语气)读 `--xh-tone-*` 的节点取到的是来源那一族。来源祖先链上局部覆盖的 CSS 自定义属性也会投影到实例壳。局部深色或局部令牌覆盖的弹层因此仍保持来源外观，同一落点下的其他实例不受影响。来源没有声明的轴与变量继续继承落点容器。

自定义属性只带属于视觉环境的那部分。作者自己的属性（不以 `--xh-` 开头）照投；`--xh-` 命名空间只投文档根上有声明的名字，即令牌与按[覆盖样式的三种粒度](./styling#覆盖样式的三种粒度)写在 `: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]` 时，索引整体让位，按原来的方式重算。

```ts
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`、`dir`，以及语气 `data-tone` 与材质轴 `data-material`。三端物理 Portal 都从逻辑来源的 composed 祖先逐项取最近显式声明，并把它们与上述自定义属性投影到实例壳；来源变化、跨 realm 与退场回收均由 Core 桥接生命周期处理。

## 在 Vue 中使用

原语都是框架无关的：接收一份配置与几个元素 getter，返回一个需要自行释放的句柄。接入 Vue 只是把释放挂到作用域结束，这层包装位于 `@xihan-ui/vue/behavior`：

```ts
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` 除状态外还返回句柄上的两个动作，回到底部按钮使用的是前者：

```ts
const { state, scrollToBottom } = useStickToBottom({
  config,
  scrollEl: () => viewportRef.value,
  contentEl: () => contentRef.value,
});

// state.value?.atBottom 为假时显示回到底部按钮，点击调用 scrollToBottom()
```

两个 getter 中读取的是 ref 时不必自行 `retarget`：节点更换后这层包装会重新绑定。

需要层栈流程的原语不在这里：消隐层、焦点域、背景失活要按顺序接入多个部分，接错的表现是点击子菜单导致父层关闭这类不报错的异常。该场景直接使用库内的浮层组件；确需自建时按下文各节的顺序接入。

## 层栈

浮层不是孤立的，它们叠成一个栈。`LayerRegistry` 是这个栈的登记表：

```ts
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 的宿主
}

export interface LayerRegistry {
  readonly ownerDocument: Document;
  // register / list / top / elementsAbove / subscribe ...
  visualOf: (layer: Layer) => {
    visualIndex: number;
    visualLane: number;
    visualLayer: string;
  };
  sync: (layer: Layer) => void; // isModal 等动态 getter 改值后显式通知
}
```

两个概念需要单独说明：

- `branches`（分支）：嵌套 portal 出去的子层。菜单开在对话框中、子菜单再 portal 到 body，DOM 上它们是兄弟，逻辑上是父子。漏登记分支会使点击子菜单被判定为点击外部，父层随之关闭。
- `surfaces`（表面）：遮罩这类点击即应关闭的元素。它属于本层，但点击它的语义是关闭而不是点击层内。

默认情况下同一 Document 共用一个注册表；自定义注册表也会在创建时固化唯一的 `ownerDocument`，公共记录本身被冻结。不同 Document（iframe、画中画窗口）的注册表不能混用。

视觉层级不再由各组件各自拿静态 `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`）。背景失活要用它把上层排除在自己的管辖之外。

单个 Headless 浮层 layer effect 会把“登记 layer → 建消解层 → 建焦点域 → 加滚动锁/背景失活”放在同一初始化事务里。同步步骤与宿主 `flush` 后才执行的背景失活都经过事务守卫；任一步抛错都会把已经取得的资源按逆序全部释放，最后移除 layer。effect 成功后交给机器的也是同一份幂等逆序 cleanup。单项清理失败原样抛出，多项失败则聚合报告，且两者都会继续清完其余项，避免错误层永久占着栈顶。

## 消隐层

```ts
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 只处理事件开始时的原始栈顶，一次按键不会沿栈连续关闭。

`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、全量尝试的清理规则。

## 焦点域

```ts
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 不会再被类型系统遗漏。

## 滚动锁

```ts
import { acquireScrollLock } from "@xihan-ui/core";

const lock = acquireScrollLock({ config });
lock.dispose();
```

锁是引用计数的：叠加三层浮层即加锁三次，全部释放后才真正解锁并还原滚动位置。

锁定的元素由 `config.scrollRoot()` 明确决定。返回 `null`、`body`、`documentElement` 或 `scrollingElement` 都锁定页面；返回其他节点则锁定该容器，节点必须是所属 Scope Document 内已连接的原生 HTMLElement。内容容器承担滚动时应显式返回它，滚动锁不扫描页面猜测目标。

同一 Document 的并行锁共享同一个规范化目标，混用不同目标会明确失败；最后一把锁释放后，下一次获取可以使用新目标。每轮保存双轴滚动位置、内联样式值与优先级及原 gutter 变量。初始化失败逆序回滚，最终释放先终结状态再尝试全部恢复；业务期间主动改写的样式不会被旧锁覆盖。页面位置按 instant 行为恢复，避免受 smooth 滚动影响。

加锁期间让出来的滚动条宽度写在文档根的 `--xh-scroll-lock-gutter` 上，供 `fixed` 定位的元素让位：

```css
.my-fixed-header {
  padding-inline-end: var(--xh-scroll-lock-gutter, 0px);
}
```

浮层内部需要滚动的场景不依赖白名单：锁修改的是滚动容器本身，浮层是 portal 出去的独立子树，其内部滚动不受影响。

## 背景失活

```ts
import { hideOutside } from "@xihan-ui/core";

const restore = hideOutside(() => [
  contentEl,
  ...branches,
  ...config.layerRegistry.elementsAbove(layer),
], config, {
  exemptSelectors: [".my-portal-root"],
});
```

给 `body` 下除目标与豁免节点外的直接子元素加 `inert`，背景内容对读屏与键盘一并消失。

第一个参数是函数而不是数组：施加 `inert` 的时机横跨整个展开期（`MutationObserver` 观察后续新增到 `body` 的节点），晚于调用时刻挂载的节点也必须能计入目标。目标必须包含全部分支节点，以及栈中位于自己之上的层（`config.layerRegistry.elementsAbove(layer)`），漏传会把 portal 出去的嵌套浮层一起设为 inert：可见但无法操作。

第二个参数必须同时提供 Scope 和计算 `elementsAbove` 的同一份 `LayerRegistry`，通常直接传 `RuntimeConfig`。`hideOutside` 只订阅该实例的层栈变化，并校验注册表的 `ownerDocument` 与 Scope Document 相同；自定义注册表、iframe 与画中画窗口都不再暗中切换到按 Document 获取的默认注册表。

带 `data-xh-inert-exempt` 的元素默认豁免。

`hideOutside` 的目标、`body`、层栈、`MutationObserver` 与 inert 引用计数严格属于 Scope 的同一 Document。iframe 与画中画窗口中的后挂节点会由各自 Window 的观察器重新计算；从其他窗口 adopt 进来的豁免节点也按当前所属 Document 生效。注册表或目标来自其他 Document、Document 没有活动 Window 或宿主缺少 `MutationObserver` 时会明确报错，初始化失败不会留下半施加的 inert 状态。

## 进出场

退场动画与何时可以从 DOM 移除是一对常见问题。`presence` 用租约解决：

```ts
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 观察器等待浏览器实际创建的有限动画对象完成或取消，同名的多个动画也分别计入；没有实际动画对象时不等待，无限装饰动画不阻塞退出。减弱动效下退场关键帧去掉位移、只剩 120ms 淡出，租约照样等它播完。自定义动画租约由创建方明确完成或取消。

Dialog 与共用其机器的 Drawer 在逻辑关闭时立即给内容设置 `inert` 和 `aria-hidden`，保持浮层登记、滚动锁与背景失活直到内容及遮罩完成退场，然后通知 `onExitComplete`（Vue/Web Components 为 `exit-complete`）。退场中重开保留原资源，旧完成不影响新状态；必要时通过原焦点域的 `reactivate()` 恢复域内焦点，不重复派发挂载自动聚焦通知。卸载立即释放资源，不等 CSS。

适配器必须在 `data-state` 已提交到 DOM 之后才调用 `update(open)`：先改属性再让 CSS 过渡开始，顺序颠倒时动画不会播放。

## 集合导航

列表型组件（菜单、列表框、组合框、树、标签页）共用一套条目导航：

```ts
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

```ts
import { createTypeahead } from "@xihan-ui/core";

const typeahead = createTypeahead({ timeout: 350 });
const query = typeahead.push(event.key); // 不参与检索的键返回 null
typeahead.clear(); // 收起浮层、切换焦点组时丢弃缓冲
```

连续按键在超时窗口内累积成查询串，超时后重新开始。空格只在缓冲区非空时参与检索，否则会拦截空格选中。

## 贴底

流式输出的消息列表需要新内容到达时自动滚到底部，用户向上翻阅后不再争抢：

```ts
import { createStickToBottom } from "@xihan-ui/core";

const stick = createStickToBottom({
  config,
  scrollEl: () => viewportEl,
  contentEl: () => contentEl, // 尺寸变化的观察目标
  threshold: 64, // 距底部多少 px 视为在底部
  onChange: (state) => {},
});

stick.scrollToBottom(); // 减弱动态效果开启时自动改为 'instant'
stick.retarget(); // 节点更换后解绑重绑
```

会话线程组件使用的就是它。

## 其他

| 导出 | 用途 |
| --- | --- |
| `prefersReducedMotion()` / `onReducedMotionChange()` | 读取与订阅减弱动态效果的系统偏好 |
| `easing` | 一组具名缓动函数 |
| `applySelection()` / `toggleSelectAll()` / `rangeBetween()` | 选中集合运算：带锚点的范围选、全选与切换，不涉及 DOM |
| `getTabbables()` / `focusFirst()` / `focusSafely()` | 可聚焦元素查询与安全聚焦 |

## 相关

- [浮层定位](./position)：坐标的计算方式
- [状态机运行时](./machine)：这些原语在 effects 中装配

- [无障碍与键盘规格](./a11y)：焦点与按键的规格出处
