@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.
@@ -1,27 +1,79 @@
1
1
  <script lang="ts">
2
+ import { tick } from 'svelte';
2
3
  import type { Snippet } from 'svelte';
4
+ import { portalContent } from '../actions/portal';
5
+ import { computePosition, type Anchor } from '../utils/positioning';
3
6
 
4
7
  let {
5
8
  label,
6
9
  class: className = '',
7
10
  disabled = false,
11
+ anchor = 'top' as Anchor,
8
12
  children,
9
13
  ...props
10
- }: { label: string; class?: string; disabled?: boolean; children: Snippet } & Record<
11
- string,
12
- unknown
13
- > = $props();
14
+ }: {
15
+ label: string;
16
+ class?: string;
17
+ disabled?: boolean;
18
+ anchor?: Anchor;
19
+ children: Snippet;
20
+ } & Record<string, unknown> = $props();
21
+
22
+ let triggerEl = $state<HTMLElement | null>(null);
23
+ let tooltipEl = $state<HTMLElement | null>(null);
24
+ let visible = $state(false);
25
+ let coords = $state({ top: 0, left: 0 });
26
+ let transform = $state('none');
27
+ let resolvedAnchor = $state<Anchor>('top');
28
+
29
+ const position = async () => {
30
+ await tick();
31
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
32
+ if (!triggerEl || !tooltipEl) return;
33
+ const result = computePosition(
34
+ triggerEl.getBoundingClientRect(),
35
+ tooltipEl.offsetWidth,
36
+ tooltipEl.offsetHeight,
37
+ anchor,
38
+ 'center'
39
+ );
40
+ coords = { top: result.top, left: result.left };
41
+ transform = result.transform;
42
+ resolvedAnchor = result.anchor;
43
+ };
44
+
45
+ const show = () => {
46
+ if (disabled) return;
47
+ visible = true;
48
+ void position();
49
+ };
50
+
51
+ const hide = () => {
52
+ visible = false;
53
+ };
14
54
  </script>
15
55
 
16
56
  <span
17
- class="tooltip-trigger relative inline-flex items-center {className}"
18
- aria-label={!disabled ? label : undefined}
57
+ bind:this={triggerEl}
58
+ class="inline-flex items-center {className}"
59
+ onmouseenter={show}
60
+ onmouseleave={hide}
61
+ onfocusin={show}
62
+ onfocusout={hide}
19
63
  {...props}
20
64
  >
21
65
  {@render children()}
22
- {#if !disabled}
23
- <span class="tooltip" role="tooltip">
24
- {label}
25
- </span>
26
- {/if}
27
66
  </span>
67
+
68
+ {#if visible}
69
+ <div
70
+ use:portalContent
71
+ class="tooltip pointer-events-none fixed z-50"
72
+ data-anchor={resolvedAnchor}
73
+ style="top: {coords.top}px; left: {coords.left}px; transform: {transform};"
74
+ role="tooltip"
75
+ bind:this={tooltipEl}
76
+ >
77
+ {label}
78
+ </div>
79
+ {/if}
@@ -1,8 +1,10 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type Anchor } from '../utils/positioning';
2
3
  type $$ComponentProps = {
3
4
  label: string;
4
5
  class?: string;
5
6
  disabled?: boolean;
7
+ anchor?: Anchor;
6
8
  children: Snippet;
7
9
  } & Record<string, unknown>;
8
10
  declare const Tooltip: import("svelte").Component<$$ComponentProps, {}, "">;
package/dist/index.d.ts CHANGED
@@ -10,7 +10,6 @@ export { default as Sheet } from './components/Sheet';
10
10
  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
- export { default as Dropdown } from './components/Dropdown';
14
13
  export { default as Popover } from './components/Popover';
15
14
  export { default as ChipInput } from './components/ChipInput.svelte';
16
15
  export { default as AsyncButton } from './components/AsyncButton.svelte';
package/dist/index.js CHANGED
@@ -10,7 +10,6 @@ export { default as Sheet } from './components/Sheet';
10
10
  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
- export { default as Dropdown } from './components/Dropdown';
14
13
  export { default as Popover } from './components/Popover';
15
14
  export { default as ChipInput } from './components/ChipInput.svelte';
16
15
  export { default as AsyncButton } from './components/AsyncButton.svelte';
@@ -20,11 +20,11 @@
20
20
  }
