@greypan/web-ui 10.2.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.CN.md +68 -68
  2. package/README.md +43 -43
  3. package/dist/assets/glass.js.map +1 -1
  4. package/dist/assets/menu-portal.js.map +1 -1
  5. package/dist/assets/modal-surface.js.map +1 -1
  6. package/dist/assets/overlay-motion.js.map +1 -1
  7. package/dist/assets/selection-control.js.map +1 -1
  8. package/dist/components/autocomplete/index.d.ts +6 -6
  9. package/dist/components/autocomplete/index.js +1 -1
  10. package/dist/components/autocomplete/index.js.map +1 -1
  11. package/dist/components/autocomplete/style.js.map +1 -1
  12. package/dist/components/avatar/index.js.map +1 -1
  13. package/dist/components/back-top/index.js.map +1 -1
  14. package/dist/components/back-top/style.js.map +1 -1
  15. package/dist/components/badge/index.js +1 -1
  16. package/dist/components/badge/index.js.map +1 -1
  17. package/dist/components/button/index.js.map +1 -1
  18. package/dist/components/button/style.js.map +1 -1
  19. package/dist/components/checkbox/index.d.ts +1 -1
  20. package/dist/components/checkbox/index.js +1 -1
  21. package/dist/components/checkbox/index.js.map +1 -1
  22. package/dist/components/checkbox/style.js.map +1 -1
  23. package/dist/components/checkbox-group/index.d.ts +9 -0
  24. package/dist/components/checkbox-group/index.js +8 -2
  25. package/dist/components/checkbox-group/index.js.map +1 -1
  26. package/dist/components/collapse/index.d.ts +5 -5
  27. package/dist/components/collapse/index.js.map +1 -1
  28. package/dist/components/collapse/style.js.map +1 -1
  29. package/dist/components/context-menu/index.d.ts +60 -60
  30. package/dist/components/context-menu/index.js +2 -2
  31. package/dist/components/context-menu/index.js.map +1 -1
  32. package/dist/components/context-menu/scrim.js.map +1 -1
  33. package/dist/components/context-menu/style.js.map +1 -1
  34. package/dist/components/dialog/index.d.ts +4 -4
  35. package/dist/components/dialog/index.js +1 -1
  36. package/dist/components/dialog/index.js.map +1 -1
  37. package/dist/components/dialog/style.js.map +1 -1
  38. package/dist/components/drawer/index.d.ts +8 -8
  39. package/dist/components/drawer/index.js +1 -1
  40. package/dist/components/drawer/index.js.map +1 -1
  41. package/dist/components/drawer/style.js.map +1 -1
  42. package/dist/components/dropdown/index.d.ts +6 -6
  43. package/dist/components/dropdown/index.js.map +1 -1
  44. package/dist/components/dropdown-item/index.js.map +1 -1
  45. package/dist/components/editable-text/index.d.ts +2 -2
  46. package/dist/components/editable-text/index.js.map +1 -1
  47. package/dist/components/editable-text/style.js.map +1 -1
  48. package/dist/components/empty/index.d.ts +3 -3
  49. package/dist/components/empty/index.js.map +1 -1
  50. package/dist/components/image-preview/index.d.ts +5 -5
  51. package/dist/components/image-preview/index.js +7 -7
  52. package/dist/components/image-preview/index.js.map +1 -1
  53. package/dist/components/image-preview/style.js.map +1 -1
  54. package/dist/components/input/index.js +4 -1
  55. package/dist/components/input/index.js.map +1 -1
  56. package/dist/components/input/style.js.map +1 -1
  57. package/dist/components/input-number/index.d.ts +6 -0
  58. package/dist/components/input-number/index.js +27 -21
  59. package/dist/components/input-number/index.js.map +1 -1
  60. package/dist/components/layout/index.d.ts +7 -7
  61. package/dist/components/layout/index.js +4 -4
  62. package/dist/components/layout/index.js.map +1 -1
  63. package/dist/components/layout/style.js.map +1 -1
  64. package/dist/components/middle-ellipsis/index.d.ts +1 -1
  65. package/dist/components/middle-ellipsis/index.js.map +1 -1
  66. package/dist/components/middle-ellipsis/truncate.d.ts +1 -1
  67. package/dist/components/middle-ellipsis/truncate.js.map +1 -1
  68. package/dist/components/popover/index.d.ts +1 -1
  69. package/dist/components/popover/index.js.map +1 -1
  70. package/dist/components/popover/style.js.map +1 -1
  71. package/dist/components/radio/index.d.ts +1 -1
  72. package/dist/components/radio/index.js +1 -1
  73. package/dist/components/radio/index.js.map +1 -1
  74. package/dist/components/radio-group/index.d.ts +7 -0
  75. package/dist/components/radio-group/index.js +21 -3
  76. package/dist/components/radio-group/index.js.map +1 -1
  77. package/dist/components/segmented/index.js.map +1 -1
  78. package/dist/components/segmented/style.js.map +1 -1
  79. package/dist/components/segmented-trigger/style.js.map +1 -1
  80. package/dist/components/select/index.js +1 -1
  81. package/dist/components/select/index.js.map +1 -1
  82. package/dist/components/select/style.js.map +1 -1
  83. package/dist/components/slider/index.js +3 -3
  84. package/dist/components/slider/index.js.map +1 -1
  85. package/dist/components/slider/style.js +1 -1
  86. package/dist/components/slider/style.js.map +1 -1
  87. package/dist/components/spinner/index.js +1 -1
  88. package/dist/components/spinner/index.js.map +1 -1
  89. package/dist/components/switch/index.d.ts +3 -0
  90. package/dist/components/switch/index.js +26 -8
  91. package/dist/components/switch/index.js.map +1 -1
  92. package/dist/components/switch/style.js.map +1 -1
  93. package/dist/components/textarea/index.js +1 -1
  94. package/dist/components/textarea/index.js.map +1 -1
  95. package/dist/components/textarea/style.js.map +1 -1
  96. package/dist/components/theme/index.js.map +1 -1
  97. package/dist/components/theme/style.js.map +1 -1
  98. package/dist/components/toast/style.js.map +1 -1
  99. package/dist/components/toast/toast.js +1 -1
  100. package/dist/components/toast/toast.js.map +1 -1
  101. package/dist/components/tooltip/index.d.ts +1 -1
  102. package/dist/components/tooltip/index.js.map +1 -1
  103. package/dist/components/tooltip/style.js.map +1 -1
  104. package/dist/scrollbars/index.d.ts +8 -8
  105. package/dist/scrollbars/index.js.map +1 -1
  106. package/dist/scrollbars/internal.js.map +1 -1
  107. package/dist/shared/element-height/index.js.map +1 -1
  108. package/dist/shared/focus/pointer-focus.js.map +1 -1
  109. package/dist/shared/group-management/index.js +5 -1
  110. package/dist/shared/group-management/index.js.map +1 -1
  111. package/dist/shared/label-emptiness/index.js.map +1 -1
  112. package/dist/shared/menu-behavior/index.js.map +1 -1
  113. package/dist/shared/menu-portal/menu-portal.js.map +1 -1
  114. package/dist/shared/menu-portal/menu-tree.js.map +1 -1
  115. package/dist/shared/option-portal/combobox-shell.js.map +1 -1
  116. package/dist/shared/option-portal/index.js.map +1 -1
  117. package/dist/shared/overlay/anchored-panel.js.map +1 -1
  118. package/dist/shared/overlay/native-dialog-presence.js.map +1 -1
  119. package/dist/shared/overlay/nested-drawer-layers.js.map +1 -1
  120. package/dist/shared/overlay/open-overlay.js.map +1 -1
  121. package/dist/shared/overlay/overlay.js.map +1 -1
  122. package/dist/shared/overlay/portal.js.map +1 -1
  123. package/dist/shared/overlay/presence.js.map +1 -1
  124. package/dist/shared/scroll-lock/scroll-lock.js.map +1 -1
  125. package/dist/shared/theme/root-sync.js.map +1 -1
  126. package/package.json +2 -2
  127. package/dist/icons/generated/heroicons-arrow-down-16-solid.d.ts +0 -6
  128. package/dist/icons/generated/heroicons-arrow-up-16-solid.d.ts +0 -6
  129. package/dist/icons/generated/jam-volume.d.ts +0 -8
  130. package/dist/icons/generated/jam-watch-f.d.ts +0 -8
  131. package/dist/icons/generated/ooui-check.d.ts +0 -6
  132. package/dist/icons/generated/radix-icons-check.d.ts +0 -6
  133. package/dist/icons/generated/si-check-alt-fill.d.ts +0 -6
@@ -4,7 +4,7 @@ import { PartialOptions } from 'overlayscrollbars';
4
4
  */
5
5
  export declare const WEB_UI_SCROLLBARS_THEME = "os-theme-macos";
