@celestia-island/hikari 0.4.4 → 0.30.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 (385) hide show
  1. package/README.md +1 -1
  2. package/package.json +22 -7
  3. package/src/DemoApp.tsx +491 -0
  4. package/src/animation/registerAnimations.test.ts +94 -0
  5. package/src/animation/registerAnimations.ts +103 -0
  6. package/src/components/HkAboutModal.scss +95 -0
  7. package/src/components/HkAboutModal.tsx +114 -0
  8. package/src/components/HkAdaptiveDialog.scss +22 -0
  9. package/src/components/HkAdaptiveDialog.test.tsx +271 -0
  10. package/src/components/HkAdaptiveDialog.tsx +167 -0
  11. package/src/components/HkAdminHeader.test.tsx +185 -0
  12. package/src/components/HkAdminHeader.tsx +308 -0
  13. package/src/components/HkAdminShell.test.tsx +237 -0
  14. package/src/components/HkAdminShell.tsx +129 -0
  15. package/src/components/HkAdminTablePage.tsx +70 -0
  16. package/src/components/HkAdminWidgets.test.ts +89 -0
  17. package/src/components/HkAlert.tsx +1 -1
  18. package/src/components/HkAltSignIn.scss +55 -0
  19. package/src/components/HkAltSignIn.test.tsx +88 -0
  20. package/src/components/HkAltSignIn.tsx +92 -0
  21. package/src/components/HkAttachmentModal.scss +49 -0
  22. package/src/components/HkAttachmentModal.tsx +318 -0
  23. package/src/components/HkAuthCard.tsx +50 -0
  24. package/src/components/HkAuthSubmitButton.scss +12 -0
  25. package/src/components/HkAuthSubmitButton.tsx +94 -0
  26. package/src/components/HkAvatar.scss +93 -0
  27. package/src/components/HkAvatar.tsx +2 -2
  28. package/src/components/HkBlockingToast.scss +123 -0
  29. package/src/components/HkBlockingToast.test.tsx +221 -0
  30. package/src/components/HkBlockingToast.tsx +182 -0
  31. package/src/components/HkBreadcrumb.scss +624 -0
  32. package/src/components/HkBreadcrumb.tsx +2 -2
  33. package/src/components/HkButton.scss +16 -2
  34. package/src/components/HkCaptchaModal.scss +14 -0
  35. package/src/components/HkCaptchaModal.tsx +62 -0
  36. package/src/components/HkCaptchaWidget.scss +30 -0
  37. package/src/components/HkCaptchaWidget.tsx +181 -0
  38. package/src/components/HkCard.scss +1 -1
  39. package/src/components/HkChatTypes.ts +65 -0
  40. package/src/components/HkCheckbox.scss +4 -9
  41. package/src/components/HkCheckbox.tsx +18 -6
  42. package/src/components/HkColorPicker.scss +120 -3
  43. package/src/components/HkColorPicker.tsx +130 -19
  44. package/src/components/HkColorPickerTouch.test.tsx +145 -0
  45. package/src/components/HkColorSchemeDialog.scss +52 -0
  46. package/src/components/HkColorSchemeDialog.test.ts +201 -0
  47. package/src/components/HkColorSchemeDialog.tsx +96 -0
  48. package/src/components/HkColorSchemeEditor.test.ts +270 -0
  49. package/src/components/HkColorSchemeEditor.tsx +410 -0
  50. package/src/components/HkConfirmDialog.scss +43 -3
  51. package/src/components/HkConfirmDialog.tsx +7 -4
  52. package/src/components/HkConnectionInfo.ts +80 -0
  53. package/src/components/HkConnectionStatus.tsx +158 -0
  54. package/src/components/HkCookieConsent.scss +22 -0
  55. package/src/components/HkCookieConsent.test.tsx +70 -0
  56. package/src/components/HkCookieConsent.tsx +56 -0
  57. package/src/components/HkCountdownDigit.tsx +105 -0
  58. package/src/components/HkDatePicker.scss +361 -0
  59. package/src/components/HkDatePicker.test.ts +459 -0
  60. package/src/components/HkDatePicker.tsx +568 -0
  61. package/src/components/HkDateTimePicker.scss +65 -37
  62. package/src/components/HkDateTimePicker.test.ts +344 -0
  63. package/src/components/HkDateTimePicker.tsx +249 -82
  64. package/src/components/HkDraggableGrid.scss +4 -1
  65. package/src/components/HkDraggableList.scss +4 -1
  66. package/src/components/HkDrawer.scss +27 -13
  67. package/src/components/HkDrawer.test.tsx +73 -0
  68. package/src/components/HkDrawer.tsx +157 -10
  69. package/src/components/HkErrorBoundary.scss +5 -2
  70. package/src/components/HkErrorBoundary.tsx +7 -4
  71. package/src/components/HkExpansionPanel.scss +143 -0
  72. package/src/components/HkExpansionPanel.test.tsx +109 -0
  73. package/src/components/HkExpansionPanel.tsx +101 -0
  74. package/src/components/HkFab.scss +162 -0
  75. package/src/components/HkFab.test.tsx +217 -0
  76. package/src/components/HkFab.tsx +214 -0
  77. package/src/components/HkHoverRevealAction.scss +6 -0
  78. package/src/components/HkHoverRevealAction.test.ts +68 -0
  79. package/src/components/HkHoverRevealAction.tsx +84 -6
  80. package/src/components/HkIcon.scss +123 -0
  81. package/src/components/HkIcon.tsx +7 -23
  82. package/src/components/HkIconButton.scss +170 -0
  83. package/src/components/HkIconButton.test.tsx +75 -0
  84. package/src/components/HkIconButton.tsx +4 -3
  85. package/src/components/HkIconButtonVars.scss +180 -0
  86. package/src/components/HkInput.affix.test.ts +253 -0
  87. package/src/components/HkInput.enter.test.ts +88 -0
  88. package/src/components/HkInput.scss +127 -8
  89. package/src/components/HkInput.tsx +245 -13
  90. package/src/components/HkKeywordSearchModal.scss +13 -0
  91. package/src/components/HkKeywordSearchModal.tsx +46 -9
  92. package/src/components/HkLabel.scss +34 -0
  93. package/src/components/HkLabel.test.tsx +61 -0
  94. package/src/components/HkLabel.tsx +45 -0
  95. package/src/components/HkListTransition.scss +26 -0
  96. package/src/components/HkListTransition.tsx +57 -0
  97. package/src/components/HkLocalePicker.scss +37 -0
  98. package/src/components/HkLocalePicker.tsx +133 -0
  99. package/src/components/HkLocalePickerPopup.tsx +35 -19
  100. package/src/components/HkLocalizedInput.scss +277 -0
  101. package/src/components/HkLocalizedInput.test.tsx +693 -0
  102. package/src/components/HkLocalizedInput.tsx +495 -0
  103. package/src/components/HkLogWindow.scss +121 -0
  104. package/src/components/HkLogWindow.tsx +195 -0
  105. package/src/components/HkLogo.scss +1 -1
  106. package/src/components/HkLogo.tsx +1 -1
  107. package/src/components/HkMarkdownRenderer.scss +22 -2
  108. package/src/components/HkMarkdownRenderer.tsx +33 -3
  109. package/src/components/HkMediaControlBar.tsx +20 -13
  110. package/src/components/HkMenu.scss +200 -0
  111. package/src/components/HkMenu.test.ts +779 -0
  112. package/src/components/HkMenu.tsx +770 -0
  113. package/src/components/HkMenuActionItem.scss +58 -0
  114. package/src/components/HkMenuActionItem.tsx +41 -0
  115. package/src/components/HkMenuIdentityItem.scss +70 -0
  116. package/src/components/HkMenuIdentityItem.tsx +42 -0
  117. package/src/components/HkMenuPanel.scss +20 -0
  118. package/src/components/HkMenuPanel.test.tsx +118 -0
  119. package/src/components/HkMenuPanel.tsx +32 -0
  120. package/src/components/HkMinimap.tsx +16 -8
  121. package/src/components/HkModal.autofollow.test.ts +44 -0
  122. package/src/components/HkModal.scss +170 -53
  123. package/src/components/HkModal.sheetgap.test.ts +82 -0
  124. package/src/components/HkModal.test.tsx +334 -0
  125. package/src/components/HkModal.tsx +314 -68
  126. package/src/components/HkModalBreadcrumb.scss +4 -1
  127. package/src/components/HkModalBreadcrumb.test.tsx +111 -0
  128. package/src/components/HkModalBreadcrumb.tsx +34 -11
  129. package/src/components/HkModelCatalog.ts +98 -0
  130. package/src/components/HkModelTag.scss +130 -0
  131. package/src/components/HkModelTag.test.tsx +111 -0
  132. package/src/components/HkModelTag.tsx +190 -0
  133. package/src/components/HkNavItem.scss +13 -2
  134. package/src/components/HkNavItem.tsx +2 -1
  135. package/src/components/HkNavSidebar.tsx +99 -0
  136. package/src/components/HkNumberInput.scss +12 -0
  137. package/src/components/HkNumberInput.tsx +15 -8
  138. package/src/components/HkPageHeader.scss +79 -0
  139. package/src/components/HkPageHeader.test.ts +67 -0
  140. package/src/components/HkPageHeader.tsx +51 -0
  141. package/src/components/HkPasswordInput.scss +29 -17
  142. package/src/components/HkPasswordInput.test.ts +281 -0
  143. package/src/components/HkPasswordInput.tsx +337 -59
  144. package/src/components/HkPickerPane.scss +158 -0
  145. package/src/components/HkPlaceholderMarquee.scss +109 -0
  146. package/src/components/HkPlaceholderMarquee.test.ts +305 -0
  147. package/src/components/HkPlaceholderMarquee.tsx +170 -0
  148. package/src/components/HkPopover.scss +134 -10
  149. package/src/components/HkPopover.sheetdock.test.ts +34 -0
  150. package/src/components/HkPopover.surfacevars.test.ts +20 -0
  151. package/src/components/HkPopover.titleSuppress.test.tsx +265 -0
  152. package/src/components/HkPopover.tsx +225 -50
  153. package/src/components/HkPopoverSheet.test.tsx +130 -0
  154. package/src/components/HkPopupSelect.scss +30 -10
  155. package/src/components/HkPopupSelect.tsx +21 -1
  156. package/src/components/HkProgressDialog.scss +31 -0
  157. package/src/components/HkProgressDialog.tsx +37 -19
  158. package/src/components/HkProtocolModal.scss +26 -0
  159. package/src/components/HkProtocolModal.tsx +112 -0
  160. package/src/components/HkRadio.scss +1 -16
  161. package/src/components/HkRadio.tsx +5 -2
  162. package/src/components/HkRichInput.scss +162 -0
  163. package/src/components/HkRichInput.tsx +335 -0
  164. package/src/components/HkRollingNumber.test.ts +90 -0
  165. package/src/components/HkRollingNumber.tsx +39 -1
  166. package/src/components/HkScrollContainer.scss +71 -54
  167. package/src/components/HkScrollContainer.test.ts +160 -0
  168. package/src/components/HkScrollContainer.tsx +187 -160
  169. package/src/components/HkSearchInput.tsx +3 -3
  170. package/src/components/HkSecretRevealModal.scss +61 -0
  171. package/src/components/HkSecretRevealModal.tsx +91 -0
  172. package/src/components/HkSelect.scss +263 -12
  173. package/src/components/HkSelect.sheetdock.test.ts +30 -0
  174. package/src/components/HkSelect.test.tsx +88 -0
  175. package/src/components/HkSelect.tsx +59 -74
  176. package/src/components/HkSelectPanel.test.tsx +666 -0
  177. package/src/components/HkSelectPanel.tsx +595 -0
  178. package/src/components/HkSelectSheet.test.tsx +132 -0
  179. package/src/components/HkSelectionGrid.scss +1 -1
  180. package/src/components/HkSelectionGrid.tsx +2 -1
  181. package/src/components/HkSelectionWaterfall.scss +1 -1
  182. package/src/components/HkShareBar.scss +40 -0
  183. package/src/components/HkShareBar.tsx +50 -0
  184. package/src/components/HkSidebar.scss +15 -10
  185. package/src/components/HkSidebar.tsx +26 -2
  186. package/src/components/HkSignInCard.test.ts +154 -0
  187. package/src/components/HkSignInCard.tsx +155 -0
  188. package/src/components/HkSlider.scss +99 -0
  189. package/src/components/HkSlider.test.tsx +155 -0
  190. package/src/components/HkSlider.tsx +173 -0
  191. package/src/components/HkSpinner.scss +36 -1
  192. package/src/components/HkSpinner.test.tsx +63 -0
  193. package/src/components/HkSpinner.tsx +16 -1
  194. package/src/components/HkSplash.scss +1 -1
  195. package/src/components/HkSplash.tsx +3 -1
  196. package/src/components/HkStatCard.scss +73 -0
  197. package/src/components/HkStatCard.tsx +46 -0
  198. package/src/components/HkStatusBar.test.tsx +178 -0
  199. package/src/components/HkStatusBar.tsx +379 -0
  200. package/src/components/HkStatusPill.scss +73 -0
  201. package/src/components/HkStatusPill.tsx +41 -0
  202. package/src/components/HkStepFlow.scss +75 -0
  203. package/src/components/HkStepFlow.test.tsx +246 -0
  204. package/src/components/HkStepFlow.tsx +115 -0
  205. package/src/components/HkSwitch.scss +5 -17
  206. package/src/components/HkSwitch.tsx +6 -3
  207. package/src/components/HkTable.scss +20 -1
  208. package/src/components/HkTable.tsx +28 -3
  209. package/src/components/HkTabs.scss +222 -48
  210. package/src/components/HkTabs.test.ts +517 -0
  211. package/src/components/HkTabs.tsx +567 -56
  212. package/src/components/HkTag.tsx +2 -1
  213. package/src/components/HkTextarea.tsx +5 -0
  214. package/src/components/HkThemeToggle.scss +232 -0
  215. package/src/components/HkThemeToggle.test.ts +231 -0
  216. package/src/components/HkThemeToggle.tsx +289 -0
  217. package/src/components/HkTimeline.scss +134 -1
  218. package/src/components/HkTimeline.test.tsx +319 -0
  219. package/src/components/HkTimeline.tsx +303 -54
  220. package/src/components/HkToast.scss +34 -5
  221. package/src/components/HkToast.test.tsx +109 -0
  222. package/src/components/HkToast.tsx +57 -8
  223. package/src/components/HkTokenUsageBadge.scss +53 -0
  224. package/src/components/HkTokenUsageBadge.test.tsx +59 -0
  225. package/src/components/HkTokenUsageBadge.tsx +54 -0
  226. package/src/components/HkTokenUsagePanel.scss +95 -0
  227. package/src/components/HkTokenUsagePanel.tsx +140 -0
  228. package/src/components/HkToolBlock.scss +428 -0
  229. package/src/components/HkToolBlock.tsx +639 -0
  230. package/src/components/HkTooltip.scss +14 -2
  231. package/src/components/HkTooltip.tsx +25 -2
  232. package/src/components/HkTrendChart.tsx +27 -16
  233. package/src/components/HkVoiceInputPopup.scss +77 -0
  234. package/src/components/HkVoiceInputPopup.tsx +97 -0
  235. package/src/components/HkZoomToolbar.scss +4 -1
  236. package/src/components/HkZoomToolbar.tsx +7 -4
  237. package/src/components/_hikari-vars.scss +6 -0
  238. package/src/composables/highlightLanguages.ts +109 -0
  239. package/src/composables/iconRegistry.ts +127 -0
  240. package/src/composables/messaging/browserTransport.ts +96 -0
  241. package/src/composables/messaging/index.ts +204 -0
  242. package/src/composables/messaging/messaging.test.ts +256 -0
  243. package/src/composables/messaging/toastTransport.ts +26 -0
  244. package/src/composables/messaging/types.ts +115 -0
  245. package/src/composables/useActionBar.ts +66 -0
  246. package/src/composables/useAttachments.ts +154 -0
  247. package/src/composables/useConnectionProbe.ts +142 -0
  248. package/src/composables/useDeferredTransition.ts +16 -0
  249. package/src/composables/useEngineHealth.ts +69 -0
  250. package/src/composables/useHighlight.ts +124 -0
  251. package/src/composables/useMeasuredHighlight.ts +150 -0
  252. package/src/composables/useOverlayScrollbar.test.ts +221 -0
  253. package/src/composables/useOverlayScrollbar.ts +296 -0
  254. package/src/composables/usePageTitle.ts +37 -0
  255. package/src/composables/useReducedMotion.ts +34 -0
  256. package/src/composables/useResourceListModal.tsx +147 -0
  257. package/src/composables/useSafeArea.ts +137 -0
  258. package/src/composables/useSendShortcut.ts +35 -0
  259. package/src/composables/useSizeMorph.test.ts +156 -0
  260. package/src/composables/useSizeMorph.ts +149 -0
  261. package/src/composables/useSurfaceTransition.test.ts +96 -0
  262. package/src/composables/useSurfaceTransition.ts +71 -0
  263. package/src/composables/useZoomPan.ts +192 -0
  264. package/src/demo.scss +9 -1
  265. package/src/demo.ts +5 -1
  266. package/src/i18n/locales/ar/chat.json +38 -0
  267. package/src/i18n/locales/ar/components.json +141 -40
  268. package/src/i18n/locales/ar/components.toml +59 -0
  269. package/src/i18n/locales/ar/platform.json +65 -0
  270. package/src/i18n/locales/de/chat.json +38 -0
  271. package/src/i18n/locales/de/components.json +139 -38
  272. package/src/i18n/locales/de/components.toml +59 -0
  273. package/src/i18n/locales/de/platform.json +65 -0
  274. package/src/i18n/locales/en/chat.json +38 -0
  275. package/src/i18n/locales/en/components.json +104 -3
  276. package/src/i18n/locales/en/components.toml +59 -0
  277. package/src/i18n/locales/en/platform.json +65 -0
  278. package/src/i18n/locales/es/chat.json +38 -0
  279. package/src/i18n/locales/es/components.json +141 -40
  280. package/src/i18n/locales/es/components.toml +59 -0
  281. package/src/i18n/locales/es/platform.json +65 -0
  282. package/src/i18n/locales/fr/chat.json +38 -0
  283. package/src/i18n/locales/fr/components.json +137 -36
  284. package/src/i18n/locales/fr/components.toml +59 -0
  285. package/src/i18n/locales/fr/platform.json +65 -0
  286. package/src/i18n/locales/ja/chat.json +38 -0
  287. package/src/i18n/locales/ja/components.json +104 -3
  288. package/src/i18n/locales/ja/components.toml +59 -0
  289. package/src/i18n/locales/ja/platform.json +65 -0
  290. package/src/i18n/locales/ko/chat.json +38 -0
  291. package/src/i18n/locales/ko/components.json +104 -3
  292. package/src/i18n/locales/ko/components.toml +59 -0
  293. package/src/i18n/locales/ko/platform.json +65 -0
  294. package/src/i18n/locales/pt/chat.json +38 -0
  295. package/src/i18n/locales/pt/components.json +141 -40
  296. package/src/i18n/locales/pt/components.toml +59 -0
  297. package/src/i18n/locales/pt/platform.json +65 -0
  298. package/src/i18n/locales/ru/chat.json +38 -0
  299. package/src/i18n/locales/ru/components.json +141 -40
  300. package/src/i18n/locales/ru/components.toml +59 -0
  301. package/src/i18n/locales/ru/platform.json +65 -0
  302. package/src/i18n/locales/zh-Hans/chat.json +38 -0
  303. package/src/i18n/locales/zh-Hans/components.json +109 -4
  304. package/src/i18n/locales/zh-Hans/components.toml +59 -0
  305. package/src/i18n/locales/zh-Hans/platform.json +65 -0
  306. package/src/i18n/locales/zh-Hant/chat.json +38 -0
  307. package/src/i18n/locales/zh-Hant/components.json +109 -4
  308. package/src/i18n/locales/zh-Hant/components.toml +59 -0
  309. package/src/i18n/locales/zh-Hant/platform.json +65 -0
  310. package/src/index.ts +225 -6
  311. package/src/plugins/bootScriptsPlugin.ts +47 -0
  312. package/src/plugins/buildHashPlugin.ts +67 -0
  313. package/src/plugins/index.ts +2 -0
  314. package/src/render.ts +1 -1
  315. package/src/runtime/animationBus.test.ts +112 -0
  316. package/src/runtime/animationBus.ts +33 -2
  317. package/src/runtime/backStack.test.ts +334 -0
  318. package/src/runtime/backStack.ts +377 -0
  319. package/src/runtime/env.ts +4 -0
  320. package/src/runtime/index.ts +15 -3
  321. package/src/runtime/intervalBus.test.ts +118 -0
  322. package/src/runtime/intervalBus.ts +189 -0
  323. package/src/runtime/mobileViewport.test.ts +132 -0
  324. package/src/runtime/mobileViewport.ts +157 -0
  325. package/src/runtime/pageLifecycle.ts +91 -0
  326. package/src/runtime/useBlockingToast.ts +112 -0
  327. package/src/runtime/useClipboard.ts +28 -0
  328. package/src/runtime/useConfirm.ts +15 -7
  329. package/src/runtime/useMediaQuery.test.ts +67 -0
  330. package/src/runtime/useMediaQuery.ts +89 -0
  331. package/src/runtime/useOverlay.test.ts +160 -0
  332. package/src/runtime/useOverlay.ts +59 -12
  333. package/src/runtime/usePopupManager.test.ts +200 -0
  334. package/src/runtime/usePopupManager.ts +106 -10
  335. package/src/runtime/useToast.test.ts +90 -0
  336. package/src/runtime/useToast.ts +20 -2
  337. package/src/styles/admin-tokens.scss +744 -0
  338. package/src/styles/index.scss +5 -3
  339. package/src/styles/theme/_glass.scss +33 -0
  340. package/src/styles/theme/_layout.scss +123 -0
  341. package/src/styles/theme/_scrollbar.scss +73 -0
  342. package/src/styles/theme/_tokens.scss +69 -0
  343. package/src/styles/theme/base.scss +617 -0
  344. package/src/styles/theme/foundation.scss +219 -0
  345. package/src/styles/theme/mixins.scss +793 -0
  346. package/src/styles/theme/themes.scss +25 -0
  347. package/src/styles/theme/variables.scss +125 -0
  348. package/src/theme/_field.scss +116 -0
  349. package/src/theme/fontContext.test.ts +179 -0
  350. package/src/theme/fontContext.ts +169 -0
  351. package/src/theme/index.ts +19 -2
  352. package/src/theme/presets.ts +86 -18
  353. package/src/theme/theme.scss +61 -1
  354. package/src/theme/tokenGroups.test.ts +534 -0
  355. package/src/theme/tokenGroups.ts +552 -0
  356. package/src/theme/useSolarTime.test.ts +113 -0
  357. package/src/theme/useSolarTime.ts +95 -20
  358. package/src/theme/useTheme.test.ts +72 -0
  359. package/src/theme/useTheme.ts +144 -9
  360. package/src/tokens.scss +110 -0
  361. package/src/utils/base64.ts +31 -0
  362. package/src/utils/connectivity.ts +51 -0
  363. package/src/utils/dom.test.ts +131 -0
  364. package/src/utils/dom.ts +133 -0
  365. package/src/utils/download.ts +22 -0
  366. package/src/utils/fileType.ts +127 -0
  367. package/src/utils/format.ts +98 -0
  368. package/src/utils/fuzzy.ts +77 -0
  369. package/src/utils/fuzzySearch.test.ts +87 -0
  370. package/src/utils/healthProbe.ts +64 -0
  371. package/src/utils/objectPath.ts +59 -0
  372. package/src/utils/password.ts +53 -0
  373. package/src/utils/pow.test.ts +45 -0
  374. package/src/utils/pow.ts +226 -0
  375. package/src/utils/powNonce.ts +95 -0
  376. package/src/utils/validation.test.ts +78 -0
  377. package/src/DemoApp.vue +0 -400
  378. package/src/components/HkCollapse.tsx +0 -48
  379. package/src/components/HkMorphingTabs.scss +0 -141
  380. package/src/components/HkMorphingTabs.tsx +0 -113
  381. package/src/composables/useBreakpoint.ts +0 -41
  382. package/src/composables/useClipboard.ts +0 -37
  383. package/src/composables/useConfirm.ts +0 -35
  384. package/src/composables/useToast.ts +0 -2
  385. package/src/runtime/useAsyncData.ts +0 -30