21
21
 
22
22
  .vstack {
23
- @apply flex min-h-0 flex-col;
23
+ @apply flex min-h-0 min-w-0 flex-col;
24
24
  }
25
25
 
26
26
  .hstack {
27
- @apply flex min-w-0 flex-row;
27
+ @apply flex min-h-0 min-w-0 flex-row;
28
28
  }
29
29
 
30
30
  .spacer {
@@ -184,28 +184,47 @@
184
184
  }
185
185
 
186
186
  .tooltip {
187
- @apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-10 -translate-x-1/2 rounded-sm bg-black px-2 py-1 text-xs whitespace-nowrap text-white opacity-0;
188
- }
189
-
190
- .tooltip-trigger:hover .tooltip,
191
- .tooltip-trigger:focus-within .tooltip {
192
- @apply opacity-100;
187
+ @apply rounded-sm bg-black px-2 py-1 text-xs whitespace-nowrap text-white;
193
188
  }
194
189
 
195
190
  .tooltip::before {
196
191
  content: '';
197
192
  position: absolute;
198
- top: calc(100% - 5px);
199
- left: 50%;
200
- width: 10px;
201
- height: 10px;
202
- transform: translateX(-50%) rotate(45deg);
193
+ width: 8px;
194
+ height: 8px;
203
195
  background: #000;
204
- border-radius: 0;
205
- border-bottom-right-radius: 0.125rem;
196
+ transform: rotate(45deg);
206
197
  z-index: -1;
207
198
  }
208
199
 
200
+ /* Arrow points down (tooltip is above trigger) */
201
+ .tooltip[data-anchor='top']::before {
202
+ bottom: -4px;
203
+ left: 50%;
204
+ margin-left: -4px;
205
+ }
206
+
207
+ /* Arrow points up (tooltip is below trigger) */
208
+ .tooltip[data-anchor='bottom']::before {
209
+ top: -4px;
210
+ left: 50%;
211
+ margin-left: -4px;
212
+ }
213
+
214
+ /* Arrow points right (tooltip is to the left of trigger) */
215
+ .tooltip[data-anchor='leading']::before {
216
+ right: -4px;
217
+ top: 50%;
218
+ margin-top: -4px;
219
+ }
220
+
221
+ /* Arrow points left (tooltip is to the right of trigger) */
222
+ .tooltip[data-anchor='trailing']::before {
223
+ left: -4px;
224
+ top: 50%;
225
+ margin-top: -4px;
226
+ }
227
+
209
228
  .segmented-picker {
210
229
  @apply gap-1 rounded-md bg-zinc-100 p-1;
211
230
  }
package/dist/style.css CHANGED
@@ -9,7 +9,6 @@
9
9
  "Courier New", monospace;
10
10
  --color-red-500: oklch(63.7% 0.237 25.331);
11
11
  --color-red-600: oklch(57.7% 0.245 27.325);
12
- --color-red-700: oklch(50.5% 0.213 27.518);
13
12
  --color-gray-200: oklch(92.8% 0.006 264.531);
14
13
  --color-gray-700: oklch(37.3% 0.034 259.733);
15
14
  --color-gray-900: oklch(21% 0.034 264.665);
@@ -20,6 +19,7 @@
20
19
  --color-zinc-400: oklch(70.5% 0.015 286.067);
21
20
  --color-zinc-500: oklch(55.2% 0.016 285.938);
22
21
  --color-zinc-700: oklch(37% 0.013 285.805);
22
+ --color-zinc-800: oklch(27.4% 0.006 286.033);
23
23
  --color-zinc-900: oklch(21% 0.006 285.885);
24
24
  --color-black: #000;
25
25
  --color-white: #fff;
@@ -200,6 +200,9 @@
200
200
  .pointer-events-none {
201
201
  pointer-events: none;
202
202
  }
203
+ .visible {
204
+ visibility: visible;
205
+ }
203
206
  .absolute {
204
207
  position: absolute;
205
208
  }
@@ -224,6 +227,9 @@
224
227
  .z-50 {
225
228
  z-index: 50;
226
229
  }
230
+ .z-\[9999\] {
231
+ z-index: 9999;
232
+ }
227
233
  .mt-2 {
228
234
  margin-top: calc(var(--spacing) * 2);
229
235
  }
