@x33025/sveltely 0.0.44 → 0.0.46

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,52 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte';
3
+ import Spinner from './Spinner.svelte';
4
+
5
+ type Props = {
6
+ icon?: Component<{ class?: string }>;
7
+ label: string;
8
+ action: () => void | Promise<void>;
9
+ disabled?: boolean;
10
+ class?: string;
11
+ type?: 'button' | 'submit' | 'reset';
12
+ };
13
+
14
+ let {
15
+ icon,
16
+ label,
17
+ action,
18
+ disabled = false,
19
+ class: className = '',
20
+ type = 'button',
21
+ ...props
22
+ }: Props & Record<string, unknown> = $props();
23
+
24
+ let pending = $state(false);
25
+
26
+ const handleClick = async () => {
27
+ if (disabled || pending) return;
28
+ pending = true;
29
+ try {
30
+ await action();
31
+ } finally {
32
+ pending = false;
33
+ }
34
+ };
35
+ </script>
36
+
37
+ <button
38
+ {type}
39
+ class="inline-flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50 {className}"
40
+ disabled={disabled || pending}
41
+ aria-busy={pending}
42
+ onclick={handleClick}
43
+ {...props}
44
+ >
45
+ {#if pending}
46
+ <Spinner class="size-4" />
47
+ {:else if icon}
48
+ {@const Icon = icon}
49
+ <Icon class="size-4" />
50
+ {/if}
51
+ <span>{label}</span>
52
+ </button>
@@ -0,0 +1,15 @@
1
+ import type { Component } from 'svelte';
2
+ type Props = {
3
+ icon?: Component<{
4
+ class?: string;
5
+ }>;
6
+ label: string;
7
+ action: () => void | Promise<void>;
8
+ disabled?: boolean;
9
+ class?: string;
10
+ type?: 'button' | 'submit' | 'reset';
11
+ };
12
+ type $$ComponentProps = Props & Record<string, unknown>;
13
+ declare const AsyncButton: Component<$$ComponentProps, {}, "">;
14
+ type AsyncButton = ReturnType<typeof AsyncButton>;
15
+ export default AsyncButton;
@@ -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,6 @@ 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';
16
+ export { default as AsyncButton } from './components/AsyncButton.svelte';
package/dist/index.js CHANGED
@@ -11,4 +11,6 @@ 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';
16
+ export { default as AsyncButton } from './components/AsyncButton.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
  }
@@ -369,6 +373,9 @@
369
373
  .bg-zinc-50 {
370
374
  background-color: var(--color-zinc-50);
371
375
  }
376
+ .bg-zinc-900 {
377
+ background-color: var(--color-zinc-900);
378
+ }
372
379
  .p-0 {
373
380
  padding: calc(var(--spacing) * 0);
374
381
  }
@@ -384,6 +391,9 @@
384
391
  .px-2 {
385
392
  padding-inline: calc(var(--spacing) * 2);
386
393
  }
394
+ .px-3 {
395
+ padding-inline: calc(var(--spacing) * 3);
396
+ }
387
397
  .px-4 {
388
398
  padding-inline: calc(var(--spacing) * 4);
389
399
  }
@@ -422,6 +432,9 @@
422
432
  .text-red-700 {
423
433
  color: var(--color-red-700);
424
434
  }
435
+ .text-white {
436
+ color: var(--color-white);
437
+ }
425
438
  .text-zinc-500 {
426
439
  color: var(--color-zinc-500);
427
440
  }
@@ -491,6 +504,11 @@
491
504
  }
492
505
  }
493
506
  }
507
+ .focus\:border-zinc-400 {
508
+ &:focus {
509
+ border-color: var(--color-zinc-400);
510
+ }
511
+ }
494
512
  .focus\:outline-none {
495
513
  &:focus {
496
514
  --tw-outline-style: none;
@@ -517,6 +535,11 @@
517
535
  opacity: 40%;
518
536
  }
519
537
  }
538
+ .disabled\:opacity-50 {
539
+ &:disabled {
540
+ opacity: 50%;
541
+ }
542
+ }
520
543
  }
521
544
  @layer base {
522
545
  html, body {
@@ -644,6 +667,16 @@
644
667
  .dropdown-item:hover {
645
668
  background-color: var(--color-zinc-100);
646
669
  }
670
+ .popover {
671
+ border-radius: var(--radius-md);
672
+ border-style: var(--tw-border-style);
673
+ border-width: 1px;
674
+ border-color: var(--color-gray-200);
675
+ background-color: var(--color-white);
676
+ padding: calc(var(--spacing) * 2);
677
+ --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));
678
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
679
+ }
647
680
  .sheet {
648
681
  border-radius: var(--radius-md);
649
682
  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.46",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",