@greypan/web-ui 10.2.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.CN.md +68 -68
  2. package/README.md +43 -43
  3. package/dist/assets/glass.js.map +1 -1
  4. package/dist/assets/menu-portal.js.map +1 -1
  5. package/dist/assets/modal-surface.js.map +1 -1
  6. package/dist/assets/overlay-motion.js.map +1 -1
  7. package/dist/assets/selection-control.js.map +1 -1
  8. package/dist/components/autocomplete/index.d.ts +6 -6
  9. package/dist/components/autocomplete/index.js +1 -1
  10. package/dist/components/autocomplete/index.js.map +1 -1
  11. package/dist/components/autocomplete/style.js.map +1 -1
  12. package/dist/components/avatar/index.js.map +1 -1
  13. package/dist/components/back-top/index.js.map +1 -1
  14. package/dist/components/back-top/style.js.map +1 -1
  15. package/dist/components/badge/index.js +1 -1
  16. package/dist/components/badge/index.js.map +1 -1
  17. package/dist/components/button/index.js.map +1 -1
  18. package/dist/components/button/style.js.map +1 -1
  19. package/dist/components/checkbox/index.d.ts +1 -1
  20. package/dist/components/checkbox/index.js +1 -1
  21. package/dist/components/checkbox/index.js.map +1 -1
  22. package/dist/components/checkbox/style.js.map +1 -1
  23. package/dist/components/checkbox-group/index.d.ts +9 -0
  24. package/dist/components/checkbox-group/index.js +8 -2
  25. package/dist/components/checkbox-group/index.js.map +1 -1
  26. package/dist/components/collapse/index.d.ts +5 -5
  27. package/dist/components/collapse/index.js.map +1 -1
  28. package/dist/components/collapse/style.js.map +1 -1
  29. package/dist/components/context-menu/index.d.ts +60 -60
  30. package/dist/components/context-menu/index.js +2 -2
  31. package/dist/components/context-menu/index.js.map +1 -1
  32. package/dist/components/context-menu/scrim.js.map +1 -1
  33. package/dist/components/context-menu/style.js.map +1 -1
  34. package/dist/components/dialog/index.d.ts +4 -4
  35. package/dist/components/dialog/index.js +1 -1
  36. package/dist/components/dialog/index.js.map +1 -1
  37. package/dist/components/dialog/style.js.map +1 -1
  38. package/dist/components/drawer/index.d.ts +8 -8
  39. package/dist/components/drawer/index.js +1 -1
  40. package/dist/components/drawer/index.js.map +1 -1
  41. package/dist/components/drawer/style.js.map +1 -1
  42. package/dist/components/dropdown/index.d.ts +6 -6
  43. package/dist/components/dropdown/index.js.map +1 -1
  44. package/dist/components/dropdown-item/index.js.map +1 -1
  45. package/dist/components/editable-text/index.d.ts +2 -2
  46. package/dist/components/editable-text/index.js.map +1 -1
  47. package/dist/components/editable-text/style.js.map +1 -1
  48. package/dist/components/empty/index.d.ts +3 -3
  49. package/dist/components/empty/index.js.map +1 -1
  50. package/dist/components/image-preview/index.d.ts +5 -5
  51. package/dist/components/image-preview/index.js +7 -7
  52. package/dist/components/image-preview/index.js.map +1 -1
  53. package/dist/components/image-preview/style.js.map +1 -1
  54. package/dist/components/input/index.js +4 -1
  55. package/dist/components/input/index.js.map +1 -1
  56. package/dist/components/input/style.js.map +1 -1
  57. package/dist/components/input-number/index.d.ts +6 -0
  58. package/dist/components/input-number/index.js +27 -21
  59. package/dist/components/input-number/index.js.map +1 -1
  60. package/dist/components/layout/index.d.ts +7 -7
  61. package/dist/components/layout/index.js +4 -4
  62. package/dist/components/layout/index.js.map +1 -1
  63. package/dist/components/layout/style.js.map +1 -1
  64. package/dist/components/middle-ellipsis/index.d.ts +1 -1
  65. package/dist/components/middle-ellipsis/index.js.map +1 -1
  66. package/dist/components/middle-ellipsis/truncate.d.ts +1 -1
  67. package/dist/components/middle-ellipsis/truncate.js.map +1 -1
  68. package/dist/components/popover/index.d.ts +1 -1
  69. package/dist/components/popover/index.js.map +1 -1
  70. package/dist/components/popover/style.js.map +1 -1
  71. package/dist/components/radio/index.d.ts +1 -1
  72. package/dist/components/radio/index.js +1 -1
  73. package/dist/components/radio/index.js.map +1 -1
  74. package/dist/components/radio-group/index.d.ts +7 -0
  75. package/dist/components/radio-group/index.js +21 -3
  76. package/dist/components/radio-group/index.js.map +1 -1
  77. package/dist/components/segmented/index.js.map +1 -1
  78. package/dist/components/segmented/style.js.map +1 -1
  79. package/dist/components/segmented-trigger/style.js.map +1 -1
  80. package/dist/components/select/index.js +1 -1
  81. package/dist/components/select/index.js.map +1 -1
  82. package/dist/components/select/style.js.map +1 -1
  83. package/dist/components/slider/index.js +3 -3
  84. package/dist/components/slider/index.js.map +1 -1
  85. package/dist/components/slider/style.js +1 -1
  86. package/dist/components/slider/style.js.map +1 -1
  87. package/dist/components/spinner/index.js +1 -1
  88. package/dist/components/spinner/index.js.map +1 -1
  89. package/dist/components/switch/index.d.ts +3 -0
  90. package/dist/components/switch/index.js +26 -8
  91. package/dist/components/switch/index.js.map +1 -1
  92. package/dist/components/switch/style.js.map +1 -1
  93. package/dist/components/textarea/index.js +1 -1
  94. package/dist/components/textarea/index.js.map +1 -1
  95. package/dist/components/textarea/style.js.map +1 -1
  96. package/dist/components/theme/index.js.map +1 -1
  97. package/dist/components/theme/style.js.map +1 -1
  98. package/dist/components/toast/style.js.map +1 -1
  99. package/dist/components/toast/toast.js +1 -1
  100. package/dist/components/toast/toast.js.map +1 -1
  101. package/dist/components/tooltip/index.d.ts +1 -1
  102. package/dist/components/tooltip/index.js.map +1 -1
  103. package/dist/components/tooltip/style.js.map +1 -1
  104. package/dist/scrollbars/index.d.ts +8 -8
  105. package/dist/scrollbars/index.js.map +1 -1
  106. package/dist/scrollbars/internal.js.map +1 -1
  107. package/dist/shared/element-height/index.js.map +1 -1
  108. package/dist/shared/focus/pointer-focus.js.map +1 -1
  109. package/dist/shared/group-management/index.js +5 -1
  110. package/dist/shared/group-management/index.js.map +1 -1
  111. package/dist/shared/label-emptiness/index.js.map +1 -1
  112. package/dist/shared/menu-behavior/index.js.map +1 -1
  113. package/dist/shared/menu-portal/menu-portal.js.map +1 -1
  114. package/dist/shared/menu-portal/menu-tree.js.map +1 -1
  115. package/dist/shared/option-portal/combobox-shell.js.map +1 -1
  116. package/dist/shared/option-portal/index.js.map +1 -1
  117. package/dist/shared/overlay/anchored-panel.js.map +1 -1
  118. package/dist/shared/overlay/native-dialog-presence.js.map +1 -1
  119. package/dist/shared/overlay/nested-drawer-layers.js.map +1 -1
  120. package/dist/shared/overlay/open-overlay.js.map +1 -1
  121. package/dist/shared/overlay/overlay.js.map +1 -1
  122. package/dist/shared/overlay/portal.js.map +1 -1
  123. package/dist/shared/overlay/presence.js.map +1 -1
  124. package/dist/shared/scroll-lock/scroll-lock.js.map +1 -1
  125. package/dist/shared/theme/root-sync.js.map +1 -1
  126. package/package.json +2 -2
  127. package/dist/icons/generated/heroicons-arrow-down-16-solid.d.ts +0 -6
  128. package/dist/icons/generated/heroicons-arrow-up-16-solid.d.ts +0 -6
  129. package/dist/icons/generated/jam-volume.d.ts +0 -8
  130. package/dist/icons/generated/jam-watch-f.d.ts +0 -8
  131. package/dist/icons/generated/ooui-check.d.ts +0 -6
  132. package/dist/icons/generated/radix-icons-check.d.ts +0 -6
  133. package/dist/icons/generated/si-check-alt-fill.d.ts +0 -6
