@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
@@ -5,6 +5,7 @@ import {
5
5
  onBeforeUnmount,
6
6
  onMounted,
7
7
  ref,
8
+ shallowRef,
8
9
  Teleport,
9
10
  Transition,
10
11
  watch,
@@ -13,9 +14,16 @@ import {
13
14
 
14
15
  import { useI18n } from "../i18n/context";
15
16
  import "./HkModal.scss";
17
+ import { focusFirst, trapFocus } from "../utils/dom";
16
18
  import { useOverlay } from "../runtime/useOverlay";
17
19
  import { usePopupManager } from "../runtime/usePopupManager";
20
+ import { createBackGuard } from "../runtime/backStack";
21
+ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
22
+ import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
23
+ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
24
+ import { useSizeMorph } from "../composables/useSizeMorph";
18
25
  import HButton from "./HkButton";
26
+ import HFab from "./HkFab";
19
27
  import HSpinner from "./HkSpinner";
20
28
 
21
29
  export interface ModalAction {
@@ -29,28 +37,6 @@ export interface ModalAction {
29
37
  const FOCUSABLE_SELECTOR =
30
38
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
31
39
 
32
- function trapFocus(container: HTMLElement, e: KeyboardEvent): void {
33
- const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
34
- if (focusable.length === 0) return;
35
- const first = focusable[0];
36
- const last = focusable[focusable.length - 1];
37
- if (e.shiftKey) {
38
- if (document.activeElement === first) {
39
- e.preventDefault();
40
- last.focus();
41
- }
42
- } else {
43
- if (document.activeElement === last) {
44
- e.preventDefault();
45
- first.focus();
46
- }
47
- }
48
- }
49
-
50
- function focusFirst(container: HTMLElement): void {
51
- const el = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
52
- if (el) el.focus();
53
- }
54
40
 
55
41
  export default defineComponent({
56
42
  name: "HkModal",
@@ -66,6 +52,14 @@ export default defineComponent({
66
52
  windowed: { type: Boolean, default: false },
67
53
  overscanScreens: { type: Number, default: 1 },
68
54
  autoFollow: { type: Boolean, default: false },
55
+ /**
56
+ * Consume the browser/system back gesture while open (window-first
57
+ * back priority): a marked history entry is pushed on open so back
58
+ * closes the modal instead of leaving the page. Only meaningful
59
+ * together with `closable`; disable for surfaces that manage their
60
+ * own history entries.
61
+ */
62
+ backGuard: { type: Boolean, default: true },
69
63
  },
70
64
  emits: {
71
65
  "update:modelValue": (_value: boolean) => true,
@@ -74,11 +68,31 @@ export default defineComponent({
74
68
  setup(props, { emit, slots }) {
75
69
  const { t } = useI18n();
76
70
  const manager = usePopupManager();
77
- const overlay = useOverlay({ name: "hk-modal" });
71
+ // Open/close motion reported into the unified animation context
72
+ // (animationBus) — scrim and content on separate tracks so each
73
+ // layer's report arms/cancels independently.
74
+ const surf = useSurfaceTransition(320);
75
+ const overlayHooks = surf.hooks("overlay");
76
+ const contentHooks = surf.hooks("content");
77
+ const overlay = useOverlay({
78
+ name: "hk-modal",
79
+ // A global closeAll() must be able to actually close this modal
80
+ // (not just untrack it) — route it through the same closable
81
+ // guard as the user-initiated paths.
82
+ onCloseRequested: () => { if (props.closable) close(); },
83
+ });
78
84
 
79
85
  const handle = ref<{ id: string; zIndex: number } | null>(null);
80
86
  const bodyRef = ref<HTMLElement>();
81
87
  const contentRef = ref<HTMLElement>();
88
+ /** Natural-height probe inside the scroll container: the body's
89
+ * content wrapper, whose height is the content's intrinsic height
90
+ * regardless of scroll — the size morph measures this (see
91
+ * useSizeMorph). */
92
+ const innerRef = ref<HTMLElement>();
93
+ // Content-driven size morphing: the frame follows content growth with
94
+ // the height transition instead of snapping (see useSizeMorph).
95
+ const morph = useSizeMorph(contentRef, innerRef);
82
96
  const shouldRender = ref(false);
83
97
  let previouslyFocused: HTMLElement | null = null;
84
98
  let unmounted = false;
@@ -95,9 +109,35 @@ export default defineComponent({
95
109
  // Auto-follow state
96
110
  let autoFollowMutationObserver: MutationObserver | null = null;
97
111
  const isFollowing = ref(true);
98
- let userScrolled = false;
112
+ /** Body actually scrolls (drives the "Auto" tag visibility). */
113
+ const hasOverflow = shallowRef(false);
114
+ /**
115
+ * Sticky user-intent flag. Set by wheel / touch / drag-press /
116
+ * scroll-key gestures and consumed by the scroll handler; cleared
117
+ * by every programmatic movement (which is never user intent).
118
+ * Fling momentum keeps firing scroll events without new touches,
119
+ * which is why the flag stays set until consumed or cleared.
120
+ */
121
+ let gestureDirty = false;
122
+ /** performance.now() of the last gesture that raised the flag —
123
+ * drives the settle-frame quiet window below. */
124
+ let gestureAt = 0;
99
125
  const scrollContainerRef = ref<HTMLElement>();
100
126
 
127
+ /**
128
+ * Window-first back priority: while this modal is the topmost open
129
+ * window, the back gesture (mobile back button/gesture, desktop
130
+ * browser back) closes it instead of navigating the page. Disabled
131
+ * for non-closable modals — back must not be swallowed by a surface
132
+ * it cannot close.
133
+ */
134
+ const backGuardEnabled = () => props.closable && props.backGuard;
135
+ const backGuard = createBackGuard({
136
+ onBack: () => {
137
+ if (backGuardEnabled()) close();
138
+ },
139
+ });
140
+
101
141
  function close() {
102
142
  emit("update:modelValue", false);
103
143
  }
@@ -209,30 +249,145 @@ export default defineComponent({
209
249
  }
210
250
 
211
251
  // --- Auto-follow ---
252
+ //
253
+ // Follow policy: ONLY real user motion can cancel following.
254
+ // Streaming content is pinned with direct scrollTop assignments
255
+ // drained across a few animation frames (never smooth-scroll
256
+ // chains), and every programmatic movement clears the sticky
257
+ // gesture flag — so mid-flight scroll events cannot flip isFollowing
258
+ // back and forth (the old flicker).
259
+
260
+ const FOLLOW_THRESHOLD = 32;
261
+ const SETTLE_FRAMES = 4;
262
+ /** Pending pin frames yield to gestures younger than this. */
263
+ const GESTURE_QUIET_MS = 250;
264
+
265
+ let settleRemaining = 0;
266
+ let settleHandle: AnimationHandle | null = null;
267
+
268
+ function runSettleFrame() {
269
+ settleHandle = null;
270
+ // A cancelled follow must not be fought by pending pins: drained
271
+ // frames bail out immediately (resumeJumpLatest re-kicks). The
272
+ // reset above must stay first so kickSettle() never sees a stale,
273
+ // already-consumed handle — that would deadlock all future pins.
274
+ if (!isFollowing.value) {
275
+ settleRemaining = 0;
276
+ return;
277
+ }
278
+ if (gestureDirty && performance.now() - gestureAt < GESTURE_QUIET_MS) {
279
+ // A live gesture is in flight: yield this frame to it and let
280
+ // the scroll handler judge the resulting position. Stale flags
281
+ // older than the quiet window fall through and are consumed
282
+ // below — no stale-flag stall, no fight against the finger.
283
+ if (settleRemaining > 0) {
284
+ settleRemaining--;
285
+ settleHandle = scheduleFrame(runSettleFrame);
286
+ }
287
+ return;
288
+ }
289
+ gestureDirty = false;
290
+ pinToBottom();
291
+ updateOverflow();
292
+ if (settleRemaining > 0) {
293
+ settleRemaining--;
294
+ settleHandle = scheduleFrame(runSettleFrame);
295
+ }
296
+ }
212
297
 
213
- function scrollToBottom(smooth = false) {
298
+ function kickSettle() {
299
+ // hasOverflow gates the "Auto" tag and the jump FAB. It is only
300
+ // recomputed inside settle frames and scroll events; with windowed
301
+ // lists the body can grow late (async chunks, virtualized mounts)
302
+ // without either firing — poll it alongside the settle drain so the
303
+ // affordances appear on the frame the overflow actually exists.
304
+ updateOverflow();
305
+ settleRemaining = SETTLE_FRAMES;
306
+ if (settleHandle) return;
307
+ settleHandle = scheduleFrame(runSettleFrame);
308
+ }
309
+
310
+ function cancelSettle() {
311
+ settleHandle?.disconnect();
312
+ settleHandle = null;
313
+ settleRemaining = 0;
314
+ }
315
+
316
+ /** Direct assignment — no smooth scrolling, no intermediate events. */
317
+ function pinToBottom() {
214
318
  const el = scrollContainerRef.value;
215
319
  if (!el) return;
216
- el.scrollTo({
217
- top: el.scrollHeight,
218
- behavior: smooth ? "smooth" : "instant" as ScrollBehavior,
219
- });
320
+ el.scrollTop = el.scrollHeight;
321
+ }
322
+
323
+ function updateOverflow() {
324
+ const el = scrollContainerRef.value;
325
+ if (!el) return;
326
+ hasOverflow.value = el.scrollHeight > el.clientHeight + 4;
220
327
  }
221
328
 
222
329
  function onBodyScroll() {
223
330
  if (!props.autoFollow) return;
224
331
  const el = scrollContainerRef.value;
225
332
  if (!el) return;
226
- const threshold = 32;
333
+ updateOverflow();
227
334
  const distanceFromBottom =
228
335
  el.scrollHeight - el.scrollTop - el.clientHeight;
229
- if (distanceFromBottom > threshold) {
230
- userScrolled = true;
231
- isFollowing.value = false;
232
- } else {
233
- userScrolled = false;
336
+ if (distanceFromBottom > FOLLOW_THRESHOLD) {
337
+ // Without a sticky gesture flag nothing cancels here — streaming
338
+ // pins no longer flash anything.
339
+ if (gestureDirty) {
340
+ gestureDirty = false;
341
+ isFollowing.value = false;
342
+ }
343
+ } else if (!isFollowing.value) {
344
+ // User returned near the bottom (or a pin landed): re-arm naturally.
345
+ gestureDirty = false;
234
346
  isFollowing.value = true;
235
347
  }
348
+ // Sub-threshold events while still following must NOT touch the
349
+ // flag: a slow upward drag emits many <32px scrolls, and eating
350
+ // the flag on any of them would make cancellation impossible.
351
+ }
352
+
353
+ const GESTURE_KEYS = new Set([
354
+ "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ",
355
+ ]);
356
+
357
+ function markGesture() {
358
+ gestureDirty = true;
359
+ gestureAt = performance.now();
360
+ }
361
+
362
+ function onGestureKeydown(e: KeyboardEvent) {
363
+ if (GESTURE_KEYS.has(e.key)) {
364
+ gestureDirty = true;
365
+ gestureAt = performance.now();
366
+ }
367
+ }
368
+
369
+ /** Press-and-drag covers mouse/touch before the drag starts; the
370
+ * flag stays sticky so post-fling momentum still cancels follow. */
371
+ function onGesturePointerdown(e: PointerEvent) {
372
+ if (e.button === 0) {
373
+ gestureDirty = true;
374
+ gestureAt = performance.now();
375
+ }
376
+ }
377
+
378
+ /** A press-drag longer than the quiet window would otherwise have
379
+ * its flag purged mid-gesture (a mutation frame pins under the
380
+ * finger and the rest of the drag can no longer cancel). Moving
381
+ * while buttons are held re-stamps freshness; free hovering —
382
+ * e.buttons === 0 — must NOT keep a stale flag alive forever. */
383
+ function onGesturePointermove(e: PointerEvent) {
384
+ if (e.buttons !== 0) markGesture();
385
+ }
386
+
387
+ /** Touch drags emit touchmove without pointermove on some engines
388
+ * (and happy-dom tests synthesize either); belt and suspenders. */
389
+ function onGestureTouchmove() {
390
+ markGesture();
236
391
  }
237
392
 
238
393
  function setupAutoFollow() {
@@ -240,16 +395,24 @@ export default defineComponent({
240
395
  teardownAutoFollow();
241
396
 
242
397
  isFollowing.value = true;
243
- userScrolled = false;
244
- scrollToBottom(false);
398
+ gestureDirty = false;
399
+ pinToBottom();
400
+ updateOverflow();
401
+ kickSettle();
402
+
403
+ const el = scrollContainerRef.value!;
404
+ el.addEventListener("wheel", markGesture, { passive: true });
405
+ el.addEventListener("touchstart", markGesture, { passive: true });
406
+ el.addEventListener("touchmove", onGestureTouchmove, { passive: true });
407
+ el.addEventListener("keydown", onGestureKeydown);
408
+ el.addEventListener("pointerdown", onGesturePointerdown);
409
+ el.addEventListener("pointermove", onGesturePointermove, { passive: true });
245
410
 
246
411
  autoFollowMutationObserver = new MutationObserver(() => {
247
- if (isFollowing.value && !unmounted) {
248
- nextTick(() => scrollToBottom(true));
249
- }
412
+ if (isFollowing.value && !unmounted) kickSettle();
250
413
  });
251
414
 
252
- autoFollowMutationObserver.observe(scrollContainerRef.value, {
415
+ autoFollowMutationObserver.observe(el, {
253
416
  childList: true,
254
417
  subtree: true,
255
418
  characterData: true,
@@ -261,19 +424,53 @@ export default defineComponent({
261
424
  autoFollowMutationObserver.disconnect();
262
425
  autoFollowMutationObserver = null;
263
426
  }
427
+ const el = scrollContainerRef.value;
428
+ if (el) {
429
+ el.removeEventListener("wheel", markGesture);
430
+ el.removeEventListener("touchstart", markGesture);
431
+ el.removeEventListener("touchmove", onGestureTouchmove);
432
+ el.removeEventListener("keydown", onGestureKeydown);
433
+ el.removeEventListener("pointerdown", onGesturePointerdown);
434
+ el.removeEventListener("pointermove", onGesturePointermove);
435
+ }
436
+ cancelSettle();
264
437
  isFollowing.value = true;
265
- userScrolled = false;
438
+ hasOverflow.value = false;
439
+ gestureDirty = false;
266
440
  }
267
441
 
268
- function resumeAutoFollow() {
442
+ function resumeJumpLatest() {
269
443
  if (!props.autoFollow) return;
270
444
  isFollowing.value = true;
271
- userScrolled = false;
272
- scrollToBottom(true);
445
+ pinToBottom();
446
+ kickSettle();
273
447
  }
274
448
 
275
449
  // --- Lifecycle ---
276
450
 
451
+ // Overlay scrollbar on the scrolling body (shared chrome). The body
452
+ // mounts with the modal surface (shouldRender) and survives through
453
+ // the leave transition; attach after the DOM lands, detach on close
454
+ // and unmount so nothing leaks inside the Teleport portal.
455
+ let bodyScrollbar: OverlayScrollbarHandle | null = null;
456
+
457
+ function detachBodyScrollbar(): void {
458
+ bodyScrollbar?.detach();
459
+ bodyScrollbar = null;
460
+ }
461
+
462
+ watch(shouldRender, (render) => {
463
+ if (render) {
464
+ void nextTick(() => {
465
+ if (!shouldRender.value || !scrollContainerRef.value) return;
466
+ detachBodyScrollbar();
467
+ bodyScrollbar = attachOverlayScrollbars(scrollContainerRef.value, { axis: "vertical" });
468
+ });
469
+ } else {
470
+ detachBodyScrollbar();
471
+ }
472
+ });
473
+
277
474
  watch(
278
475
  () => props.modelValue,
279
476
  (val) => {
@@ -284,17 +481,36 @@ export default defineComponent({
284
481
  manager.unregister(handle.value.id);
285
482
  }
286
483
  shouldRender.value = true;
484
+ // Windows always block, so the kind alone lists this layer in
485
+ // the modal-stack breadcrumb on every form factor.
287
486
  handle.value = manager.register("modal", true, props.title);
288
487
  overlay.open();
488
+ if (backGuardEnabled() && backGuard.entries === 0) {
489
+ backGuard.push();
490
+ }
289
491
  } else {
290
492
  // Close happens via Transition onAfterLeave,
291
493
  // but if modelValue flips to false without Transition
292
494
  // (e.g. immediate), clean up now.
495
+ overlay.close();
496
+ backGuard.release();
293
497
  }
294
498
  },
295
499
  { immediate: true },
296
500
  );
297
501
 
502
+ // closable/backGuard may flip while open (submit flows disable
503
+ // closing): keep the owned entry in lockstep so back is never a
504
+ // dead gesture on a surface it can no longer close.
505
+ watch(
506
+ backGuardEnabled,
507
+ (enabled) => {
508
+ if (unmounted || !props.modelValue) return;
509
+ if (enabled && backGuard.entries === 0) backGuard.push();
510
+ else if (!enabled && backGuard.entries > 0) backGuard.release();
511
+ },
512
+ );
513
+
298
514
  watch(
299
515
  () => props.title,
300
516
  (newTitle) => {
@@ -310,8 +526,10 @@ export default defineComponent({
310
526
 
311
527
  onBeforeUnmount(() => {
312
528
  unmounted = true;
529
+ detachBodyScrollbar();
313
530
  teardownWindowed();
314
531
  teardownAutoFollow();
532
+ backGuard.destroy();
315
533
  if (handle.value) {
316
534
  manager.unregister(handle.value.id);
317
535
  handle.value = null;
@@ -358,7 +576,14 @@ export default defineComponent({
358
576
  style={{ zIndex: overlayZ.value }}
359
577
  onKeydown={onKeydown}
360
578
  >
361
- <Transition name="hk-modal-overlay" appear>
579
+ <Transition
580
+ name="hk-modal-overlay"
581
+ appear
582
+ onBeforeEnter={overlayHooks.onBeforeEnter}
583
+ onAfterEnter={overlayHooks.onAfterEnter}
584
+ onBeforeLeave={overlayHooks.onBeforeLeave}
585
+ onAfterLeave={overlayHooks.onAfterLeave}
586
+ >
362
587
  {props.modelValue && (
363
588
  <div
364
589
  class="hk-modal-overlay"
@@ -369,8 +594,23 @@ export default defineComponent({
369
594
  <Transition
370
595
  name="hk-modal-content"
371
596
  appear
372
- onAfterEnter={onAfterEnter}
373
- onAfterLeave={onAfterLeave}
597
+ onBeforeEnter={contentHooks.onBeforeEnter}
598
+ onAfterEnter={() => {
599
+ contentHooks.onAfterEnter();
600
+ onAfterEnter();
601
+ // Size morphs arm once the open choreography finished —
602
+ // pinning during enter would override its height reveal.
603
+ morph.start();
604
+ }}
605
+ onBeforeLeave={() => {
606
+ contentHooks.onBeforeLeave();
607
+ // Release the pinned height so the leave owns the frame.
608
+ morph.stop();
609
+ }}
610
+ onAfterLeave={() => {
611
+ contentHooks.onAfterLeave();
612
+ onAfterLeave();
613
+ }}
374
614
  >
375
615
  {props.modelValue && (
376
616
  <div
@@ -395,7 +635,7 @@ export default defineComponent({
395
635
  {props.closable && (
396
636
  <button
397
637
  class="hk-modal-close"
398
- aria-label={t("hk.modal.close", "Close")}
638
+ aria-label={t("hikari::modal.close", "Close")}
399
639
  onClick={close}
400
640
  >
401
641
  <svg
@@ -424,30 +664,36 @@ export default defineComponent({
424
664
  class="hk-modal-body-scroll"
425
665
  onScroll={onBodyScroll}
426
666
  >
427
- <div class="hk-modal-body-inner">
667
+ <div ref={innerRef} class="hk-modal-body-inner">
428
668
  {props.windowed
429
669
  ? renderWindowedBody()
430
670
  : slots.default?.()}
431
671
  </div>
432
672
  </div>
433
- {props.autoFollow && !isFollowing.value && (
434
- <div
435
- class="hk-modal-autofollow-bar"
436
- onClick={resumeAutoFollow}
437
- >
438
- <svg
439
- viewBox="0 0 24 24"
440
- fill="none"
441
- stroke="currentColor"
442
- stroke-width="2"
443
- stroke-linecap="round"
444
- width="14"
445
- height="14"
446
- >
447
- <polyline points="6 9 12 15 18 9" />
448
- </svg>
449
- <span>Auto</span>
450
- </div>
673
+ {props.autoFollow && (
674
+ <>
675
+ {/* "Auto" marks ACTIVE follow: pinned at bottom
676
+ with a body that overflows. Cancelled follow
677
+ swaps it for the jump-back FAB instead. */}
678
+ {hasOverflow.value && isFollowing.value && (
679
+ <span
680
+ class="hk-modal-autofollow-tag"
681
+ aria-hidden="true"
682
+ >
683
+ {t("hikari::modal.auto", "Auto")}
684
+ </span>
685
+ )}
686
+ {!isFollowing.value && (
687
+ <HFab
688
+ class="hk-modal-jump"
689
+ positioning="absolute"
690
+ corner="bottom-right"
691
+ icon="ArrowDown"
692
+ ariaLabel={t("hikari::modal.jumpToLatest", "Back to latest")}
693
+ onClick={resumeJumpLatest}
694
+ />
695
+ )}
696
+ </>
451
697
  )}
452
698
  </div>
453
699
  {renderFooter()}
@@ -2,7 +2,10 @@
2
2
  position: fixed;
3
3
  left: 50%;
4
4
  transform: translate(-50%, -50%);
5
- z-index: var(--hk-breadcrumb-z, 100001);
5
+ /* Above every popup band's scrim (modal 1000+, dropdown 2000+; in-band
6
+ steps of 2 keep real stacks far below this) so multi-layer windows
7
+ never bury the strip; still below tooltip (3000) / toast (4000). */
8
+ z-index: var(--hk-breadcrumb-z, 2500);
6
9
  display: flex;
7
10
  align-items: center;
8
11
  gap: var(--hi-space-4, 0.5rem);
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, nextTick } from "vue";
3
+
4
+ import HkModalBreadcrumb from "./HkModalBreadcrumb";
5
+ import { usePopupManager } from "../runtime/usePopupManager";
6
+
7
+ const mounts: ReturnType<typeof createApp>[] = [];
8
+ const containers: HTMLElement[] = [];
9
+ // The registry is a module singleton shared with the strip.
10
+ const manager = usePopupManager();
11
+
12
+ afterEach(async () => {
13
+ for (const app of mounts.splice(0)) app.unmount();
14
+ for (const el of containers.splice(0)) el.remove();
15
+ for (const entry of [...manager.registry.value.values()]) {
16
+ manager.unregister(entry.id);
17
+ }
18
+ document.body.style.overflow = "";
19
+ });
20
+
21
+ async function mountStrip(): Promise<void> {
22
+ const container = document.createElement("div");
23
+ document.body.appendChild(container);
24
+ containers.push(container);
25
+ const app = createApp(HkModalBreadcrumb);
26
+ mounts.push(app);
27
+ app.mount(container);
28
+ await nextTick();
29
+ }
30
+
31
+ function strip(): HTMLElement | null {
32
+ return document.querySelector("nav.hk-modal-breadcrumb");
33
+ }
34
+
35
+ function labels(): string[] {
36
+ const nav = strip();
37
+ if (!nav) return [];
38
+ return [...nav.querySelectorAll(".hk-modal-breadcrumb-item")].map(
39
+ (el) => el.textContent ?? "",
40
+ );
41
+ }
42
+
43
+ describe("HkModalBreadcrumb window-stack policy", () => {
44
+ it("treats an anchored desktop menu as a hidden level", async () => {
45
+ // Theme window + an untitled anchor-attached popover (blocking=false):
46
+ // the popover is not a window the user navigates, so a single-window
47
+ // situation stays strip-less.
48
+ manager.register("modal", true, "Custom theme");
49
+ manager.register("dropdown", false);
50
+ await mountStrip();
51
+ expect(strip()).toBeNull();
52
+ });
53
+
54
+ it("lists a menu only while it blocks as a mobile bottom sheet", async () => {
55
+ manager.register("modal", true, "Custom theme");
56
+ const popup = manager.register("dropdown", false, "Pick a color", true);
57
+ await mountStrip();
58
+ expect(labels()).toEqual(["Custom theme", "Pick a color"]);
59
+ // The sheet paints above the window, so it is the current crumb.
60
+ expect(
61
+ strip()!.querySelector(".hk-modal-breadcrumb-item-current")!.textContent,
62
+ ).toBe("Pick a color");
63
+
64
+ // Morphing back to an anchored popover (blocking=false) removes the
65
+ // level instead of leaving a stale crumb behind.
66
+ manager.setBlocking(popup.id, false);
67
+ await nextTick();
68
+ expect(strip()).toBeNull();
69
+ });
70
+
71
+ it("never falls back to a bare Layer N label", async () => {
72
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
73
+ try {
74
+ // Untitled window + untitled blocking sheet: the strip must use the
75
+ // localized generic labels ("Window" / "Menu" in en), not "Layer N".
76
+ manager.register("modal", true);
77
+ manager.register("dropdown", false, undefined, true);
78
+ await mountStrip();
79
+ expect(labels()).toEqual(["Window", "Menu"]);
80
+ expect(labels().join(" ")).not.toMatch(/Layer/);
81
+ } finally {
82
+ warn.mockRestore();
83
+ }
84
+ });
85
+
86
+ it("orders crumbs by z and names the strip for assistive tech", async () => {
87
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
88
+ try {
89
+ const win = manager.register("modal", true, "Settings");
90
+ const drawer = manager.register("drawer", true, "Details");
91
+ expect(drawer.zIndex).toBeGreaterThan(win.zIndex); // same band, later slot
92
+ await mountStrip();
93
+ // In-band order is open order: the later drawer sits on top.
94
+ expect(labels()).toEqual(["Settings", "Details"]);
95
+ expect(strip()!.getAttribute("aria-label")).toBe("Window layers");
96
+ } finally {
97
+ warn.mockRestore();
98
+ }
99
+ });
100
+
101
+ it("exposes the registry entry shape the strip reads", async () => {
102
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ try {
104
+ const handle = manager.register("dropdown", false, "Sheet", true);
105
+ const entry = manager.registry.value.get(handle.id);
106
+ expect(entry).toMatchObject({ kind: "dropdown", title: "Sheet", blocking: true });
107
+ } finally {
108
+ warn.mockRestore();
109
+ }
110
+ });
111
+ });