@x33025/sveltely 0.0.44 → 0.0.45

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.
@@ -0,0 +1,312 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import { portalContent } from '../../actions/portal';
5
+
6
+ interface Props {
7
+ trigger: Snippet;
8
+ children: Snippet;
9
+ open?: boolean;
10
+ class?: string;
11
+ align?: 'left' | 'center' | 'right';
12
+ anchor?: 'top' | 'bottom' | 'leading' | 'trailing';
13
+ }
14
+ type Anchor = 'top' | 'bottom' | 'leading' | 'trailing';
15
+ type Align = 'left' | 'center' | 'right';
16
+ 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%)';
27
+ };
28
+
29
+ let {
30
+ trigger,
31
+ children,
32
+ open = $bindable(false),
33
+ class: className = '',
34
+ align = 'left',
35
+ anchor = 'bottom'
36
+ }: Props = $props();
37
+
38
+ let triggerEl = $state<HTMLElement | null>(null);
39
+ let panelEl = $state<HTMLElement | null>(null);
40
+ let panelCoords = $state({ top: 0, left: 0 });
41
+ let panelTransform = $state('none');
42
+ let resolvedAnchor = $state<Anchor>('bottom');
43
+
44
+ const isInsideRect = (rect: DOMRect, x: number, y: number) =>
45
+ x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
46
+
47
+ const pointInPolygon = (point: Point, polygon: Point[]) => {
48
+ let inside = false;
49
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
50
+ const xi = polygon[i].x;
51
+ const yi = polygon[i].y;
52
+ const xj = polygon[j].x;
53
+ const yj = polygon[j].y;
54
+ const intersects =
55
+ yi > point.y !== yj > point.y &&
56
+ point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
57
+ if (intersects) inside = !inside;
58
+ }
59
+ return inside;
60
+ };
61
+
62
+ const getSafePolygon = (
63
+ triggerRect: DOMRect,
64
+ popoverRect: DOMRect,
65
+ currentAnchor: Anchor
66
+ ): Point[] => {
67
+ if (currentAnchor === 'bottom') {
68
+ return [
69
+ { x: triggerRect.left, y: triggerRect.top },
70
+ { x: triggerRect.right, y: triggerRect.top },
71
+ { x: popoverRect.right, y: popoverRect.top },
72
+ { x: popoverRect.left, y: popoverRect.top }
73
+ ];
74
+ }
75
+
76
+ if (currentAnchor === 'top') {
77
+ return [
78
+ { x: triggerRect.left, y: triggerRect.bottom },
79
+ { x: triggerRect.right, y: triggerRect.bottom },
80
+ { x: popoverRect.right, y: popoverRect.bottom },
81
+ { x: popoverRect.left, y: popoverRect.bottom }
82
+ ];
83
+ }
84
+
85
+ if (currentAnchor === 'leading') {
86
+ return [
87
+ { x: triggerRect.right, y: triggerRect.top },
88
+ { x: triggerRect.right, y: triggerRect.bottom },
89
+ { x: popoverRect.right, y: popoverRect.bottom },
90
+ { x: popoverRect.right, y: popoverRect.top }
91
+ ];
92
+ }
93
+
94
+ return [
95
+ { x: triggerRect.left, y: triggerRect.top },
96
+ { x: triggerRect.left, y: triggerRect.bottom },
97
+ { x: popoverRect.left, y: popoverRect.bottom },
98
+ { x: popoverRect.left, y: popoverRect.top }
99
+ ];
100
+ };
101
+
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';
112
+ };
113
+
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
+ };
148
+ }
149
+
150
+ return {
151
+ top: rect.top + window.scrollY,
152
+ left: rect.right + window.scrollX,
153
+ transform: 'none'
154
+ };
155
+ };
156
+
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
+ };
174
+
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;
184
+ };
185
+
186
+ const positionPanel = () => {
187
+ if (!triggerEl || !panelEl) return;
188
+ 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;
222
+ };
223
+
224
+ async function openPanel() {
225
+ open = true;
226
+ await tick();
227
+ positionPanel();
228
+ }
229
+
230
+ function closePanel() {
231
+ open = false;
232
+ }
233
+
234
+ function toggle() {
235
+ if (open) {
236
+ closePanel();
237
+ } else {
238
+ void openPanel();
239
+ }
240
+ }
241
+
242
+ function handleOutsideClick(event: MouseEvent) {
243
+ if (!open) return;
244
+ const target = event.target as HTMLElement;
245
+ if (target.closest('[data-popover-root]') || target.closest('[data-popover]')) return;
246
+ closePanel();
247
+ }
248
+
249
+ function handleEscape(event: KeyboardEvent) {
250
+ if (!open) return;
251
+ if (event.key === 'Escape') closePanel();
252
+ }
253
+
254
+ function handlePointerMove(event: MouseEvent) {
255
+ if (!open || !triggerEl || !panelEl) return;
256
+
257
+ const pointer = { x: event.clientX, y: event.clientY };
258
+ const triggerRect = triggerEl.getBoundingClientRect();
259
+ const popoverRect = panelEl.getBoundingClientRect();
260
+ const safePolygon = getSafePolygon(triggerRect, popoverRect, resolvedAnchor);
261
+
262
+ if (isInsideRect(triggerRect, pointer.x, pointer.y)) return;
263
+ if (isInsideRect(popoverRect, pointer.x, pointer.y)) return;
264
+ if (pointInPolygon(pointer, safePolygon)) return;
265
+
266
+ closePanel();
267
+ }
268
+
269
+ $effect(() => {
270
+ if (!open) return;
271
+ void tick().then(() => {
272
+ positionPanel();
273
+ });
274
+ });
275
+ </script>
276
+
277
+ <svelte:window
278
+ onclick={handleOutsideClick}
279
+ onscroll={closePanel}
280
+ onresize={closePanel}
281
+ onkeydown={handleEscape}
282
+ onmousemove={handlePointerMove}
283
+ />
284
+
285
+ <div class="relative inline-block text-left" data-popover-root>
286
+ <div bind:this={triggerEl}>
287
+ <button
288
+ type="button"
289
+ class="text-left"
290
+ onclick={toggle}
291
+ aria-expanded={open}
292
+ aria-haspopup="dialog"
293
+ >
294
+ {@render trigger()}
295
+ </button>
296
+ </div>
297
+
298
+ {#if open}
299
+ <div
300
+ use:portalContent
301
+ class="popover fixed z-50 focus:outline-none {className}"
302
+ data-popover
303
+ style="top: {panelCoords.top}px; left: {panelCoords.left}px; transform: {panelTransform};"
304
+ role="dialog"
305
+ aria-modal="false"
306
+ tabindex="-1"
307
+ bind:this={panelEl}
308
+ >
309
+ {@render children()}
310
+ </div>
311
+ {/if}
312
+ </div>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ trigger: Snippet;
4
+ children: Snippet;
5
+ open?: boolean;
6
+ class?: string;
7
+ align?: 'left' | 'center' | 'right';
8
+ anchor?: 'top' | 'bottom' | 'leading' | 'trailing';
9
+ }
10
+ declare const Popover: import("svelte").Component<Props, {}, "open">;
11
+ type Popover = ReturnType<typeof Popover>;
12
+ export default Popover;
@@ -0,0 +1 @@
1
+ export { default } from './Popover.svelte';
@@ -0,0 +1 @@
1
+ export { default } from './Popover.svelte';
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export { default as Spinner } from './components/Spinner.svelte';
11
11
  export { default as TextShimmer } from './components/TextShimmer.svelte';
