@x33025/sveltely 0.0.50 → 0.0.52

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.
@@ -10,6 +10,8 @@
10
10
  label: string;
11
11
  action: () => void | Promise<void>;
12
12
  error?: unknown | null;
13
+ loading?: boolean;
14
+ disableWhileLoading?: boolean;
13
15
  style?: 'iconAndLabel' | 'iconOnly';
14
16
  } & Omit<HTMLButtonAttributes, 'children' | 'onclick'>;
15
17
 
@@ -18,6 +20,8 @@
18
20
  label,
19
21
  action,
20
22
  error = $bindable<unknown | null>(null),
23
+ loading = undefined,
24
+ disableWhileLoading = true,
21
25
  style: styleMode = 'iconAndLabel',
22
26
  disabled = false,
23
27
  class: className = '',
@@ -25,18 +29,31 @@
25
29
  ...props
26
30
  }: Props = $props();
27
31
 
28
- let pending = $state(false);
32
+ let internalLoading = $state(false);
33
+
34
+ const isControlledLoading = $derived(loading !== undefined);
35
+ const effectiveLoading = $derived(isControlledLoading ? loading : internalLoading);
36
+ const effectiveDisabled = $derived(disabled || (disableWhileLoading && effectiveLoading));
29
37
 
30
38
  const handleClick = async () => {
31
- if (disabled || pending) return;
39
+ if (effectiveDisabled) return;
32
40
  error = null;
33
- pending = true;
41
+ if (isControlledLoading) {
42
+ try {
43
+ await action();
44
+ } catch (caught) {
45
+ error = caught;
46
+ }
47
+ return;
48
+ }
49
+
50
+ internalLoading = true;
34
51
  try {
35
52
  await action();
36
53
  } catch (caught) {
37
54
  error = caught;
38
55
  } finally {
39
- pending = false;
56
+ internalLoading = false;
40
57
  }
41
58
  };
42
59
 
@@ -52,21 +69,23 @@
52
69
  <button
53
70
  {type}
54
71
  class="inline-flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50 {className}"
55
- disabled={disabled || pending}
56
- aria-busy={pending}
72
+ disabled={effectiveDisabled}
73
+ aria-busy={effectiveLoading}
57
74
  aria-label={styleMode === 'iconOnly' ? label : undefined}
58
75
  data-error={error ? 'true' : 'false'}
59
76
  {...props}
60
77
  onclick={handleClick}
61
78
  >