@@ -0,0 +1,170 @@
1
+ import { defineComponent, ref, computed, onMounted, onBeforeUnmount, watch, type PropType } from "vue";
2
+
3
+ /**
4
+ * Overflow strategy for a placeholder that does not fit its input.
5
+ *
6
+ * - `marquee` (default): while the text fits, the overlay stays in the DOM
7
+ * only as an invisible measuring probe and the host input shows its NATIVE
8
+ * placeholder (crisp, accessible, zero animation). When the text overflows
9
+ * the input line, the overlay becomes visible, renders the text three
10
+ * times side by side inside a clipping window, and translates the strip
11
+ * leftward in a loop — the classic scrolling storefront sign. The strip
12
+ * content is identical in all three copies so the wrap-around is seamless.
13
+ *
14
+ * The motion is PURE CSS: one registered infinite keyframes animation
15
+ * (`hk-placeholder-marquee-scroll`, see animation/registerAnimations.ts)
16
+ * whose loop distance and duration live on inline custom properties
17
+ * (`--hk-marquee-shift` / `--hk-marquee-duration`), measured once per
18
+ * geometry change — never per frame. There is no JS/rAF animation and no
19
+ * reactive per-frame transform write, so scrolling the page (or a mobile
20
+ * bottom sheet) never competes with the strip for the main thread; the
21
+ * animation runs on the compositor. The animation-context switch
22
+ * (`setReducedMotion` / performance suspension via
23
+ * `html[data-css-animations="0"]`) parks the strip, and the strip also
24
+ * parks while the host input is focused (`data-parked`).
25
+ *
26
+ * The `overflowChange` event tells the host when to swap its native
27
+ * placeholder for the scrolling overlay.
28
+ * - `truncate`: hard-cut the text at the container edge with an ellipsis.
29
+ */
30
+ export type PlaceholderVariant = "marquee" | "truncate";
31
+
32
+ /** Horizontal spacing between repeated copies — mirrors the scss copy
33
+ * padding-right; kept in sync so overflow detection measures reality. */
34
+ const COPY_SPACING = 24;
35
+
36
+ export const HkPlaceholderMarquee = defineComponent({
37
+ name: "HkPlaceholderMarquee",
38
+ props: {
39
+ text: { type: String, default: "" },
40
+ variant: { type: String as PropType<PlaceholderVariant>, default: "marquee" },
41
+ /** Pixels per second of strip travel. Negative scrolls rightward. */
42
+ speed: { type: Number, default: 24 },
43
+ },
44
+ emits: {
45
+ overflowChange: (_overflowing: boolean) => true,
46
+ },
47
+ setup(props, { emit, expose }) {
48
+ const hostEl = ref<HTMLElement | null>(null);
49
+ const firstCopyEl = ref<HTMLElement | null>(null);
50
+ const overflowing = ref(false);
51
+ const parked = ref(false);
52
+ /** One copy's laid-out width (incl. trailing spacing) — reactive so
53
+ * the loop geometry republishes when it changes even if the
54
+ * overflow state does not (zoom, font swap, text edit mid-overflow). */
55
+ const loopWidth = ref(0);
56
+
57
+ let ro: ResizeObserver | null = null;
58
+
59
+ const measure = () => {
60
+ const host = hostEl.value;
61
+ const copy = firstCopyEl.value;
62
+ if (!host || !copy) return;
63
+ // Measure ONE copy's laid-out width directly (it includes the 24px
64
+ // copy spacing as trailing padding). Reading the first copy element —
65
+ // instead of stripWidth / 3 — stays correct in the fitting case,
66
+ // where the strip holds a single copy, and through overflow flips,
67
+ // where the copy count changes under the same strip. The fractional
68
+ // bounding-rect width keeps the CSS loop shift bit-exact with the
69
+ // laid-out advance — an integer-rounded width would show a ≤0.5px
70
+ // seam at every wrap (the copy rides the animated strip, so the
71
+ // width itself is never affected by the strip's transform).
72
+ const copyWidth = copy.getBoundingClientRect().width;
73
+ loopWidth.value = copyWidth;
74
+ overflowing.value = copyWidth - COPY_SPACING > host.clientWidth;
75
+ emit("overflowChange", overflowing.value);
76
+ };
77
+
78
+ onMounted(() => {
79
+ measure();
80
+ if (typeof ResizeObserver !== "undefined" && hostEl.value) {
81
+ ro = new ResizeObserver(() => measure());
82
+ ro.observe(hostEl.value);
83
+ }
84
+ });
85
+
86
+ watch(() => props.text, () => {
87
+ // Re-measure after the DOM paints the new strip.
88
+ requestAnimationFrame(() => measure());
89
+ });
90
+
91
+ onBeforeUnmount(() => {
92
+ ro?.disconnect();
93
+ });
94
+
95
+ expose({
96
+ /** Called by the host input: focus parks the strip, blur resumes. */
97
+ setActive(active: boolean) {
98
+ parked.value = active;
99
+ },
100
+ measure,
101
+ });
102
+
103
+ /** Loop geometry, published as custom properties + a direction flag
104
+ * on the strip: the keyframes animate
105
+ * `translateX(0 → var(--hk-marquee-shift))` over
106
+ * `var(--hk-marquee-duration)` — both measured, never recomputed per
107
+ * frame. Duration = one copy width at the configured px/s speed;
108
+ * the sign of the speed flips the direction (reverse plays the
109
+ * keyframes backwards, which wraps seamlessly on the three copies).
110
+ * A zero speed parks the strip (no scroll class) — matching the
111
+ * pre-CSS behavior, where the JS loop simply never advanced
112
+ * (non-finite speeds fall back to the default rate). */
113
+ const stripVars = computed(() => {
114
+ if (!overflowing.value || loopWidth.value <= 0) return undefined;
115
+ const speed = Number.isFinite(props.speed) ? Math.abs(props.speed) : 24;
116
+ if (speed === 0) return undefined;
117
+ return {
118
+ "--hk-marquee-shift": `${-loopWidth.value}px`,
119
+ "--hk-marquee-duration": `${(loopWidth.value / speed).toFixed(3)}s`,
120
+ "--hk-marquee-direction": (props.speed ?? 24) < 0 ? "reverse" : "normal",
121
+ } as Record<string, string>;
122
+ });
123
+
124
+ return () => {
125
+ if (!props.text) return null;
126
+ if (props.variant === "truncate") {
127
+ return (
128
+ <span ref={hostEl} class="hk-placeholder-marquee hk-placeholder-marquee--truncate">
129
+ <span class="hk-placeholder-marquee__text">{props.text}</span>
130
+ </span>
131
+ );
132
+ }
133
+ return (
134
+ <span
135
+ ref={hostEl}
136
+ class={[
137
+ "hk-placeholder-marquee",
138
+ // While the text fits the overlay stays hidden (but laid out,
139
+ // so the probe keeps measuring) and the input's NATIVE
140
+ // placeholder does the showing.
141
+ overflowing.value ? "" : "hk-placeholder-marquee--hidden",
142
+ ].filter(Boolean).join(" ")}
143
+ data-parked={parked.value || undefined}
144
+ aria-hidden="true"
145
+ >
146
+ <span
147
+ class={[
148
+ "hk-placeholder-marquee__strip",
149
+ // The sweep only starts once the loop geometry is known —
150
+ // overflowing text with a parked speed (0/non-finite) stays
151
+ // a static strip, matching the old JS behavior.
152
+ overflowing.value && stripVars.value ? "hk-placeholder-marquee__strip--scroll" : "",
153
+ ].filter(Boolean).join(" ")}
154
+ style={stripVars.value}
155
+ >
156
+ <span ref={firstCopyEl} class="hk-placeholder-marquee__copy">{props.text}</span>
157
+ {overflowing.value && (
158
+ <>
159
+ <span class="hk-placeholder-marquee__copy">{props.text}</span>
160
+ <span class="hk-placeholder-marquee__copy">{props.text}</span>
161
+ </>
162
+ )}
163
+ </span>
164
+ </span>
165
+ );
166
+ };
167
+ },
168
+ });
169
+
170
+ export default HkPlaceholderMarquee;
@@ -9,33 +9,45 @@
9
9
  max-height: min(70vh, calc(100vh - 2 * 8px));