12
12
  export { default as Tooltip } from './components/Tooltip.svelte';
13
13
  export { default as Dropdown } from './components/Dropdown';
14
+ export { default as Popover } from './components/Popover';
14
15
  export { default as ChipInput } from './components/ChipInput.svelte';
package/dist/index.js CHANGED
@@ -11,4 +11,5 @@ export { default as Spinner } from './components/Spinner.svelte';
11
11
  export { default as TextShimmer } from './components/TextShimmer.svelte';
12
12
  export { default as Tooltip } from './components/Tooltip.svelte';
13
13
  export { default as Dropdown } from './components/Dropdown';
14
+ export { default as Popover } from './components/Popover';
14
15
  export { default as ChipInput } from './components/ChipInput.svelte';
@@ -103,6 +103,10 @@
103
103
  @apply bg-zinc-100;
104
104
  }
105
105
 
106
+ .popover {
107
+ @apply rounded-md border border-gray-200 bg-white p-2 shadow-md;
108
+ }
109
+
106
110
  .sheet {
107
111
  @apply rounded-md bg-white p-4 shadow-md;
108
112
  }
package/dist/style.css CHANGED
@@ -15,6 +15,7 @@
15
15
  --color-zinc-100: oklch(96.7% 0.001 286.375);
16
16
  --color-zinc-200: oklch(92% 0.004 286.32);
17
17
  --color-zinc-300: oklch(87.1% 0.006 286.286);
18
+ --color-zinc-400: oklch(70.5% 0.015 286.067);
18
19
  --color-zinc-500: oklch(55.2% 0.016 285.938);
19
20
  --color-zinc-700: oklch(37% 0.013 285.805);
20
21
  --color-zinc-900: oklch(21% 0.006 285.885);
@@ -275,6 +276,9 @@
275
276
  .w-60 {
276
277
  width: calc(var(--spacing) * 60);
277
278
  }
279
+ .w-64 {
280
+ width: calc(var(--spacing) * 64);
281
+ }
278
282
  .w-fit {
279
283
  width: fit-content;
280
284
  }
@@ -384,6 +388,9 @@
384
388
  .px-2 {
385
389
  padding-inline: calc(var(--spacing) * 2);
386
390
  }
391
+ .px-3 {
392
+ padding-inline: calc(var(--spacing) * 3);
393
+ }
387
394
  .px-4 {
388
395
  padding-inline: calc(var(--spacing) * 4);
389
396
  }
@@ -491,6 +498,11 @@
491
498
  }
492
499
  }
493
500
  }
501
+ .focus\:border-zinc-400 {
502
+ &:focus {
503
+ border-color: var(--color-zinc-400);
504
+ }
505
+ }
494
506
  .focus\:outline-none {
495
507
  &:focus {
496
508
  --tw-outline-style: none;
@@ -644,6 +656,16 @@
644
656
  .dropdown-item:hover {
645
657
  background-color: var(--color-zinc-100);
646
658
  }
659
+ .popover {
660
+ border-radius: var(--radius-md);
661
+ border-style: var(--tw-border-style);
662
+ border-width: 1px;
663
+ border-color: var(--color-gray-200);
664
+ background-color: var(--color-white);
665
+ padding: calc(var(--spacing) * 2);
666
+ --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
667
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
668
+ }
647
669
  .sheet {
648
670
  border-radius: var(--radius-md);
649
671
  background-color: var(--color-white);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x33025/sveltely",
3
- "version": "0.0.44",
3
+ "version": "0.0.45",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",