62
- {#if pending}
63
- <Spinner class="async-button-icon size-4" />
64
- {:else if error}
65
- <CircleAlertIcon class="async-button-icon size-4 text-red-600" />
66
- {:else if icon}
67
- {@const Icon = icon}
68
- <Icon class="async-button-icon size-4" />
69
- {/if}
79
+ <span class="inline-grid size-4 shrink-0 place-items-center">
80
+ {#if effectiveLoading}
81
+ <Spinner class="async-button-icon size-4" />
82
+ {:else if error}
83
+ <CircleAlertIcon class="async-button-icon size-4 text-red-600" />
84
+ {:else if icon}
85
+ {@const Icon = icon}
86
+ <Icon class="async-button-icon size-4" />
87
+ {/if}
88
+ </span>
70
89
  {#if styleMode === 'iconAndLabel'}
71
90
  <span class="async-button-text">{label}</span>
72
91
  {/if}
@@ -7,6 +7,8 @@ type Props = {
7
7
  label: string;
8
8
  action: () => void | Promise<void>;
9
9
  error?: unknown | null;
10
+ loading?: boolean;
11
+ disableWhileLoading?: boolean;
10
12
  style?: 'iconAndLabel' | 'iconOnly';
11
13
  } & Omit<HTMLButtonAttributes, 'children' | 'onclick'>;
12
14
  declare const AsyncButton: Component<Props, {}, "error">;
@@ -1,29 +1,31 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte';
2
+ import { tick, onDestroy } from 'svelte';
3
3
  import type { Snippet } from 'svelte';
4
4
  import { portalContent } from '../../actions/portal';
5
+ import { computePosition, type Anchor, type Align } from '../../utils/positioning';
6
+ import {
7
+ registerPopover,
8
+ unregisterPopover,
9
+ setParentId,
10
+ setOpen,
11
+ hasOpenChild,
12
+ isAncestor
13
+ } from './registry.svelte';
5
14
 
6
15
  interface Props {
7
16
  trigger: Snippet;
8
17
  children: Snippet;
9
18
  open?: boolean;
10
19
  class?: string;
11
- align?: 'left' | 'center' | 'right';
12
- anchor?: 'top' | 'bottom' | 'leading' | 'trailing';
20
+ align?: Align;
21
+ anchor?: Anchor;
13
22
  }
14
- type Anchor = 'top' | 'bottom' | 'leading' | 'trailing';
15
- type Align = 'left' | 'center' | 'right';
16
23
  type Point = { x: number; y: number };
17
- type Placement = {
18
- top: number;
19
- left: number;
20
- transform:
21
- | 'none'
22
- | 'translateX(-50%)'
23
- | 'translateX(-100%)'
24
- | 'translateY(-100%)'
25
- | 'translate(-50%, -100%)'
26
- | 'translate(-100%, -100%)';
24
+ const createPopoverId = () => {
25
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
26
+ return crypto.randomUUID();
27
+ }
28
+ return `popover-${Math.random().toString(36).slice(2, 10)}`;
27
29
  };
28
30
 
29
31
  let {
@@ -41,6 +43,36 @@
41
43
  let panelTransform = $state('none');
42
44
  let resolvedAnchor = $state<Anchor>('bottom');
43
45
 
46
+ const popoverId = createPopoverId();
47
+ let parentPopoverId = $state<string | null>(null);
48
+
49
+ /**
50
+ * Detect parent popover by walking the DOM upward from the trigger element.
51
+ * Works even with portaling because the trigger stays in its original
52
+ * DOM position (inside the parent's portaled content).
53
+ */
54
+ const detectParentPopoverId = (): string | null => {
55
+ let current = triggerEl?.parentElement ?? null;
56
+ while (current) {
57
+ const id = current.getAttribute('data-popover-id');
58
+ if (id && id !== popoverId) return id;
59
+ current = current.parentElement;
60
+ }
61
+ return null;
62
+ };
63
+
64
+ registerPopover(popoverId);
65
+
66
+ $effect(() => {
67
+ if (!triggerEl) return;
68
+ parentPopoverId = detectParentPopoverId();
69
+ setParentId(popoverId, parentPopoverId);
70
+ });
71
+
72
+ $effect(() => setOpen(popoverId, open));
73
+
74
+ onDestroy(() => unregisterPopover(popoverId));
75
+
44
76
  const isInsideRect = (rect: DOMRect, x: number, y: number) =>
45
77
  x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
46
78
 
@@ -99,132 +131,42 @@
99
131
  ];
100
132
  };
101
133
 
102
- const oppositeAnchor = (value: Anchor): Anchor => {
103
- if (value === 'top') return 'bottom';
104
- if (value === 'bottom') return 'top';
105
- if (value === 'leading') return 'trailing';
106
- return 'leading';
107
- };
108
-
109
- const flippedAlign = (value: Align): Align => {
110
- if (value === 'center') return 'center';
111
- return value === 'left' ? 'right' : 'left';
134
+ const positionAndMeasure = async () => {
135
+ await tick();
136
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
137
+ positionPanel();
112
138
  };
113
139
 
114
- const getPlacement = (rect: DOMRect, nextAnchor: Anchor, nextAlign: Align): Placement => {
115
- if (nextAnchor === 'top' || nextAnchor === 'bottom') {
116
- const left =
117
- nextAlign === 'left'
118
- ? rect.left + window.scrollX
119
- : nextAlign === 'right'
120
- ? rect.right + window.scrollX
121
- : rect.left + rect.width / 2 + window.scrollX;
122
- const top =
123
- nextAnchor === 'bottom' ? rect.bottom + window.scrollY : rect.top + window.scrollY;
124
- if (nextAnchor === 'top' && nextAlign === 'center') {
125
- return { top, left, transform: 'translate(-50%, -100%)' };
126
- }
127
- if (nextAnchor === 'top' && nextAlign === 'right') {
128
- return { top, left, transform: 'translate(-100%, -100%)' };
129
- }
130
- if (nextAlign === 'center') {
131
- return { top, left, transform: 'translateX(-50%)' };
132
- }
133
- if (nextAnchor === 'top') {
134
- return { top, left, transform: 'translateY(-100%)' };
135
- }
136
- if (nextAlign === 'right') {
137
- return { top, left, transform: 'translateX(-100%)' };
138
- }
139
- return { top, left, transform: 'none' };
140
- }
141
-
142
- if (nextAnchor === 'leading') {
143
- return {
144
- top: rect.top + window.scrollY,
145
- left: rect.left + window.scrollX,
146
- transform: 'translateX(-100%)'
147
- };
140
+ const getPopoverIdsFromEvent = (event: Event) => {
141
+ const ids: string[] = [];
142
+ for (const node of event.composedPath()) {
143
+ if (!(node instanceof Element)) continue;
144
+ const id = node.getAttribute('data-popover-id');
145
+ if (!id || ids.includes(id)) continue;
146
+ ids.push(id);
148
147
  }
149
-
150
- return {
151
- top: rect.top + window.scrollY,
152
- left: rect.right + window.scrollX,
153
- transform: 'none'
154
- };
148
+ return ids;
155
149
  };
156
150
 
157
- const getViewportRect = (placement: Placement, width: number, height: number) => {
158
- let left = placement.left - window.scrollX;
159
- let top = placement.top - window.scrollY;
160
-
161
- if (placement.transform === 'translateX(-100%)') left -= width;
162
- else if (placement.transform === 'translateX(-50%)') left -= width / 2;
163
- else if (placement.transform === 'translateY(-100%)') top -= height;
164
- else if (placement.transform === 'translate(-50%, -100%)') {
165
- left -= width / 2;
166
- top -= height;
167
- } else if (placement.transform === 'translate(-100%, -100%)') {
168
- left -= width;
169
- top -= height;
170
- }
171
-
172
- return { left, top, right: left + width, bottom: top + height };
173
- };
151
+ const isInPopoverTree = (candidateId: string | null) => isAncestor(candidateId, popoverId);
174
152
 
175
- const overflowScore = (
176
- rect: { left: number; top: number; right: number; bottom: number },
177
- margin = 8
178
- ) => {
179
- const leftOverflow = Math.max(0, margin - rect.left);
180
- const rightOverflow = Math.max(0, rect.right - (window.innerWidth - margin));
181
- const topOverflow = Math.max(0, margin - rect.top);
182
- const bottomOverflow = Math.max(0, rect.bottom - (window.innerHeight - margin));
183
- return leftOverflow + rightOverflow + topOverflow + bottomOverflow;
153
+ const isEventInPopoverTree = (event: Event) => {
154
+ const ids = getPopoverIdsFromEvent(event);
155
+ return ids.some(isInPopoverTree);
184
156
  };
185
157
 
186
158
  const positionPanel = () => {
187
159
  if (!triggerEl || !panelEl) return;
188
160
  const rect = triggerEl.getBoundingClientRect();
189
- const width = panelEl.offsetWidth;
190
- const height = panelEl.offsetHeight;
191
- const candidates: Array<{ anchor: Anchor; align: Align }> = [
192
- { anchor, align },
193
- { anchor, align: flippedAlign(align) },
194
- { anchor: oppositeAnchor(anchor), align },
195
- { anchor: oppositeAnchor(anchor), align: flippedAlign(align) }
196
- ];
197
-
198
- const unique = new Set<string>();
199
- let bestPlacement: Placement | null = null;
200
- let bestAnchor: Anchor | null = null;
201
- let bestScore = Number.POSITIVE_INFINITY;
202
-
203
- for (const candidate of candidates) {
204
- const key = `${candidate.anchor}:${candidate.align}`;
205
- if (unique.has(key)) continue;
206
- unique.add(key);
207
-
208
- const placement = getPlacement(rect, candidate.anchor, candidate.align);
209
- const nextRect = getViewportRect(placement, width, height);
210
- const score = overflowScore(nextRect);
211
- if (score < bestScore) {
212
- bestScore = score;
213
- bestPlacement = placement;
214
- bestAnchor = candidate.anchor;
215
- }
216
- }
217
-
218
- if (!bestPlacement) return;
219
- panelCoords = { top: bestPlacement.top, left: bestPlacement.left };
220
- panelTransform = bestPlacement.transform;
221
- if (bestAnchor) resolvedAnchor = bestAnchor;
161
+ const result = computePosition(rect, panelEl.offsetWidth, panelEl.offsetHeight, anchor, align);
162
+ panelCoords = { top: result.top, left: result.left };
163
+ panelTransform = result.transform;
164
+ resolvedAnchor = result.anchor;
222
165
  };
223
166
 
224
167
  async function openPanel() {
225
168
  open = true;
226
- await tick();
227
- positionPanel();
169
+ await positionAndMeasure();
228
170
  }
229
171
 
230
172
  function closePanel() {
@@ -241,8 +183,7 @@
241
183
 
242
184
  function handleOutsideClick(event: MouseEvent) {
243
185
  if (!open) return;
244
- const target = event.target as HTMLElement;
245
- if (target.closest('[data-popover-root]') || target.closest('[data-popover]')) return;
186
+ if (isEventInPopoverTree(event)) return;
246
187
  closePanel();
247
188
  }
248
189
 
@@ -253,6 +194,8 @@
253
194
 
254
195
  function handlePointerMove(event: MouseEvent) {
255
196
  if (!open || !triggerEl || !panelEl) return;
197
+ if (isEventInPopoverTree(event)) return;
198
+ if (hasOpenChild(popoverId)) return;
256
199
 
257
200
  const pointer = { x: event.clientX, y: event.clientY };
258
201
  const triggerRect = triggerEl.getBoundingClientRect();
@@ -268,9 +211,7 @@
268
211
 
269
212
  $effect(() => {
270
213
  if (!open) return;
271
- void tick().then(() => {
272
- positionPanel();
273
- });
214
+ void positionAndMeasure();
274
215
  });
275
216
  </script>
276
217
 
@@ -282,7 +223,12 @@
282
223
  onmousemove={handlePointerMove}
283
224
  />
284
225
 
285
- <div class="relative inline-block text-left" data-popover-root>
226
+ <div
227
+ class="relative inline-block text-left"
228
+ data-popover-root
229
+ data-popover-id={popoverId}
230
+ data-popover-parent-id={parentPopoverId ?? ''}
231
+ >
286
232
  <div bind:this={triggerEl}>
287
233
  <button
288
234
  type="button"
@@ -300,6 +246,8 @@
300
246
  use:portalContent
301
247
  class="popover fixed z-50 focus:outline-none {className}"
302
248
  data-popover
249
+ data-popover-id={popoverId}
250
+ data-popover-parent-id={parentPopoverId ?? ''}
303
251
  style="top: {panelCoords.top}px; left: {panelCoords.left}px; transform: {panelTransform};"
304
252
  role="dialog"
305
253
  aria-modal="false"
@@ -1,11 +1,12 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type Anchor, type Align } from '../../utils/positioning';
2
3
  interface Props {
3
4
  trigger: Snippet;
4
5
  children: Snippet;
5
6
  open?: boolean;
6
7
  class?: string;
7
- align?: 'left' | 'center' | 'right';
8
- anchor?: 'top' | 'bottom' | 'leading' | 'trailing';
8
+ align?: Align;
9
+ anchor?: Anchor;
9
10
  }
10
11
  declare const Popover: import("svelte").Component<Props, {}, "open">;
11
12
  type Popover = ReturnType<typeof Popover>;
@@ -0,0 +1,163 @@
1
+ <script lang="ts">
2
+ import { portalContent } from '../../actions/portal';
3
+ import { getOpenPopoverIds, getParentId } from './registry.svelte';
4
+
5
+ type Point = { x: number; y: number };
6
+ type Anchor = 'top' | 'bottom' | 'leading' | 'trailing';
7
+
8
+ interface PopoverGeometry {
9
+ id: string;
10
+ parentId: string | null;
11
+ triggerRect: DOMRect;
12
+ panelRect: DOMRect;
13
+ anchor: Anchor;
14
+ safePolygon: Point[];
15
+ }
16
+
17
+ let geometries = $state<PopoverGeometry[]>([]);
18
+
19
+ const inferAnchor = (trigger: DOMRect, panel: DOMRect): Anchor => {
20
+ const dx = panel.left + panel.width / 2 - (trigger.left + trigger.width / 2);
21
+ const dy = panel.top + panel.height / 2 - (trigger.top + trigger.height / 2);
22
+ if (Math.abs(dy) >= Math.abs(dx)) return dy >= 0 ? 'bottom' : 'top';
23
+ return dx >= 0 ? 'trailing' : 'leading';
24
+ };
25
+
26
+ const getSafePolygon = (trigger: DOMRect, panel: DOMRect, anchor: Anchor): Point[] => {
27
+ if (anchor === 'bottom') {
28
+ return [
29
+ { x: trigger.left, y: trigger.top },
30
+ { x: trigger.right, y: trigger.top },
31
+ { x: panel.right, y: panel.top },
32
+ { x: panel.left, y: panel.top }
33
+ ];
34
+ }
35
+ if (anchor === 'top') {
36
+ return [
37
+ { x: trigger.left, y: trigger.bottom },
38
+ { x: trigger.right, y: trigger.bottom },
39
+ { x: panel.right, y: panel.bottom },
40
+ { x: panel.left, y: panel.bottom }
41
+ ];
42
+ }
43
+ if (anchor === 'leading') {
44
+ return [
45
+ { x: trigger.right, y: trigger.top },
46
+ { x: trigger.right, y: trigger.bottom },
47
+ { x: panel.right, y: panel.bottom },
48
+ { x: panel.right, y: panel.top }
49
+ ];
50
+ }
51
+ return [
52
+ { x: trigger.left, y: trigger.top },
53
+ { x: trigger.left, y: trigger.bottom },
54
+ { x: panel.left, y: panel.bottom },
55
+ { x: panel.left, y: panel.top }
56
+ ];
57
+ };
58
+
59
+ const measure = () => {
60
+ const ids = getOpenPopoverIds();
61
+ const results: PopoverGeometry[] = [];
62
+
63
+ for (const id of ids) {
64
+ const root = document.querySelector<HTMLElement>(
65
+ `[data-popover-root][data-popover-id="${id}"]`
66
+ );
67
+ const panel = document.querySelector<HTMLElement>(`[data-popover][data-popover-id="${id}"]`);
68
+ if (!root || !panel) continue;
69
+
70
+ const triggerRect = root.getBoundingClientRect();
71
+ const panelRect = panel.getBoundingClientRect();
72
+ const anchor = inferAnchor(triggerRect, panelRect);
73
+
74
+ results.push({
75
+ id,
76
+ parentId: getParentId(id),
77
+ triggerRect,
78
+ panelRect,
79
+ anchor,
80
+ safePolygon: getSafePolygon(triggerRect, panelRect, anchor)
81
+ });
82
+ }
83
+
84
+ geometries = results;
85
+ };
86
+
87
+ $effect(() => {
88
+ let raf: number;
89
+ const loop = () => {
90
+ measure();
91
+ raf = requestAnimationFrame(loop);
92
+ };
93
+ raf = requestAnimationFrame(loop);
94
+ return () => cancelAnimationFrame(raf);
95
+ });
96
+ </script>
97
+
98
+ {#if geometries.length > 0}
99
+ <div use:portalContent class="pointer-events-none fixed inset-0 z-[9999]">
100
+ <svg width="100%" height="100%" aria-hidden="true">
101
+ {#each geometries as geo (geo.id)}
102
+ <!-- Safe polygon (rendered first so rects draw over it) -->
103
+ <polygon
104
+ points={geo.safePolygon.map((p) => `${p.x},${p.y}`).join(' ')}
105
+ fill="rgba(239,68,68,0.10)"
106
+ stroke="rgb(220,38,38)"
107
+ stroke-width="1.5"
108
+ />
109
+
110
+ <!-- Trigger rect -->
111
+ <rect
112
+ x={geo.triggerRect.left}
113
+ y={geo.triggerRect.top}
114
+ width={geo.triggerRect.width}
115
+ height={geo.triggerRect.height}
116
+ fill="rgba(37,99,235,0.06)"
117
+ stroke="rgb(37,99,235)"
118
+ stroke-width="1"
119
+ stroke-dasharray="3,2"
120
+ />
121
+
122
+ <!-- Panel rect -->
123
+ <rect
124
+ x={geo.panelRect.left}
125
+ y={geo.panelRect.top}
126
+ width={geo.panelRect.width}
127
+ height={geo.panelRect.height}
128
+ fill="rgba(22,163,74,0.06)"
129
+ stroke="rgb(22,163,74)"
130
+ stroke-width="1"
131
+ stroke-dasharray="3,2"
132
+ />
133
+
134
+ <!-- Parent → child connection line -->
135
+ {#if geo.parentId}
136
+ {@const parent = geometries.find((g) => g.id === geo.parentId)}
137
+ {#if parent}
138
+ <line
139
+ x1={parent.panelRect.left + parent.panelRect.width / 2}
140
+ y1={parent.panelRect.top + parent.panelRect.height / 2}
141
+ x2={geo.panelRect.left + geo.panelRect.width / 2}
142
+ y2={geo.panelRect.top + geo.panelRect.height / 2}
143
+ stroke="rgba(168,85,247,0.4)"
144
+ stroke-width="1"
145
+ stroke-dasharray="4,3"
146
+ />
147
+ {/if}
148
+ {/if}
149
+
150
+ <!-- Popover ID label -->
151
+ <text
152
+ x={geo.panelRect.left + 4}
153
+ y={geo.panelRect.top - 4}
154
+ font-size="10"
155
+ fill="rgb(113,113,122)"
156
+ font-family="ui-monospace, monospace"
157
+ >
158
+ {geo.id.slice(0, 8)}
159
+ </text>
160
+ {/each}
161
+ </svg>
162
+ </div>
163
+ {/if}
@@ -0,0 +1,3 @@
1
+ declare const PopoverDebugOverlay: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type PopoverDebugOverlay = ReturnType<typeof PopoverDebugOverlay>;
3
+ export default PopoverDebugOverlay;
@@ -1 +1,2 @@
1
1
  export { default } from './Popover.svelte';
2
+ export { default as PopoverDebugOverlay } from './PopoverDebugOverlay.svelte';
@@ -1 +1,2 @@
1
1
  export { default } from './Popover.svelte';
2
+ export { default as PopoverDebugOverlay } from './PopoverDebugOverlay.svelte';
@@ -0,0 +1,18 @@
1
+ export declare function registerPopover(id: string): void;
2
+ export declare function unregisterPopover(id: string): void;
3
+ export declare function setParentId(id: string, parentId: string | null): void;
4
+ export declare function setOpen(id: string, open: boolean): void;
5
+ export declare function getParentId(id: string): string | null;
6
+ /**
7
+ * Returns true if any registered popover lists `parentId` as its parent and is currently open.
8
+ */
9
+ export declare function hasOpenChild(parentId: string): boolean;
10
+ /**
11
+ * Walk the parent chain from `candidateId` upward.
12
+ * Returns true if `ancestorId` is found in the chain.
13
+ */
14
+ export declare function isAncestor(candidateId: string | null, ancestorId: string): boolean;
15
+ /**
16
+ * Returns the IDs of all currently open popovers.
17
+ */
18
+ export declare function getOpenPopoverIds(): string[];
@@ -0,0 +1,58 @@
1
+ const registry = new Map();
2
+ export function registerPopover(id) {
3
+ registry.set(id, { parentId: null, open: false });
4
+ }
5
+ export function unregisterPopover(id) {
6
+ registry.delete(id);
7
+ }
8
+ export function setParentId(id, parentId) {
9
+ const entry = registry.get(id);
10
+ if (entry)
11
+ entry.parentId = parentId;
12
+ }
13
+ export function setOpen(id, open) {
14
+ const entry = registry.get(id);
15
+ if (entry)
16
+ entry.open = open;
17
+ }
18
+ export function getParentId(id) {
19
+ return registry.get(id)?.parentId ?? null;
20
+ }
21
+ /**
22
+ * Returns true if any registered popover lists `parentId` as its parent and is currently open.
23
+ */
24
+ export function hasOpenChild(parentId) {
25
+ for (const [, entry] of registry) {
26
+ if (entry.parentId === parentId && entry.open)
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ /**
32
+ * Walk the parent chain from `candidateId` upward.
33
+ * Returns true if `ancestorId` is found in the chain.
34
+ */
35
+ export function isAncestor(candidateId, ancestorId) {
36
+ let currentId = candidateId;
37
+ const visited = new Set();
38
+ while (currentId) {
39
+ if (currentId === ancestorId)
40
+ return true;
41
+ if (visited.has(currentId))
42
+ return false;
43
+ visited.add(currentId);
44
+ currentId = registry.get(currentId)?.parentId ?? null;
45
+ }
46
+ return false;
47
+ }
48
+ /**
49
+ * Returns the IDs of all currently open popovers.
50
+ */
51
+ export function getOpenPopoverIds() {
52
+ const ids = [];
53
+ for (const [id, entry] of registry) {
54
+ if (entry.open)
55
+ ids.push(id);
56
+ }
57
+ return ids;
58
+ }