10
10
  }
11
11
 
12
+ /* Pop motion family tokens (--hk-pop-* in theme.scss) — the reference
13
+ * consumer. HkSelectPanel's desktop popout and HkTooltip use the same
14
+ * set so every anchored surface pops with one curve. */
12
15
  .hk-popover-enter-active {
13
16
  transition:
14
- opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1),
15
- transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
17
+ opacity var(--hk-pop-in-duration, 0.2s)
18
+ var(--hk-pop-in-opacity-ease, cubic-bezier(0.16, 1, 0.3, 1)),
19
+ transform var(--hk-pop-in-duration, 0.2s)
20
+ var(--hk-pop-in-transform-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
16
21
  }
17
22
 
18
23
  .hk-popover-leave-active {
19
24
  transition:
20
- opacity 0.15s cubic-bezier(0.4, 0, 1, 1),
21
- transform 0.15s cubic-bezier(0.4, 0, 1, 1);
25
+ opacity var(--hk-pop-out-duration, 0.15s)
26
+ var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1)),
27
+ transform var(--hk-pop-out-duration, 0.15s)
28
+ var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1));
22
29
  }
23
30
 
24
31
  .hk-popover-enter-from {
25
32
  opacity: 0;
26
- transform: translateY(-4px) scale(0.97);
33
+ transform: translateY(calc(-1 * var(--hk-pop-offset, 4px)))
34
+ scale(var(--hk-pop-scale, 0.97));
27
35
  }
