@x33025/sveltely 0.0.50 → 0.0.53
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 +128 -129
- 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 +33 -26
- package/dist/style.css +50 -47
- 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 {
|
|
@@ -37,9 +39,41 @@
|
|
|
37
39
|
|
|
38
40
|
let triggerEl = $state<HTMLElement | null>(null);
|
|
39
41
|
let panelEl = $state<HTMLElement | null>(null);
|
|
42
|
+
let contentEl = $state<HTMLElement | null>(null);
|
|
40
43
|
let panelCoords = $state({ top: 0, left: 0 });
|
|
41
44
|
let panelTransform = $state('none');
|
|
42
45
|
let resolvedAnchor = $state<Anchor>('bottom');
|
|
46
|
+
let computedPanelRadius = $state<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const popoverId = createPopoverId();
|
|
49
|
+
let parentPopoverId = $state<string | null>(null);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect parent popover by walking the DOM upward from the trigger element.
|
|
53
|
+
* Works even with portaling because the trigger stays in its original
|
|
54
|
+
* DOM position (inside the parent's portaled content).
|
|
55
|
+
*/
|
|
56
|
+
const detectParentPopoverId = (): string | null => {
|
|
57
|
+
let current = triggerEl?.parentElement ?? null;
|
|
58
|
+
while (current) {
|
|
59
|
+
const id = current.getAttribute('data-popover-id');
|
|
60
|
+
if (id && id !== popoverId) return id;
|
|
61
|
+
current = current.parentElement;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
registerPopover(popoverId);
|
|
67
|
+
|
|
68
|
+
$effect(() => {
|
|
69
|
+
if (!triggerEl) return;
|
|
70
|
+
parentPopoverId = detectParentPopoverId();
|
|
71
|
+
setParentId(popoverId, parentPopoverId);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$effect(() => setOpen(popoverId, open));
|
|
75
|
+
|
|
76
|
+
onDestroy(() => unregisterPopover(popoverId));
|
|
43
77
|
|
|
44
78
|
const isInsideRect = (rect: DOMRect, x: number, y: number) =>
|
|
45
79
|
x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
@@ -99,136 +133,87 @@
|
|
|
99
133
|
];
|
|
100
134
|
};
|
|
101
135
|
|
|
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';
|
|
136
|
+
const parsePx = (value: string) => {
|
|
137
|
+
const first = value.split(' ')[0];
|
|
138
|
+
const parsed = Number.parseFloat(first);
|
|
139
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
112
140
|
};
|
|
113
141
|
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
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' };
|
|
142
|
+
const updateComputedPanelRadius = () => {
|
|
143
|
+
if (!panelEl || !contentEl) {
|
|
144
|
+
computedPanelRadius = null;
|
|
145
|
+
return;
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
const itemEl =
|
|
149
|
+
contentEl.querySelector<HTMLElement>('[data-popover-radius-item]') ||
|
|
150
|
+
contentEl.querySelector<HTMLElement>('.popover-item') ||
|
|
151
|
+
contentEl.querySelector<HTMLElement>('.dropdown-item');
|
|
152
|
+
if (!itemEl) {
|
|
153
|
+
computedPanelRadius = null;
|
|
154
|
+
return;
|
|
148
155
|
}
|
|
149
156
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
157
|
+
const itemRect = itemEl.getBoundingClientRect();
|
|
158
|
+
const itemStyle = getComputedStyle(itemEl);
|
|
159
|
+
const itemRadius = parsePx(itemStyle.borderTopLeftRadius);
|
|
160
|
+
const effectiveItemRadius = Math.min(itemRadius, itemRect.height / 2, itemRect.width / 2);
|
|
161
|
+
|
|
162
|
+
const panelStyle = getComputedStyle(panelEl);
|
|
163
|
+
const panelPadding = Math.min(parsePx(panelStyle.paddingTop), parsePx(panelStyle.paddingLeft));
|
|
164
|
+
|
|
165
|
+
const panelRect = panelEl.getBoundingClientRect();
|
|
166
|
+
const outerRadius = Math.min(
|
|
167
|
+
effectiveItemRadius + panelPadding,
|
|
168
|
+
panelRect.height / 2,
|
|
169
|
+
panelRect.width / 2
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
computedPanelRadius = `${outerRadius}px`;
|
|
155
173
|
};
|
|
156
174
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
}
|
|
175
|
+
const positionAndMeasure = async () => {
|
|
176
|
+
await tick();
|
|
177
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
178
|
+
positionPanel();
|
|
179
|
+
updateComputedPanelRadius();
|
|
180
|
+
};
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
const getPopoverIdsFromEvent = (event: Event) => {
|
|
183
|
+
const ids: string[] = [];
|
|
184
|
+
for (const node of event.composedPath()) {
|
|
185
|
+
if (!(node instanceof Element)) continue;
|
|
186
|
+
const id = node.getAttribute('data-popover-id');
|
|
187
|
+
if (!id || ids.includes(id)) continue;
|
|
188
|
+
ids.push(id);
|
|
189
|
+
}
|
|
190
|
+
return ids;
|
|
173
191
|
};
|
|
174
192
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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;
|
|
193
|
+
const isInPopoverTree = (candidateId: string | null) => isAncestor(candidateId, popoverId);
|
|
194
|
+
|
|
195
|
+
const isEventInPopoverTree = (event: Event) => {
|
|
196
|
+
const ids = getPopoverIdsFromEvent(event);
|
|
197
|
+
return ids.some(isInPopoverTree);
|
|
184
198
|
};
|
|
185
199
|
|
|
186
200
|
const positionPanel = () => {
|
|
187
201
|
if (!triggerEl || !panelEl) return;
|
|
188
202
|
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;
|
|
203
|
+
const result = computePosition(rect, panelEl.offsetWidth, panelEl.offsetHeight, anchor, align);
|
|
204
|
+
panelCoords = { top: result.top, left: result.left };
|
|
205
|
+
panelTransform = result.transform;
|
|
206
|
+
resolvedAnchor = result.anchor;
|
|
222
207
|
};
|
|
223
208
|
|
|
224
209
|
async function openPanel() {
|
|
225
210
|
open = true;
|
|
226
|
-
await
|
|
227
|
-
positionPanel();
|
|
211
|
+
await positionAndMeasure();
|
|
228
212
|
}
|
|
229
213
|
|
|
230
214
|
function closePanel() {
|
|
231
215
|
open = false;
|
|
216
|
+
computedPanelRadius = null;
|
|
232
217
|
}
|
|
233
218
|
|
|
234
219
|
function toggle() {
|
|
@@ -241,8 +226,7 @@
|
|
|
241
226
|
|
|
242
227
|
function handleOutsideClick(event: MouseEvent) {
|
|
243
228
|
if (!open) return;
|
|
244
|
-
|
|
245
|
-
if (target.closest('[data-popover-root]') || target.closest('[data-popover]')) return;
|
|
229
|
+
if (isEventInPopoverTree(event)) return;
|
|
246
230
|
closePanel();
|
|
247
231
|
}
|
|
248
232
|
|
|
@@ -253,6 +237,8 @@
|
|
|
253
237
|
|
|
254
238
|
function handlePointerMove(event: MouseEvent) {
|
|
255
239
|
if (!open || !triggerEl || !panelEl) return;
|
|
240
|
+
if (isEventInPopoverTree(event)) return;
|
|
241
|
+
if (hasOpenChild(popoverId)) return;
|
|
256
242
|
|
|
257
243
|
const pointer = { x: event.clientX, y: event.clientY };
|
|
258
244
|
const triggerRect = triggerEl.getBoundingClientRect();
|
|
@@ -268,9 +254,12 @@
|
|
|
268
254
|
|
|
269
255
|
$effect(() => {
|
|
270
256
|
if (!open) return;
|
|
271
|
-
void
|
|
272
|
-
|
|
273
|
-
|
|
257
|
+
void positionAndMeasure();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
$effect(() => {
|
|
261
|
+
if (!open) return;
|
|
262
|
+
updateComputedPanelRadius();
|
|
274
263
|
});
|
|
275
264
|
</script>
|
|
276
265
|
|
|
@@ -282,7 +271,12 @@
|
|
|
282
271
|
onmousemove={handlePointerMove}
|
|
283
272
|
/>
|
|
284
273
|
|
|
285
|
-
<div
|
|
274
|
+
<div
|
|
275
|
+
class="relative inline-block text-left"
|
|
276
|
+
data-popover-root
|
|
277
|
+
data-popover-id={popoverId}
|
|
278
|
+
data-popover-parent-id={parentPopoverId ?? ''}
|
|
279
|
+
>
|
|
286
280
|
<div bind:this={triggerEl}>
|
|
287
281
|
<button
|
|
288
282
|
type="button"
|
|
@@ -300,13 +294,18 @@
|
|
|
300
294
|
use:portalContent
|
|
301
295
|
class="popover fixed z-50 focus:outline-none {className}"
|
|
302
296
|
data-popover
|
|
303
|
-
|
|
297
|
+
data-popover-id={popoverId}
|
|
298
|
+
data-popover-parent-id={parentPopoverId ?? ''}
|
|
299
|
+
style="top: {panelCoords.top}px; left: {panelCoords.left}px; transform: {panelTransform}; border-radius: {computedPanelRadius ??
|
|
300
|
+
'0.375rem'};"
|
|
304
301
|
role="dialog"
|
|
305
302
|
aria-modal="false"
|
|
306
303
|
tabindex="-1"
|
|
307
304
|
bind:this={panelEl}
|
|
308
305
|
>
|
|
309
|
-
{
|
|
306
|
+
<div role="none" bind:this={contentEl}>
|
|
307
|
+
{@render children()}
|
|
308
|
+
</div>
|
|
310
309
|
</div>
|
|
311
310
|
{/if}
|
|
312
311
|
</div>
|
|
@@ -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[];
|