@@ -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":"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"}
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":";;AAyBA,IAAM,IAAiB,sCACjB,IAAe,KACR,IAAqB,iCAOrB,IAA4B,MAKnC,IAAyB,oBAOzB,oBAAU,IAAI,IAAuB,GAEvC,IAA2B,IAC3B,IAAuC;AAE3C,SAAS,EAAa,GAAmC;CACvD,IAAM,IAAM,iBAAiB,CAAM,CAAC,CAAC,iBAAiB,CAAkB,GAClE,IAAS,OAAO,WAAW,CAAG;CACpC,OAAO,OAAO,SAAS,CAAM,IAAI,IAAS;AAC5C;AAEA,SAAS,IAAuB;CAC9B,AAAI,EAAQ,OAAO,KAAG,EAAY;AACpC;AAEA,SAAS,IAAc;CACrB,IAAM,IAID,CAAC;CAEN,KAAK,IAAM,KAAS,GAAS;EAC3B,IAAM,IAAS,EAAM;EACrB,IAAI,CAAC,EAAO,eAAe,CAAC,EAAO,MAAM;GACvC,EAAQ,OAAO,CAAK;GACpB;EACF;EACA,IAAM,IACJ,EAAM,UAAU,MAAM,UAAU,EAAM,UAAU,MAAM,UAAU,EAAO,cAAc,EAAO;EAC9F,EAAY,KAAK;GAAE;GAAO;GAAQ;EAAK,CAAC;CAC1C;CAEA,IAAM,IAAQ,EAAY;CAC1B,KAAK,IAAI,IAAI,GAAG,IAAI,GAAO,KAAK;EAC9B,IAAM,EAAE,WAAQ,YAAS,EAAY,IAG/B,IAAQ,IAAQ,IAAI,GACpB,IAAiB,MAAc,GAC/B,IAAU,KAAQ,IAAI,KAAU,GAGlC,IAAe;EACnB,KAAK,IAAI,IAAI,IAAI,GAAG,IAAI,GAAO,KAC7B,AAAI,EAAY,EAAE,CAAC,OAAO,MACxB,IAAe,EAAY,EAAE,CAAC;EAGlC,IAAM,IAAW,KAAK,IAAI,GAAG,IAAe,CAAI,GAE1C,IAAO,EAAa,CAAM,IAAI,KAAK,IAAI,IAAQ,CAAC,GAEhD,IAAQ,IAAQ,IAAI,IAAS,IAAW,IAAO;EAKrD,AAHA,EAAO,MAAM,YAAY,GAAgB,OAAO,CAAK,CAAC,GACtD,EAAO,MAAM,YAAY,sCAAsC,EAAM,QAAQ,CAAC,CAAC,GAC/E,EAAO,MAAM,YAAY,sCAAsC,GAAG,EAAM,QAAQ,CAAC,EAAE,GAAG,GAClF,IAAQ,IACV,EAAO,UAAU,IAAI,iBAAiB,IAEtC,EAAO,UAAU,OAAO,iBAAiB;CAE7C;AACF;AAEA,SAAS,EAAqB,GAAc;CACpC,EAAM,kBAAkB,qBAE1B,EAAQ,OAAO,KAAG,EAAY;AACpC;AAEA,SAAS,IAAyB;CAC5B,AAOJ,OANA,SAAS,iBAAiB,SAAS,GAAsB,EAAI,GAEzD,OAAO,OAAO,cAAe,eAC/B,IAAgB,OAAO,WAAW,CAAsB,GACxD,EAAc,iBAAiB,UAAU,CAAoB,IAEpC;AAC7B;AAeA,IAAa,UACX,GAA6D,OAAQ;CACnE,WAAW;EACT,IAAM,IAAS,EAAI,UAAU;EACxB,OAGL;QAAK,IAAM,KAAS,GAClB,AAAI,EAAM,WAAW,KAAQ,EAAQ,OAAO,CAAK;GAInD,AAFA,EAAuB,GACvB,EAAQ,IAAI;IAAE;IAAQ,iBAAiB,EAAI,aAAa;GAAE,CAAC,GAC3D,EAAY;EAJuC;CAKrD;CAEA,aAAa;EACX,IAAM,IAAS,EAAI,UAAU;EAC7B,KAAK,IAAM,KAAS,GAClB,AAAI,EAAM,WAAW,KAAQ,EAAQ,OAAO,CAAK;EAGnD,AAAI,EAAQ,OAAO,KAAG,EAAY;CACpC;CAEA,UAAU;EACR,IAAM,IAAS,EAAI,UAAU;EAC7B,KAAK,IAAM,KAAS,GAClB,AAAI,EAAM,WAAW,KAAQ,EAAQ,OAAO,CAAK;EAEnD,AAAI,EAAQ,SAAS,KAAK,MACxB,SAAS,oBAAoB,SAAS,GAAsB,EAAI,GAChE,GAAe,oBAAoB,UAAU,CAAoB,GACjE,IAAgB,MAChB,IAA2B;CAE/B;AACF,EAAE"}
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 +1 @@
1
- {"version":3,"file":"overlay.js","names":[],"sources":["../../../src/shared/overlay/overlay.ts"],"sourcesContent":["import {\n autoUpdate,\n computePosition,\n flip,\n offset,\n shift,\n size,\n type Middleware,\n type Placement,\n type Strategy\n} from '@floating-ui/dom'\nimport { definePlugin } from '@greypan/js-kit'\n\nimport { defineOverlayPositioningGeneration, type OverlayPositioningGeneration } from './positioning-generation'\n\nexport interface OverlayOptions {\n placement?: Placement\n offset?: number\n flip?: boolean\n shift?: boolean\n /** 面板宽度与锚点宽度保持一致。 */\n matchWidth?: boolean\n /** 面板至少与锚点同宽,同时允许内容将面板撑宽。 */\n minAnchorWidth?: boolean\n strategy?: Strategy\n}\n\n/** 虚拟锚点:无对应 DOM 元素的定位基准。context-menu 未走 defineOverlay(定位语义分歧见 ADR-0038)。 */\nexport interface OverlayVirtualAnchor {\n getBoundingClientRect(): DOMRect\n}\n\nexport type OverlayAnchor = HTMLElement | OverlayVirtualAnchor\n\nexport interface OverlayApi {\n isOpen(): boolean\n readonly options: Required<OverlayOptions>\n open(): void\n close(): void\n toggle(): void\n update(options: Partial<OverlayOptions>): void\n updateAnchor(anchor: OverlayAnchor): void\n dispose(): void\n}\n\nconst DEFAULT_OVERLAY_MIN_WIDTH = 120\nconst OVERLAY_MIN_WIDTH_VARIABLE = '--wui-overlay-min-width'\n\nconst DEFAULT_OPTIONS: Required<OverlayOptions> = {\n placement: 'bottom-start',\n offset: 4,\n flip: true,\n shift: true,\n matchWidth: false,\n minAnchorWidth: false,\n strategy: 'absolute'\n}\n\ninterface WidthStyleState {\n hasManagedStyles: boolean\n}\n\nfunction readOverlayMinWidth(overlay: HTMLElement): number {\n if (typeof getComputedStyle !== 'function') return DEFAULT_OVERLAY_MIN_WIDTH\n\n const minWidth = Number.parseFloat(getComputedStyle(overlay).getPropertyValue(OVERLAY_MIN_WIDTH_VARIABLE))\n return Number.isFinite(minWidth) ? minWidth : DEFAULT_OVERLAY_MIN_WIDTH\n}\n\nfunction clearManagedWidthStyles(overlay: HTMLElement) {\n overlay.style.removeProperty('width')\n overlay.style.removeProperty('min-width')\n}\n\nfunction createWidthMiddleware(\n overlay: HTMLElement,\n options: Required<OverlayOptions>,\n widthStyles: WidthStyleState,\n positioningGeneration: OverlayPositioningGeneration,\n generation: number\n): Middleware | undefined {\n const isCurrentGeneration = () => positioningGeneration.isCurrent(generation)\n if (options.matchWidth) {\n // matchWidth 必须移除之前 minAnchorWidth 写入的 min-width,才能精确跟随 trigger。\n overlay.style.removeProperty('min-width')\n widthStyles.hasManagedStyles = true\n return size({\n apply({ rects }) {\n if (!isCurrentGeneration()) return\n overlay.style.width = `${rects.reference.width}px`\n }\n })\n }\n\n if (options.minAnchorWidth) {\n const minWidth = readOverlayMinWidth(overlay)\n widthStyles.hasManagedStyles = true\n return size({\n apply({ rects }) {\n if (!isCurrentGeneration()) return\n // max-content 让内容决定宽度,min-width 再提供 trigger 与 floor 两个下限。\n overlay.style.width = 'max-content'\n overlay.style.minWidth = `${Math.max(rects.reference.width, minWidth)}px`\n }\n })\n }\n\n if (widthStyles.hasManagedStyles) {\n clearManagedWidthStyles(overlay)\n widthStyles.hasManagedStyles = false\n }\n\n return undefined\n}\n\nfunction createPositioningMiddleware(\n overlay: HTMLElement,\n options: Required<OverlayOptions>,\n widthStyles: WidthStyleState,\n positioningGeneration: OverlayPositioningGeneration,\n generation: number\n): Middleware[] {\n const middleware: Middleware[] = [offset(options.offset)]\n if (options.flip) middleware.push(flip())\n if (options.shift) middleware.push(shift({ padding: 8 }))\n\n const widthMiddleware = createWidthMiddleware(overlay, options, widthStyles, positioningGeneration, generation)\n if (widthMiddleware) middleware.push(widthMiddleware)\n\n return middleware\n}\n\nfunction getTransformOrigin(placement: Placement): string {\n const [side, alignment] = placement.split('-')\n const horizontalOrigin = alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'\n const verticalOrigin = alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'\n\n if (side === 'top') return `bottom ${horizontalOrigin}`\n if (side === 'bottom') return `top ${horizontalOrigin}`\n if (side === 'left') return `right ${verticalOrigin}`\n return `left ${verticalOrigin}`\n}\n\nexport const defineOverlay = () =>\n definePlugin<OverlayApi, { anchor: OverlayAnchor; overlay: HTMLElement } & OverlayOptions>(ctx => {\n const options: Required<OverlayOptions> = {\n placement: ctx.placement ?? DEFAULT_OPTIONS.placement,\n offset: ctx.offset ?? DEFAULT_OPTIONS.offset,\n flip: ctx.flip ?? DEFAULT_OPTIONS.flip,\n shift: ctx.shift ?? DEFAULT_OPTIONS.shift,\n matchWidth: ctx.matchWidth ?? DEFAULT_OPTIONS.matchWidth,\n minAnchorWidth: ctx.minAnchorWidth ?? DEFAULT_OPTIONS.minAnchorWidth,\n strategy: ctx.strategy ?? DEFAULT_OPTIONS.strategy\n }\n\n const overlay = ctx.overlay\n const widthStyles: WidthStyleState = { hasManagedStyles: false }\n let isOpen = false\n let cleanupAutoUpdate: (() => void) | null = null\n let currentAnchor = ctx.anchor\n // 每次刷新都是新定位代;open 期间 autoUpdate 的多次回调属于同一代。\n // reposition/close 会让上一代 promise 失效,避免乱序完成覆盖最新坐标。\n const positioningGeneration = defineOverlayPositioningGeneration().make()\n\n function updatePosition() {\n const generation = positioningGeneration.next()\n const middleware = createPositioningMiddleware(overlay, options, widthStyles, positioningGeneration, generation)\n\n const applyPosition = () => {\n void computePosition(currentAnchor, overlay, {\n placement: options.placement,\n strategy: options.strategy,\n middleware\n }).then(({ x, y, placement }) => {\n if (!positioningGeneration.isCurrent(generation) || !isOpen) return\n overlay.style.left = `${x}px`\n overlay.style.top = `${y}px`\n overlay.style.setProperty('--wui-internal-overlay-transform-origin', getTransformOrigin(placement))\n })\n }\n\n // 立即定位一次,避免浮层首帧出现在左上角;autoUpdate 负责后续滚动/尺寸变化。\n applyPosition()\n cleanupAutoUpdate?.()\n cleanupAutoUpdate = autoUpdate(currentAnchor, overlay, applyPosition)\n }\n\n return {\n isOpen() {\n return isOpen\n },\n options,\n\n open() {\n if (isOpen) return\n isOpen = true\n overlay.style.display = ''\n updatePosition()\n },\n\n close() {\n if (!isOpen) return\n isOpen = false\n positioningGeneration.invalidate()\n cleanupAutoUpdate?.()\n cleanupAutoUpdate = null\n },\n\n toggle() {\n if (isOpen) this.close()\n else this.open()\n },\n\n update(nextOptions) {\n Object.assign(options, nextOptions)\n if (isOpen) updatePosition()\n },\n\n updateAnchor(newAnchor: OverlayAnchor) {\n currentAnchor = newAnchor\n if (isOpen) updatePosition()\n },\n\n dispose() {\n this.close()\n }\n }\n })\n"],"mappings":"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"}
1
+ {"version":3,"file":"overlay.js","names":[],"sources":["../../../src/shared/overlay/overlay.ts"],"sourcesContent":["import {\n autoUpdate,\n computePosition,\n flip,\n offset,\n shift,\n size,\n type Middleware,\n type Placement,\n type Strategy\n} from '@floating-ui/dom'\nimport { definePlugin } from '@greypan/js-kit'\n\nimport { defineOverlayPositioningGeneration, type OverlayPositioningGeneration } from './positioning-generation'\n\nexport interface OverlayOptions {\n placement?: Placement\n offset?: number\n flip?: boolean\n shift?: boolean\n /** panel 宽度与锚点宽度保持一致。 */\n matchWidth?: boolean\n /** panel 至少与锚点同宽,同时允许内容将 panel 撑宽。 */\n minAnchorWidth?: boolean\n strategy?: Strategy\n}\n\n/** 虚拟锚点:无对应 DOM 元素的定位基准。context-menu 未走 defineOverlay(定位语义分歧见 ADR-0038)。 */\nexport interface OverlayVirtualAnchor {\n getBoundingClientRect(): DOMRect\n}\n\nexport type OverlayAnchor = HTMLElement | OverlayVirtualAnchor\n\nexport interface OverlayApi {\n isOpen(): boolean\n readonly options: Required<OverlayOptions>\n open(): void\n close(): void\n toggle(): void\n update(options: Partial<OverlayOptions>): void\n updateAnchor(anchor: OverlayAnchor): void\n dispose(): void\n}\n\nconst DEFAULT_OVERLAY_MIN_WIDTH = 120\nconst OVERLAY_MIN_WIDTH_VARIABLE = '--wui-overlay-min-width'\n\nconst DEFAULT_OPTIONS: Required<OverlayOptions> = {\n placement: 'bottom-start',\n offset: 4,\n flip: true,\n shift: true,\n matchWidth: false,\n minAnchorWidth: false,\n strategy: 'absolute'\n}\n\ninterface WidthStyleState {\n hasManagedStyles: boolean\n}\n\nfunction readOverlayMinWidth(overlay: HTMLElement): number {\n if (typeof getComputedStyle !== 'function') return DEFAULT_OVERLAY_MIN_WIDTH\n\n const minWidth = Number.parseFloat(getComputedStyle(overlay).getPropertyValue(OVERLAY_MIN_WIDTH_VARIABLE))\n return Number.isFinite(minWidth) ? minWidth : DEFAULT_OVERLAY_MIN_WIDTH\n}\n\nfunction clearManagedWidthStyles(overlay: HTMLElement) {\n overlay.style.removeProperty('width')\n overlay.style.removeProperty('min-width')\n}\n\nfunction createWidthMiddleware(\n overlay: HTMLElement,\n options: Required<OverlayOptions>,\n widthStyles: WidthStyleState,\n positioningGeneration: OverlayPositioningGeneration,\n generation: number\n): Middleware | undefined {\n const isCurrentGeneration = () => positioningGeneration.isCurrent(generation)\n if (options.matchWidth) {\n // matchWidth 必须移除之前 minAnchorWidth 写入的 min-width,才能精确跟随 trigger。\n overlay.style.removeProperty('min-width')\n widthStyles.hasManagedStyles = true\n return size({\n apply({ rects }) {\n if (!isCurrentGeneration()) return\n overlay.style.width = `${rects.reference.width}px`\n }\n })\n }\n\n if (options.minAnchorWidth) {\n const minWidth = readOverlayMinWidth(overlay)\n widthStyles.hasManagedStyles = true\n return size({\n apply({ rects }) {\n if (!isCurrentGeneration()) return\n // max-content 让内容决定宽度,min-width 再提供 trigger 与 floor 两个下限。\n overlay.style.width = 'max-content'\n overlay.style.minWidth = `${Math.max(rects.reference.width, minWidth)}px`\n }\n })\n }\n\n if (widthStyles.hasManagedStyles) {\n clearManagedWidthStyles(overlay)\n widthStyles.hasManagedStyles = false\n }\n\n return undefined\n}\n\nfunction createPositioningMiddleware(\n overlay: HTMLElement,\n options: Required<OverlayOptions>,\n widthStyles: WidthStyleState,\n positioningGeneration: OverlayPositioningGeneration,\n generation: number\n): Middleware[] {\n const middleware: Middleware[] = [offset(options.offset)]\n if (options.flip) middleware.push(flip())\n if (options.shift) middleware.push(shift({ padding: 8 }))\n\n const widthMiddleware = createWidthMiddleware(overlay, options, widthStyles, positioningGeneration, generation)\n if (widthMiddleware) middleware.push(widthMiddleware)\n\n return middleware\n}\n\nfunction getTransformOrigin(placement: Placement): string {\n const [side, alignment] = placement.split('-')\n const horizontalOrigin = alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'\n const verticalOrigin = alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'\n\n if (side === 'top') return `bottom ${horizontalOrigin}`\n if (side === 'bottom') return `top ${horizontalOrigin}`\n if (side === 'left') return `right ${verticalOrigin}`\n return `left ${verticalOrigin}`\n}\n\nexport const defineOverlay = () =>\n definePlugin<OverlayApi, { anchor: OverlayAnchor; overlay: HTMLElement } & OverlayOptions>(ctx => {\n const options: Required<OverlayOptions> = {\n placement: ctx.placement ?? DEFAULT_OPTIONS.placement,\n offset: ctx.offset ?? DEFAULT_OPTIONS.offset,\n flip: ctx.flip ?? DEFAULT_OPTIONS.flip,\n shift: ctx.shift ?? DEFAULT_OPTIONS.shift,\n matchWidth: ctx.matchWidth ?? DEFAULT_OPTIONS.matchWidth,\n minAnchorWidth: ctx.minAnchorWidth ?? DEFAULT_OPTIONS.minAnchorWidth,\n strategy: ctx.strategy ?? DEFAULT_OPTIONS.strategy\n }\n\n const overlay = ctx.overlay\n const widthStyles: WidthStyleState = { hasManagedStyles: false }\n let isOpen = false\n let cleanupAutoUpdate: (() => void) | null = null\n let currentAnchor = ctx.anchor\n // 每次刷新都是新定位代;open 期间 autoUpdate 的多次回调属于同一代。\n // reposition/close 会让上一代 promise 失效,避免乱序完成覆盖最新坐标。\n const positioningGeneration = defineOverlayPositioningGeneration().make()\n\n function updatePosition() {\n const generation = positioningGeneration.next()\n const middleware = createPositioningMiddleware(overlay, options, widthStyles, positioningGeneration, generation)\n\n const applyPosition = () => {\n void computePosition(currentAnchor, overlay, {\n placement: options.placement,\n strategy: options.strategy,\n middleware\n }).then(({ x, y, placement }) => {\n if (!positioningGeneration.isCurrent(generation) || !isOpen) return\n overlay.style.left = `${x}px`\n overlay.style.top = `${y}px`\n overlay.style.setProperty('--wui-internal-overlay-transform-origin', getTransformOrigin(placement))\n })\n }\n\n // 立即定位一次,避免 overlay 首帧出现在左上角;autoUpdate 负责后续滚动/尺寸变化。\n applyPosition()\n cleanupAutoUpdate?.()\n cleanupAutoUpdate = autoUpdate(currentAnchor, overlay, applyPosition)\n }\n\n return {\n isOpen() {\n return isOpen\n },\n options,\n\n open() {\n if (isOpen) return\n isOpen = true\n overlay.style.display = ''\n updatePosition()\n },\n\n close() {\n if (!isOpen) return\n isOpen = false\n positioningGeneration.invalidate()\n cleanupAutoUpdate?.()\n cleanupAutoUpdate = null\n },\n\n toggle() {\n if (isOpen) this.close()\n else this.open()\n },\n\n update(nextOptions) {\n Object.assign(options, nextOptions)\n if (isOpen) updatePosition()\n },\n\n updateAnchor(newAnchor: OverlayAnchor) {\n currentAnchor = newAnchor\n if (isOpen) updatePosition()\n },\n\n dispose() {\n this.close()\n }\n }\n })\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"portal.js","names":[],"sources":["../../../src/shared/overlay/portal.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport { getFallbackOverlayRoot } from '@/shared/overlay/overlay-root'\nimport { findNearestTheme, findRootTheme } from '@/shared/overlay/theme-overlay-scope'\n\nexport type OverlayContainer = HTMLElement | (() => HTMLElement | undefined)\n\nexport interface OverlayContainerResolutionOptions {\n /** 无 target 时优先使用文档中 root theme 的 theme-owned overlay root。 */\n preferRootTheme?: boolean\n}\n\nexport interface OverlayPortalOptions {\n container?: OverlayContainer\n target: Element\n style: string\n className: string\n /**\n * 额外的面板内容变化反应。注意:已迁移节点被框架物理删除后的解除追踪\n * (removeContent)是 portal 的内建默认行为,不经过该回调;这里只承载\n * 组件自己的附加逻辑(如 option 域的刷新、空态占位改写)。\n */\n onContentChange?: (mutations: MutationRecord[]) => void\n /**\n * 打开期实时渲染契约:框架(Vue/React 条件渲染)可能在浮层打开期间向宿主\n * light DOM 持续插入节点。提供该回调时,portal 监听宿主直接子节点的 childList,\n * 把回调认定的可迁移节点增量迁入面板;关闭恢复时与初始迁移节点一样按原位回插。\n * 回调应只返回渲染性内容(元素/文本),框架注释锚点必须留在宿主,\n * 否则框架后续 patch 会以面板内节点为插入基准。\n * 返回空数组表示本次新增无需迁移。\n */\n migrateAddedNodes?: (addedNodes: Node[]) => Node[]\n}\n\nexport interface OverlayPortal {\n readonly panel: HTMLElement\n restoreContent(): void\n moveContent(nodes: Node[], target?: HTMLElement): void\n appendContent(nodes: Node[], target?: HTMLElement): void\n removeContent(nodes: Node[]): void\n remove(): void\n}\n\n/**\n * Portal 面板位于独立 shadow root,宿主上的 CSS 自定义属性不会沿渲染树继承到 panel。\n * 这里在创建时镜像锚点解析后的变量,让组件 host 上的尺寸配置对 portal 仍然生效。\n */\nexport function applyOverlayVariables(panel: HTMLElement, source: Element, variables: readonly string[]): void {\n const computedStyle = getComputedStyle(source)\n for (const variable of variables) {\n const value = computedStyle.getPropertyValue(variable).trim()\n if (value) panel.style.setProperty(variable, value)\n }\n}\n\n/**\n * 迁移节点的追踪条目。每个节点迁入面板时在宿主 light DOM 原位留一个 marker 注释:\n * 宿主因此始终持有完整有序的骨架(marker + 框架注释锚点 + 未迁移节点),\n * 框架(Vue/React 条件渲染)打开期的插入以宿主为容器、以真实锚点为基准;\n * 面板内节点按宿主骨架序重排(中段插入不落到面板末尾);关闭时按 marker 位\n * 双向归还、marker 摘除。机制与 menu-tree 的托管菜单项锚一致(context-menu/dropdown 共用)。\n */\ninterface TrackedNodeEntry {\n node: Node\n marker: Comment\n}\n\nconst PORTAL_CONTENT_MARKER = 'wui-portal-content-anchor'\n\n/**\n * 构建带 Shadow DOM 样式边界的 Portal 面板,并追踪被迁移的内容节点以便恢复。\n */\nexport const defineOverlayPortal = () =>\n definePlugin<OverlayPortal, OverlayPortalOptions>(ctx => {\n const host = document.createElement('div')\n // :host 规则可能让宿主在 overlay 容器中生成行盒;display: contents 保留面板定位而不参与布局。\n host.style.display = 'contents'\n\n const root = host.attachShadow({ mode: 'open' })\n const style = document.createElement('style')\n style.textContent = ctx.style\n const panel = document.createElement('div')\n // 单层玻璃:wui-glass 原样留在面板自身,背景/阴影/backdrop-filter 随面板\n // opacity 与 blur 插值一起过渡(见 overlay-motion.css),无 blur/surface 双层。\n panel.className = ctx.className\n panel.dataset.wuiPresence = 'entering'\n root.append(style, panel)\n\n const trackedNodes: TrackedNodeEntry[] = []\n const contentObserver = new MutationObserver(mutations => {\n // 内部不变量先于消费方回调执行:配对依赖 trackedNodes 尚未被消费方 untrack 的状态。\n returnStrayFrameworkComments(mutations)\n // 框架在打开期物理删除已迁移节点:即时解除追踪,否则关闭恢复时会把已删除\n // 节点复活回宿主 light DOM。面板内部移动(重排 insertBefore)在同一 mutation\n // record 中同时出现在 removed/added,不属于删除,须排除。\n const addedInBatch = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const removedNodes = mutations\n .flatMap(mutation => [...mutation.removedNodes])\n .filter(node => !addedInBatch.has(node))\n if (removedNodes.length) untrackNodes(removedNodes)\n ctx.onContentChange?.(mutations)\n })\n // 同一 observer 批次内被插入又移除的游离节点没有宿主位可锚,不追踪不迁移\n // (insertBefore 要求 node 是 parent 的子节点);其归宿由框架的删除语义决定。\n const recordEntry = (node: Node): TrackedNodeEntry | null => {\n const parent = node.parentNode\n if (!parent) return null\n const marker = document.createComment(PORTAL_CONTENT_MARKER)\n parent.insertBefore(marker, node)\n const entry: TrackedNodeEntry = { node, marker }\n trackedNodes.push(entry)\n return entry\n }\n const untrackNodes = (nodes: Node[]) => {\n for (const node of nodes) {\n const index = trackedNodes.findIndex(entry => entry.node === node)\n if (index >= 0) {\n trackedNodes[index].marker.remove()\n trackedNodes.splice(index, 1)\n }\n }\n }\n // 面板内节点按宿主骨架序重排:骨架 = 宿主 childNodes 中本 portal 的 marker 序。\n // 只在已跟踪节点之间移动(insertBefore 相对定位),不触碰面板内的组件自有节点\n // (如 autocomplete 的空态占位);末位节点 append,越过面板内注释锚点的情形由\n // restoreContent 的相邻注释随迁兜底。\n const orderTargetBySkeleton = (target: HTMLElement) => {\n const skeleton: Node[] = []\n for (const node of Array.from(ctx.target.childNodes)) {\n if (node instanceof Comment && node.textContent === PORTAL_CONTENT_MARKER) {\n const entry = trackedNodes.find(e => e.marker === node)\n if (entry && entry.node.parentNode === target) skeleton.push(entry.node)\n }\n }\n if (skeleton.length === 0) return\n const current = Array.from(target.childNodes).filter(\n node => skeleton.includes(node) || !(node instanceof Comment)\n )\n if (current.length === skeleton.length && current.every((node, index) => node === skeleton[index])) return\n for (let index = skeleton.length - 1; index >= 0; index--) {\n const node = skeleton[index]\n const next = skeleton[index + 1]\n if (next) {\n if ((node as Element).nextElementSibling !== next) target.insertBefore(node, next)\n } else {\n target.appendChild(node)\n }\n }\n }\n\n /**\n * 打开期不变量:框架删除已迁移节点时,其条件渲染占位注释(v-if 等)会被插进\n * 面板内容区而不是宿主——block 子树 patch 以 hostParentNode(oldVNode.el) 重定\n * container,而旧 el 已在面板(可能是 panel 自身或组件的内容子容器)。\n * 注释是框架后续翻转依赖的 vnode.el:留在面板会随面板销毁,下次翻转把内容插进\n * 已脱离文档的旧面板(内容\"既不在宿主也不在面板\"且无法恢复)。\n * 每个面板 mutation 批次内即时归还:与同批次被框架删除的 tracked 节点按宿主\n * marker 序配对,插到其 marker 位(多注释对多删除为近似配对;无配对时落宿主\n * 骨架末尾兜底)。tracked 子树内的注释是迁移内容自身的嵌套条件渲染,不属游离\n * 锚点,必须留在原位;随整个子树被删的注释已不在面板内,无需救援。\n */\n const returnStrayFrameworkComments = (mutations: MutationRecord[]) => {\n const isInsideTrackedSubtree = (start: Node): boolean => {\n let current: Node | null = start\n while (current && current !== panel) {\n if (trackedNodes.some(entry => entry.node === current)) return true\n current = current.parentNode\n }\n return false\n }\n const addedNodes = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const strays: Comment[] = []\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (!(node instanceof Comment) || node.textContent === PORTAL_CONTENT_MARKER) continue\n // 同批次移入又移除的游离注释、随删除子树一起消失的注释都不在面板内,跳过\n if (!panel.contains(node)) continue\n if (isInsideTrackedSubtree(mutation.target)) continue\n strays.push(node)\n }\n }\n if (!strays.length) return\n const hostChildren = Array.from(ctx.target.childNodes)\n const removedEntries = trackedNodes\n .filter(\n entry =>\n !addedNodes.has(entry.node) && mutations.some(mutation => [...mutation.removedNodes].includes(entry.node))\n )\n .sort((a, b) => hostChildren.indexOf(a.marker) - hostChildren.indexOf(b.marker))\n for (const [index, comment] of strays.entries()) {\n const marker = removedEntries[Math.min(index, removedEntries.length - 1)]?.marker\n if (marker && marker.parentNode === ctx.target) ctx.target.insertBefore(comment, marker)\n else ctx.target.appendChild(comment)\n }\n }\n\n resolveOverlayContainer(ctx.container, ctx.target).appendChild(host)\n // 面板内容观察是 portal 自身的不变量(游离注释救援 + 删除节点解除追踪),\n // 不依赖消费方是否注册 onContentChange。\n contentObserver.observe(panel, { childList: true, subtree: true })\n\n // 打开期宿主 childList 观察(不含子树,框架 patch 嵌套组件的 light DOM 不属于\n // 本面板内容)。portal 实例的生命周期即打开期,随恢复/移除断开。\n const migrateAddedNodes = ctx.migrateAddedNodes\n const hostObserver = new MutationObserver(mutations => {\n // 框架清除逻辑(如 lit 的 _$clear)按宿主 marker 邻位遍历已迁移内容:元素\n // 物理上在面板,遍历找不到本体,会把 portal marker 当作内容本体移除。\n // marker 被框架摘除即框架删除语义——解除追踪并清掉滞留面板的游离元素;\n // 同批次移入又移回的 marker(框架重排)不属于删除。实时迁移仍以\n // migrateAddedNodes 的回调为准,无该回调的 portal(如 option 域)只做删除清理。\n const addedNodes = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const strandedNodes = mutations\n .flatMap(mutation => [...mutation.removedNodes])\n .filter(node => node instanceof Comment && node.textContent === PORTAL_CONTENT_MARKER && !addedNodes.has(node))\n .map(marker => trackedNodes.find(entry => entry.marker === marker)?.node)\n .filter((node): node is Node => node !== undefined)\n if (strandedNodes.length) {\n api.removeContent(strandedNodes)\n // remove() 的规范语义即 parentNode.removeChild:宿主/类型层面都以 Node 表达\n for (const node of strandedNodes) node.parentNode?.removeChild(node)\n }\n if (!migrateAddedNodes) return\n const migratable = migrateAddedNodes(mutations.flatMap(mutation => [...mutation.addedNodes]))\n if (migratable.length) api.appendContent(migratable)\n })\n hostObserver.observe(ctx.target, { childList: true })\n\n const api: OverlayPortal = {\n panel,\n\n moveContent(nodes, target = panel) {\n trackedNodes.forEach(entry => entry.marker.remove())\n const entries = nodes.map(recordEntry).filter((entry): entry is TrackedNodeEntry => entry !== null)\n trackedNodes.length = 0\n trackedNodes.push(...entries)\n target.append(...nodes)\n orderTargetBySkeleton(target)\n },\n\n appendContent(nodes, target = panel) {\n for (const node of nodes) {\n untrackNodes([node])\n const entry = recordEntry(node)\n if (!entry) continue\n target.appendChild(entry.node)\n }\n orderTargetBySkeleton(target)\n },\n\n removeContent(nodes) {\n // 框架删除节点时插进面板的占位注释由 contentObserver 的内部不变量\n // (returnStrayFrameworkComments)即时归还宿主,这里只负责解除追踪。\n untrackNodes(nodes)\n },\n\n restoreContent() {\n // 先停掉实时迁移:恢复动作本身会改动宿主 childList,\n // 否则回插的节点会被当成新增内容再次迁入面板形成回环。\n hostObserver.disconnect()\n\n // 节点回到宿主中自己的 marker 位;面板内紧邻其前的框架注释锚点(v-if 占位)\n // 随元素一起迁回——Vue 仍持有这些锚点的 vnode.el 引用,留在面板会随面板销毁,\n // 下次翻转将因 parentNode === null 崩溃。\n for (const entry of trackedNodes) {\n const pending: Comment[] = []\n let previous = entry.node.previousSibling\n while (previous instanceof Comment && previous.textContent !== PORTAL_CONTENT_MARKER) {\n pending.unshift(previous)\n previous = previous.previousSibling\n }\n for (const comment of pending) {\n if (entry.marker.parentNode === ctx.target) ctx.target.insertBefore(comment, entry.marker)\n else ctx.target.appendChild(comment)\n }\n if (entry.marker.parentNode === ctx.target) ctx.target.insertBefore(entry.node, entry.marker)\n else ctx.target.appendChild(entry.node)\n entry.marker.remove()\n }\n trackedNodes.length = 0\n },\n\n remove() {\n hostObserver.disconnect()\n contentObserver.disconnect()\n // 防御:正常 dispose 先 restore(marker 已清),异常路径下避免 marker 泄漏在宿主。\n trackedNodes.forEach(entry => entry.marker.remove())\n trackedNodes.length = 0\n host.remove()\n }\n }\n return api\n })\n\n/**\n * 判断 node 是否是 slot 已分配节点(或其 light DOM 后代),即 node 实际渲染在 shadowRoot 内。\n * slotted 内容的 parentNode 仍停留在 light DOM,因此不能用普通祖先遍历跨过 shadow 边界,\n * 只能通过 slot.assignedNodes() 判断内容被渲染到哪个 slot。\n */\nfunction isAssignedIntoTarget(node: Node, assignedNode: Node, boundary: Node): boolean {\n let current: Node | null = node\n while (current && current !== boundary) {\n if (current === assignedNode) return true\n current = current.parentNode\n }\n return false\n}\n\n/** parentNode 为 null 时跨过 shadow 边界上行到 host(portal 面板等 shadow 子树的 parentNode 链会断裂)。 */\nfunction getParentAcrossShadow(node: Node): Node | null {\n if (node.parentNode) return node.parentNode\n const root = node.getRootNode()\n return root instanceof ShadowRoot ? root.host : null\n}\n\n/**\n * 查找包含 target 且处于打开态的最近原生 <dialog>。\n * 原生 showModal() 会把 dialog 提升到浏览器 top layer,而常规 overlay 容器在普通文档流,\n * 二者无法靠 z-index 叠序——overlay 会被 top layer 的 dialog 遮住。\n * 因此 target 被渲染到某个已打开的原生 dialog 内时,应把 overlay 挂到该 dialog 上,\n * 使其一并进入 top layer(抽屉 / 对话框内的 dropdown、tooltip、context-menu 均适用)。\n * 上行路径必须跨 shadow 边界:target 可能位于 portal 面板(shadow 子树)内,\n * 纯 parentNode 链在该处断裂会误判为 dialog 外,浮层落进 fallback root 被遮挡。\n */\nfunction findEnclosingOpenDialog(target: Element): HTMLDialogElement | null {\n let current: Node | null = target\n while (current && current !== document.documentElement) {\n if (current instanceof HTMLDialogElement && current.open) return current\n\n // 跨过 shadow host:检查其 shadowRoot 内已打开的 dialog 是否渲染了 target。\n if (current instanceof Element && current.shadowRoot) {\n const dialogs = current.shadowRoot.querySelectorAll('dialog')\n for (const dialog of dialogs) {\n if (!(dialog instanceof HTMLDialogElement) || !dialog.open) continue\n const slots = dialog.querySelectorAll('slot')\n for (const slot of slots) {\n for (const assigned of slot.assignedNodes()) {\n if (!(assigned instanceof Node)) continue\n if (isAssignedIntoTarget(target, assigned, current)) return dialog\n }\n }\n }\n }\n\n current = getParentAcrossShadow(current)\n }\n return null\n}\n\nexport function resolveOverlayContainer(\n container: OverlayContainer | undefined,\n target: Element,\n options: OverlayContainerResolutionOptions = {}\n): HTMLElement {\n const explicit = typeof container === 'function' ? container() : container\n if (explicit) return explicit\n\n // target 在已打开的原生 dialog / drawer 内:挂到该 dialog,加入 top layer。\n const enclosingDialog = findEnclosingOpenDialog(target)\n if (enclosingDialog) return enclosingDialog\n\n // 无 target 的调用(如菜单)优先使用 root theme 的 theme-owned overlay root;\n // 有 target 时使用最近 theme 的 theme-owned overlay root。\n const theme = options.preferRootTheme ? findRootTheme() : findNearestTheme(target)\n return theme?.getOverlayRoot() ?? getFallbackOverlayRoot()\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"portal.js","names":[],"sources":["../../../src/shared/overlay/portal.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nimport { getFallbackOverlayRoot } from '@/shared/overlay/overlay-root'\nimport { findNearestTheme, findRootTheme } from '@/shared/overlay/theme-overlay-scope'\n\nexport type OverlayContainer = HTMLElement | (() => HTMLElement | undefined)\n\nexport interface OverlayContainerResolutionOptions {\n /** 无 target 时优先使用文档中 root theme 的 theme-owned overlay root。 */\n preferRootTheme?: boolean\n}\n\nexport interface OverlayPortalOptions {\n container?: OverlayContainer\n target: Element\n style: string\n className: string\n /**\n * 额外的 panel 内容变化反应。注意:已迁移节点被框架物理删除后的解除追踪\n * (removeContent)是 portal 的内建默认行为,不经过该回调;这里只承载\n * 组件自己的附加逻辑(如 option 域的刷新、空态占位改写)。\n */\n onContentChange?: (mutations: MutationRecord[]) => void\n /**\n * 打开期实时渲染契约:框架(Vue/React 条件渲染)可能在 overlay 打开期间向宿主\n * light DOM 持续插入节点。提供该回调时,portal 监听宿主直接子节点的 childList,\n * 把回调认定的可迁移节点增量迁入 panel;关闭恢复时与初始迁移节点一样按原位回插。\n * 回调应只返回渲染性内容(元素/文本),框架注释锚点必须留在宿主,\n * 否则框架后续 patch 会以 panel 内节点为插入基准。\n * 返回空数组表示本次新增无需迁移。\n */\n migrateAddedNodes?: (addedNodes: Node[]) => Node[]\n}\n\nexport interface OverlayPortal {\n readonly panel: HTMLElement\n restoreContent(): void\n moveContent(nodes: Node[], target?: HTMLElement): void\n appendContent(nodes: Node[], target?: HTMLElement): void\n removeContent(nodes: Node[]): void\n remove(): void\n}\n\n/**\n * Portal panel 位于独立 shadow root,宿主上的 CSS 自定义属性不会沿渲染树继承到 panel。\n * 这里在创建时镜像锚点解析后的变量,让组件 host 上的尺寸配置对 portal 仍然生效。\n */\nexport function applyOverlayVariables(panel: HTMLElement, source: Element, variables: readonly string[]): void {\n const computedStyle = getComputedStyle(source)\n for (const variable of variables) {\n const value = computedStyle.getPropertyValue(variable).trim()\n if (value) panel.style.setProperty(variable, value)\n }\n}\n\n/**\n * 迁移节点的追踪条目。每个节点迁入 panel 时在宿主 light DOM 原位留一个 marker 注释:\n * 宿主因此始终持有完整有序的骨架(marker + 框架注释锚点 + 未迁移节点),\n * 框架(Vue/React 条件渲染)打开期的插入以宿主为容器、以真实锚点为基准;\n * panel 内节点按宿主骨架序重排(中段插入不落到 panel 末尾);关闭时按 marker 位\n * 双向归还、marker 摘除。机制与 menu-tree 的托管 menu item 锚一致(context-menu/dropdown 共用)。\n */\ninterface TrackedNodeEntry {\n node: Node\n marker: Comment\n}\n\nconst PORTAL_CONTENT_MARKER = 'wui-portal-content-anchor'\n\n/**\n * 构建带 Shadow DOM 样式边界的 Portal panel,并追踪被迁移的内容节点以便恢复。\n */\nexport const defineOverlayPortal = () =>\n definePlugin<OverlayPortal, OverlayPortalOptions>(ctx => {\n const host = document.createElement('div')\n // :host 规则可能让宿主在 overlay 容器中生成行盒;display: contents 保留 panel 定位而不参与布局。\n host.style.display = 'contents'\n\n const root = host.attachShadow({ mode: 'open' })\n const style = document.createElement('style')\n style.textContent = ctx.style\n const panel = document.createElement('div')\n // 单层玻璃:wui-glass 原样留在 panel 自身,背景/阴影/backdrop-filter 随 panel\n // opacity 与 blur 插值一起过渡(见 overlay-motion.css),无 blur/surface 双层。\n panel.className = ctx.className\n panel.dataset.wuiPresence = 'entering'\n root.append(style, panel)\n\n const trackedNodes: TrackedNodeEntry[] = []\n const contentObserver = new MutationObserver(mutations => {\n // 内部不变量先于消费方回调执行:配对依赖 trackedNodes 尚未被消费方 untrack 的状态。\n returnStrayFrameworkComments(mutations)\n // 框架在打开期物理删除已迁移节点:即时解除追踪,否则关闭恢复时会把已删除\n // 节点复活回宿主 light DOM。panel 内部移动(重排 insertBefore)在同一 mutation\n // record 中同时出现在 removed/added,不属于删除,须排除。\n const addedInBatch = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const removedNodes = mutations\n .flatMap(mutation => [...mutation.removedNodes])\n .filter(node => !addedInBatch.has(node))\n if (removedNodes.length) untrackNodes(removedNodes)\n ctx.onContentChange?.(mutations)\n })\n // 同一 observer 批次内被插入又移除的游离节点没有宿主位可锚,不追踪不迁移\n // (insertBefore 要求 node 是 parent 的子节点);其归宿由框架的删除语义决定。\n const recordEntry = (node: Node): TrackedNodeEntry | null => {\n const parent = node.parentNode\n if (!parent) return null\n const marker = document.createComment(PORTAL_CONTENT_MARKER)\n parent.insertBefore(marker, node)\n const entry: TrackedNodeEntry = { node, marker }\n trackedNodes.push(entry)\n return entry\n }\n const untrackNodes = (nodes: Node[]) => {\n for (const node of nodes) {\n const index = trackedNodes.findIndex(entry => entry.node === node)\n if (index >= 0) {\n trackedNodes[index].marker.remove()\n trackedNodes.splice(index, 1)\n }\n }\n }\n // panel 内节点按宿主骨架序重排:骨架 = 宿主 childNodes 中本 portal 的 marker 序。\n // 只在已跟踪节点之间移动(insertBefore 相对定位),不触碰 panel 内的组件自有节点\n // (如 autocomplete 的空态占位);末位节点 append,越过 panel 内注释锚点的情形由\n // restoreContent 的相邻注释随迁兜底。\n const orderTargetBySkeleton = (target: HTMLElement) => {\n const skeleton: Node[] = []\n for (const node of Array.from(ctx.target.childNodes)) {\n if (node instanceof Comment && node.textContent === PORTAL_CONTENT_MARKER) {\n const entry = trackedNodes.find(e => e.marker === node)\n if (entry && entry.node.parentNode === target) skeleton.push(entry.node)\n }\n }\n if (skeleton.length === 0) return\n const current = Array.from(target.childNodes).filter(\n node => skeleton.includes(node) || !(node instanceof Comment)\n )\n if (current.length === skeleton.length && current.every((node, index) => node === skeleton[index])) return\n for (let index = skeleton.length - 1; index >= 0; index--) {\n const node = skeleton[index]\n const next = skeleton[index + 1]\n if (next) {\n if ((node as Element).nextElementSibling !== next) target.insertBefore(node, next)\n } else {\n target.appendChild(node)\n }\n }\n }\n\n /**\n * 打开期不变量:框架删除已迁移节点时,其条件渲染占位注释(v-if 等)会被插进\n * panel 内容区而不是宿主——block 子树 patch 以 hostParentNode(oldVNode.el) 重定\n * container,而旧 el 已在 panel(可能是 panel 自身或组件的内容子容器)。\n * 注释是框架后续翻转依赖的 vnode.el:留在 panel 会随 panel 销毁,下次翻转把内容插进\n * 已脱离文档的旧 panel(内容\"既不在宿主也不在 panel\"且无法恢复)。\n * 每个 panel mutation 批次内即时归还:与同批次被框架删除的 tracked 节点按宿主\n * marker 序配对,插到其 marker 位(多注释对多删除为近似配对;无配对时落宿主\n * 骨架末尾兜底)。tracked 子树内的注释是迁移内容自身的嵌套条件渲染,不属游离\n * 锚点,必须留在原位;随整个子树被删的注释已不在 panel 内,无需救援。\n */\n const returnStrayFrameworkComments = (mutations: MutationRecord[]) => {\n const isInsideTrackedSubtree = (start: Node): boolean => {\n let current: Node | null = start\n while (current && current !== panel) {\n if (trackedNodes.some(entry => entry.node === current)) return true\n current = current.parentNode\n }\n return false\n }\n const addedNodes = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const strays: Comment[] = []\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (!(node instanceof Comment) || node.textContent === PORTAL_CONTENT_MARKER) continue\n // 同批次移入又移除的游离注释、随删除子树一起消失的注释都不在 panel 内,跳过\n if (!panel.contains(node)) continue\n if (isInsideTrackedSubtree(mutation.target)) continue\n strays.push(node)\n }\n }\n if (!strays.length) return\n const hostChildren = Array.from(ctx.target.childNodes)\n const removedEntries = trackedNodes\n .filter(\n entry =>\n !addedNodes.has(entry.node) && mutations.some(mutation => [...mutation.removedNodes].includes(entry.node))\n )\n .sort((a, b) => hostChildren.indexOf(a.marker) - hostChildren.indexOf(b.marker))\n for (const [index, comment] of strays.entries()) {\n const marker = removedEntries[Math.min(index, removedEntries.length - 1)]?.marker\n if (marker && marker.parentNode === ctx.target) ctx.target.insertBefore(comment, marker)\n else ctx.target.appendChild(comment)\n }\n }\n\n resolveOverlayContainer(ctx.container, ctx.target).appendChild(host)\n // panel 内容观察是 portal 自身的不变量(游离注释救援 + 删除节点解除追踪),\n // 不依赖消费方是否注册 onContentChange。\n contentObserver.observe(panel, { childList: true, subtree: true })\n\n // 打开期宿主 childList 观察(不含子树,框架 patch 嵌套组件的 light DOM 不属于\n // 本 panel 内容)。portal 实例的生命周期即打开期,随恢复/移除断开。\n const migrateAddedNodes = ctx.migrateAddedNodes\n const hostObserver = new MutationObserver(mutations => {\n // 框架清除逻辑(如 lit 的 _$clear)按宿主 marker 邻位遍历已迁移内容:元素\n // 物理上在 panel,遍历找不到本体,会把 portal marker 当作内容本体移除。\n // marker 被框架摘除即框架删除语义——解除追踪并清掉滞留 panel 的游离元素;\n // 同批次移入又移回的 marker(框架重排)不属于删除。实时迁移仍以\n // migrateAddedNodes 的回调为准,无该回调的 portal(如 option 域)只做删除清理。\n const addedNodes = new Set(mutations.flatMap(mutation => [...mutation.addedNodes]))\n const strandedNodes = mutations\n .flatMap(mutation => [...mutation.removedNodes])\n .filter(node => node instanceof Comment && node.textContent === PORTAL_CONTENT_MARKER && !addedNodes.has(node))\n .map(marker => trackedNodes.find(entry => entry.marker === marker)?.node)\n .filter((node): node is Node => node !== undefined)\n if (strandedNodes.length) {\n api.removeContent(strandedNodes)\n // remove() 的规范语义即 parentNode.removeChild:宿主/类型层面都以 Node 表达\n for (const node of strandedNodes) node.parentNode?.removeChild(node)\n }\n if (!migrateAddedNodes) return\n const migratable = migrateAddedNodes(mutations.flatMap(mutation => [...mutation.addedNodes]))\n if (migratable.length) api.appendContent(migratable)\n })\n hostObserver.observe(ctx.target, { childList: true })\n\n const api: OverlayPortal = {\n panel,\n\n moveContent(nodes, target = panel) {\n trackedNodes.forEach(entry => entry.marker.remove())\n const entries = nodes.map(recordEntry).filter((entry): entry is TrackedNodeEntry => entry !== null)\n trackedNodes.length = 0\n trackedNodes.push(...entries)\n target.append(...nodes)\n orderTargetBySkeleton(target)\n },\n\n appendContent(nodes, target = panel) {\n for (const node of nodes) {\n untrackNodes([node])\n const entry = recordEntry(node)\n if (!entry) continue\n target.appendChild(entry.node)\n }\n orderTargetBySkeleton(target)\n },\n\n removeContent(nodes) {\n // 框架删除节点时插进 panel 的占位注释由 contentObserver 的内部不变量\n // (returnStrayFrameworkComments)即时归还宿主,这里只负责解除追踪。\n untrackNodes(nodes)\n },\n\n restoreContent() {\n // 先停掉实时迁移:恢复动作本身会改动宿主 childList,\n // 否则回插的节点会被当成新增内容再次迁入 panel 形成回环。\n hostObserver.disconnect()\n\n // 节点回到宿主中自己的 marker 位;panel 内紧邻其前的框架注释锚点(v-if 占位)\n // 随元素一起迁回——Vue 仍持有这些锚点的 vnode.el 引用,留在 panel 会随 panel 销毁,\n // 下次翻转将因 parentNode === null 崩溃。\n for (const entry of trackedNodes) {\n const pending: Comment[] = []\n let previous = entry.node.previousSibling\n while (previous instanceof Comment && previous.textContent !== PORTAL_CONTENT_MARKER) {\n pending.unshift(previous)\n previous = previous.previousSibling\n }\n for (const comment of pending) {\n if (entry.marker.parentNode === ctx.target) ctx.target.insertBefore(comment, entry.marker)\n else ctx.target.appendChild(comment)\n }\n if (entry.marker.parentNode === ctx.target) ctx.target.insertBefore(entry.node, entry.marker)\n else ctx.target.appendChild(entry.node)\n entry.marker.remove()\n }\n trackedNodes.length = 0\n },\n\n remove() {\n hostObserver.disconnect()\n contentObserver.disconnect()\n // 防御:正常 dispose 先 restore(marker 已清),异常路径下避免 marker 泄漏在宿主。\n trackedNodes.forEach(entry => entry.marker.remove())\n trackedNodes.length = 0\n host.remove()\n }\n }\n return api\n })\n\n/**\n * 判断 node 是否是 slot 已分配节点(或其 light DOM 后代),即 node 实际渲染在 shadowRoot 内。\n * slotted 内容的 parentNode 仍停留在 light DOM,因此不能用普通祖先遍历跨过 shadow 边界,\n * 只能通过 slot.assignedNodes() 判断内容被渲染到哪个 slot。\n */\nfunction isAssignedIntoTarget(node: Node, assignedNode: Node, boundary: Node): boolean {\n let current: Node | null = node\n while (current && current !== boundary) {\n if (current === assignedNode) return true\n current = current.parentNode\n }\n return false\n}\n\n/** parentNode 为 null 时跨过 shadow 边界上行到 host(portal panel 等 shadow 子树的 parentNode 链会断裂)。 */\nfunction getParentAcrossShadow(node: Node): Node | null {\n if (node.parentNode) return node.parentNode\n const root = node.getRootNode()\n return root instanceof ShadowRoot ? root.host : null\n}\n\n/**\n * 查找包含 target 且处于打开态的最近原生 <dialog>。\n * 原生 showModal() 会把 dialog 提升到浏览器 top layer,而常规 overlay 容器在普通文档流,\n * 二者无法靠 z-index 叠序——overlay 会被 top layer 的 dialog 遮住。\n * 因此 target 被渲染到某个已打开的原生 dialog 内时,应把 overlay 挂到该 dialog 上,\n * 使其一并进入 top layer(drawer / dialog 内的 dropdown、tooltip、context-menu 均适用)。\n * 上行路径必须跨 shadow 边界:target 可能位于 portal panel(shadow 子树)内,\n * 纯 parentNode 链在该处断裂会误判为 dialog 外,overlay 落进 fallback root 被遮挡。\n */\nfunction findEnclosingOpenDialog(target: Element): HTMLDialogElement | null {\n let current: Node | null = target\n while (current && current !== document.documentElement) {\n if (current instanceof HTMLDialogElement && current.open) return current\n\n // 跨过 shadow host:检查其 shadowRoot 内已打开的 dialog 是否渲染了 target。\n if (current instanceof Element && current.shadowRoot) {\n const dialogs = current.shadowRoot.querySelectorAll('dialog')\n for (const dialog of dialogs) {\n if (!(dialog instanceof HTMLDialogElement) || !dialog.open) continue\n const slots = dialog.querySelectorAll('slot')\n for (const slot of slots) {\n for (const assigned of slot.assignedNodes()) {\n if (!(assigned instanceof Node)) continue\n if (isAssignedIntoTarget(target, assigned, current)) return dialog\n }\n }\n }\n }\n\n current = getParentAcrossShadow(current)\n }\n return null\n}\n\nexport function resolveOverlayContainer(\n container: OverlayContainer | undefined,\n target: Element,\n options: OverlayContainerResolutionOptions = {}\n): HTMLElement {\n const explicit = typeof container === 'function' ? container() : container\n if (explicit) return explicit\n\n // target 在已打开的原生 dialog / drawer 内:挂到该 dialog,加入 top layer。\n const enclosingDialog = findEnclosingOpenDialog(target)\n if (enclosingDialog) return enclosingDialog\n\n // 无 target 的调用(如 menu)优先使用 root theme 的 theme-owned overlay root;\n // 有 target 时使用最近 theme 的 theme-owned overlay root。\n const theme = options.preferRootTheme ? findRootTheme() : findNearestTheme(target)\n return theme?.getOverlayRoot() ?? getFallbackOverlayRoot()\n}\n"],"mappings":";;;;AA+CA,SAAgB,EAAsB,GAAoB,GAAiB,GAAoC;CAC7G,IAAM,IAAgB,iBAAiB,CAAM;CAC7C,KAAK,IAAM,KAAY,GAAW;EAChC,IAAM,IAAQ,EAAc,iBAAiB,CAAQ,CAAC,CAAC,KAAK;EAC5D,AAAI,KAAO,EAAM,MAAM,YAAY,GAAU,CAAK;CACpD;AACF;AAcA,IAAM,IAAwB,6BAKjB,UACX,GAAkD,MAAO;CACvD,IAAM,IAAO,SAAS,cAAc,KAAK;CAEzC,EAAK,MAAM,UAAU;CAErB,IAAM,IAAO,EAAK,aAAa,EAAE,MAAM,OAAO,CAAC,GACzC,IAAQ,SAAS,cAAc,OAAO;CAC5C,EAAM,cAAc,EAAI;CACxB,IAAM,IAAQ,SAAS,cAAc,KAAK;CAK1C,AAFA,EAAM,YAAY,EAAI,WACtB,EAAM,QAAQ,cAAc,YAC5B,EAAK,OAAO,GAAO,CAAK;CAExB,IAAM,IAAmC,CAAC,GACpC,IAAkB,IAAI,kBAAiB,MAAa;EAExD,EAA6B,CAAS;EAItC,IAAM,IAAe,IAAI,IAAI,EAAU,SAAQ,MAAY,CAAC,GAAG,EAAS,UAAU,CAAC,CAAC,GAC9E,IAAe,EAClB,SAAQ,MAAY,CAAC,GAAG,EAAS,YAAY,CAAC,CAAC,CAC/C,QAAO,MAAQ,CAAC,EAAa,IAAI,CAAI,CAAC;EAEzC,AADI,EAAa,UAAQ,EAAa,CAAY,GAClD,EAAI,kBAAkB,CAAS;CACjC,CAAC,GAGK,KAAe,MAAwC;EAC3D,IAAM,IAAS,EAAK;EACpB,IAAI,CAAC,GAAQ,OAAO;EACpB,IAAM,IAAS,SAAS,cAAc,CAAqB;EAC3D,EAAO,aAAa,GAAQ,CAAI;EAChC,IAAM,IAA0B;GAAE;GAAM;EAAO;EAE/C,OADA,EAAa,KAAK,CAAK,GAChB;CACT,GACM,KAAgB,MAAkB;EACtC,KAAK,IAAM,KAAQ,GAAO;GACxB,IAAM,IAAQ,EAAa,WAAU,MAAS,EAAM,SAAS,CAAI;GACjE,AAAI,KAAS,MACX,EAAa,EAAM,CAAC,OAAO,OAAO,GAClC,EAAa,OAAO,GAAO,CAAC;EAEhC;CACF,GAKM,KAAyB,MAAwB;EACrD,IAAM,IAAmB,CAAC;EAC1B,KAAK,IAAM,KAAQ,MAAM,KAAK,EAAI,OAAO,UAAU,GACjD,IAAI,aAAgB,WAAW,EAAK,gBAAgB,GAAuB;GACzE,IAAM,IAAQ,EAAa,MAAK,MAAK,EAAE,WAAW,CAAI;GACtD,AAAI,KAAS,EAAM,KAAK,eAAe,KAAQ,EAAS,KAAK,EAAM,IAAI;EACzE;EAEF,IAAI,EAAS,WAAW,GAAG;EAC3B,IAAM,IAAU,MAAM,KAAK,EAAO,UAAU,CAAC,CAAC,QAC5C,MAAQ,EAAS,SAAS,CAAI,KAAK,EAAE,aAAgB,QACvD;EACI,QAAQ,WAAW,EAAS,UAAU,EAAQ,OAAO,GAAM,MAAU,MAAS,EAAS,EAAM,IACjG,KAAK,IAAI,IAAQ,EAAS,SAAS,GAAG,KAAS,GAAG,KAAS;GACzD,IAAM,IAAO,EAAS,IAChB,IAAO,EAAS,IAAQ;GAC9B,AAAI,IACG,EAAiB,uBAAuB,KAAM,EAAO,aAAa,GAAM,CAAI,IAEjF,EAAO,YAAY,CAAI;EAE3B;CACF,GAaM,KAAgC,MAAgC;EACpE,IAAM,KAA0B,MAAyB;GACvD,IAAI,IAAuB;GAC3B,OAAO,KAAW,MAAY,IAAO;IACnC,IAAI,EAAa,MAAK,MAAS,EAAM,SAAS,CAAO,GAAG,OAAO;IAC/D,IAAU,EAAQ;GACpB;GACA,OAAO;EACT,GACM,IAAa,IAAI,IAAI,EAAU,SAAQ,MAAY,CAAC,GAAG,EAAS,UAAU,CAAC,CAAC,GAC5E,IAAoB,CAAC;EAC3B,KAAK,IAAM,KAAY,GACrB,KAAK,IAAM,KAAQ,EAAS,YACtB,AAAE,aAAgB,WAAY,EAAK,gBAAgB,KAElD,EAAM,SAAS,CAAI,MACpB,EAAuB,EAAS,MAAM,KAC1C,EAAO,KAAK,CAAI;EAGpB,IAAI,CAAC,EAAO,QAAQ;EACpB,IAAM,IAAe,MAAM,KAAK,EAAI,OAAO,UAAU,GAC/C,IAAiB,EACpB,QACC,MACE,CAAC,EAAW,IAAI,EAAM,IAAI,KAAK,EAAU,MAAK,MAAY,CAAC,GAAG,EAAS,YAAY,CAAC,CAAC,SAAS,EAAM,IAAI,CAAC,CAC7G,CAAC,CACA,MAAM,GAAG,MAAM,EAAa,QAAQ,EAAE,MAAM,IAAI,EAAa,QAAQ,EAAE,MAAM,CAAC;EACjF,KAAK,IAAM,CAAC,GAAO,MAAY,EAAO,QAAQ,GAAG;GAC/C,IAAM,IAAS,EAAe,KAAK,IAAI,GAAO,EAAe,SAAS,CAAC,EAAE,EAAE;GAC3E,AAAI,KAAU,EAAO,eAAe,EAAI,SAAQ,EAAI,OAAO,aAAa,GAAS,CAAM,IAClF,EAAI,OAAO,YAAY,CAAO;EACrC;CACF;CAKA,AAHA,EAAwB,EAAI,WAAW,EAAI,MAAM,CAAC,CAAC,YAAY,CAAI,GAGnE,EAAgB,QAAQ,GAAO;EAAE,WAAW;EAAM,SAAS;CAAK,CAAC;CAIjE,IAAM,IAAoB,EAAI,mBACxB,IAAe,IAAI,kBAAiB,MAAa;EAMrD,IAAM,IAAa,IAAI,IAAI,EAAU,SAAQ,MAAY,CAAC,GAAG,EAAS,UAAU,CAAC,CAAC,GAC5E,IAAgB,EACnB,SAAQ,MAAY,CAAC,GAAG,EAAS,YAAY,CAAC,CAAC,CAC/C,QAAO,MAAQ,aAAgB,WAAW,EAAK,gBAAgB,KAAyB,CAAC,EAAW,IAAI,CAAI,CAAC,CAAC,CAC9G,KAAI,MAAU,EAAa,MAAK,MAAS,EAAM,WAAW,CAAM,CAAC,EAAE,IAAI,CAAC,CACxE,QAAQ,MAAuB,MAAS,KAAA,CAAS;EACpD,IAAI,EAAc,QAAQ;GACxB,EAAI,cAAc,CAAa;GAE/B,KAAK,IAAM,KAAQ,GAAe,EAAK,YAAY,YAAY,CAAI;EACrE;EACA,IAAI,CAAC,GAAmB;EACxB,IAAM,IAAa,EAAkB,EAAU,SAAQ,MAAY,CAAC,GAAG,EAAS,UAAU,CAAC,CAAC;EAC5F,AAAI,EAAW,UAAQ,EAAI,cAAc,CAAU;CACrD,CAAC;CACD,EAAa,QAAQ,EAAI,QAAQ,EAAE,WAAW,GAAK,CAAC;CAEpD,IAAM,IAAqB;EACzB;EAEA,YAAY,GAAO,IAAS,GAAO;GACjC,EAAa,SAAQ,MAAS,EAAM,OAAO,OAAO,CAAC;GACnD,IAAM,IAAU,EAAM,IAAI,CAAW,CAAC,CAAC,QAAQ,MAAqC,MAAU,IAAI;GAIlG,AAHA,EAAa,SAAS,GACtB,EAAa,KAAK,GAAG,CAAO,GAC5B,EAAO,OAAO,GAAG,CAAK,GACtB,EAAsB,CAAM;EAC9B;EAEA,cAAc,GAAO,IAAS,GAAO;GACnC,KAAK,IAAM,KAAQ,GAAO;IACxB,EAAa,CAAC,CAAI,CAAC;IACnB,IAAM,IAAQ,EAAY,CAAI;IACzB,KACL,EAAO,YAAY,EAAM,IAAI;GAC/B;GACA,EAAsB,CAAM;EAC9B;EAEA,cAAc,GAAO;GAGnB,EAAa,CAAK;EACpB;EAEA,iBAAiB;GAGf,EAAa,WAAW;GAKxB,KAAK,IAAM,KAAS,GAAc;IAChC,IAAM,IAAqB,CAAC,GACxB,IAAW,EAAM,KAAK;IAC1B,OAAO,aAAoB,WAAW,EAAS,gBAAgB,IAE7D,AADA,EAAQ,QAAQ,CAAQ,GACxB,IAAW,EAAS;IAEtB,KAAK,IAAM,KAAW,GACpB,AAAI,EAAM,OAAO,eAAe,EAAI,SAAQ,EAAI,OAAO,aAAa,GAAS,EAAM,MAAM,IACpF,EAAI,OAAO,YAAY,CAAO;IAIrC,AAFI,EAAM,OAAO,eAAe,EAAI,SAAQ,EAAI,OAAO,aAAa,EAAM,MAAM,EAAM,MAAM,IACvF,EAAI,OAAO,YAAY,EAAM,IAAI,GACtC,EAAM,OAAO,OAAO;GACtB;GACA,EAAa,SAAS;EACxB;EAEA,SAAS;GAMP,AALA,EAAa,WAAW,GACxB,EAAgB,WAAW,GAE3B,EAAa,SAAQ,MAAS,EAAM,OAAO,OAAO,CAAC,GACnD,EAAa,SAAS,GACtB,EAAK,OAAO;EACd;CACF;CACA,OAAO;AACT,CAAC;AAOH,SAAS,EAAqB,GAAY,GAAoB,GAAyB;CACrF,IAAI,IAAuB;CAC3B,OAAO,KAAW,MAAY,IAAU;EACtC,IAAI,MAAY,GAAc,OAAO;EACrC,IAAU,EAAQ;CACpB;CACA,OAAO;AACT;AAGA,SAAS,EAAsB,GAAyB;CACtD,IAAI,EAAK,YAAY,OAAO,EAAK;CACjC,IAAM,IAAO,EAAK,YAAY;CAC9B,OAAO,aAAgB,aAAa,EAAK,OAAO;AAClD;AAWA,SAAS,EAAwB,GAA2C;CAC1E,IAAI,IAAuB;CAC3B,OAAO,KAAW,MAAY,SAAS,kBAAiB;EACtD,IAAI,aAAmB,qBAAqB,EAAQ,MAAM,OAAO;EAGjE,IAAI,aAAmB,WAAW,EAAQ,YAAY;GACpD,IAAM,IAAU,EAAQ,WAAW,iBAAiB,QAAQ;GAC5D,KAAK,IAAM,KAAU,GAAS;IAC5B,IAAI,EAAE,aAAkB,sBAAsB,CAAC,EAAO,MAAM;IAC5D,IAAM,IAAQ,EAAO,iBAAiB,MAAM;IAC5C,KAAK,IAAM,KAAQ,GACjB,KAAK,IAAM,KAAY,EAAK,cAAc,GAClC,iBAAoB,QACtB,EAAqB,GAAQ,GAAU,CAAO,GAAG,OAAO;GAGlE;EACF;EAEA,IAAU,EAAsB,CAAO;CACzC;CACA,OAAO;AACT;AAEA,SAAgB,EACd,GACA,GACA,IAA6C,CAAC,GACjC;CAWb,QAViB,OAAO,KAAc,aAAa,EAAU,IAAI,MAIzC,EAAwB,CAC5C,OAIU,EAAQ,kBAAkB,EAAc,IAAI,EAAiB,CAAM,EAAA,EACnE,eAAe,KAAK,EAAuB;AAC3D"}
@@ -1 +1 @@
1
- {"version":3,"file":"presence.js","names":[],"sources":["../../../src/shared/overlay/presence.ts"],"sourcesContent":["import { parseDuration } from '@/shared/theme/duration'\n\nconst DEFAULT_EXIT_DURATION = 160\nconst EXIT_FALLBACK_BUFFER = 80\n\nconst pendingExits = new WeakMap<HTMLElement, () => void>()\n\ninterface OverlayPresenceOptions {\n isInstant?: boolean\n}\n\n// 读取元素实际 CSS transition 的最长时长,供可覆盖主题 token 的 JS 生命周期使用。\nexport function getTransitionDuration(panel: HTMLElement, fallback = DEFAULT_EXIT_DURATION): number {\n const style = getComputedStyle(panel)\n const durations = style.transitionDuration.split(',').map(value => parseDuration(value) ?? 0)\n const delays = style.transitionDelay.split(',').map(value => parseDuration(value) ?? 0)\n const longest = durations.reduce(\n (max, duration, index) => Math.max(max, duration + (delays[index] ?? delays[0] ?? 0)),\n 0\n )\n\n return longest || fallback\n}\n\n// 以可中断的方式显示浮层,避免快速切换时重启动画。\nexport function showOverlayPresence(panel: HTMLElement, options: OverlayPresenceOptions = {}) {\n pendingExits.get(panel)?.()\n panel.hidden = false\n if (options.isInstant) {\n panel.dataset.wuiPresence = 'open'\n return\n }\n panel.dataset.wuiPresence = 'entering'\n\n // 提交初始状态,让新挂载的面板拥有过渡起点。\n void panel.offsetWidth\n\n requestAnimationFrame(() => {\n if (panel.dataset.wuiPresence === 'entering') panel.dataset.wuiPresence = 'open'\n })\n}\n\n// 播放退出过渡;若在结束前重新显示则返回 false。\nexport function hideOverlayPresence(panel: HTMLElement): Promise<boolean> {\n pendingExits.get(panel)?.()\n panel.dataset.wuiPresence = 'closing'\n\n // jsdom 等没有计算 CSS transition 的环境无需伪造退场等待。\n if (getTransitionDuration(panel, 0) === 0) {\n panel.hidden = true\n return Promise.resolve(true)\n }\n\n return new Promise(resolve => {\n let settled = false\n const finish = (didClose: boolean) => {\n if (settled) return\n settled = true\n panel.removeEventListener('transitionend', onTransitionEnd)\n clearTimeout(timeout)\n pendingExits.delete(panel)\n if (didClose && panel.dataset.wuiPresence === 'closing') panel.hidden = true\n resolve(didClose)\n }\n\n const onTransitionEnd = (event: TransitionEvent) => {\n if (event.target === panel && (event.propertyName === 'opacity' || event.propertyName === 'transform'))\n finish(true)\n }\n\n pendingExits.set(panel, () => finish(false))\n panel.addEventListener('transitionend', onTransitionEnd)\n const timeout = setTimeout(() => finish(true), getTransitionDuration(panel) + EXIT_FALLBACK_BUFFER)\n })\n}\n"],"mappings":";;AAEA,IAAM,IAAwB,KACxB,IAAuB,IAEvB,oBAAe,IAAI,QAAiC;AAO1D,SAAgB,EAAsB,GAAoB,IAAW,GAA+B;CAClG,IAAM,IAAQ,iBAAiB,CAAK,GAC9B,IAAY,EAAM,mBAAmB,MAAM,GAAG,CAAC,CAAC,KAAI,MAAS,EAAc,CAAK,KAAK,CAAC,GACtF,IAAS,EAAM,gBAAgB,MAAM,GAAG,CAAC,CAAC,KAAI,MAAS,EAAc,CAAK,KAAK,CAAC;CAMtF,OALgB,EAAU,QACvB,GAAK,GAAU,MAAU,KAAK,IAAI,GAAK,KAAY,EAAO,MAAU,EAAO,MAAM,EAAE,GACpF,CAGK,KAAW;AACpB;AAGA,SAAgB,EAAoB,GAAoB,IAAkC,CAAC,GAAG;CAG5F,IAFA,EAAa,IAAI,CAAK,CAAC,GAAG,GAC1B,EAAM,SAAS,IACX,EAAQ,WAAW;EACrB,EAAM,QAAQ,cAAc;EAC5B;CACF;CAMA,AALA,EAAM,QAAQ,cAAc,YAG5B,EAAW,aAEX,4BAA4B;EAC1B,AAAI,EAAM,QAAQ,gBAAgB,eAAY,EAAM,QAAQ,cAAc;CAC5E,CAAC;AACH;AAGA,SAAgB,EAAoB,GAAsC;CAUxE,OATA,EAAa,IAAI,CAAK,CAAC,GAAG,GAC1B,EAAM,QAAQ,cAAc,WAGxB,EAAsB,GAAO,CAAC,MAAM,KACtC,EAAM,SAAS,IACR,QAAQ,QAAQ,EAAI,KAGtB,IAAI,SAAQ,MAAW;EAC5B,IAAI,IAAU,IACR,KAAU,MAAsB;GAChC,MACJ,IAAU,IACV,EAAM,oBAAoB,iBAAiB,CAAe,GAC1D,aAAa,CAAO,GACpB,EAAa,OAAO,CAAK,GACrB,KAAY,EAAM,QAAQ,gBAAgB,cAAW,EAAM,SAAS,KACxE,EAAQ,CAAQ;EAClB,GAEM,KAAmB,MAA2B;GAClD,AAAI,EAAM,WAAW,MAAU,EAAM,iBAAiB,aAAa,EAAM,iBAAiB,gBACxF,EAAO,EAAI;EACf;EAGA,AADA,EAAa,IAAI,SAAa,EAAO,EAAK,CAAC,GAC3C,EAAM,iBAAiB,iBAAiB,CAAe;EACvD,IAAM,IAAU,iBAAiB,EAAO,EAAI,GAAG,EAAsB,CAAK,IAAI,CAAoB;CACpG,CAAC;AACH"}
1
+ {"version":3,"file":"presence.js","names":[],"sources":["../../../src/shared/overlay/presence.ts"],"sourcesContent":["import { parseDuration } from '@/shared/theme/duration'\n\nconst DEFAULT_EXIT_DURATION = 160\nconst EXIT_FALLBACK_BUFFER = 80\n\nconst pendingExits = new WeakMap<HTMLElement, () => void>()\n\ninterface OverlayPresenceOptions {\n isInstant?: boolean\n}\n\n// 读取元素实际 CSS transition 的最长时长,供可覆盖主题 token 的 JS 生命周期使用。\nexport function getTransitionDuration(panel: HTMLElement, fallback = DEFAULT_EXIT_DURATION): number {\n const style = getComputedStyle(panel)\n const durations = style.transitionDuration.split(',').map(value => parseDuration(value) ?? 0)\n const delays = style.transitionDelay.split(',').map(value => parseDuration(value) ?? 0)\n const longest = durations.reduce(\n (max, duration, index) => Math.max(max, duration + (delays[index] ?? delays[0] ?? 0)),\n 0\n )\n\n return longest || fallback\n}\n\n// 以可中断的方式显示 overlay,避免快速切换时重启动画。\nexport function showOverlayPresence(panel: HTMLElement, options: OverlayPresenceOptions = {}) {\n pendingExits.get(panel)?.()\n panel.hidden = false\n if (options.isInstant) {\n panel.dataset.wuiPresence = 'open'\n return\n }\n panel.dataset.wuiPresence = 'entering'\n\n // 提交初始状态,让新挂载的 panel 拥有过渡起点。\n void panel.offsetWidth\n\n requestAnimationFrame(() => {\n if (panel.dataset.wuiPresence === 'entering') panel.dataset.wuiPresence = 'open'\n })\n}\n\n// 播放退出过渡;若在结束前重新显示则返回 false。\nexport function hideOverlayPresence(panel: HTMLElement): Promise<boolean> {\n pendingExits.get(panel)?.()\n panel.dataset.wuiPresence = 'closing'\n\n // jsdom 等没有计算 CSS transition 的环境无需伪造退场等待。\n if (getTransitionDuration(panel, 0) === 0) {\n panel.hidden = true\n return Promise.resolve(true)\n }\n\n return new Promise(resolve => {\n let settled = false\n const finish = (didClose: boolean) => {\n if (settled) return\n settled = true\n panel.removeEventListener('transitionend', onTransitionEnd)\n clearTimeout(timeout)\n pendingExits.delete(panel)\n if (didClose && panel.dataset.wuiPresence === 'closing') panel.hidden = true\n resolve(didClose)\n }\n\n const onTransitionEnd = (event: TransitionEvent) => {\n if (event.target === panel && (event.propertyName === 'opacity' || event.propertyName === 'transform'))\n finish(true)\n }\n\n pendingExits.set(panel, () => finish(false))\n panel.addEventListener('transitionend', onTransitionEnd)\n const timeout = setTimeout(() => finish(true), getTransitionDuration(panel) + EXIT_FALLBACK_BUFFER)\n })\n}\n"],"mappings":";;AAEA,IAAM,IAAwB,KACxB,IAAuB,IAEvB,oBAAe,IAAI,QAAiC;AAO1D,SAAgB,EAAsB,GAAoB,IAAW,GAA+B;CAClG,IAAM,IAAQ,iBAAiB,CAAK,GAC9B,IAAY,EAAM,mBAAmB,MAAM,GAAG,CAAC,CAAC,KAAI,MAAS,EAAc,CAAK,KAAK,CAAC,GACtF,IAAS,EAAM,gBAAgB,MAAM,GAAG,CAAC,CAAC,KAAI,MAAS,EAAc,CAAK,KAAK,CAAC;CAMtF,OALgB,EAAU,QACvB,GAAK,GAAU,MAAU,KAAK,IAAI,GAAK,KAAY,EAAO,MAAU,EAAO,MAAM,EAAE,GACpF,CAGK,KAAW;AACpB;AAGA,SAAgB,EAAoB,GAAoB,IAAkC,CAAC,GAAG;CAG5F,IAFA,EAAa,IAAI,CAAK,CAAC,GAAG,GAC1B,EAAM,SAAS,IACX,EAAQ,WAAW;EACrB,EAAM,QAAQ,cAAc;EAC5B;CACF;CAMA,AALA,EAAM,QAAQ,cAAc,YAG5B,EAAW,aAEX,4BAA4B;EAC1B,AAAI,EAAM,QAAQ,gBAAgB,eAAY,EAAM,QAAQ,cAAc;CAC5E,CAAC;AACH;AAGA,SAAgB,EAAoB,GAAsC;CAUxE,OATA,EAAa,IAAI,CAAK,CAAC,GAAG,GAC1B,EAAM,QAAQ,cAAc,WAGxB,EAAsB,GAAO,CAAC,MAAM,KACtC,EAAM,SAAS,IACR,QAAQ,QAAQ,EAAI,KAGtB,IAAI,SAAQ,MAAW;EAC5B,IAAI,IAAU,IACR,KAAU,MAAsB;GAChC,MACJ,IAAU,IACV,EAAM,oBAAoB,iBAAiB,CAAe,GAC1D,aAAa,CAAO,GACpB,EAAa,OAAO,CAAK,GACrB,KAAY,EAAM,QAAQ,gBAAgB,cAAW,EAAM,SAAS,KACxE,EAAQ,CAAQ;EAClB,GAEM,KAAmB,MAA2B;GAClD,AAAI,EAAM,WAAW,MAAU,EAAM,iBAAiB,aAAa,EAAM,iBAAiB,gBACxF,EAAO,EAAI;EACf;EAGA,AADA,EAAa,IAAI,SAAa,EAAO,EAAK,CAAC,GAC3C,EAAM,iBAAiB,iBAAiB,CAAe;EACvD,IAAM,IAAU,iBAAiB,EAAO,EAAI,GAAG,EAAsB,CAAK,IAAI,CAAoB;CACpG,CAAC;AACH"}
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-lock.js","names":[],"sources":["../../../src/shared/scroll-lock/scroll-lock.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nlet lockCount = 0\nlet savedOverflow = ''\nlet savedOverscroll = ''\n\nexport interface ScrollLockLease {\n readonly isLocked: boolean\n sync(shouldLock: boolean): void\n release(): void\n}\n\n/**\n * 锁定页面滚动。支持嵌套调用(引用计数),仅在最后一个锁释放时恢复。\n *\n * 只改 `documentElement` 的 `overflow` 和 `overscroll-behavior`,body 始终留在文档流内。\n * 改用 `body { position: fixed }` + `top: -scrollY` 会让 body 脱离文档流:\n * `documentElement.scrollHeight` 随之塌缩、`window.scrollY` 被归零,\n * 按窗口偏移定位的虚拟列表因此渲染出第 0 行却仍带旧的 `top` 偏移,表现为整屏空白。\n *\n * 阻止滚动本身(`overflow`)与抑制橡皮筋/下拉刷新(`overscroll-behavior`)是两件事:\n * 旧实现用 fixed body 同时覆盖两者,现在拆成两个根元素属性。macOS 已实测\n * `overscroll-behavior: none` 有效;iOS 侧尚无真机证据,属待验收项。\n */\nexport function lockScroll() {\n if (lockCount === 0) {\n savedOverflow = document.documentElement.style.overflow\n savedOverscroll = document.documentElement.style.overscrollBehavior\n document.documentElement.style.overflow = 'hidden'\n document.documentElement.style.overscrollBehavior = 'none'\n }\n lockCount++\n}\n\n/**\n * 解锁页面滚动。与 lockScroll 配对,引用归零时恢复。\n */\nexport function unlockScroll() {\n lockCount = Math.max(0, lockCount - 1)\n if (lockCount === 0) {\n document.documentElement.style.overflow = savedOverflow\n document.documentElement.style.overscrollBehavior = savedOverscroll\n }\n}\n\n/**\n * 构建组件实例持有的滚动锁租约。每个租约仅释放自身获得的那一次锁,\n * 因此卸载、属性切换和嵌套浮层不会互相影响。\n */\nexport const defineScrollLockLease = () =>\n definePlugin<ScrollLockLease, Record<never, never>>(() => {\n let isLocked = false\n\n const sync = (shouldLock: boolean) => {\n if (shouldLock === isLocked) return\n if (shouldLock) lockScroll()\n else unlockScroll()\n isLocked = shouldLock\n }\n\n return {\n get isLocked() {\n return isLocked\n },\n\n sync,\n\n release() {\n sync(false)\n }\n }\n })\n"],"mappings":";;AAEA,IAAI,IAAY,GACZ,IAAgB,IAChB,IAAkB;AAoBtB,SAAgB,IAAa;CAO3B,AANI,MAAc,MAChB,IAAgB,SAAS,gBAAgB,MAAM,UAC/C,IAAkB,SAAS,gBAAgB,MAAM,oBACjD,SAAS,gBAAgB,MAAM,WAAW,UAC1C,SAAS,gBAAgB,MAAM,qBAAqB,SAEtD;AACF;AAKA,SAAgB,IAAe;CAE7B,AADA,IAAY,KAAK,IAAI,GAAG,IAAY,CAAC,GACjC,MAAc,MAChB,SAAS,gBAAgB,MAAM,WAAW,GAC1C,SAAS,gBAAgB,MAAM,qBAAqB;AAExD;AAMA,IAAa,UACX,QAA0D;CACxD,IAAI,IAAW,IAET,KAAQ,MAAwB;EAChC,MAAe,MACf,IAAY,EAAW,IACtB,EAAa,GAClB,IAAW;CACb;CAEA,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA;EAEA,UAAU;GACR,EAAK,EAAK;EACZ;CACF;AACF,CAAC"}
1
+ {"version":3,"file":"scroll-lock.js","names":[],"sources":["../../../src/shared/scroll-lock/scroll-lock.ts"],"sourcesContent":["import { definePlugin } from '@greypan/js-kit'\n\nlet lockCount = 0\nlet savedOverflow = ''\nlet savedOverscroll = ''\n\nexport interface ScrollLockLease {\n readonly isLocked: boolean\n sync(shouldLock: boolean): void\n release(): void\n}\n\n/**\n * 锁定页面滚动。支持嵌套调用(引用计数),仅在最后一个锁释放时恢复。\n *\n * 只改 `documentElement` 的 `overflow` 和 `overscroll-behavior`,body 始终留在文档流内。\n * 改用 `body { position: fixed }` + `top: -scrollY` 会让 body 脱离文档流:\n * `documentElement.scrollHeight` 随之塌缩、`window.scrollY` 被归零,\n * 按窗口偏移定位的虚拟列表因此渲染出第 0 行却仍带旧的 `top` 偏移,表现为整屏空白。\n *\n * 阻止滚动本身(`overflow`)与抑制橡皮筋/下拉刷新(`overscroll-behavior`)是两件事:\n * 旧实现用 fixed body 同时覆盖两者,现在拆成两个根元素属性。macOS 已实测\n * `overscroll-behavior: none` 有效;iOS 侧尚无真机证据,属待验收项。\n */\nexport function lockScroll() {\n if (lockCount === 0) {\n savedOverflow = document.documentElement.style.overflow\n savedOverscroll = document.documentElement.style.overscrollBehavior\n document.documentElement.style.overflow = 'hidden'\n document.documentElement.style.overscrollBehavior = 'none'\n }\n lockCount++\n}\n\n/**\n * 解锁页面滚动。与 lockScroll 配对,引用归零时恢复。\n */\nexport function unlockScroll() {\n lockCount = Math.max(0, lockCount - 1)\n if (lockCount === 0) {\n document.documentElement.style.overflow = savedOverflow\n document.documentElement.style.overscrollBehavior = savedOverscroll\n }\n}\n\n/**\n * 构建组件实例持有的滚动锁租约。每个租约仅释放自身获得的那一次锁,\n * 因此卸载、属性切换和嵌套 overlay 不会互相影响。\n */\nexport const defineScrollLockLease = () =>\n definePlugin<ScrollLockLease, Record<never, never>>(() => {\n let isLocked = false\n\n const sync = (shouldLock: boolean) => {\n if (shouldLock === isLocked) return\n if (shouldLock) lockScroll()\n else unlockScroll()\n isLocked = shouldLock\n }\n\n return {\n get isLocked() {\n return isLocked\n },\n\n sync,\n\n release() {\n sync(false)\n }\n }\n })\n"],"mappings":";;AAEA,IAAI,IAAY,GACZ,IAAgB,IAChB,IAAkB;AAoBtB,SAAgB,IAAa;CAO3B,AANI,MAAc,MAChB,IAAgB,SAAS,gBAAgB,MAAM,UAC/C,IAAkB,SAAS,gBAAgB,MAAM,oBACjD,SAAS,gBAAgB,MAAM,WAAW,UAC1C,SAAS,gBAAgB,MAAM,qBAAqB,SAEtD;AACF;AAKA,SAAgB,IAAe;CAE7B,AADA,IAAY,KAAK,IAAI,GAAG,IAAY,CAAC,GACjC,MAAc,MAChB,SAAS,gBAAgB,MAAM,WAAW,GAC1C,SAAS,gBAAgB,MAAM,qBAAqB;AAExD;AAMA,IAAa,UACX,QAA0D;CACxD,IAAI,IAAW,IAET,KAAQ,MAAwB;EAChC,MAAe,MACf,IAAY,EAAW,IACtB,EAAa,GAClB,IAAW;CACb;CAEA,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA;EAEA,UAAU;GACR,EAAK,EAAK;EACZ;CACF;AACF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"root-sync.js","names":[],"sources":["../../../src/shared/theme/root-sync.ts"],"sourcesContent":["/**\n * 根节点 page 色同步 —— 「哪一层最外层」的唯一拥有者。\n *\n * macOS 橡皮筋回弹露白的成因不在 body 而在画布:body 的背景只覆盖滚动范围,回弹时被扯出来的\n * 那一圈用的是 documentElement 的画布背景。而 `--wui-color-page` 定义在 `<web-ui-theme>` 的\n * shadow 作用域里,自定义属性的继承链到 documentElement 就断了,所以消费方写\n * `body { background: var(--wui-color-page) }` 也管不到回弹区。\n *\n * 本模块补上这一环:最外层 active theme 把自己计算出的 `--wui-color-page` 写到 documentElement\n * 的行内自定义属性上,消费方仍然只需要那一行 body 规则。\n *\n * 为什么按 connect 顺序取「第一个 active 实例」,而不是去判「DOM 上最外层的那一个」:\n * 组件无法可靠地回答后者——portal、shadow host 与 slot 都会让「谁包着谁」的物理关系和主题\n * 作用域不一致(`WebUiTheme._findThemeAncestor()` 正是为此单独实现了跨 shadow 边界的祖先链)。\n * root 值只可能有一个来源,按 connect 顺序取第一个是唯一不需要猜树形状的判据,且与用户视角\n * 一致:嵌套主题总是后 connect,先进页面那个主题范围就是盖住整页的那个。\n *\n * 其余取舍:\n *\n * - 同步权在已登记实例之间按同一顺序顺延:最外层断开时下一个已连接实例接管,不查 DOM。\n * 重新挂载的实例按新的一次 connect 排到队尾——队首才是当前 outermost。\n * - 写入值取该实例的**计算值**,因此消费者在 host 上覆盖 `--wui-color-page`(包括在自己的\n * 媒体查询里覆盖)会被原样镜像,主题与消费者的优先级关系不因此改变。\n * - 全部断开时**保留最后值**:清掉属性会让 body 背景在主题卸载/重挂的间隙闪回 UA 白底,\n * 而那正是本能力要消除的观感。代价是最后一个主题卸载后 documentElement 上仍留着一个自定义\n * 属性;消费者自己写 body 背景时它不产生任何影响。\n * - `appearance=\"system\"` 的 token 计算值仍由组件内部 CSS 的 `prefers-color-scheme` 决定;\n * root 同步与 `resolved-appearance` 输出共用同一个 MediaQueryList 监听,翻转时一次重算。\n * - 登记表强引用实例,但删除路径只有组件自己的 disconnectedCallback:自定义元素反应保证移除\n * 必然触发它,不存在 open-overlay 那种「面板被 portal 搬运」需要惰性兜底回收的形状。\n */\n\nconst ROOT_PAGE_TOKEN = '--wui-color-page'\n\n/** 按 connect 顺序排列的已登记 active 实例;队首持有 root 同步权。 */\nconst registered = new Set<HTMLElement>()\n\nlet schemeQuery: MediaQueryList | undefined\nconst schemeSubscribers = new Set<() => void>()\n\n/*\n * dev 期测试钩子:登记表尺寸。\n *\n * 「组件卸载后忘了撤销登记」是这层唯一的结构性风险,而 `registered` 无法从公开面观察——\n * 调用方只看见 documentElement 上的值,看不到注册表。有了它,泄漏在测试里是一个可直接断言的\n * 数字。`import.meta.env.DEV` 是编译期常量,生产构建里整段消失。\n */\ndeclare global {\n // eslint-disable-next-line no-var\n var __webUiThemeRootSyncCount: () => number\n}\n\nif (import.meta.env.DEV) {\n globalThis.__webUiThemeRootSyncCount = () => registered.size\n}\n\nfunction isRootSyncOwner(host: HTMLElement): boolean {\n return registered.values().next().value === host\n}\n\n/** 计算值可能为空(jsdom、未激活作用域、已断开),空值不写入也不删除既有值。 */\nfunction readPageColor(host: HTMLElement): string {\n return getComputedStyle(host).getPropertyValue(ROOT_PAGE_TOKEN).trim()\n}\n\nfunction syncRootPageColor(): void {\n const owner = registered.values().next().value as HTMLElement | undefined\n if (!owner) return\n // 已断开的所有者不能再写 root:整棵子树一起卸载时,队首顺延到的实例已经不在文档里\n // (浏览器对 detached 元素取不到计算值,jsdom 却仍按选择器匹配返回旧值),\n // 不设这道坎就会在两种环境里得到不同的 root 末值。\n if (!owner.isConnected) return\n const value = readPageColor(owner)\n if (!value) return\n const style = document.documentElement.style\n // 与当前行内值比较而不是与模块记忆的上次写入比较:消费者中途覆写时,下一次同步仍然夺回\n // 归属权,而值没变时不做无谓的样式写入。\n if (style.getPropertyValue(ROOT_PAGE_TOKEN) === value) return\n style.setProperty(ROOT_PAGE_TOKEN, value)\n}\n\nfunction handleSchemeChange(): void {\n syncRootPageColor()\n for (const subscriber of schemeSubscribers) subscriber()\n}\n\nfunction syncSchemeListener(): void {\n if (registered.size > 0 || schemeSubscribers.size > 0) {\n if (schemeQuery) return\n try {\n schemeQuery = window.matchMedia('(prefers-color-scheme: dark)')\n } catch {\n // jsdom 等没有 matchMedia 的环境:system 档跟随不到系统翻转,登记与输出仍保持当前值。\n return\n }\n schemeQuery.addEventListener('change', handleSchemeChange)\n return\n }\n if (!schemeQuery) return\n schemeQuery.removeEventListener('change', handleSchemeChange)\n schemeQuery = undefined\n}\n\n/*\n * 复用 root 同步已经持有的系统 scheme 监听:resolved-appearance 只登记回调,不另建 MediaQueryList。\n * 登记先于订阅或订阅先于登记都可以;syncSchemeListener 以两者任一非空为准。\n */\nexport function subscribeThemeSystemAppearanceChange(listener: () => void): () => void {\n schemeSubscribers.add(listener)\n syncSchemeListener()\n return () => {\n if (!schemeSubscribers.delete(listener)) return\n syncSchemeListener()\n }\n}\n\n/**\n * 登记一个 active theme。幂等,组件的 connectedCallback 与 updated() 可以无条件调用:\n * 已在表中的实例重复登记保留原队序,只有队首实例会刷新 root 值。\n */\nexport function registerThemeRootSync(host: HTMLElement): void {\n registered.add(host)\n syncSchemeListener()\n // 嵌套实例的变更按契约不影响 root 值;顺延不到新实例时 root 值同样不变。\n if (!isRootSyncOwner(host)) return\n syncRootPageColor()\n}\n\n/** 撤销登记。移交同步权时由新的队首实例立即接管 root 值。 */\nexport function unregisterThemeRootSync(host: HTMLElement): void {\n if (!registered.delete(host)) return\n syncSchemeListener()\n syncRootPageColor()\n}\n"],"mappings":";AAgCA,IAAM,IAAkB,oBAGlB,oBAAa,IAAI,IAAiB,GAEpC,GACE,oBAAoB,IAAI,IAAgB;AAkB9C,SAAS,EAAgB,GAA4B;CACnD,OAAO,EAAW,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,UAAU;AAC9C;AAGA,SAAS,EAAc,GAA2B;CAChD,OAAO,iBAAiB,CAAI,CAAC,CAAC,iBAAiB,CAAe,CAAC,CAAC,KAAK;AACvE;AAEA,SAAS,IAA0B;CACjC,IAAM,IAAQ,EAAW,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC;CAKzC,IAJI,CAAC,KAID,CAAC,EAAM,aAAa;CACxB,IAAM,IAAQ,EAAc,CAAK;CACjC,IAAI,CAAC,GAAO;CACZ,IAAM,IAAQ,SAAS,gBAAgB;CAGnC,EAAM,iBAAiB,CAAe,MAAM,KAChD,EAAM,YAAY,GAAiB,CAAK;AAC1C;AAEA,SAAS,IAA2B;CAClC,EAAkB;CAClB,KAAK,IAAM,KAAc,GAAmB,EAAW;AACzD;AAEA,SAAS,IAA2B;CAClC,IAAI,EAAW,OAAO,KAAK,EAAkB,OAAO,GAAG;EACrD,IAAI,GAAa;EACjB,IAAI;GACF,IAAc,OAAO,WAAW,8BAA8B;EAChE,QAAQ;GAEN;EACF;EACA,EAAY,iBAAiB,UAAU,CAAkB;EACzD;CACF;CACK,AAEL,OADA,EAAY,oBAAoB,UAAU,CAAkB,GAC9C,KAAA;AAChB;AAMA,SAAgB,EAAqC,GAAkC;CAGrF,OAFA,EAAkB,IAAI,CAAQ,GAC9B,EAAmB,SACN;EACN,EAAkB,OAAO,CAAQ,KACtC,EAAmB;CACrB;AACF;AAMA,SAAgB,EAAsB,GAAyB;CAC7D,EAAW,IAAI,CAAI,GACnB,EAAmB,GAEd,EAAgB,CAAI,KACzB,EAAkB;AACpB;AAGA,SAAgB,EAAwB,GAAyB;CAC1D,EAAW,OAAO,CAAI,MAC3B,EAAmB,GACnB,EAAkB;AACpB"}
1
+ {"version":3,"file":"root-sync.js","names":[],"sources":["../../../src/shared/theme/root-sync.ts"],"sourcesContent":["/**\n * 根节点 page 色同步 —— 「哪一层最外层」的唯一拥有者。\n *\n * macOS 橡皮筋回弹露白的成因不在 body 而在画布:body 的背景只覆盖滚动范围,回弹时被扯出来的\n * 那一圈用的是 documentElement 的画布背景。而 `--wui-color-page` 定义在 `<web-ui-theme>` 的\n * shadow 作用域里,自定义属性的继承链到 documentElement 就断了,所以消费方写\n * `body { background: var(--wui-color-page) }` 也管不到回弹区。\n *\n * 本模块补上这一环:最外层 active theme 把自己计算出的 `--wui-color-page` 写到 documentElement\n * 的行内自定义属性上,消费方仍然只需要那一行 body 规则。\n *\n * 为什么按 connect 顺序取「第一个 active 实例」,而不是去判「DOM 上最外层的那一个」:\n * 组件无法可靠地回答后者——portal、shadow host 与 slot 都会让「谁包着谁」的物理关系和主题\n * 作用域不一致(`WebUiTheme._findThemeAncestor()` 正是为此单独实现了跨 shadow 边界的祖先链)。\n * root 值只可能有一个来源,按 connect 顺序取第一个是唯一不需要猜树形状的判据,且与用户视角\n * 一致:嵌套主题总是后 connect,先进页面那个主题范围就是盖住整页的那个。\n *\n * 其余取舍:\n *\n * - 同步权在已登记实例之间按同一顺序顺延:最外层断开时下一个已连接实例接管,不查 DOM。\n * 重新挂载的实例按新的一次 connect 排到队尾——队首才是当前 outermost。\n * - 写入值取该实例的**计算值**,因此消费者在 host 上覆盖 `--wui-color-page`(包括在自己的\n * 媒体查询里覆盖)会被原样镜像,主题与消费者的优先级关系不因此改变。\n * - 全部断开时**保留最后值**:清掉属性会让 body 背景在主题卸载/重挂的间隙闪回 UA 白底,\n * 而那正是本能力要消除的观感。代价是最后一个主题卸载后 documentElement 上仍留着一个自定义\n * 属性;消费者自己写 body 背景时它不产生任何影响。\n * - `appearance=\"system\"` 的 token 计算值仍由组件内部 CSS 的 `prefers-color-scheme` 决定;\n * root 同步与 `resolved-appearance` 输出共用同一个 MediaQueryList 监听,翻转时一次重算。\n * - 登记表强引用实例,但删除路径只有组件自己的 disconnectedCallback:自定义元素反应保证移除\n * 必然触发它,不存在 open-overlay 那种「panel 被 portal 搬运」需要惰性兜底回收的形状。\n */\n\nconst ROOT_PAGE_TOKEN = '--wui-color-page'\n\n/** 按 connect 顺序排列的已登记 active 实例;队首持有 root 同步权。 */\nconst registered = new Set<HTMLElement>()\n\nlet schemeQuery: MediaQueryList | undefined\nconst schemeSubscribers = new Set<() => void>()\n\n/*\n * dev 期测试钩子:登记表尺寸。\n *\n * 「组件卸载后忘了撤销登记」是这层唯一的结构性风险,而 `registered` 无法从公开面观察——\n * 调用方只看见 documentElement 上的值,看不到注册表。有了它,泄漏在测试里是一个可直接断言的\n * 数字。`import.meta.env.DEV` 是编译期常量,生产构建里整段消失。\n */\ndeclare global {\n // eslint-disable-next-line no-var\n var __webUiThemeRootSyncCount: () => number\n}\n\nif (import.meta.env.DEV) {\n globalThis.__webUiThemeRootSyncCount = () => registered.size\n}\n\nfunction isRootSyncOwner(host: HTMLElement): boolean {\n return registered.values().next().value === host\n}\n\n/** 计算值可能为空(jsdom、未激活作用域、已断开),空值不写入也不删除既有值。 */\nfunction readPageColor(host: HTMLElement): string {\n return getComputedStyle(host).getPropertyValue(ROOT_PAGE_TOKEN).trim()\n}\n\nfunction syncRootPageColor(): void {\n const owner = registered.values().next().value as HTMLElement | undefined\n if (!owner) return\n // 已断开的所有者不能再写 root:整棵子树一起卸载时,队首顺延到的实例已经不在文档里\n // (浏览器对 detached 元素取不到计算值,jsdom 却仍按选择器匹配返回旧值),\n // 不设这道坎就会在两种环境里得到不同的 root 末值。\n if (!owner.isConnected) return\n const value = readPageColor(owner)\n if (!value) return\n const style = document.documentElement.style\n // 与当前行内值比较而不是与模块记忆的上次写入比较:消费者中途覆写时,下一次同步仍然夺回\n // 归属权,而值没变时不做无谓的样式写入。\n if (style.getPropertyValue(ROOT_PAGE_TOKEN) === value) return\n style.setProperty(ROOT_PAGE_TOKEN, value)\n}\n\nfunction handleSchemeChange(): void {\n syncRootPageColor()\n for (const subscriber of schemeSubscribers) subscriber()\n}\n\nfunction syncSchemeListener(): void {\n if (registered.size > 0 || schemeSubscribers.size > 0) {\n if (schemeQuery) return\n try {\n schemeQuery = window.matchMedia('(prefers-color-scheme: dark)')\n } catch {\n // jsdom 等没有 matchMedia 的环境:system 档跟随不到系统翻转,登记与输出仍保持当前值。\n return\n }\n schemeQuery.addEventListener('change', handleSchemeChange)\n return\n }\n if (!schemeQuery) return\n schemeQuery.removeEventListener('change', handleSchemeChange)\n schemeQuery = undefined\n}\n\n/*\n * 复用 root 同步已经持有的系统 scheme 监听:resolved-appearance 只登记回调,不另建 MediaQueryList。\n * 登记先于订阅或订阅先于登记都可以;syncSchemeListener 以两者任一非空为准。\n */\nexport function subscribeThemeSystemAppearanceChange(listener: () => void): () => void {\n schemeSubscribers.add(listener)\n syncSchemeListener()\n return () => {\n if (!schemeSubscribers.delete(listener)) return\n syncSchemeListener()\n }\n}\n\n/**\n * 登记一个 active theme。幂等,组件的 connectedCallback 与 updated() 可以无条件调用:\n * 已在表中的实例重复登记保留原队序,只有队首实例会刷新 root 值。\n */\nexport function registerThemeRootSync(host: HTMLElement): void {\n registered.add(host)\n syncSchemeListener()\n // 嵌套实例的变更按契约不影响 root 值;顺延不到新实例时 root 值同样不变。\n if (!isRootSyncOwner(host)) return\n syncRootPageColor()\n}\n\n/** 撤销登记。移交同步权时由新的队首实例立即接管 root 值。 */\nexport function unregisterThemeRootSync(host: HTMLElement): void {\n if (!registered.delete(host)) return\n syncSchemeListener()\n syncRootPageColor()\n}\n"],"mappings":";AAgCA,IAAM,IAAkB,oBAGlB,oBAAa,IAAI,IAAiB,GAEpC,GACE,oBAAoB,IAAI,IAAgB;AAkB9C,SAAS,EAAgB,GAA4B;CACnD,OAAO,EAAW,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,UAAU;AAC9C;AAGA,SAAS,EAAc,GAA2B;CAChD,OAAO,iBAAiB,CAAI,CAAC,CAAC,iBAAiB,CAAe,CAAC,CAAC,KAAK;AACvE;AAEA,SAAS,IAA0B;CACjC,IAAM,IAAQ,EAAW,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC;CAKzC,IAJI,CAAC,KAID,CAAC,EAAM,aAAa;CACxB,IAAM,IAAQ,EAAc,CAAK;CACjC,IAAI,CAAC,GAAO;CACZ,IAAM,IAAQ,SAAS,gBAAgB;CAGnC,EAAM,iBAAiB,CAAe,MAAM,KAChD,EAAM,YAAY,GAAiB,CAAK;AAC1C;AAEA,SAAS,IAA2B;CAClC,EAAkB;CAClB,KAAK,IAAM,KAAc,GAAmB,EAAW;AACzD;AAEA,SAAS,IAA2B;CAClC,IAAI,EAAW,OAAO,KAAK,EAAkB,OAAO,GAAG;EACrD,IAAI,GAAa;EACjB,IAAI;GACF,IAAc,OAAO,WAAW,8BAA8B;EAChE,QAAQ;GAEN;EACF;EACA,EAAY,iBAAiB,UAAU,CAAkB;EACzD;CACF;CACK,AAEL,OADA,EAAY,oBAAoB,UAAU,CAAkB,GAC9C,KAAA;AAChB;AAMA,SAAgB,EAAqC,GAAkC;CAGrF,OAFA,EAAkB,IAAI,CAAQ,GAC9B,EAAmB,SACN;EACN,EAAkB,OAAO,CAAQ,KACtC,EAAmB;CACrB;AACF;AAMA,SAAgB,EAAsB,GAAyB;CAC7D,EAAW,IAAI,CAAI,GACnB,EAAmB,GAEd,EAAgB,CAAI,KACzB,EAAkB;AACpB;AAGA,SAAgB,EAAwB,GAAyB;CAC1D,EAAW,OAAO,CAAI,MAC3B,EAAmB,GACnB,EAAkB;AACpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greypan/web-ui",
3
- "version": "10.2.0",
3
+ "version": "11.0.0",
4
4
  "description": "Lit-based web components for React, Vue, and vanilla JS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "@lit/context": "^1.1.6"
54
54
  },
55
55
  "devDependencies": {
56
- "@greypan/tsconfig": "1.0.5",
56
+ "@greypan/tsconfig": "0.0.0",
57
57
  "@iconify-json/akar-icons": "^1.2.7",
58
58
  "@iconify-json/bi": "^1.2.7",
59
59
  "@iconify-json/fluent": "^1.2.58",
@@ -1,6 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- width: number;
4
- height: number;
5
- };
6
- export default _default;
@@ -1,6 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- width: number;
4
- height: number;
5
- };
6
- export default _default;
@@ -1,8 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- left: number;
4
- top: number;
5
- width: number;
6
- height: number;
7
- };
8
- export default _default;
@@ -1,8 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- left: number;
4
- top: number;
5
- width: number;
6
- height: number;
7
- };
8
- export default _default;
@@ -1,6 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- width: number;
4
- height: number;
5
- };
6
- export default _default;
@@ -1,6 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- width: number;
4
- height: number;
5
- };
6
- export default _default;
@@ -1,6 +0,0 @@
1
- declare const _default: {
2
- body: string;
3
- width: number;
4
- height: number;
5
- };
6
- export default _default;