@greypan/web-ui 10.2.1 → 11.1.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 (210) hide show
  1. package/README.CN.md +132 -68
  2. package/README.md +112 -43
  3. package/dist/assets/menu-portal.js.map +1 -1
  4. package/dist/assets/modal-surface.js.map +1 -1
  5. package/dist/assets/overlay-motion.js +1 -1
  6. package/dist/assets/overlay-motion.js.map +1 -1
  7. package/dist/assets/selection-control.js +1 -1
  8. package/dist/assets/selection-control.js.map +1 -1
  9. package/dist/classes/glass.js +6 -0
  10. package/dist/classes/glass.js.map +1 -0
  11. package/dist/classes/glow.js +6 -0
  12. package/dist/classes/glow.js.map +1 -0
  13. package/dist/classes/style.css +1 -0
  14. package/dist/components/autocomplete/index.d.ts +13 -6
  15. package/dist/components/autocomplete/index.js +34 -14
  16. package/dist/components/autocomplete/index.js.map +1 -1
  17. package/dist/components/autocomplete/style.js +1 -1
  18. package/dist/components/autocomplete/style.js.map +1 -1
  19. package/dist/components/avatar/index.js +2 -2
  20. package/dist/components/avatar/index.js.map +1 -1
  21. package/dist/components/avatar/style.js +1 -1
  22. package/dist/components/avatar/style.js.map +1 -1
  23. package/dist/components/back-top/index.d.ts +4 -0
  24. package/dist/components/back-top/index.js +60 -22
  25. package/dist/components/back-top/index.js.map +1 -1
  26. package/dist/components/back-top/style.js +1 -1
  27. package/dist/components/back-top/style.js.map +1 -1
  28. package/dist/components/badge/index.d.ts +3 -0
  29. package/dist/components/badge/index.js +30 -15
  30. package/dist/components/badge/index.js.map +1 -1
  31. package/dist/components/badge/style.js +1 -1
  32. package/dist/components/badge/style.js.map +1 -1
  33. package/dist/components/button/index.d.ts +4 -0
  34. package/dist/components/button/index.js +32 -19
  35. package/dist/components/button/index.js.map +1 -1
  36. package/dist/components/button/style.js +1 -1
  37. package/dist/components/button/style.js.map +1 -1
  38. package/dist/components/button-group/index.d.ts +3 -0
  39. package/dist/components/button-group/index.js +15 -2
  40. package/dist/components/button-group/index.js.map +1 -1
  41. package/dist/components/button-group/style.js +1 -1
  42. package/dist/components/button-group/style.js.map +1 -1
  43. package/dist/components/checkbox/index.d.ts +1 -1
  44. package/dist/components/checkbox/index.js +7 -7
  45. package/dist/components/checkbox/index.js.map +1 -1
  46. package/dist/components/checkbox/style.js +1 -1
  47. package/dist/components/checkbox/style.js.map +1 -1
  48. package/dist/components/checkbox-group/index.js +1 -1
  49. package/dist/components/checkbox-group/index.js.map +1 -1
  50. package/dist/components/collapse/index.d.ts +8 -6
  51. package/dist/components/collapse/index.js +45 -25
  52. package/dist/components/collapse/index.js.map +1 -1
  53. package/dist/components/collapse/style.js +1 -1
  54. package/dist/components/collapse/style.js.map +1 -1
  55. package/dist/components/context-menu/index.d.ts +60 -60
  56. package/dist/components/context-menu/index.js +2 -2
  57. package/dist/components/context-menu/index.js.map +1 -1
  58. package/dist/components/context-menu/scrim.js.map +1 -1
  59. package/dist/components/context-menu/style.js.map +1 -1
  60. package/dist/components/dialog/index.d.ts +5 -4
  61. package/dist/components/dialog/index.js +6 -3
  62. package/dist/components/dialog/index.js.map +1 -1
  63. package/dist/components/dialog/style.js +1 -1
  64. package/dist/components/dialog/style.js.map +1 -1
  65. package/dist/components/drawer/index.d.ts +8 -8
  66. package/dist/components/drawer/index.js +2 -2
  67. package/dist/components/drawer/index.js.map +1 -1
  68. package/dist/components/drawer/style.js.map +1 -1
  69. package/dist/components/dropdown/index.d.ts +22 -6
  70. package/dist/components/dropdown/index.js +147 -18
  71. package/dist/components/dropdown/index.js.map +1 -1
  72. package/dist/components/dropdown-item/index.js.map +1 -1
  73. package/dist/components/dropdown-item/style.js +1 -1
  74. package/dist/components/dropdown-item/style.js.map +1 -1
  75. package/dist/components/editable-text/index.d.ts +2 -2
  76. package/dist/components/editable-text/index.js +7 -3
  77. package/dist/components/editable-text/index.js.map +1 -1
  78. package/dist/components/editable-text/style.js +1 -1
  79. package/dist/components/editable-text/style.js.map +1 -1
  80. package/dist/components/empty/index.d.ts +3 -3
  81. package/dist/components/empty/index.js.map +1 -1
  82. package/dist/components/image-preview/index.d.ts +5 -5
  83. package/dist/components/image-preview/index.js +9 -9
  84. package/dist/components/image-preview/index.js.map +1 -1
  85. package/dist/components/image-preview/style.js.map +1 -1
  86. package/dist/components/input/index.d.ts +1 -0
  87. package/dist/components/input/index.js +6 -3
  88. package/dist/components/input/index.js.map +1 -1
  89. package/dist/components/input/style.js +1 -1
  90. package/dist/components/input/style.js.map +1 -1
  91. package/dist/components/input-number/index.d.ts +31 -1
  92. package/dist/components/input-number/index.js +281 -22
  93. package/dist/components/input-number/index.js.map +1 -1
  94. package/dist/components/input-number/style.js +1 -1
  95. package/dist/components/input-number/style.js.map +1 -1
  96. package/dist/components/layout/index.d.ts +7 -7
  97. package/dist/components/layout/index.js +47 -42
  98. package/dist/components/layout/index.js.map +1 -1
  99. package/dist/components/layout/style.js +1 -1
  100. package/dist/components/layout/style.js.map +1 -1
  101. package/dist/components/middle-ellipsis/index.d.ts +9 -2
  102. package/dist/components/middle-ellipsis/index.js +68 -10
  103. package/dist/components/middle-ellipsis/index.js.map +1 -1
  104. package/dist/components/middle-ellipsis/truncate.d.ts +1 -1
  105. package/dist/components/middle-ellipsis/truncate.js.map +1 -1
  106. package/dist/components/option/style.js +1 -1
  107. package/dist/components/option/style.js.map +1 -1
  108. package/dist/components/popover/index.d.ts +1 -1
  109. package/dist/components/popover/index.js +1 -1
  110. package/dist/components/popover/index.js.map +1 -1
  111. package/dist/components/popover/style.js +1 -1
  112. package/dist/components/popover/style.js.map +1 -1
  113. package/dist/components/radio/index.d.ts +6 -1
  114. package/dist/components/radio/index.js +34 -24
  115. package/dist/components/radio/index.js.map +1 -1
  116. package/dist/components/radio/interaction.d.ts +4 -0
  117. package/dist/components/radio/interaction.js +17 -0
  118. package/dist/components/radio/interaction.js.map +1 -0
  119. package/dist/components/radio/style.js +1 -1
  120. package/dist/components/radio/style.js.map +1 -1
  121. package/dist/components/radio-group/index.d.ts +23 -1
  122. package/dist/components/radio-group/index.js +220 -29
  123. package/dist/components/radio-group/index.js.map +1 -1
  124. package/dist/components/segmented/index.d.ts +2 -0
  125. package/dist/components/segmented/index.js +74 -18
  126. package/dist/components/segmented/index.js.map +1 -1
  127. package/dist/components/segmented/style.js +1 -1
  128. package/dist/components/segmented/style.js.map +1 -1
  129. package/dist/components/segmented-trigger/index.js +3 -2
  130. package/dist/components/segmented-trigger/index.js.map +1 -1
  131. package/dist/components/segmented-trigger/style.js +1 -1
  132. package/dist/components/segmented-trigger/style.js.map +1 -1
  133. package/dist/components/select/index.d.ts +8 -0
  134. package/dist/components/select/index.js +77 -26
  135. package/dist/components/select/index.js.map +1 -1
  136. package/dist/components/select/style.js +1 -1
  137. package/dist/components/select/style.js.map +1 -1
  138. package/dist/components/slider/index.d.ts +15 -0
  139. package/dist/components/slider/index.js +175 -39
  140. package/dist/components/slider/index.js.map +1 -1
  141. package/dist/components/slider/style.js +1 -1
  142. package/dist/components/slider/style.js.map +1 -1
  143. package/dist/components/spinner/index.d.ts +15 -0
  144. package/dist/components/spinner/index.js +79 -22
  145. package/dist/components/spinner/index.js.map +1 -1
  146. package/dist/components/spinner/style.js +1 -1
  147. package/dist/components/spinner/style.js.map +1 -1
  148. package/dist/components/svg-draw-lines/index.js +48 -12
  149. package/dist/components/svg-draw-lines/index.js.map +1 -1
  150. package/dist/components/switch/index.d.ts +11 -3
  151. package/dist/components/switch/index.js +58 -54
  152. package/dist/components/switch/index.js.map +1 -1
  153. package/dist/components/switch/style.js +1 -1
  154. package/dist/components/switch/style.js.map +1 -1
  155. package/dist/components/textarea/index.js +3 -3
  156. package/dist/components/textarea/index.js.map +1 -1
  157. package/dist/components/textarea/style.js +1 -1
  158. package/dist/components/textarea/style.js.map +1 -1
  159. package/dist/components/theme/index.d.ts +1 -0
  160. package/dist/components/theme/index.js +26 -15
  161. package/dist/components/theme/index.js.map +1 -1
  162. package/dist/components/theme/style.js +1 -1
  163. package/dist/components/theme/style.js.map +1 -1
  164. package/dist/components/toast/style.js +1 -1
  165. package/dist/components/toast/style.js.map +1 -1
  166. package/dist/components/toast/toast.js +2 -2
  167. package/dist/components/toast/toast.js.map +1 -1
  168. package/dist/components/tooltip/index.d.ts +2 -1
  169. package/dist/components/tooltip/index.js +10 -5
  170. package/dist/components/tooltip/index.js.map +1 -1
  171. package/dist/components/tooltip/style.js.map +1 -1
  172. package/dist/scrollbars/index.d.ts +8 -8
  173. package/dist/scrollbars/index.js.map +1 -1
  174. package/dist/scrollbars/internal.js.map +1 -1
  175. package/dist/shared/element-height/index.js.map +1 -1
  176. package/dist/shared/focus/pointer-focus.js +12 -10
  177. package/dist/shared/focus/pointer-focus.js.map +1 -1
  178. package/dist/shared/gesture/drag-gesture.js +4 -4
  179. package/dist/shared/gesture/drag-gesture.js.map +1 -1
  180. package/dist/shared/group-management/index.js +43 -18
  181. package/dist/shared/group-management/index.js.map +1 -1
  182. package/dist/shared/label-emptiness/index.js.map +1 -1
  183. package/dist/shared/menu-behavior/index.js.map +1 -1
  184. package/dist/shared/menu-portal/menu-portal.js +1 -1
  185. package/dist/shared/menu-portal/menu-portal.js.map +1 -1
  186. package/dist/shared/menu-portal/menu-tree.js.map +1 -1
  187. package/dist/shared/option-portal/combobox-shell.js.map +1 -1
  188. package/dist/shared/option-portal/index.js.map +1 -1
  189. package/dist/shared/overlay/anchored-panel.js.map +1 -1
  190. package/dist/shared/overlay/native-dialog-presence.js.map +1 -1
  191. package/dist/shared/overlay/nested-drawer-layers.js.map +1 -1
  192. package/dist/shared/overlay/open-overlay.js.map +1 -1
  193. package/dist/shared/overlay/overlay-root.js +1 -1
  194. package/dist/shared/overlay/overlay-root.js.map +1 -1
  195. package/dist/shared/overlay/overlay.js +7 -1
  196. package/dist/shared/overlay/overlay.js.map +1 -1
  197. package/dist/shared/overlay/portal.js.map +1 -1
  198. package/dist/shared/overlay/presence.js.map +1 -1
  199. package/dist/shared/scroll-lock/scroll-lock.js.map +1 -1
  200. package/dist/shared/theme/root-sync.js.map +1 -1
  201. package/package.json +5 -2
  202. package/dist/assets/glass.js +0 -6
  203. package/dist/assets/glass.js.map +0 -1
  204. package/dist/icons/generated/heroicons-arrow-down-16-solid.d.ts +0 -6
  205. package/dist/icons/generated/heroicons-arrow-up-16-solid.d.ts +0 -6
  206. package/dist/icons/generated/jam-volume.d.ts +0 -8
  207. package/dist/icons/generated/jam-watch-f.d.ts +0 -8
  208. package/dist/icons/generated/ooui-check.d.ts +0 -6
  209. package/dist/icons/generated/radix-icons-check.d.ts +0 -6
  210. package/dist/icons/generated/si-check-alt-fill.d.ts +0 -6