6
6
  /**
7
- * 全仓统一的滚动条行为:外观对齐 macOS、自动隐藏、滚动时短暂显示、track 点击直达。
7
+ * 全仓统一的 scrollbar 行为:外观对齐 macOS、自动隐藏、滚动时短暂显示、track 点击直达。
8
8
  *
9
9
  * 内容尺寸或平台变化都不需要改这里,只有三条产品要求本身变了才改。覆盖层要实现的是
10
10
  * 同一套手势语言,所以用法是把它整个传给 OverlayScrollbars 初始化,而不是逐项拼接:
@@ -14,17 +14,17 @@ export declare const WEB_UI_SCROLLBARS_THEME = "os-theme-macos";
14
14
  * import '@greypan/web-ui/scrollbars.css'
15
15
  * ```
16
16
  *
17
- * - `autoHide: 'scroll'`:滚动条只在滚动时出现,停下 `autoHideDelay` 之后自动隐去。用户明确选了
17
+ * - `autoHide: 'scroll'`:scrollbar 只在滚动时出现,停下 `autoHideDelay` 之后自动隐去。用户明确选了
18
18
  * 这一档而不是 `'move'`——`'move'` 会让指针在滚动区域内移动就唤出,比 macOS 原生积极得多。
19
- * 代价写清楚:**hover 本身不再唤出滚动条**(隐藏态的元素不可命中,指针根本落不到它上面),
19
+ * 代价写清楚:**hover 本身不再唤出 scrollbar**(隐藏态的元素不可命中,指针根本落不到它上面),
20
20
  * 所以 issue 里「hover 的时候短暂显示」那条只以「滚动时短暂显示」的形式满足。
21
- * - `autoHideDelay: 2000`:这一档同时是「track 点击跳转」的可用窗口——滚动条隐去后它就不可命中。
22
- * 指针从滚动的位置移到轨道、再点下去,实测预算在 1s 上下,默认的 1300ms 太贴边;2000ms 留出
23
- * 余量,又不至于让半透明滚动条在静止画面上久留(macOS 原生的淡出也在 2s 这个量级)。
24
- * - `autoHideSuspend` 保持关闭:打开会让滚动条在首次滚动前一直可见,与「自动隐藏」相反。
21
+ * - `autoHideDelay: 2000`:这一档同时是「track 点击跳转」的可用窗口——scrollbar 隐去后它就不可命中。
22
+ * 指针从滚动的位置移到 track、再点下去,实测预算在 1s 上下,默认的 1300ms 太贴边;2000ms 留出
23
+ * 余量,又不至于让半透明 scrollbar 在静止画面上久留(macOS 原生的淡出也在 2s 这个量级)。
24
+ * - `autoHideSuspend` 保持关闭:打开会让 scrollbar 在首次滚动前一直可见,与「自动隐藏」相反。
25
25
  * - `clickScroll` 走函数形态:`true` 只是「按一个视口步进」,跳到的是当前位置的下/上一屏,
26
26
  * 与点击处的距离无关。`clickScrollDistance: 0` 才是「以点击处为目标」——库把 0 定义为
27
- * 「距离等于目标距离」。函数形态是必需的:这个选项只在实现里经过 `isFunction` 分支才会被读到,
27
+ * 「距离等于目标距离」。函数形态是必需的:这个 option 只在实现里经过 `isFunction` 分支才会被读到,
28
28
  * 直接传对象会被静默忽略。
29
29
  * - `clickScrollDuration: 0`:库默认用 200ms 缓动滚到点击处,在渲染虚拟列表的资源列表上会卡顿,
30
30
  * 这里改成即时落位。0 是合法时长而不是「非法值被忽略」:`animateNumber` 先用 `Math.max(0, ms)`
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/scrollbars/index.ts"],"sourcesContent":["import './style.css'\n\nimport { ClickScrollPlugin, OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'\n\n/**\n * `scrollbars.theme` 的取值,同时也是 `@greypan/web-ui/scrollbars.css` 里主题类的名字。\n */\nexport const WEB_UI_SCROLLBARS_THEME = 'os-theme-macos'\n\n/*\n * track 点击滚动由 ClickScrollPlugin 提供;插件未注册时 `clickScroll` 静默失效——\n * 不报错、不打日志,轨道只是没有反应。注册写在模块作用域,只要消费方用了本模块,\n * 插件就一定在位,不会因为漏掉一行 bootstrap 而退化。\n */\nOverlayScrollbars.plugin(ClickScrollPlugin)\n\n/**\n * 全仓统一的滚动条行为:外观对齐 macOS、自动隐藏、滚动时短暂显示、track 点击直达。\n *\n * 内容尺寸或平台变化都不需要改这里,只有三条产品要求本身变了才改。覆盖层要实现的是\n * 同一套手势语言,所以用法是把它整个传给 OverlayScrollbars 初始化,而不是逐项拼接:\n *\n * ```ts\n * import { webUiScrollbarsOptions } from '@greypan/web-ui/scrollbars'\n * import '@greypan/web-ui/scrollbars.css'\n * ```\n *\n * - `autoHide: 'scroll'`:滚动条只在滚动时出现,停下 `autoHideDelay` 之后自动隐去。用户明确选了\n * 这一档而不是 `'move'`——`'move'` 会让指针在滚动区域内移动就唤出,比 macOS 原生积极得多。\n * 代价写清楚:**hover 本身不再唤出滚动条**(隐藏态的元素不可命中,指针根本落不到它上面),\n * 所以 issue 里「hover 的时候短暂显示」那条只以「滚动时短暂显示」的形式满足。\n * - `autoHideDelay: 2000`:这一档同时是「track 点击跳转」的可用窗口——滚动条隐去后它就不可命中。\n * 指针从滚动的位置移到轨道、再点下去,实测预算在 1s 上下,默认的 1300ms 太贴边;2000ms 留出\n * 余量,又不至于让半透明滚动条在静止画面上久留(macOS 原生的淡出也在 2s 这个量级)。\n * - `autoHideSuspend` 保持关闭:打开会让滚动条在首次滚动前一直可见,与「自动隐藏」相反。\n * - `clickScroll` 走函数形态:`true` 只是「按一个视口步进」,跳到的是当前位置的下/上一屏,\n * 与点击处的距离无关。`clickScrollDistance: 0` 才是「以点击处为目标」——库把 0 定义为\n * 「距离等于目标距离」。函数形态是必需的:这个选项只在实现里经过 `isFunction` 分支才会被读到,\n * 直接传对象会被静默忽略。\n * - `clickScrollDuration: 0`:库默认用 200ms 缓动滚到点击处,在渲染虚拟列表的资源列表上会卡顿,\n * 这里改成即时落位。0 是合法时长而不是「非法值被忽略」:`animateNumber` 先用 `Math.max(0, ms)`\n * 得到时长 0,首帧(同步执行、不经 rAF)就判定已到期并落到终点。\n */\nexport const webUiScrollbarsOptions: PartialOptions = {\n scrollbars: {\n theme: WEB_UI_SCROLLBARS_THEME,\n autoHide: 'scroll',\n autoHideDelay: 2000,\n clickScroll: () => ({ clickScrollDistance: 0, clickScrollDuration: 0 })\n }\n}\n"],"mappings":";;;AAOA,IAAa,IAA0B;AAOvC,EAAkB,OAAO,CAAiB;AA6B1C,IAAa,IAAyC,EACpD,YAAY;CACV,OAAO;CACP,UAAU;CACV,eAAe;CACf,oBAAoB;EAAE,qBAAqB;EAAG,qBAAqB;CAAE;AACvE,EACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/scrollbars/index.ts"],"sourcesContent":["import './style.css'\n\nimport { ClickScrollPlugin, OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'\n\n/**\n * `scrollbars.theme` 的取值,同时也是 `@greypan/web-ui/scrollbars.css` 里主题类的名字。\n */\nexport const WEB_UI_SCROLLBARS_THEME = 'os-theme-macos'\n\n/*\n * track 点击滚动由 ClickScrollPlugin 提供;插件未注册时 `clickScroll` 静默失效——\n * 不报错、不打日志,track 只是没有反应。注册写在模块作用域,只要消费方用了本模块,\n * 插件就一定在位,不会因为漏掉一行 bootstrap 而退化。\n */\nOverlayScrollbars.plugin(ClickScrollPlugin)\n\n/**\n * 全仓统一的 scrollbar 行为:外观对齐 macOS、自动隐藏、滚动时短暂显示、track 点击直达。\n *\n * 内容尺寸或平台变化都不需要改这里,只有三条产品要求本身变了才改。覆盖层要实现的是\n * 同一套手势语言,所以用法是把它整个传给 OverlayScrollbars 初始化,而不是逐项拼接:\n *\n * ```ts\n * import { webUiScrollbarsOptions } from '@greypan/web-ui/scrollbars'\n * import '@greypan/web-ui/scrollbars.css'\n * ```\n *\n * - `autoHide: 'scroll'`:scrollbar 只在滚动时出现,停下 `autoHideDelay` 之后自动隐去。用户明确选了\n * 这一档而不是 `'move'`——`'move'` 会让指针在滚动区域内移动就唤出,比 macOS 原生积极得多。\n * 代价写清楚:**hover 本身不再唤出 scrollbar**(隐藏态的元素不可命中,指针根本落不到它上面),\n * 所以 issue 里「hover 的时候短暂显示」那条只以「滚动时短暂显示」的形式满足。\n * - `autoHideDelay: 2000`:这一档同时是「track 点击跳转」的可用窗口——scrollbar 隐去后它就不可命中。\n * 指针从滚动的位置移到 track、再点下去,实测预算在 1s 上下,默认的 1300ms 太贴边;2000ms 留出\n * 余量,又不至于让半透明 scrollbar 在静止画面上久留(macOS 原生的淡出也在 2s 这个量级)。\n * - `autoHideSuspend` 保持关闭:打开会让 scrollbar 在首次滚动前一直可见,与「自动隐藏」相反。\n * - `clickScroll` 走函数形态:`true` 只是「按一个视口步进」,跳到的是当前位置的下/上一屏,\n * 与点击处的距离无关。`clickScrollDistance: 0` 才是「以点击处为目标」——库把 0 定义为\n * 「距离等于目标距离」。函数形态是必需的:这个 option 只在实现里经过 `isFunction` 分支才会被读到,\n * 直接传对象会被静默忽略。\n * - `clickScrollDuration: 0`:库默认用 200ms 缓动滚到点击处,在渲染虚拟列表的资源列表上会卡顿,\n * 这里改成即时落位。0 是合法时长而不是「非法值被忽略」:`animateNumber` 先用 `Math.max(0, ms)`\n * 得到时长 0,首帧(同步执行、不经 rAF)就判定已到期并落到终点。\n */\nexport const webUiScrollbarsOptions: PartialOptions = {\n scrollbars: {\n theme: WEB_UI_SCROLLBARS_THEME,\n autoHide: 'scroll',\n autoHideDelay: 2000,\n clickScroll: () => ({ clickScrollDistance: 0, clickScrollDuration: 0 })\n }\n}\n"],"mappings":";;;AAOA,IAAa,IAA0B;AAOvC,EAAkB,OAAO,CAAiB;AA6B1C,IAAa,IAAyC,EACpD,YAAY;CACV,OAAO;CACP,UAAU;CACV,eAAe;CACf,oBAAoB;EAAE,qBAAqB;EAAG,qBAAqB;CAAE;AACvE,EACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"internal.js","names":[],"sources":["../../src/scrollbars/internal.css?inline"],"sourcesContent":["/*\n * 组件内部滚动容器的原生滚动条主题。\n *\n * 为什么不是直接上 OverlayScrollbars:库的结构样式挂在 `.os-scrollbar` 上、主题类\n * `.os-theme-macos` 也靠文档层的选择器命中,两者都进不了组件的 shadow——shadow 里既匹配不到\n * `web-ui-theme[...]` 这类后代选择器,也匹配不到 `html:has()`。库还会把宿主改成 flex 行、把\n * 子节点搬进它生成的 viewport,会破坏组件自身承重的布局。唯一能跨 shadow 边界的是继承的\n * 自定义属性,所以这里只**读**主题里同一组 `--wui-scrollbar-*`(由 `web-ui-theme` 的外观分支\n * 写、`.os-theme-macos` 的回退值兜底),颜色因此与整页的覆盖式滚动条同源。\n *\n * 刻意**不写 `::-webkit-scrollbar`**:一旦给某条滚动条声明了自定义 webkit 伪元素,引擎就会\n * 放弃平台自带的覆盖式滚动条,改用经典滚动条并固定占一条布局宽度。实测(macOS Chromium,一条\n * `width: 7px` 的 `::-webkit-scrollbar`):同尺寸容器的 `offsetWidth - clientWidth` 从 0 变成 7。\n * 那正好违反 issue #225 的第 (1) 条「不占用宽度」。`scrollbar-color` / `scrollbar-width` 不触发\n * 这个切换:覆盖式平台上滚动条保持零宽覆盖,经典平台上才按它们上色、收窄。\n *\n * 覆盖式平台(macOS、无头 Chromium)上这条规则几乎不可见——原生的覆盖滚动条由系统绘制,并不\n * 看这两个属性;它真正的用处在经典滚动条的平台(Windows / Linux)。想拿到与库逐像素一致的\n * 几何、圆角与自动隐藏只有上库这一条路,那需要把库初始化进每个 shadow root,超出 issue #225\n * 「组件不必引入第三方依赖」的范围。\n *\n * `scrollbar-color` 与 `scrollbar-width` 都是**继承属性**,写一次即可作用于容器自身及其后代;\n * 调用方只在组件自己的 shadow 里 adopted 本文件(见各组件 `static styles`),所以样式天然隔离,\n * 不会漏到文档层,满足第 (3) 条。\n */\n.wui-scroll {\n scrollbar-color: var(--wui-scrollbar-handle, rgb(0 0 0 / 0.39)) transparent;\n scrollbar-width: thin;\n}\n"],"mappings":""}
1
+ {"version":3,"file":"internal.js","names":[],"sources":["../../src/scrollbars/internal.css?inline"],"sourcesContent":["/*\n * 组件内部滚动容器的原生 scrollbar 主题。\n *\n * 为什么不是直接上 OverlayScrollbars:库的结构样式挂在 `.os-scrollbar` 上、主题类\n * `.os-theme-macos` 也靠文档层的选择器命中,两者都进不了组件的 shadow——shadow 里既匹配不到\n * `web-ui-theme[...]` 这类后代选择器,也匹配不到 `html:has()`。库还会把宿主改成 flex 行、把\n * 子节点搬进它生成的 viewport,会破坏组件自身承重的布局。唯一能跨 shadow 边界的是继承的\n * 自定义属性,所以这里只**读**主题里同一组 `--wui-scrollbar-*`(由 `web-ui-theme` 的外观分支\n * 写、`.os-theme-macos` 的回退值兜底),颜色因此与整页的覆盖式 scrollbar 同源。\n *\n * 刻意**不写 `::-webkit-scrollbar`**:一旦给某条 scrollbar 声明了自定义 webkit 伪元素,引擎就会\n * 放弃平台自带的覆盖式 scrollbar,改用经典 scrollbar 并固定占一条布局宽度。实测(macOS Chromium,一条\n * `width: 7px` 的 `::-webkit-scrollbar`):同尺寸容器的 `offsetWidth - clientWidth` 从 0 变成 7。\n * 那正好违反 issue #225 的第 (1) 条「不占用宽度」。`scrollbar-color` / `scrollbar-width` 不触发\n * 这个切换:覆盖式平台上 scrollbar 保持零宽覆盖,经典平台上才按它们上色、收窄。\n *\n * 覆盖式平台(macOS、无头 Chromium)上这条规则几乎不可见——原生的覆盖 scrollbar 由系统绘制,并不\n * 看这两个属性;它真正的用处在经典 scrollbar 的平台(Windows / Linux)。想拿到与库逐像素一致的\n * 几何、圆角与自动隐藏只有上库这一条路,那需要把库初始化进每个 shadow root,超出 issue #225\n * 「组件不必引入第三方依赖」的范围。\n *\n * `scrollbar-color` 与 `scrollbar-width` 都是**继承属性**,写一次即可作用于容器自身及其后代;\n * 调用方只在组件自己的 shadow 里 adopted 本文件(见各组件 `static styles`),所以样式天然隔离,\n * 不会漏到文档层,满足第 (3) 条。\n */\n.wui-scroll {\n scrollbar-color: var(--wui-scrollbar-handle, rgb(0 0 0 / 0.39)) transparent;\n scrollbar-width: thin;\n}\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/element-height/index.ts"],"sourcesContent":["/**\n * 跟踪宿主的某个子元素的 border-box 高度。\n *\n * 用途是「元素的高度是动态的,但布局里另有一处要按它让位」——例如 drawer 的拖拽热区必须\n * 避开 header,而 header 的高度由 slotted 内容与 consumer 的 padding 决定(窄屏下还可能\n * 被 `max-[640px]:h-14` 改写)。这种让位关系在 CSS 里没有纯表达:热区是 dialog 的绝对\n * 定位兄弟节点,拿不到 header 的盒子尺寸;写成 `top: 100%` 之类又会把内容区一起算进去。\n *\n * 观察的是 border-box 而不是默认的 content-box:这里的判据是「这块区域在布局里占了多高」,\n * 含 padding 与 border。改 padding 而内容不变时 content-box 尺寸可能不变,回调不会来。\n */\nimport type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'\n\ninterface Watched {\n readonly notify: (height: number) => void\n height: number\n}\n\nconst watched = new WeakMap<Element, Watched>()\nlet observer: ResizeObserver | undefined\n\n/**\n * 全模块共用一个 ResizeObserver:逐实例建观察者意味着每个目标各排一次尺寸回调,而同一屏里\n * 可能同时有几十个浮层。没有 ResizeObserver 的环境(jsdom)没有布局可测,直接不接。\n */\nfunction getObserver(): ResizeObserver | null {\n if (typeof ResizeObserver === 'undefined') return null\n if (observer) return observer\n\n observer = new ResizeObserver(entries => {\n for (const entry of entries) {\n const target = watched.get(entry.target)\n if (!target) continue\n\n // offsetHeight 而非 entry.borderBoxSize:后者在旧引擎上可能缺席,且是分数值;\n // 布局让位只需要整数像素,与盒子自身量出来的口径一致。\n const height = (entry.target as HTMLElement).offsetHeight\n if (target.height === height) continue\n\n target.height = height\n target.notify(height)\n }\n })\n\n return observer\n}\n\nfunction watch(target: Element, notify: (height: number) => void) {\n const shared = getObserver()\n if (!shared) return\n\n // 初值取 -1 而不是 0:首帧的真实高度由 observe() 的初始回调报上来,而 0 是一个合法高度,\n // 用它当初值会把「高度就是 0」与「还没量过」混成同一种状态。\n watched.set(target, { notify, height: -1 })\n shared.observe(target, { box: 'border-box' })\n}\n\nfunction unwatch(target: Element) {\n watched.delete(target)\n observer?.unobserve(target)\n}\n\n/**\n * 把目标盒子的高度变化报给宿主;断开重连后重新接上观察。\n *\n * 目标按 selector 在宿主的 renderRoot 里查,渲染结构变化(目标被条件渲染掉再渲染回来)时\n * 重新解析——同一宿主先后观察过两个不同节点的情况是真实存在的(模式切换会换掉整棵子树)。\n */\nexport class ElementHeightController implements ReactiveController {\n private target: Element | undefined\n\n constructor(\n private readonly host: ReactiveControllerHost & LitElement,\n private readonly selector: string,\n private readonly notify: (height: number) => void\n ) {\n host.addController(this)\n }\n\n /*\n * 两个生命周期都要接:重连(移出 DOM 再放回去、列表重排、Teleport)不会排一次更新,只挂在\n * hostUpdated 上的话观察在断开时被拆掉就再也接不回来。而首连时 shadow root 还没有子节点,\n * 这里查不到目标,得靠 hostUpdated 补上第一次。\n */\n hostConnected() {\n this.attach()\n }\n\n hostUpdated() {\n this.attach()\n }\n\n hostDisconnected() {\n this.detach()\n }\n\n private attach() {\n const target = this.host.renderRoot.querySelector(this.selector)\n if (target === this.target) return\n\n if (this.target) unwatch(this.target)\n this.target = target ?? undefined\n if (this.target) watch(this.target, this.notify)\n }\n\n private detach() {\n if (!this.target) return\n unwatch(this.target)\n this.target = undefined\n }\n}\n"],"mappings":";AAkBA,IAAM,oBAAU,IAAI,QAA0B,GAC1C;AAMJ,SAAS,IAAqC;CAmB5C,OAlBI,OAAO,iBAAmB,MAAoB,OAC9C,MAEJ,IAAW,IAAI,gBAAe,MAAW;EACvC,KAAK,IAAM,KAAS,GAAS;GAC3B,IAAM,IAAS,EAAQ,IAAI,EAAM,MAAM;GACvC,IAAI,CAAC,GAAQ;GAIb,IAAM,IAAU,EAAM,OAAuB;GACzC,EAAO,WAAW,MAEtB,EAAO,SAAS,GAChB,EAAO,OAAO,CAAM;EACtB;CACF,CAAC,GAEM;AACT;AAEA,SAAS,EAAM,GAAiB,GAAkC;CAChE,IAAM,IAAS,EAAY;CACtB,MAIL,EAAQ,IAAI,GAAQ;EAAE;EAAQ,QAAQ;CAAG,CAAC,GAC1C,EAAO,QAAQ,GAAQ,EAAE,KAAK,aAAa,CAAC;AAC9C;AAEA,SAAS,EAAQ,GAAiB;CAEhC,AADA,EAAQ,OAAO,CAAM,GACrB,GAAU,UAAU,CAAM;AAC5B;AAQA,IAAa,IAAb,MAAmE;CAGjE,YACE,GACA,GACA,GACA;EACA,AAJiB,KAAA,OAAA,GACA,KAAA,WAAA,GACA,KAAA,SAAA,GAEjB,EAAK,cAAc,IAAI;CACzB;CAOA,gBAAgB;EACd,KAAK,OAAO;CACd;CAEA,cAAc;EACZ,KAAK,OAAO;CACd;CAEA,mBAAmB;EACjB,KAAK,OAAO;CACd;CAEA,SAAiB;EACf,IAAM,IAAS,KAAK,KAAK,WAAW,cAAc,KAAK,QAAQ;EAC3D,MAAW,KAAK,WAEhB,KAAK,UAAQ,EAAQ,KAAK,MAAM,GACpC,KAAK,SAAS,KAAU,KAAA,GACpB,KAAK,UAAQ,EAAM,KAAK,QAAQ,KAAK,MAAM;CACjD;CAEA,SAAiB;EACV,AAEL,KAAK,YADL,EAAQ,KAAK,MAAM,GACL,KAAA;CAChB;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/element-height/index.ts"],"sourcesContent":["/**\n * 跟踪宿主的某个子元素的 border-box 高度。\n *\n * 用途是「元素的高度是动态的,但布局里另有一处要按它让位」——例如 drawer 的拖拽 drag zone 必须\n * 避开 header,而 header 的高度由 slotted 内容与 consumer 的 padding 决定(窄屏下还可能\n * 被 `max-[640px]:h-14` 改写)。这种让位关系在 CSS 里没有纯表达:drag zone 是 dialog 的绝对\n * 定位兄弟节点,拿不到 header 的盒子尺寸;写成 `top: 100%` 之类又会把内容区一起算进去。\n *\n * 观察的是 border-box 而不是默认的 content-box:这里的判据是「这块区域在布局里占了多高」,\n * 含 padding 与 border。改 padding 而内容不变时 content-box 尺寸可能不变,回调不会来。\n */\nimport type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'\n\ninterface Watched {\n readonly notify: (height: number) => void\n height: number\n}\n\nconst watched = new WeakMap<Element, Watched>()\nlet observer: ResizeObserver | undefined\n\n/**\n * 全模块共用一个 ResizeObserver:逐实例建观察者意味着每个目标各排一次尺寸回调,而同一屏里\n * 可能同时有几十个 overlay。没有 ResizeObserver 的环境(jsdom)没有布局可测,直接不接。\n */\nfunction getObserver(): ResizeObserver | null {\n if (typeof ResizeObserver === 'undefined') return null\n if (observer) return observer\n\n observer = new ResizeObserver(entries => {\n for (const entry of entries) {\n const target = watched.get(entry.target)\n if (!target) continue\n\n // offsetHeight 而非 entry.borderBoxSize:后者在旧引擎上可能缺席,且是分数值;\n // 布局让位只需要整数像素,与盒子自身量出来的口径一致。\n const height = (entry.target as HTMLElement).offsetHeight\n if (target.height === height) continue\n\n target.height = height\n target.notify(height)\n }\n })\n\n return observer\n}\n\nfunction watch(target: Element, notify: (height: number) => void) {\n const shared = getObserver()\n if (!shared) return\n\n // 初值取 -1 而不是 0:首帧的真实高度由 observe() 的初始回调报上来,而 0 是一个合法高度,\n // 用它当初值会把「高度就是 0」与「还没量过」混成同一种状态。\n watched.set(target, { notify, height: -1 })\n shared.observe(target, { box: 'border-box' })\n}\n\nfunction unwatch(target: Element) {\n watched.delete(target)\n observer?.unobserve(target)\n}\n\n/**\n * 把目标盒子的高度变化报给宿主;断开重连后重新接上观察。\n *\n * 目标按 selector 在宿主的 renderRoot 里查,渲染结构变化(目标被条件渲染掉再渲染回来)时\n * 重新解析——同一宿主先后观察过两个不同节点的情况是真实存在的(模式切换会换掉整棵子树)。\n */\nexport class ElementHeightController implements ReactiveController {\n private target: Element | undefined\n\n constructor(\n private readonly host: ReactiveControllerHost & LitElement,\n private readonly selector: string,\n private readonly notify: (height: number) => void\n ) {\n host.addController(this)\n }\n\n /*\n * 两个生命周期都要接:重连(移出 DOM 再放回去、列表重排、Teleport)不会排一次更新,只挂在\n * hostUpdated 上的话观察在断开时被拆掉就再也接不回来。而首连时 shadow root 还没有子节点,\n * 这里查不到目标,得靠 hostUpdated 补上第一次。\n */\n hostConnected() {\n this.attach()\n }\n\n hostUpdated() {\n this.attach()\n }\n\n hostDisconnected() {\n this.detach()\n }\n\n private attach() {\n const target = this.host.renderRoot.querySelector(this.selector)\n if (target === this.target) return\n\n if (this.target) unwatch(this.target)\n this.target = target ?? undefined\n if (this.target) watch(this.target, this.notify)\n }\n\n private detach() {\n if (!this.target) return\n unwatch(this.target)\n this.target = undefined\n }\n}\n"],"mappings":";AAkBA,IAAM,oBAAU,IAAI,QAA0B,GAC1C;AAMJ,SAAS,IAAqC;CAmB5C,OAlBI,OAAO,iBAAmB,MAAoB,OAC9C,MAEJ,IAAW,IAAI,gBAAe,MAAW;EACvC,KAAK,IAAM,KAAS,GAAS;GAC3B,IAAM,IAAS,EAAQ,IAAI,EAAM,MAAM;GACvC,IAAI,CAAC,GAAQ;GAIb,IAAM,IAAU,EAAM,OAAuB;GACzC,EAAO,WAAW,MAEtB,EAAO,SAAS,GAChB,EAAO,OAAO,CAAM;EACtB;CACF,CAAC,GAEM;AACT;AAEA,SAAS,EAAM,GAAiB,GAAkC;CAChE,IAAM,IAAS,EAAY;CACtB,MAIL,EAAQ,IAAI,GAAQ;EAAE;EAAQ,QAAQ;CAAG,CAAC,GAC1C,EAAO,QAAQ,GAAQ,EAAE,KAAK,aAAa,CAAC;AAC9C;AAEA,SAAS,EAAQ,GAAiB;CAEhC,AADA,EAAQ,OAAO,CAAM,GACrB,GAAU,UAAU,CAAM;AAC5B;AAQA,IAAa,IAAb,MAAmE;CAGjE,YACE,GACA,GACA,GACA;EACA,AAJiB,KAAA,OAAA,GACA,KAAA,WAAA,GACA,KAAA,SAAA,GAEjB,EAAK,cAAc,IAAI;CACzB;CAOA,gBAAgB;EACd,KAAK,OAAO;CACd;CAEA,cAAc;EACZ,KAAK,OAAO;CACd;CAEA,mBAAmB;EACjB,KAAK,OAAO;CACd;CAEA,SAAiB;EACf,IAAM,IAAS,KAAK,KAAK,WAAW,cAAc,KAAK,QAAQ;EAC3D,MAAW,KAAK,WAEhB,KAAK,UAAQ,EAAQ,KAAK,MAAM,GACpC,KAAK,SAAS,KAAU,KAAA,GACpB,KAAK,UAAQ,EAAM,KAAK,QAAQ,KAAK,MAAM;CACjD;CAEA,SAAiB;EACV,AAEL,KAAK,YADL,EAAQ,KAAK,MAAM,GACL,KAAA;CAChB;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"pointer-focus.js","names":[],"sources":["../../../src/shared/focus/pointer-focus.ts"],"sourcesContent":["/**\n * 指针输入期间的 focus ring 抑制。\n *\n * Safari 等浏览器在 pointer 点击后仍可能让 `:focus-visible` 命中新聚焦元素,\n * 导致点击打开 drawer/dialog 时内部按钮自动出现 focus ring。这里记录最近一次\n * 指针输入,并在 focusin 时给聚焦元素打 `data-wui-pointer-focus` 标记;组件样式\n * 用该标记将 focus ring 透明化,键盘导航(Tab/方向键等)会清除标记并恢复可见。\n */\n\nconst POINTER_FOCUS_WINDOW_MS = 1000\n\nlet installed = false\nlet lastPointerAt = 0\nlet pointerActive = false\n\nfunction markPointer() {\n pointerActive = true\n lastPointerAt = Date.now()\n}\n\nfunction markKeyboard() {\n pointerActive = false\n lastPointerAt = 0\n}\n\nfunction onPointerDown() {\n markPointer()\n}\n\nfunction onKeyDown(event: KeyboardEvent) {\n if (\n event.key === 'Tab' ||\n event.key.startsWith('Arrow') ||\n event.key === 'Enter' ||\n event.key === ' ' ||\n event.key === 'Home' ||\n event.key === 'End' ||\n event.key === 'PageUp' ||\n event.key === 'PageDown'\n ) {\n markKeyboard()\n }\n}\n\nfunction onFocusIn(event: FocusEvent) {\n const target = event.target\n if (!(target instanceof HTMLElement)) return\n\n if (pointerActive && Date.now() - lastPointerAt <= POINTER_FOCUS_WINDOW_MS) {\n target.dataset.wuiPointerFocus = 'true'\n const root = target.getRootNode()\n if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {\n root.host.dataset.wuiPointerFocus = 'true'\n }\n } else {\n target.removeAttribute('data-wui-pointer-focus')\n const root = target.getRootNode()\n if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {\n root.host.removeAttribute('data-wui-pointer-focus')\n }\n }\n}\n\n/**\n * 安装一次全局 pointer/keyboard/focus 监听。重复调用幂等。\n */\nexport function installPointerFocusSuppression(): () => void {\n if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}\n if (installed) return () => {}\n installed = true\n\n // 只用 document 一份 capture 监听:shadow DOM 内的 pointerdown/keydown/focusin\n // 都会 composed 冒泡到 document,避免 window/document 双份导致同一事件处理两次。\n document.addEventListener('pointerdown', onPointerDown, true)\n document.addEventListener('keydown', onKeyDown, true)\n document.addEventListener('focusin', onFocusIn, true)\n\n return () => {\n installed = false\n document.removeEventListener('pointerdown', onPointerDown, true)\n document.removeEventListener('keydown', onKeyDown, true)\n document.removeEventListener('focusin', onFocusIn, true)\n }\n}\n\n/**\n * 测试辅助:清除指针输入状态,避免用例间泄漏。\n */\nexport function resetPointerFocusState(): void {\n markKeyboard()\n}\n"],"mappings":";AASA,IAEI,IAAY,IACZ,IAAgB,GAChB,IAAgB;AAEpB,SAAS,IAAc;CAErB,AADA,IAAgB,IAChB,IAAgB,KAAK,IAAI;AAC3B;AAEA,SAAS,IAAe;CAEtB,AADA,IAAgB,IAChB,IAAgB;AAClB;AAEA,SAAS,IAAgB;CACvB,EAAY;AACd;AAEA,SAAS,EAAU,GAAsB;CACvC,CACE,EAAM,QAAQ,SACd,EAAM,IAAI,WAAW,OAAO,KAC5B,EAAM,QAAQ,WACd,EAAM,QAAQ,OACd,EAAM,QAAQ,UACd,EAAM,QAAQ,SACd,EAAM,QAAQ,YACd,EAAM,QAAQ,eAEd,EAAa;AAEjB;AAEA,SAAS,EAAU,GAAmB;CACpC,IAAM,IAAS,EAAM;CACf,iBAAkB,aAExB;MAAI,KAAiB,KAAK,IAAI,IAAI,KAAiB,KAAyB;GAC1E,EAAO,QAAQ,kBAAkB;GACjC,IAAM,IAAO,EAAO,YAAY;GAChC,AAAI,aAAgB,cAAc,EAAK,gBAAgB,gBACrD,EAAK,KAAK,QAAQ,kBAAkB;EAExC,OAAO;GACL,EAAO,gBAAgB,wBAAwB;GAC/C,IAAM,IAAO,EAAO,YAAY;GAChC,AAAI,aAAgB,cAAc,EAAK,gBAAgB,eACrD,EAAK,KAAK,gBAAgB,wBAAwB;EAEtD;;AACF;AAKA,SAAgB,IAA6C;CAW3D,OAVI,OAAO,SAAW,OAAe,OAAO,WAAa,OACrD,UAAwB,CAAC,KAC7B,IAAY,IAIZ,SAAS,iBAAiB,eAAe,GAAe,EAAI,GAC5D,SAAS,iBAAiB,WAAW,GAAW,EAAI,GACpD,SAAS,iBAAiB,WAAW,GAAW,EAAI,SAEvC;EAIX,AAHA,IAAY,IACZ,SAAS,oBAAoB,eAAe,GAAe,EAAI,GAC/D,SAAS,oBAAoB,WAAW,GAAW,EAAI,GACvD,SAAS,oBAAoB,WAAW,GAAW,EAAI;CACzD;AACF"}
1
+ {"version":3,"file":"pointer-focus.js","names":[],"sources":["../../../src/shared/focus/pointer-focus.ts"],"sourcesContent":["/**\n * 指针输入期间的 focus ring 抑制。\n *\n * Safari 等浏览器在 pointer 点击后仍可能让 `:focus-visible` 命中新聚焦元素,\n * 导致点击打开 drawer/dialog 时内部 button 自动出现 focus ring。这里记录最近一次\n * 指针输入,并在 focusin 时给聚焦元素打 `data-wui-pointer-focus` 标记;组件样式\n * 用该标记将 focus ring 透明化,键盘导航(Tab/方向键等)会清除标记并恢复可见。\n */\n\nconst POINTER_FOCUS_WINDOW_MS = 1000\n\nlet installed = false\nlet lastPointerAt = 0\nlet pointerActive = false\n\nfunction markPointer() {\n pointerActive = true\n lastPointerAt = Date.now()\n}\n\nfunction markKeyboard() {\n pointerActive = false\n lastPointerAt = 0\n}\n\nfunction onPointerDown() {\n markPointer()\n}\n\nfunction onKeyDown(event: KeyboardEvent) {\n if (\n event.key === 'Tab' ||\n event.key.startsWith('Arrow') ||\n event.key === 'Enter' ||\n event.key === ' ' ||\n event.key === 'Home' ||\n event.key === 'End' ||\n event.key === 'PageUp' ||\n event.key === 'PageDown'\n ) {\n markKeyboard()\n }\n}\n\nfunction onFocusIn(event: FocusEvent) {\n const target = event.target\n if (!(target instanceof HTMLElement)) return\n\n if (pointerActive && Date.now() - lastPointerAt <= POINTER_FOCUS_WINDOW_MS) {\n target.dataset.wuiPointerFocus = 'true'\n const root = target.getRootNode()\n if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {\n root.host.dataset.wuiPointerFocus = 'true'\n }\n } else {\n target.removeAttribute('data-wui-pointer-focus')\n const root = target.getRootNode()\n if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {\n root.host.removeAttribute('data-wui-pointer-focus')\n }\n }\n}\n\n/**\n * 安装一次全局 pointer/keyboard/focus 监听。重复调用幂等。\n */\nexport function installPointerFocusSuppression(): () => void {\n if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}\n if (installed) return () => {}\n installed = true\n\n // 只用 document 一份 capture 监听:shadow DOM 内的 pointerdown/keydown/focusin\n // 都会 composed 冒泡到 document,避免 window/document 双份导致同一事件处理两次。\n document.addEventListener('pointerdown', onPointerDown, true)\n document.addEventListener('keydown', onKeyDown, true)\n document.addEventListener('focusin', onFocusIn, true)\n\n return () => {\n installed = false\n document.removeEventListener('pointerdown', onPointerDown, true)\n document.removeEventListener('keydown', onKeyDown, true)\n document.removeEventListener('focusin', onFocusIn, true)\n }\n}\n\n/**\n * 测试辅助:清除指针输入状态,避免用例间泄漏。\n */\nexport function resetPointerFocusState(): void {\n markKeyboard()\n}\n"],"mappings":";AASA,IAEI,IAAY,IACZ,IAAgB,GAChB,IAAgB;AAEpB,SAAS,IAAc;CAErB,AADA,IAAgB,IAChB,IAAgB,KAAK,IAAI;AAC3B;AAEA,SAAS,IAAe;CAEtB,AADA,IAAgB,IAChB,IAAgB;AAClB;AAEA,SAAS,IAAgB;CACvB,EAAY;AACd;AAEA,SAAS,EAAU,GAAsB;CACvC,CACE,EAAM,QAAQ,SACd,EAAM,IAAI,WAAW,OAAO,KAC5B,EAAM,QAAQ,WACd,EAAM,QAAQ,OACd,EAAM,QAAQ,UACd,EAAM,QAAQ,SACd,EAAM,QAAQ,YACd,EAAM,QAAQ,eAEd,EAAa;AAEjB;AAEA,SAAS,EAAU,GAAmB;CACpC,IAAM,IAAS,EAAM;CACf,iBAAkB,aAExB;MAAI,KAAiB,KAAK,IAAI,IAAI,KAAiB,KAAyB;GAC1E,EAAO,QAAQ,kBAAkB;GACjC,IAAM,IAAO,EAAO,YAAY;GAChC,AAAI,aAAgB,cAAc,EAAK,gBAAgB,gBACrD,EAAK,KAAK,QAAQ,kBAAkB;EAExC,OAAO;GACL,EAAO,gBAAgB,wBAAwB;GAC/C,IAAM,IAAO,EAAO,YAAY;GAChC,AAAI,aAAgB,cAAc,EAAK,gBAAgB,eACrD,EAAK,KAAK,gBAAgB,wBAAwB;EAEtD;;AACF;AAKA,SAAgB,IAA6C;CAW3D,OAVI,OAAO,SAAW,OAAe,OAAO,WAAa,OACrD,UAAwB,CAAC,KAC7B,IAAY,IAIZ,SAAS,iBAAiB,eAAe,GAAe,EAAI,GAC5D,SAAS,iBAAiB,WAAW,GAAW,EAAI,GACpD,SAAS,iBAAiB,WAAW,GAAW,EAAI,SAEvC;EAIX,AAHA,IAAY,IACZ,SAAS,oBAAoB,eAAe,GAAe,EAAI,GAC/D,SAAS,oBAAoB,WAAW,GAAW,EAAI,GACvD,SAAS,oBAAoB,WAAW,GAAW,EAAI;CACzD;AACF"}
@@ -100,7 +100,11 @@ function h(t) {
100
100
  handleChange(e) {
101
101
  if (t.getDisabled() || !t.isItem(e.target) || !t.host.contains(e.target)) return;
102
102
  let n = t.getValue(), r = t.getNextValue(e.target, n);
103
- t.valuesEqual(n, r) || (t.setValue(r), t.dispatchValueChange());
103
+ if (t.valuesEqual(n, r)) {
104
+ t.resyncOnUnchangedValue?.(e.target);
105
+ return;
106
+ }
107
+ t.setValue(r), t.dispatchValueChange();
104
108
  }
105
109
  };
106
110
  });
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/group-management/index.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\nimport { ContextConsumer, ContextProvider, createContext, type Context } from '@lit/context'\nimport type { ReactiveController, ReactiveControllerHost } from 'lit'\n\n/*\n * 下行通道基于 @lit/context(ADR-0032):root 上的 ContextProvider 以\n * `ReadonlyMap<HTMLElement, Context>` 为载荷向全子树广播,子项侧\n * ContextConsumer 订阅并按自身元素取条目。context-request 事件 composed,\n * 可穿过中间 shadow 边界;custom element 升级顺序保证祖先先于后代升级,\n * provider 总是先于子项请求就绪。成员追踪(querySelectorAll +\n * MutationObserver/slotchange)、点击归因与 setItemSelected 上行写回\n * 不在下行通道内,仍由 GroupController 驱动。\n */\n\nexport interface SelectionGroupContext {\n readonly disabled: boolean\n}\n\nexport interface ButtonGroupContext {\n readonly direction: 'horizontal' | 'vertical'\n readonly isLast: boolean\n}\n\n// 载荷类型仅编译期品牌化;按载荷形状分两个 key,避免异构树串扰。\n// createContext 泛型顺序为 <ValueType, K>。\nconst SELECTION_GROUP_CONTEXT = createContext<ReadonlyMap<HTMLElement, SelectionGroupContext>, symbol>(\n Symbol('web-ui.selection-group-context')\n)\n\nconst BUTTON_GROUP_CONTEXT = createContext<ReadonlyMap<HTMLElement, ButtonGroupContext>, symbol>(\n Symbol('web-ui.button-group-context')\n)\n\n// 两个 key 的运行时形状一致(Map 载荷),泛型插件内部统一按宽松类型驱动。\ntype AnyGroupContext = Context<symbol, ReadonlyMap<HTMLElement, unknown>>\n\nconst SELECTION_GROUP_CONTEXT_LOOSE = SELECTION_GROUP_CONTEXT as unknown as AnyGroupContext\nconst BUTTON_GROUP_CONTEXT_LOOSE = BUTTON_GROUP_CONTEXT as unknown as AnyGroupContext\n\nexport const selectionGroupContextKey: AnyGroupContext = SELECTION_GROUP_CONTEXT_LOOSE\nexport const buttonGroupContextKey: AnyGroupContext = BUTTON_GROUP_CONTEXT_LOOSE\n\ninterface GroupControllerHost extends HTMLElement, ReactiveControllerHost {\n readonly renderRoot: Element | DocumentFragment\n}\n\n/**\n * 创建受管理 group 的子项侧能力:订阅最近 provider 的 context 载荷并提取\n * 自身条目。在 `make()` 时完成注册,组件无需再编写 constructor 样板。\n */\nexport function defineGroupManaged<Context>(\n item: HTMLElement & ReactiveControllerHost,\n options: {\n /** 与所属根组件使用的 context key 一致。 */\n context: AnyGroupContext\n requestUpdate: () => void\n equals?: (a: Context | undefined, b: Context | undefined) => boolean\n }\n) {\n return definePlugin(() => {\n let context: Context | undefined\n const equals = options.equals ?? Object.is\n\n const consumer = new ContextConsumer(item, {\n context: options.context,\n subscribe: true,\n callback: payload => {\n const next = (payload as ReadonlyMap<HTMLElement, Context> | undefined)?.get(item)\n if (equals(context, next)) return\n context = next\n options.requestUpdate()\n }\n })\n\n /*\n * 离开 group(被移出 provider 子树)时 ContextConsumer 只是静默退订,\n * 不会收到「条目已移除」的通知:显式清空上下文,恢复独立控件语义\n * (如 checkbox 恢复自身表单提交)。跨组移动先断连清空、再重连订阅新组。\n */\n item.addController({\n hostDisconnected() {\n if (equals(context, undefined)) return\n context = undefined\n options.requestUpdate()\n }\n })\n\n return {\n getContext(): Context | undefined {\n return context\n },\n // 供测试或特殊场景确认订阅状态。\n isSubscribed(): boolean {\n return consumer.value !== undefined\n }\n }\n })\n}\n\nexport interface GroupPresentationOptions<Item extends HTMLElement, Context> {\n readonly host: HTMLElement\n /** 与子项 defineGroupManaged 使用的 context key 一致。 */\n readonly context: AnyGroupContext\n readonly getItems: () => readonly Item[]\n readonly getContext: (item: Item, index: number, items: readonly Item[]) => Context\n}\n\n/**\n * 为分组子项注册可逆的展示上下文(@lit/context 下行通道)。不持有公开 DOM\n * 属性;sync() 以新 Map 载荷整体广播,成员变化体现为子项条目消失。\n */\nexport function defineGroupPresentation<Item extends HTMLElement, Context>(\n options: GroupPresentationOptions<Item, Context>\n) {\n return definePlugin(() => {\n const provider = new ContextProvider(options.host, {\n context: options.context,\n initialValue: new Map<HTMLElement, unknown>()\n })\n\n return {\n sync(): boolean {\n const nextItems = options.getItems()\n const payload = new Map<HTMLElement, unknown>()\n nextItems.forEach((item, index) => payload.set(item, options.getContext(item, index, nextItems)))\n // 新 Map 实例保证 Object.is 判定为变更,全量通知订阅者。\n // 每次 hostUpdated 都重播(即使成员/方向未变)是有意形态:每次新建\n // context 对象,但子项 defineGroupManaged 的 equals 门控 requestUpdate\n // (标量字段比较),重播不产生多余渲染;代价是比「仅成员变化才通知」\n // 更频繁的广播事件,换取零状态追踪的简单性(与 GroupController 的\n // 全量 sync 对齐)。\n provider.setValue(payload)\n return true\n },\n disconnect() {\n provider.setValue(new Map())\n }\n }\n })\n}\n\nexport interface GroupLifecycle {\n sync(): boolean\n disconnect(): void\n handleChange?(event: Event): void\n}\n\ninterface GroupControllerOptions {\n afterSync?: () => void\n}\n\n/**\n * 把组生命周期插件接入 Lit,协调规则不落在组件生命周期方法里。\n */\nexport class GroupController implements ReactiveController {\n private connected = false\n private observer: MutationObserver | null = null\n private observedSlots = new Set<HTMLSlotElement>()\n constructor(\n private readonly host: GroupControllerHost,\n private readonly lifecycle: GroupLifecycle,\n private readonly options: GroupControllerOptions = {}\n ) {\n host.addController(this)\n }\n\n hostConnected() {\n this.connected = true\n this.host.addEventListener('change', this.handleChange, true)\n this.observeHost()\n this.observeSlots()\n this.sync()\n }\n\n hostUpdated() {\n this.observeSlots()\n this.sync()\n }\n\n hostDisconnected() {\n this.connected = false\n this.host.removeEventListener('change', this.handleChange, true)\n this.disconnectSlots()\n this.disconnectHostObserver()\n this.lifecycle.disconnect()\n }\n\n private readonly handleChange = (event: Event) => {\n this.lifecycle.handleChange?.(event)\n }\n\n private readonly handleSlotChange = () => {\n if (!this.connected) return\n this.sync()\n }\n\n private readonly handleMutations = () => {\n if (!this.connected) return\n this.sync()\n }\n\n sync() {\n if (!this.lifecycle.sync()) return\n this.options.afterSync?.()\n }\n\n private observeHost() {\n this.disconnectHostObserver()\n // 监听 light DOM 的增删,覆盖 Vue v-if / React && 的 comment 锚点替换与深层包裹\n this.observer = new MutationObserver(this.handleMutations)\n this.observer.observe(this.host, { childList: true, subtree: true })\n }\n\n private disconnectHostObserver() {\n if (this.observer) {\n this.observer.disconnect()\n this.observer = null\n }\n }\n\n private observeSlots() {\n const root = this.host.renderRoot as Element\n const slots = root.querySelectorAll<HTMLSlotElement>('slot')\n for (const slot of slots) {\n if (!this.observedSlots.has(slot)) {\n slot.addEventListener('slotchange', this.handleSlotChange)\n this.observedSlots.add(slot)\n }\n }\n // 清理已移除的 slot(热更新等场景)\n for (const slot of this.observedSlots) {\n if (!root.contains(slot)) {\n slot.removeEventListener('slotchange', this.handleSlotChange)\n this.observedSlots.delete(slot)\n }\n }\n }\n\n private disconnectSlots() {\n for (const slot of this.observedSlots) {\n slot.removeEventListener('slotchange', this.handleSlotChange)\n }\n this.observedSlots.clear()\n }\n}\n\nexport interface GroupCoordinatorOptions<Item extends HTMLElement, Value> {\n readonly host: HTMLElement\n readonly getItems: () => readonly Item[]\n readonly getValue: () => Value\n readonly setValue: (value: Value) => void\n readonly getDisabled: () => boolean\n readonly isItem: (target: EventTarget | null) => target is Item\n readonly isItemSelected: (item: Item, value: Value) => boolean\n readonly getNextValue: (item: Item, value: Value) => Value\n readonly valuesEqual: (a: Value, b: Value) => boolean\n readonly copyValue: (value: Value) => Value\n readonly setItemSelected: (item: Item, selected: boolean) => void\n readonly dispatchValueChange: () => void\n}\n\nconst ENABLED_SELECTION_CONTEXT: SelectionGroupContext = { disabled: false }\nconst DISABLED_SELECTION_CONTEXT: SelectionGroupContext = { disabled: true }\n\n/**\n * 在一个内部插件接口后收敛选中态、继承禁用与受管子项成员关系。\n * 禁用态经 @lit/context 下行广播;选中态上行写回保持直写。\n */\nexport function defineGroupCoordinator<Item extends HTMLElement, Value>(options: GroupCoordinatorOptions<Item, Value>) {\n return definePlugin(() => {\n const provider = new ContextProvider(options.host, {\n context: SELECTION_GROUP_CONTEXT_LOOSE,\n initialValue: new Map<HTMLElement, SelectionGroupContext>()\n })\n\n let lastValue: Value | undefined\n let lastDisabled: boolean | undefined\n let lastItems = new Set<Item>()\n let initialized = false\n\n const sync = (): boolean => {\n const nextItems = options.getItems()\n const value = options.getValue()\n const disabled = options.getDisabled()\n // 成员变化也要重放选中写回:新成员需继承当前选中态。\n const membershipChanged = nextItems.length !== lastItems.size || [...nextItems].some(item => !lastItems.has(item))\n const selectionChanged =\n !initialized ||\n !options.valuesEqual(value, lastValue as Value) ||\n disabled !== lastDisabled ||\n membershipChanged\n\n // 上行选中写回:根写入子项(context 是纯下行,不承担该方向)。\n if (selectionChanged) {\n for (const item of nextItems) {\n options.setItemSelected(item, options.isItemSelected(item, value))\n }\n }\n\n // 禁用下行:每次 sync 以新 Map 载荷整体广播(成员增删与禁用变化一并覆盖,\n // 新 Map 实例保证 Object.is 判定为变更)。\n provider.setValue(\n new Map(\n nextItems.map(item => [\n item as HTMLElement,\n disabled ? DISABLED_SELECTION_CONTEXT : ENABLED_SELECTION_CONTEXT\n ])\n )\n )\n\n lastItems = new Set(nextItems)\n lastValue = options.copyValue(value)\n lastDisabled = disabled\n initialized = true\n return selectionChanged\n }\n\n return {\n sync,\n disconnect() {\n provider.setValue(new Map())\n lastItems.clear()\n lastValue = undefined\n lastDisabled = undefined\n initialized = false\n },\n handleChange(event: Event) {\n if (options.getDisabled() || !options.isItem(event.target) || !options.host.contains(event.target)) {\n return\n }\n\n const current = options.getValue()\n const next = options.getNextValue(event.target, current)\n if (options.valuesEqual(current, next)) return\n\n options.setValue(next)\n options.dispatchValueChange()\n }\n }\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/group-management/index.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\nimport { ContextConsumer, ContextProvider, createContext, type Context } from '@lit/context'\nimport type { ReactiveController, ReactiveControllerHost } from 'lit'\n\n/*\n * 下行通道基于 @lit/context(ADR-0032):root 上的 ContextProvider 以\n * `ReadonlyMap<HTMLElement, Context>` 为载荷向全子树广播,子项侧\n * ContextConsumer 订阅并按自身元素取条目。context-request 事件 composed,\n * 可穿过中间 shadow 边界;custom element 升级顺序保证祖先先于后代升级,\n * provider 总是先于子项请求就绪。成员追踪(querySelectorAll +\n * MutationObserver/slotchange)、点击归因与 setItemSelected 上行写回\n * 不在下行通道内,仍由 GroupController 驱动。\n */\n\nexport interface SelectionGroupContext {\n readonly disabled: boolean\n}\n\nexport interface ButtonGroupContext {\n readonly direction: 'horizontal' | 'vertical'\n readonly isLast: boolean\n}\n\n// 载荷类型仅编译期品牌化;按载荷形状分两个 key,避免异构树串扰。\n// createContext 泛型顺序为 <ValueType, K>。\nconst SELECTION_GROUP_CONTEXT = createContext<ReadonlyMap<HTMLElement, SelectionGroupContext>, symbol>(\n Symbol('web-ui.selection-group-context')\n)\n\nconst BUTTON_GROUP_CONTEXT = createContext<ReadonlyMap<HTMLElement, ButtonGroupContext>, symbol>(\n Symbol('web-ui.button-group-context')\n)\n\n// 两个 key 的运行时形状一致(Map 载荷),泛型插件内部统一按宽松类型驱动。\ntype AnyGroupContext = Context<symbol, ReadonlyMap<HTMLElement, unknown>>\n\nconst SELECTION_GROUP_CONTEXT_LOOSE = SELECTION_GROUP_CONTEXT as unknown as AnyGroupContext\nconst BUTTON_GROUP_CONTEXT_LOOSE = BUTTON_GROUP_CONTEXT as unknown as AnyGroupContext\n\nexport const selectionGroupContextKey: AnyGroupContext = SELECTION_GROUP_CONTEXT_LOOSE\nexport const buttonGroupContextKey: AnyGroupContext = BUTTON_GROUP_CONTEXT_LOOSE\n\ninterface GroupControllerHost extends HTMLElement, ReactiveControllerHost {\n readonly renderRoot: Element | DocumentFragment\n}\n\n/**\n * 创建受管理 group 的子项侧能力:订阅最近 provider 的 context 载荷并提取\n * 自身条目。在 `make()` 时完成注册,组件无需再编写 constructor 样板。\n */\nexport function defineGroupManaged<Context>(\n item: HTMLElement & ReactiveControllerHost,\n options: {\n /** 与所属根组件使用的 context key 一致。 */\n context: AnyGroupContext\n requestUpdate: () => void\n equals?: (a: Context | undefined, b: Context | undefined) => boolean\n }\n) {\n return definePlugin(() => {\n let context: Context | undefined\n const equals = options.equals ?? Object.is\n\n const consumer = new ContextConsumer(item, {\n context: options.context,\n subscribe: true,\n callback: payload => {\n const next = (payload as ReadonlyMap<HTMLElement, Context> | undefined)?.get(item)\n if (equals(context, next)) return\n context = next\n options.requestUpdate()\n }\n })\n\n /*\n * 离开 group(被移出 provider 子树)时 ContextConsumer 只是静默退订,\n * 不会收到「条目已移除」的通知:显式清空上下文,恢复独立控件语义\n * (如 checkbox 恢复自身表单提交)。跨组移动先断连清空、再重连订阅新组。\n */\n item.addController({\n hostDisconnected() {\n if (equals(context, undefined)) return\n context = undefined\n options.requestUpdate()\n }\n })\n\n return {\n getContext(): Context | undefined {\n return context\n },\n // 供测试或特殊场景确认订阅状态。\n isSubscribed(): boolean {\n return consumer.value !== undefined\n }\n }\n })\n}\n\nexport interface GroupPresentationOptions<Item extends HTMLElement, Context> {\n readonly host: HTMLElement\n /** 与子项 defineGroupManaged 使用的 context key 一致。 */\n readonly context: AnyGroupContext\n readonly getItems: () => readonly Item[]\n readonly getContext: (item: Item, index: number, items: readonly Item[]) => Context\n}\n\n/**\n * 为分组子项注册可逆的展示上下文(@lit/context 下行通道)。不持有公开 DOM\n * 属性;sync() 以新 Map 载荷整体广播,成员变化体现为子项条目消失。\n */\nexport function defineGroupPresentation<Item extends HTMLElement, Context>(\n options: GroupPresentationOptions<Item, Context>\n) {\n return definePlugin(() => {\n const provider = new ContextProvider(options.host, {\n context: options.context,\n initialValue: new Map<HTMLElement, unknown>()\n })\n\n return {\n sync(): boolean {\n const nextItems = options.getItems()\n const payload = new Map<HTMLElement, unknown>()\n nextItems.forEach((item, index) => payload.set(item, options.getContext(item, index, nextItems)))\n // 新 Map 实例保证 Object.is 判定为变更,全量通知订阅者。\n // 每次 hostUpdated 都重播(即使成员/方向未变)是有意形态:每次新建\n // context 对象,但子项 defineGroupManaged 的 equals 门控 requestUpdate\n // (标量字段比较),重播不产生多余渲染;代价是比「仅成员变化才通知」\n // 更频繁的广播事件,换取零状态追踪的简单性(与 GroupController 的\n // 全量 sync 对齐)。\n provider.setValue(payload)\n return true\n },\n disconnect() {\n provider.setValue(new Map())\n }\n }\n })\n}\n\nexport interface GroupLifecycle {\n sync(): boolean\n disconnect(): void\n handleChange?(event: Event): void\n}\n\ninterface GroupControllerOptions {\n afterSync?: () => void\n}\n\n/**\n * 把组生命周期插件接入 Lit,协调规则不落在组件生命周期方法里。\n */\nexport class GroupController implements ReactiveController {\n private connected = false\n private observer: MutationObserver | null = null\n private observedSlots = new Set<HTMLSlotElement>()\n constructor(\n private readonly host: GroupControllerHost,\n private readonly lifecycle: GroupLifecycle,\n private readonly options: GroupControllerOptions = {}\n ) {\n host.addController(this)\n }\n\n hostConnected() {\n this.connected = true\n this.host.addEventListener('change', this.handleChange, true)\n this.observeHost()\n this.observeSlots()\n this.sync()\n }\n\n hostUpdated() {\n this.observeSlots()\n this.sync()\n }\n\n hostDisconnected() {\n this.connected = false\n this.host.removeEventListener('change', this.handleChange, true)\n this.disconnectSlots()\n this.disconnectHostObserver()\n this.lifecycle.disconnect()\n }\n\n private readonly handleChange = (event: Event) => {\n this.lifecycle.handleChange?.(event)\n }\n\n private readonly handleSlotChange = () => {\n if (!this.connected) return\n this.sync()\n }\n\n private readonly handleMutations = () => {\n if (!this.connected) return\n this.sync()\n }\n\n sync() {\n if (!this.lifecycle.sync()) return\n this.options.afterSync?.()\n }\n\n private observeHost() {\n this.disconnectHostObserver()\n // 监听 light DOM 的增删,覆盖 Vue v-if / React && 的 comment 锚点替换与深层包裹\n this.observer = new MutationObserver(this.handleMutations)\n this.observer.observe(this.host, { childList: true, subtree: true })\n }\n\n private disconnectHostObserver() {\n if (this.observer) {\n this.observer.disconnect()\n this.observer = null\n }\n }\n\n private observeSlots() {\n const root = this.host.renderRoot as Element\n const slots = root.querySelectorAll<HTMLSlotElement>('slot')\n for (const slot of slots) {\n if (!this.observedSlots.has(slot)) {\n slot.addEventListener('slotchange', this.handleSlotChange)\n this.observedSlots.add(slot)\n }\n }\n // 清理已移除的 slot(热更新等场景)\n for (const slot of this.observedSlots) {\n if (!root.contains(slot)) {\n slot.removeEventListener('slotchange', this.handleSlotChange)\n this.observedSlots.delete(slot)\n }\n }\n }\n\n private disconnectSlots() {\n for (const slot of this.observedSlots) {\n slot.removeEventListener('slotchange', this.handleSlotChange)\n }\n this.observedSlots.clear()\n }\n}\n\nexport interface GroupCoordinatorOptions<Item extends HTMLElement, Value> {\n readonly host: HTMLElement\n readonly getItems: () => readonly Item[]\n readonly getValue: () => Value\n readonly setValue: (value: Value) => void\n readonly getDisabled: () => boolean\n readonly isItem: (target: EventTarget | null) => target is Item\n readonly isItemSelected: (item: Item, value: Value) => boolean\n readonly getNextValue: (item: Item, value: Value) => Value\n readonly valuesEqual: (a: Value, b: Value) => boolean\n readonly copyValue: (value: Value) => Value\n readonly setItemSelected: (item: Item, selected: boolean) => void\n readonly dispatchValueChange: () => void\n /**\n * 交互已发生但值没有变化时的回调(例如单选组成员 value 重复:点击同值成员\n * 不会改变 value,却必须把选中态收到被点击的那一项)。缺省视为无事可做。\n */\n readonly resyncOnUnchangedValue?: (item: Item) => void\n}\n\nconst ENABLED_SELECTION_CONTEXT: SelectionGroupContext = { disabled: false }\nconst DISABLED_SELECTION_CONTEXT: SelectionGroupContext = { disabled: true }\n\n/**\n * 在一个内部插件接口后收敛选中态、继承禁用与受管子项成员关系。\n * 禁用态经 @lit/context 下行广播;选中态上行写回保持直写。\n */\nexport function defineGroupCoordinator<Item extends HTMLElement, Value>(options: GroupCoordinatorOptions<Item, Value>) {\n return definePlugin(() => {\n const provider = new ContextProvider(options.host, {\n context: SELECTION_GROUP_CONTEXT_LOOSE,\n initialValue: new Map<HTMLElement, SelectionGroupContext>()\n })\n\n let lastValue: Value | undefined\n let lastDisabled: boolean | undefined\n let lastItems = new Set<Item>()\n let initialized = false\n\n const sync = (): boolean => {\n const nextItems = options.getItems()\n const value = options.getValue()\n const disabled = options.getDisabled()\n // 成员变化也要重放选中写回:新成员需继承当前选中态。\n const membershipChanged = nextItems.length !== lastItems.size || [...nextItems].some(item => !lastItems.has(item))\n const selectionChanged =\n !initialized ||\n !options.valuesEqual(value, lastValue as Value) ||\n disabled !== lastDisabled ||\n membershipChanged\n\n // 上行选中写回:根写入子项(context 是纯下行,不承担该方向)。\n if (selectionChanged) {\n for (const item of nextItems) {\n options.setItemSelected(item, options.isItemSelected(item, value))\n }\n }\n\n // 禁用下行:每次 sync 以新 Map 载荷整体广播(成员增删与禁用变化一并覆盖,\n // 新 Map 实例保证 Object.is 判定为变更)。\n provider.setValue(\n new Map(\n nextItems.map(item => [\n item as HTMLElement,\n disabled ? DISABLED_SELECTION_CONTEXT : ENABLED_SELECTION_CONTEXT\n ])\n )\n )\n\n lastItems = new Set(nextItems)\n lastValue = options.copyValue(value)\n lastDisabled = disabled\n initialized = true\n return selectionChanged\n }\n\n return {\n sync,\n disconnect() {\n provider.setValue(new Map())\n lastItems.clear()\n lastValue = undefined\n lastDisabled = undefined\n initialized = false\n },\n handleChange(event: Event) {\n if (options.getDisabled() || !options.isItem(event.target) || !options.host.contains(event.target)) {\n return\n }\n\n const current = options.getValue()\n const next = options.getNextValue(event.target, current)\n if (options.valuesEqual(current, next)) {\n options.resyncOnUnchangedValue?.(event.target)\n return\n }\n\n options.setValue(next)\n options.dispatchValueChange()\n }\n }\n })\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/label-emptiness/index.ts"],"sourcesContent":["/**\n * 判断 label 槽位有没有真的画出东西。\n *\n * radio / checkbox 的触发行是 `inline-flex` + `gap: 10px`,宿主宽度由内容撑开,所以那条 gap 是\n * 指示器与标签之间的距离。但 gap 只认「这里有个 flex item」,不认它有没有宽度:槽位空着,或者只放了\n * 一个视觉隐藏的无障碍名(`.sr-only` 出流,不产生行内盒),标签盒子是 0 宽,指示器右边仍然多出 10px。\n *\n * 判据因此取渲染宽度而不是「槽位有没有被指派内容」——两者只在无障碍名那一种用法上分开,而那正是这里\n * 要修的一种。CSS 也判不出这件事:`slot:empty` 看的是 slot 自己的子节点,被 assign 进来的节点并不是\n * 它的孩子。同理不能用 `hidden` 把盒子收掉,display: none 会连带把里面的文字从无障碍名里去掉。\n */\nimport type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'\n\ninterface Watched {\n readonly notify: (empty: boolean) => void\n empty: boolean\n}\n\nconst watched = new WeakMap<Element, Watched>()\nlet observer: ResizeObserver | undefined\n\n/**\n * 全模块共用一个 ResizeObserver:一个列表里可能有上百个选择控件,逐实例建观察者要给每个控件单独排\n * 一次尺寸回调。没有 ResizeObserver 的环境(jsdom)没有布局可测,直接不接。\n */\nfunction getObserver(): ResizeObserver | null {\n if (typeof ResizeObserver === 'undefined') return null\n if (observer) return observer\n\n observer = new ResizeObserver(entries => {\n for (const entry of entries) {\n const target = watched.get(entry.target)\n if (!target) continue\n\n const empty = entry.contentRect.width === 0\n if (target.empty === empty) continue\n\n target.empty = empty\n target.notify(empty)\n }\n })\n\n return observer\n}\n\nfunction watch(target: Element, notify: (empty: boolean) => void) {\n const shared = getObserver()\n if (!shared) return\n\n // 初值跟宿主的默认状态一致:非空。首帧的真实尺寸由 observe() 的初始回调报上来。\n watched.set(target, { notify, empty: false })\n shared.observe(target)\n}\n\nfunction unwatch(target: Element) {\n watched.delete(target)\n observer?.unobserve(target)\n}\n\n/** 把标签盒子的空 / 非空变化报给宿主;断开重连后重新接上观察。 */\nexport class LabelEmptinessController implements ReactiveController {\n private target: Element | undefined\n\n constructor(\n private readonly host: ReactiveControllerHost & LitElement,\n private readonly selector: string,\n private readonly notify: (empty: boolean) => void\n ) {\n host.addController(this)\n }\n\n /*\n * 两个生命周期都要接:重连(移出 DOM 再放回去、列表重排、Teleport)不会排一次更新,所以只挂在\n * hostUpdated 上的话,观察在断开时被拆掉就再也接不回来,标签后来改成什么都不显示宽度都停在旧值上。\n * 而首连时 shadow root 还没有子节点,这里查不到目标,得靠 hostUpdated 补上第一次。\n */\n hostConnected() {\n this.attach()\n }\n\n hostUpdated() {\n this.attach()\n }\n\n hostDisconnected() {\n if (this.target) unwatch(this.target)\n this.target = undefined\n }\n\n private attach() {\n if (this.target) return\n\n const target = this.host.renderRoot.querySelector(this.selector)\n if (!target) return\n\n this.target = target\n watch(target, this.notify)\n }\n}\n"],"mappings":";AAkBA,IAAM,oBAAU,IAAI,QAA0B,GAC1C;AAMJ,SAAS,IAAqC;CAiB5C,OAhBI,OAAO,iBAAmB,MAAoB,OAC9C,MAEJ,IAAW,IAAI,gBAAe,MAAW;EACvC,KAAK,IAAM,KAAS,GAAS;GAC3B,IAAM,IAAS,EAAQ,IAAI,EAAM,MAAM;GACvC,IAAI,CAAC,GAAQ;GAEb,IAAM,IAAQ,EAAM,YAAY,UAAU;GACtC,EAAO,UAAU,MAErB,EAAO,QAAQ,GACf,EAAO,OAAO,CAAK;EACrB;CACF,CAAC,GAEM;AACT;AAEA,SAAS,EAAM,GAAiB,GAAkC;CAChE,IAAM,IAAS,EAAY;CACtB,MAGL,EAAQ,IAAI,GAAQ;EAAE;EAAQ,OAAO;CAAM,CAAC,GAC5C,EAAO,QAAQ,CAAM;AACvB;AAEA,SAAS,EAAQ,GAAiB;CAEhC,AADA,EAAQ,OAAO,CAAM,GACrB,GAAU,UAAU,CAAM;AAC5B;AAGA,IAAa,IAAb,MAAoE;CAGlE,YACE,GACA,GACA,GACA;EACA,AAJiB,KAAA,OAAA,GACA,KAAA,WAAA,GACA,KAAA,SAAA,GAEjB,EAAK,cAAc,IAAI;CACzB;CAOA,gBAAgB;EACd,KAAK,OAAO;CACd;CAEA,cAAc;EACZ,KAAK,OAAO;CACd;CAEA,mBAAmB;EAEjB,AADI,KAAK,UAAQ,EAAQ,KAAK,MAAM,GACpC,KAAK,SAAS,KAAA;CAChB;CAEA,SAAiB;EACf,IAAI,KAAK,QAAQ;EAEjB,IAAM,IAAS,KAAK,KAAK,WAAW,cAAc,KAAK,QAAQ;EAC1D,MAEL,KAAK,SAAS,GACd,EAAM,GAAQ,KAAK,MAAM;CAC3B;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/label-emptiness/index.ts"],"sourcesContent":["/**\n * 判断 label 槽位有没有真的画出东西。\n *\n * radio / checkbox 的触发行是 `inline-flex` + `gap: 10px`,宿主宽度由内容撑开,所以那条 gap 是\n * indicator 与 label 之间的距离。但 gap 只认「这里有个 flex item」,不认它有没有宽度:槽位空着,或者只放了\n * 一个视觉隐藏的无障碍名(`.sr-only` 出流,不产生行内盒),label 盒子是 0 宽,indicator 右边仍然多出 10px。\n *\n * 判据因此取渲染宽度而不是「槽位有没有被指派内容」——两者只在无障碍名那一种用法上分开,而那正是这里\n * 要修的一种。CSS 也判不出这件事:`slot:empty` 看的是 slot 自己的子节点,被 assign 进来的节点并不是\n * 它的孩子。同理不能用 `hidden` 把盒子收掉,display: none 会连带把里面的文字从无障碍名里去掉。\n */\nimport type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'\n\ninterface Watched {\n readonly notify: (empty: boolean) => void\n empty: boolean\n}\n\nconst watched = new WeakMap<Element, Watched>()\nlet observer: ResizeObserver | undefined\n\n/**\n * 全模块共用一个 ResizeObserver:一个列表里可能有上百个选择控件,逐实例建观察者要给每个控件单独排\n * 一次尺寸回调。没有 ResizeObserver 的环境(jsdom)没有布局可测,直接不接。\n */\nfunction getObserver(): ResizeObserver | null {\n if (typeof ResizeObserver === 'undefined') return null\n if (observer) return observer\n\n observer = new ResizeObserver(entries => {\n for (const entry of entries) {\n const target = watched.get(entry.target)\n if (!target) continue\n\n const empty = entry.contentRect.width === 0\n if (target.empty === empty) continue\n\n target.empty = empty\n target.notify(empty)\n }\n })\n\n return observer\n}\n\nfunction watch(target: Element, notify: (empty: boolean) => void) {\n const shared = getObserver()\n if (!shared) return\n\n // 初值跟宿主的默认状态一致:非空。首帧的真实尺寸由 observe() 的初始回调报上来。\n watched.set(target, { notify, empty: false })\n shared.observe(target)\n}\n\nfunction unwatch(target: Element) {\n watched.delete(target)\n observer?.unobserve(target)\n}\n\n/** 把 label 盒子的空 / 非空变化报给宿主;断开重连后重新接上观察。 */\nexport class LabelEmptinessController implements ReactiveController {\n private target: Element | undefined\n\n constructor(\n private readonly host: ReactiveControllerHost & LitElement,\n private readonly selector: string,\n private readonly notify: (empty: boolean) => void\n ) {\n host.addController(this)\n }\n\n /*\n * 两个生命周期都要接:重连(移出 DOM 再放回去、列表重排、Teleport)不会排一次更新,所以只挂在\n * hostUpdated 上的话,观察在断开时被拆掉就再也接不回来,label 后来改成什么都不显示宽度都停在旧值上。\n * 而首连时 shadow root 还没有子节点,这里查不到目标,得靠 hostUpdated 补上第一次。\n */\n hostConnected() {\n this.attach()\n }\n\n hostUpdated() {\n this.attach()\n }\n\n hostDisconnected() {\n if (this.target) unwatch(this.target)\n this.target = undefined\n }\n\n private attach() {\n if (this.target) return\n\n const target = this.host.renderRoot.querySelector(this.selector)\n if (!target) return\n\n this.target = target\n watch(target, this.notify)\n }\n}\n"],"mappings":";AAkBA,IAAM,oBAAU,IAAI,QAA0B,GAC1C;AAMJ,SAAS,IAAqC;CAiB5C,OAhBI,OAAO,iBAAmB,MAAoB,OAC9C,MAEJ,IAAW,IAAI,gBAAe,MAAW;EACvC,KAAK,IAAM,KAAS,GAAS;GAC3B,IAAM,IAAS,EAAQ,IAAI,EAAM,MAAM;GACvC,IAAI,CAAC,GAAQ;GAEb,IAAM,IAAQ,EAAM,YAAY,UAAU;GACtC,EAAO,UAAU,MAErB,EAAO,QAAQ,GACf,EAAO,OAAO,CAAK;EACrB;CACF,CAAC,GAEM;AACT;AAEA,SAAS,EAAM,GAAiB,GAAkC;CAChE,IAAM,IAAS,EAAY;CACtB,MAGL,EAAQ,IAAI,GAAQ;EAAE;EAAQ,OAAO;CAAM,CAAC,GAC5C,EAAO,QAAQ,CAAM;AACvB;AAEA,SAAS,EAAQ,GAAiB;CAEhC,AADA,EAAQ,OAAO,CAAM,GACrB,GAAU,UAAU,CAAM;AAC5B;AAGA,IAAa,IAAb,MAAoE;CAGlE,YACE,GACA,GACA,GACA;EACA,AAJiB,KAAA,OAAA,GACA,KAAA,WAAA,GACA,KAAA,SAAA,GAEjB,EAAK,cAAc,IAAI;CACzB;CAOA,gBAAgB;EACd,KAAK,OAAO;CACd;CAEA,cAAc;EACZ,KAAK,OAAO;CACd;CAEA,mBAAmB;EAEjB,AADI,KAAK,UAAQ,EAAQ,KAAK,MAAM,GACpC,KAAK,SAAS,KAAA;CAChB;CAEA,SAAiB;EACf,IAAI,KAAK,QAAQ;EAEjB,IAAM,IAAS,KAAK,KAAK,WAAW,cAAc,KAAK,QAAQ;EAC1D,MAEL,KAAK,SAAS,GACd,EAAM,GAAQ,KAAK,MAAM;CAC3B;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/menu-behavior/index.ts"],"sourcesContent":["/**\n * 菜单行为层:dropdown 与 context-menu 共用的交互行为。\n *\n * 结构层(面板构建、迁移、定位)由 menu-portal/menu-tree 与各组件自己承载;\n * 这里只收口四种在两个组件中逐行复制的行为:\n * 1. outside-click 守卫(同帧吞掉打开点击 + composedPath 内含判定)\n * 2. 层级 hover 绑定(200ms 打开计时、touch 守卫、hover 收起更深层级)\n * 3. roving 键盘导航(Arrow/Home/End/Enter/Space/Escape)\n * 4. 关闭中的 submenu presence 收尾栈(动画结束后归还子项并销毁面板)\n *\n * 组件是 adapter:提供层级容器/项的查询与打开/关闭原语,行为由此驱动。\n */\nimport { findFocusedMenuItem, focusMenuItem, getEnabledMenuItems } from '@/shared/menu-portal/menu-tree'\nimport { hideOverlayPresence as hidePanelPresence } from '@/shared/overlay/presence'\n\n/** 菜单层级面板的最小视图:行为层只需要面板(presence/contains)与内容容器(项查询)。 */\nexport interface MenuLevelSurface {\n panel: HTMLElement\n content: HTMLElement\n}\n\nexport interface MenuOutsideClickGuard {\n /** 打开动作后调用:吞掉同一帧内随后冒泡到 document 的本次 click。 */\n arm(): void\n isArmed(): boolean\n /** 事件是否发生在宿主 shadow root 或任何已打开面板内部。 */\n isInside(event: MouseEvent): boolean\n dispose(): void\n}\n\nexport function createMenuOutsideClickGuard(\n host: HTMLElement,\n isInsideOverlayPanel: (node: Node) => boolean\n): MenuOutsideClickGuard {\n let armed = false\n let disarmTimer: ReturnType<typeof setTimeout> | undefined\n\n const isInside = (event: MouseEvent): boolean => {\n for (const node of event.composedPath()) {\n if (node === host || node === host.shadowRoot) return true\n if (node instanceof Node && node.getRootNode() === host.shadowRoot) return true\n if (node instanceof Node && isInsideOverlayPanel(node)) return true\n }\n return false\n }\n\n return {\n arm() {\n armed = true\n clearTimeout(disarmTimer)\n disarmTimer = setTimeout(() => {\n armed = false\n })\n },\n isArmed: () => armed,\n isInside,\n dispose() {\n clearTimeout(disarmTimer)\n armed = false\n }\n }\n}\n\nexport interface MenuHoverDelegate {\n /** 当前展开的 submenu 深度;行为层会绑定 0..depth+1 每一层。 */\n getOpenDepth(): number\n getLevelItems(level: number): HTMLElement[]\n /** 该层当前激活(子面板已展开)的项;用于避免重复打开。 */\n getActiveItem(level: number): HTMLElement | undefined\n isSubmenuItem(item: HTMLElement): boolean\n /** hover 到 submenu 项且它不是当前激活项。 */\n openSubmenu(item: HTMLElement): void\n /** hover 到叶子项且存在更深层级:关闭比它深的子面板(内部自行重新 bind)。 */\n closeFrom(level: number): void\n}\n\nexport interface MenuHoverBinder {\n bind(): void\n dispose(): void\n}\n\nconst SUBMENU_HOVER_DELAY = 200\n\nexport function createMenuHoverBinder(delegate: MenuHoverDelegate, itemSelector: string): MenuHoverBinder {\n let openTimer: ReturnType<typeof setTimeout> | undefined\n let cleanupFns: (() => void)[] = []\n\n function bind() {\n cleanupFns.forEach(fn => fn())\n cleanupFns = []\n\n const depth = delegate.getOpenDepth()\n for (let level = 0; level <= depth + 1; level++) {\n const items = delegate.getLevelItems(level)\n if (!items.length) continue\n items.forEach(item => {\n if (!item.matches(itemSelector) || item.hasAttribute('disabled')) return\n const handler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return\n clearTimeout(openTimer)\n if (delegate.isSubmenuItem(item)) {\n if (delegate.getActiveItem(level) !== item) {\n openTimer = setTimeout(() => delegate.openSubmenu(item), SUBMENU_HOVER_DELAY)\n }\n } else if (delegate.getOpenDepth() > level) {\n delegate.closeFrom(level)\n }\n }\n item.addEventListener('pointerenter', handler, { passive: true })\n cleanupFns.push(() => item.removeEventListener('pointerenter', handler))\n })\n }\n }\n\n return {\n bind,\n dispose() {\n clearTimeout(openTimer)\n cleanupFns.forEach(fn => fn())\n cleanupFns = []\n }\n }\n}\n\nexport interface MenuKeyboardDelegate {\n getFocusedItem(event?: KeyboardEvent): HTMLElement | undefined\n /** 焦点项所在层级;不在任何面板内时返回 undefined。 */\n getLevelOf(item: HTMLElement): number | undefined\n getEnabledItems(level: number): HTMLElement[]\n isSubmenuItem(item: HTMLElement): boolean\n /** 键盘 ArrowRight:以 instant 语义打开该项的子面板。 */\n openSubmenuInstant(item: HTMLElement): void\n /** 键盘 ArrowLeft:关闭 level 层子面板并返回其父项。 */\n closeToParent(level: number): HTMLElement | undefined\n /** Escape:关闭最深一层子面板;没有子面板时关闭整个菜单。 */\n closeDeepestOrAll(): void\n}\n\n/**\n * 处理菜单键盘事件;未命中的键原样返回,调用方不做 preventDefault。\n * 事件监听的挂载位置(宿主 / 面板 / document)由组件自己决定。\n */\nexport function handleMenuKeyboard(delegate: MenuKeyboardDelegate, e: KeyboardEvent): void {\n /*\n * Escape 不在本函数处理:它必须跨组件归属(issue #120 Block 1),由共享仲裁者在\n * document 捕获阶段判定最内层后调用 `closeDeepestOrAll()`。留在各组件自己的\n * keydown 里会让一次 Escape 同时命中内外两层。\n */\n const focused = delegate.getFocusedItem(e)\n const level = focused ? delegate.getLevelOf(focused) : undefined\n if (level === undefined) return\n const items = delegate.getEnabledItems(level)\n const currentIndex = focused ? items.indexOf(focused) : -1\n\n switch (e.key) {\n case 'ArrowDown':\n focusMenuItem(items[(currentIndex + 1 + items.length) % items.length])\n break\n case 'ArrowUp':\n focusMenuItem(items[(currentIndex - 1 + items.length) % items.length])\n break\n case 'Home':\n focusMenuItem(items[0])\n break\n case 'End':\n focusMenuItem(items.at(-1))\n break\n case 'ArrowRight':\n if (focused?.hasAttribute('submenu')) {\n delegate.openSubmenuInstant(focused)\n // 双 rAF:等子面板构建/迁移完成后再聚焦其第一个可用项\n requestAnimationFrame(() => requestAnimationFrame(() => focusMenuItem(delegate.getEnabledItems(level + 1)[0])))\n }\n break\n case 'ArrowLeft':\n if (level > 0) {\n focusMenuItem(delegate.closeToParent(level))\n }\n break\n case 'Enter':\n case ' ':\n focused?.click()\n break\n default:\n return\n }\n e.preventDefault()\n e.stopPropagation()\n}\n\n/** 供 getFocusedItem 实现:优先取事件 composedPath 上的菜单项,其次查找面板内已聚焦项。 */\nexport function getFocusedMenuItemFromPanels(\n event: KeyboardEvent | undefined,\n panels: Iterable<HTMLElement | undefined>,\n itemSelector: string\n): HTMLElement | undefined {\n const eventItem = event\n ?.composedPath()\n .find((node): node is HTMLElement => node instanceof HTMLElement && node.matches(itemSelector))\n if (eventItem) return eventItem\n\n return findFocusedMenuItem(panels)\n}\n\n/** 供 getEnabledItems 实现:从层级 content 查询可用项。 */\nexport function getEnabledMenuLevelItems(content: HTMLElement | undefined): HTMLElement[] {\n return content ? getEnabledMenuItems(content) : []\n}\n\nexport interface ClosingSubmenuStack<C> {\n /** 取出正在关闭的容器以复用(同帧关闭→重开同一父项)。 */\n take(parentItem: HTMLElement): C | undefined\n /** 挂起关闭:等出场动画结束后归还子项并销毁面板。 */\n closeAsync(parentItem: HTMLElement, container: C): void\n /** 立即归还并销毁全部关闭中的容器(整菜单关闭 / 重开路径)。 */\n restoreAll(): void\n /** 全部强制销毁(断开连接时),不再归还子项。 */\n disposeAll(): void\n /**\n * 正在关闭、尚未销毁的容器快照(只读)。\n * 这些面板仍在 DOM 里、可能仍然可见,但已经离开调用方的活跃集合(`_overlays` /\n * `_activeSubmenus`)—— 根层重新 claim 时必须把它们一并重新挂回新会话的子树,\n * 否则它们会被判成「面板外」。\n */\n closing(): C[]\n}\n\nexport interface ClosingSubmenuStackAdapter<C> {\n getPanel(container: C): HTMLElement\n /** 归子项回父项(moveMenuChildren 与隐藏补丁由 adapter 决定)。 */\n restoreItems(container: C, parentItem: HTMLElement): void\n /** 销毁面板(remove + overlay api.dispose 等)。 */\n dispose(container: C): void\n}\n\nexport function createClosingSubmenuStack<C>(adapter: ClosingSubmenuStackAdapter<C>): ClosingSubmenuStack<C> {\n const closing = new Map<HTMLElement, C>()\n\n return {\n take(parentItem) {\n const container = closing.get(parentItem)\n if (container) closing.delete(parentItem)\n return container\n },\n closeAsync(parentItem, container) {\n // 先登记再等出场动画:同帧关闭→重开时 take() 会从这里取走复用\n closing.set(parentItem, container)\n void (async () => {\n if (!(await hidePanelPresence(adapter.getPanel(container)))) return\n if (closing.get(parentItem) !== container) return\n\n closing.delete(parentItem)\n adapter.restoreItems(container, parentItem)\n adapter.dispose(container)\n })()\n },\n restoreAll() {\n closing.forEach((container, parentItem) => {\n adapter.restoreItems(container, parentItem)\n adapter.dispose(container)\n })\n closing.clear()\n },\n disposeAll() {\n closing.forEach(container => adapter.dispose(container))\n closing.clear()\n },\n closing() {\n return [...closing.values()]\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/menu-behavior/index.ts"],"sourcesContent":["/**\n * menu 行为层:dropdown 与 context-menu 共用的交互行为。\n *\n * 结构层(panel 构建、迁移、定位)由 menu-portal/menu-tree 与各组件自己承载;\n * 这里只收口四种在两个组件中逐行复制的行为:\n * 1. outside-click 守卫(同帧吞掉打开点击 + composedPath 内含判定)\n * 2. 层级 hover 绑定(200ms 打开计时、touch 守卫、hover 收起更深层级)\n * 3. roving 键盘导航(Arrow/Home/End/Enter/Space/Escape)\n * 4. 关闭中的 submenu presence 收尾栈(动画结束后归还子项并销毁 panel)\n *\n * 组件是 adapter:提供层级容器/项的查询与打开/关闭原语,行为由此驱动。\n */\nimport { findFocusedMenuItem, focusMenuItem, getEnabledMenuItems } from '@/shared/menu-portal/menu-tree'\nimport { hideOverlayPresence as hidePanelPresence } from '@/shared/overlay/presence'\n\n/** menu hierarchy panel 的最小视图:行为层只需要 panel(presence/contains)与内容容器(项查询)。 */\nexport interface MenuLevelSurface {\n panel: HTMLElement\n content: HTMLElement\n}\n\nexport interface MenuOutsideClickGuard {\n /** 打开动作后调用:吞掉同一帧内随后冒泡到 document 的本次 click。 */\n arm(): void\n isArmed(): boolean\n /** 事件是否发生在宿主 shadow root 或任何已打开 panel 内部。 */\n isInside(event: MouseEvent): boolean\n dispose(): void\n}\n\nexport function createMenuOutsideClickGuard(\n host: HTMLElement,\n isInsideOverlayPanel: (node: Node) => boolean\n): MenuOutsideClickGuard {\n let armed = false\n let disarmTimer: ReturnType<typeof setTimeout> | undefined\n\n const isInside = (event: MouseEvent): boolean => {\n for (const node of event.composedPath()) {\n if (node === host || node === host.shadowRoot) return true\n if (node instanceof Node && node.getRootNode() === host.shadowRoot) return true\n if (node instanceof Node && isInsideOverlayPanel(node)) return true\n }\n return false\n }\n\n return {\n arm() {\n armed = true\n clearTimeout(disarmTimer)\n disarmTimer = setTimeout(() => {\n armed = false\n })\n },\n isArmed: () => armed,\n isInside,\n dispose() {\n clearTimeout(disarmTimer)\n armed = false\n }\n }\n}\n\nexport interface MenuHoverDelegate {\n /** 当前展开的 submenu 深度;行为层会绑定 0..depth+1 每一层。 */\n getOpenDepth(): number\n getLevelItems(level: number): HTMLElement[]\n /** 该层当前激活(子 panel 已展开)的项;用于避免重复打开。 */\n getActiveItem(level: number): HTMLElement | undefined\n isSubmenuItem(item: HTMLElement): boolean\n /** hover 到 submenu item 且它不是当前激活项。 */\n openSubmenu(item: HTMLElement): void\n /** hover 到叶子项且存在更深层级:关闭比它深的子 panel(内部自行重新 bind)。 */\n closeFrom(level: number): void\n}\n\nexport interface MenuHoverBinder {\n bind(): void\n dispose(): void\n}\n\nconst SUBMENU_HOVER_DELAY = 200\n\nexport function createMenuHoverBinder(delegate: MenuHoverDelegate, itemSelector: string): MenuHoverBinder {\n let openTimer: ReturnType<typeof setTimeout> | undefined\n let cleanupFns: (() => void)[] = []\n\n function bind() {\n cleanupFns.forEach(fn => fn())\n cleanupFns = []\n\n const depth = delegate.getOpenDepth()\n for (let level = 0; level <= depth + 1; level++) {\n const items = delegate.getLevelItems(level)\n if (!items.length) continue\n items.forEach(item => {\n if (!item.matches(itemSelector) || item.hasAttribute('disabled')) return\n const handler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return\n clearTimeout(openTimer)\n if (delegate.isSubmenuItem(item)) {\n if (delegate.getActiveItem(level) !== item) {\n openTimer = setTimeout(() => delegate.openSubmenu(item), SUBMENU_HOVER_DELAY)\n }\n } else if (delegate.getOpenDepth() > level) {\n delegate.closeFrom(level)\n }\n }\n item.addEventListener('pointerenter', handler, { passive: true })\n cleanupFns.push(() => item.removeEventListener('pointerenter', handler))\n })\n }\n }\n\n return {\n bind,\n dispose() {\n clearTimeout(openTimer)\n cleanupFns.forEach(fn => fn())\n cleanupFns = []\n }\n }\n}\n\nexport interface MenuKeyboardDelegate {\n getFocusedItem(event?: KeyboardEvent): HTMLElement | undefined\n /** 焦点项所在层级;不在任何 panel 内时返回 undefined。 */\n getLevelOf(item: HTMLElement): number | undefined\n getEnabledItems(level: number): HTMLElement[]\n isSubmenuItem(item: HTMLElement): boolean\n /** 键盘 ArrowRight:以 instant 语义打开该项的子 panel。 */\n openSubmenuInstant(item: HTMLElement): void\n /** 键盘 ArrowLeft:关闭 level 层子 panel 并返回其父项。 */\n closeToParent(level: number): HTMLElement | undefined\n /** Escape:关闭最深一层子 panel;没有子 panel 时关闭整个 menu。 */\n closeDeepestOrAll(): void\n}\n\n/**\n * 处理 menu 键盘事件;未命中的键原样返回,调用方不做 preventDefault。\n * 事件监听的挂载位置(宿主 / panel / document)由组件自己决定。\n */\nexport function handleMenuKeyboard(delegate: MenuKeyboardDelegate, e: KeyboardEvent): void {\n /*\n * Escape 不在本函数处理:它必须跨组件归属(issue #120 Block 1),由共享仲裁者在\n * document 捕获阶段判定最内层后调用 `closeDeepestOrAll()`。留在各组件自己的\n * keydown 里会让一次 Escape 同时命中内外两层。\n */\n const focused = delegate.getFocusedItem(e)\n const level = focused ? delegate.getLevelOf(focused) : undefined\n if (level === undefined) return\n const items = delegate.getEnabledItems(level)\n const currentIndex = focused ? items.indexOf(focused) : -1\n\n switch (e.key) {\n case 'ArrowDown':\n focusMenuItem(items[(currentIndex + 1 + items.length) % items.length])\n break\n case 'ArrowUp':\n focusMenuItem(items[(currentIndex - 1 + items.length) % items.length])\n break\n case 'Home':\n focusMenuItem(items[0])\n break\n case 'End':\n focusMenuItem(items.at(-1))\n break\n case 'ArrowRight':\n if (focused?.hasAttribute('submenu')) {\n delegate.openSubmenuInstant(focused)\n // 双 rAF:等子 panel 构建/迁移完成后再聚焦其第一个可用项\n requestAnimationFrame(() => requestAnimationFrame(() => focusMenuItem(delegate.getEnabledItems(level + 1)[0])))\n }\n break\n case 'ArrowLeft':\n if (level > 0) {\n focusMenuItem(delegate.closeToParent(level))\n }\n break\n case 'Enter':\n case ' ':\n focused?.click()\n break\n default:\n return\n }\n e.preventDefault()\n e.stopPropagation()\n}\n\n/** 供 getFocusedItem 实现:优先取事件 composedPath 上的 menu item,其次查找 panel 内已聚焦项。 */\nexport function getFocusedMenuItemFromPanels(\n event: KeyboardEvent | undefined,\n panels: Iterable<HTMLElement | undefined>,\n itemSelector: string\n): HTMLElement | undefined {\n const eventItem = event\n ?.composedPath()\n .find((node): node is HTMLElement => node instanceof HTMLElement && node.matches(itemSelector))\n if (eventItem) return eventItem\n\n return findFocusedMenuItem(panels)\n}\n\n/** 供 getEnabledItems 实现:从层级 content 查询可用项。 */\nexport function getEnabledMenuLevelItems(content: HTMLElement | undefined): HTMLElement[] {\n return content ? getEnabledMenuItems(content) : []\n}\n\nexport interface ClosingSubmenuStack<C> {\n /** 取出正在关闭的容器以复用(同帧关闭→重开同一父项)。 */\n take(parentItem: HTMLElement): C | undefined\n /** 挂起关闭:等出场动画结束后归还子项并销毁 panel。 */\n closeAsync(parentItem: HTMLElement, container: C): void\n /** 立即归还并销毁全部关闭中的容器(整 menu 关闭 / 重开路径)。 */\n restoreAll(): void\n /** 全部强制销毁(断开连接时),不再归还子项。 */\n disposeAll(): void\n /**\n * 正在关闭、尚未销毁的容器快照(只读)。\n * 这些 panel 仍在 DOM 里、可能仍然可见,但已经离开调用方的活跃集合(`_overlays` /\n * `_activeSubmenus`)—— 根层重新 claim 时必须把它们一并重新挂回新会话的子树,\n * 否则它们会被判成「panel 外」。\n */\n closing(): C[]\n}\n\nexport interface ClosingSubmenuStackAdapter<C> {\n getPanel(container: C): HTMLElement\n /** 归子项回父项(moveMenuChildren 与隐藏补丁由 adapter 决定)。 */\n restoreItems(container: C, parentItem: HTMLElement): void\n /** 销毁 panel(remove + overlay api.dispose 等)。 */\n dispose(container: C): void\n}\n\nexport function createClosingSubmenuStack<C>(adapter: ClosingSubmenuStackAdapter<C>): ClosingSubmenuStack<C> {\n const closing = new Map<HTMLElement, C>()\n\n return {\n take(parentItem) {\n const container = closing.get(parentItem)\n if (container) closing.delete(parentItem)\n return container\n },\n closeAsync(parentItem, container) {\n // 先登记再等出场动画:同帧关闭→重开时 take() 会从这里取走复用\n closing.set(parentItem, container)\n void (async () => {\n if (!(await hidePanelPresence(adapter.getPanel(container)))) return\n if (closing.get(parentItem) !== container) return\n\n closing.delete(parentItem)\n adapter.restoreItems(container, parentItem)\n adapter.dispose(container)\n })()\n },\n restoreAll() {\n closing.forEach((container, parentItem) => {\n adapter.restoreItems(container, parentItem)\n adapter.dispose(container)\n })\n closing.clear()\n },\n disposeAll() {\n closing.forEach(container => adapter.dispose(container))\n closing.clear()\n },\n closing() {\n return [...closing.values()]\n }\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"menu-portal.js","names":[],"sources":["../../../src/shared/menu-portal/menu-portal.ts"],"sourcesContent":["import glass from '@/assets/glass.css?inline'\nimport menuPortalStyle from '@/assets/menu-portal.css?inline'\nimport overlayMotion from '@/assets/overlay-motion.css?inline'\nimport internalScrollbars from '@/scrollbars/internal.css?inline'\nimport { resolveOverlayContainer } from '@/shared/overlay/portal'\n\nexport interface MenuPortalOverlay {\n readonly panel: HTMLElement\n readonly content: HTMLElement\n}\n\n// 菜单面板所需的共享样式。theme-owned/fallback overlay root 已预注入这些样式;但当 overlay 因\n// target 位于已打开原生 dialog 内而被挂到该 dialog(top layer)时,dialog 的\n// shadow root 并不包含这些样式。故面板自携带一份,保证在任何容器下都能正确渲染。\nconst MENU_PANEL_STYLES = `${glass}\n${menuPortalStyle}\n${overlayMotion}\n${internalScrollbars}`\n\n/**\n * 菜单浮层面板:面板直接挂到 overlay 容器(与普通 Overlay Portal 共用容器解析),\n * 样式复用容器内已注入的共享菜单样式(glass/menu-portal/overlay-motion),\n * 因此无需自建 shadow。菜单内容由 dropdown/context-menu 自行构建(menu-tree),\n * 这里只提供 panel 与 content 挂载点。\n */\n/**\n * @param scrim 显式容器。模态化的 context-menu 传自己的 scrim `<dialog>`,面板因此\n * 与 scrim 同处 top layer,「点外面」由 scrim 吃掉,面板不会被下层抢走命中。\n *\n 不能靠 `resolveOverlayContainer` 的祖先发现自动找到它:该函数查的是「**target 的祖先**里\n 有没有已打开的 dialog」,而 scrim 是面板的**后代方向**,方向相反,查不到。菜单面板\n * 又不在 host 的 shadow root 里(它在 `web-ui-theme` 的 overlay root 内),这条路径也断。\n *\n * 省略时行为与从前完全一致 —— dropdown 的 `target` 承重调用点(dropdown-in-dialog)\n * 一个字都不用改。\n */\nexport function createMenuPortalOverlay(className: string, target?: Element, scrim?: HTMLElement): MenuPortalOverlay {\n const panel = document.createElement('div')\n // 单层玻璃:wui-glass 留在面板自身,背景/阴影/backdrop-filter 随面板 opacity\n // 与 blur 插值一起过渡(见 overlay-motion.css),无需 blur/surface 双层。\n panel.className = `wui-menu-portal-overlay wui-floating-panel wui-glass ${className}`\n panel.dataset.wuiPresence = 'entering'\n const scroll = document.createElement('div')\n scroll.className = 'wui-menu-scroll wui-scroll'\n const content = document.createElement('div')\n content.className = 'wui-menu-content'\n scroll.append(content)\n\n const container =\n scrim ??\n resolveOverlayContainer(undefined, target ?? document.body, {\n preferRootTheme: target === undefined\n })\n // theme-owned/fallback overlay root 已预注入共享样式;只有 dialog 没有,因此按容器条件注入。\n if (container instanceof HTMLDialogElement) {\n const style = document.createElement('style')\n style.textContent = MENU_PANEL_STYLES\n panel.append(style)\n }\n panel.append(scroll)\n container.appendChild(panel)\n\n return { panel, content }\n}\n"],"mappings":";;;;;;AAcA,IAAM,IAAoB,GAAG,EAAM;EACjC,EAAgB;EAChB,EAAc;EACd;AAmBF,SAAgB,EAAwB,GAAmB,GAAkB,GAAwC;CACnH,IAAM,IAAQ,SAAS,cAAc,KAAK;CAI1C,AADA,EAAM,YAAY,wDAAwD,KAC1E,EAAM,QAAQ,cAAc;CAC5B,IAAM,IAAS,SAAS,cAAc,KAAK;CAC3C,EAAO,YAAY;CACnB,IAAM,IAAU,SAAS,cAAc,KAAK;CAE5C,AADA,EAAQ,YAAY,oBACpB,EAAO,OAAO,CAAO;CAErB,IAAM,IACJ,KACA,EAAwB,KAAA,GAAW,KAAU,SAAS,MAAM,EAC1D,iBAAiB,MAAW,KAAA,EAC9B,CAAC;CAEH,IAAI,aAAqB,mBAAmB;EAC1C,IAAM,IAAQ,SAAS,cAAc,OAAO;EAE5C,AADA,EAAM,cAAc,GACpB,EAAM,OAAO,CAAK;CACpB;CAIA,OAHA,EAAM,OAAO,CAAM,GACnB,EAAU,YAAY,CAAK,GAEpB;EAAE;EAAO;CAAQ;AAC1B"}
1
+ {"version":3,"file":"menu-portal.js","names":[],"sources":["../../../src/shared/menu-portal/menu-portal.ts"],"sourcesContent":["import glass from '@/assets/glass.css?inline'\nimport menuPortalStyle from '@/assets/menu-portal.css?inline'\nimport overlayMotion from '@/assets/overlay-motion.css?inline'\nimport internalScrollbars from '@/scrollbars/internal.css?inline'\nimport { resolveOverlayContainer } from '@/shared/overlay/portal'\n\nexport interface MenuPortalOverlay {\n readonly panel: HTMLElement\n readonly content: HTMLElement\n}\n\n// menu panel 所需的共享样式。theme-owned/fallback overlay root 已预注入这些样式;但当 overlay 因\n// target 位于已打开原生 dialog 内而被挂到该 dialog(top layer)时,dialog 的\n// shadow root 并不包含这些样式。故 panel 自携带一份,保证在任何容器下都能正确渲染。\nconst MENU_PANEL_STYLES = `${glass}\n${menuPortalStyle}\n${overlayMotion}\n${internalScrollbars}`\n\n/**\n * menu overlay panel:panel 直接挂到 overlay 容器(与普通 Overlay Portal 共用容器解析),\n * 样式复用容器内已注入的共享 menu 样式(glass/menu-portal/overlay-motion),\n * 因此无需自建 shadow。menu content 由 dropdown/context-menu 自行构建(menu-tree),\n * 这里只提供 panel 与 content 挂载点。\n */\n/**\n * @param scrim 显式容器。模态化的 context-menu 传自己的 scrim `<dialog>`,panel 因此\n * 与 scrim 同处 top layer,「点外面」由 scrim 吃掉,panel 不会被下层抢走命中。\n *\n 不能靠 `resolveOverlayContainer` 的祖先发现自动找到它:该函数查的是「**target 的祖先**里\n 有没有已打开的 dialog」,而 scrim 是 panel 的**后代方向**,方向相反,查不到。menu panel\n * 又不在 host 的 shadow root 里(它在 `web-ui-theme` 的 overlay root 内),这条路径也断。\n *\n * 省略时行为与从前完全一致 —— dropdown 的 `target` 承重调用点(dropdown-in-dialog)\n * 一个字都不用改。\n */\nexport function createMenuPortalOverlay(className: string, target?: Element, scrim?: HTMLElement): MenuPortalOverlay {\n const panel = document.createElement('div')\n // 单层玻璃:wui-glass 留在 panel 自身,背景/阴影/backdrop-filter 随 panel opacity\n // 与 blur 插值一起过渡(见 overlay-motion.css),无需 blur/surface 双层。\n panel.className = `wui-menu-portal-overlay wui-floating-panel wui-glass ${className}`\n panel.dataset.wuiPresence = 'entering'\n const scroll = document.createElement('div')\n scroll.className = 'wui-menu-scroll wui-scroll'\n const content = document.createElement('div')\n content.className = 'wui-menu-content'\n scroll.append(content)\n\n const container =\n scrim ??\n resolveOverlayContainer(undefined, target ?? document.body, {\n preferRootTheme: target === undefined\n })\n // theme-owned/fallback overlay root 已预注入共享样式;只有 dialog 没有,因此按容器条件注入。\n if (container instanceof HTMLDialogElement) {\n const style = document.createElement('style')\n style.textContent = MENU_PANEL_STYLES\n panel.append(style)\n }\n panel.append(scroll)\n container.appendChild(panel)\n\n return { panel, content }\n}\n"],"mappings":";;;;;;AAcA,IAAM,IAAoB,GAAG,EAAM;EACjC,EAAgB;EAChB,EAAc;EACd;AAmBF,SAAgB,EAAwB,GAAmB,GAAkB,GAAwC;CACnH,IAAM,IAAQ,SAAS,cAAc,KAAK;CAI1C,AADA,EAAM,YAAY,wDAAwD,KAC1E,EAAM,QAAQ,cAAc;CAC5B,IAAM,IAAS,SAAS,cAAc,KAAK;CAC3C,EAAO,YAAY;CACnB,IAAM,IAAU,SAAS,cAAc,KAAK;CAE5C,AADA,EAAQ,YAAY,oBACpB,EAAO,OAAO,CAAO;CAErB,IAAM,IACJ,KACA,EAAwB,KAAA,GAAW,KAAU,SAAS,MAAM,EAC1D,iBAAiB,MAAW,KAAA,EAC9B,CAAC;CAEH,IAAI,aAAqB,mBAAmB;EAC1C,IAAM,IAAQ,SAAS,cAAc,OAAO;EAE5C,AADA,EAAM,cAAc,GACpB,EAAM,OAAO,CAAK;CACpB;CAIA,OAHA,EAAM,OAAO,CAAM,GACnB,EAAU,YAAY,CAAK,GAEpB;EAAE;EAAO;CAAQ;AAC1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"menu-tree.js","names":[],"sources":["../../../src/shared/menu-portal/menu-tree.ts"],"sourcesContent":["export const MENU_CONTENT_SELECTOR = 'web-ui-dropdown-item, web-ui-dropdown-divider, web-ui-dropdown-header'\nexport const MENU_ITEM_SELECTOR = 'web-ui-dropdown-item'\n\nexport function getMenuChildren(container: ParentNode): HTMLElement[] {\n const children: HTMLElement[] = []\n for (const child of Array.from(container.children)) {\n if (!(child instanceof HTMLElement)) continue\n if (child.matches(MENU_CONTENT_SELECTOR)) {\n children.push(child)\n continue\n }\n\n // 框架条件渲染会用元素包裹自有节点,或留下注释锚点。搜索 wrapper,\n // 但不进入 menu item 内部;其子节点属于嵌套 submenu。\n children.push(...getMenuChildren(child))\n }\n return children\n}\n\nexport function getEnabledMenuItems(container: ParentNode): HTMLElement[] {\n return getMenuChildren(container).filter(item => item.matches(`${MENU_ITEM_SELECTOR}:not([disabled])`))\n}\n\nexport function getMovableMenuSubtrees(container: ParentNode): HTMLElement[] {\n const subtrees: HTMLElement[] = []\n for (const child of Array.from(container.children)) {\n if (!(child instanceof HTMLElement)) continue\n if (child.matches(MENU_CONTENT_SELECTOR) || getMenuChildren(child).length > 0) subtrees.push(child)\n }\n return subtrees\n}\n\nexport function moveMenuChildren(source: ParentNode, target: HTMLElement) {\n getMovableMenuSubtrees(source).forEach(subtree => {\n // 正常调用 source !== target,移走后项不再属于 source,天然收敛;\n // 这里兜底防御 source === target 的误用,避免 appendChild 自移动触发观察者刷新循环。\n if (subtree.parentNode === target) return\n target.appendChild(subtree)\n })\n}\n\nexport function hideNestedMenuChildren(root: ParentNode, slot: string) {\n root.querySelectorAll<HTMLElement>('web-ui-dropdown-item[submenu]').forEach(item => {\n getMenuChildren(item).forEach(child => child.setAttribute('slot', slot))\n })\n}\n\nexport function getMenuItemFromEvent(event: Event): HTMLElement | null {\n return (\n event\n .composedPath()\n .find((node): node is HTMLElement => node instanceof HTMLElement && node.matches(MENU_ITEM_SELECTOR)) ?? null\n )\n}\n\nexport interface MenuItemFocusOptions {\n /** 初始打开时的程序化聚焦不应绘制键盘焦点视觉。 */\n suppressFocusVisible?: boolean\n}\n\nexport function focusMenuItem(item: HTMLElement | undefined, options: MenuItemFocusOptions = {}) {\n if (!item || item.hasAttribute('disabled')) return\n item.toggleAttribute('data-wui-menu-focus-suppressed', options.suppressFocusVisible === true)\n ;(item as HTMLElement & { focusItem?: () => void }).focusItem?.()\n}\n\nexport function findFocusedMenuItem(panels: Iterable<HTMLElement | undefined>): HTMLElement | undefined {\n return [...panels]\n .filter((panel): panel is HTMLElement => panel !== undefined)\n .flatMap(panel => Array.from(panel.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)))\n .find(item => Boolean(item.shadowRoot?.activeElement))\n}\n\n/**\n * 托管菜单项的模板位锚:每个被迁入面板的项在宿主 light DOM 留一个 marker 注释,\n * 表示其模板位。宿主因此始终持有完整有序骨架(marker + 框架注释锚点 + 非菜单节点),\n * 框架(Vue/React 条件渲染)打开期的插入以宿主为容器、以真实锚点为基准;\n * 面板项的顺序按 marker 序重排;关闭时按 marker 位双向归还。\n * context-menu 与 dropdown 的打开期实时渲染共用本机制。\n */\nexport type MenuItemAnchors = Map<HTMLElement, Comment>\n\n/**\n * 全集 reconcile:以「content 现有元素 ∪ 宿主元素」为托管全集。\n * 框架(如 Vue 的 v-if 翻转)可能不经宿主直接改写 portal 内节点——卸载旧项留下\n * 注释锚点、把新项直接插进 content——它们都会被这里收编(补 marker + 补隐藏由调用方负责),\n * 已被框架删除/移出两处的条目则清理 marker。最后把宿主中尚存的托管项移入 content。\n */\nexport function reconcileManagedMenuItems(\n host: HTMLElement,\n content: HTMLElement,\n anchors: MenuItemAnchors,\n markerText: string\n): void {\n // 宿主与 portal 面板分别位于不同容器,结构上不相交且收集只看直接子层,\n // 两个集合不会重复。\n const inContent = getMovableMenuSubtrees(content)\n const inHost = getMovableMenuSubtrees(host)\n const managed = new Set([...inContent, ...inHost])\n\n // 清理已失效条目:托管元素既不在 content 也不在宿主,说明已被框架删除。\n // 仍在 content 的元素必被上面收集进 managed,故这里只需处理宿主侧。\n anchors.forEach((marker, subtree) => {\n if (managed.has(subtree)) return\n marker.remove()\n anchors.delete(subtree)\n })\n\n // 确保每个托管元素都有 marker:框架直接插入 content 的项在此收编。\n // content 项按其当前 DOM 序(框架语义序)**反向**处理,新 marker 插到后继项 marker 之前——\n // 这样补建的 marker 序无需重排即可与 content 对齐;宿主项的 marker 原地补插。\n const ensureMarker = (subtree: HTMLElement, inHost: boolean, before?: Comment) => {\n const existing = anchors.get(subtree)\n if (existing && existing.parentNode === host) return existing\n existing?.remove()\n const marker = document.createComment(markerText)\n if (inHost) {\n host.insertBefore(marker, subtree)\n } else if (before && before.parentNode === host) {\n host.insertBefore(marker, before)\n } else {\n host.appendChild(marker)\n }\n anchors.set(subtree, marker)\n return marker\n }\n for (let index = inContent.length - 1; index >= 0; index--) {\n const subtree = inContent[index]\n const next = inContent[index + 1]\n const nextMarker = next ? anchors.get(next) : undefined\n ensureMarker(subtree, false, nextMarker)\n }\n for (const subtree of inHost) {\n ensureMarker(subtree, true)\n }\n\n for (const subtree of inHost) {\n content.appendChild(subtree)\n }\n}\n\n/** 按宿主 marker 序重排 content 项;顺序已一致时早退。 */\nexport function orderManagedMenuItems(\n host: HTMLElement,\n content: HTMLElement,\n anchors: MenuItemAnchors,\n markerText: string\n): void {\n const targetItems: HTMLElement[] = []\n for (const node of Array.from(host.childNodes)) {\n if (node.nodeType !== Node.COMMENT_NODE || node.textContent !== markerText) continue\n anchors.forEach((marker, subtree) => {\n if (marker !== node || subtree.parentNode !== content) return\n targetItems.push(subtree)\n })\n }\n\n if (targetItems.length === 0) return\n\n const currentItems = Array.from(content.children)\n if (currentItems.length === targetItems.length && currentItems.every((item, index) => item === targetItems[index]))\n return\n\n // 把每个元素移动到目标序中下一个元素之前,而不是 appendChild 全部移到末尾。\n // appendChild 会把元素越过 content 中的框架注释锚点(v-if 锚点),导致锚点脱离\n // 模板位置,下次框架翻转时据此锚点插入新分支项就会落错位;insertBefore 只重排\n // 元素相对顺序,锚点保持在原模板位置。锚点复位由 restoreFrameworkAnchors 独立完成。\n for (let index = targetItems.length - 1; index >= 0; index--) {\n const item = targetItems[index]\n const next = targetItems[index + 1]\n if (next) {\n if (item.nextElementSibling !== next) content.insertBefore(item, next)\n } else {\n content.appendChild(item)\n }\n }\n}\n\n/**\n * 捕获框架 v-if 注释锚点相对其后继元素的绑定,供重排后复位。\n * 后继以 nextElementSibling 解析(跳过注释/文本,多个相邻锚点绑定同一后继)。\n * 锚点在 content 末尾、无后继元素时记为 null(尾部锚点,复位时保持末尾)。\n */\nexport function captureFrameworkAnchors(content: HTMLElement, markerText: string): Map<Comment, HTMLElement | null> {\n const anchors = new Map<Comment, HTMLElement | null>()\n for (const node of Array.from(content.childNodes)) {\n if (node instanceof Comment && node.textContent !== markerText) {\n const nextEl = node.nextElementSibling\n anchors.set(node, nextEl instanceof HTMLElement ? nextEl : null)\n }\n }\n return anchors\n}\n\n/**\n * 把锚点复位到捕获时其后继元素之前(尾部锚点保持末尾)。\n * 元素重排用 insertBefore/appendChild 不会移动注释,锚点必须显式复位,\n * 否则脱离模板位置后 Vue 下次 v-if 翻转会把新分支项插到错误插入点。\n */\nexport function restoreFrameworkAnchors(content: HTMLElement, anchors: Map<Comment, HTMLElement | null>): void {\n for (const [anchor, successor] of anchors) {\n if (anchor.parentNode !== content) continue\n if (successor && successor.parentNode === content) {\n if (anchor.nextElementSibling !== successor) content.insertBefore(anchor, successor)\n } else {\n // 尾部锚点:重排可能把元素 append 到末尾越过锚点,恢复其末尾位置。\n content.appendChild(anchor)\n }\n }\n}\n\n/**\n * 关闭恢复:按 content 子节点序双向归还——元素回到宿主中自己的 marker 位,\n * 框架 v-if 注释锚点插到对应元素之前。Vue 仍持有这些锚点的 vnode.el 引用,\n * 随元素一起迁回宿主可确保框架下次 patch 以宿主为容器,不会因 parentNode === null 崩溃。\n * 归还后清除全部 marker 与登记。\n */\nexport function returnManagedMenuItemsToSlot(host: HTMLElement, content: HTMLElement, anchors: MenuItemAnchors): void {\n const pending: Comment[] = []\n for (const node of Array.from(content.childNodes)) {\n // instanceof 收窄:Comment 接口为空,nodeType 继承自 Node 的 number,无法用 === 收窄\n if (node instanceof Comment) {\n pending.push(node)\n continue\n }\n if (!(node instanceof HTMLElement)) continue\n const marker = anchors.get(node)\n if (!marker) continue\n for (const c of pending) {\n if (marker.parentNode === host) host.insertBefore(c, marker)\n else host.appendChild(c)\n }\n pending.length = 0\n if (marker.parentNode === host) {\n host.insertBefore(node, marker)\n } else {\n host.appendChild(node)\n }\n marker.remove()\n anchors.delete(node)\n }\n // 尾部框架锚点追加到宿主末尾\n for (const c of pending) host.appendChild(c)\n // 映射中剩余 = 已不在 content 的条目(如翻转中被框架卸载的),仅清理 marker\n anchors.forEach(marker => marker.remove())\n anchors.clear()\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"menu-tree.js","names":[],"sources":["../../../src/shared/menu-portal/menu-tree.ts"],"sourcesContent":["export const MENU_CONTENT_SELECTOR = 'web-ui-dropdown-item, web-ui-dropdown-divider, web-ui-dropdown-header'\nexport const MENU_ITEM_SELECTOR = 'web-ui-dropdown-item'\n\nexport function getMenuChildren(container: ParentNode): HTMLElement[] {\n const children: HTMLElement[] = []\n for (const child of Array.from(container.children)) {\n if (!(child instanceof HTMLElement)) continue\n if (child.matches(MENU_CONTENT_SELECTOR)) {\n children.push(child)\n continue\n }\n\n // 框架条件渲染会用元素包裹自有节点,或留下注释锚点。搜索 wrapper,\n // 但不进入 menu item 内部;其子节点属于嵌套 submenu。\n children.push(...getMenuChildren(child))\n }\n return children\n}\n\nexport function getEnabledMenuItems(container: ParentNode): HTMLElement[] {\n return getMenuChildren(container).filter(item => item.matches(`${MENU_ITEM_SELECTOR}:not([disabled])`))\n}\n\nexport function getMovableMenuSubtrees(container: ParentNode): HTMLElement[] {\n const subtrees: HTMLElement[] = []\n for (const child of Array.from(container.children)) {\n if (!(child instanceof HTMLElement)) continue\n if (child.matches(MENU_CONTENT_SELECTOR) || getMenuChildren(child).length > 0) subtrees.push(child)\n }\n return subtrees\n}\n\nexport function moveMenuChildren(source: ParentNode, target: HTMLElement) {\n getMovableMenuSubtrees(source).forEach(subtree => {\n // 正常调用 source !== target,移走后项不再属于 source,天然收敛;\n // 这里兜底防御 source === target 的误用,避免 appendChild 自移动触发观察者刷新循环。\n if (subtree.parentNode === target) return\n target.appendChild(subtree)\n })\n}\n\nexport function hideNestedMenuChildren(root: ParentNode, slot: string) {\n root.querySelectorAll<HTMLElement>('web-ui-dropdown-item[submenu]').forEach(item => {\n getMenuChildren(item).forEach(child => child.setAttribute('slot', slot))\n })\n}\n\nexport function getMenuItemFromEvent(event: Event): HTMLElement | null {\n return (\n event\n .composedPath()\n .find((node): node is HTMLElement => node instanceof HTMLElement && node.matches(MENU_ITEM_SELECTOR)) ?? null\n )\n}\n\nexport interface MenuItemFocusOptions {\n /** 初始打开时的程序化聚焦不应绘制键盘焦点视觉。 */\n suppressFocusVisible?: boolean\n}\n\nexport function focusMenuItem(item: HTMLElement | undefined, options: MenuItemFocusOptions = {}) {\n if (!item || item.hasAttribute('disabled')) return\n item.toggleAttribute('data-wui-menu-focus-suppressed', options.suppressFocusVisible === true)\n ;(item as HTMLElement & { focusItem?: () => void }).focusItem?.()\n}\n\nexport function findFocusedMenuItem(panels: Iterable<HTMLElement | undefined>): HTMLElement | undefined {\n return [...panels]\n .filter((panel): panel is HTMLElement => panel !== undefined)\n .flatMap(panel => Array.from(panel.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)))\n .find(item => Boolean(item.shadowRoot?.activeElement))\n}\n\n/**\n * 托管 menu item 的模板位锚:每个被迁入 panel 的项在宿主 light DOM 留一个 marker 注释,\n * 表示其模板位。宿主因此始终持有完整有序骨架(marker + 框架注释锚点 + 非 menu 节点),\n * 框架(Vue/React 条件渲染)打开期的插入以宿主为容器、以真实锚点为基准;\n * panel 项的顺序按 marker 序重排;关闭时按 marker 位双向归还。\n * context-menu 与 dropdown 的打开期实时渲染共用本机制。\n */\nexport type MenuItemAnchors = Map<HTMLElement, Comment>\n\n/**\n * 全集 reconcile:以「content 现有元素 ∪ 宿主元素」为托管全集。\n * 框架(如 Vue 的 v-if 翻转)可能不经宿主直接改写 portal 内节点——卸载旧项留下\n * 注释锚点、把新项直接插进 content——它们都会被这里收编(补 marker + 补隐藏由调用方负责),\n * 已被框架删除/移出两处的条目则清理 marker。最后把宿主中尚存的托管项移入 content。\n */\nexport function reconcileManagedMenuItems(\n host: HTMLElement,\n content: HTMLElement,\n anchors: MenuItemAnchors,\n markerText: string\n): void {\n // 宿主与 portal panel 分别位于不同容器,结构上不相交且收集只看直接子层,\n // 两个集合不会重复。\n const inContent = getMovableMenuSubtrees(content)\n const inHost = getMovableMenuSubtrees(host)\n const managed = new Set([...inContent, ...inHost])\n\n // 清理已失效条目:托管元素既不在 content 也不在宿主,说明已被框架删除。\n // 仍在 content 的元素必被上面收集进 managed,故这里只需处理宿主侧。\n anchors.forEach((marker, subtree) => {\n if (managed.has(subtree)) return\n marker.remove()\n anchors.delete(subtree)\n })\n\n // 确保每个托管元素都有 marker:框架直接插入 content 的项在此收编。\n // content 项按其当前 DOM 序(框架语义序)**反向**处理,新 marker 插到后继项 marker 之前——\n // 这样补建的 marker 序无需重排即可与 content 对齐;宿主项的 marker 原地补插。\n const ensureMarker = (subtree: HTMLElement, inHost: boolean, before?: Comment) => {\n const existing = anchors.get(subtree)\n if (existing && existing.parentNode === host) return existing\n existing?.remove()\n const marker = document.createComment(markerText)\n if (inHost) {\n host.insertBefore(marker, subtree)\n } else if (before && before.parentNode === host) {\n host.insertBefore(marker, before)\n } else {\n host.appendChild(marker)\n }\n anchors.set(subtree, marker)\n return marker\n }\n for (let index = inContent.length - 1; index >= 0; index--) {\n const subtree = inContent[index]\n const next = inContent[index + 1]\n const nextMarker = next ? anchors.get(next) : undefined\n ensureMarker(subtree, false, nextMarker)\n }\n for (const subtree of inHost) {\n ensureMarker(subtree, true)\n }\n\n for (const subtree of inHost) {\n content.appendChild(subtree)\n }\n}\n\n/** 按宿主 marker 序重排 content 项;顺序已一致时早退。 */\nexport function orderManagedMenuItems(\n host: HTMLElement,\n content: HTMLElement,\n anchors: MenuItemAnchors,\n markerText: string\n): void {\n const targetItems: HTMLElement[] = []\n for (const node of Array.from(host.childNodes)) {\n if (node.nodeType !== Node.COMMENT_NODE || node.textContent !== markerText) continue\n anchors.forEach((marker, subtree) => {\n if (marker !== node || subtree.parentNode !== content) return\n targetItems.push(subtree)\n })\n }\n\n if (targetItems.length === 0) return\n\n const currentItems = Array.from(content.children)\n if (currentItems.length === targetItems.length && currentItems.every((item, index) => item === targetItems[index]))\n return\n\n // 把每个元素移动到目标序中下一个元素之前,而不是 appendChild 全部移到末尾。\n // appendChild 会把元素越过 content 中的框架注释锚点(v-if 锚点),导致锚点脱离\n // 模板位置,下次框架翻转时据此锚点插入新分支项就会落错位;insertBefore 只重排\n // 元素相对顺序,锚点保持在原模板位置。锚点复位由 restoreFrameworkAnchors 独立完成。\n for (let index = targetItems.length - 1; index >= 0; index--) {\n const item = targetItems[index]\n const next = targetItems[index + 1]\n if (next) {\n if (item.nextElementSibling !== next) content.insertBefore(item, next)\n } else {\n content.appendChild(item)\n }\n }\n}\n\n/**\n * 捕获框架 v-if 注释锚点相对其后继元素的绑定,供重排后复位。\n * 后继以 nextElementSibling 解析(跳过注释/文本,多个相邻锚点绑定同一后继)。\n * 锚点在 content 末尾、无后继元素时记为 null(尾部锚点,复位时保持末尾)。\n */\nexport function captureFrameworkAnchors(content: HTMLElement, markerText: string): Map<Comment, HTMLElement | null> {\n const anchors = new Map<Comment, HTMLElement | null>()\n for (const node of Array.from(content.childNodes)) {\n if (node instanceof Comment && node.textContent !== markerText) {\n const nextEl = node.nextElementSibling\n anchors.set(node, nextEl instanceof HTMLElement ? nextEl : null)\n }\n }\n return anchors\n}\n\n/**\n * 把锚点复位到捕获时其后继元素之前(尾部锚点保持末尾)。\n * 元素重排用 insertBefore/appendChild 不会移动注释,锚点必须显式复位,\n * 否则脱离模板位置后 Vue 下次 v-if 翻转会把新分支项插到错误插入点。\n */\nexport function restoreFrameworkAnchors(content: HTMLElement, anchors: Map<Comment, HTMLElement | null>): void {\n for (const [anchor, successor] of anchors) {\n if (anchor.parentNode !== content) continue\n if (successor && successor.parentNode === content) {\n if (anchor.nextElementSibling !== successor) content.insertBefore(anchor, successor)\n } else {\n // 尾部锚点:重排可能把元素 append 到末尾越过锚点,恢复其末尾位置。\n content.appendChild(anchor)\n }\n }\n}\n\n/**\n * 关闭恢复:按 content 子节点序双向归还——元素回到宿主中自己的 marker 位,\n * 框架 v-if 注释锚点插到对应元素之前。Vue 仍持有这些锚点的 vnode.el 引用,\n * 随元素一起迁回宿主可确保框架下次 patch 以宿主为容器,不会因 parentNode === null 崩溃。\n * 归还后清除全部 marker 与登记。\n */\nexport function returnManagedMenuItemsToSlot(host: HTMLElement, content: HTMLElement, anchors: MenuItemAnchors): void {\n const pending: Comment[] = []\n for (const node of Array.from(content.childNodes)) {\n // instanceof 收窄:Comment 接口为空,nodeType 继承自 Node 的 number,无法用 === 收窄\n if (node instanceof Comment) {\n pending.push(node)\n continue\n }\n if (!(node instanceof HTMLElement)) continue\n const marker = anchors.get(node)\n if (!marker) continue\n for (const c of pending) {\n if (marker.parentNode === host) host.insertBefore(c, marker)\n else host.appendChild(c)\n }\n pending.length = 0\n if (marker.parentNode === host) {\n host.insertBefore(node, marker)\n } else {\n host.appendChild(node)\n }\n marker.remove()\n anchors.delete(node)\n }\n // 尾部框架锚点追加到宿主末尾\n for (const c of pending) host.appendChild(c)\n // 映射中剩余 = 已不在 content 的条目(如翻转中被框架卸载的),仅清理 marker\n anchors.forEach(marker => marker.remove())\n anchors.clear()\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"combobox-shell.js","names":[],"sources":["../../../src/shared/option-portal/combobox-shell.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { WebUiOption } from '@/components/option'\nimport type { OpenOverlayHandle } from '@/shared/overlay/open-overlay'\n\n/**\n * combobox 开合与 option 交互的共享骨架(select / autocomplete)。\n *\n * 与 option-portal 的边界约定一致:交互语义的\"差异点\"经回调注入,\n * 这里收口两组件逐行复制的生命周期不变量——\n * - open/close 状态迁移顺序:guard → 状态翻转 → open-change 派发 →\n * 激活钩子 → scroll-lock 配对 → portal 模式的 rAF 延迟 openOverlay\n * - close 路径:before-close 钩子(portal 内容先收敛)→ 激活清理 →\n * scroll-lock 释放 → closeOverlay(关闭后使 portal 引用失效)\n * - option 四事件监听对的绑定/解绑\n * - focusout 的 rAF :focus-within 复核\n * - 键盘导航的环绕游标算法\n */\n\nexport interface ComboboxOpenHooks {\n /** 是否允许打开(disabled / readonly 等由组件判断)。 */\n canOpen(): boolean\n getIsOpen(): boolean\n setIsOpen(open: boolean): void\n dispatchOpenChange(): void\n syncScrollLock(open: boolean): void\n /** 是否 portal 模式(决定 openOverlay 是否延迟到下一帧)。 */\n isPortal(): boolean\n openOverlay(isKeyboardNavigation: boolean): void\n closeOverlay(): void\n /** 打开时的激活项初始化(键盘导航取选中/首项,指针打开保留现状)。 */\n onOpen?(isKeyboardNavigation: boolean): void\n /** 关闭前钩子:autocomplete 需先把 light DOM 新增节点收敛进当前 portal content。 */\n onBeforeClose?(): void\n /** 关闭时清理激活状态(清 _activeIndex 与 active 属性)。 */\n onAfterClose?(): void\n}\n\nexport interface ComboboxOpenController {\n open(isKeyboardNavigation?: boolean): void\n close(): void\n}\n\nexport function createComboboxOpenController(hooks: ComboboxOpenHooks): ComboboxOpenController {\n function open(isKeyboardNavigation = false) {\n if (hooks.canOpen() === false || hooks.getIsOpen()) return\n hooks.setIsOpen(true)\n hooks.dispatchOpenChange()\n hooks.onOpen?.(isKeyboardNavigation)\n hooks.syncScrollLock(true)\n if (hooks.isPortal()) {\n requestAnimationFrame(() => {\n if (hooks.getIsOpen()) hooks.openOverlay(isKeyboardNavigation)\n })\n } else {\n hooks.openOverlay(isKeyboardNavigation)\n }\n }\n\n function close() {\n if (!hooks.getIsOpen()) return\n hooks.onBeforeClose?.()\n hooks.setIsOpen(false)\n hooks.dispatchOpenChange()\n hooks.onAfterClose?.()\n hooks.syncScrollLock(false)\n hooks.closeOverlay()\n }\n\n return { open, close }\n}\n\nexport interface OptionListenerHandlers {\n onClick: (event: Event) => void\n onPointerOver: (event: PointerEvent) => void\n onPointerDown: (event: PointerEvent) => void\n onUpdate: () => void\n}\n\nexport interface ComboboxTriggerSource {\n /** 持 ARIA 与委托监听的 trigger 包装 div(shadow 内)。 */\n getWrapper(): HTMLElement | null\n /** `slot[name=\"trigger\"]` 的首个 assigned 元素;无自定义触发器时为 null。 */\n getCustomTrigger(): HTMLElement | null\n /** 默认触发器(shadow 内的 web-ui-input);未渲染时为 null。 */\n getFallbackTrigger(): HTMLElement | null\n}\n\nexport interface ComboboxTriggerHandlers {\n /** 触发器文本变化;value 取自触发器 host 的字符串 value 属性。 */\n onInput(value: string): void\n /** 点击触发器区域(含包装 div 自身留白)。 */\n onClick(): void\n onFocusIn(event: FocusEvent): void\n onFocusOut(event: FocusEvent): void\n}\n\nexport interface ComboboxTriggerApi {\n /** 当前生效的触发器 host:自定义 trigger slot 首个 assigned 元素,否则默认触发器。 */\n getTrigger(): HTMLElement | null\n /** 触发器当前文本;触发器不暴露字符串 value 时返回 null。 */\n getValue(): string | null\n /** 回写文本;仅在文本触发器且值确实变化时写入,避免无谓重渲染。 */\n setValue(value: string): void\n /** 多行可编辑元素判定:Enter 保留换行语义,不接管为「选中高亮项」。 */\n isMultilineEdit(event: KeyboardEvent): boolean\n /**\n * 程序化聚焦当前触发器。组件自带 focus 重定向(web-ui-input/textarea)时落到\n * 内部原生控件;自定义触发器没有该能力时,原生 focus() 聚焦 host 自身。\n */\n focusTrigger(options?: FocusOptions): void\n /** 失焦当前触发器,兜底语义同 focusTrigger。 */\n blurTrigger(): void\n /** 绑定包装 div 上的委托监听(幂等:包装 div 不变则跳过)。 */\n bind(): void\n dispose(): void\n}\n\n/**\n * combobox 触发器委托层(autocomplete)。\n *\n * 默认触发器是 shadow 内的 `web-ui-input`,自定义触发器由 `slot[name=\"trigger\"]`\n * 提供(`web-ui-textarea` 等)。两者对组件暴露同一形状:字符串 value、可聚焦、\n * 派发 composed 的 input / click / focusin / focusout。委托层把这些差异收敛成一份接口:\n * 监听统一挂在包装 div 上(事件委托,覆盖 shadow 内与 light DOM 两条路径),\n * 文本读写只认触发器 host 的 value 属性。\n *\n * 焦点用 focusin/focusout 而非 focus/blur:后两者不冒泡,到不了包装 div\n * (实测:shadow 内默认触发器与 light DOM 自定义触发器都不触发包装 div 的 focus/blur)。\n *\n * 触发器自身派发的 change(web-ui-input/textarea 在原生 change 不 composed 时补发)\n * 必须被拦下:组件的 change 只表示「选中提交」,否则失焦即误报一次 change。\n */\nexport const defineComboboxTrigger = () =>\n definePlugin<ComboboxTriggerApi, ComboboxTriggerSource & ComboboxTriggerHandlers>(source => {\n let boundWrapper: HTMLElement | null = null\n\n const getTrigger = (): HTMLElement | null => source.getCustomTrigger() ?? source.getFallbackTrigger() ?? null\n\n const readValue = (trigger: HTMLElement | null): string | null => {\n const value = (trigger as (HTMLElement & { value?: unknown }) | null)?.value\n return typeof value === 'string' ? value : null\n }\n\n // composed 事件在包装 div 上看到的是触发器 host(或其在 light DOM 内的后代);\n // 只有来自触发器内部的输入才计入,包装 div 上的其它内容不干扰。\n const isWithinTrigger = (event: Event, trigger: HTMLElement | null): boolean => {\n const target = event.target\n return !!trigger && target instanceof Node && (target === trigger || trigger.contains(target))\n }\n\n const onInput = (event: Event) => {\n const trigger = getTrigger()\n if (!isWithinTrigger(event, trigger)) return\n const value = readValue(trigger)\n if (value !== null) source.onInput(value)\n }\n\n const onNativeChange = (event: Event) => {\n if (isWithinTrigger(event, getTrigger())) event.stopPropagation()\n }\n\n const onClick = () => source.onClick()\n const onFocusIn = (event: FocusEvent) => source.onFocusIn(event)\n const onFocusOut = (event: FocusEvent) => source.onFocusOut(event)\n\n return {\n getTrigger,\n getValue: () => readValue(getTrigger()),\n setValue(value: string) {\n const trigger = getTrigger()\n const current = readValue(trigger)\n if (!trigger || current === null || current === value) return\n ;(trigger as HTMLElement & { value: string }).value = value\n },\n isMultilineEdit(event: KeyboardEvent) {\n // composedPath()[0] 是未 retarget 的真实可编辑元素:textarea 保留 Enter 换行\n return event.composedPath()[0] instanceof HTMLTextAreaElement\n },\n focusTrigger(options?: FocusOptions) {\n // web-ui-input/textarea 的公共 focus() 已重定向到内部原生控件;普通元素或\n // 未实现 focus 的自定义触发器走原生 HTMLElement.focus(),即聚焦 host 自身\n getTrigger()?.focus(options)\n },\n blurTrigger() {\n getTrigger()?.blur()\n },\n bind() {\n const wrapper = source.getWrapper()\n if (!wrapper || wrapper === boundWrapper) return\n boundWrapper = wrapper\n wrapper.addEventListener('input', onInput)\n wrapper.addEventListener('change', onNativeChange)\n wrapper.addEventListener('click', onClick)\n wrapper.addEventListener('focusin', onFocusIn)\n wrapper.addEventListener('focusout', onFocusOut)\n },\n dispose() {\n const wrapper = boundWrapper\n boundWrapper = null\n if (!wrapper) return\n wrapper.removeEventListener('input', onInput)\n wrapper.removeEventListener('change', onNativeChange)\n wrapper.removeEventListener('click', onClick)\n wrapper.removeEventListener('focusin', onFocusIn)\n wrapper.removeEventListener('focusout', onFocusOut)\n }\n }\n })\n\n/** option 四事件监听对的绑定/解绑;两组件的 handler 签名一致,仅实现不同。 */\nexport function createOptionListenerBinding(handlers: OptionListenerHandlers) {\n function bind(option: WebUiOption) {\n option.addEventListener('click', handlers.onClick)\n option.addEventListener('pointerover', handlers.onPointerOver)\n option.addEventListener('pointerdown', handlers.onPointerDown)\n option.addEventListener('option-update', handlers.onUpdate)\n }\n function unbind(option: WebUiOption) {\n option.removeEventListener('click', handlers.onClick)\n option.removeEventListener('pointerover', handlers.onPointerOver)\n option.removeEventListener('pointerdown', handlers.onPointerDown)\n option.removeEventListener('option-update', handlers.onUpdate)\n }\n return { bind, unbind }\n}\n\n/**\n * focusout 关闭复核:焦点是否真的离开了宿主与面板。\n * rAF 等待 click/pointerdown 相关的焦点迁移完成后再判定。\n */\nexport function handleComboboxFocusOut(\n host: HTMLElement,\n getHandle: () => OpenOverlayHandle | null | undefined,\n getIsOpen: () => boolean,\n close: () => void\n): void {\n requestAnimationFrame(() => {\n const handle = getHandle()\n if (getIsOpen() && !host.matches(':focus-within') && !handle?.hasFocusWithin()) {\n close()\n }\n })\n}\n\n/**\n * 环绕游标:在 enabled 列表内按 delta 移动;从 -1(无激活)向下取首项、向上取末项。\n * 返回在 enabled 中的下一索引;调用方负责换算回全量 options 索引。\n */\nexport function nextWrappingIndex(enabledCount: number, currentIdx: number, delta: number): number {\n if (enabledCount === 0) return -1\n let nextIdx = currentIdx + delta\n if (nextIdx < 0) nextIdx = enabledCount - 1\n if (nextIdx >= enabledCount) nextIdx = 0\n return nextIdx\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"combobox-shell.js","names":[],"sources":["../../../src/shared/option-portal/combobox-shell.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { WebUiOption } from '@/components/option'\nimport type { OpenOverlayHandle } from '@/shared/overlay/open-overlay'\n\n/**\n * combobox 开合与 option 交互的共享骨架(select / autocomplete)。\n *\n * 与 option-portal 的边界约定一致:交互语义的\"差异点\"经回调注入,\n * 这里收口两组件逐行复制的生命周期不变量——\n * - open/close 状态迁移顺序:guard → 状态翻转 → open-change 派发 →\n * 激活钩子 → scroll-lock 配对 → portal 模式的 rAF 延迟 openOverlay\n * - close 路径:before-close 钩子(portal 内容先收敛)→ 激活清理 →\n * scroll-lock 释放 → closeOverlay(关闭后使 portal 引用失效)\n * - option 四事件监听对的绑定/解绑\n * - focusout 的 rAF :focus-within 复核\n * - 键盘导航的环绕游标算法\n */\n\nexport interface ComboboxOpenHooks {\n /** 是否允许打开(disabled / readonly 等由组件判断)。 */\n canOpen(): boolean\n getIsOpen(): boolean\n setIsOpen(open: boolean): void\n dispatchOpenChange(): void\n syncScrollLock(open: boolean): void\n /** 是否 portal 模式(决定 openOverlay 是否延迟到下一帧)。 */\n isPortal(): boolean\n openOverlay(isKeyboardNavigation: boolean): void\n closeOverlay(): void\n /** 打开时的激活项初始化(键盘导航取选中/首项,指针打开保留现状)。 */\n onOpen?(isKeyboardNavigation: boolean): void\n /** 关闭前钩子:autocomplete 需先把 light DOM 新增节点收敛进当前 portal content。 */\n onBeforeClose?(): void\n /** 关闭时清理激活状态(清 _activeIndex 与 active 属性)。 */\n onAfterClose?(): void\n}\n\nexport interface ComboboxOpenController {\n open(isKeyboardNavigation?: boolean): void\n close(): void\n}\n\nexport function createComboboxOpenController(hooks: ComboboxOpenHooks): ComboboxOpenController {\n function open(isKeyboardNavigation = false) {\n if (hooks.canOpen() === false || hooks.getIsOpen()) return\n hooks.setIsOpen(true)\n hooks.dispatchOpenChange()\n hooks.onOpen?.(isKeyboardNavigation)\n hooks.syncScrollLock(true)\n if (hooks.isPortal()) {\n requestAnimationFrame(() => {\n if (hooks.getIsOpen()) hooks.openOverlay(isKeyboardNavigation)\n })\n } else {\n hooks.openOverlay(isKeyboardNavigation)\n }\n }\n\n function close() {\n if (!hooks.getIsOpen()) return\n hooks.onBeforeClose?.()\n hooks.setIsOpen(false)\n hooks.dispatchOpenChange()\n hooks.onAfterClose?.()\n hooks.syncScrollLock(false)\n hooks.closeOverlay()\n }\n\n return { open, close }\n}\n\nexport interface OptionListenerHandlers {\n onClick: (event: Event) => void\n onPointerOver: (event: PointerEvent) => void\n onPointerDown: (event: PointerEvent) => void\n onUpdate: () => void\n}\n\nexport interface ComboboxTriggerSource {\n /** 持 ARIA 与委托监听的 trigger 包装 div(shadow 内)。 */\n getWrapper(): HTMLElement | null\n /** `slot[name=\"trigger\"]` 的首个 assigned 元素;无自定义 trigger 时为 null。 */\n getCustomTrigger(): HTMLElement | null\n /** 默认 trigger(shadow 内的 web-ui-input);未渲染时为 null。 */\n getFallbackTrigger(): HTMLElement | null\n}\n\nexport interface ComboboxTriggerHandlers {\n /** trigger 文本变化;value 取自 trigger host 的字符串 value 属性。 */\n onInput(value: string): void\n /** 点击 trigger 区域(含包装 div 自身留白)。 */\n onClick(): void\n onFocusIn(event: FocusEvent): void\n onFocusOut(event: FocusEvent): void\n}\n\nexport interface ComboboxTriggerApi {\n /** 当前生效的 trigger host:自定义 trigger slot 首个 assigned 元素,否则默认 trigger。 */\n getTrigger(): HTMLElement | null\n /** trigger 当前文本;trigger 不暴露字符串 value 时返回 null。 */\n getValue(): string | null\n /** 回写文本;仅在文本 trigger 且值确实变化时写入,避免无谓重渲染。 */\n setValue(value: string): void\n /** 多行可编辑元素判定:Enter 保留换行语义,不接管为「选中高亮项」。 */\n isMultilineEdit(event: KeyboardEvent): boolean\n /**\n * 程序化聚焦当前 trigger。组件自带 focus 重定向(web-ui-input/textarea)时落到\n * 内部原生控件;自定义 trigger 没有该能力时,原生 focus() 聚焦 host 自身。\n */\n focusTrigger(options?: FocusOptions): void\n /** 失焦当前 trigger,兜底语义同 focusTrigger。 */\n blurTrigger(): void\n /** 绑定包装 div 上的委托监听(幂等:包装 div 不变则跳过)。 */\n bind(): void\n dispose(): void\n}\n\n/**\n * combobox trigger 委托层(autocomplete)。\n *\n * 默认 trigger 是 shadow 内的 `web-ui-input`,自定义 trigger 由 `slot[name=\"trigger\"]`\n * 提供(`web-ui-textarea` 等)。两者对组件暴露同一形状:字符串 value、可聚焦、\n * 派发 composed 的 input / click / focusin / focusout。委托层把这些差异收敛成一份接口:\n * 监听统一挂在包装 div 上(事件委托,覆盖 shadow 内与 light DOM 两条路径),\n * 文本读写只认 trigger host 的 value 属性。\n *\n * 焦点用 focusin/focusout 而非 focus/blur:后两者不冒泡,到不了包装 div\n * (实测:shadow 内默认 trigger 与 light DOM 自定义 trigger 都不触发包装 div 的 focus/blur)。\n *\n * trigger 自身派发的 change(web-ui-input/textarea 在原生 change 不 composed 时补发)\n * 必须被拦下:组件的 change 只表示「选中提交」,否则失焦即误报一次 change。\n */\nexport const defineComboboxTrigger = () =>\n definePlugin<ComboboxTriggerApi, ComboboxTriggerSource & ComboboxTriggerHandlers>(source => {\n let boundWrapper: HTMLElement | null = null\n\n const getTrigger = (): HTMLElement | null => source.getCustomTrigger() ?? source.getFallbackTrigger() ?? null\n\n const readValue = (trigger: HTMLElement | null): string | null => {\n const value = (trigger as (HTMLElement & { value?: unknown }) | null)?.value\n return typeof value === 'string' ? value : null\n }\n\n // composed 事件在包装 div 上看到的是 trigger host(或其在 light DOM 内的后代);\n // 只有来自 trigger 内部的输入才计入,包装 div 上的其它内容不干扰。\n const isWithinTrigger = (event: Event, trigger: HTMLElement | null): boolean => {\n const target = event.target\n return !!trigger && target instanceof Node && (target === trigger || trigger.contains(target))\n }\n\n const onInput = (event: Event) => {\n const trigger = getTrigger()\n if (!isWithinTrigger(event, trigger)) return\n const value = readValue(trigger)\n if (value !== null) source.onInput(value)\n }\n\n const onNativeChange = (event: Event) => {\n if (isWithinTrigger(event, getTrigger())) event.stopPropagation()\n }\n\n const onClick = () => source.onClick()\n const onFocusIn = (event: FocusEvent) => source.onFocusIn(event)\n const onFocusOut = (event: FocusEvent) => source.onFocusOut(event)\n\n return {\n getTrigger,\n getValue: () => readValue(getTrigger()),\n setValue(value: string) {\n const trigger = getTrigger()\n const current = readValue(trigger)\n if (!trigger || current === null || current === value) return\n ;(trigger as HTMLElement & { value: string }).value = value\n },\n isMultilineEdit(event: KeyboardEvent) {\n // composedPath()[0] 是未 retarget 的真实可编辑元素:textarea 保留 Enter 换行\n return event.composedPath()[0] instanceof HTMLTextAreaElement\n },\n focusTrigger(options?: FocusOptions) {\n // web-ui-input/textarea 的公共 focus() 已重定向到内部原生控件;普通元素或\n // 未实现 focus 的自定义 trigger 走原生 HTMLElement.focus(),即聚焦 host 自身\n getTrigger()?.focus(options)\n },\n blurTrigger() {\n getTrigger()?.blur()\n },\n bind() {\n const wrapper = source.getWrapper()\n if (!wrapper || wrapper === boundWrapper) return\n boundWrapper = wrapper\n wrapper.addEventListener('input', onInput)\n wrapper.addEventListener('change', onNativeChange)\n wrapper.addEventListener('click', onClick)\n wrapper.addEventListener('focusin', onFocusIn)\n wrapper.addEventListener('focusout', onFocusOut)\n },\n dispose() {\n const wrapper = boundWrapper\n boundWrapper = null\n if (!wrapper) return\n wrapper.removeEventListener('input', onInput)\n wrapper.removeEventListener('change', onNativeChange)\n wrapper.removeEventListener('click', onClick)\n wrapper.removeEventListener('focusin', onFocusIn)\n wrapper.removeEventListener('focusout', onFocusOut)\n }\n }\n })\n\n/** option 四事件监听对的绑定/解绑;两组件的 handler 签名一致,仅实现不同。 */\nexport function createOptionListenerBinding(handlers: OptionListenerHandlers) {\n function bind(option: WebUiOption) {\n option.addEventListener('click', handlers.onClick)\n option.addEventListener('pointerover', handlers.onPointerOver)\n option.addEventListener('pointerdown', handlers.onPointerDown)\n option.addEventListener('option-update', handlers.onUpdate)\n }\n function unbind(option: WebUiOption) {\n option.removeEventListener('click', handlers.onClick)\n option.removeEventListener('pointerover', handlers.onPointerOver)\n option.removeEventListener('pointerdown', handlers.onPointerDown)\n option.removeEventListener('option-update', handlers.onUpdate)\n }\n return { bind, unbind }\n}\n\n/**\n * focusout 关闭复核:焦点是否真的离开了宿主与 panel。\n * rAF 等待 click/pointerdown 相关的焦点迁移完成后再判定。\n */\nexport function handleComboboxFocusOut(\n host: HTMLElement,\n getHandle: () => OpenOverlayHandle | null | undefined,\n getIsOpen: () => boolean,\n close: () => void\n): void {\n requestAnimationFrame(() => {\n const handle = getHandle()\n if (getIsOpen() && !host.matches(':focus-within') && !handle?.hasFocusWithin()) {\n close()\n }\n })\n}\n\n/**\n * 环绕游标:在 enabled 列表内按 delta 移动;从 -1(无激活)向下取首项、向上取末项。\n * 返回在 enabled 中的下一索引;调用方负责换算回全量 options 索引。\n */\nexport function nextWrappingIndex(enabledCount: number, currentIdx: number, delta: number): number {\n if (enabledCount === 0) return -1\n let nextIdx = currentIdx + delta\n if (nextIdx < 0) nextIdx = enabledCount - 1\n if (nextIdx >= enabledCount) nextIdx = 0\n return nextIdx\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/option-portal/index.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { WebUiOption } from '@/components/option'\nimport type { OverlayPortal } from '@/shared/overlay/portal'\n\n/**\n * select / autocomplete 共用的 portal option 域:宿主侧 option 注册表、\n * portal 内容同步(打开期迁移 + 真删除清理)与微任务调度。\n *\n * 交互语义与 Lit 响应式由各组件保留(同 anchored-panel 的边界约定):\n * 组件通过 host 回调注入差异点(迁移节点来源、打开期即时同步等),\n * 并持有 _options/_activeIndex 的 @state,shared 只经返回值同步数据。\n */\n\nexport interface OptionPortalHost {\n /** 承载宿主级监听的元素(select / autocomplete 宿主自身)。 */\n readonly element: HTMLElement\n /** option id 前缀;宿主应保证跨实例唯一(如含实例序号),供 aria 引用与样式定位。 */\n readonly idPrefix: string\n getPortal(): OverlayPortal | undefined\n getPortalContent(): HTMLElement | undefined\n /** 打开判定:syncPortalContent 仅在打开期执行内容迁移。 */\n isOpen(): boolean\n /**\n * 打开期待迁移的节点来源。两组件粒度不同(select 迁裸 option,\n * autocomplete 迁全部 childNodes),保持各自现状不在此统一。\n */\n getMigratableNodes(): Node[]\n /** 微任务回调中判断宿主是否已完成首次渲染(Lit hasUpdated)。 */\n hasUpdated(): boolean\n /** 微任务末的宿主刷新(requestUpdate)。 */\n requestUpdate(): void\n /**\n * 打开期即时同步钩子。select 在微任务内立即刷新标签与激活态\n * (portal 打开时 willUpdate 查不到面板外的新增项),autocomplete 无此需要。\n */\n onImmediateSync?(): void\n /**\n * 组件侧的逐元素交互监听(click/pointerover/pointerdown/option-update)。\n * shared 负责随 refresh 配对调用,语义由组件定义。\n */\n bindOption(option: WebUiOption): void\n unbindOption(option: WebUiOption): void\n}\n\nexport interface OptionPortalApi {\n /** 面板优先查询,面板无 option 或关闭时回退 light DOM。 */\n queryOptions(): WebUiOption[]\n /**\n * diff 出旧集合解绑、新集合绑定;按 option 身份保持激活索引。\n * 返回新注册表数组与新的激活索引(-1 表示激活项已失效,如移除/迁移)。\n */\n refresh(\n previous: readonly WebUiOption[],\n activeOption: WebUiOption | undefined\n ): {\n options: WebUiOption[]\n activeIndex: number\n }\n /** 稳定 id 分配(WeakMap 身份 + 冲突循环),跨 remove/reorder 保持不变。 */\n ensureOptionIds(options: readonly WebUiOption[]): void\n /** pending 真删除清理 + 打开期把滞留 light DOM 的节点迁入面板。 */\n syncPortalContent(): void\n /** register/unregister/contentChange 的统一微任务收敛入口(slot 监听由组件模板持有)。 */\n scheduleRefresh(): void\n /** 宿主级与逐元素 unregister 监听共用的处理器。 */\n handleOptionUnregister(event: Event): void\n /** 宿主级监听配对(connectedCallback 调用,与 dispose 配对)。 */\n bindHost(): void\n /** 断连清理:解绑宿主监听并清空 pending 集合。 */\n dispose(): void\n}\n\nconst OPTION_TAG = 'web-ui-option'\n\nexport const defineOptionPortal = () =>\n definePlugin<OptionPortalApi, OptionPortalHost>(host => {\n const pendingUnregistered = new Set<WebUiOption>()\n // 稳定 id:WeakMap 按 option 身份记忆分配结果,remove/reorder 后 id 不变;\n // aria-activedescendant / 外部样式都依赖 id 的时序稳定性。\n const optionIds = new WeakMap<WebUiOption, string>()\n let nextOptionId = 0\n\n const bindOption = (option: WebUiOption) => {\n host.bindOption(option)\n option.addEventListener('option-unregister', handleOptionUnregister)\n }\n\n const unbindOption = (option: WebUiOption) => {\n host.unbindOption(option)\n option.removeEventListener('option-unregister', handleOptionUnregister)\n }\n\n const handleOptionUnregister = (event: Event) => {\n // Portal 迁移与真实删除都会触发 unregister;先记录候选,\n // 微任务末 syncPortalContent 按最终位置区分二者。\n if (event.target instanceof HTMLElement && event.target.localName === OPTION_TAG) {\n pendingUnregistered.add(event.target as WebUiOption)\n }\n api.scheduleRefresh()\n }\n\n const handleRegister = () => {\n api.scheduleRefresh()\n }\n\n const api: OptionPortalApi = {\n queryOptions() {\n const panel = host.getPortal()?.panel ?? host.getPortalContent()\n const panelOptions = panel ? [...panel.querySelectorAll<WebUiOption>(OPTION_TAG)] : []\n // 面板内容仅在浮层打开、moveContent 之后存在;关闭时回退 light DOM,\n // 否则 portal 组件首次加载时注册表为空。\n return panelOptions.length > 0 ? panelOptions : [...host.element.querySelectorAll<WebUiOption>(OPTION_TAG)]\n },\n\n refresh(previous, activeOption) {\n const nextOptions = api.queryOptions()\n\n // register/unregister 是 composed 事件:Portal 迁移时 target 会 retarget 成宿主,\n // 不能作为监听器绑定依据;绑定只发生在按 DOM 查询确认身份的 option 上。\n for (const option of previous) if (!nextOptions.includes(option)) unbindOption(option)\n nextOptions.forEach(bindOption)\n\n // 激活索引按 option 身份保持,避免移除中间项后静默偏移到相邻项\n return {\n options: nextOptions,\n activeIndex: activeOption && nextOptions.includes(activeOption) ? nextOptions.indexOf(activeOption) : -1\n }\n },\n\n ensureOptionIds(registryOptions) {\n const usedIds = new Set(registryOptions.map(option => option.id).filter((id): id is string => Boolean(id)))\n registryOptions.forEach(option => {\n if (option.id) return\n\n let id = optionIds.get(option)\n if (!id || usedIds.has(id)) {\n do {\n id = `${host.idPrefix}-option-${++nextOptionId}`\n } while (usedIds.has(id))\n optionIds.set(option, id)\n }\n option.id = id\n usedIds.add(id)\n })\n },\n\n syncPortalContent() {\n const portal = host.getPortal()\n const portalContent = host.getPortalContent()\n if (!portal || !portalContent) return\n\n // 真删除(既不在面板也不在 light DOM)的 option 从 portal 追踪列表摘除,\n // 否则关闭时 restoreContent 会把它复活回 light DOM。\n for (const option of pendingUnregistered) {\n if (!portalContent.contains(option) && !host.element.contains(option)) portal.removeContent([option])\n }\n pendingUnregistered.clear()\n\n if (!host.isOpen()) return\n\n // Portal 打开期间框架条件渲染(v-if)可能在 light DOM 插入新节点;\n // 面板内已有内容时 queryOptions 不会回退 light DOM,必须显式迁入面板。\n // 注释(框架锚点与 portal 的 marker)不迁移:锚点留守宿主是实时渲染契约。\n const nodes = host.getMigratableNodes().filter(node => !(node instanceof Comment))\n if (nodes.length) portal.appendContent(nodes, portalContent)\n },\n\n scheduleRefresh() {\n queueMicrotask(() => {\n if (!host.element.isConnected) return\n api.syncPortalContent()\n host.requestUpdate()\n // 关闭状态下 willUpdate 查不到 portal 面板内容;打开期的新增/删除\n // 由宿主在微任务内立即同步标签与激活态。\n if (host.isOpen() && host.hasUpdated()) host.onImmediateSync?.()\n })\n },\n\n handleOptionUnregister,\n\n bindHost() {\n host.element.addEventListener('option-register', handleRegister)\n host.element.addEventListener('option-unregister', handleOptionUnregister)\n },\n\n dispose() {\n host.element.removeEventListener('option-register', handleRegister)\n host.element.removeEventListener('option-unregister', handleOptionUnregister)\n pendingUnregistered.clear()\n }\n }\n\n return api\n })\nexport * from './combobox-shell'\n"],"mappings":";;;AAyEA,IAAM,IAAa,iBAEN,UACX,GAAgD,MAAQ;CACtD,IAAM,oBAAsB,IAAI,IAAiB,GAG3C,oBAAY,IAAI,QAA6B,GAC/C,IAAe,GAEb,KAAc,MAAwB;EAE1C,AADA,EAAK,WAAW,CAAM,GACtB,EAAO,iBAAiB,qBAAqB,CAAsB;CACrE,GAEM,KAAgB,MAAwB;EAE5C,AADA,EAAK,aAAa,CAAM,GACxB,EAAO,oBAAoB,qBAAqB,CAAsB;CACxE,GAEM,KAA0B,MAAiB;EAM/C,AAHI,EAAM,kBAAkB,eAAe,EAAM,OAAO,cAAc,KACpE,EAAoB,IAAI,EAAM,MAAqB,GAErD,EAAI,gBAAgB;CACtB,GAEM,UAAuB;EAC3B,EAAI,gBAAgB;CACtB,GAEM,IAAuB;EAC3B,eAAe;GACb,IAAM,IAAQ,EAAK,UAAU,CAAC,EAAE,SAAS,EAAK,iBAAiB,GACzD,IAAe,IAAQ,CAAC,GAAG,EAAM,iBAA8B,CAAU,CAAC,IAAI,CAAC;GAGrF,OAAO,EAAa,SAAS,IAAI,IAAe,CAAC,GAAG,EAAK,QAAQ,iBAA8B,CAAU,CAAC;EAC5G;EAEA,QAAQ,GAAU,GAAc;GAC9B,IAAM,IAAc,EAAI,aAAa;GAIrC,KAAK,IAAM,KAAU,GAAU,AAAK,EAAY,SAAS,CAAM,KAAG,EAAa,CAAM;GAIrF,OAHA,EAAY,QAAQ,CAAU,GAGvB;IACL,SAAS;IACT,aAAa,KAAgB,EAAY,SAAS,CAAY,IAAI,EAAY,QAAQ,CAAY,IAAI;GACxG;EACF;EAEA,gBAAgB,GAAiB;GAC/B,IAAM,IAAU,IAAI,IAAI,EAAgB,KAAI,MAAU,EAAO,EAAE,CAAC,CAAC,QAAQ,MAAqB,EAAQ,CAAG,CAAC;GAC1G,EAAgB,SAAQ,MAAU;IAChC,IAAI,EAAO,IAAI;IAEf,IAAI,IAAK,EAAU,IAAI,CAAM;IAC7B,IAAI,CAAC,KAAM,EAAQ,IAAI,CAAE,GAAG;KAC1B;MACE,IAAK,GAAG,EAAK,SAAS,UAAU,EAAE;YAC3B,EAAQ,IAAI,CAAE;KACvB,EAAU,IAAI,GAAQ,CAAE;IAC1B;IAEA,AADA,EAAO,KAAK,GACZ,EAAQ,IAAI,CAAE;GAChB,CAAC;EACH;EAEA,oBAAoB;GAClB,IAAM,IAAS,EAAK,UAAU,GACxB,IAAgB,EAAK,iBAAiB;GAC5C,IAAI,CAAC,KAAU,CAAC,GAAe;GAI/B,KAAK,IAAM,KAAU,GACnB,AAAI,CAAC,EAAc,SAAS,CAAM,KAAK,CAAC,EAAK,QAAQ,SAAS,CAAM,KAAG,EAAO,cAAc,CAAC,CAAM,CAAC;GAItG,IAFA,EAAoB,MAAM,GAEtB,CAAC,EAAK,OAAO,GAAG;GAKpB,IAAM,IAAQ,EAAK,mBAAmB,CAAC,CAAC,QAAO,MAAQ,EAAE,aAAgB,QAAQ;GACjF,AAAI,EAAM,UAAQ,EAAO,cAAc,GAAO,CAAa;EAC7D;EAEA,kBAAkB;GAChB,qBAAqB;IACd,EAAK,QAAQ,gBAClB,EAAI,kBAAkB,GACtB,EAAK,cAAc,GAGf,EAAK,OAAO,KAAK,EAAK,WAAW,KAAG,EAAK,kBAAkB;GACjE,CAAC;EACH;EAEA;EAEA,WAAW;GAET,AADA,EAAK,QAAQ,iBAAiB,mBAAmB,CAAc,GAC/D,EAAK,QAAQ,iBAAiB,qBAAqB,CAAsB;EAC3E;EAEA,UAAU;GAGR,AAFA,EAAK,QAAQ,oBAAoB,mBAAmB,CAAc,GAClE,EAAK,QAAQ,oBAAoB,qBAAqB,CAAsB,GAC5E,EAAoB,MAAM;EAC5B;CACF;CAEA,OAAO;AACT,CAAC"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/shared/option-portal/index.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { WebUiOption } from '@/components/option'\nimport type { OverlayPortal } from '@/shared/overlay/portal'\n\n/**\n * select / autocomplete 共用的 portal option 域:宿主侧 option 注册表、\n * portal 内容同步(打开期迁移 + 真删除清理)与微任务调度。\n *\n * 交互语义与 Lit 响应式由各组件保留(同 anchored-panel 的边界约定):\n * 组件通过 host 回调注入差异点(迁移节点来源、打开期即时同步等),\n * 并持有 _options/_activeIndex 的 @state,shared 只经返回值同步数据。\n */\n\nexport interface OptionPortalHost {\n /** 承载宿主级监听的元素(select / autocomplete 宿主自身)。 */\n readonly element: HTMLElement\n /** option id 前缀;宿主应保证跨实例唯一(如含实例序号),供 aria 引用与样式定位。 */\n readonly idPrefix: string\n getPortal(): OverlayPortal | undefined\n getPortalContent(): HTMLElement | undefined\n /** 打开判定:syncPortalContent 仅在打开期执行内容迁移。 */\n isOpen(): boolean\n /**\n * 打开期待迁移的节点来源。两组件粒度不同(select 迁裸 option,\n * autocomplete 迁全部 childNodes),保持各自现状不在此统一。\n */\n getMigratableNodes(): Node[]\n /** 微任务回调中判断宿主是否已完成首次渲染(Lit hasUpdated)。 */\n hasUpdated(): boolean\n /** 微任务末的宿主刷新(requestUpdate)。 */\n requestUpdate(): void\n /**\n * 打开期即时同步钩子。select 在微任务内立即刷新 label 与激活态\n * (portal 打开时 willUpdate 查不到 panel 外的新增项),autocomplete 无此需要。\n */\n onImmediateSync?(): void\n /**\n * 组件侧的逐元素交互监听(click/pointerover/pointerdown/option-update)。\n * shared 负责随 refresh 配对调用,语义由组件定义。\n */\n bindOption(option: WebUiOption): void\n unbindOption(option: WebUiOption): void\n}\n\nexport interface OptionPortalApi {\n /** panel 优先查询,panel 无 option 或关闭时回退 light DOM。 */\n queryOptions(): WebUiOption[]\n /**\n * diff 出旧集合解绑、新集合绑定;按 option 身份保持激活索引。\n * 返回新注册表数组与新的激活索引(-1 表示激活项已失效,如移除/迁移)。\n */\n refresh(\n previous: readonly WebUiOption[],\n activeOption: WebUiOption | undefined\n ): {\n options: WebUiOption[]\n activeIndex: number\n }\n /** 稳定 id 分配(WeakMap 身份 + 冲突循环),跨 remove/reorder 保持不变。 */\n ensureOptionIds(options: readonly WebUiOption[]): void\n /** pending 真删除清理 + 打开期把滞留 light DOM 的节点迁入 panel。 */\n syncPortalContent(): void\n /** register/unregister/contentChange 的统一微任务收敛入口(slot 监听由组件模板持有)。 */\n scheduleRefresh(): void\n /** 宿主级与逐元素 unregister 监听共用的处理器。 */\n handleOptionUnregister(event: Event): void\n /** 宿主级监听配对(connectedCallback 调用,与 dispose 配对)。 */\n bindHost(): void\n /** 断连清理:解绑宿主监听并清空 pending 集合。 */\n dispose(): void\n}\n\nconst OPTION_TAG = 'web-ui-option'\n\nexport const defineOptionPortal = () =>\n definePlugin<OptionPortalApi, OptionPortalHost>(host => {\n const pendingUnregistered = new Set<WebUiOption>()\n // 稳定 id:WeakMap 按 option 身份记忆分配结果,remove/reorder 后 id 不变;\n // aria-activedescendant / 外部样式都依赖 id 的时序稳定性。\n const optionIds = new WeakMap<WebUiOption, string>()\n let nextOptionId = 0\n\n const bindOption = (option: WebUiOption) => {\n host.bindOption(option)\n option.addEventListener('option-unregister', handleOptionUnregister)\n }\n\n const unbindOption = (option: WebUiOption) => {\n host.unbindOption(option)\n option.removeEventListener('option-unregister', handleOptionUnregister)\n }\n\n const handleOptionUnregister = (event: Event) => {\n // Portal 迁移与真实删除都会触发 unregister;先记录候选,\n // 微任务末 syncPortalContent 按最终位置区分二者。\n if (event.target instanceof HTMLElement && event.target.localName === OPTION_TAG) {\n pendingUnregistered.add(event.target as WebUiOption)\n }\n api.scheduleRefresh()\n }\n\n const handleRegister = () => {\n api.scheduleRefresh()\n }\n\n const api: OptionPortalApi = {\n queryOptions() {\n const panel = host.getPortal()?.panel ?? host.getPortalContent()\n const panelOptions = panel ? [...panel.querySelectorAll<WebUiOption>(OPTION_TAG)] : []\n // panel 内容仅在 overlay 打开、moveContent 之后存在;关闭时回退 light DOM,\n // 否则 portal 组件首次加载时注册表为空。\n return panelOptions.length > 0 ? panelOptions : [...host.element.querySelectorAll<WebUiOption>(OPTION_TAG)]\n },\n\n refresh(previous, activeOption) {\n const nextOptions = api.queryOptions()\n\n // register/unregister 是 composed 事件:Portal 迁移时 target 会 retarget 成宿主,\n // 不能作为监听器绑定依据;绑定只发生在按 DOM 查询确认身份的 option 上。\n for (const option of previous) if (!nextOptions.includes(option)) unbindOption(option)\n nextOptions.forEach(bindOption)\n\n // 激活索引按 option 身份保持,避免移除中间项后静默偏移到相邻项\n return {\n options: nextOptions,\n activeIndex: activeOption && nextOptions.includes(activeOption) ? nextOptions.indexOf(activeOption) : -1\n }\n },\n\n ensureOptionIds(registryOptions) {\n const usedIds = new Set(registryOptions.map(option => option.id).filter((id): id is string => Boolean(id)))\n registryOptions.forEach(option => {\n if (option.id) return\n\n let id = optionIds.get(option)\n if (!id || usedIds.has(id)) {\n do {\n id = `${host.idPrefix}-option-${++nextOptionId}`\n } while (usedIds.has(id))\n optionIds.set(option, id)\n }\n option.id = id\n usedIds.add(id)\n })\n },\n\n syncPortalContent() {\n const portal = host.getPortal()\n const portalContent = host.getPortalContent()\n if (!portal || !portalContent) return\n\n // 真删除(既不在 panel 也不在 light DOM)的 option 从 portal 追踪列表摘除,\n // 否则关闭时 restoreContent 会把它复活回 light DOM。\n for (const option of pendingUnregistered) {\n if (!portalContent.contains(option) && !host.element.contains(option)) portal.removeContent([option])\n }\n pendingUnregistered.clear()\n\n if (!host.isOpen()) return\n\n // Portal 打开期间框架条件渲染(v-if)可能在 light DOM 插入新节点;\n // panel 内已有内容时 queryOptions 不会回退 light DOM,必须显式迁入 panel。\n // 注释(框架锚点与 portal 的 marker)不迁移:锚点留守宿主是实时渲染契约。\n const nodes = host.getMigratableNodes().filter(node => !(node instanceof Comment))\n if (nodes.length) portal.appendContent(nodes, portalContent)\n },\n\n scheduleRefresh() {\n queueMicrotask(() => {\n if (!host.element.isConnected) return\n api.syncPortalContent()\n host.requestUpdate()\n // 关闭状态下 willUpdate 查不到 portal panel 内容;打开期的新增/删除\n // 由宿主在微任务内立即同步 label 与激活态。\n if (host.isOpen() && host.hasUpdated()) host.onImmediateSync?.()\n })\n },\n\n handleOptionUnregister,\n\n bindHost() {\n host.element.addEventListener('option-register', handleRegister)\n host.element.addEventListener('option-unregister', handleOptionUnregister)\n },\n\n dispose() {\n host.element.removeEventListener('option-register', handleRegister)\n host.element.removeEventListener('option-unregister', handleOptionUnregister)\n pendingUnregistered.clear()\n }\n }\n\n return api\n })\nexport * from './combobox-shell'\n"],"mappings":";;;AAyEA,IAAM,IAAa,iBAEN,UACX,GAAgD,MAAQ;CACtD,IAAM,oBAAsB,IAAI,IAAiB,GAG3C,oBAAY,IAAI,QAA6B,GAC/C,IAAe,GAEb,KAAc,MAAwB;EAE1C,AADA,EAAK,WAAW,CAAM,GACtB,EAAO,iBAAiB,qBAAqB,CAAsB;CACrE,GAEM,KAAgB,MAAwB;EAE5C,AADA,EAAK,aAAa,CAAM,GACxB,EAAO,oBAAoB,qBAAqB,CAAsB;CACxE,GAEM,KAA0B,MAAiB;EAM/C,AAHI,EAAM,kBAAkB,eAAe,EAAM,OAAO,cAAc,KACpE,EAAoB,IAAI,EAAM,MAAqB,GAErD,EAAI,gBAAgB;CACtB,GAEM,UAAuB;EAC3B,EAAI,gBAAgB;CACtB,GAEM,IAAuB;EAC3B,eAAe;GACb,IAAM,IAAQ,EAAK,UAAU,CAAC,EAAE,SAAS,EAAK,iBAAiB,GACzD,IAAe,IAAQ,CAAC,GAAG,EAAM,iBAA8B,CAAU,CAAC,IAAI,CAAC;GAGrF,OAAO,EAAa,SAAS,IAAI,IAAe,CAAC,GAAG,EAAK,QAAQ,iBAA8B,CAAU,CAAC;EAC5G;EAEA,QAAQ,GAAU,GAAc;GAC9B,IAAM,IAAc,EAAI,aAAa;GAIrC,KAAK,IAAM,KAAU,GAAU,AAAK,EAAY,SAAS,CAAM,KAAG,EAAa,CAAM;GAIrF,OAHA,EAAY,QAAQ,CAAU,GAGvB;IACL,SAAS;IACT,aAAa,KAAgB,EAAY,SAAS,CAAY,IAAI,EAAY,QAAQ,CAAY,IAAI;GACxG;EACF;EAEA,gBAAgB,GAAiB;GAC/B,IAAM,IAAU,IAAI,IAAI,EAAgB,KAAI,MAAU,EAAO,EAAE,CAAC,CAAC,QAAQ,MAAqB,EAAQ,CAAG,CAAC;GAC1G,EAAgB,SAAQ,MAAU;IAChC,IAAI,EAAO,IAAI;IAEf,IAAI,IAAK,EAAU,IAAI,CAAM;IAC7B,IAAI,CAAC,KAAM,EAAQ,IAAI,CAAE,GAAG;KAC1B;MACE,IAAK,GAAG,EAAK,SAAS,UAAU,EAAE;YAC3B,EAAQ,IAAI,CAAE;KACvB,EAAU,IAAI,GAAQ,CAAE;IAC1B;IAEA,AADA,EAAO,KAAK,GACZ,EAAQ,IAAI,CAAE;GAChB,CAAC;EACH;EAEA,oBAAoB;GAClB,IAAM,IAAS,EAAK,UAAU,GACxB,IAAgB,EAAK,iBAAiB;GAC5C,IAAI,CAAC,KAAU,CAAC,GAAe;GAI/B,KAAK,IAAM,KAAU,GACnB,AAAI,CAAC,EAAc,SAAS,CAAM,KAAK,CAAC,EAAK,QAAQ,SAAS,CAAM,KAAG,EAAO,cAAc,CAAC,CAAM,CAAC;GAItG,IAFA,EAAoB,MAAM,GAEtB,CAAC,EAAK,OAAO,GAAG;GAKpB,IAAM,IAAQ,EAAK,mBAAmB,CAAC,CAAC,QAAO,MAAQ,EAAE,aAAgB,QAAQ;GACjF,AAAI,EAAM,UAAQ,EAAO,cAAc,GAAO,CAAa;EAC7D;EAEA,kBAAkB;GAChB,qBAAqB;IACd,EAAK,QAAQ,gBAClB,EAAI,kBAAkB,GACtB,EAAK,cAAc,GAGf,EAAK,OAAO,KAAK,EAAK,WAAW,KAAG,EAAK,kBAAkB;GACjE,CAAC;EACH;EAEA;EAEA,WAAW;GAET,AADA,EAAK,QAAQ,iBAAiB,mBAAmB,CAAc,GAC/D,EAAK,QAAQ,iBAAiB,qBAAqB,CAAsB;EAC3E;EAEA,UAAU;GAGR,AAFA,EAAK,QAAQ,oBAAoB,mBAAmB,CAAc,GAClE,EAAK,QAAQ,oBAAoB,qBAAqB,CAAsB,GAC5E,EAAoB,MAAM;EAC5B;CACF;CAEA,OAAO;AACT,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"anchored-panel.js","names":[],"sources":["../../../src/shared/overlay/anchored-panel.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { OpenOverlay, OpenOverlayHandle, OverlayArbitration } from './open-overlay'\nimport { defineOverlay, type OverlayApi, type OverlayOptions } from './overlay'\nimport type { OverlayPortal } from './portal'\nimport { hideOverlayPresence, showOverlayPresence } from './presence'\n\nexport interface AnchoredPanelOptions {\n getAnchor(): HTMLElement | null\n getLocalPanel(): HTMLElement | null\n getPositioning(): OverlayOptions\n isPortal(): boolean\n createPortal(): OverlayPortal\n /**\n * 承载「本面板是一次开启态浮层」的实例:登记与 Escape 归属由它完成,本模块不再\n * 自己维护逻辑父子关系。\n *\n * 必须是**调用方持有的那个实例**(而不是这里新建一个)——调用方同时要用它的帧\n * 事务入口,两个实例会让「我开着吗」重新分裂。\n */\n openOverlay: OpenOverlay\n /** 本面板与 Escape 仲裁的关系。tooltip 传 `none`;缺省 `escape`。 */\n arbitration?: OverlayArbitration\n}\n\n/**\n * 管理锚定面板的本地/Portal 容器、定位器和可中断 presence。\n * 内容迁移与交互语义由各组件保留,避免把不同组件的 public contract 混入此模块。\n */\nexport interface AnchoredPanelApi {\n getPanel(): HTMLElement | null\n /**\n * 当前在场会话的句柄;没有任何在场会话时为 `null`。\n * 查询(`contains` / `containsEvent` / `hasFocusWithin`)与 `setInert` 都走它,\n * 调用方因此不必持有 panel 引用,也不知道存在全局注册表。\n * 退场等待期间仍是同一个句柄(暂缓仲裁),动画播完或重新 `open()` 才换人。\n */\n getHandle(): OpenOverlayHandle | null\n open(isInstant?: boolean): void\n close(isStillOpen: () => boolean): Promise<boolean>\n updatePosition(): void\n reconfigure(isOpen: boolean): void\n dispose(): void\n}\n\nexport const defineAnchoredPanel = () =>\n definePlugin<AnchoredPanelApi, AnchoredPanelOptions>(ctx => {\n let overlay: OverlayApi | undefined\n let portal: OverlayPortal | undefined\n let handle: OpenOverlayHandle | null = null\n\n const getPanel = () => portal?.panel ?? ctx.getLocalPanel()\n\n const releaseHandle = () => {\n handle?.release()\n handle = null\n }\n\n const disposePortal = () => {\n if (!portal) return\n portal.restoreContent()\n portal.remove()\n portal = undefined\n overlay = undefined\n }\n\n const getOrCreatePanel = (): HTMLElement | null => {\n if (!ctx.isPortal()) return ctx.getLocalPanel()\n portal ??= ctx.createPortal()\n return portal.panel\n }\n\n const ensureOverlay = (anchor: HTMLElement, panel: HTMLElement) => {\n if (overlay) {\n overlay.updateAnchor(anchor)\n overlay.update(ctx.getPositioning())\n return\n }\n overlay = defineOverlay().make({ anchor, overlay: panel, ...ctx.getPositioning() })\n }\n\n const open = (isInstant = false) => {\n const anchor = ctx.getAnchor()\n if (!anchor) return\n const panel = getOrCreatePanel()\n if (!panel) return\n // portal panel 与宿主物理分离;本地 panel 也可能隔着 shadow 边界。\n // 以 anchor 祖先链寻找最近的已开启浮层,建立统一逻辑父子关系。\n // claim ⟺ open:重复 open 视为新的一次开启,旧会话整体作废。\n releaseHandle()\n handle = ctx.openOverlay.claim(panel, { arbitration: ctx.arbitration, ancestryFrom: anchor })\n ensureOverlay(anchor, panel)\n overlay?.open()\n showOverlayPresence(panel, { isInstant })\n }\n\n const dispose = () => {\n overlay?.dispose()\n overlay = undefined\n releaseHandle()\n disposePortal()\n }\n\n return {\n getPanel() {\n return getPanel()\n },\n\n getHandle() {\n return handle\n },\n\n open,\n\n async close(isStillOpen) {\n /*\n * 第三态「可见但暂缓仲裁」:退场等待期间面板仍在场,登记不撤,但不再走关闭\n * 入口——它已经在关的路上了。撤销推迟到动画结束,或被 `open()` 以新会话取代。\n *\n * 旧实现在这里立即 `releaseHandle()`,于是两条提前返回的路径都把还看得见的面板\n * 留在未登记状态:未登记即不参与「谁是最内层」的仲裁,直到下一次 `open()` 才\n * 恢复(issue #138)。\n *\n * 暂缓层只是兜底候选:仍在开启的外层永远优先。否则「内层关掉后立刻再按一次\n * Escape」会被退场动画吞掉,外层永远等不到自己那一次——issue #120 的契约。\n *\n * 退场被打断时 `showOverlayPresence` 已把面板带回场,`open()` 同时重新 claim\n * (与「重复 open 视为新的一次开启」同一条路径),本条 `close()` 随后的\n * `return false` 不碰那个新句柄。\n *\n * 走到 `isStillOpen()` 时退场已经播完:面板已 `hidden`。此刻宿主仍认为开着,\n * 是面板状态与宿主状态不一致的那一格——把层交还仲裁而不是继续暂缓。继续暂缓的\n * 前提是「面板可见」,而这里面板已经看不见了:一个隐藏的面板无限吞掉 Escape,\n * 用户再也没有按键路径把这个状态收敛掉(按一次 Escape 走宿主的关闭入口,宿主\n * 回写 open=false 后由随后的 close() 正常收尾)。登记仍然不撤,「宿主认为开着\n * 就不掉出仲裁」的 #138 语义不变。\n */\n handle?.deferArbitration()\n overlay?.close()\n const panel = getPanel()\n if (panel && !(await hideOverlayPresence(panel))) return false\n if (isStillOpen()) {\n handle?.deferArbitration(false)\n return false\n }\n releaseHandle()\n disposePortal()\n return true\n },\n\n updatePosition() {\n overlay?.update(ctx.getPositioning())\n },\n\n reconfigure(isOpen) {\n const shouldAnimate = getPanel()?.dataset.wuiPresence === 'entering'\n dispose()\n if (isOpen) open(!shouldAnimate)\n },\n\n dispose\n }\n })\n"],"mappings":";;;;AA6CA,IAAa,UACX,GAAqD,MAAO;CAC1D,IAAI,GACA,GACA,IAAmC,MAEjC,UAAiB,GAAQ,SAAS,EAAI,cAAc,GAEpD,UAAsB;EAE1B,AADA,GAAQ,QAAQ,GAChB,IAAS;CACX,GAEM,UAAsB;EACrB,MACL,EAAO,eAAe,GACtB,EAAO,OAAO,GACd,IAAS,KAAA,GACT,IAAU,KAAA;CACZ,GAEM,UACC,EAAI,SAAS,KAClB,MAAW,EAAI,aAAa,GACrB,EAAO,SAFc,EAAI,cAAc,GAK1C,KAAiB,GAAqB,MAAuB;EACjE,IAAI,GAAS;GAEX,AADA,EAAQ,aAAa,CAAM,GAC3B,EAAQ,OAAO,EAAI,eAAe,CAAC;GACnC;EACF;EACA,IAAU,EAAc,CAAC,CAAC,KAAK;GAAE;GAAQ,SAAS;GAAO,GAAG,EAAI,eAAe;EAAE,CAAC;CACpF,GAEM,KAAQ,IAAY,OAAU;EAClC,IAAM,IAAS,EAAI,UAAU;EAC7B,IAAI,CAAC,GAAQ;EACb,IAAM,IAAQ,EAAiB;EAC1B,MAIL,EAAc,GACd,IAAS,EAAI,YAAY,MAAM,GAAO;GAAE,aAAa,EAAI;GAAa,cAAc;EAAO,CAAC,GAC5F,EAAc,GAAQ,CAAK,GAC3B,GAAS,KAAK,GACd,EAAoB,GAAO,EAAE,aAAU,CAAC;CAC1C,GAEM,UAAgB;EAIpB,AAHA,GAAS,QAAQ,GACjB,IAAU,KAAA,GACV,EAAc,GACd,EAAc;CAChB;CAEA,OAAO;EACL,WAAW;GACT,OAAO,EAAS;EAClB;EAEA,YAAY;GACV,OAAO;EACT;EAEA;EAEA,MAAM,MAAM,GAAa;GAwBvB,AADA,GAAQ,iBAAiB,GACzB,GAAS,MAAM;GACf,IAAM,IAAQ,EAAS;GAQvB,OAPI,KAAS,CAAE,MAAM,EAAoB,CAAK,IAAW,KACrD,EAAY,KACd,GAAQ,iBAAiB,EAAK,GACvB,OAET,EAAc,GACd,EAAc,GACP;EACT;EAEA,iBAAiB;GACf,GAAS,OAAO,EAAI,eAAe,CAAC;EACtC;EAEA,YAAY,GAAQ;GAClB,IAAM,IAAgB,EAAS,CAAC,EAAE,QAAQ,gBAAgB;GAE1D,AADA,EAAQ,GACJ,KAAQ,EAAK,CAAC,CAAa;EACjC;EAEA;CACF;AACF,CAAC"}
1
+ {"version":3,"file":"anchored-panel.js","names":[],"sources":["../../../src/shared/overlay/anchored-panel.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport type { OpenOverlay, OpenOverlayHandle, OverlayArbitration } from './open-overlay'\nimport { defineOverlay, type OverlayApi, type OverlayOptions } from './overlay'\nimport type { OverlayPortal } from './portal'\nimport { hideOverlayPresence, showOverlayPresence } from './presence'\n\nexport interface AnchoredPanelOptions {\n getAnchor(): HTMLElement | null\n getLocalPanel(): HTMLElement | null\n getPositioning(): OverlayOptions\n isPortal(): boolean\n createPortal(): OverlayPortal\n /**\n * 承载「本 panel 是一次开启态 overlay」的实例:登记与 Escape 归属由它完成,本模块不再\n * 自己维护逻辑父子关系。\n *\n * 必须是**调用方持有的那个实例**(而不是这里新建一个)——调用方同时要用它的帧\n * 事务入口,两个实例会让「我开着吗」重新分裂。\n */\n openOverlay: OpenOverlay\n /** 本 panel 与 Escape 仲裁的关系。tooltip 传 `none`;缺省 `escape`。 */\n arbitration?: OverlayArbitration\n}\n\n/**\n * 管理锚定 panel 的本地/Portal 容器、定位器和可中断 presence。\n * 内容迁移与交互语义由各组件保留,避免把不同组件的 public contract 混入此模块。\n */\nexport interface AnchoredPanelApi {\n getPanel(): HTMLElement | null\n /**\n * 当前在场会话的 handle;没有任何在场会话时为 `null`。\n * 查询(`contains` / `containsEvent` / `hasFocusWithin`)与 `setInert` 都走它,\n * 调用方因此不必持有 panel 引用,也不知道存在全局注册表。\n * 退场等待期间仍是同一个 handle(暂缓仲裁),动画播完或重新 `open()` 才换人。\n */\n getHandle(): OpenOverlayHandle | null\n open(isInstant?: boolean): void\n close(isStillOpen: () => boolean): Promise<boolean>\n updatePosition(): void\n reconfigure(isOpen: boolean): void\n dispose(): void\n}\n\nexport const defineAnchoredPanel = () =>\n definePlugin<AnchoredPanelApi, AnchoredPanelOptions>(ctx => {\n let overlay: OverlayApi | undefined\n let portal: OverlayPortal | undefined\n let handle: OpenOverlayHandle | null = null\n\n const getPanel = () => portal?.panel ?? ctx.getLocalPanel()\n\n const releaseHandle = () => {\n handle?.release()\n handle = null\n }\n\n const disposePortal = () => {\n if (!portal) return\n portal.restoreContent()\n portal.remove()\n portal = undefined\n overlay = undefined\n }\n\n const getOrCreatePanel = (): HTMLElement | null => {\n if (!ctx.isPortal()) return ctx.getLocalPanel()\n portal ??= ctx.createPortal()\n return portal.panel\n }\n\n const ensureOverlay = (anchor: HTMLElement, panel: HTMLElement) => {\n if (overlay) {\n overlay.updateAnchor(anchor)\n overlay.update(ctx.getPositioning())\n return\n }\n overlay = defineOverlay().make({ anchor, overlay: panel, ...ctx.getPositioning() })\n }\n\n const open = (isInstant = false) => {\n const anchor = ctx.getAnchor()\n if (!anchor) return\n const panel = getOrCreatePanel()\n if (!panel) return\n // portal panel 与宿主物理分离;本地 panel 也可能隔着 shadow 边界。\n // 以 anchor 祖先链寻找最近的已开启 overlay,建立统一逻辑父子关系。\n // claim ⟺ open:重复 open 视为新的一次开启,旧会话整体作废。\n releaseHandle()\n handle = ctx.openOverlay.claim(panel, { arbitration: ctx.arbitration, ancestryFrom: anchor })\n ensureOverlay(anchor, panel)\n overlay?.open()\n showOverlayPresence(panel, { isInstant })\n }\n\n const dispose = () => {\n overlay?.dispose()\n overlay = undefined\n releaseHandle()\n disposePortal()\n }\n\n return {\n getPanel() {\n return getPanel()\n },\n\n getHandle() {\n return handle\n },\n\n open,\n\n async close(isStillOpen) {\n /*\n * 第三态「可见但暂缓仲裁」:退场等待期间 panel 仍在场,登记不撤,但不再走关闭\n * 入口——它已经在关的路上了。撤销推迟到动画结束,或被 `open()` 以新会话取代。\n *\n * 旧实现在这里立即 `releaseHandle()`,于是两条提前返回的路径都把还看得见的 panel\n * 留在未登记状态:未登记即不参与「谁是最内层」的仲裁,直到下一次 `open()` 才\n * 恢复(issue #138)。\n *\n * 暂缓层只是兜底候选:仍在开启的外层永远优先。否则「内层关掉后立刻再按一次\n * Escape」会被退场动画吞掉,外层永远等不到自己那一次——issue #120 的契约。\n *\n * 退场被打断时 `showOverlayPresence` 已把 panel 带回场,`open()` 同时重新 claim\n * (与「重复 open 视为新的一次开启」同一条路径),本条 `close()` 随后的\n * `return false` 不碰那个新 handle。\n *\n * 走到 `isStillOpen()` 时退场已经播完:panel 已 `hidden`。此刻宿主仍认为开着,\n * 是 panel 状态与宿主状态不一致的那一格——把层交还仲裁而不是继续暂缓。继续暂缓的\n * 前提是「panel 可见」,而这里 panel 已经看不见了:一个隐藏的 panel 无限吞掉 Escape,\n * 用户再也没有按键路径把这个状态收敛掉(按一次 Escape 走宿主的关闭入口,宿主\n * 回写 open=false 后由随后的 close() 正常收尾)。登记仍然不撤,「宿主认为开着\n * 就不掉出仲裁」的 #138 语义不变。\n */\n handle?.deferArbitration()\n overlay?.close()\n const panel = getPanel()\n if (panel && !(await hideOverlayPresence(panel))) return false\n if (isStillOpen()) {\n handle?.deferArbitration(false)\n return false\n }\n releaseHandle()\n disposePortal()\n return true\n },\n\n updatePosition() {\n overlay?.update(ctx.getPositioning())\n },\n\n reconfigure(isOpen) {\n const shouldAnimate = getPanel()?.dataset.wuiPresence === 'entering'\n dispose()\n if (isOpen) open(!shouldAnimate)\n },\n\n dispose\n }\n })\n"],"mappings":";;;;AA6CA,IAAa,UACX,GAAqD,MAAO;CAC1D,IAAI,GACA,GACA,IAAmC,MAEjC,UAAiB,GAAQ,SAAS,EAAI,cAAc,GAEpD,UAAsB;EAE1B,AADA,GAAQ,QAAQ,GAChB,IAAS;CACX,GAEM,UAAsB;EACrB,MACL,EAAO,eAAe,GACtB,EAAO,OAAO,GACd,IAAS,KAAA,GACT,IAAU,KAAA;CACZ,GAEM,UACC,EAAI,SAAS,KAClB,MAAW,EAAI,aAAa,GACrB,EAAO,SAFc,EAAI,cAAc,GAK1C,KAAiB,GAAqB,MAAuB;EACjE,IAAI,GAAS;GAEX,AADA,EAAQ,aAAa,CAAM,GAC3B,EAAQ,OAAO,EAAI,eAAe,CAAC;GACnC;EACF;EACA,IAAU,EAAc,CAAC,CAAC,KAAK;GAAE;GAAQ,SAAS;GAAO,GAAG,EAAI,eAAe;EAAE,CAAC;CACpF,GAEM,KAAQ,IAAY,OAAU;EAClC,IAAM,IAAS,EAAI,UAAU;EAC7B,IAAI,CAAC,GAAQ;EACb,IAAM,IAAQ,EAAiB;EAC1B,MAIL,EAAc,GACd,IAAS,EAAI,YAAY,MAAM,GAAO;GAAE,aAAa,EAAI;GAAa,cAAc;EAAO,CAAC,GAC5F,EAAc,GAAQ,CAAK,GAC3B,GAAS,KAAK,GACd,EAAoB,GAAO,EAAE,aAAU,CAAC;CAC1C,GAEM,UAAgB;EAIpB,AAHA,GAAS,QAAQ,GACjB,IAAU,KAAA,GACV,EAAc,GACd,EAAc;CAChB;CAEA,OAAO;EACL,WAAW;GACT,OAAO,EAAS;EAClB;EAEA,YAAY;GACV,OAAO;EACT;EAEA;EAEA,MAAM,MAAM,GAAa;GAwBvB,AADA,GAAQ,iBAAiB,GACzB,GAAS,MAAM;GACf,IAAM,IAAQ,EAAS;GAQvB,OAPI,KAAS,CAAE,MAAM,EAAoB,CAAK,IAAW,KACrD,EAAY,KACd,GAAQ,iBAAiB,EAAK,GACvB,OAET,EAAc,GACd,EAAc,GACP;EACT;EAEA,iBAAiB;GACf,GAAS,OAAO,EAAI,eAAe,CAAC;EACtC;EAEA,YAAY,GAAQ;GAClB,IAAM,IAAgB,EAAS,CAAC,EAAE,QAAQ,gBAAgB;GAE1D,AADA,EAAQ,GACJ,KAAQ,EAAK,CAAC,CAAa;EACjC;EAEA;CACF;AACF,CAAC"}