@@ -245,6 +251,9 @@
245
251
  .inline-flex {
246
252
  display: inline-flex;
247
253
  }
254
+ .inline-grid {
255
+ display: inline-grid;
256
+ }
248
257
  .size-3 {
249
258
  width: calc(var(--spacing) * 3);
250
259
  height: calc(var(--spacing) * 3);
@@ -275,6 +284,9 @@
275
284
  .w-8 {
276
285
  width: calc(var(--spacing) * 8);
277
286
  }
287
+ .w-56 {
288
+ width: calc(var(--spacing) * 56);
289
+ }
278
290
  .w-60 {
279
291
  width: calc(var(--spacing) * 60);
280
292
  }
@@ -290,6 +302,9 @@
290
302
  .flex-1 {
291
303
  flex: 1;
292
304
  }
305
+ .shrink-0 {
306
+ flex-shrink: 0;
307
+ }
293
308
  .-translate-x-px {
294
309
  --tw-translate-x: -1px;
295
310
  translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -313,6 +328,9 @@
313
328
  .flex-wrap {
314
329
  flex-wrap: wrap;
315
330
  }
331
+ .place-items-center {
332
+ place-items: center;
333
+ }
316
334
  .items-center {
317
335
  align-items: center;
318
336
  }
@@ -399,9 +417,6 @@
399
417
  .px-3 {
400
418
  padding-inline: calc(var(--spacing) * 3);
401
419
  }
402
- .px-4 {
403
- padding-inline: calc(var(--spacing) * 4);
404
- }
405
420
  .py-1 {
406
421
  padding-block: calc(var(--spacing) * 1);
407
422
  }
@@ -415,6 +430,10 @@
415
430
  font-size: var(--text-sm);
416
431
  line-height: var(--tw-leading, var(--text-sm--line-height));
417
432
  }
433
+ .text-xs {
434
+ font-size: var(--text-xs);
435
+ line-height: var(--tw-leading, var(--text-xs--line-height));
436
+ }
418
437
  .leading-none {
419
438
  --tw-leading: 1;
420
439
  line-height: 1;
@@ -437,9 +456,6 @@
437
456
  .text-red-600 {
438
457
  color: var(--color-red-600);
439
458
  }
440
- .text-red-700 {
441
- color: var(--color-red-700);
442
- }
443
459
  .text-white {
444
460
  color: var(--color-white);
445
461
  }
@@ -449,6 +465,9 @@
449
465
  .text-zinc-700 {
450
466
  color: var(--color-zinc-700);
451
467
  }
468
+ .text-zinc-800 {
469
+ color: var(--color-zinc-800);
470
+ }
452
471
  .uppercase {
453
472
  text-transform: uppercase;
454
473
  }
@@ -538,11 +557,6 @@
538
557
  color: var(--color-zinc-500);
539
558
  }
540
559
  }