@@ -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 menuPortalStyle from '@/assets/menu-portal.css?inline'\nimport overlayMotion from '@/assets/overlay-motion.css?inline'\nimport glass from '@/classes/glass.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":";;AA2CA,SAAgB,EAA6B,GAAkD;CAC7F,SAAS,EAAK,IAAuB,IAAO;EACtC,EAAM,QAAQ,MAAM,MAAS,EAAM,UAAU,MACjD,EAAM,UAAU,EAAI,GACpB,EAAM,mBAAmB,GACzB,EAAM,SAAS,CAAoB,GACnC,EAAM,eAAe,EAAI,GACrB,EAAM,SAAS,IACjB,4BAA4B;GAC1B,AAAI,EAAM,UAAU,KAAG,EAAM,YAAY,CAAoB;EAC/D,CAAC,IAED,EAAM,YAAY,CAAoB;CAE1C;CAEA,SAAS,IAAQ;EACV,EAAM,UAAU,MACrB,EAAM,gBAAgB,GACtB,EAAM,UAAU,EAAK,GACrB,EAAM,mBAAmB,GACzB,EAAM,eAAe,GACrB,EAAM,eAAe,EAAK,GAC1B,EAAM,aAAa;CACrB;CAEA,OAAO;EAAE;EAAM;CAAM;AACvB;AA+DA,IAAa,UACX,GAAkF,MAAU;CAC1F,IAAI,IAAmC,MAEjC,UAAuC,EAAO,iBAAiB,KAAK,EAAO,mBAAmB,KAAK,MAEnG,KAAa,MAA+C;EAChE,IAAM,IAAS,GAAwD;EACvE,OAAO,OAAO,KAAU,WAAW,IAAQ;CAC7C,GAIM,KAAmB,GAAc,MAAyC;EAC9E,IAAM,IAAS,EAAM;EACrB,OAAO,CAAC,CAAC,KAAW,aAAkB,SAAS,MAAW,KAAW,EAAQ,SAAS,CAAM;CAC9F,GAEM,KAAW,MAAiB;EAChC,IAAM,IAAU,EAAW;EAC3B,IAAI,CAAC,EAAgB,GAAO,CAAO,GAAG;EACtC,IAAM,IAAQ,EAAU,CAAO;EAC/B,AAAI,MAAU,QAAM,EAAO,QAAQ,CAAK;CAC1C,GAEM,KAAkB,MAAiB;EACvC,AAAI,EAAgB,GAAO,EAAW,CAAC,KAAG,EAAM,gBAAgB;CAClE,GAEM,UAAgB,EAAO,QAAQ,GAC/B,KAAa,MAAsB,EAAO,UAAU,CAAK,GACzD,KAAc,MAAsB,EAAO,WAAW,CAAK;CAEjE,OAAO;EACL;EACA,gBAAgB,EAAU,EAAW,CAAC;EACtC,SAAS,GAAe;GACtB,IAAM,IAAU,EAAW,GACrB,IAAU,EAAU,CAAO;GAC7B,AAAC,KAAW,MAAY,QAAQ,MAAY,MAC/C,EAA6C,QAAQ;EACxD;EACA,gBAAgB,GAAsB;GAEpC,OAAO,EAAM,aAAa,CAAC,CAAC,cAAc;EAC5C;EACA,aAAa,GAAwB;GAGnC,EAAW,CAAC,EAAE,MAAM,CAAO;EAC7B;EACA,cAAc;GACZ,EAAW,CAAC,EAAE,KAAK;EACrB;EACA,OAAO;GACL,IAAM,IAAU,EAAO,WAAW;GAC9B,AAAC,KAAW,MAAY,MAC5B,IAAe,GACf,EAAQ,iBAAiB,SAAS,CAAO,GACzC,EAAQ,iBAAiB,UAAU,CAAc,GACjD,EAAQ,iBAAiB,SAAS,CAAO,GACzC,EAAQ,iBAAiB,WAAW,CAAS,GAC7C,EAAQ,iBAAiB,YAAY,CAAU;EACjD;EACA,UAAU;GACR,IAAM,IAAU;GAChB,IAAe,MACV,MACL,EAAQ,oBAAoB,SAAS,CAAO,GAC5C,EAAQ,oBAAoB,UAAU,CAAc,GACpD,EAAQ,oBAAoB,SAAS,CAAO,GAC5C,EAAQ,oBAAoB,WAAW,CAAS,GAChD,EAAQ,oBAAoB,YAAY,CAAU;EACpD;CACF;AACF,CAAC;AAGH,SAAgB,EAA4B,GAAkC;CAC5E,SAAS,EAAK,GAAqB;EAIjC,AAHA,EAAO,iBAAiB,SAAS,EAAS,OAAO,GACjD,EAAO,iBAAiB,eAAe,EAAS,aAAa,GAC7D,EAAO,iBAAiB,eAAe,EAAS,aAAa,GAC7D,EAAO,iBAAiB,iBAAiB,EAAS,QAAQ;CAC5D;CACA,SAAS,EAAO,GAAqB;EAInC,AAHA,EAAO,oBAAoB,SAAS,EAAS,OAAO,GACpD,EAAO,oBAAoB,eAAe,EAAS,aAAa,GAChE,EAAO,oBAAoB,eAAe,EAAS,aAAa,GAChE,EAAO,oBAAoB,iBAAiB,EAAS,QAAQ;CAC/D;CACA,OAAO;EAAE;EAAM;CAAO;AACxB;AAMA,SAAgB,EACd,GACA,GACA,GACA,GACM;CACN,4BAA4B;EAC1B,IAAM,IAAS,EAAU;EACzB,AAAI,EAAU,KAAK,CAAC,EAAK,QAAQ,eAAe,KAAK,CAAC,GAAQ,eAAe,KAC3E,EAAM;CAEV,CAAC;AACH;AAMA,SAAgB,EAAkB,GAAsB,GAAoB,GAAuB;CACjG,IAAI,MAAiB,GAAG,OAAO;CAC/B,IAAI,IAAU,IAAa;CAG3B,OAFI,IAAU,MAAG,IAAU,IAAe,IACtC,KAAW,MAAc,IAAU,IAChC;AACT"}
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"}
@@ -1 +1 @@
1
- {"version":3,"file":"native-dialog-presence.js","names":[],"sources":["../../../src/shared/overlay/native-dialog-presence.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport { getTransitionDuration } from './presence'\n\nexport interface NativeDialogPresenceOptions {\n getDialog(): HTMLDialogElement | null\n isConnected(): boolean\n isOpen(): boolean\n}\n\nexport interface NativeDialogPresenceApi {\n sync(open: boolean): void\n /**\n * 重挂载对账:宿主在打开态被移出文档再接回时调用。\n *\n * 平台行为(Chromium/WebKit 实测):`<dialog>` 随宿主移出文档时脱离 top layer,\n * 但 `open` 属性残留。此时 `sync(true)` 救不回来——`startOpening` 的\n * `if (!dialog.open)` 守卫会跳过 `showModal`,而直接对残留 open 的 dialog 调\n * `showModal` 会抛 `InvalidStateError`;且排队的 `close` 事件若被消费方当成\n * 外部关闭,会把 `open` 误置为 false。本方法先标记 self-close 再真正 close,\n * 随后走 `startOpening`(此刻 open 属性已清,`showModal` 正常执行,排队的\n * `close` 事件由 `handleNativeClose` 消费),dialog 因此同步回到 top layer。\n */\n reconcile(): void\n handleTransitionEnd(event: TransitionEvent): void\n /**\n * 处理原生 dialog 的 `close` 事件。\n *\n * 返回 `true` 表示该事件是本模块自己 `finishClosing → dialog.close()` 排队的\n * 异步事件(正常时序在本关闭会话内到达,快速重开时在重开后到达的过期事件),\n * 消费方不应把它当成外部关闭;返回 `false` 表示真实的外部关闭\n * (如表单 method=\"dialog\"、宿主程序化 dialog.close()),消费方需自行恢复状态。\n */\n handleNativeClose(): boolean\n dispose(): void\n}\n\n// 管理原生 dialog 保持在 top layer 直至退出动画完成的生命周期。\nexport const defineNativeDialogPresence = () =>\n definePlugin<NativeDialogPresenceApi, NativeDialogPresenceOptions>(ctx => {\n let closeFallbackTimer: ReturnType<typeof setTimeout> | undefined\n let openFrame: number | undefined\n let isClosing = false\n // 由本模块 finishClosing 主动 dialog.close() 时置 true。原生 dialog 的 close 事件\n // 是异步任务(queue a task),可能在之后任意时刻才到达:正常时序在本关闭会话内\n // 到达(消费方 open 已为 false,本就该忽略);快速「关闭→重开」时它在重新打开\n // 之后才到达,若消费方把它当成外部关闭,会把刚重开的 dialog 关掉——这是 drawer\n // 连续开关后丢失过渡动画(重开即被误关)的根因。置位后由消费方的\n // handleNativeClose 经 handleNativeClose() 消费;消费方 open 为 false 提前返回时\n // 也必须调用一次以消费标志,避免泄漏到下一次真实关闭。\n let selfClosePending = false\n\n const clearCloseFallback = () => {\n if (closeFallbackTimer === undefined) return\n clearTimeout(closeFallbackTimer)\n closeFallbackTimer = undefined\n }\n\n const cancelOpenFrame = () => {\n if (openFrame === undefined) return\n cancelAnimationFrame(openFrame)\n openFrame = undefined\n }\n\n const finishClosing = () => {\n const dialog = ctx.getDialog()\n if (!dialog || !isClosing || ctx.isOpen()) return\n clearCloseFallback()\n isClosing = false\n dialog.classList.remove('is-closing', 'is-visible')\n if (dialog.open) {\n selfClosePending = true\n dialog.close?.()\n }\n }\n\n const startOpening = () => {\n const dialog = ctx.getDialog()\n if (!dialog) return\n isClosing = false\n clearCloseFallback()\n dialog.classList.remove('is-closing')\n if (!dialog.open) {\n try {\n // 并发多个 modal dialog(nested drawer 场景)在规范中合法;\n // 此 try/catch 为防御性兜底(如对已在 top layer 的 dialog 重复 showModal\n // 会抛 InvalidStateError),失败时由下方 rAF 回调的 dialog.open 判断中止动画。\n dialog.showModal?.()\n } catch {\n // 保留未打开状态\n }\n }\n // 强制计算初始 top-layer 布局,确保 Safari 等浏览器在下一帧切换 class 时\n // 有稳定的 transition 起点,而不是偶发跳过进场过渡。\n void dialog.offsetWidth\n\n cancelOpenFrame()\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!ctx.isConnected() || !ctx.isOpen() || !dialog.open) return\n dialog.classList.add('is-visible')\n })\n }\n\n const startClosingTransition = (dialog: HTMLDialogElement) => {\n // WebKit may coalesce class changes in the same style scope and skip the transition\n // when the previous computed state has not been submitted yet.\n void dialog.offsetWidth\n dialog.classList.add('is-closing')\n dialog.classList.remove('is-visible')\n clearCloseFallback()\n closeFallbackTimer = setTimeout(() => finishClosing(), getTransitionDuration(dialog) + 80)\n }\n\n const startClosing = () => {\n const dialog = ctx.getDialog()\n if (!dialog?.open) return\n const hasPendingOpenFrame = openFrame !== undefined\n cancelOpenFrame()\n isClosing = true\n if (dialog.classList.contains('is-visible')) {\n startClosingTransition(dialog)\n return\n }\n if (!hasPendingOpenFrame) {\n finishClosing()\n return\n }\n\n // 关闭可能先于打开路径的首个 rAF 到达。先用一帧补齐可见态并强制提交布局,\n // 再用下一帧切换 closing,让退出 transition 有稳定的起点。进场 transition\n // 不能参与这次布局提交,否则当前 transform 仍停在闭合起点。\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!isClosing || ctx.isOpen() || !dialog.open) return\n const transition = dialog.style.getPropertyValue('transition')\n const transitionPriority = dialog.style.getPropertyPriority('transition')\n dialog.style.setProperty('transition', 'none', 'important')\n dialog.classList.add('is-visible')\n void dialog.offsetWidth\n if (transition) {\n dialog.style.setProperty('transition', transition, transitionPriority)\n } else {\n dialog.style.removeProperty('transition')\n }\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!isClosing || ctx.isOpen() || !dialog.open) return\n startClosingTransition(dialog)\n })\n })\n }\n\n // jsdom 的选择器引擎不认识 :modal(可能抛错);抛错按「不在 top layer」处理,\n // reconcile 随之走 close+showModal 的收敛路径,行为仍正确。\n const isInTopLayer = (dialog: HTMLDialogElement): boolean => {\n try {\n return dialog.matches(':modal')\n } catch {\n return false\n }\n }\n\n return {\n sync(open) {\n if (open) startOpening()\n else startClosing()\n },\n\n reconcile() {\n const dialog = ctx.getDialog()\n if (!dialog) return\n\n if (!ctx.isOpen()) {\n // 宿主在断连期间被置为关闭(或关闭动画被 disconnect 打断):\n // updated() 在断连时被跳过,inner dialog 可能仍残留 open 属性与\n // is-visible/is-closing class,补一次强制收敛到关闭态。\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n if (dialog.open) {\n selfClosePending = true\n dialog.close()\n }\n dialog.classList.remove('is-closing', 'is-visible')\n return\n }\n\n // 打开态重挂载:open 属性残留但已脱离 top layer 时,必须先真关闭再重开。\n if (dialog.open && !isInTopLayer(dialog)) {\n selfClosePending = true\n dialog.close()\n }\n startOpening()\n },\n\n handleTransitionEnd(event) {\n if (event.target !== ctx.getDialog() || event.propertyName !== 'transform') return\n finishClosing()\n },\n\n handleNativeClose() {\n if (selfClosePending) {\n // 我们自己的 dialog.close() 排队的事件:正常时序在本会话内到达、快速重开\n // 时是上一会话的过期事件——两种情况本模块都已在 finishClosing 完成全部清理,\n // 消费标志并让调用方跳过外部关闭处理即可。\n selfClosePending = false\n return true\n }\n // 真实的外部关闭:重置打开/关闭状态,等待消费方恢复(如 open=false)。\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n ctx.getDialog()?.classList.remove('is-closing', 'is-visible')\n return false\n },\n\n dispose() {\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n selfClosePending = false\n }\n }\n })\n"],"mappings":";;;AAsCA,IAAa,UACX,GAAmE,MAAO;CACxE,IAAI,GACA,GACA,IAAY,IAQZ,IAAmB,IAEjB,UAA2B;EAC3B,MAAuB,KAAA,MAC3B,aAAa,CAAkB,GAC/B,IAAqB,KAAA;CACvB,GAEM,UAAwB;EACxB,MAAc,KAAA,MAClB,qBAAqB,CAAS,GAC9B,IAAY,KAAA;CACd,GAEM,UAAsB;EAC1B,IAAM,IAAS,EAAI,UAAU;EACzB,AAAC,KAAW,KAAa,GAAI,OAAO,MACxC,EAAmB,GACnB,IAAY,IACZ,EAAO,UAAU,OAAO,cAAc,YAAY,GAC9C,EAAO,SACT,IAAmB,IACnB,EAAO,QAAQ;CAEnB,GAEM,UAAqB;EACzB,IAAM,IAAS,EAAI,UAAU;EACxB,OAIL;OAHA,IAAY,IACZ,EAAmB,GACnB,EAAO,UAAU,OAAO,YAAY,GAChC,CAAC,EAAO,MACV,IAAI;IAIF,EAAO,YAAY;GACrB,QAAQ,CAER;GAOF,AAHA,EAAY,aAEZ,EAAgB,GAChB,IAAY,4BAA4B;IACtC,IAAY,KAAA,GACP,EAAI,YAAY,KAAM,EAAI,OAAO,KAAM,EAAO,QACnD,EAAO,UAAU,IAAI,YAAY;GACnC,CAAC;EAXC;CAYJ,GAEM,KAA0B,MAA8B;EAO5D,AAJA,EAAY,aACZ,EAAO,UAAU,IAAI,YAAY,GACjC,EAAO,UAAU,OAAO,YAAY,GACpC,EAAmB,GACnB,IAAqB,iBAAiB,EAAc,GAAG,EAAsB,CAAM,IAAI,EAAE;CAC3F,GAEM,UAAqB;EACzB,IAAM,IAAS,EAAI,UAAU;EAC7B,IAAI,CAAC,GAAQ,MAAM;EACnB,IAAM,IAAsB,MAAc,KAAA;EAG1C,IAFA,EAAgB,GAChB,IAAY,IACR,EAAO,UAAU,SAAS,YAAY,GAAG;GAC3C,EAAuB,CAAM;GAC7B;EACF;EACA,IAAI,CAAC,GAAqB;GACxB,EAAc;GACd;EACF;EAKA,IAAY,4BAA4B;GAEtC,IADA,IAAY,KAAA,GACR,CAAC,KAAa,EAAI,OAAO,KAAK,CAAC,EAAO,MAAM;GAChD,IAAM,IAAa,EAAO,MAAM,iBAAiB,YAAY,GACvD,IAAqB,EAAO,MAAM,oBAAoB,YAAY;GASxE,AARA,EAAO,MAAM,YAAY,cAAc,QAAQ,WAAW,GAC1D,EAAO,UAAU,IAAI,YAAY,GACjC,EAAY,aACR,IACF,EAAO,MAAM,YAAY,cAAc,GAAY,CAAkB,IAErE,EAAO,MAAM,eAAe,YAAY,GAE1C,IAAY,4BAA4B;IACtC,IAAY,KAAA,GACP,KAAa,GAAI,OAAO,KAAM,EAAO,QAC1C,EAAuB,CAAM;GAC/B,CAAC;EACH,CAAC;CACH,GAIM,KAAgB,MAAuC;EAC3D,IAAI;GACF,OAAO,EAAO,QAAQ,QAAQ;EAChC,QAAQ;GACN,OAAO;EACT;CACF;CAEA,OAAO;EACL,KAAK,GAAM;GACT,AAAI,IAAM,EAAa,IAClB,EAAa;EACpB;EAEA,YAAY;GACV,IAAM,IAAS,EAAI,UAAU;GACxB,OAEL;QAAI,CAAC,EAAI,OAAO,GAAG;KAWjB,AAPA,EAAgB,GAChB,EAAmB,GACnB,IAAY,IACR,EAAO,SACT,IAAmB,IACnB,EAAO,MAAM,IAEf,EAAO,UAAU,OAAO,cAAc,YAAY;KAClD;IACF;IAOA,AAJI,EAAO,QAAQ,CAAC,EAAa,CAAM,MACrC,IAAmB,IACnB,EAAO,MAAM,IAEf,EAAa;GAPb;EAQF;EAEA,oBAAoB,GAAO;GACrB,EAAM,WAAW,EAAI,UAAU,KAAK,EAAM,iBAAiB,eAC/D,EAAc;EAChB;EAEA,oBAAoB;GAalB,OAZI,KAIF,IAAmB,IACZ,OAGT,EAAgB,GAChB,EAAmB,GACnB,IAAY,IACZ,EAAI,UAAU,CAAC,EAAE,UAAU,OAAO,cAAc,YAAY,GACrD;EACT;EAEA,UAAU;GAIR,AAHA,EAAgB,GAChB,EAAmB,GACnB,IAAY,IACZ,IAAmB;EACrB;CACF;AACF,CAAC"}
1
+ {"version":3,"file":"native-dialog-presence.js","names":[],"sources":["../../../src/shared/overlay/native-dialog-presence.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport { getTransitionDuration } from './presence'\n\nexport interface NativeDialogPresenceOptions {\n getDialog(): HTMLDialogElement | null\n isConnected(): boolean\n isOpen(): boolean\n}\n\nexport interface NativeDialogPresenceApi {\n sync(open: boolean): void\n /**\n * 重挂载对账:宿主在打开态被移出文档再接回时调用。\n *\n * 平台行为(Chromium/WebKit 实测):`<dialog>` 随宿主移出文档时脱离 top layer,\n * 但 `open` 属性残留。此时 `sync(true)` 救不回来——`startOpening` 的\n * `if (!dialog.open)` 守卫会跳过 `showModal`,而直接对残留 open 的 dialog 调\n * `showModal` 会抛 `InvalidStateError`;且排队的 `close` 事件若被消费方当成\n * 外部关闭,会把 `open` 误置为 false。本方法先标记 self-close 再真正 close,\n * 随后走 `startOpening`(此刻 open 属性已清,`showModal` 正常执行,排队的\n * `close` 事件由 `handleNativeClose` 消费),dialog 因此同步回到 top layer。\n */\n reconcile(): void\n handleTransitionEnd(event: TransitionEvent): void\n /**\n * 处理原生 dialog 的 `close` 事件。\n *\n * 返回 `true` 表示该事件是本模块自己 `finishClosing → dialog.close()` 排队的\n * 异步事件(正常时序在本关闭会话内到达,快速重开时在重开后到达的过期事件),\n * 消费方不应把它当成外部关闭;返回 `false` 表示真实的外部关闭\n * (如表单 method=\"dialog\"、宿主程序化 dialog.close()),消费方需自行恢复状态。\n */\n handleNativeClose(): boolean\n dispose(): void\n}\n\n// 管理原生 dialog 保持在 top layer 直至退出动画完成的生命周期。\nexport const defineNativeDialogPresence = () =>\n definePlugin<NativeDialogPresenceApi, NativeDialogPresenceOptions>(ctx => {\n let closeFallbackTimer: ReturnType<typeof setTimeout> | undefined\n let openFrame: number | undefined\n let isClosing = false\n // 由本模块 finishClosing 主动 dialog.close() 时置 true。原生 dialog 的 close 事件\n // 是异步任务(queue a task),可能在之后任意时刻才到达:正常时序在本关闭会话内\n // 到达(消费方 open 已为 false,本就该忽略);快速「关闭→重开」时它在重新打开\n // 之后才到达,若消费方把它当成外部关闭,会把刚重开的 dialog 关掉——这是 drawer\n // 连续 toggle 后丢失过渡动画(重开即被误关)的根因。置位后由消费方的\n // handleNativeClose 经 handleNativeClose() 消费;消费方 open 为 false 提前返回时\n // 也必须调用一次以消费标志,避免泄漏到下一次真实关闭。\n let selfClosePending = false\n\n const clearCloseFallback = () => {\n if (closeFallbackTimer === undefined) return\n clearTimeout(closeFallbackTimer)\n closeFallbackTimer = undefined\n }\n\n const cancelOpenFrame = () => {\n if (openFrame === undefined) return\n cancelAnimationFrame(openFrame)\n openFrame = undefined\n }\n\n const finishClosing = () => {\n const dialog = ctx.getDialog()\n if (!dialog || !isClosing || ctx.isOpen()) return\n clearCloseFallback()\n isClosing = false\n dialog.classList.remove('is-closing', 'is-visible')\n if (dialog.open) {\n selfClosePending = true\n dialog.close?.()\n }\n }\n\n const startOpening = () => {\n const dialog = ctx.getDialog()\n if (!dialog) return\n isClosing = false\n clearCloseFallback()\n dialog.classList.remove('is-closing')\n if (!dialog.open) {\n try {\n // 并发多个 modal dialog(nested drawer 场景)在规范中合法;\n // 此 try/catch 为防御性兜底(如对已在 top layer 的 dialog 重复 showModal\n // 会抛 InvalidStateError),失败时由下方 rAF 回调的 dialog.open 判断中止动画。\n dialog.showModal?.()\n } catch {\n // 保留未打开状态\n }\n }\n // 强制计算初始 top-layer 布局,确保 Safari 等浏览器在下一帧切换 class 时\n // 有稳定的 transition 起点,而不是偶发跳过进场过渡。\n void dialog.offsetWidth\n\n cancelOpenFrame()\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!ctx.isConnected() || !ctx.isOpen() || !dialog.open) return\n dialog.classList.add('is-visible')\n })\n }\n\n const startClosingTransition = (dialog: HTMLDialogElement) => {\n // WebKit may coalesce class changes in the same style scope and skip the transition\n // when the previous computed state has not been submitted yet.\n void dialog.offsetWidth\n dialog.classList.add('is-closing')\n dialog.classList.remove('is-visible')\n clearCloseFallback()\n closeFallbackTimer = setTimeout(() => finishClosing(), getTransitionDuration(dialog) + 80)\n }\n\n const startClosing = () => {\n const dialog = ctx.getDialog()\n if (!dialog?.open) return\n const hasPendingOpenFrame = openFrame !== undefined\n cancelOpenFrame()\n isClosing = true\n if (dialog.classList.contains('is-visible')) {\n startClosingTransition(dialog)\n return\n }\n if (!hasPendingOpenFrame) {\n finishClosing()\n return\n }\n\n // 关闭可能先于打开路径的首个 rAF 到达。先用一帧补齐可见态并强制提交布局,\n // 再用下一帧切换 closing,让退出 transition 有稳定的起点。进场 transition\n // 不能参与这次布局提交,否则当前 transform 仍停在闭合起点。\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!isClosing || ctx.isOpen() || !dialog.open) return\n const transition = dialog.style.getPropertyValue('transition')\n const transitionPriority = dialog.style.getPropertyPriority('transition')\n dialog.style.setProperty('transition', 'none', 'important')\n dialog.classList.add('is-visible')\n void dialog.offsetWidth\n if (transition) {\n dialog.style.setProperty('transition', transition, transitionPriority)\n } else {\n dialog.style.removeProperty('transition')\n }\n openFrame = requestAnimationFrame(() => {\n openFrame = undefined\n if (!isClosing || ctx.isOpen() || !dialog.open) return\n startClosingTransition(dialog)\n })\n })\n }\n\n // jsdom 的选择器引擎不认识 :modal(可能抛错);抛错按「不在 top layer」处理,\n // reconcile 随之走 close+showModal 的收敛路径,行为仍正确。\n const isInTopLayer = (dialog: HTMLDialogElement): boolean => {\n try {\n return dialog.matches(':modal')\n } catch {\n return false\n }\n }\n\n return {\n sync(open) {\n if (open) startOpening()\n else startClosing()\n },\n\n reconcile() {\n const dialog = ctx.getDialog()\n if (!dialog) return\n\n if (!ctx.isOpen()) {\n // 宿主在断连期间被置为关闭(或关闭动画被 disconnect 打断):\n // updated() 在断连时被跳过,inner dialog 可能仍残留 open 属性与\n // is-visible/is-closing class,补一次强制收敛到关闭态。\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n if (dialog.open) {\n selfClosePending = true\n dialog.close()\n }\n dialog.classList.remove('is-closing', 'is-visible')\n return\n }\n\n // 打开态重挂载:open 属性残留但已脱离 top layer 时,必须先真关闭再重开。\n if (dialog.open && !isInTopLayer(dialog)) {\n selfClosePending = true\n dialog.close()\n }\n startOpening()\n },\n\n handleTransitionEnd(event) {\n if (event.target !== ctx.getDialog() || event.propertyName !== 'transform') return\n finishClosing()\n },\n\n handleNativeClose() {\n if (selfClosePending) {\n // 我们自己的 dialog.close() 排队的事件:正常时序在本会话内到达、快速重开\n // 时是上一会话的过期事件——两种情况本模块都已在 finishClosing 完成全部清理,\n // 消费标志并让调用方跳过外部关闭处理即可。\n selfClosePending = false\n return true\n }\n // 真实的外部关闭:重置打开/关闭状态,等待消费方恢复(如 open=false)。\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n ctx.getDialog()?.classList.remove('is-closing', 'is-visible')\n return false\n },\n\n dispose() {\n cancelOpenFrame()\n clearCloseFallback()\n isClosing = false\n selfClosePending = false\n }\n }\n })\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"nested-drawer-layers.js","names":[],"sources":["../../../src/shared/overlay/nested-drawer-layers.ts"],"sourcesContent":["/*\n * Nested drawer 层序管理:\n * 每打开一层 modal drawer,其下所有已打开的 drawer 按 0.95^n 缩放并向\n * 屏幕内侧平移,在顶层抽屉后方露出阶梯式卡片边缘(peeking edge);顶层全尺寸。\n *\n * 层序来源是原生 top layer 本身:depth = 在自身 showModal 之前已打开的\n * modal dialog 数。上层关闭通过 document 捕获阶段的 `close` 事件感知\n * (原生 close 事件不冒泡,但捕获阶段在 document 上可观察所有 target),\n * 底层重算 depth 后由 CSS transition 平滑回弹。\n *\n * 视觉规格:\n * - scale = 0.95^depth\n * - 堆叠总宽度随层数按对数曲线增长:T(n) = base · ln(n)。\n * shift = shrink + sizeDiff + base · ln(depth + 1)(向屏幕内侧偏移,露出阶梯卡片边缘)。\n * ln(1) = 0,单层不受影响;层数越多每层新增的露边越少,堆叠总宽不随层数线性膨胀。\n * base 读自公开 token `--wui-drawer-nested-peek-base`(注册项的 initialValue 是桌面\n * 基准的唯一真相源,窄视口基准由 drawer 的媒体查询覆盖),消费方可整体改写层叠观感。\n * - 过渡 transform 450ms cubic-bezier(0.22, 1, 0.36, 1)\n *\n * 拖拽与释放后的收尾期间 JS 直接写 dialog.style.transform(优先级高于本机制的\n * CSS 变量组合),顶层才有拖拽,故无冲突。\n */\n\nimport { definePlugin } from '@greypan/js-kit'\n\nconst DEPTH_VARIABLE = '--wui-internal-drawer-nested-depth'\nconst NESTED_SCALE = 0.95\nexport const PEEK_BASE_VARIABLE = '--wui-drawer-nested-peek-base'\n/*\n * 注册项 initialValue 的镜像,**不是**第二真相源:真实基准由 drawer/index.ts 的\n * `CSS.registerProperty` 决定,浏览器里 computed style 永远有值,走不到这个兜底。\n * 保留它只为 jsdom——那里没有 registerProperty,getComputedStyle 返回空串,\n * 层序数学仍需要有定义的基准。浏览器 spec 有一条断言把两者钉在一起,防止漂移。\n */\nexport const NESTED_PEEK_BASE_FALLBACK = 43.2\n/*\n * 与 drawer/style.css 里 `@media (width <= 640px)` 同一条断点:跨越断点时媒体查询会\n * 换掉基准,已打开的堆叠要重算,否则会停留在旧断点的露边宽度上。\n */\nconst NESTED_PEEK_BASE_QUERY = '(width <= 640px)'\n\ninterface NestedDrawerEntry {\n dialog: HTMLDialogElement\n placement: () => 'right' | 'left' | 'top' | 'bottom'\n}\n\nconst entries = new Set<NestedDrawerEntry>()\n\nlet documentListenerAttached = false\nlet peekBaseQuery: MediaQueryList | null = null\n\nfunction readPeekBase(dialog: HTMLDialogElement): number {\n const raw = getComputedStyle(dialog).getPropertyValue(PEEK_BASE_VARIABLE)\n const parsed = Number.parseFloat(raw)\n return Number.isFinite(parsed) ? parsed : NESTED_PEEK_BASE_FALLBACK\n}\n\nfunction handlePeekBaseChange() {\n if (entries.size > 0) applyLayers()\n}\n\nfunction applyLayers() {\n const openEntries: Array<{\n entry: NestedDrawerEntry\n dialog: HTMLDialogElement\n size: number\n }> = []\n\n for (const entry of entries) {\n const dialog = entry.dialog\n if (!dialog.isConnected || !dialog.open) {\n entries.delete(entry)\n continue\n }\n const size =\n entry.placement() === 'left' || entry.placement() === 'right' ? dialog.offsetWidth : dialog.offsetHeight\n openEntries.push({ entry, dialog, size })\n }\n\n const total = openEntries.length\n for (let i = 0; i < total; i++) {\n const { dialog, size } = openEntries[i]\n // 注册顺序:先打开的在前 (index 小),后打开的在后 (index 大)。\n // depth = 在我之后打开的 drawer 数量 = (total - 1) - i\n const depth = total - 1 - i\n const scale = Math.pow(NESTED_SCALE, depth)\n const shrink = (size * (1 - scale)) / 2\n\n // 检查所有排在我之后的上层 drawer 的最大尺寸(若上层更宽,补偿宽度差确保露边)\n let aboveMaxSize = 0\n for (let j = i + 1; j < total; j++) {\n if (openEntries[j].size > aboveMaxSize) {\n aboveMaxSize = openEntries[j].size\n }\n }\n const sizeDiff = Math.max(0, aboveMaxSize - size)\n // 逐层读基准:消费方可以给某一层单独换基准,不必所有层共用一个值。\n const peek = readPeekBase(dialog) * Math.log(depth + 1)\n // depth 0 显式归零:单层抽屉的偏移不依赖 ln(1) 的浮点结果,也不依赖基准是否被改写。\n const shift = depth > 0 ? shrink + sizeDiff + peek : 0\n\n dialog.style.setProperty(DEPTH_VARIABLE, String(depth))\n dialog.style.setProperty('--wui-internal-drawer-nested-scale', scale.toFixed(4))\n dialog.style.setProperty('--wui-internal-drawer-nested-shift', `${shift.toFixed(2)}px`)\n if (depth > 0) {\n dialog.classList.add('is-nested-lower')\n } else {\n dialog.classList.remove('is-nested-lower')\n }\n }\n}\n\nfunction handleAnyDialogClose(event: Event) {\n if (!(event.target instanceof HTMLDialogElement)) return\n // 上层(或任意层)关闭后,重算所有存活 drawer 的 depth。\n if (entries.size > 0) applyLayers()\n}\n\nfunction ensureDocumentListener() {\n if (documentListenerAttached) return\n document.addEventListener('close', handleAnyDialogClose, true)\n // jsdom 等无 matchMedia 的环境:媒体查询换不掉基准,但打开/关闭路径仍会重算。\n if (typeof window.matchMedia === 'function') {\n peekBaseQuery = window.matchMedia(NESTED_PEEK_BASE_QUERY)\n peekBaseQuery.addEventListener('change', handlePeekBaseChange)\n }\n documentListenerAttached = true\n}\n\nexport interface NestedDrawerLayerOptions {\n getDialog(): HTMLDialogElement | null\n getPlacement(): 'right' | 'left' | 'top' | 'bottom'\n}\n\nexport interface NestedDrawerLayerApi {\n /** dialog 进入 top layer(打开动画就绪)后调用,纳入层序管理。 */\n register(): void\n /** 关闭流程开始时调用:移出层序并触发底层回弹。 */\n unregister(): void\n dispose(): void\n}\n\nexport const defineNestedDrawerLayers = () =>\n definePlugin<NestedDrawerLayerApi, NestedDrawerLayerOptions>(ctx => ({\n register() {\n const dialog = ctx.getDialog()\n if (!dialog) return\n // 幂等保护:重挂载对账与 updated() 的 open 分支可能对同一 dialog 先后各调\n // 一次 register,重复条目会让 applyLayers 把同一层计两次。\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n ensureDocumentListener()\n entries.add({ dialog, placement: () => ctx.getPlacement() })\n applyLayers()\n },\n\n unregister() {\n const dialog = ctx.getDialog()\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n // 移除后重算剩余层(顶层关闭 → 次层回到 depth 0)。\n if (entries.size > 0) applyLayers()\n },\n\n dispose() {\n const dialog = ctx.getDialog()\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n if (entries.size === 0 && documentListenerAttached) {\n document.removeEventListener('close', handleAnyDialogClose, true)\n peekBaseQuery?.removeEventListener('change', handlePeekBaseChange)\n peekBaseQuery = null\n documentListenerAttached = false\n }\n }\n }))\n"],"mappings":"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"}
1
+ {"version":3,"file":"nested-drawer-layers.js","names":[],"sources":["../../../src/shared/overlay/nested-drawer-layers.ts"],"sourcesContent":["/*\n * Nested drawer 层序管理:\n * 每打开一层 modal drawer,其下所有已打开的 drawer 按 0.95^n 缩放并向\n * 屏幕内侧平移,在顶层 drawer 后方露出阶梯式卡片边缘(peeking edge);顶层全尺寸。\n *\n * 层序来源是原生 top layer 本身:depth = 在自身 showModal 之前已打开的\n * modal dialog 数。上层关闭通过 document 捕获阶段的 `close` 事件感知\n * (原生 close 事件不冒泡,但捕获阶段在 document 上可观察所有 target),\n * 底层重算 depth 后由 CSS transition 平滑回弹。\n *\n * 视觉规格:\n * - scale = 0.95^depth\n * - 堆叠总宽度随层数按对数曲线增长:T(n) = base · ln(n)。\n * shift = shrink + sizeDiff + base · ln(depth + 1)(向屏幕内侧偏移,露出阶梯卡片边缘)。\n * ln(1) = 0,单层不受影响;层数越多每层新增的露边越少,堆叠总宽不随层数线性膨胀。\n * base 读自公开 token `--wui-drawer-nested-peek-base`(注册项的 initialValue 是桌面\n * 基准的唯一真相源,窄视口基准由 drawer 的媒体查询覆盖),消费方可整体改写层叠观感。\n * - 过渡 transform 450ms cubic-bezier(0.22, 1, 0.36, 1)\n *\n * 拖拽与释放后的收尾期间 JS 直接写 dialog.style.transform(优先级高于本机制的\n * CSS 变量组合),顶层才有拖拽,故无冲突。\n */\n\nimport { definePlugin } from '@greypan/js-kit'\n\nconst DEPTH_VARIABLE = '--wui-internal-drawer-nested-depth'\nconst NESTED_SCALE = 0.95\nexport const PEEK_BASE_VARIABLE = '--wui-drawer-nested-peek-base'\n/*\n * 注册项 initialValue 的镜像,**不是**第二真相源:真实基准由 drawer/index.ts 的\n * `CSS.registerProperty` 决定,浏览器里 computed style 永远有值,走不到这个兜底。\n * 保留它只为 jsdom——那里没有 registerProperty,getComputedStyle 返回空串,\n * 层序数学仍需要有定义的基准。浏览器 spec 有一条断言把两者钉在一起,防止漂移。\n */\nexport const NESTED_PEEK_BASE_FALLBACK = 43.2\n/*\n * 与 drawer/style.css 里 `@media (width <= 640px)` 同一条断点:跨越断点时媒体查询会\n * 换掉基准,已打开的堆叠要重算,否则会停留在旧断点的露边宽度上。\n */\nconst NESTED_PEEK_BASE_QUERY = '(width <= 640px)'\n\ninterface NestedDrawerEntry {\n dialog: HTMLDialogElement\n placement: () => 'right' | 'left' | 'top' | 'bottom'\n}\n\nconst entries = new Set<NestedDrawerEntry>()\n\nlet documentListenerAttached = false\nlet peekBaseQuery: MediaQueryList | null = null\n\nfunction readPeekBase(dialog: HTMLDialogElement): number {\n const raw = getComputedStyle(dialog).getPropertyValue(PEEK_BASE_VARIABLE)\n const parsed = Number.parseFloat(raw)\n return Number.isFinite(parsed) ? parsed : NESTED_PEEK_BASE_FALLBACK\n}\n\nfunction handlePeekBaseChange() {\n if (entries.size > 0) applyLayers()\n}\n\nfunction applyLayers() {\n const openEntries: Array<{\n entry: NestedDrawerEntry\n dialog: HTMLDialogElement\n size: number\n }> = []\n\n for (const entry of entries) {\n const dialog = entry.dialog\n if (!dialog.isConnected || !dialog.open) {\n entries.delete(entry)\n continue\n }\n const size =\n entry.placement() === 'left' || entry.placement() === 'right' ? dialog.offsetWidth : dialog.offsetHeight\n openEntries.push({ entry, dialog, size })\n }\n\n const total = openEntries.length\n for (let i = 0; i < total; i++) {\n const { dialog, size } = openEntries[i]\n // 注册顺序:先打开的在前 (index 小),后打开的在后 (index 大)。\n // depth = 在我之后打开的 drawer 数量 = (total - 1) - i\n const depth = total - 1 - i\n const scale = Math.pow(NESTED_SCALE, depth)\n const shrink = (size * (1 - scale)) / 2\n\n // 检查所有排在我之后的上层 drawer 的最大尺寸(若上层更宽,补偿宽度差确保露边)\n let aboveMaxSize = 0\n for (let j = i + 1; j < total; j++) {\n if (openEntries[j].size > aboveMaxSize) {\n aboveMaxSize = openEntries[j].size\n }\n }\n const sizeDiff = Math.max(0, aboveMaxSize - size)\n // 逐层读基准:消费方可以给某一层单独换基准,不必所有层共用一个值。\n const peek = readPeekBase(dialog) * Math.log(depth + 1)\n // depth 0 显式归零:单层 drawer 的偏移不依赖 ln(1) 的浮点结果,也不依赖基准是否被改写。\n const shift = depth > 0 ? shrink + sizeDiff + peek : 0\n\n dialog.style.setProperty(DEPTH_VARIABLE, String(depth))\n dialog.style.setProperty('--wui-internal-drawer-nested-scale', scale.toFixed(4))\n dialog.style.setProperty('--wui-internal-drawer-nested-shift', `${shift.toFixed(2)}px`)\n if (depth > 0) {\n dialog.classList.add('is-nested-lower')\n } else {\n dialog.classList.remove('is-nested-lower')\n }\n }\n}\n\nfunction handleAnyDialogClose(event: Event) {\n if (!(event.target instanceof HTMLDialogElement)) return\n // 上层(或任意层)关闭后,重算所有存活 drawer 的 depth。\n if (entries.size > 0) applyLayers()\n}\n\nfunction ensureDocumentListener() {\n if (documentListenerAttached) return\n document.addEventListener('close', handleAnyDialogClose, true)\n // jsdom 等无 matchMedia 的环境:媒体查询换不掉基准,但打开/关闭路径仍会重算。\n if (typeof window.matchMedia === 'function') {\n peekBaseQuery = window.matchMedia(NESTED_PEEK_BASE_QUERY)\n peekBaseQuery.addEventListener('change', handlePeekBaseChange)\n }\n documentListenerAttached = true\n}\n\nexport interface NestedDrawerLayerOptions {\n getDialog(): HTMLDialogElement | null\n getPlacement(): 'right' | 'left' | 'top' | 'bottom'\n}\n\nexport interface NestedDrawerLayerApi {\n /** dialog 进入 top layer(打开动画就绪)后调用,纳入层序管理。 */\n register(): void\n /** 关闭流程开始时调用:移出层序并触发底层回弹。 */\n unregister(): void\n dispose(): void\n}\n\nexport const defineNestedDrawerLayers = () =>\n definePlugin<NestedDrawerLayerApi, NestedDrawerLayerOptions>(ctx => ({\n register() {\n const dialog = ctx.getDialog()\n if (!dialog) return\n // 幂等保护:重挂载对账与 updated() 的 open 分支可能对同一 dialog 先后各调\n // 一次 register,重复条目会让 applyLayers 把同一层计两次。\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n ensureDocumentListener()\n entries.add({ dialog, placement: () => ctx.getPlacement() })\n applyLayers()\n },\n\n unregister() {\n const dialog = ctx.getDialog()\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n // 移除后重算剩余层(顶层关闭 → 次层回到 depth 0)。\n if (entries.size > 0) applyLayers()\n },\n\n dispose() {\n const dialog = ctx.getDialog()\n for (const entry of entries) {\n if (entry.dialog === dialog) entries.delete(entry)\n }\n if (entries.size === 0 && documentListenerAttached) {\n document.removeEventListener('close', handleAnyDialogClose, true)\n peekBaseQuery?.removeEventListener('change', handlePeekBaseChange)\n peekBaseQuery = null\n documentListenerAttached = false\n }\n }\n }))\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"open-overlay.js","names":[],"sources":["../../../src/shared/overlay/open-overlay.ts"],"sourcesContent":["/**\n * 开启态浮层(open overlay)——「哪一层正开着」的唯一拥有者。\n *\n * 合并原先三个各自问一遍「你现在开着吗」的模块:composition(逻辑父子树)、\n * escape-dismiss(最内层仲裁)、lifecycle(帧事务失效)。合并的判据不是三者文件相邻,\n * 而是它们共享同一条不变量:**已登记 ⟺ 在场 ⟺ 有仲裁身份**。\n *\n * 拆成三个模块时这条不变量没有主人,8 个浮层组件各自把它拼成三步协议\n * (`registerPanelFromAncestry` + `setPanel` / `unregisterPanel` + `setPanel(null)`)。\n * 结果是「漏撤销」与「重挂载后与仲裁失联」只能靠每个组件自己记得,而\n * `lifecycle` 还得让调用方手工传一个 `expectedOpen` 来复述「我开着吗」。\n *\n * 本模块的取舍:\n *\n * - 身份是**句柄**而非 panel 元素:`release()` 不需要调用方回忆当初传了哪个 panel。\n * - 开启状态是**声明**而非询问:仲裁时不再回调宿主问 `isOpen()`,宿主只提供\n * `requestClose()` 与一个仅供惰性回收读取的 `isConnected()`;模块持有的真相即真相。\n * - `claim` 即「我开着」:一次 claim 对应一次开启,登记与仲裁是同一个动作。\n * - 在场分两态:开启中(Escape 走关闭入口)与退场中(面板仍可见,暂缓仲裁、只作兜底\n * 候选)。后者由 `deferArbitration()` 进入、随 `release()`、重新 claim 或显式\n * `deferArbitration(false)` 离开:于是「退场被打断」与「宿主仍认为开着」不再把看得见\n * 的面板留在未登记状态,而退场已播完、宿主却仍认为开着的那一格,也不会让一个看不\n * 见的面板无限吞掉 Escape。\n * - 查询走句柄(`contains` / `containsEvent` / `hasFocusWithin`),调用方不再需要\n * 持有 panel 引用,也不知道存在全局注册表。\n * - 实例作用域与会话作用域分开:帧事务的 lifetime 比一次开合长(tooltip 跨断连\n * `resume()`),把它压进会话句柄会在 `release()` 时误杀事务。\n *\n * 刻意**不**吸收 presence、滚动锁与 drawer 的层序(nested layers):它们回答的是\n * 视觉问题(`is-visible`、层序缩放),与「谁是最内层」正交,硬合并会互相污染。\n */\n\nimport { definePlugin } from '@greypan/js-kit'\n\n/**\n * 本层是否参与 Escape 仲裁。\n *\n * - `escape`:正常候选。命中即走宿主既有的用户关闭入口。\n * - `none`:只在逻辑组合树内,完全不参与仲裁,Escape 穿过它落到下层。用于 tooltip\n * —— 它今天就在树里(经 anchored panel 登记)但从不作为候选。\n *\n * 「暂时不可关闭」不在这里,而由 `setInert` 表达。静态与动态是同一件事的两面:\n * `no-escape-close` 这类策略看似静态,但它是**被观察的属性**,开启期间可被改写,\n * 所以同样要走动态通道(claim 后立即 `setInert(noEscapeClose)`)。把静态策略做成\n * 第三个枚举值只会产生一个没有真实调用方的成员。\n */\nexport type OverlayArbitration = 'escape' | 'none'\n\nexport interface OverlayClaimOptions {\n /** 默认 `escape`。 */\n arbitration?: OverlayArbitration\n /**\n * 沿该节点的祖先链寻找最近的已开启浮层,作为本层的逻辑父级;缺省用 panel 自身。\n *\n * portal 面板与宿主物理分离,从 panel 自身向上走找不到组合父级,必须由调用方给出\n * 锚点(trigger)或宿主,才能把「谁在谁里面」判对。\n *\n * **顺序约束**:祖先链查找要求 panel 与 ancestryFrom 此刻都已就位(在 DOM 中)。\n * 本模块不做「等一帧再补登记」——那会让「已登记」与「已开启」短暂不等。\n */\n ancestryFrom?: Node\n}\n\nexport interface OverlayFrameHandle {\n cancel(): void\n}\n\n/** 会话作用域:一次开启对应一个句柄。 */\nexport interface OpenOverlayHandle {\n /**\n * 惰性:仍是候选(照旧 `preventDefault()` 压掉原生 `<dialog>` 的关闭请求),但不走\n * 关闭入口。这是「暂时不可关闭」的唯一通道,静态策略与瞬时状态都走这里。\n *\n * 不改变本层在树里的位置,也不改变 `seq` —— 因此不会影响并列浮层的裁决顺序。\n * `none` 层不受影响(它本就不是候选)。\n *\n * **与会话同 lifetime,新会话恒从 `false` 开始**:`claim` 建立的每一层都重置为\n * 非惰性。所以凡是「重新 claim」的路径(`anchored-panel.reconfigure`、同一 panel 重复\n * `open()`)之后,都必须按当前状态重推一次,否则惰性会被静默清掉。重推放在哪里取决于\n * 该组件在 reconfigure 后是否渲染:会渲染的(select / autocomplete 在 `updated()` 里\n * 同步)可以把紧随 claim 的那一行降级为备份,不会渲染的(popover)必须自己补。\n */\n setInert(inert: boolean): void\n /**\n * 进入退场暂缓态:面板仍在场(可见),本层继续留在登记表里并吞掉 Escape,但不走\n * 关闭入口。`anchored-panel.close()` 在退场等待前调用它。\n *\n * 与 `setInert` 的分工:`inert` 是**调用方**对「暂时不可关闭」的声明,会随渲染被\n * 重推;本态由**生命周期**进入,调用方没有任何途径清掉它,只有 `release()` 与\n * 渲染重推它,只有 `release()`、「同一 panel 重新 claim」与下面那个 `false` 能让它\n * 消失。因此重新 claim 出来的新会话恒不暂缓,退场被打断后由新会话接管仲裁。\n *\n * 传 `false` 退出暂态。唯一的使用者是 `anchored-panel.close()`:退场已播完(面板\n * 已隐藏)而宿主仍认为开着时,把层交还仲裁——此刻面板并不在「可见但暂缓」的前提\n * 里,继续暂缓会让一个看不见的面板无限吞掉 Escape,用户再也没有按键路径把这个\n * 不一致的状态收敛掉。\n *\n * 暂缓层只是兜底候选:`resolve()` 里仍在开启的层永远优先。否则「内层关掉后立刻\n * 再按一次 Escape」会被退场动画吞掉,外层永远等不到自己那一次(issue #120)。\n */\n deferArbitration(deferred?: boolean): void\n /**\n * 把子层 panel 纳入本句柄的子树(多级子菜单),默认不成为独立候选。\n *\n * 与 `claim` 的区别只在父级如何确定:`claim` 走祖先链,`adopt` 由调用方指名。\n *\n * 在已 `release()` 的句柄上调用**不建层**、返回空操作句柄 —— 否则会建出一个父层已死\n * 的孤层:它仍参与仲裁,却不在父层的子树里,除调用方手里那个句柄外没有回收路径。\n */\n adopt(panel: HTMLElement, options?: OverlayClaimOptions): OpenOverlayHandle\n /** 本层子树(含已 adopt 的子层)是否包含 node。 */\n contains(node: Node): boolean\n /** event 的完整 composed path 是否落入本层子树。 */\n containsEvent(event: Event): boolean\n /** 焦点是否在本层子树内。 */\n hasFocusWithin(): boolean\n /** 幂等。撤销本层及其全部子层,退出仲裁。 */\n release(): void\n}\n\n/** 实例作用域:每个宿主组件一次,跨开合与断连存活。 */\nexport interface OpenOverlay {\n claim(panel: HTMLElement, options?: OverlayClaimOptions): OpenOverlayHandle\n /**\n * 帧事务调度。`whileLive` 句柄一旦 release,回调自动作废 —— 取代原先由调用方\n * 手工传入 `expectedOpen` 的轮询(那个参数正是「模块还得回头问宿主」的证据)。\n */\n scheduleFrame(callback: () => void, whileLive?: OpenOverlayHandle): OverlayFrameHandle\n /** 结束当前事务:取消未执行的 frame,并让旧 generation 的回调失效。 */\n invalidate(): void\n /** 断连后停止调度(可 `resume()`);generation 继续单调递增,旧事务保持失效。 */\n suspend(): void\n resume(): void\n}\n\nexport interface OpenOverlayHost {\n /** 走宿主既有的用户关闭入口(内部负责 controlled 语义与 `open-change` 派发)。 */\n requestClose(): void\n /**\n * 宿主元素是否仍在文档中。只被惰性回收读取(见 `layers` 注册表处的注释),\n * 仲裁路径不回调它——读 DOM 连接状态不是回调宿主问「我开着吗」。\n */\n isConnected(): boolean\n}\n\ninterface Layer {\n panel: HTMLElement\n host: OpenOverlayHost\n arbitration: OverlayArbitration\n /** 动态惰性开关,与会话同 lifetime。 */\n inert: boolean\n /** 退场暂缓开关:面板仍在场,但只作兜底候选(见 `deferArbitration`)。 */\n deferred: boolean\n /** 登记序号:互不包含的并列浮层里,后开的更靠上。 */\n seq: number\n released: boolean\n}\n\n/*\n * 模块级注册表:单例 document 监听器需要一个全局视野,这正是「谁是最内层」不能被\n * 拆到各组件里的原因。\n *\n * `Layer` 强引用 panel 与 host,所以一条没被删掉的记录会让整个组件(含子树)无法回收。\n * 删除路径有两条:显式 `release()`,以及 `reclaimDisconnectedLayers()` 的惰性兜底\n * ——「宿主还在但组件被丢弃」这类漏 release 的路径,光靠 `isCandidate()` 筛掉不可达\n * 候选只会留下永久泄漏,还会让 `shouldListen` 恒为 true 把 document 监听留住。\n * 兜底判据取 panel 与 host **同时**失联:面板可能被 portal 在容器间搬运,也可能只是\n * 短暂移除再放回(层对象比 DOM 连接活得久),只有两者都不在文档里才判定为死层。\n * 回收**不扫刚 claim 的新层**:调用方完全可能先 claim 再把 panel 与宿主挂进文档,\n * 那一刻两者都还没连上。所以 claim 路径的回收跑在建层之前(`createLayer` 开头),\n * 新层入表之后只做监听对账。\n * 读 DOM 连接状态不是回调宿主问「我开着吗」,不违反声明式的取舍。\n */\nconst layers = new Set<Layer>()\nconst layerByPanel = new WeakMap<HTMLElement, Layer>()\nconst layerByHandle = new WeakMap<OpenOverlayHandle, Layer>()\nconst parents = new WeakMap<HTMLElement, HTMLElement>()\nconst childPanels = new WeakMap<HTMLElement, Set<HTMLElement>>()\nlet listening = false\nlet sequence = 0\n\n/*\n * dev 期测试钩子:登记表尺寸。\n *\n * 「漏 release」是结构性风险,而 `layers` 的尺寸无法从公开 interface 观察——调用方\n * 只持有句柄,看不到全局注册表,测试于是只能靠 GC 语义推断。有了它,泄漏在测试里\n * 是一个可直接断言的数字。`import.meta.env.DEV` 是编译期常量,生产构建里整段消失。\n */\ndeclare global {\n // eslint-disable-next-line no-var\n var __openOverlayLayerCount: () => number\n}\n\nif (import.meta.env.DEV) {\n globalThis.__openOverlayLayerCount = () => layers.size\n}\n\n/*\n * 已失效句柄的空操作替身,用于 `adopt` 在 released 句柄上的返回值。其余句柄方法在\n * released 时都静默降级,返回值也必须一起降级,否则调用方会以为自己拿到了一棵可用子树。\n */\nconst inactiveHandle: OpenOverlayHandle = {\n deferArbitration() {},\n setInert() {},\n adopt: () => inactiveHandle,\n contains: () => false,\n containsEvent: () => false,\n hasFocusWithin: () => false,\n release() {}\n}\n\nfunction nextCompositionNode(node: Node): Node | null {\n if (node instanceof Element && node.assignedSlot) return node.assignedSlot\n if (node.parentNode) return node.parentNode\n if (node instanceof ShadowRoot) return node.host\n return null\n}\n\nfunction nodeIsInside(root: HTMLElement, node: Node): boolean {\n let current: Node | null = node\n while (current) {\n if (current === root || root.contains(current)) return true\n current = nextCompositionNode(current)\n }\n return false\n}\n\nfunction detachFromParent(panel: HTMLElement): void {\n const parent = parents.get(panel)\n if (!parent) return\n const siblings = childPanels.get(parent)\n siblings?.delete(panel)\n if (siblings?.size === 0) childPanels.delete(parent)\n parents.delete(panel)\n}\n\nfunction attachToParent(panel: HTMLElement, parent: HTMLElement | undefined): void {\n detachFromParent(panel)\n if (!parent || parent === panel) return\n const siblings = childPanels.get(parent) ?? new Set<HTMLElement>()\n siblings.add(panel)\n childPanels.set(parent, siblings)\n parents.set(panel, parent)\n}\n\n/** panel 自身或其任何已登记子层是否包含 target。 */\nfunction containsPanel(panel: HTMLElement, target: Node): boolean {\n if (nodeIsInside(panel, target)) return true\n for (const child of childPanels.get(panel) ?? []) {\n if (containsPanel(child, target)) return true\n }\n return false\n}\n\nfunction hasFocusWithinPanel(panel: HTMLElement): boolean {\n if (panel.matches(':focus-within')) return true\n for (const child of childPanels.get(panel) ?? []) {\n if (hasFocusWithinPanel(child)) return true\n }\n return false\n}\n\n/** 从 from 沿祖先链找最近的已开启浮层 panel;panel 自身永远不算自己的父级。 */\nfunction findOpenAncestor(panel: HTMLElement, from: Node): HTMLElement | null {\n let current: Node | null = from === panel ? nextCompositionNode(from) : from\n while (current) {\n if (current instanceof HTMLElement && current !== panel) {\n const candidate = layerByPanel.get(current)\n if (candidate && !candidate.released) return current\n }\n current = nextCompositionNode(current)\n }\n return null\n}\n\nfunction releaseLayer(layer: Layer): void {\n if (layer.released) return\n layer.released = true\n for (const child of Array.from(childPanels.get(layer.panel) ?? [])) {\n const childLayer = layerByPanel.get(child)\n if (childLayer) releaseLayer(childLayer)\n else detachFromParent(child)\n }\n layers.delete(layer)\n layerByPanel.delete(layer.panel)\n childPanels.delete(layer.panel)\n detachFromParent(layer.panel)\n // 不在这里 syncListener:本函数被子层递归与兜底回收复用,监听器对账由调用方在\n // 整棵子树摘完后做一次即可。\n}\n\nfunction createLayer(input: {\n panel: HTMLElement\n host: OpenOverlayHost\n arbitration: OverlayArbitration\n parent?: HTMLElement\n}): OpenOverlayHandle {\n /*\n * 先摘死层,再建新层。claim/adopt 是完全合法的「先开启后挂载」时序——调用方那一刻\n * 可能还没把 panel 与宿主挂进文档,两者都失联。回收跑在新层入表**之前**,新层就不\n * 会被自己的 claim 判成死层;而漏 release 留下的死层依旧在这里被扫掉,与旧实现一致。\n */\n reclaimDisconnectedLayers()\n\n const previous = layerByPanel.get(input.panel)\n // 同一 panel 重新 claim = 新的一次开启:旧会话(含其子层)整体作废,seq 重新分配。\n if (previous) releaseLayer(previous)\n\n const layer: Layer = {\n panel: input.panel,\n host: input.host,\n arbitration: input.arbitration,\n inert: false,\n deferred: false,\n seq: ++sequence,\n released: false\n }\n layers.add(layer)\n layerByPanel.set(input.panel, layer)\n attachToParent(input.panel, input.parent)\n\n const handle: OpenOverlayHandle = {\n deferArbitration(deferred = true) {\n if (layer.released) return\n layer.deferred = deferred\n },\n\n setInert(inert) {\n if (layer.released) return\n layer.inert = inert\n },\n\n adopt(panel, options = {}) {\n if (layer.released) return inactiveHandle\n return createLayer({\n panel,\n host: layer.host,\n arbitration: options.arbitration ?? 'none',\n parent: layer.panel\n })\n },\n\n contains(node) {\n return !layer.released && containsPanel(layer.panel, node)\n },\n\n containsEvent(event) {\n return (\n !layer.released && event.composedPath().some(node => node instanceof Node && containsPanel(layer.panel, node))\n )\n },\n\n hasFocusWithin() {\n return !layer.released && hasFocusWithinPanel(layer.panel)\n },\n\n release() {\n releaseLayer(layer)\n syncListener()\n }\n }\n\n layerByHandle.set(handle, layer)\n // 不带回收:新层面板与宿主可能还没挂载,回收已在函数开头跑过(见 `reclaim` 参数)。\n syncListener(false)\n return handle\n}\n\nfunction isCandidate(layer: Layer): boolean {\n return !layer.released && layer.arbitration !== 'none' && layer.panel.isConnected\n}\n\n/** 在候选里取最内层:子树包含关系下的极大元;互不包含时取后开的。 */\nfunction pickInnermost(candidates: Layer[]): Layer | null {\n let best: Layer | null = null\n for (const layer of candidates) {\n if (!best) {\n best = layer\n continue\n }\n if (containsPanel(best.panel, layer.panel)) {\n best = layer\n continue\n }\n if (containsPanel(layer.panel, best.panel)) continue\n if (layer.seq > best.seq) best = layer\n }\n return best\n}\n\nfunction resolve(): Layer | null {\n // 兜底回收:每次仲裁先摘掉死层,漏 release 的组件不会把注册表和监听一起拖住。\n syncListener()\n\n const live: Layer[] = []\n const deferred: Layer[] = []\n for (const layer of layers) {\n if (!isCandidate(layer)) continue\n if (layer.deferred) deferred.push(layer)\n else live.push(layer)\n }\n\n /*\n * 暂缓层只是兜底候选:仍在开启的层永远优先。\n *\n * 反过来(暂缓层也按最内层取胜)会让「内层关掉后立刻再按一次 Escape」被退场动画吞掉\n * ——它已不再开启却仍占着名额,外层永远等不到自己那一次,正是 issue #120 的契约\n * (escape-ownership.browser.spec.ts 压住它)。第三态要补的缺口是另一件事:退场被\n * 打断、新会话 claim 之前,面板还在场上却不再登记,此刻按 Escape 会关到外层。\n *\n * 与 `pickInnermost` 同样刻意**不按事件路径筛选**:抽屉打开、内部列表也打开、而焦点\n * 停在抽屉上时,事件路径只命中抽屉,按路径判定会关掉外层、留下内层。正确语义是\n * 「最内层优先」,与焦点无关。\n */\n return pickInnermost(live) ?? pickInnermost(deferred)\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape') return\n // 已有更早的捕获监听处理过本次 Escape:不重复介入。\n if (event.defaultPrevented) return\n\n const layer = resolve()\n if (!layer) return\n\n /*\n * preventDefault 压掉原生 dialog 的关闭请求;stopPropagation 让组件自身的 handler\n * 不再执行。惰性层与暂缓层同样被吞掉按键,只是不走关闭入口 —— 否则事件会落到下层\n * 浮层,关闭顺序与用户预期相反。\n *\n * 已知边界:stopPropagation 不阻止**同一节点**(document)上后注册的其他捕获监听。\n * 刻意不用 stopImmediatePropagation —— 那会连带掐掉应用自己在 document 上的捕获\n * 监听,超出本模块职责。\n */\n event.preventDefault()\n event.stopPropagation()\n // 惰性与暂缓都吞掉按键,只是不走关闭入口。\n if (layer.inert || layer.deferred) return\n layer.host.requestClose()\n}\n\n/**\n * panel 与 host 同时失联的层是死层:显式 release 之外的兜底删除路径。\n *\n * 惰性体现在只在遍历登记表的入口跑(建层之前、仲裁、release),不挂 DOM 变动回调 ——\n * 面板短暂移除再放回必须不丢登记。建层**之后**不跑:新层可能还没挂载,扫它就会误杀\n * (见 `createLayer` 开头与 `layers` 注册表处的注释)。\n */\nfunction reclaimDisconnectedLayers(): void {\n for (const layer of Array.from(layers)) {\n if (layer.released || layer.panel.isConnected || layer.host.isConnected()) continue\n releaseLayer(layer)\n }\n}\n\n/**\n * 监听器对账:惰性回收死层,再按登记表重算 document 监听。\n *\n * `reclaim` 关掉回收,只给 `createLayer` 建层之后那一次调用用:新层刚入注册表,调用方\n * 可能还没把 panel 与宿主挂进文档,那一刻扫它等于把新层判成死层。死层不会因此漏掉——\n * 回收在建层之前已经单独跑过一轮(见 `createLayer` 开头)。\n */\nfunction syncListener(reclaim = true): void {\n if (reclaim) reclaimDisconnectedLayers()\n\n let shouldListen = false\n for (const layer of layers) {\n /*\n * 未 release 的非 `none` 层就是监听理由,**不附加连接判据**:刚 claim 而尚未挂载的\n * 层要靠这条把监听开着——面板挂上之后没有任何入口(不挂 DOM 变动回调)把它加回来,\n * 监听一撤这一层就再也听不见 Escape。死层的截杀交给上面的回收:它在每个带回收的\n * 入口先跑一步,轮到这里时同时失联的层已经不在表里,这条判据因此不会把死层留下来。\n */\n if (!layer.released && layer.arbitration !== 'none') {\n shouldListen = true\n break\n }\n }\n if (shouldListen === listening) return\n listening = shouldListen\n if (shouldListen) document.addEventListener('keydown', handleKeydown, true)\n else document.removeEventListener('keydown', handleKeydown, true)\n}\n\nexport const defineOpenOverlay = () =>\n definePlugin<OpenOverlay, OpenOverlayHost>(host => {\n let generation = 0\n let suspended = false\n const frames = new Set<OverlayFrameHandle & { id: number; generation: number }>()\n\n const cancelFrame = (frame: OverlayFrameHandle & { id: number; generation: number }) => {\n cancelAnimationFrame(frame.id)\n frames.delete(frame)\n }\n\n const cancelFrames = () => {\n for (const frame of Array.from(frames)) cancelFrame(frame)\n }\n\n const invalidateCurrent = () => {\n generation += 1\n cancelFrames()\n }\n\n const api: OpenOverlay = {\n claim(panel, options = {}) {\n const arbitration = options.arbitration ?? 'escape'\n const ancestryFrom = options.ancestryFrom ?? panel\n return createLayer({\n panel,\n host,\n arbitration,\n parent: findOpenAncestor(panel, ancestryFrom) ?? undefined\n })\n },\n\n scheduleFrame(callback, whileLive) {\n if (suspended) return { cancel() {} }\n\n const scheduledGeneration = generation\n const frame: OverlayFrameHandle & { id: number; generation: number } = {\n generation: scheduledGeneration,\n id: requestAnimationFrame(() => {\n frames.delete(frame)\n if (suspended || scheduledGeneration !== generation) return\n // 会话已结束:该帧属于上一轮开合,丢弃而不是执行在错误的层上。\n // 句柄已 release,或同一 panel 重新 claim 换了新句柄(旧层仍在 WeakMap 里,\n // 标记为 released),两种情形都要丢。\n if (whileLive) {\n const liveLayer = layerByHandle.get(whileLive)\n if (!liveLayer || liveLayer.released) return\n }\n callback()\n }),\n cancel() {\n cancelFrame(frame)\n }\n }\n frames.add(frame)\n return frame\n },\n\n invalidate: invalidateCurrent,\n\n suspend() {\n if (suspended) return\n suspended = true\n // 停调度之后再撤销在途帧(两者互不依赖,顺序不影响语义)。不能只靠回调内部的\n // suspended 复查:那会让这些帧白占一个 rAF 周期(旧实现在此处因早退而没有取消)。\n cancelFrames()\n },\n\n resume() {\n if (!suspended) return\n suspended = false\n generation += 1\n }\n }\n\n return api\n })\n"],"mappings":"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"}
1
+ {"version":3,"file":"open-overlay.js","names":[],"sources":["../../../src/shared/overlay/open-overlay.ts"],"sourcesContent":["/**\n * 开启态 overlay(open overlay)——「哪一层正开着」的唯一拥有者。\n *\n * 合并原先三个各自问一遍「你现在开着吗」的模块:composition(逻辑父子树)、\n * escape-dismiss(最内层仲裁)、lifecycle(帧事务失效)。合并的判据不是三者文件相邻,\n * 而是它们共享同一条不变量:**已登记 ⟺ 在场 ⟺ 有仲裁身份**。\n *\n * 拆成三个模块时这条不变量没有主人,8 个 overlay 组件各自把它拼成三步协议\n * (`registerPanelFromAncestry` + `setPanel` / `unregisterPanel` + `setPanel(null)`)。\n * 结果是「漏撤销」与「重挂载后与仲裁失联」只能靠每个组件自己记得,而\n * `lifecycle` 还得让调用方手工传一个 `expectedOpen` 来复述「我开着吗」。\n *\n * 本模块的取舍:\n *\n * - 身份是**handle**而非 panel 元素:`release()` 不需要调用方回忆当初传了哪个 panel。\n * - 开启状态是**声明**而非询问:仲裁时不再回调宿主问 `isOpen()`,宿主只提供\n * `requestClose()` 与一个仅供惰性回收读取的 `isConnected()`;模块持有的真相即真相。\n * - `claim` 即「我开着」:一次 claim 对应一次开启,登记与仲裁是同一个动作。\n * - 在场分两态:开启中(Escape 走关闭入口)与退场中(panel 仍可见,暂缓仲裁、只作兜底\n * 候选)。后者由 `deferArbitration()` 进入、随 `release()`、重新 claim 或显式\n * `deferArbitration(false)` 离开:于是「退场被打断」与「宿主仍认为开着」不再把看得见\n * 的 panel 留在未登记状态,而退场已播完、宿主却仍认为开着的那一格,也不会让一个看不\n * 见的 panel 无限吞掉 Escape。\n * - 查询走 handle(`contains` / `containsEvent` / `hasFocusWithin`),调用方不再需要\n * 持有 panel 引用,也不知道存在全局注册表。\n * - 实例作用域与会话作用域分开:帧事务的 lifetime 比一次开合长(tooltip 跨断连\n * `resume()`),把它压进会话 handle 会在 `release()` 时误杀事务。\n *\n * 刻意**不**吸收 presence、滚动锁与 drawer 的层序(nested layers):它们回答的是\n * 视觉问题(`is-visible`、层序缩放),与「谁是最内层」正交,硬合并会互相污染。\n */\n\nimport { definePlugin } from '@greypan/js-kit'\n\n/**\n * 本层是否参与 Escape 仲裁。\n *\n * - `escape`:正常候选。命中即走宿主既有的用户关闭入口。\n * - `none`:只在逻辑组合树内,完全不参与仲裁,Escape 穿过它落到下层。用于 tooltip\n * —— 它今天就在树里(经 anchored panel 登记)但从不作为候选。\n *\n * 「暂时不可关闭」不在这里,而由 `setInert` 表达。静态与动态是同一件事的两面:\n * `no-escape-close` 这类策略看似静态,但它是**被观察的属性**,开启期间可被改写,\n * 所以同样要走动态通道(claim 后立即 `setInert(noEscapeClose)`)。把静态策略做成\n * 第三个枚举值只会产生一个没有真实调用方的成员。\n */\nexport type OverlayArbitration = 'escape' | 'none'\n\nexport interface OverlayClaimOptions {\n /** 默认 `escape`。 */\n arbitration?: OverlayArbitration\n /**\n * 沿该节点的祖先链寻找最近的已开启 overlay,作为本层的逻辑父级;缺省用 panel 自身。\n *\n * portal panel 与宿主物理分离,从 panel 自身向上走找不到组合父级,必须由调用方给出\n * 锚点(trigger)或宿主,才能把「谁在谁里面」判对。\n *\n * **顺序约束**:祖先链查找要求 panel 与 ancestryFrom 此刻都已就位(在 DOM 中)。\n * 本模块不做「等一帧再补登记」——那会让「已登记」与「已开启」短暂不等。\n */\n ancestryFrom?: Node\n}\n\nexport interface OverlayFrameHandle {\n cancel(): void\n}\n\n/** 会话作用域:一次开启对应一个 handle。 */\nexport interface OpenOverlayHandle {\n /**\n * 惰性:仍是候选(照旧 `preventDefault()` 压掉原生 `<dialog>` 的关闭请求),但不走\n * 关闭入口。这是「暂时不可关闭」的唯一通道,静态策略与瞬时状态都走这里。\n *\n * 不改变本层在树里的位置,也不改变 `seq` —— 因此不会影响并列 overlay 的裁决顺序。\n * `none` 层不受影响(它本就不是候选)。\n *\n * **与会话同 lifetime,新会话恒从 `false` 开始**:`claim` 建立的每一层都重置为\n * 非惰性。所以凡是「重新 claim」的路径(`anchored-panel.reconfigure`、同一 panel 重复\n * `open()`)之后,都必须按当前状态重推一次,否则惰性会被静默清掉。重推放在哪里取决于\n * 该组件在 reconfigure 后是否渲染:会渲染的(select / autocomplete 在 `updated()` 里\n * 同步)可以把紧随 claim 的那一行降级为备份,不会渲染的(popover)必须自己补。\n */\n setInert(inert: boolean): void\n /**\n * 进入退场暂缓态:panel 仍在场(可见),本层继续留在登记表里并吞掉 Escape,但不走\n * 关闭入口。`anchored-panel.close()` 在退场等待前调用它。\n *\n * 与 `setInert` 的分工:`inert` 是**调用方**对「暂时不可关闭」的声明,会随渲染被\n * 重推;本态由**生命周期**进入,调用方没有任何途径清掉它,只有 `release()` 与\n * 渲染重推它,只有 `release()`、「同一 panel 重新 claim」与下面那个 `false` 能让它\n * 消失。因此重新 claim 出来的新会话恒不暂缓,退场被打断后由新会话接管仲裁。\n *\n * 传 `false` 退出暂态。唯一的使用者是 `anchored-panel.close()`:退场已播完(panel\n * 已隐藏)而宿主仍认为开着时,把层交还仲裁——此刻 panel 并不在「可见但暂缓」的前提\n * 里,继续暂缓会让一个看不见的 panel 无限吞掉 Escape,用户再也没有按键路径把这个\n * 不一致的状态收敛掉。\n *\n * 暂缓层只是兜底候选:`resolve()` 里仍在开启的层永远优先。否则「内层关掉后立刻\n * 再按一次 Escape」会被退场动画吞掉,外层永远等不到自己那一次(issue #120)。\n */\n deferArbitration(deferred?: boolean): void\n /**\n * 把子层 panel 纳入本 handle 的子树(多级 submenu),默认不成为独立候选。\n *\n * 与 `claim` 的区别只在父级如何确定:`claim` 走祖先链,`adopt` 由调用方指名。\n *\n * 在已 `release()` 的 handle 上调用**不建层**、返回空操作 handle —— 否则会建出一个父层已死\n * 的孤层:它仍参与仲裁,却不在父层的子树里,除调用方手里那个 handle 外没有回收路径。\n */\n adopt(panel: HTMLElement, options?: OverlayClaimOptions): OpenOverlayHandle\n /** 本层子树(含已 adopt 的子层)是否包含 node。 */\n contains(node: Node): boolean\n /** event 的完整 composed path 是否落入本层子树。 */\n containsEvent(event: Event): boolean\n /** 焦点是否在本层子树内。 */\n hasFocusWithin(): boolean\n /** 幂等。撤销本层及其全部子层,退出仲裁。 */\n release(): void\n}\n\n/** 实例作用域:每个宿主组件一次,跨开合与断连存活。 */\nexport interface OpenOverlay {\n claim(panel: HTMLElement, options?: OverlayClaimOptions): OpenOverlayHandle\n /**\n * 帧事务调度。`whileLive` handle 一旦 release,回调自动作废 —— 取代原先由调用方\n * 手工传入 `expectedOpen` 的轮询(那个参数正是「模块还得回头问宿主」的证据)。\n */\n scheduleFrame(callback: () => void, whileLive?: OpenOverlayHandle): OverlayFrameHandle\n /** 结束当前事务:取消未执行的 frame,并让旧 generation 的回调失效。 */\n invalidate(): void\n /** 断连后停止调度(可 `resume()`);generation 继续单调递增,旧事务保持失效。 */\n suspend(): void\n resume(): void\n}\n\nexport interface OpenOverlayHost {\n /** 走宿主既有的用户关闭入口(内部负责 controlled 语义与 `open-change` 派发)。 */\n requestClose(): void\n /**\n * 宿主元素是否仍在文档中。只被惰性回收读取(见 `layers` 注册表处的注释),\n * 仲裁路径不回调它——读 DOM 连接状态不是回调宿主问「我开着吗」。\n */\n isConnected(): boolean\n}\n\ninterface Layer {\n panel: HTMLElement\n host: OpenOverlayHost\n arbitration: OverlayArbitration\n /** 动态惰性 toggle,与会话同 lifetime。 */\n inert: boolean\n /** 退场暂缓 switch:panel 仍在场,但只作兜底候选(见 `deferArbitration`)。 */\n deferred: boolean\n /** 登记序号:互不包含的并列 overlay 里,后开的更靠上。 */\n seq: number\n released: boolean\n}\n\n/*\n * 模块级注册表:单例 document 监听器需要一个全局视野,这正是「谁是最内层」不能被\n * 拆到各组件里的原因。\n *\n * `Layer` 强引用 panel 与 host,所以一条没被删掉的记录会让整个组件(含子树)无法回收。\n * 删除路径有两条:显式 `release()`,以及 `reclaimDisconnectedLayers()` 的惰性兜底\n * ——「宿主还在但组件被丢弃」这类漏 release 的路径,光靠 `isCandidate()` 筛掉不可达\n * 候选只会留下永久泄漏,还会让 `shouldListen` 恒为 true 把 document 监听留住。\n * 兜底判据取 panel 与 host **同时**失联:panel 可能被 portal 在容器间搬运,也可能只是\n * 短暂移除再放回(层对象比 DOM 连接活得久),只有两者都不在文档里才判定为死层。\n * 回收**不扫刚 claim 的新层**:调用方完全可能先 claim 再把 panel 与宿主挂进文档,\n * 那一刻两者都还没连上。所以 claim 路径的回收跑在建层之前(`createLayer` 开头),\n * 新层入表之后只做监听对账。\n * 读 DOM 连接状态不是回调宿主问「我开着吗」,不违反声明式的取舍。\n */\nconst layers = new Set<Layer>()\nconst layerByPanel = new WeakMap<HTMLElement, Layer>()\nconst layerByHandle = new WeakMap<OpenOverlayHandle, Layer>()\nconst parents = new WeakMap<HTMLElement, HTMLElement>()\nconst childPanels = new WeakMap<HTMLElement, Set<HTMLElement>>()\nlet listening = false\nlet sequence = 0\n\n/*\n * dev 期测试钩子:登记表尺寸。\n *\n * 「漏 release」是结构性风险,而 `layers` 的尺寸无法从公开 interface 观察——调用方\n * 只持有 handle,看不到全局注册表,测试于是只能靠 GC 语义推断。有了它,泄漏在测试里\n * 是一个可直接断言的数字。`import.meta.env.DEV` 是编译期常量,生产构建里整段消失。\n */\ndeclare global {\n // eslint-disable-next-line no-var\n var __openOverlayLayerCount: () => number\n}\n\nif (import.meta.env.DEV) {\n globalThis.__openOverlayLayerCount = () => layers.size\n}\n\n/*\n * 已失效 handle 的空操作替身,用于 `adopt` 在 released handle 上的返回值。其余 handle 方法在\n * released 时都静默降级,返回值也必须一起降级,否则调用方会以为自己拿到了一棵可用子树。\n */\nconst inactiveHandle: OpenOverlayHandle = {\n deferArbitration() {},\n setInert() {},\n adopt: () => inactiveHandle,\n contains: () => false,\n containsEvent: () => false,\n hasFocusWithin: () => false,\n release() {}\n}\n\nfunction nextCompositionNode(node: Node): Node | null {\n if (node instanceof Element && node.assignedSlot) return node.assignedSlot\n if (node.parentNode) return node.parentNode\n if (node instanceof ShadowRoot) return node.host\n return null\n}\n\nfunction nodeIsInside(root: HTMLElement, node: Node): boolean {\n let current: Node | null = node\n while (current) {\n if (current === root || root.contains(current)) return true\n current = nextCompositionNode(current)\n }\n return false\n}\n\nfunction detachFromParent(panel: HTMLElement): void {\n const parent = parents.get(panel)\n if (!parent) return\n const siblings = childPanels.get(parent)\n siblings?.delete(panel)\n if (siblings?.size === 0) childPanels.delete(parent)\n parents.delete(panel)\n}\n\nfunction attachToParent(panel: HTMLElement, parent: HTMLElement | undefined): void {\n detachFromParent(panel)\n if (!parent || parent === panel) return\n const siblings = childPanels.get(parent) ?? new Set<HTMLElement>()\n siblings.add(panel)\n childPanels.set(parent, siblings)\n parents.set(panel, parent)\n}\n\n/** panel 自身或其任何已登记子层是否包含 target。 */\nfunction containsPanel(panel: HTMLElement, target: Node): boolean {\n if (nodeIsInside(panel, target)) return true\n for (const child of childPanels.get(panel) ?? []) {\n if (containsPanel(child, target)) return true\n }\n return false\n}\n\nfunction hasFocusWithinPanel(panel: HTMLElement): boolean {\n if (panel.matches(':focus-within')) return true\n for (const child of childPanels.get(panel) ?? []) {\n if (hasFocusWithinPanel(child)) return true\n }\n return false\n}\n\n/** 从 from 沿祖先链找最近的已开启 overlay panel;panel 自身永远不算自己的父级。 */\nfunction findOpenAncestor(panel: HTMLElement, from: Node): HTMLElement | null {\n let current: Node | null = from === panel ? nextCompositionNode(from) : from\n while (current) {\n if (current instanceof HTMLElement && current !== panel) {\n const candidate = layerByPanel.get(current)\n if (candidate && !candidate.released) return current\n }\n current = nextCompositionNode(current)\n }\n return null\n}\n\nfunction releaseLayer(layer: Layer): void {\n if (layer.released) return\n layer.released = true\n for (const child of Array.from(childPanels.get(layer.panel) ?? [])) {\n const childLayer = layerByPanel.get(child)\n if (childLayer) releaseLayer(childLayer)\n else detachFromParent(child)\n }\n layers.delete(layer)\n layerByPanel.delete(layer.panel)\n childPanels.delete(layer.panel)\n detachFromParent(layer.panel)\n // 不在这里 syncListener:本函数被子层递归与兜底回收复用,监听器对账由调用方在\n // 整棵子树摘完后做一次即可。\n}\n\nfunction createLayer(input: {\n panel: HTMLElement\n host: OpenOverlayHost\n arbitration: OverlayArbitration\n parent?: HTMLElement\n}): OpenOverlayHandle {\n /*\n * 先摘死层,再建新层。claim/adopt 是完全合法的「先开启后挂载」时序——调用方那一刻\n * 可能还没把 panel 与宿主挂进文档,两者都失联。回收跑在新层入表**之前**,新层就不\n * 会被自己的 claim 判成死层;而漏 release 留下的死层依旧在这里被扫掉,与旧实现一致。\n */\n reclaimDisconnectedLayers()\n\n const previous = layerByPanel.get(input.panel)\n // 同一 panel 重新 claim = 新的一次开启:旧会话(含其子层)整体作废,seq 重新分配。\n if (previous) releaseLayer(previous)\n\n const layer: Layer = {\n panel: input.panel,\n host: input.host,\n arbitration: input.arbitration,\n inert: false,\n deferred: false,\n seq: ++sequence,\n released: false\n }\n layers.add(layer)\n layerByPanel.set(input.panel, layer)\n attachToParent(input.panel, input.parent)\n\n const handle: OpenOverlayHandle = {\n deferArbitration(deferred = true) {\n if (layer.released) return\n layer.deferred = deferred\n },\n\n setInert(inert) {\n if (layer.released) return\n layer.inert = inert\n },\n\n adopt(panel, options = {}) {\n if (layer.released) return inactiveHandle\n return createLayer({\n panel,\n host: layer.host,\n arbitration: options.arbitration ?? 'none',\n parent: layer.panel\n })\n },\n\n contains(node) {\n return !layer.released && containsPanel(layer.panel, node)\n },\n\n containsEvent(event) {\n return (\n !layer.released && event.composedPath().some(node => node instanceof Node && containsPanel(layer.panel, node))\n )\n },\n\n hasFocusWithin() {\n return !layer.released && hasFocusWithinPanel(layer.panel)\n },\n\n release() {\n releaseLayer(layer)\n syncListener()\n }\n }\n\n layerByHandle.set(handle, layer)\n // 不带回收:新层 panel 与宿主可能还没挂载,回收已在函数开头跑过(见 `reclaim` 参数)。\n syncListener(false)\n return handle\n}\n\nfunction isCandidate(layer: Layer): boolean {\n return !layer.released && layer.arbitration !== 'none' && layer.panel.isConnected\n}\n\n/** 在候选里取最内层:子树包含关系下的极大元;互不包含时取后开的。 */\nfunction pickInnermost(candidates: Layer[]): Layer | null {\n let best: Layer | null = null\n for (const layer of candidates) {\n if (!best) {\n best = layer\n continue\n }\n if (containsPanel(best.panel, layer.panel)) {\n best = layer\n continue\n }\n if (containsPanel(layer.panel, best.panel)) continue\n if (layer.seq > best.seq) best = layer\n }\n return best\n}\n\nfunction resolve(): Layer | null {\n // 兜底回收:每次仲裁先摘掉死层,漏 release 的组件不会把注册表和监听一起拖住。\n syncListener()\n\n const live: Layer[] = []\n const deferred: Layer[] = []\n for (const layer of layers) {\n if (!isCandidate(layer)) continue\n if (layer.deferred) deferred.push(layer)\n else live.push(layer)\n }\n\n /*\n * 暂缓层只是兜底候选:仍在开启的层永远优先。\n *\n * 反过来(暂缓层也按最内层取胜)会让「内层关掉后立刻再按一次 Escape」被退场动画吞掉\n * ——它已不再开启却仍占着名额,外层永远等不到自己那一次,正是 issue #120 的契约\n * (escape-ownership.browser.spec.ts 压住它)。第三态要补的缺口是另一件事:退场被\n * 打断、新会话 claim 之前,panel 还在场上却不再登记,此刻按 Escape 会关到外层。\n *\n * 与 `pickInnermost` 同样刻意**不按事件路径筛选**:drawer 打开、内部列表也打开、而焦点\n * 停在 drawer 上时,事件路径只命中 drawer,按路径判定会关掉外层、留下内层。正确语义是\n * 「最内层优先」,与焦点无关。\n */\n return pickInnermost(live) ?? pickInnermost(deferred)\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape') return\n // 已有更早的捕获监听处理过本次 Escape:不重复介入。\n if (event.defaultPrevented) return\n\n const layer = resolve()\n if (!layer) return\n\n /*\n * preventDefault 压掉原生 dialog 的关闭请求;stopPropagation 让组件自身的 handler\n * 不再执行。惰性层与暂缓层同样被吞掉按键,只是不走关闭入口 —— 否则事件会落到下层\n * overlay,关闭顺序与用户预期相反。\n *\n * 已知边界:stopPropagation 不阻止**同一节点**(document)上后注册的其他捕获监听。\n * 刻意不用 stopImmediatePropagation —— 那会连带掐掉应用自己在 document 上的捕获\n * 监听,超出本模块职责。\n */\n event.preventDefault()\n event.stopPropagation()\n // 惰性与暂缓都吞掉按键,只是不走关闭入口。\n if (layer.inert || layer.deferred) return\n layer.host.requestClose()\n}\n\n/**\n * panel 与 host 同时失联的层是死层:显式 release 之外的兜底删除路径。\n *\n * 惰性体现在只在遍历登记表的入口跑(建层之前、仲裁、release),不挂 DOM 变动回调 ——\n * panel 短暂移除再放回必须不丢登记。建层**之后**不跑:新层可能还没挂载,扫它就会误杀\n * (见 `createLayer` 开头与 `layers` 注册表处的注释)。\n */\nfunction reclaimDisconnectedLayers(): void {\n for (const layer of Array.from(layers)) {\n if (layer.released || layer.panel.isConnected || layer.host.isConnected()) continue\n releaseLayer(layer)\n }\n}\n\n/**\n * 监听器对账:惰性回收死层,再按登记表重算 document 监听。\n *\n * `reclaim` 关掉回收,只给 `createLayer` 建层之后那一次调用用:新层刚入注册表,调用方\n * 可能还没把 panel 与宿主挂进文档,那一刻扫它等于把新层判成死层。死层不会因此漏掉——\n * 回收在建层之前已经单独跑过一轮(见 `createLayer` 开头)。\n */\nfunction syncListener(reclaim = true): void {\n if (reclaim) reclaimDisconnectedLayers()\n\n let shouldListen = false\n for (const layer of layers) {\n /*\n * 未 release 的非 `none` 层就是监听理由,**不附加连接判据**:刚 claim 而尚未挂载的\n * 层要靠这条把监听开着——panel 挂上之后没有任何入口(不挂 DOM 变动回调)把它加回来,\n * 监听一撤这一层就再也听不见 Escape。死层的截杀交给上面的回收:它在每个带回收的\n * 入口先跑一步,轮到这里时同时失联的层已经不在表里,这条判据因此不会把死层留下来。\n */\n if (!layer.released && layer.arbitration !== 'none') {\n shouldListen = true\n break\n }\n }\n if (shouldListen === listening) return\n listening = shouldListen\n if (shouldListen) document.addEventListener('keydown', handleKeydown, true)\n else document.removeEventListener('keydown', handleKeydown, true)\n}\n\nexport const defineOpenOverlay = () =>\n definePlugin<OpenOverlay, OpenOverlayHost>(host => {\n let generation = 0\n let suspended = false\n const frames = new Set<OverlayFrameHandle & { id: number; generation: number }>()\n\n const cancelFrame = (frame: OverlayFrameHandle & { id: number; generation: number }) => {\n cancelAnimationFrame(frame.id)\n frames.delete(frame)\n }\n\n const cancelFrames = () => {\n for (const frame of Array.from(frames)) cancelFrame(frame)\n }\n\n const invalidateCurrent = () => {\n generation += 1\n cancelFrames()\n }\n\n const api: OpenOverlay = {\n claim(panel, options = {}) {\n const arbitration = options.arbitration ?? 'escape'\n const ancestryFrom = options.ancestryFrom ?? panel\n return createLayer({\n panel,\n host,\n arbitration,\n parent: findOpenAncestor(panel, ancestryFrom) ?? undefined\n })\n },\n\n scheduleFrame(callback, whileLive) {\n if (suspended) return { cancel() {} }\n\n const scheduledGeneration = generation\n const frame: OverlayFrameHandle & { id: number; generation: number } = {\n generation: scheduledGeneration,\n id: requestAnimationFrame(() => {\n frames.delete(frame)\n if (suspended || scheduledGeneration !== generation) return\n // 会话已结束:该帧属于上一轮开合,丢弃而不是执行在错误的层上。\n // handle 已 release,或同一 panel 重新 claim 换了新 handle(旧层仍在 WeakMap 里,\n // 标记为 released),两种情形都要丢。\n if (whileLive) {\n const liveLayer = layerByHandle.get(whileLive)\n if (!liveLayer || liveLayer.released) return\n }\n callback()\n }),\n cancel() {\n cancelFrame(frame)\n }\n }\n frames.add(frame)\n return frame\n },\n\n invalidate: invalidateCurrent,\n\n suspend() {\n if (suspended) return\n suspended = true\n // 停调度之后再撤销在途帧(两者互不依赖,顺序不影响语义)。不能只靠回调内部的\n // suspended 复查:那会让这些帧白占一个 rAF 周期(旧实现在此处因早退而没有取消)。\n cancelFrames()\n },\n\n resume() {\n if (!suspended) return\n suspended = false\n generation += 1\n }\n }\n\n return api\n })\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import e from "../../assets/glass.js";
1
+ import e from "../../classes/glass.js";
2
2
  import t from "../../assets/overlay-motion.js";
3
3
  import n from "../../scrollbars/internal.js";
4
4
  import r from "../../assets/menu-portal.js";
@@ -1 +1 @@
1
- {"version":3,"file":"overlay-root.js","names":[],"sources":["../../../src/shared/overlay/overlay-root.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 toastContainerStyle from '@/assets/toast-containers.css?inline'\nimport internalScrollbars from '@/scrollbars/internal.css?inline'\n\n/**\n * Theme-owned overlay root plumbing: applies shared overlay styles to a theme\n * root and lazily creates the document-level fallback overlay root.\n */\nconst FALLBACK_ROOT_ATTRIBUTE = 'data-wui-overlay-root'\n\nconst overlayStyle = `${glass}\\n${toastContainerStyle}\\n${menuPortalStyle}\\n${overlayMotion}\\n${internalScrollbars}`\n\nexport function applyOverlayRootStyles(root: ShadowRoot) {\n const style = document.createElement('style')\n style.textContent = overlayStyle\n root.prepend(style)\n}\n\nexport function getFallbackOverlayRoot(): HTMLElement {\n const existing = document.querySelector<HTMLElement>(`[${FALLBACK_ROOT_ATTRIBUTE}]`)\n if (existing?.shadowRoot) return getOverlayContainer(existing.shadowRoot)\n\n const host = document.createElement('div')\n host.setAttribute(FALLBACK_ROOT_ATTRIBUTE, '')\n const root = host.attachShadow({ mode: 'open' })\n applyOverlayRootStyles(root)\n document.body.appendChild(host)\n return getOverlayContainer(root)\n}\n\nexport function getOverlayContainer(root: ShadowRoot): HTMLElement {\n const existing = root.querySelector<HTMLElement>('[data-wui-overlay-container]')\n if (existing) return existing\n\n const container = document.createElement('div')\n container.setAttribute('data-wui-overlay-container', '')\n root.appendChild(container)\n return container\n}\n"],"mappings":";;;;;;AAUA,IAAM,IAA0B,yBAE1B,IAAe,GAAG,EAAM,IAAI,EAAoB,IAAI,EAAgB,IAAI,EAAc,IAAI;AAEhG,SAAgB,EAAuB,GAAkB;CACvD,IAAM,IAAQ,SAAS,cAAc,OAAO;CAE5C,AADA,EAAM,cAAc,GACpB,EAAK,QAAQ,CAAK;AACpB;AAEA,SAAgB,IAAsC;CACpD,IAAM,IAAW,SAAS,cAA2B,IAAI,EAAwB,EAAE;CACnF,IAAI,GAAU,YAAY,OAAO,EAAoB,EAAS,UAAU;CAExE,IAAM,IAAO,SAAS,cAAc,KAAK;CACzC,EAAK,aAAa,GAAyB,EAAE;CAC7C,IAAM,IAAO,EAAK,aAAa,EAAE,MAAM,OAAO,CAAC;CAG/C,OAFA,EAAuB,CAAI,GAC3B,SAAS,KAAK,YAAY,CAAI,GACvB,EAAoB,CAAI;AACjC;AAEA,SAAgB,EAAoB,GAA+B;CACjE,IAAM,IAAW,EAAK,cAA2B,8BAA8B;CAC/E,IAAI,GAAU,OAAO;CAErB,IAAM,IAAY,SAAS,cAAc,KAAK;CAG9C,OAFA,EAAU,aAAa,8BAA8B,EAAE,GACvD,EAAK,YAAY,CAAS,GACnB;AACT"}
1
+ {"version":3,"file":"overlay-root.js","names":[],"sources":["../../../src/shared/overlay/overlay-root.ts"],"sourcesContent":["import menuPortalStyle from '@/assets/menu-portal.css?inline'\nimport overlayMotion from '@/assets/overlay-motion.css?inline'\nimport toastContainerStyle from '@/assets/toast-containers.css?inline'\nimport glass from '@/classes/glass.css?inline'\nimport internalScrollbars from '@/scrollbars/internal.css?inline'\n\n/**\n * Theme-owned overlay root plumbing: applies shared overlay styles to a theme\n * root and lazily creates the document-level fallback overlay root.\n */\nconst FALLBACK_ROOT_ATTRIBUTE = 'data-wui-overlay-root'\n\nconst overlayStyle = `${glass}\\n${toastContainerStyle}\\n${menuPortalStyle}\\n${overlayMotion}\\n${internalScrollbars}`\n\nexport function applyOverlayRootStyles(root: ShadowRoot) {\n const style = document.createElement('style')\n style.textContent = overlayStyle\n root.prepend(style)\n}\n\nexport function getFallbackOverlayRoot(): HTMLElement {\n const existing = document.querySelector<HTMLElement>(`[${FALLBACK_ROOT_ATTRIBUTE}]`)\n if (existing?.shadowRoot) return getOverlayContainer(existing.shadowRoot)\n\n const host = document.createElement('div')\n host.setAttribute(FALLBACK_ROOT_ATTRIBUTE, '')\n const root = host.attachShadow({ mode: 'open' })\n applyOverlayRootStyles(root)\n document.body.appendChild(host)\n return getOverlayContainer(root)\n}\n\nexport function getOverlayContainer(root: ShadowRoot): HTMLElement {\n const existing = root.querySelector<HTMLElement>('[data-wui-overlay-container]')\n if (existing) return existing\n\n const container = document.createElement('div')\n container.setAttribute('data-wui-overlay-container', '')\n root.appendChild(container)\n return container\n}\n"],"mappings":";;;;;;AAUA,IAAM,IAA0B,yBAE1B,IAAe,GAAG,EAAM,IAAI,EAAoB,IAAI,EAAgB,IAAI,EAAc,IAAI;AAEhG,SAAgB,EAAuB,GAAkB;CACvD,IAAM,IAAQ,SAAS,cAAc,OAAO;CAE5C,AADA,EAAM,cAAc,GACpB,EAAK,QAAQ,CAAK;AACpB;AAEA,SAAgB,IAAsC;CACpD,IAAM,IAAW,SAAS,cAA2B,IAAI,EAAwB,EAAE;CACnF,IAAI,GAAU,YAAY,OAAO,EAAoB,EAAS,UAAU;CAExE,IAAM,IAAO,SAAS,cAAc,KAAK;CACzC,EAAK,aAAa,GAAyB,EAAE;CAC7C,IAAM,IAAO,EAAK,aAAa,EAAE,MAAM,OAAO,CAAC;CAG/C,OAFA,EAAuB,CAAI,GAC3B,SAAS,KAAK,YAAY,CAAI,GACvB,EAAoB,CAAI;AACjC;AAEA,SAAgB,EAAoB,GAA+B;CACjE,IAAM,IAAW,EAAK,cAA2B,8BAA8B;CAC/E,IAAI,GAAU,OAAO;CAErB,IAAM,IAAY,SAAS,cAAc,KAAK;CAG9C,OAFA,EAAU,aAAa,8BAA8B,EAAE,GACvD,EAAK,YAAY,CAAS,GACnB;AACT"}
@@ -34,7 +34,13 @@ function p(e, t, n, r, i) {
34
34
  }
35
35
  function m(e, t, n, r, s) {
36
36
  let c = [a(t.offset)];
37
- t.flip && c.push(i()), t.shift && c.push(o({ padding: 8 }));
37
+ if (t.flip && c.push(i()), t.shift) {
38
+ let e = t.placement.split("-")[0];
39
+ c.push(o({
40
+ padding: 8,
41
+ crossAxis: e === "left" || e === "right"
42
+ }));
43
+ }
38
44
  let l = p(e, t, n, r, s);
39
45
  return l && c.push(l), c;
40
46
  }