@karyl-chan/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +58 -0
  2. package/src/components/AppBadge.vue +155 -0
  3. package/src/components/AppButton.vue +171 -0
  4. package/src/components/AppConfirmDialog.vue +176 -0
  5. package/src/components/AppItemCard.vue +216 -0
  6. package/src/components/AppMenu.vue +197 -0
  7. package/src/components/AppMenuItem.vue +43 -0
  8. package/src/components/AppModal.vue +257 -0
  9. package/src/components/AppPopover.vue +275 -0
  10. package/src/components/AppSelect.vue +89 -0
  11. package/src/components/AppSelectField.vue +260 -0
  12. package/src/components/AppTabs.vue +310 -0
  13. package/src/components/AppTabsRouted.vue +409 -0
  14. package/src/components/AppTextArea.vue +128 -0
  15. package/src/components/AppTextField.vue +165 -0
  16. package/src/components/AppToast.vue +65 -0
  17. package/src/components/AppToggle.vue +105 -0
  18. package/src/components/Draggable.vue +178 -0
  19. package/src/components/GlobalConfirmDialog.vue +25 -0
  20. package/src/components/UnreadPill.vue +28 -0
  21. package/src/components/UserAvatar.vue +114 -0
  22. package/src/components/UserCard.vue +274 -0
  23. package/src/components/UserItem.vue +167 -0
  24. package/src/components/tab-types.ts +9 -0
  25. package/src/composables/use-app-shell.ts +65 -0
  26. package/src/composables/use-breakpoint.ts +14 -0
  27. package/src/composables/use-click-outside-stack.ts +67 -0
  28. package/src/composables/use-color-scheme.ts +75 -0
  29. package/src/composables/use-confirm.ts +36 -0
  30. package/src/composables/use-drawer.css +47 -0
  31. package/src/composables/use-drawer.ts +58 -0
  32. package/src/composables/use-escape-stack.ts +80 -0
  33. package/src/composables/use-file-drop.ts +47 -0
  34. package/src/composables/use-long-press.ts +62 -0
  35. package/src/composables/use-popover.css +32 -0
  36. package/src/composables/use-popover.ts +1324 -0
  37. package/src/composables/use-shift-key.ts +18 -0
  38. package/src/index.ts +75 -0
  39. package/src/lib/avatar.ts +35 -0
  40. package/src/reset.css +53 -0
  41. package/src/stores/toastStore.ts +26 -0
  42. package/src/tokens.css +159 -0
