@x33025/sveltely 0.0.49 → 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.
- package/dist/components/AsyncButton.svelte +33 -14
- package/dist/components/AsyncButton.svelte.d.ts +2 -0
- package/dist/components/Popover/Popover.svelte +80 -132
- package/dist/components/Popover/Popover.svelte.d.ts +3 -2
- package/dist/components/Popover/PopoverDebugOverlay.svelte +163 -0
- package/dist/components/Popover/PopoverDebugOverlay.svelte.d.ts +3 -0
- package/dist/components/Popover/index.d.ts +1 -0
- package/dist/components/Popover/index.js +1 -0
- package/dist/components/Popover/registry.svelte.d.ts +18 -0
- package/dist/components/Popover/registry.svelte.js +58 -0
- package/dist/components/Tooltip.svelte +63 -11
- package/dist/components/Tooltip.svelte.d.ts +2 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/style/index.css +34 -15
- package/dist/style.css +51 -30
- package/dist/utils/positioning.d.ts +36 -0
- package/dist/utils/positioning.js +112 -0
- package/package.json +1 -1
- package/dist/components/Dropdown/Dropdown.svelte +0 -260
- package/dist/components/Dropdown/Dropdown.svelte.d.ts +0 -12
- package/dist/components/Dropdown/index.d.ts +0 -1
- package/dist/components/Dropdown/index.js +0 -1
|
@@ -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
|
|
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 (
|
|
39
|
+
if (effectiveDisabled) return;
|
|
32
40
|
error = null;
|
|
33
|
-
|
|
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
|
-
|
|
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={
|
|
56
|
-
aria-busy={
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
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?:
|
|
12
|
-
anchor?:
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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?:
|
|
8
|
-
anchor?:
|
|
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,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
|
+
}
|