@celestia-island/hikari 0.4.4

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 (187) hide show
  1. package/README.md +54 -0
  2. package/package.json +63 -0
  3. package/src/DemoApp.vue +400 -0
  4. package/src/components/HkActionBar.tsx +15 -0
  5. package/src/components/HkAlert.scss +121 -0
  6. package/src/components/HkAlert.tsx +85 -0
  7. package/src/components/HkAvatar.tsx +29 -0
  8. package/src/components/HkBadge.scss +117 -0
  9. package/src/components/HkBadge.tsx +50 -0
  10. package/src/components/HkBreadcrumb.tsx +39 -0
  11. package/src/components/HkButton.scss +128 -0
  12. package/src/components/HkButton.tsx +74 -0
  13. package/src/components/HkCard.scss +49 -0
  14. package/src/components/HkCard.tsx +38 -0
  15. package/src/components/HkCheckbox.scss +102 -0
  16. package/src/components/HkCheckbox.tsx +85 -0
  17. package/src/components/HkCollapse.tsx +48 -0
  18. package/src/components/HkColorPicker.scss +148 -0
  19. package/src/components/HkColorPicker.tsx +204 -0
  20. package/src/components/HkConfirmDialog.scss +12 -0
  21. package/src/components/HkConfirmDialog.tsx +75 -0
  22. package/src/components/HkDateTimePicker.scss +361 -0
  23. package/src/components/HkDateTimePicker.tsx +424 -0
  24. package/src/components/HkDivider.scss +85 -0
  25. package/src/components/HkDivider.tsx +51 -0
  26. package/src/components/HkDraggableGrid.scss +132 -0
  27. package/src/components/HkDraggableGrid.tsx +291 -0
  28. package/src/components/HkDraggableList.scss +110 -0
  29. package/src/components/HkDraggableList.tsx +238 -0
  30. package/src/components/HkDrawer.scss +175 -0
  31. package/src/components/HkDrawer.tsx +171 -0
  32. package/src/components/HkEmptyState.scss +42 -0
  33. package/src/components/HkEmptyState.tsx +41 -0
  34. package/src/components/HkErrorBoundary.scss +64 -0
  35. package/src/components/HkErrorBoundary.tsx +82 -0
  36. package/src/components/HkGaugeRing.scss +4 -0
  37. package/src/components/HkGaugeRing.tsx +130 -0
  38. package/src/components/HkHoverRevealAction.scss +66 -0
  39. package/src/components/HkHoverRevealAction.tsx +77 -0
  40. package/src/components/HkIcon.tsx +43 -0
  41. package/src/components/HkIconButton.tsx +42 -0
  42. package/src/components/HkImageViewer.scss +33 -0
  43. package/src/components/HkImageViewer.tsx +250 -0
  44. package/src/components/HkInput.scss +172 -0
  45. package/src/components/HkInput.tsx +175 -0
  46. package/src/components/HkKbd.tsx +26 -0
  47. package/src/components/HkKeywordSearchModal.scss +148 -0
  48. package/src/components/HkKeywordSearchModal.tsx +308 -0
  49. package/src/components/HkListTransition.scss +131 -0
  50. package/src/components/HkListTransition.tsx +29 -0
  51. package/src/components/HkLocalePickerPopup.tsx +42 -0
  52. package/src/components/HkLogo.scss +36 -0
  53. package/src/components/HkLogo.tsx +93 -0
  54. package/src/components/HkMarkdownRenderer.scss +141 -0
  55. package/src/components/HkMarkdownRenderer.tsx +123 -0
  56. package/src/components/HkMediaControlBar.scss +65 -0
  57. package/src/components/HkMediaControlBar.tsx +115 -0
  58. package/src/components/HkMediaPlayer.scss +29 -0
  59. package/src/components/HkMediaPlayer.tsx +210 -0
  60. package/src/components/HkMediaSlider.scss +54 -0
  61. package/src/components/HkMediaSlider.tsx +81 -0
  62. package/src/components/HkMediaVisualizer.scss +7 -0
  63. package/src/components/HkMediaVisualizer.tsx +119 -0
  64. package/src/components/HkMinimap.scss +76 -0
  65. package/src/components/HkMinimap.tsx +255 -0
  66. package/src/components/HkModal.scss +318 -0
  67. package/src/components/HkModal.tsx +481 -0
  68. package/src/components/HkModalBreadcrumb.scss +55 -0
  69. package/src/components/HkModalBreadcrumb.tsx +113 -0
  70. package/src/components/HkMorphingTabs.scss +141 -0
  71. package/src/components/HkMorphingTabs.tsx +113 -0
  72. package/src/components/HkNavItem.scss +81 -0
  73. package/src/components/HkNavItem.tsx +157 -0
  74. package/src/components/HkNumberInput.scss +154 -0
  75. package/src/components/HkNumberInput.tsx +161 -0
  76. package/src/components/HkPasswordInput.scss +289 -0
  77. package/src/components/HkPasswordInput.tsx +533 -0
  78. package/src/components/HkPhaseTransition.scss +25 -0
  79. package/src/components/HkPhaseTransition.tsx +40 -0
  80. package/src/components/HkPopover.scss +82 -0
  81. package/src/components/HkPopover.tsx +410 -0
  82. package/src/components/HkPopupSelect.scss +136 -0
  83. package/src/components/HkPopupSelect.tsx +199 -0
  84. package/src/components/HkProgressBar.scss +106 -0
  85. package/src/components/HkProgressBar.tsx +61 -0
  86. package/src/components/HkProgressDialog.tsx +67 -0
  87. package/src/components/HkProgressRing.scss +56 -0
  88. package/src/components/HkProgressRing.tsx +82 -0
  89. package/src/components/HkRadio.scss +119 -0
  90. package/src/components/HkRadio.tsx +75 -0
  91. package/src/components/HkRollingNumber.scss +37 -0
  92. package/src/components/HkRollingNumber.tsx +97 -0
  93. package/src/components/HkScrollContainer.scss +101 -0
  94. package/src/components/HkScrollContainer.tsx +362 -0
  95. package/src/components/HkSearchInput.scss +132 -0
  96. package/src/components/HkSearchInput.tsx +117 -0
  97. package/src/components/HkSelect.scss +143 -0
  98. package/src/components/HkSelect.tsx +230 -0
  99. package/src/components/HkSelectionGrid.scss +177 -0
  100. package/src/components/HkSelectionGrid.tsx +99 -0
  101. package/src/components/HkSelectionWaterfall.scss +93 -0
  102. package/src/components/HkSelectionWaterfall.tsx +90 -0
  103. package/src/components/HkSidebar.scss +81 -0
  104. package/src/components/HkSidebar.tsx +45 -0
  105. package/src/components/HkSkeleton.scss +44 -0
  106. package/src/components/HkSkeleton.tsx +30 -0
  107. package/src/components/HkSkeletonList.tsx +35 -0
  108. package/src/components/HkSpinner.scss +67 -0
  109. package/src/components/HkSpinner.tsx +41 -0
  110. package/src/components/HkSplash.scss +60 -0
  111. package/src/components/HkSplash.tsx +40 -0
  112. package/src/components/HkSwitch.scss +157 -0
  113. package/src/components/HkSwitch.tsx +72 -0
  114. package/src/components/HkTable.scss +198 -0
  115. package/src/components/HkTable.tsx +246 -0
  116. package/src/components/HkTabs.scss +109 -0
  117. package/src/components/HkTabs.tsx +111 -0
  118. package/src/components/HkTag.scss +88 -0
  119. package/src/components/HkTag.tsx +44 -0
  120. package/src/components/HkTextarea.scss +72 -0
  121. package/src/components/HkTextarea.tsx +104 -0
  122. package/src/components/HkTimeline.scss +139 -0
  123. package/src/components/HkTimeline.tsx +95 -0
  124. package/src/components/HkToast.scss +168 -0
  125. package/src/components/HkToast.tsx +175 -0
  126. package/src/components/HkTooltip.scss +47 -0
  127. package/src/components/HkTooltip.tsx +108 -0
  128. package/src/components/HkTree.scss +188 -0
  129. package/src/components/HkTree.tsx +217 -0
  130. package/src/components/HkTrendChart.tsx +180 -0
  131. package/src/components/HkWindowedItem.scss +17 -0
  132. package/src/components/HkWindowedItem.tsx +107 -0
  133. package/src/components/HkZoomToolbar.scss +35 -0
  134. package/src/components/HkZoomToolbar.tsx +69 -0
  135. package/src/composables/useBreakpoint.ts +41 -0
  136. package/src/composables/useClipboard.ts +37 -0
  137. package/src/composables/useConfirm.ts +35 -0
  138. package/src/composables/useProgressDialog.ts +52 -0
  139. package/src/composables/useReportedTransition.ts +69 -0
  140. package/src/composables/useScrollWindow.ts +21 -0
  141. package/src/composables/useToast.ts +2 -0
  142. package/src/demo.scss +24 -0
  143. package/src/demo.ts +22 -0
  144. package/src/env.d.ts +6 -0
  145. package/src/i18n/context.ts +89 -0
  146. package/src/i18n/locales/ar/components.json +43 -0
  147. package/src/i18n/locales/ar/components.toml +40 -0
  148. package/src/i18n/locales/de/components.json +43 -0
  149. package/src/i18n/locales/de/components.toml +40 -0
  150. package/src/i18n/locales/en/components.json +43 -0
  151. package/src/i18n/locales/en/components.toml +40 -0
  152. package/src/i18n/locales/es/components.json +43 -0
  153. package/src/i18n/locales/es/components.toml +40 -0
  154. package/src/i18n/locales/fr/components.json +43 -0
  155. package/src/i18n/locales/fr/components.toml +40 -0
  156. package/src/i18n/locales/ja/components.json +43 -0
  157. package/src/i18n/locales/ja/components.toml +40 -0
  158. package/src/i18n/locales/ko/components.json +43 -0
  159. package/src/i18n/locales/ko/components.toml +40 -0
  160. package/src/i18n/locales/pt/components.json +43 -0
  161. package/src/i18n/locales/pt/components.toml +40 -0
  162. package/src/i18n/locales/ru/components.json +43 -0
  163. package/src/i18n/locales/ru/components.toml +40 -0
  164. package/src/i18n/locales/zh-Hans/components.json +43 -0
  165. package/src/i18n/locales/zh-Hans/components.toml +40 -0
  166. package/src/i18n/locales/zh-Hant/components.json +43 -0
  167. package/src/i18n/locales/zh-Hant/components.toml +40 -0
  168. package/src/index.ts +137 -0
  169. package/src/render.ts +18 -0
  170. package/src/runtime/animationBus.ts +278 -0
  171. package/src/runtime/cronBus.ts +25 -0
  172. package/src/runtime/index.ts +12 -0
  173. package/src/runtime/useAsyncData.ts +30 -0
  174. package/src/runtime/useBreakpoint.ts +62 -0
  175. package/src/runtime/useClipboard.ts +37 -0
  176. package/src/runtime/useConfirm.ts +35 -0
  177. package/src/runtime/useOverlay.ts +82 -0
  178. package/src/runtime/usePopupManager.ts +89 -0
  179. package/src/runtime/useToast.ts +140 -0
  180. package/src/styles/index.scss +10 -0
  181. package/src/theme/index.ts +5 -0
  182. package/src/theme/presets.ts +307 -0
  183. package/src/theme/theme.scss +17 -0
  184. package/src/theme/useBackgroundLuminance.ts +145 -0
  185. package/src/theme/useSolarTime.ts +101 -0
  186. package/src/theme/useTheme.ts +141 -0
  187. package/src/tokens.scss +26 -0