@@ -0,0 +1,1324 @@
1
+ /**
2
+ * Popover composable — three layers in this single file. Big on purpose:
3
+ * the imperative createPopover() relies on closure-shared mutable state
4
+ * (popperInstance, isVisible, animation counters, leave/enter flags,
5
+ * etc.) across ~25 internal helpers. Splitting it into multiple files
6
+ * would force that state into a class or a passed-around context
7
+ * object — both noisier than the closure for no architectural win.
8
+ *
9
+ * Layout:
10
+ * 1. Types + utility exports (~lines 30 – 380)
11
+ * TriggerType, PopoverOptions, PopoverInstance, UsePopoverOptions,
12
+ * UsePopoverReturn, PLACEMENTS, createVirtualElement{,FromEvent},
13
+ * getScrollParent{,s}, DEFAULT_OPTIONS
14
+ *
15
+ * 2. createPopover() — imperative API (~lines 380 – 1050)
16
+ * Internal sub-sections (search by header comments):
17
+ * • animation: isPlacementAnimation … performLeaveTransition
18
+ * • popper lifecycle: buildPopperOptions, show, hide, toggle, update
19
+ * • event listeners: clearEventListeners, setupCloseListeners,
20
+ * setupTriggerListeners, setupAllEventListeners
21
+ * • public API: setOptions, destroy, updateReference
22
+ *
23
+ * 3. usePopover() — reactive Vue wrapper (~lines 1050 – 1220)
24
+ * Wraps createPopover with refs, watches options changes, manages
25
+ * click-outside / escape stacks, exposes Vue-friendly Ref<bool>.
26
+ *
27
+ * If you need to extend: prefer adding helpers inside the relevant
28
+ * sub-section of createPopover over breaking out a sibling file —
29
+ * everything in here references the same closure state.
30
+ */
31
+
32
+ import { createPopper } from "@popperjs/core";
33
+ import type {
34
+ Instance as PopperInstance,
35
+ Options as PopperOptions,
36
+ Placement,
37
+ VirtualElement,
38
+ Modifier,
39
+ State,
40
+ } from "@popperjs/core";
41
+ import {
42
+ ref,
43
+ watch,
44
+ onMounted,
45
+ onBeforeUnmount,
46
+ type Ref,
47
+ unref,
48
+ type MaybeRef,
49
+ } from "vue";
50
+
51
+ import { useClickOutsideStack } from "./use-click-outside-stack";
52
+ import { useEscapeStack } from "./use-escape-stack";
53
+
54
+ export type TriggerType =
55
+ | "click"
56
+ | "hover"
57
+ | "focus"
58
+ | "manual"
59
+ | "contextmenu";
60
+
61
+ export interface PopoverOptions {
62
+ placement?: Placement;
63
+ trigger?: TriggerType | TriggerType[];
64
+ showDelay?: number;
65
+ hideDelay?: number;
66
+ /** [skidding, distance] */
67
+ offset?: [number, number];
68
+ arrow?: boolean | HTMLElement;
69
+ arrowPadding?: number;
70
+ flip?: boolean;
71
+ fallbackPlacements?: Placement[];
72
+ boundary?: HTMLElement | "clippingParents" | "viewport";
73
+ strategy?: "fixed" | "absolute";
74
+ preventOverflow?: boolean;
75
+ overflowPadding?: number;
76
+ gpuAcceleration?: boolean;
77
+ /** 與觸發元素同寬 */
78
+ sameWidth?: boolean;
79
+ zIndex?: number;
80
+ onShow?: () => void;
81
+ onHide?: () => void;
82
+ onFirstUpdate?: (state: State) => void;
83
+ disabled?: boolean;
84
+ teleportTo?: string | HTMLElement;
85
+ closeOnContentClick?: boolean;
86
+ /** 由 usePopover composable 統一管理,raw createPopover 不處理此選項 */
87
+ closeOnClickOutside?: boolean;
88
+ /** 由 usePopover composable 統一管理,raw createPopover 不處理此選項 */
89
+ closeOnEscape?: boolean;
90
+ hideOnScroll?: boolean;
91
+ /** false 禁用, true 使用預設名稱 'popover', string 自定義名稱 */
92
+ transition?: boolean | string;
93
+ /** 作為 transitionend 事件的備用超時 (ms) */
94
+ transitionDuration?: number;
95
+ onBeforeShow?: () => void;
96
+ onAfterShow?: () => void;
97
+ onBeforeHide?: () => void;
98
+ onAfterHide?: () => void;
99
+ }
100
+
101
+ export interface PopoverInstance {
102
+ popper: PopperInstance | null;
103
+ show: () => void;
104
+ hide: () => void;
105
+ toggle: () => void;
106
+ update: () => Promise<Partial<State>> | void;
107
+ forceUpdate: () => void;
108
+ setOptions: (options: Partial<PopoverOptions>) => void;
109
+ updateReference: (newReference: HTMLElement | VirtualElement) => void;
110
+ destroy: () => void;
111
+ isVisible: () => boolean;
112
+ /** 含過渡動畫期間,動畫結束後才變 false */
113
+ isContentVisible: () => boolean;
114
+ getPlacement: () => Placement | undefined;
115
+ }
116
+
117
+ export interface UsePopoverOptions extends PopoverOptions {
118
+ visible?: Ref<boolean>;
119
+ }
120
+
121
+ export interface UsePopoverReturn {
122
+ instance: Ref<PopoverInstance | null>;
123
+ /** 意圖狀態,hide 時立即變 false */
124
+ isVisible: Ref<boolean>;
125
+ /** 含過渡動畫期間,動畫結束後才變 false */
126
+ isContentVisible: Ref<boolean>;
127
+ show: () => void;
128
+ hide: () => void;
129
+ toggle: () => void;
130
+ update: () => void;
131
+ }
132
+
133
+ const PLACEMENT_ANIM_DIR: Record<string, "down" | "up" | "left" | "right"> = {
134
+ top: "up",
135
+ bottom: "down",
136
+ left: "left",
137
+ right: "right",
138
+ };
139
+
140
+ function getPlacementAnimClass(placement: string, reverse: boolean): string {
141
+ const dir = placement.split("-")[0];
142
+ const cssDir = PLACEMENT_ANIM_DIR[dir] ?? "down";
143
+ const direction = reverse ? "reverse" : "normal";
144
+ return `popover-anim-${cssDir}-${direction}`;
145
+ }
146
+
147
+ export const PLACEMENTS: Placement[] = [
148
+ "top",
149
+ "top-start",
150
+ "top-end",
151
+ "bottom",
152
+ "bottom-start",
153
+ "bottom-end",
154
+ "left",
155
+ "left-start",
156
+ "left-end",
157
+ "right",
158
+ "right-start",
159
+ "right-end",
160
+ "auto",
161
+ "auto-start",
162
+ "auto-end",
163
+ ];
164
+
165
+ const DEFAULT_OPTIONS: Required<
166
+ Pick<
167
+ PopoverOptions,
168
+ | "placement"
169
+ | "trigger"
170
+ | "showDelay"
171
+ | "hideDelay"
172
+ | "offset"
173
+ | "arrow"
174
+ | "arrowPadding"
175
+ | "flip"
176
+ | "strategy"
177
+ | "preventOverflow"
178
+ | "overflowPadding"
179
+ | "gpuAcceleration"
180
+ | "sameWidth"
181
+ | "closeOnContentClick"
182
+ | "hideOnScroll"
183
+ | "transition"
184
+ >
185
+ > = {
186
+ placement: "bottom",
187
+ trigger: "click",
188
+ showDelay: 0,
189
+ hideDelay: 0,
190
+ offset: [0, 8],
191
+ arrow: false,
192
+ arrowPadding: 4,
193
+ flip: true,
194
+ strategy: "absolute",
195
+ preventOverflow: true,
196
+ overflowPadding: 8,
197
+ gpuAcceleration: true,
198
+ sameWidth: false,
199
+ closeOnContentClick: false,
200
+ hideOnScroll: false,
201
+ transition: true,
202
+ };
203
+
204
+ export function createVirtualElement(x: number, y: number): VirtualElement {
205
+ return {
206
+ getBoundingClientRect: () => ({
207
+ width: 0,
208
+ height: 0,
209
+ top: y,
210
+ right: x,
211
+ bottom: y,
212
+ left: x,
213
+ x,
214
+ y,
215
+ toJSON: () => ({}),
216
+ }),
217
+ };
218
+ }
219
+
220
+ export function createVirtualElementFromEvent(
221
+ event: MouseEvent,
222
+ ): VirtualElement {
223
+ return createVirtualElement(event.clientX, event.clientY);
224
+ }
225
+
226
+ export function getScrollParent(element: HTMLElement): HTMLElement | Window {
227
+ if (!element) return window;
228
+
229
+ const overflowRegex = /(auto|scroll)/;
230
+ const { position } = getComputedStyle(element);
231
+
232
+ if (position === "fixed") return window;
233
+
234
+ let parent: HTMLElement | null = element.parentElement;
235
+
236
+ while (parent) {
237
+ const style = getComputedStyle(parent);
238
+ const { overflow, overflowX, overflowY } = style;
239
+
240
+ if (overflowRegex.test(overflow + overflowY + overflowX)) {
241
+ return parent;
242
+ }
243
+ parent = parent.parentElement;
244
+ }
245
+
246
+ return window;
247
+ }
248
+
249
+ export function getScrollParents(
250
+ element: HTMLElement,
251
+ ): (HTMLElement | Window)[] {
252
+ const scrollParents: (HTMLElement | Window)[] = [];
253
+ let current: HTMLElement | null = element;
254
+
255
+ while (current) {
256
+ const scrollParent = getScrollParent(current);
257
+ if (scrollParent === window) {
258
+ scrollParents.push(window);
259
+ break;
260
+ }
261
+ scrollParents.push(scrollParent as HTMLElement);
262
+ current = scrollParent as HTMLElement;
263
+ }
264
+
265
+ return scrollParents;
266
+ }
267
+
268
+ function createSameWidthModifier(): Modifier<"sameWidth", object> {
269
+ return {
270
+ name: "sameWidth",
271
+ enabled: true,
272
+ phase: "beforeWrite",
273
+ requires: ["computeStyles"],
274
+ fn({ state }) {
275
+ state.styles.popper.width = `${state.rects.reference.width}px`;
276
+ },
277
+ effect({ state }) {
278
+ // effect 階段 rects 尚未計算,直接從 DOM 元素讀取寬度
279
+ const refWidth = (
280
+ state.elements.reference as HTMLElement
281
+ ).getBoundingClientRect().width;
282
+ state.elements.popper.style.width = `${refWidth}px`;
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 創建 z-index 修飾器
289
+ */
290
+ function createZIndexModifier(zIndex: number): Modifier<"zIndex", object> {
291
+ return {
292
+ name: "zIndex",
293
+ enabled: true,
294
+ phase: "write",
295
+ fn({ state }) {
296
+ state.elements.popper.style.zIndex = String(zIndex);
297
+ },
298
+ };
299
+ }
300
+
301
+ /**
302
+ * 創建 GPU 加速修飾器
303
+ */
304
+ function createGpuAccelerationModifier(
305
+ enabled: boolean,
306
+ ): Partial<Modifier<"computeStyles", { gpuAcceleration: boolean }>> {
307
+ return {
308
+ name: "computeStyles",
309
+ options: {
310
+ gpuAcceleration: enabled,
311
+ },
312
+ };
313
+ }
314
+
315
+ /**
316
+ * 修正箭頭跨軸定位:Popper.js 的 arrow modifier 只設定主軸(沿邊緣置中),
317
+ * 跨軸(箭頭在哪條邊)完全依賴外部 CSS。為避免 CSS 規則未生效或被覆蓋
318
+ * 導致箭頭跑到錯誤的位置,此 modifier 直接透過 inline style 設定跨軸定位。
319
+ */
320
+ function createArrowPositionFixModifier(): Modifier<
321
+ "arrowPositionFix",
322
+ object
323
+ > {
324
+ return {
325
+ name: "arrowPositionFix",
326
+ enabled: true,
327
+ phase: "afterWrite",
328
+ requires: ["arrow"],
329
+ fn({ state }) {
330
+ const arrow = state.elements.arrow;
331
+ if (!arrow) return;
332
+
333
+ const basePlacement = state.placement.split("-")[0];
334
+ const arrowHalfH = arrow.offsetHeight / 2;
335
+ const arrowHalfW = arrow.offsetWidth / 2;
336
+
337
+ // 清除所有跨軸方向,再設定正確的那一個
338
+ arrow.style.top = "";
339
+ arrow.style.bottom = "";
340
+ arrow.style.left = "";
341
+ arrow.style.right = "";
342
+
343
+ switch (basePlacement) {
344
+ case "top":
345
+ arrow.style.bottom = `${-arrowHalfH}px`;
346
+ break;
347
+ case "bottom":
348
+ arrow.style.top = `${-arrowHalfH}px`;
349
+ break;
350
+ case "left":
351
+ arrow.style.right = `${-arrowHalfW}px`;
352
+ break;
353
+ case "right":
354
+ arrow.style.left = `${-arrowHalfW}px`;
355
+ break;
356
+ }
357
+
358
+ // 還原 Popper 計算的主軸定位
359
+ const arrowData = state.modifiersData.arrow;
360
+ if (arrowData) {
361
+ if (basePlacement === "top" || basePlacement === "bottom") {
362
+ arrow.style.left = arrowData.x != null ? `${arrowData.x}px` : "";
363
+ } else {
364
+ arrow.style.top = arrowData.y != null ? `${arrowData.y}px` : "";
365
+ }
366
+ }
367
+ },
368
+ };
369
+ }
370
+
371
+ /**
372
+ * 創建 Popover 實例
373
+ *
374
+ * @param reference - 觸發元素
375
+ * @param content - 彈出層內容元素
376
+ * @param options - 配置選項
377
+ * @returns PopoverInstance
378
+ *
379
+ * @example
380
+ * ```ts
381
+ * const popover = createPopover(buttonEl, tooltipEl, {
382
+ * placement: 'top',
383
+ * trigger: 'hover',
384
+ * showDelay: 100,
385
+ * hideDelay: 200,
386
+ * })
387
+ *
388
+ * popover.show()
389
+ * popover.hide()
390
+ * popover.destroy()
391
+ * ```
392
+ */
393
+ export function createPopover(
394
+ initialReference: HTMLElement | VirtualElement,
395
+ content: HTMLElement,
396
+ options: PopoverOptions = {},
397
+ ): PopoverInstance {
398
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
399
+ let reference = initialReference;
400
+
401
+ let popperInstance: PopperInstance | null = null;
402
+ let isVisible = false;
403
+ let destroyed = false;
404
+ let showTimeout: ReturnType<typeof setTimeout> | null = null;
405
+ let hideTimeout: ReturnType<typeof setTimeout> | null = null;
406
+ let originalParent: ParentNode | null = null;
407
+
408
+ // 事件監聽器的 cleanup — 會在 setOptions / updateReference 改變 trigger
409
+ // 設定時整批 teardown + rebind。觀察者類 cleanup(ResizeObserver 等)
410
+ // 不放這裡,否則 trigger 切換會誤殺它們,dynamic-resize 後就不會重新
411
+ // 觸發 popper.update()。
412
+ const cleanupFns: (() => void)[] = [];
413
+ // 跟元素生命週期綁定的長期觀察者 cleanup — 只在 destroy 時釋放。
414
+ const lifecycleCleanups: (() => void)[] = [];
415
+
416
+ let isLeaving = false;
417
+ let currentAnimationId = 0;
418
+ let animationCleanup: (() => void) | null = null;
419
+ let currentAnimClass: string | null = null;
420
+ let updateReferenceCleanup: (() => void) | null = null;
421
+
422
+ /**
423
+ * 是否使用內建方向感知動畫(transition === true)
424
+ */
425
+ function isPlacementAnimation(): boolean {
426
+ return mergedOptions.transition === true;
427
+ }
428
+
429
+ /**
430
+ * 取得過渡動畫名稱;回傳 null 表示不啟用(含 placement animation 由另一路徑處理)
431
+ */
432
+ function getTransitionName(): string | null {
433
+ if (!mergedOptions.transition || mergedOptions.transition === true)
434
+ return null;
435
+ return mergedOptions.transition;
436
+ }
437
+
438
+ /**
439
+ * 等待 CSS transition / animation 結束,附帶備用超時
440
+ * 回傳取消函式
441
+ */
442
+ function whenTransitionEnds(
443
+ el: HTMLElement,
444
+ callback: () => void,
445
+ ): () => void {
446
+ const duration = mergedOptions.transitionDuration ?? 300;
447
+ let done = false;
448
+
449
+ const resolve = () => {
450
+ if (done) return;
451
+ done = true;
452
+ el.removeEventListener("transitionend", onEnd);
453
+ el.removeEventListener("animationend", onEnd);
454
+ clearTimeout(fallbackTimer);
455
+ callback();
456
+ };
457
+
458
+ const onEnd = (e: TransitionEvent | AnimationEvent) => {
459
+ if (e.target === el) resolve();
460
+ };
461
+
462
+ el.addEventListener("transitionend", onEnd);
463
+ el.addEventListener("animationend", onEnd);
464
+ const fallbackTimer = setTimeout(resolve, duration);
465
+
466
+ return () => {
467
+ if (done) return;
468
+ done = true;
469
+ el.removeEventListener("transitionend", onEnd);
470
+ el.removeEventListener("animationend", onEnd);
471
+ clearTimeout(fallbackTimer);
472
+ };
473
+ }
474
+
475
+ /**
476
+ * 清除所有過渡動畫 class 並取消進行中的動畫
477
+ */
478
+ function cancelCurrentAnimation() {
479
+ currentAnimationId++;
480
+
481
+ if (animationCleanup) {
482
+ animationCleanup();
483
+ animationCleanup = null;
484
+ }
485
+
486
+ // 清除 class-based transition classes
487
+ const name = getTransitionName();
488
+ if (name) {
489
+ const e = [
490
+ `${name}-enter-from`,
491
+ `${name}-enter-active`,
492
+ `${name}-enter-to`,
493
+ ];
494
+ const l = [
495
+ `${name}-leave-from`,
496
+ `${name}-leave-active`,
497
+ `${name}-leave-to`,
498
+ ];
499
+ content.classList.remove(...e, ...l);
500
+ }
501
+
502
+ // 清除 placement animation class
503
+ if (currentAnimClass) {
504
+ content.classList.remove(currentAnimClass);
505
+ currentAnimClass = null;
506
+ }
507
+ content.style.opacity = "";
508
+
509
+ isLeaving = false;
510
+ }
511
+
512
+ /**
513
+ * 方向感知動畫 — 根據 Popper placement 加上 MasterCSS @keyframes animation class
514
+ */
515
+ function performPlacementAnimation(reverse: boolean, onComplete: () => void) {
516
+ const animId = currentAnimationId;
517
+ if (reverse) isLeaving = true;
518
+
519
+ const startAnimation = () => {
520
+ if (animId !== currentAnimationId) return;
521
+
522
+ if (!reverse) {
523
+ // Enter:重新 forceUpdate,讓 Popper 根據完整渲染後的內容高度決定最終方向
524
+ popperInstance?.forceUpdate();
525
+ // 抑制子元素 CSS transition
526
+ content.setAttribute("data-popover-entering", "");
527
+ }
528
+
529
+ const placement =
530
+ popperInstance?.state?.placement ?? mergedOptions.placement;
531
+ const animClass = getPlacementAnimClass(placement, reverse);
532
+
533
+ currentAnimClass = animClass;
534
+ content.classList.add(animClass);
535
+
536
+ animationCleanup = whenTransitionEnds(content, () => {
537
+ if (animId !== currentAnimationId) return;
538
+ content.classList.remove(animClass);
539
+ if (currentAnimClass === animClass) currentAnimClass = null;
540
+ content.style.opacity = "";
541
+ content.removeAttribute("data-popover-entering");
542
+ if (reverse) isLeaving = false;
543
+ animationCleanup = null;
544
+ onComplete();
545
+ });
546
+ };
547
+
548
+ // Enter:延遲一幀,等待巢狀元件完成渲染,Popper 才能正確判斷方向
549
+ // Leave:內容已完整渲染,立即執行
550
+ if (!reverse) {
551
+ requestAnimationFrame(startAnimation);
552
+ } else {
553
+ startAnimation();
554
+ }
555
+ }
556
+
557
+ /**
558
+ * 進入動畫
559
+ */
560
+ function performEnterTransition(onComplete: () => void) {
561
+ if (isPlacementAnimation()) {
562
+ performPlacementAnimation(false, onComplete);
563
+ return;
564
+ }
565
+ const name = getTransitionName();
566
+ if (!name) {
567
+ onComplete();
568
+ return;
569
+ }
570
+
571
+ const animId = currentAnimationId;
572
+
573
+ content.classList.add(`${name}-enter-from`, `${name}-enter-active`);
574
+
575
+ // 強制回流,確保瀏覽器已計算 enter-from 初始狀態
576
+ void content.offsetHeight;
577
+
578
+ // 雙 rAF 確保瀏覽器已繪製初始狀態(與 Vue <Transition> 行為一致)
579
+ requestAnimationFrame(() => {
580
+ if (animId !== currentAnimationId) return;
581
+
582
+ requestAnimationFrame(() => {
583
+ if (animId !== currentAnimationId) return;
584
+
585
+ content.classList.remove(`${name}-enter-from`);
586
+ content.classList.add(`${name}-enter-to`);
587
+
588
+ animationCleanup = whenTransitionEnds(content, () => {
589
+ if (animId !== currentAnimationId) return;
590
+ content.classList.remove(`${name}-enter-active`, `${name}-enter-to`);
591
+ animationCleanup = null;
592
+ onComplete();
593
+ });
594
+ });
595
+ });
596
+ }
597
+
598
+ /**
599
+ * 離開動畫
600
+ */
601
+ function performLeaveTransition(onComplete: () => void) {
602
+ if (isPlacementAnimation()) {
603
+ performPlacementAnimation(true, onComplete);
604
+ return;
605
+ }
606
+ const name = getTransitionName();
607
+ if (!name) {
608
+ onComplete();
609
+ return;
610
+ }
611
+
612
+ const animId = currentAnimationId;
613
+ isLeaving = true;
614
+
615
+ content.classList.add(`${name}-leave-from`, `${name}-leave-active`);
616
+
617
+ void content.offsetHeight;
618
+
619
+ // 雙 rAF,與 enter 對稱
620
+ requestAnimationFrame(() => {
621
+ if (animId !== currentAnimationId) return;
622
+
623
+ requestAnimationFrame(() => {
624
+ if (animId !== currentAnimationId) return;
625
+
626
+ content.classList.remove(`${name}-leave-from`);
627
+ content.classList.add(`${name}-leave-to`);
628
+
629
+ animationCleanup = whenTransitionEnds(content, () => {
630
+ if (animId !== currentAnimationId) return;
631
+ content.classList.remove(`${name}-leave-active`, `${name}-leave-to`);
632
+ isLeaving = false;
633
+ animationCleanup = null;
634
+ onComplete();
635
+ });
636
+ });
637
+ });
638
+ }
639
+
640
+ function buildPopperOptions(): Partial<PopperOptions> {
641
+ const modifiers: Partial<Modifier<string, object>>[] = [];
642
+
643
+ if (mergedOptions.offset) {
644
+ modifiers.push({
645
+ name: "offset",
646
+ options: {
647
+ offset: mergedOptions.offset,
648
+ },
649
+ });
650
+ }
651
+
652
+ if (mergedOptions.arrow) {
653
+ const arrowElement =
654
+ typeof mergedOptions.arrow === "boolean"
655
+ ? undefined
656
+ : mergedOptions.arrow;
657
+ modifiers.push({
658
+ name: "arrow",
659
+ options: {
660
+ element: arrowElement,
661
+ padding: mergedOptions.arrowPadding,
662
+ },
663
+ });
664
+ modifiers.push(createArrowPositionFixModifier());
665
+ }
666
+
667
+ // flip 跟 preventOverflow 共用 boundary —— 否則 flip 仍以
668
+ // clippingParents 判斷是否翻轉,preventOverflow 卻用 custom boundary
669
+ // 夾邊,會把彈出層 clip 在錯邊(用戶看到 cut off 而不是翻到對邊)。
670
+ const boundary = mergedOptions.boundary ?? "clippingParents";
671
+
672
+ modifiers.push({
673
+ name: "flip",
674
+ enabled: mergedOptions.flip,
675
+ options: {
676
+ fallbackPlacements: mergedOptions.fallbackPlacements,
677
+ boundary,
678
+ },
679
+ });
680
+
681
+ modifiers.push({
682
+ name: "preventOverflow",
683
+ enabled: mergedOptions.preventOverflow,
684
+ options: {
685
+ boundary,
686
+ padding: mergedOptions.overflowPadding,
687
+ altAxis: true,
688
+ },
689
+ });
690
+
691
+ // 使用 CSS transition 時必須禁用 GPU 加速,
692
+ // 避免 Popper 的 inline transform 與動畫 class 的 transform 衝突
693
+ const hasAnimation = !!mergedOptions.transition;
694
+ const effectiveGpuAcceleration = hasAnimation
695
+ ? false
696
+ : mergedOptions.gpuAcceleration;
697
+ modifiers.push(createGpuAccelerationModifier(effectiveGpuAcceleration));
698
+
699
+ if (mergedOptions.sameWidth) {
700
+ modifiers.push(createSameWidthModifier());
701
+ }
702
+
703
+ if (mergedOptions.zIndex !== undefined) {
704
+ modifiers.push(createZIndexModifier(mergedOptions.zIndex));
705
+ }
706
+
707
+ return {
708
+ placement: mergedOptions.placement,
709
+ strategy: mergedOptions.strategy,
710
+ modifiers,
711
+ onFirstUpdate: mergedOptions.onFirstUpdate as
712
+ | ((arg0: Partial<State>) => void)
713
+ | undefined,
714
+ };
715
+ }
716
+
717
+ function clearTimeouts() {
718
+ if (showTimeout) {
719
+ clearTimeout(showTimeout);
720
+ showTimeout = null;
721
+ }
722
+ if (hideTimeout) {
723
+ clearTimeout(hideTimeout);
724
+ hideTimeout = null;
725
+ }
726
+ }
727
+
728
+ function show() {
729
+ if (destroyed || mergedOptions.disabled) return;
730
+
731
+ // 必須清除 pending hide timeout,否則 hover 從 trigger 移至 content 時 hide 不會被取消
732
+ clearTimeouts();
733
+
734
+ if (isVisible && !isLeaving) return;
735
+
736
+ const doShow = () => {
737
+ if (destroyed || mergedOptions.disabled) return;
738
+
739
+ cancelCurrentAnimation();
740
+
741
+ // 清除 updateReference 殘留的位移過渡,避免重新開啟時從舊位置滑動
742
+ content.style.transition = "";
743
+
744
+ mergedOptions.onBeforeShow?.();
745
+
746
+ // 預先套用初始隱藏狀態,確保元素一出現就帶有 opacity: 0 不會閃爍
747
+ if (isPlacementAnimation()) {
748
+ content.style.opacity = "0";
749
+ } else {
750
+ const transitionName = getTransitionName();
751
+ if (transitionName) {
752
+ content.classList.add(`${transitionName}-enter-from`);
753
+ }
754
+ }
755
+
756
+ if (mergedOptions.teleportTo) {
757
+ const target =
758
+ typeof mergedOptions.teleportTo === "string"
759
+ ? document.querySelector(mergedOptions.teleportTo)
760
+ : mergedOptions.teleportTo;
761
+ if (!target) {
762
+ // 找不到 teleport 目標就靜悄悄停在原位 → 預期的 z-index /
763
+ // stacking context 完全錯位,畫出來的位置會被父層 transform/
764
+ // overflow 干擾。發現時越早越好。
765
+ console.warn(
766
+ "[use-popover] teleportTo target not found:",
767
+ mergedOptions.teleportTo,
768
+ "— popover stays in its original DOM position.",
769
+ );
770
+ } else if (content.parentNode !== target) {
771
+ originalParent ??= content.parentNode;
772
+ target.appendChild(content);
773
+ }
774
+ }
775
+
776
+ // 必須在建立 Popper 之前設定 display,否則 Popper 無法測量元素尺寸。
777
+ // 使用明確的 "block" 而非清空字串,確保能覆蓋 CSS class 的 display:none
778
+ // (例如 AppMenu/AppPopover 用 scoped class 取代 inline style 後,清空
779
+ // inline style 會讓 class 的 display:none 重新生效,導致 popover 不顯示)。
780
+ content.style.display = "block";
781
+ content.setAttribute("data-show", "");
782
+
783
+ if (!popperInstance) {
784
+ popperInstance = createPopper(reference, content, buildPopperOptions());
785
+ } else {
786
+ popperInstance.setOptions(buildPopperOptions());
787
+ }
788
+ popperInstance.forceUpdate();
789
+
790
+ isVisible = true;
791
+ mergedOptions.onShow?.();
792
+
793
+ performEnterTransition(() => {
794
+ mergedOptions.onAfterShow?.();
795
+ });
796
+ };
797
+
798
+ if (mergedOptions.showDelay > 0) {
799
+ showTimeout = setTimeout(doShow, mergedOptions.showDelay);
800
+ } else {
801
+ doShow();
802
+ }
803
+ }
804
+
805
+ function hide() {
806
+ if (destroyed) return;
807
+
808
+ // 必須清除 pending show timeout,否則快速滑過時 popover 會在滑鼠離開後才開啟
809
+ clearTimeouts();
810
+
811
+ if (!isVisible) return;
812
+
813
+ const doHide = () => {
814
+ if (destroyed) return;
815
+
816
+ cancelCurrentAnimation();
817
+ content.style.transition = "";
818
+
819
+ mergedOptions.onBeforeHide?.();
820
+
821
+ // 意圖狀態立即切換
822
+ isVisible = false;
823
+ mergedOptions.onHide?.();
824
+
825
+ // 離開動畫結束後才隱藏 DOM
826
+ performLeaveTransition(() => {
827
+ content.style.display = "none";
828
+ content.removeAttribute("data-show");
829
+ mergedOptions.onAfterHide?.();
830
+ });
831
+ };
832
+
833
+ if (mergedOptions.hideDelay > 0) {
834
+ hideTimeout = setTimeout(doHide, mergedOptions.hideDelay);
835
+ } else {
836
+ doHide();
837
+ }
838
+ }
839
+
840
+ function toggle() {
841
+ if (isVisible) {
842
+ hide();
843
+ } else {
844
+ show();
845
+ }
846
+ }
847
+
848
+ function update() {
849
+ return popperInstance?.update();
850
+ }
851
+
852
+ function forceUpdate() {
853
+ popperInstance?.forceUpdate();
854
+ }
855
+
856
+ /**
857
+ * 清除所有事件監聽器
858
+ */
859
+ function clearEventListeners() {
860
+ cleanupFns.forEach((fn) => fn());
861
+ cleanupFns.length = 0;
862
+ }
863
+
864
+ /**
865
+ * 設置關閉相關的事件監聽器
866
+ */
867
+ function setupCloseListeners() {
868
+ const referenceEl = reference as HTMLElement;
869
+
870
+ if (mergedOptions.closeOnContentClick) {
871
+ const handleContentClick = () => hide();
872
+ content.addEventListener("click", handleContentClick);
873
+ cleanupFns.push(() =>
874
+ content.removeEventListener("click", handleContentClick),
875
+ );
876
+ }
877
+
878
+ if (mergedOptions.hideOnScroll) {
879
+ const scrollParents = getScrollParents(referenceEl);
880
+ const handleScroll = () => {
881
+ if (isVisible) hide();
882
+ };
883
+ scrollParents.forEach((parent) => {
884
+ parent.addEventListener("scroll", handleScroll, { passive: true });
885
+ });
886
+ cleanupFns.push(() => {
887
+ scrollParents.forEach((parent) => {
888
+ parent.removeEventListener("scroll", handleScroll);
889
+ });
890
+ });
891
+ }
892
+ }
893
+
894
+ /**
895
+ * 設置觸發相關的事件監聽器
896
+ */
897
+ function setupTriggerListeners() {
898
+ const triggers = Array.isArray(mergedOptions.trigger)
899
+ ? mergedOptions.trigger
900
+ : [mergedOptions.trigger];
901
+
902
+ const referenceEl = reference as HTMLElement;
903
+
904
+ if (triggers.includes("click")) {
905
+ const handleClick = () => toggle();
906
+ referenceEl.addEventListener?.("click", handleClick);
907
+ cleanupFns.push(() =>
908
+ referenceEl.removeEventListener?.("click", handleClick),
909
+ );
910
+ }
911
+
912
+ if (triggers.includes("hover")) {
913
+ const handleMouseEnter = () => show();
914
+ const handleMouseLeave = (e: MouseEvent) => {
915
+ const related = e.relatedTarget as Node | null;
916
+ // 滑鼠在 trigger ↔ content 之間移動時不關閉,避免 content 覆蓋 trigger 導致閃爍
917
+ if (
918
+ related &&
919
+ (content.contains(related) ||
920
+ (referenceEl as HTMLElement).contains?.(related))
921
+ ) {
922
+ return;
923
+ }
924
+ hide();
925
+ };
926
+
927
+ referenceEl.addEventListener?.("mouseenter", handleMouseEnter);
928
+ referenceEl.addEventListener?.("mouseleave", handleMouseLeave);
929
+ content.addEventListener("mouseenter", handleMouseEnter);
930
+ content.addEventListener("mouseleave", handleMouseLeave);
931
+
932
+ cleanupFns.push(() => {
933
+ referenceEl.removeEventListener?.("mouseenter", handleMouseEnter);
934
+ referenceEl.removeEventListener?.("mouseleave", handleMouseLeave);
935
+ content.removeEventListener("mouseenter", handleMouseEnter);
936
+ content.removeEventListener("mouseleave", handleMouseLeave);
937
+ });
938
+ }
939
+
940
+ if (triggers.includes("focus")) {
941
+ const handleFocus = () => show();
942
+ const handleBlur = () => hide();
943
+
944
+ // 使用 focusin/focusout 而非 focus/blur,確保 wrapper 元素能收到子元素的聚焦事件
945
+ referenceEl.addEventListener?.("focusin", handleFocus);
946
+ referenceEl.addEventListener?.("focusout", handleBlur);
947
+
948
+ cleanupFns.push(() => {
949
+ referenceEl.removeEventListener?.("focusin", handleFocus);
950
+ referenceEl.removeEventListener?.("focusout", handleBlur);
951
+ });
952
+ }
953
+
954
+ if (triggers.includes("contextmenu")) {
955
+ const handleContextMenu = (e: MouseEvent) => {
956
+ e.preventDefault();
957
+ const virtualEl = createVirtualElementFromEvent(e);
958
+ // 同步更新閉包中的 reference,確保 clickOutside 判斷正確
959
+ reference = virtualEl;
960
+ if (popperInstance) {
961
+ // Popper.js 不提供公開 API 更換 reference,直接修改 state 是官方建議的做法
962
+ popperInstance.state.elements.reference = virtualEl;
963
+ popperInstance.forceUpdate();
964
+ }
965
+ show();
966
+ };
967
+ referenceEl.addEventListener?.("contextmenu", handleContextMenu);
968
+ cleanupFns.push(() =>
969
+ referenceEl.removeEventListener?.("contextmenu", handleContextMenu),
970
+ );
971
+ }
972
+ }
973
+
974
+ /**
975
+ * 根據目前 trigger 模式設置所有事件監聽器
976
+ */
977
+ function setupAllEventListeners() {
978
+ const triggers = Array.isArray(mergedOptions.trigger)
979
+ ? mergedOptions.trigger
980
+ : [mergedOptions.trigger];
981
+
982
+ // manual 模式不設置觸發監聽器,但仍尊重 close options
983
+ if (!triggers.includes("manual")) {
984
+ setupTriggerListeners();
985
+ }
986
+ setupCloseListeners();
987
+ }
988
+
989
+ function setOptions(newOptions: Partial<PopoverOptions>) {
990
+ if (
991
+ import.meta.env?.DEV &&
992
+ ("closeOnEscape" in newOptions || "closeOnClickOutside" in newOptions)
993
+ ) {
994
+ // These two options belong to the usePopover wrapper, not the raw
995
+ // createPopover layer. The wrapper intercepts them before they hit
996
+ // here; if a caller wired createPopover directly, they're a silent
997
+ // no-op — surface that loudly during development.
998
+ console.warn(
999
+ "[use-popover] closeOnEscape / closeOnClickOutside are ignored by " +
1000
+ "createPopover.setOptions — use the usePopover composable for those.",
1001
+ );
1002
+ }
1003
+
1004
+ const prevTrigger = mergedOptions.trigger;
1005
+ const prevCloseOnContentClick = mergedOptions.closeOnContentClick;
1006
+ const prevHideOnScroll = mergedOptions.hideOnScroll;
1007
+
1008
+ Object.assign(mergedOptions, newOptions);
1009
+
1010
+ if (popperInstance) {
1011
+ popperInstance.setOptions(buildPopperOptions());
1012
+ }
1013
+
1014
+ const eventOptionsChanged =
1015
+ (newOptions.trigger !== undefined &&
1016
+ newOptions.trigger !== prevTrigger) ||
1017
+ (newOptions.closeOnContentClick !== undefined &&
1018
+ newOptions.closeOnContentClick !== prevCloseOnContentClick) ||
1019
+ (newOptions.hideOnScroll !== undefined &&
1020
+ newOptions.hideOnScroll !== prevHideOnScroll);
1021
+
1022
+ if (eventOptionsChanged) {
1023
+ clearEventListeners();
1024
+ setupAllEventListeners();
1025
+ }
1026
+ }
1027
+
1028
+ function destroy() {
1029
+ destroyed = true;
1030
+ clearTimeouts();
1031
+ cancelCurrentAnimation();
1032
+ updateReferenceCleanup?.();
1033
+ clearEventListeners();
1034
+ // Lifecycle observers — only released at destroy(); see lifecycleCleanups.
1035
+ lifecycleCleanups.forEach((fn) => fn());
1036
+ lifecycleCleanups.length = 0;
1037
+
1038
+ if (popperInstance) {
1039
+ popperInstance.destroy();
1040
+ popperInstance = null;
1041
+ }
1042
+
1043
+ content.style.display = "none";
1044
+ content.removeAttribute("data-show");
1045
+
1046
+ // 還原 teleport 前的 DOM 位置
1047
+ if (originalParent && content.parentNode !== originalParent) {
1048
+ try {
1049
+ originalParent.appendChild(content);
1050
+ } catch {
1051
+ // 原始父節點可能已不在 DOM 中
1052
+ }
1053
+ originalParent = null;
1054
+ }
1055
+
1056
+ isVisible = false;
1057
+ }
1058
+
1059
+ content.style.display = "none";
1060
+ setupAllEventListeners();
1061
+
1062
+ // 觀察 content 尺寸變化,內容撐開時重算位置。
1063
+ // 放 lifecycleCleanups 而非 cleanupFns — 否則切換 trigger 設定的
1064
+ // clearEventListeners() 會把它一起 disconnect 掉,之後 async 內容
1065
+ // 載入或 v-if 展開時 popover 就不會重新定位 (issue #1 from review)。
1066
+ if (typeof ResizeObserver !== "undefined") {
1067
+ const ro = new ResizeObserver(() => {
1068
+ if (isVisible) popperInstance?.update();
1069
+ });
1070
+ ro.observe(content);
1071
+ lifecycleCleanups.push(() => ro.disconnect());
1072
+ }
1073
+
1074
+ /**
1075
+ * 更新 reference 元素,用於面板已開啟時平滑切換對齊目標。
1076
+ *
1077
+ * 註:先前版本會在 content 上設 `top/left` 的 CSS transition 試圖
1078
+ * 做平滑滑動,但 Popper 在 `gpuAcceleration: true`(預設)下用的是
1079
+ * `transform: translate3d(...)`,所以 top/left transition 永遠不會
1080
+ * 觸發,反而留下了一個從不 fire 的 transitionend 監聽(直到下次
1081
+ * clearEventListeners 才會被清)。已移除這段死碼;如真要平滑移動,
1082
+ * 應該對 `transform` 加 transition,但同時要 reflow + 重新計算位置,
1083
+ * 取捨上保留 Popper 的 jump-to-new-position 行為。
1084
+ */
1085
+ function updateReference(newReference: HTMLElement | VirtualElement) {
1086
+ reference = newReference;
1087
+
1088
+ if (popperInstance) {
1089
+ updateReferenceCleanup?.();
1090
+ popperInstance.state.elements.reference = newReference;
1091
+ popperInstance.forceUpdate();
1092
+ }
1093
+
1094
+ // reference 變了,clickOutside 判斷需要更新
1095
+ clearEventListeners();
1096
+ setupAllEventListeners();
1097
+ }
1098
+
1099
+ return {
1100
+ get popper() {
1101
+ return popperInstance;
1102
+ },
1103
+ show,
1104
+ hide,
1105
+ toggle,
1106
+ update,
1107
+ forceUpdate,
1108
+ setOptions,
1109
+ updateReference,
1110
+ destroy,
1111
+ isVisible: () => isVisible,
1112
+ isContentVisible: () => isVisible || isLeaving,
1113
+ getPlacement: () => popperInstance?.state?.placement,
1114
+ };
1115
+ }
1116
+
1117
+ /**
1118
+ * Vue Composable for Popover
1119
+ *
1120
+ * @param referenceRef - 觸發元素 ref
1121
+ * @param contentRef - 內容元素 ref
1122
+ * @param options - 配置選項
1123
+ *
1124
+ * @example
1125
+ * ```vue
1126
+ * <script setup>
1127
+ * import { ref } from 'vue'
1128
+ * import { usePopover } from '@ui/hooks/use-popover'
1129
+ *
1130
+ * const buttonRef = ref<HTMLElement>()
1131
+ * const tooltipRef = ref<HTMLElement>()
1132
+ *
1133
+ * const { isVisible, show, hide, toggle } = usePopover(buttonRef, tooltipRef, {
1134
+ * placement: 'top',
1135
+ * trigger: 'hover',
1136
+ * })
1137
+ * </script>
1138
+ * ```
1139
+ */
1140
+ export function usePopover(
1141
+ referenceRef: MaybeRef<HTMLElement | VirtualElement | null | undefined>,
1142
+ contentRef: MaybeRef<HTMLElement | null | undefined>,
1143
+ options: UsePopoverOptions = {},
1144
+ ): UsePopoverReturn {
1145
+ const instance = ref<PopoverInstance | null>(null);
1146
+ const isVisible = ref(false);
1147
+ const isContentVisible = ref(false);
1148
+
1149
+ const { visible: visibleRef, ...popoverOptions } = options;
1150
+
1151
+ let closeOnEscape = popoverOptions.closeOnEscape !== false;
1152
+ let closeOnClickOutside = popoverOptions.closeOnClickOutside !== false;
1153
+ const { register: escapeRegister, unregister: escapeUnregister } =
1154
+ useEscapeStack();
1155
+ const { register: clickOutsideRegister, unregister: clickOutsideUnregister } =
1156
+ useClickOutsideStack();
1157
+
1158
+ // 忽略觸發 show 的同一個 click 事件,避免 show 後立即被關閉
1159
+ let showTriggeredInCurrentEvent = false;
1160
+
1161
+ function registerClickOutside() {
1162
+ clickOutsideRegister({
1163
+ shouldIgnore: () => showTriggeredInCurrentEvent,
1164
+ isInside: (target) => {
1165
+ const c = unref(contentRef);
1166
+ const r = unref(referenceRef);
1167
+ if (c && c.contains(target)) return true;
1168
+ if (r && (r as HTMLElement).contains?.(target)) return true;
1169
+ return false;
1170
+ },
1171
+ close: () => instance.value?.hide(),
1172
+ });
1173
+ }
1174
+
1175
+ function createInstance() {
1176
+ const reference = unref(referenceRef);
1177
+ const content = unref(contentRef);
1178
+
1179
+ if (!reference || !content) return;
1180
+
1181
+ if (instance.value) {
1182
+ instance.value.destroy();
1183
+ }
1184
+
1185
+ instance.value = createPopover(reference, content, {
1186
+ ...popoverOptions,
1187
+ onShow: () => {
1188
+ escapeRegister(closeOnEscape ? () => instance.value?.hide() : null);
1189
+ if (closeOnClickOutside) {
1190
+ showTriggeredInCurrentEvent = true;
1191
+ setTimeout(() => {
1192
+ showTriggeredInCurrentEvent = false;
1193
+ }, 0);
1194
+ registerClickOutside();
1195
+ }
1196
+ isVisible.value = true;
1197
+ isContentVisible.value = true;
1198
+ popoverOptions.onShow?.();
1199
+ },
1200
+ onHide: () => {
1201
+ escapeUnregister();
1202
+ clickOutsideUnregister();
1203
+ isVisible.value = false;
1204
+ popoverOptions.onHide?.();
1205
+ },
1206
+ onAfterHide: () => {
1207
+ isContentVisible.value = false;
1208
+ popoverOptions.onAfterHide?.();
1209
+ },
1210
+ });
1211
+
1212
+ // 攔截 setOptions,讓 closeOnEscape / closeOnClickOutside 的動態切換
1213
+ // 能同步更新 stack 註冊狀態(createPopover 本身已不處理這兩個選項)
1214
+ const rawSetOptions = instance.value.setOptions;
1215
+ instance.value.setOptions = (newOptions) => {
1216
+ if (newOptions.closeOnEscape !== undefined) {
1217
+ closeOnEscape = newOptions.closeOnEscape !== false;
1218
+ if (isVisible.value) {
1219
+ escapeUnregister();
1220
+ escapeRegister(closeOnEscape ? () => instance.value?.hide() : null);
1221
+ }
1222
+ }
1223
+ if (newOptions.closeOnClickOutside !== undefined) {
1224
+ const next = newOptions.closeOnClickOutside !== false;
1225
+ if (closeOnClickOutside !== next) {
1226
+ closeOnClickOutside = next;
1227
+ if (isVisible.value) {
1228
+ if (next) registerClickOutside();
1229
+ else clickOutsideUnregister();
1230
+ }
1231
+ }
1232
+ }
1233
+ // Strip the wrapper-managed keys before forwarding, so the
1234
+ // createPopover-level "ignored" guard doesn't fire on every legit
1235
+ // usePopover toggle.
1236
+ const { closeOnEscape: _ce, closeOnClickOutside: _co, ...rest } =
1237
+ newOptions;
1238
+ void _ce;
1239
+ void _co;
1240
+ rawSetOptions(rest);
1241
+ };
1242
+
1243
+ // 同步受控模式的初始狀態(watch immediate 在 onMounted 前觸發,實例尚未建立)
1244
+ if (visibleRef && unref(visibleRef)) {
1245
+ instance.value.show();
1246
+ }
1247
+ }
1248
+
1249
+ function show() {
1250
+ instance.value?.show();
1251
+ }
1252
+
1253
+ function hide() {
1254
+ instance.value?.hide();
1255
+ }
1256
+
1257
+ function toggle() {
1258
+ instance.value?.toggle();
1259
+ }
1260
+
1261
+ function update() {
1262
+ instance.value?.update();
1263
+ }
1264
+
1265
+ if (visibleRef) {
1266
+ watch(
1267
+ visibleRef,
1268
+ (val) => {
1269
+ if (val) {
1270
+ show();
1271
+ } else {
1272
+ hide();
1273
+ }
1274
+ },
1275
+ { immediate: true },
1276
+ );
1277
+ }
1278
+
1279
+ watch(
1280
+ () => unref(referenceRef),
1281
+ (newRef, oldRef) => {
1282
+ if (newRef && newRef !== oldRef) {
1283
+ if (isVisible.value && instance.value) {
1284
+ // 面板已開啟:僅更新 reference,不重建實例,平滑移動。
1285
+ // 用 default 'post' flush(不再用 'sync')— sync 會在 Vue
1286
+ // 完成 vdom patch 但瀏覽器還沒做 layout 時就 forceUpdate,
1287
+ // getBoundingClientRect 拿到 0×0,popover 會被釘到 [0,0]。
1288
+ // post 等到 DOM 更新後再執行,量到正確的 rect。
1289
+ instance.value.updateReference(newRef);
1290
+ } else {
1291
+ createInstance();
1292
+ }
1293
+ }
1294
+ },
1295
+ );
1296
+
1297
+ onMounted(() => {
1298
+ createInstance();
1299
+ });
1300
+
1301
+ onBeforeUnmount(() => {
1302
+ instance.value?.destroy();
1303
+ instance.value = null;
1304
+ });
1305
+
1306
+ return {
1307
+ instance,
1308
+ isVisible,
1309
+ isContentVisible,
1310
+ show,
1311
+ hide,
1312
+ toggle,
1313
+ update,
1314
+ };
1315
+ }
1316
+
1317
+ export type {
1318
+ Placement,
1319
+ PopperInstance,
1320
+ PopperOptions,
1321
+ VirtualElement,
1322
+ Modifier,
1323
+ State,
1324
+ };