541
- .disabled\:opacity-40 {
542
- &:disabled {
543
- opacity: 40%;
544
- }
545
- }
546
560
  .disabled\:opacity-50 {
547
561
  &:disabled {
548
562
  opacity: 50%;
@@ -582,10 +596,12 @@
582
596
  .vstack {
583
597
  display: flex;
584
598
  min-height: calc(var(--spacing) * 0);
599
+ min-width: calc(var(--spacing) * 0);
585
600
  flex-direction: column;
586
601
  }
587
602
  .hstack {
588
603
  display: flex;
604
+ min-height: calc(var(--spacing) * 0);
589
605
  min-width: calc(var(--spacing) * 0);
590
606
  flex-direction: row;
591
607
  }
@@ -766,13 +782,6 @@
766
782
  box-shadow: var(--shadow-md);
767
783
  }
768
784
  .tooltip {
769
- pointer-events: none;
770
- position: absolute;
771
- bottom: calc(100% + 6px);
772
- left: calc(1/2 * 100%);
773
- z-index: 10;
774
- --tw-translate-x: calc(calc(1/2 * 100%) * -1);
775
- translate: var(--tw-translate-x) var(--tw-translate-y);
776
785
  border-radius: var(--radius-sm);
777
786
  background-color: var(--color-black);
778
787
  padding-inline: calc(var(--spacing) * 2);
@@ -781,24 +790,36 @@
781
790
  line-height: var(--tw-leading, var(--text-xs--line-height));
782
791
  white-space: nowrap;
783
792
  color: var(--color-white);
784
- opacity: 0%;
785
- }
786
- .tooltip-trigger:hover .tooltip, .tooltip-trigger:focus-within .tooltip {
787
- opacity: 100%;
788
793
  }
789
794
  .tooltip::before {
790
795
  content: '';
791
796
  position: absolute;
792
- top: calc(100% - 5px);
793
- left: 50%;
794
- width: 10px;
795
- height: 10px;
796
- transform: translateX(-50%) rotate(45deg);
797
+ width: 8px;
798
+ height: 8px;
797
799
  background: #000;
798
- border-radius: 0;
799
- border-bottom-right-radius: 0.125rem;
800
+ transform: rotate(45deg);
800
801
  z-index: -1;
801
802
  }
803
+ .tooltip[data-anchor='top']::before {
804
+ bottom: -4px;
805
+ left: 50%;
806
+ margin-left: -4px;
807
+ }
808
+ .tooltip[data-anchor='bottom']::before {
809
+ top: -4px;
810
+ left: 50%;
811
+ margin-left: -4px;
812
+ }
813
+ .tooltip[data-anchor='leading']::before {
814
+ right: -4px;
815
+ top: 50%;
816
+ margin-top: -4px;
817
+ }
818
+ .tooltip[data-anchor='trailing']::before {
819
+ left: -4px;
820
+ top: 50%;
821
+ margin-top: -4px;
822
+ }
802
823
  .segmented-picker {
803
824
  gap: calc(var(--spacing) * 1);
804
825
  border-radius: var(--radius-md);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared positioning engine for floating UI elements (popovers, tooltips, dropdowns, etc.).
3
+ * Handles anchor/align placement with automatic viewport-overflow flipping.
4
+ */
5
+ export type Anchor = 'top' | 'bottom' | 'leading' | 'trailing';
6
+ export type Align = 'left' | 'center' | 'right';
7
+ export type Transform = 'none' | 'translateX(-50%)' | 'translateX(-100%)' | 'translateY(-100%)' | 'translate(-50%, -100%)' | 'translate(-100%, -100%)';
8
+ export interface Placement {
9
+ top: number;
10
+ left: number;
11
+ transform: Transform;
12
+ }
13
+ export interface PositionResult extends Placement {
14
+ anchor: Anchor;
15
+ align: Align;
16
+ }
17
+ export declare function oppositeAnchor(value: Anchor): Anchor;
18
+ export declare function flippedAlign(value: Align): Align;
19
+ export declare function getPlacement(rect: DOMRect, nextAnchor: Anchor, nextAlign: Align): Placement;
20
+ export declare function getViewportRect(placement: Placement, width: number, height: number): {
21
+ left: number;
22
+ top: number;
23
+ right: number;
24
+ bottom: number;
25
+ };
26
+ export declare function overflowScore(rect: {
27
+ left: number;
28
+ top: number;
29
+ right: number;
30
+ bottom: number;
31
+ }, margin?: number): number;
32
+ /**
33
+ * Compute the best placement for a floating element anchored to a trigger.
34
+ * Tries the preferred anchor/align first, then flips to avoid viewport overflow.
35
+ */
36
+ export declare function computePosition(triggerRect: DOMRect, floatingWidth: number, floatingHeight: number, preferredAnchor: Anchor, preferredAlign: Align): PositionResult;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared positioning engine for floating UI elements (popovers, tooltips, dropdowns, etc.).
3
+ * Handles anchor/align placement with automatic viewport-overflow flipping.
4
+ */
5
+ export function oppositeAnchor(value) {
6
+ if (value === 'top')
7
+ return 'bottom';
8
+ if (value === 'bottom')
9
+ return 'top';
10
+ if (value === 'leading')
11
+ return 'trailing';
12
+ return 'leading';
13
+ }
14
+ export function flippedAlign(value) {
15
+ if (value === 'center')
16
+ return 'center';
17
+ return value === 'left' ? 'right' : 'left';
18
+ }
19
+ export function getPlacement(rect, nextAnchor, nextAlign) {
20
+ if (nextAnchor === 'top' || nextAnchor === 'bottom') {
21
+ const left = nextAlign === 'left'
22
+ ? rect.left + window.scrollX
23
+ : nextAlign === 'right'
24
+ ? rect.right + window.scrollX
25
+ : rect.left + rect.width / 2 + window.scrollX;
26
+ const top = nextAnchor === 'bottom' ? rect.bottom + window.scrollY : rect.top + window.scrollY;
27
+ if (nextAnchor === 'top' && nextAlign === 'center') {
28
+ return { top, left, transform: 'translate(-50%, -100%)' };
29
+ }
30
+ if (nextAnchor === 'top' && nextAlign === 'right') {
31
+ return { top, left, transform: 'translate(-100%, -100%)' };
32
+ }
33
+ if (nextAlign === 'center') {
34
+ return { top, left, transform: 'translateX(-50%)' };
35
+ }
36
+ if (nextAnchor === 'top') {
37
+ return { top, left, transform: 'translateY(-100%)' };
38
+ }
39
+ if (nextAlign === 'right') {
40
+ return { top, left, transform: 'translateX(-100%)' };
41
+ }
42
+ return { top, left, transform: 'none' };
43
+ }
44
+ if (nextAnchor === 'leading') {
45
+ return {
46
+ top: rect.top + window.scrollY,
47
+ left: rect.left + window.scrollX,
48
+ transform: 'translateX(-100%)'
49
+ };
50
+ }
51
+ return {
52
+ top: rect.top + window.scrollY,
53
+ left: rect.right + window.scrollX,
54
+ transform: 'none'
55
+ };
56
+ }
57
+ export function getViewportRect(placement, width, height) {
58
+ let left = placement.left - window.scrollX;
59
+ let top = placement.top - window.scrollY;
60
+ if (placement.transform === 'translateX(-100%)')
61
+ left -= width;
62
+ else if (placement.transform === 'translateX(-50%)')
63
+ left -= width / 2;
64
+ else if (placement.transform === 'translateY(-100%)')
65
+ top -= height;
66
+ else if (placement.transform === 'translate(-50%, -100%)') {
67
+ left -= width / 2;
68
+ top -= height;
69
+ }
70
+ else if (placement.transform === 'translate(-100%, -100%)') {
71
+ left -= width;
72
+ top -= height;
73
+ }
74
+ return { left, top, right: left + width, bottom: top + height };
75
+ }
76
+ export function overflowScore(rect, margin = 8) {
77
+ const leftOverflow = Math.max(0, margin - rect.left);
78
+ const rightOverflow = Math.max(0, rect.right - (window.innerWidth - margin));
79
+ const topOverflow = Math.max(0, margin - rect.top);
80
+ const bottomOverflow = Math.max(0, rect.bottom - (window.innerHeight - margin));
81
+ return leftOverflow + rightOverflow + topOverflow + bottomOverflow;
82
+ }
83
+ /**
84
+ * Compute the best placement for a floating element anchored to a trigger.
85
+ * Tries the preferred anchor/align first, then flips to avoid viewport overflow.
86
+ */
87
+ export function computePosition(triggerRect, floatingWidth, floatingHeight, preferredAnchor, preferredAlign) {
88
+ const candidates = [
89
+ { anchor: preferredAnchor, align: preferredAlign },
90
+ { anchor: preferredAnchor, align: flippedAlign(preferredAlign) },
91
+ { anchor: oppositeAnchor(preferredAnchor), align: preferredAlign },
92
+ { anchor: oppositeAnchor(preferredAnchor), align: flippedAlign(preferredAlign) }
93
+ ];
94
+ const seen = new Set();
95
+ let best = null;
96
+ let bestScore = Number.POSITIVE_INFINITY;
97
+ for (const candidate of candidates) {
98
+ const key = `${candidate.anchor}:${candidate.align}`;
99
+ if (seen.has(key))
100
+ continue;
101
+ seen.add(key);
102
+ const placement = getPlacement(triggerRect, candidate.anchor, candidate.align);
103
+ const rect = getViewportRect(placement, floatingWidth, floatingHeight);
104
+ const score = overflowScore(rect);
105
+ if (score < bestScore) {
106
+ bestScore = score;
107
+ best = { ...placement, anchor: candidate.anchor, align: candidate.align };
108
+ }
109
+ }
110
+ // Fallback (should never happen — at least one candidate always exists)
111
+ return best ?? { top: 0, left: 0, transform: 'none', anchor: preferredAnchor, align: preferredAlign };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x33025/sveltely",
3
- "version": "0.0.49",
3
+ "version": "0.0.52",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",