28
36
 
29
37
  .hk-popover-leave-to {
30
38
  opacity: 0;
31
- transform: translateY(4px) scale(0.97);
39
+ transform: translateY(var(--hk-pop-offset, 4px))
40
+ scale(var(--hk-pop-scale, 0.97));
32
41
  }
33
42
 
34
- /* Glass dropdown content — used when `glass` prop is true */
43
+ /* Glass dropdown content — used when `glass` prop is true.
44
+ --hk-popover-bg/--hk-popover-blur let a host app's surface-finish
45
+ preference (chest html[data-surface]) re-skin this layer; the defaults
46
+ keep the historical look. */
35
47
  .hii-dropdown-content,
36
48
  .hk-popover-panel.hii-dropdown-content {
37
- background: rgb(var(--color-surface));
38
- backdrop-filter: blur(var(--blur-lg)) saturate(1.2);
49
+ background: var(--hk-popover-bg, rgb(var(--color-surface)));
50
+ backdrop-filter: var(--hk-popover-blur, blur(var(--blur-lg)) saturate(1.2));
39
51
  border: 1px solid rgb(var(--color-border) / 20%);
40
52
  border-radius: var(--radius-sm);
41
53
  box-shadow: var(--shadow-dropdown);
@@ -62,7 +74,9 @@
62
74
  border-radius: var(--radius-sm);
63
75
  }