@@ -0,0 +1,481 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ ref,
8
+ Teleport,
9
+ Transition,
10
+ watch,
11
+ type PropType,
12
+ } from "vue";
13
+
14
+ import { useI18n } from "../i18n/context";
15
+ import "./HkModal.scss";
16
+ import { useOverlay } from "../runtime/useOverlay";
17
+ import { usePopupManager } from "../runtime/usePopupManager";
18
+ import HButton from "./HkButton";
19
+ import HSpinner from "./HkSpinner";
20
+
21
+ export interface ModalAction {
22
+ label: string;
23
+ variant?: "primary" | "secondary" | "danger" | "ghost" | "outline";
24
+ loading?: boolean;
25
+ disabled?: boolean;
26
+ onClick?: () => void;
27
+ }
28
+
29
+ const FOCUSABLE_SELECTOR =
30
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
31
+
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
+
55
+ export default defineComponent({
56
+ name: "HkModal",
57
+ props: {
58
+ modelValue: { type: Boolean, required: true },
59
+ title: { type: String, default: undefined },
60
+ closable: { type: Boolean, default: true },
61
+ width: { type: String, default: "32rem" },
62
+ footerActions: {
63
+ type: Array as PropType<ModalAction[]>,
64
+ default: undefined,
65
+ },
66
+ windowed: { type: Boolean, default: false },
67
+ overscanScreens: { type: Number, default: 1 },
68
+ autoFollow: { type: Boolean, default: false },
69
+ },
70
+ emits: {
71
+ "update:modelValue": (_value: boolean) => true,
72
+ afterLeave: () => true,
73
+ },
74
+ setup(props, { emit, slots }) {
75
+ const { t } = useI18n();
76
+ const manager = usePopupManager();
77
+ const overlay = useOverlay({ name: "hk-modal" });
78
+
79
+ const handle = ref<{ id: string; zIndex: number } | null>(null);
80
+ const bodyRef = ref<HTMLElement>();
81
+ const contentRef = ref<HTMLElement>();
82
+ const shouldRender = ref(false);
83
+ let previouslyFocused: HTMLElement | null = null;
84
+ let unmounted = false;
85
+
86
+ const overlayZ = computed(() => handle.value?.zIndex ?? 0);
87
+ const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
88
+ const resolvedWidth = computed(() => props.width);
89
+
90
+ // Windowed mode state
91
+ const visibleIndices = ref(new Set<number>());
92
+ const sentinelMap = ref(new Map<number, HTMLElement>());
93
+ let windowedObserver: IntersectionObserver | null = null;
94
+
95
+ // Auto-follow state
96
+ let autoFollowMutationObserver: MutationObserver | null = null;
97
+ const isFollowing = ref(true);
98
+ let userScrolled = false;
99
+ const scrollContainerRef = ref<HTMLElement>();
100
+
101
+ function close() {
102
+ emit("update:modelValue", false);
103
+ }
104
+
105
+ function onOverlayClick() {
106
+ if (!props.closable) return;
107
+ close();
108
+ }
109
+
110
+ function onContentClick(e: MouseEvent) {
111
+ e.stopPropagation();
112
+ }
113
+
114
+ function onKeydown(e: KeyboardEvent) {
115
+ if (e.key === "Escape") {
116
+ if (props.closable) {
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+ close();
120
+ }
121
+ return;
122
+ }
123
+ if (e.key === "Tab") {
124
+ const el = contentRef.value;
125
+ if (el) trapFocus(el, e);
126
+ }
127
+ }
128
+
129
+ function onAfterEnter() {
130
+ const el = contentRef.value;
131
+ if (el) {
132
+ focusFirst(el);
133
+ setupWindowed(el);
134
+ setupAutoFollow();
135
+ }
136
+ }
137
+
138
+ function onAfterLeave() {
139
+ if (handle.value) {
140
+ manager.unregister(handle.value.id);
141
+ handle.value = null;
142
+ }
143
+ shouldRender.value = false;
144
+ if (previouslyFocused) {
145
+ previouslyFocused.focus();
146
+ previouslyFocused = null;
147
+ }
148
+ teardownWindowed();
149
+ teardownAutoFollow();
150
+ emit("afterLeave");
151
+ }
152
+
153
+ // --- Windowed mode ---
154
+
155
+ function setupWindowed(container: HTMLElement) {
156
+ if (!props.windowed) return;
157
+ teardownWindowed();
158
+
159
+ const sentinels = container.querySelectorAll<HTMLElement>(
160
+ "[data-hk-windowed]",
161
+ );
162
+ const overscanPx =
163
+ props.overscanScreens * (container.clientHeight || 600);
164
+
165
+ windowedObserver = new IntersectionObserver(
166
+ (entries) => {
167
+ const next = new Set(visibleIndices.value);
168
+ let changed = false;
169
+ for (const entry of entries) {
170
+ const sentinel = entry.target as HTMLElement;
171
+ const idxStr = sentinel.dataset.hkWindowed;
172
+ if (idxStr == null) continue;
173
+ const idx = Number(idxStr);
174
+ if (entry.isIntersecting) {
175
+ if (!next.has(idx)) {
176
+ next.add(idx);
177
+ changed = true;
178
+ }
179
+ } else {
180
+ if (next.has(idx)) {
181
+ next.delete(idx);
182
+ changed = true;
183
+ }
184
+ }
185
+ }
186
+ if (changed) {
187
+ visibleIndices.value = next;
188
+ }
189
+ },
190
+ {
191
+ root: container,
192
+ rootMargin: `${overscanPx}px 0px ${overscanPx}px 0px`,
193
+ threshold: 0,
194
+ },
195
+ );
196
+
197
+ for (const s of sentinels) {
198
+ windowedObserver.observe(s);
199
+ }
200
+ }
201
+
202
+ function teardownWindowed() {
203
+ if (windowedObserver) {
204
+ windowedObserver.disconnect();
205
+ windowedObserver = null;
206
+ }
207
+ visibleIndices.value = new Set();
208
+ sentinelMap.value = new Map();
209
+ }
210
+
211
+ // --- Auto-follow ---
212
+
213
+ function scrollToBottom(smooth = false) {
214
+ const el = scrollContainerRef.value;
215
+ if (!el) return;
216
+ el.scrollTo({
217
+ top: el.scrollHeight,
218
+ behavior: smooth ? "smooth" : "instant" as ScrollBehavior,
219
+ });
220
+ }
221
+
222
+ function onBodyScroll() {
223
+ if (!props.autoFollow) return;
224
+ const el = scrollContainerRef.value;
225
+ if (!el) return;
226
+ const threshold = 32;
227
+ const distanceFromBottom =
228
+ el.scrollHeight - el.scrollTop - el.clientHeight;
229
+ if (distanceFromBottom > threshold) {
230
+ userScrolled = true;
231
+ isFollowing.value = false;
232
+ } else {
233
+ userScrolled = false;
234
+ isFollowing.value = true;
235
+ }
236
+ }
237
+
238
+ function setupAutoFollow() {
239
+ if (!props.autoFollow || !scrollContainerRef.value) return;
240
+ teardownAutoFollow();
241
+
242
+ isFollowing.value = true;
243
+ userScrolled = false;
244
+ scrollToBottom(false);
245
+
246
+ autoFollowMutationObserver = new MutationObserver(() => {
247
+ if (isFollowing.value && !unmounted) {
248
+ nextTick(() => scrollToBottom(true));
249
+ }
250
+ });
251
+
252
+ autoFollowMutationObserver.observe(scrollContainerRef.value, {
253
+ childList: true,
254
+ subtree: true,
255
+ characterData: true,
256
+ });
257
+ }
258
+
259
+ function teardownAutoFollow() {
260
+ if (autoFollowMutationObserver) {
261
+ autoFollowMutationObserver.disconnect();
262
+ autoFollowMutationObserver = null;
263
+ }
264
+ isFollowing.value = true;
265
+ userScrolled = false;
266
+ }
267
+
268
+ function resumeAutoFollow() {
269
+ if (!props.autoFollow) return;
270
+ isFollowing.value = true;
271
+ userScrolled = false;
272
+ scrollToBottom(true);
273
+ }
274
+
275
+ // --- Lifecycle ---
276
+
277
+ watch(
278
+ () => props.modelValue,
279
+ (val) => {
280
+ if (unmounted) return;
281
+ if (val) {
282
+ previouslyFocused = document.activeElement as HTMLElement | null;
283
+ if (handle.value) {
284
+ manager.unregister(handle.value.id);
285
+ }
286
+ shouldRender.value = true;
287
+ handle.value = manager.register("modal", true, props.title);
288
+ overlay.open();
289
+ } else {
290
+ // Close happens via Transition onAfterLeave,
291
+ // but if modelValue flips to false without Transition
292
+ // (e.g. immediate), clean up now.
293
+ }
294
+ },
295
+ { immediate: true },
296
+ );
297
+
298
+ watch(
299
+ () => props.title,
300
+ (newTitle) => {
301
+ if (handle.value && newTitle) {
302
+ manager.setTitle(handle.value.id, newTitle);
303
+ }
304
+ },
305
+ );
306
+
307
+ onMounted(() => {
308
+ unmounted = false;
309
+ });
310
+
311
+ onBeforeUnmount(() => {
312
+ unmounted = true;
313
+ teardownWindowed();
314
+ teardownAutoFollow();
315
+ if (handle.value) {
316
+ manager.unregister(handle.value.id);
317
+ handle.value = null;
318
+ }
319
+ shouldRender.value = false;
320
+ });
321
+
322
+ // --- Render helpers ---
323
+
324
+ function renderFooter() {
325
+ if (slots.footer) {
326
+ return <div class="hk-modal-footer">{slots.footer()}</div>;
327
+ }
328
+ if (props.footerActions && props.footerActions.length > 0) {
329
+ return (
330
+ <div class="hk-modal-footer">
331
+ {props.footerActions.map((action, i) => (
332
+ <HButton
333
+ key={i}
334
+ variant={action.variant ?? "secondary"}
335
+ size="sm"
336
+ loading={action.loading}
337
+ disabled={action.disabled}
338
+ onClick={action.onClick}
339
+ >
340
+ {action.label}
341
+ </HButton>
342
+ ))}
343
+ </div>
344
+ );
345
+ }
346
+ return null;
347
+ }
348
+
349
+ return () => {
350
+ if (!shouldRender.value) return null;
351
+
352
+ const headerShown = props.title || props.closable || slots.header;
353
+
354
+ return (
355
+ <Teleport to="body">
356
+ <div
357
+ class="hk-modal-root"
358
+ style={{ zIndex: overlayZ.value }}
359
+ onKeydown={onKeydown}
360
+ >
361
+ <Transition name="hk-modal-overlay" appear>
362
+ {props.modelValue && (
363
+ <div
364
+ class="hk-modal-overlay"
365
+ onClick={onOverlayClick}
366
+ />
367
+ )}
368
+ </Transition>
369
+ <Transition
370
+ name="hk-modal-content"
371
+ appear
372
+ onAfterEnter={onAfterEnter}
373
+ onAfterLeave={onAfterLeave}
374
+ >
375
+ {props.modelValue && (
376
+ <div
377
+ ref={contentRef}
378
+ class="hk-modal-content"
379
+ role="dialog"
380
+ aria-modal="true"
381
+ aria-label={props.title}
382
+ style={{
383
+ maxWidth: resolvedWidth.value,
384
+ zIndex: contentZ.value,
385
+ }}
386
+ onClick={onContentClick}
387
+ tabindex={-1}
388
+ >
389
+ {headerShown && (
390
+ <>
391
+ <div class="hk-modal-header">
392
+ <h2 class="hk-modal-title">
393
+ {slots.header ? (props.title ?? "") : (props.title ?? "")}
394
+ </h2>
395
+ {props.closable && (
396
+ <button
397
+ class="hk-modal-close"
398
+ aria-label={t("hk.modal.close", "Close")}
399
+ onClick={close}
400
+ >
401
+ <svg
402
+ viewBox="0 0 24 24"
403
+ fill="none"
404
+ stroke="currentColor"
405
+ stroke-width="2"
406
+ stroke-linecap="round"
407
+ width="16"
408
+ height="16"
409
+ >
410
+ <line x1="18" y1="6" x2="6" y2="18" />
411
+ <line x1="6" y1="6" x2="18" y2="18" />
412
+ </svg>
413
+ </button>
414
+ )}
415
+ </div>
416
+ {slots.header && (
417
+ <div class="hk-modal-subheader">{slots.header()}</div>
418
+ )}
419
+ </>
420
+ )}
421
+ <div ref={bodyRef} class="hk-modal-body">
422
+ <div
423
+ ref={scrollContainerRef}
424
+ class="hk-modal-body-scroll"
425
+ onScroll={onBodyScroll}
426
+ >
427
+ <div class="hk-modal-body-inner">
428
+ {props.windowed
429
+ ? renderWindowedBody()
430
+ : slots.default?.()}
431
+ </div>
432
+ </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>
451
+ )}
452
+ </div>
453
+ {renderFooter()}
454
+ </div>
455
+ )}
456
+ </Transition>
457
+ </div>
458
+ </Teleport>
459
+ );
460
+ };
461
+
462
+ function renderWindowedBody() {
463
+ const children = slots.default?.();
464
+ if (!children) return null;
465
+
466
+ const arr = Array.isArray(children) ? children : [children];
467
+
468
+ return arr.map((child, i) => (
469
+ <div
470
+ key={i}
471
+ data-hk-windowed={String(i)}
472
+ style={{ minHeight: "1px" }}
473
+ >
474
+ {visibleIndices.value.has(i) || i === 0 || i === arr.length - 1
475
+ ? child
476
+ : <div style={{ height: "1em" }} />}
477
+ </div>
478
+ ));
479
+ }
480
+ },
481
+ });
@@ -0,0 +1,55 @@
1
+ .hk-modal-breadcrumb {
2
+ position: fixed;
3
+ left: 50%;
4
+ transform: translate(-50%, -50%);
5
+ z-index: var(--hk-breadcrumb-z, 100001);
6
+ display: flex;
7
+ align-items: center;
8
+ gap: var(--hi-space-4, 0.5rem);
9
+ padding: var(--hi-space-6, 0.75rem) var(--hi-space-16, 2rem);
10
+ background: var(--hi-color-surface, rgba(255, 255, 255, 0.95));
11
+ border: 1px solid var(--hk-breadcrumb-border, rgba(0, 0, 0, 0.06));
12
+ border-radius: var(--hi-radius-md, 0.5rem);
13
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
14
+ backdrop-filter: blur(16px);
15
+ pointer-events: none;
16
+ user-select: none;
17
+ animation: hk-modal-breadcrumb-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
18
+ }
19
+
20
+ .hk-modal-breadcrumb-crumb {
21
+ display: inline-flex;
22
+ align-items: center;
23
+ gap: var(--hi-space-4, 0.5rem);
24
+ }
25
+
26
+ .hk-modal-breadcrumb-item {
27
+ display: inline-flex;
28
+ align-items: center;
29
+ line-height: 1.4;
30
+ font-size: var(--hi-text-xs, 0.75rem);
31
+ font-weight: 600;
32
+ color: var(--hi-color-text-secondary, #666);
33
+ white-space: nowrap;
34
+ }
35
+
36
+ .hk-modal-breadcrumb-item-current {
37
+ color: var(--hi-color-primary, #58a6ff);
38
+ }
39
+
40
+ .hk-modal-breadcrumb-sep {
41
+ color: var(--hi-color-text-secondary, #666);
42
+ opacity: 0.5;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ @keyframes hk-modal-breadcrumb-in {
47
+ from {
48
+ opacity: 0;
49
+ transform: translate(-50%, calc(-50% - 8px));
50
+ }
51
+ to {
52
+ opacity: 1;
53
+ transform: translate(-50%, -50%);
54
+ }
55
+ }
@@ -0,0 +1,113 @@
1
+ import { computed, defineComponent, onBeforeUnmount, ref, Teleport, watch } from "vue";
2
+ import { usePopupManager } from "../runtime/usePopupManager";
3
+ import { useReportedTransition } from "../composables/useReportedTransition";
4
+ import { scheduleEvery } from "../runtime/animationBus";
5
+ import "./HkModalBreadcrumb.scss";
6
+
7
+ export default defineComponent({
8
+ name: "HkModalBreadcrumb",
9
+ props: {
10
+ appRootId: { type: String, default: "app" },
11
+ headerSelector: { type: String, default: ".hk-glass-header" },
12
+ headerFallbackHeight: { type: Number, default: 48 },
13
+ },
14
+ setup(props) {
15
+ const manager = usePopupManager();
16
+
17
+ const modalEntries = computed(() => {
18
+ const entries: { id: string; zIndex: number; title?: string }[] = [];
19
+ for (const [, entry] of manager.registry.value) {
20
+ if (entry.kind === "modal") {
21
+ entries.push({ id: entry.id, zIndex: entry.zIndex, title: entry.title });
22
+ }
23
+ }
24
+ return entries.sort((a, b) => a.zIndex - b.zIndex);
25
+ });
26
+
27
+ const visible = computed(() => modalEntries.value.length > 1);
28
+
29
+ const segments = computed(() =>
30
+ modalEntries.value.map((m, i) => ({
31
+ label: m.title || `Layer ${i + 1}`,
32
+ current: i === modalEntries.value.length - 1,
33
+ })),
34
+ );
35
+
36
+ const topPx = ref(24);
37
+ function resyncTop() {
38
+ const app = document.getElementById(props.appRootId);
39
+ if (!app) return;
40
+ const appTop = parseFloat(getComputedStyle(app).top) || 0;
41
+ const header = app.querySelector(props.headerSelector) as HTMLElement | null;
42
+ const headerH = header ? header.getBoundingClientRect().height : props.headerFallbackHeight;
43
+ topPx.value = appTop + headerH / 2;
44
+ }
45
+
46
+ const ENTER_ANIM_MS = 150;
47
+ const enterAnim = useReportedTransition(ENTER_ANIM_MS);
48
+ let handle: ReturnType<typeof scheduleEvery> | null = null;
49
+ watch(
50
+ visible,
51
+ (v) => {
52
+ if (v) {
53
+ resyncTop();
54
+ enterAnim.run();
55
+ if (!handle) handle = scheduleEvery(resyncTop, 1000);
56
+ window.addEventListener("resize", resyncTop);
57
+ } else {
58
+ enterAnim.cancel();
59
+ if (handle) {
60
+ handle.disconnect();
61
+ handle = null;
62
+ }
63
+ window.removeEventListener("resize", resyncTop);
64
+ }
65
+ },
66
+ { immediate: true },
67
+ );
68
+ onBeforeUnmount(() => {
69
+ if (handle) handle.disconnect();
70
+ window.removeEventListener("resize", resyncTop);
71
+ });
72
+
73
+ return () =>
74
+ visible.value ? (
75
+ <Teleport to="body">
76
+ <div
77
+ class="hk-modal-breadcrumb"
78
+ aria-live="polite"
79
+ style={{ top: `${topPx.value}px` }}
80
+ >
81
+ {segments.value.map((seg, i) => (
82
+ <span key={i} class="hk-modal-breadcrumb-crumb">
83
+ {i > 0 && (
84
+ <svg
85
+ class="hk-modal-breadcrumb-sep"
86
+ width="12"
87
+ height="12"
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ stroke-width="2"
92
+ stroke-linecap="round"
93
+ >
94
+ <polyline points="9 18 15 12 9 6" />
95
+ </svg>
96
+ )}
97
+ <span
98
+ class={[
99
+ "hk-modal-breadcrumb-item",
100
+ seg.current && "hk-modal-breadcrumb-item-current",
101
+ ]
102
+ .filter(Boolean)
103
+ .join(" ")}
104
+ >
105
+ {seg.label}
106
+ </span>
107
+ </span>
108
+ ))}
109
+ </div>
110
+ </Teleport>
111
+ ) : null;
112
+ },
113
+ });