64
76
 
65
- .hk-popup-menu-item:hover {
77
+ /* Hover = background wash only, never on the active item (active >
78
+ hover precedence — see HkNavItem.scss for the spec). */
79
+ .hk-popup-menu-item:hover:not([data-active]) {
66
80
  background: var(--c-primary-subtle);
67
81
  }
68
82
 
@@ -80,3 +94,113 @@
80
94
  font-size: var(--text-base);
81
95
  line-height: 1;
82
96
  }
97
+
98
+ // ------
99
+ // Mobile bottom-sheet mode (`sheetOnMobile`) — the panel docks to the
100
+ // bottom edge on <768px viewports instead of anchoring: a real scrim
101
+ // (click closes), rounded top corners only, slide-up reveal. Same visual
102
+ // contract as the modal mobile docking (top inset keeps the secondary-
103
+ // window breadcrumb strip visible).
104
+ // ------
105
+ .hk-popover-scrim {
106
+ position: fixed;
107
+ inset: 0;
108
+ pointer-events: auto;
109
+ background: rgb(0 0 0 / 40%);
110
+ }
111
+
112
+ .hk-popover-panel.hk-is-sheet {
113
+ border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
114
+ var(--hk-modal-radius, var(--hi-radius-lg, 12px))
115
+ 0 0;
116
+ border-left: 0;
117
+ border-right: 0;
118
+ border-bottom: 0;
119
+ max-height: min(
120
+ 72vh,
121
+ calc(
122
+ 100vh - var(--hk-sheet-top-inset, 4.25rem) -
123
+ var(--hk-sheet-bottom-gap, 0.375rem)
124
+ )
125
+ );
126
+ display: flex;
127
+ flex-direction: column;
128
+ overflow-y: auto;
129
+ overscroll-behavior: contain;
130
+ padding-top: 0;
131
+ /* The sheet's bottom-edge distance itself comes from the shared
132
+ * --hk-sheet-bottom-gap (inline in HkPopover's sheet style); here the
133
+ * content keeps the last row clear of the home bar: the safe-area
134
+ * inset stacked on a 12px family base, which also restores a bottom
135
+ * cushion over the glass dropdown chrome (equal specificity, earlier
136
+ * in source, whose own bottom padding the sheet rule would otherwise
137
+ * zero out). The modal sheet footer goes further (it adds
138
+ * --hk-sheet-footer-gap for its action buttons); this sheet has no
139
+ * footer, so this is the family baseline. */
140
+ padding-bottom: calc(var(--space-12, 0.75rem) + env(safe-area-inset-bottom, 0px));
141
+
142
+ /* Overlay scrollbar (useOverlayScrollbar, sheet mode only) — the
143
+ native chrome is hidden, never styled. */
144
+ scrollbar-width: none;
145
+
146
+ &::-webkit-scrollbar {
147
+ display: none;
148
+ }
149
+ }
150
+
151
+ .hk-popover-sheet-grabber {
152
+ flex-shrink: 0;
153
+ // Grabber zone: tap target tall enough for a thumb, bar visually small.
154
+ padding: 14px 0 20px;
155
+ margin: 0 auto;
156
+ width: 100%;
157
+ cursor: pointer;
158
+ display: flex;
159
+ justify-content: center;
160
+
161
+ &::before {
162
+ content: "";
163
+ width: 2.25rem;
164
+ height: 4px;
165
+ border-radius: 999px;
166
+ background: rgb(var(--color-border, 100 100 100) / 40%);
167
+ }
168
+ }
169
+
170
+ .hk-popover-sheet-title {
171
+ flex-shrink: 0;
172
+ // Same heading grammar as the select sheet title: small, semibold,
173
+ // slightly letterspaced, inset to the content edge. Hosts tune the
174
+ // leading inset via --hk-sheet-title-inset.
175
+ padding: 0 var(--space-16, 1rem) var(--space-8, 0.5rem)
176
+ var(--hk-sheet-title-inset, var(--space-16, 1rem));
177
+ font-size: var(--text-xs, 0.75rem);
178
+ font-weight: 600;
179
+ letter-spacing: 0.02em;
180
+ color: rgb(var(--color-muted, 140 140 140));
181
+ }
182
+
183
+ .hk-popover-sheet-enter-active {
184
+ transition:
185
+ opacity 0.25s ease-out,
186
+ transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
187
+ }
188
+
189
+ .hk-popover-sheet-leave-active {
190
+ transition:
191
+ opacity 0.2s ease-in,
192
+ transform 0.25s cubic-bezier(0.4, 0, 1, 1);
193
+ }
194
+
195
+ .hk-popover-sheet-enter-from,
196
+ .hk-popover-sheet-leave-to {
197
+ opacity: 0;
198
+ transform: translateY(100%);
199
+ }
200
+
201
+ @media (prefers-reduced-motion: reduce) {
202
+ .hk-popover-sheet-enter-active,
203
+ .hk-popover-sheet-leave-active {
204
+ transition: none;
205
+ }
206
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Source contract for the popover sheet docking (2026-08-30 user
3
+ * report: the theme toggle's bottom-up sheet didn't read as part of
4
+ * the standard sheet family). HkPopover's sheetOnMobile mode must dock
5
+ * with the SAME host-tunable --hk-sheet-bottom-gap as the HkModal and
6
+ * HkSelectPanel dockings, and keep its content clear of the home bar
7
+ * via the safe-area inset. The inline style used to pin hard
8
+ * `bottom: "0"`.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const tsx = readFileSync(join(here, "HkPopover.tsx"), "utf-8");
17
+ const scss = readFileSync(join(here, "HkPopover.scss"), "utf-8");
18
+
19
+ describe("HkPopover mobile sheet docking contract", () => {
20
+ it("docks the sheet above the shared bottom gap (inline sheet style)", () => {
21
+ const block = tsx.slice(tsx.indexOf("panelStyle"));
22
+ expect(block).toContain("var(--hk-sheet-bottom-gap");
23
+ // Same default as the HkModal sheet contract.
24
+ expect(block).toMatch(/--hk-sheet-bottom-gap,\s*0\.375rem/);
25
+ });
26
+
27
+ it("keeps sheet content clear of the home bar via the safe-area inset", () => {
28
+ const block = scss.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)?.[0] ?? "";
29
+ // Stacked on the family 12px base so the glass dropdown chrome's
30
+ // bottom padding survives on non-notched phones too.
31
+ expect(block).toContain("calc(var(--space-12");
32
+ expect(block).toContain("env(safe-area-inset-bottom");
33
+ });
34
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Source contract: popover glass content reads --hk-popover-bg/--hk-popover-blur
3
+ * so a host surface-finish preference (chest html[data-surface]) can re-skin
4
+ * floating dropdown layers without forking the component.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const src = readFileSync(join(here, "HkPopover.scss"), "utf-8");
13
+
14
+ describe("HkPopover glass layer surface hooks", () => {
15
+ it("keeps the glass background host-tunable with the historical default", () => {
16
+ const block = src.slice(src.indexOf(".hii-dropdown-content"));
17
+ expect(block).toContain("var(--hk-popover-bg, rgb(var(--color-surface)))");
18
+ expect(block).toContain("var(--hk-popover-blur,");
19
+ });
20
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Source contract: while an HkPopover is open, no native browser tooltip
3
+ * may fire over the custom popup — the anchor's `title` AND every
4
+ * descendant `title` are blanked and restored exactly (the model pill of
5
+ * hikari #338 was a descendant-title case). Restore must be lossless:
6
+ * elements without a title never gain one, `title=""` stays `title=""`,
7
+ * and unmounting while open must not leak a blanked title.
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
11
+
12
+ import HkPopover from "./HkPopover";
13
+
14
+ const mounts: ReturnType<typeof createApp>[] = [];
15
+ const containers: HTMLElement[] = [];
16
+
17
+ afterEach(() => {
18
+ for (const app of mounts.splice(0)) app.unmount();
19
+ for (const el of containers.splice(0)) el.remove();
20
+ // happy-dom never fires transitionend, so leave transitions never
21
+ // finish and teleported panels linger on <body> — strip them or the
22
+ // next test's querySelector sees this test's panel.
23
+ document.body
24
+ .querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
25
+ .forEach((el) => el.remove());
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ function setViewport(width: number) {
30
+ window.innerWidth = width;
31
+ window.dispatchEvent(new Event("resize"));
32
+ }
33
+
34
+ /**
35
+ * Mounts an HkPopover whose anchor is a plain DOM node (NOT rendered by
36
+ * Vue) so tests can decorate it with arbitrary `title` attributes and
37
+ * observe attribute mutations without Vue's patching interfering.
38
+ */
39
+ function mountPopover(opts: { open?: boolean; anchor?: HTMLElement } = {}) {
40
+ const container = document.createElement("div");
41
+ document.body.appendChild(container);
42
+ containers.push(container);
43
+
44
+ const anchor = opts.anchor ?? document.createElement("button");
45
+ // Only seed text on a fresh anchor — a caller-provided one may already
46
+ // carry the decorated descendants under test (textContent would wipe them).
47
+ if (!opts.anchor) anchor.textContent = "anchor";
48
+ container.appendChild(anchor);
49
+
50
+ const open = ref(opts.open ?? false);
51
+ const Wrapper = defineComponent({
52
+ setup() {
53
+ return () =>
54
+ h(HkPopover, {
55
+ modelValue: open.value,
56
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
57
+ anchorRef: anchor,
58
+ }, { default: () => h("div", { class: "pop-content" }, "content") });
59
+ },
60
+ });
61
+ const app = createApp(Wrapper);
62
+ mounts.push(app);
63
+ app.mount(container);
64
+ return { open, app, anchor };
65
+ }
66
+
67
+ describe("HkPopover native title suppression", () => {
68
+ it("blanks the anchor's and descendants' titles while open and restores both exactly on close", async () => {
69
+ setViewport(1200);
70
+ const anchor = document.createElement("button");
71
+ anchor.setAttribute("title", "anchor tip");
72
+ const inner = document.createElement("span");
73
+ inner.setAttribute("title", "inner tip");
74
+ const nested = document.createElement("em");
75
+ nested.setAttribute("title", "nested tip");
76
+ inner.appendChild(nested);
77
+ anchor.appendChild(inner);
78
+
79
+ const { open } = mountPopover({ anchor });
80
+ await nextTick();
81
+ expect(anchor.getAttribute("title")).toBe("anchor tip");
82
+
83
+ open.value = true;
84
+ await nextTick();
85
+ expect(anchor.getAttribute("title")).toBe("");
86
+ expect(inner.getAttribute("title")).toBe("");
87
+ expect(nested.getAttribute("title")).toBe("");
88
+
89
+ open.value = false;
90
+ await nextTick();
91
+ expect(anchor.getAttribute("title")).toBe("anchor tip");
92
+ expect(inner.getAttribute("title")).toBe("inner tip");
93
+ expect(nested.getAttribute("title")).toBe("nested tip");
94
+ });
95
+
96
+ it("does not grant a title to an element that never had one", async () => {
97
+ setViewport(1200);
98
+ const anchor = document.createElement("button");
99
+ anchor.setAttribute("title", "anchor tip");
100
+ const plain = document.createElement("span");
101
+ anchor.appendChild(plain);
102
+
103
+ const { open } = mountPopover({ anchor });
104
+ open.value = true;
105
+ await nextTick();
106
+ open.value = false;
107
+ await nextTick();
108
+
109
+ expect(plain.hasAttribute("title")).toBe(false);
110
+ expect(plain.getAttribute("title")).toBeNull();
111
+ });
112
+
113
+ it("leaves an existing title=\"\" as title=\"\" (not removed, not filled)", async () => {
114
+ setViewport(1200);
115
+ const anchor = document.createElement("button");
116
+ const empty = document.createElement("span");
117
+ empty.setAttribute("title", "");
118
+ anchor.appendChild(empty);
119
+
120
+ const { open } = mountPopover({ anchor });
121
+ open.value = true;
122
+ await nextTick();
123
+ expect(empty.hasAttribute("title")).toBe(true);
124
+ expect(empty.getAttribute("title")).toBe("");
125
+
126
+ open.value = false;
127
+ await nextTick();
128
+ expect(empty.hasAttribute("title")).toBe(true);
129
+ expect(empty.getAttribute("title")).toBe("");
130
+ });
131
+
132
+ it("restores the anchor's title when unmounted while open", async () => {
133
+ setViewport(1200);
134
+ const anchor = document.createElement("button");
135
+ anchor.setAttribute("title", "unmount tip");
136
+
137
+ const { open, app } = mountPopover({ anchor });
138
+ open.value = true;
139
+ await nextTick();
140
+ expect(anchor.getAttribute("title")).toBe("");
141
+
142
+ app.unmount();
143
+ expect(anchor.getAttribute("title")).toBe("unmount tip");
144
+ });
145
+
146
+ it("performs no title-related DOM mutations when the anchor has no titles anywhere", async () => {
147
+ setViewport(1200);
148
+ const anchor = document.createElement("button");
149
+ const plain = document.createElement("span");
150
+ anchor.appendChild(plain);
151
+ const setSpy = vi.spyOn(anchor, "setAttribute");
152
+ const removeSpy = vi.spyOn(anchor, "removeAttribute");
153
+
154
+ const { open } = mountPopover({ anchor });
155
+ open.value = true;
156
+ await nextTick();
157
+ open.value = false;
158
+ await nextTick();
159
+
160
+ expect(setSpy).not.toHaveBeenCalled();
161
+ expect(removeSpy).not.toHaveBeenCalled();
162
+ expect(anchor.hasAttribute("title")).toBe(false);
163
+ expect(anchor.querySelector("[title]")).toBeNull();
164
+ });
165
+
166
+ it("restores the retired anchor and suppresses the new one on a mid-open anchorRef swap", async () => {
167
+ setViewport(1200);
168
+ const container = document.createElement("div");
169
+ document.body.appendChild(container);
170
+ containers.push(container);
171
+
172
+ const oldAnchor = document.createElement("button");
173
+ oldAnchor.setAttribute("title", "old tip");
174
+ const oldInner = document.createElement("span");
175
+ oldInner.setAttribute("title", "old inner tip");
176
+ oldAnchor.appendChild(oldInner);
177
+ const newAnchor = document.createElement("button");
178
+ newAnchor.setAttribute("title", "new tip");
179
+ container.append(oldAnchor, newAnchor);
180
+
181
+ const open = ref(false);
182
+ const anchorRef = ref<HTMLElement | null>(oldAnchor);
183
+ const Wrapper = defineComponent({
184
+ setup() {
185
+ return () =>
186
+ h(HkPopover, {
187
+ modelValue: open.value,
188
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
189
+ anchorRef: anchorRef.value,
190
+ }, { default: () => h("div", { class: "pop-content" }, "content") });
191
+ },
192
+ });
193
+ const app = createApp(Wrapper);
194
+ mounts.push(app);
195
+ app.mount(container);
196
+
197
+ open.value = true;
198
+ await nextTick();
199
+ expect(oldAnchor.getAttribute("title")).toBe("");
200
+ expect(oldInner.getAttribute("title")).toBe("");
201
+
202
+ anchorRef.value = newAnchor;
203
+ await nextTick();
204
+ expect(oldAnchor.getAttribute("title")).toBe("old tip");
205
+ expect(oldInner.getAttribute("title")).toBe("old inner tip");
206
+ expect(newAnchor.getAttribute("title")).toBe("");
207
+
208
+ open.value = false;
209
+ await nextTick();
210
+ expect(newAnchor.getAttribute("title")).toBe("new tip");
211
+ expect(oldAnchor.getAttribute("title")).toBe("old tip");
212
+ });
213
+
214
+ it("restores the retired subtree when opening and an anchor swap land in the same flush", async () => {
215
+ setViewport(1200);
216
+ const container = document.createElement("div");
217
+ document.body.appendChild(container);
218
+ containers.push(container);
219
+
220
+ const anchorA = document.createElement("button");
221
+ anchorA.setAttribute("title", "A tip");
222
+ const childA = document.createElement("span");
223
+ childA.setAttribute("title", "A child tip");
224
+ anchorA.appendChild(childA);
225
+ const anchorB = document.createElement("button");
226
+ anchorB.setAttribute("title", "B tip");
227
+ const childB = document.createElement("span");
228
+ childB.setAttribute("title", "B child tip");
229
+ anchorB.appendChild(childB);
230
+ container.append(anchorA, anchorB);
231
+
232
+ const open = ref(false);
233
+ const anchorRef = ref<HTMLElement | null>(anchorA);
234
+ const Wrapper = defineComponent({
235
+ setup() {
236
+ return () =>
237
+ h(HkPopover, {
238
+ modelValue: open.value,
239
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
240
+ anchorRef: anchorRef.value,
241
+ }, { default: () => h("div", { class: "pop-content" }, "content") });
242
+ },
243
+ });
244
+ const app = createApp(Wrapper);
245
+ mounts.push(app);
246
+ app.mount(container);
247
+
248
+ // Same tick: the popover never observes "open while anchored to A" —
249
+ // open and the swap to B land in one flush together.
250
+ open.value = true;
251
+ anchorRef.value = anchorB;
252
+ await nextTick();
253
+ expect(anchorA.getAttribute("title")).toBe("A tip");
254
+ expect(childA.getAttribute("title")).toBe("A child tip");
255
+ expect(anchorB.getAttribute("title")).toBe("");
256
+ expect(childB.getAttribute("title")).toBe("");
257
+
258
+ open.value = false;
259
+ await nextTick();
260
+ expect(anchorA.getAttribute("title")).toBe("A tip");
261
+ expect(childA.getAttribute("title")).toBe("A child tip");
262
+ expect(anchorB.getAttribute("title")).toBe("B tip");
263
+ expect(childB.getAttribute("title")).toBe("B child tip");
264
+ });
265
+ });