@placeholderco/placeholder-ui 1.0.3 → 1.0.6

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.
Files changed (136) hide show
  1. package/LICENSE +26 -26
  2. package/README.md +179 -179
  3. package/dist/display/Alert.svelte +179 -179
  4. package/dist/display/Avatar.svelte +166 -166
  5. package/dist/display/LinkCollection.svelte +161 -161
  6. package/dist/display/Paper.svelte +118 -118
  7. package/dist/form/Autocomplete.svelte +223 -191
  8. package/dist/form/Autocomplete.svelte.d.ts +3 -1
  9. package/dist/form/AutocompleteMulti.svelte +356 -0
  10. package/dist/form/AutocompleteMulti.svelte.d.ts +28 -0
  11. package/dist/form/Checkbox.svelte +201 -201
  12. package/dist/form/Chips.svelte +128 -128
  13. package/dist/form/ComboBox.svelte +158 -158
  14. package/dist/form/ComboBox.svelte.d.ts +1 -1
  15. package/dist/form/ComboBoxItemBuilder.svelte +460 -460
  16. package/dist/form/ComboBoxMulti.svelte +197 -197
  17. package/dist/form/ComboBoxMulti.svelte.d.ts +1 -1
  18. package/dist/form/CronBuilder.svelte +693 -693
  19. package/dist/form/DatePicker.svelte +672 -672
  20. package/dist/form/DateTimePicker.svelte +712 -712
  21. package/dist/form/FileInput.svelte +235 -235
  22. package/dist/form/FormGroup.svelte +68 -68
  23. package/dist/form/Number.svelte +238 -238
  24. package/dist/form/PasswordInput.svelte +252 -252
  25. package/dist/form/RadioGroup.svelte +210 -210
  26. package/dist/form/Rating.svelte +235 -235
  27. package/dist/form/SegmentedControl.svelte +149 -149
  28. package/dist/form/Select.svelte +590 -590
  29. package/dist/form/Select.svelte.d.ts +1 -1
  30. package/dist/form/SelectMulti.svelte +613 -613
  31. package/dist/form/SelectMulti.svelte.d.ts +1 -1
  32. package/dist/form/Slider.svelte +358 -358
  33. package/dist/form/Switch.svelte +147 -147
  34. package/dist/form/TextArea.svelte +148 -148
  35. package/dist/form/Textbox.svelte +228 -228
  36. package/dist/form/TimePicker.svelte +267 -267
  37. package/dist/icon/Icon.svelte +52 -52
  38. package/dist/icon/alert-octagon.svg +5 -5
  39. package/dist/icon/alert-triangle.svg +5 -5
  40. package/dist/icon/archive.svg +1 -1
  41. package/dist/icon/arrow-down.svg +1 -1
  42. package/dist/icon/arrow-left.svg +1 -1
  43. package/dist/icon/arrow-right.svg +1 -1
  44. package/dist/icon/arrow-up.svg +1 -1
  45. package/dist/icon/at.svg +1 -1
  46. package/dist/icon/bell.svg +1 -1
  47. package/dist/icon/bookmark.svg +1 -1
  48. package/dist/icon/calendar.svg +1 -1
  49. package/dist/icon/camera.svg +1 -1
  50. package/dist/icon/chart-bar.svg +1 -1
  51. package/dist/icon/chart-line.svg +1 -1
  52. package/dist/icon/chart-pie.svg +1 -1
  53. package/dist/icon/checkbox.svg +1 -1
  54. package/dist/icon/checklist.svg +1 -1
  55. package/dist/icon/circle-check.svg +1 -1
  56. package/dist/icon/circle-x.svg +1 -1
  57. package/dist/icon/clock.svg +1 -1
  58. package/dist/icon/credit-card.svg +1 -1
  59. package/dist/icon/dots-vertical.svg +1 -1
  60. package/dist/icon/dots.svg +1 -1
  61. package/dist/icon/external-link.svg +1 -1
  62. package/dist/icon/eye-off.svg +1 -1
  63. package/dist/icon/eye.svg +1 -1
  64. package/dist/icon/filter.svg +1 -1
  65. package/dist/icon/fingerprint.svg +1 -1
  66. package/dist/icon/flag.svg +1 -1
  67. package/dist/icon/heart.svg +1 -1
  68. package/dist/icon/home.svg +1 -1
  69. package/dist/icon/key.svg +1 -1
  70. package/dist/icon/list-check.svg +1 -1
  71. package/dist/icon/login.svg +1 -1
  72. package/dist/icon/logout.svg +1 -1
  73. package/dist/icon/map-pin.svg +1 -1
  74. package/dist/icon/maximize.svg +1 -1
  75. package/dist/icon/microphone.svg +1 -1
  76. package/dist/icon/minimize.svg +1 -1
  77. package/dist/icon/note.svg +1 -1
  78. package/dist/icon/player-pause.svg +1 -1
  79. package/dist/icon/printer.svg +1 -1
  80. package/dist/icon/qrcode.svg +1 -1
  81. package/dist/icon/send.svg +1 -1
  82. package/dist/icon/settings.svg +1 -1
  83. package/dist/icon/share.svg +1 -1
  84. package/dist/icon/shopping-cart.svg +1 -1
  85. package/dist/icon/sort-ascending.svg +1 -1
  86. package/dist/icon/sort-descending.svg +1 -1
  87. package/dist/icon/star.svg +1 -1
  88. package/dist/icon/tag.svg +1 -1
  89. package/dist/icon/trending-down.svg +1 -1
  90. package/dist/icon/trending-up.svg +1 -1
  91. package/dist/icon/upload.svg +1 -1
  92. package/dist/icon/volume-off.svg +1 -1
  93. package/dist/icon/volume.svg +1 -1
  94. package/dist/icon/world.svg +1 -1
  95. package/dist/icon/zoom-in.svg +1 -1
  96. package/dist/icon/zoom-out.svg +1 -1
  97. package/dist/index.d.ts +1 -0
  98. package/dist/index.js +1 -0
  99. package/dist/layout/AppShell.svelte +169 -169
  100. package/dist/layout/CustomNavbar.svelte +61 -61
  101. package/dist/layout/Navbar.svelte +206 -206
  102. package/dist/layout/NavbarItemDisplay.svelte +29 -29
  103. package/dist/layout/Sidenav.svelte +712 -712
  104. package/dist/styles/components.css +199 -199
  105. package/dist/styles/dark.css +146 -146
  106. package/dist/styles/index.css +116 -116
  107. package/dist/styles/reset.css +110 -110
  108. package/dist/styles/semantic.css +86 -86
  109. package/dist/styles/tokens.css +203 -197
  110. package/dist/styles/utilities.css +523 -523
  111. package/dist/ui/Accordion.svelte +289 -289
  112. package/dist/ui/ActionIcon.svelte +76 -76
  113. package/dist/ui/Badge.svelte +329 -279
  114. package/dist/ui/Breadcrumbs.svelte +131 -131
  115. package/dist/ui/Button.svelte +432 -370
  116. package/dist/ui/ButtonVariant.d.ts +1 -1
  117. package/dist/ui/Dialog.svelte +307 -307
  118. package/dist/ui/Drawer.svelte +524 -524
  119. package/dist/ui/Dropdown.svelte +97 -97
  120. package/dist/ui/Dropzone.svelte +122 -122
  121. package/dist/ui/Link.svelte +32 -32
  122. package/dist/ui/Loader.svelte +70 -70
  123. package/dist/ui/LoadingOverlay.svelte +53 -53
  124. package/dist/ui/Pagination.svelte +135 -135
  125. package/dist/ui/Popover.svelte +225 -225
  126. package/dist/ui/Progress.svelte +191 -191
  127. package/dist/ui/RingProgress.svelte +141 -141
  128. package/dist/ui/Skeleton.svelte +85 -85
  129. package/dist/ui/Stepper.svelte +355 -355
  130. package/dist/ui/Table.svelte +345 -345
  131. package/dist/ui/Tabs.svelte +146 -146
  132. package/dist/ui/ThemeSwitcher.svelte +39 -39
  133. package/dist/ui/Timeline.svelte +225 -225
  134. package/dist/ui/Toaster.svelte +6 -6
  135. package/dist/ui/Tooltip.svelte +434 -434
  136. package/package.json +14 -14
@@ -1,435 +1,435 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import { fade } from 'svelte/transition';
4
-
5
- interface Props {
6
- /** The element to attach the tooltip to */
7
- children: Snippet;
8
- /** Rich content using a Svelte snippet */
9
- tooltipContent?: Snippet;
10
- /** Simple text content (alternative to snippet) */
11
- text?: string;
12
- /** HTML content (use with caution - sanitize user input) */
13
- html?: string;
14
- /** Preferred tooltip position */
15
- location?: 'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end';
16
- /** Maximum width of tooltip */
17
- maxWidth?: string;
18
- /** Offset distance from target element (px) */
19
- offsetDistance?: number;
20
- /** Show arrow pointing to target */
21
- showArrow?: boolean;
22
- /** Delay before showing tooltip (ms) */
23
- delay?: number;
24
- /** Callback when tooltip opens/closes */
25
- onOpen?: (open: boolean) => void;
26
- /** Disable the tooltip */
27
- disabled?: boolean;
28
- }
29
-
30
- let {
31
- children,
32
- tooltipContent,
33
- text,
34
- html,
35
- location = 'top',
36
- maxWidth = '300px',
37
- offsetDistance = 4,
38
- showArrow = true,
39
- delay = 0,
40
- onOpen,
41
- disabled = false
42
- }: Props = $props();
43
-
44
- // State
45
- let open = $state(false);
46
- let referenceElement: HTMLElement | null = $state(null);
47
- let tooltipElement: HTMLElement | null = $state(null);
48
- let arrowElement: HTMLElement | null = $state(null);
49
- let timeoutId: number | null = null;
50
-
51
- // Position state
52
- let tooltipStyle = $state('');
53
- let arrowStyle = $state('');
54
- let actualPlacement = $state<'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end'>('top');
55
-
56
- // Sync actualPlacement with location prop
57
- $effect(() => {
58
- actualPlacement = location;
59
- });
60
-
61
- // Check if tooltip has any content
62
- const hasContent = $derived(!!(text || html || tooltipContent));
63
-
64
- function handleMouseEnter() {
65
- if (disabled || !hasContent) return;
66
-
67
- if (delay > 0) {
68
- timeoutId = window.setTimeout(() => {
69
- showTooltip();
70
- }, delay);
71
- } else {
72
- showTooltip();
73
- }
74
- }
75
-
76
- function handleMouseLeave() {
77
- if (timeoutId) {
78
- clearTimeout(timeoutId);
79
- timeoutId = null;
80
- }
81
- hideTooltip();
82
- }
83
-
84
- function showTooltip() {
85
- open = true;
86
- onOpen?.(true);
87
-
88
- // Position tooltip after it's rendered
89
- // Use multiple RAF calls and verify dimensions to ensure layout is complete
90
- requestAnimationFrame(() => {
91
- requestAnimationFrame(() => {
92
- if (referenceElement && tooltipElement) {
93
- positionTooltip();
94
-
95
- // Verify and reposition if dimensions changed
96
- requestAnimationFrame(() => {
97
- if (tooltipElement) {
98
- const rect = tooltipElement.getBoundingClientRect();
99
- if (rect.width > 0 && rect.height > 0) {
100
- positionTooltip();
101
- }
102
- }
103
- });
104
- }
105
- });
106
- });
107
- }
108
-
109
- function hideTooltip() {
110
- open = false;
111
- onOpen?.(false);
112
- }
113
-
114
- function positionTooltip() {
115
- if (!referenceElement || !tooltipElement) return;
116
-
117
- const refRect = referenceElement.getBoundingClientRect();
118
- const tooltipRect = tooltipElement.getBoundingClientRect();
119
- const arrowSize = showArrow ? 12 : 0;
120
- const offset = offsetDistance + arrowSize;
121
-
122
- let position = calculatePosition(location, refRect, tooltipRect, offset);
123
-
124
- // Check if tooltip would overflow viewport and flip if needed
125
- const flippedPosition = checkAndFlipPosition(position, tooltipRect, location, refRect, offset);
126
- position = flippedPosition.position;
127
- actualPlacement = flippedPosition.placement as typeof actualPlacement;
128
-
129
- // Apply position
130
- tooltipStyle = `left: ${position.left}px; top: ${position.top}px;`;
131
-
132
- // Position arrow
133
- if (showArrow && arrowElement) {
134
- arrowStyle = calculateArrowPosition(actualPlacement, refRect, position);
135
- }
136
- }
137
-
138
- function calculatePosition(
139
- placement: string,
140
- refRect: DOMRect,
141
- tooltipRect: DOMRect,
142
- offset: number
143
- ): { left: number; top: number } {
144
- let left = 0;
145
- let top = 0;
146
-
147
- const [side, alignment] = placement.split('-');
148
-
149
- switch (side) {
150
- case 'top':
151
- top = refRect.top - tooltipRect.height - offset;
152
- left = refRect.left + refRect.width / 2 - tooltipRect.width / 2;
153
- if (alignment === 'start') left = refRect.left;
154
- if (alignment === 'end') left = refRect.right - tooltipRect.width;
155
- break;
156
-
157
- case 'bottom':
158
- top = refRect.bottom + offset;
159
- left = refRect.left + refRect.width / 2 - tooltipRect.width / 2;
160
- if (alignment === 'start') left = refRect.left;
161
- if (alignment === 'end') left = refRect.right - tooltipRect.width;
162
- break;
163
-
164
- case 'left':
165
- left = refRect.left - tooltipRect.width - offset;
166
- top = refRect.top + refRect.height / 2 - tooltipRect.height / 2;
167
- if (alignment === 'start') top = refRect.top;
168
- if (alignment === 'end') top = refRect.bottom - tooltipRect.height;
169
- break;
170
-
171
- case 'right':
172
- left = refRect.right + offset;
173
- top = refRect.top + refRect.height / 2 - tooltipRect.height / 2;
174
- if (alignment === 'start') top = refRect.top;
175
- if (alignment === 'end') top = refRect.bottom - tooltipRect.height;
176
- break;
177
- }
178
-
179
- return { left, top };
180
- }
181
-
182
- function checkAndFlipPosition(
183
- position: { left: number; top: number },
184
- tooltipRect: DOMRect,
185
- placement: string,
186
- refRect: DOMRect,
187
- offset: number
188
- ): { position: { left: number; top: number }; placement: string } {
189
- const viewportWidth = window.innerWidth;
190
- const viewportHeight = window.innerHeight;
191
- const padding = 5;
192
-
193
- let newPosition = { ...position };
194
- let newPlacement = placement;
195
- const [side] = placement.split('-');
196
-
197
- // Check vertical overflow
198
- if (side === 'top' && position.top < padding) {
199
- // Flip to bottom
200
- newPlacement = placement.replace('top', 'bottom');
201
- newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
202
- } else if (side === 'bottom' && position.top + tooltipRect.height > viewportHeight - padding) {
203
- // Flip to top
204
- newPlacement = placement.replace('bottom', 'top');
205
- newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
206
- }
207
-
208
- // Check horizontal overflow
209
- if (side === 'left' && position.left < padding) {
210
- // Flip to right
211
- newPlacement = placement.replace('left', 'right');
212
- newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
213
- } else if (side === 'right' && position.left + tooltipRect.width > viewportWidth - padding) {
214
- // Flip to left
215
- newPlacement = placement.replace('right', 'left');
216
- newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
217
- }
218
-
219
- // Shift within bounds if still overflowing
220
- if (newPosition.left < padding) {
221
- newPosition.left = padding;
222
- } else if (newPosition.left + tooltipRect.width > viewportWidth - padding) {
223
- newPosition.left = viewportWidth - tooltipRect.width - padding;
224
- }
225
-
226
- if (newPosition.top < padding) {
227
- newPosition.top = padding;
228
- } else if (newPosition.top + tooltipRect.height > viewportHeight - padding) {
229
- newPosition.top = viewportHeight - tooltipRect.height - padding;
230
- }
231
-
232
- return { position: newPosition, placement: newPlacement };
233
- }
234
-
235
- function calculateArrowPosition(
236
- placement: string,
237
- refRect: DOMRect,
238
- tooltipPos: { left: number; top: number }
239
- ): string {
240
- const [side] = placement.split('-');
241
-
242
- const refCenter = {
243
- x: refRect.left + refRect.width / 2,
244
- y: refRect.top + refRect.height / 2
245
- };
246
-
247
- const tooltipHeight = tooltipElement?.offsetHeight || 0;
248
- const tooltipWidth = tooltipElement?.offsetWidth || 0;
249
-
250
- // Calculate arrow center position relative to tooltip
251
- const arrowX = refCenter.x - tooltipPos.left;
252
- const arrowY = refCenter.y - tooltipPos.top;
253
-
254
- switch (side) {
255
- case 'top':
256
- // Arrow at bottom edge, pointing down
257
- return `
258
- left: ${arrowX}px;
259
- top: ${tooltipHeight}px;
260
- border-left: 12px solid transparent;
261
- border-right: 12px solid transparent;
262
- border-top: 12px solid var(--tooltip-bg);
263
- transform: translateX(-50%) translateY(-50%);
264
- `;
265
-
266
- case 'bottom':
267
- // Arrow at top edge, pointing up
268
- return `
269
- left: ${arrowX}px;
270
- top: 0px;
271
- border-left: 12px solid transparent;
272
- border-right: 12px solid transparent;
273
- border-bottom: 12px solid var(--tooltip-bg);
274
- transform: translateX(-50%) translateY(-50%);
275
- `;
276
-
277
- case 'left':
278
- // Arrow at right edge, pointing left
279
- return `
280
- left: ${tooltipWidth}px;
281
- top: ${arrowY}px;
282
- border-top: 12px solid transparent;
283
- border-bottom: 12px solid transparent;
284
- border-left: 12px solid var(--tooltip-bg);
285
- transform: translateX(-50%) translateY(-50%);
286
- `;
287
-
288
- case 'right':
289
- // Arrow at left edge, pointing right
290
- return `
291
- left: 0px;
292
- top: ${arrowY}px;
293
- border-top: 12px solid transparent;
294
- border-bottom: 12px solid transparent;
295
- border-right: 12px solid var(--tooltip-bg);
296
- transform: translateX(-50%) translateY(-50%);
297
- `;
298
-
299
- default:
300
- return '';
301
- }
302
- }
303
-
304
- function handleKeydown(event: KeyboardEvent) {
305
- if (event.key === 'Escape' && open) {
306
- hideTooltip();
307
- }
308
- }
309
-
310
- $effect(() => {
311
- if (open) {
312
- window.addEventListener('keydown', handleKeydown);
313
- window.addEventListener('scroll', positionTooltip, true);
314
- window.addEventListener('resize', positionTooltip);
315
-
316
- return () => {
317
- window.removeEventListener('keydown', handleKeydown);
318
- window.removeEventListener('scroll', positionTooltip, true);
319
- window.removeEventListener('resize', positionTooltip);
320
- };
321
- }
322
- });
323
- </script>
324
-
325
- <div class="tooltip-v2-container">
326
- <!-- Reference Element -->
327
- <div
328
- bind:this={referenceElement}
329
- class="tooltip-v2-trigger"
330
- onmouseenter={handleMouseEnter}
331
- onmouseleave={handleMouseLeave}
332
- onfocus={handleMouseEnter}
333
- onblur={handleMouseLeave}
334
- role="button"
335
- tabindex="0"
336
- >
337
- {@render children()}
338
- </div>
339
-
340
- <!-- Floating Tooltip -->
341
- {#if open && hasContent && !disabled}
342
- <div
343
- bind:this={tooltipElement}
344
- class="tooltip-v2-floating"
345
- style={tooltipStyle}
346
- role="tooltip"
347
- transition:fade={{ duration: 150 }}
348
- >
349
- <div class="tooltip-v2-content" style="max-width: {maxWidth}">
350
- {#if text}
351
- <span>{text}</span>
352
- {:else if html}
353
- {@html html}
354
- {:else if tooltipContent}
355
- {@render tooltipContent()}
356
- {/if}
357
- </div>
358
-
359
- {#if showArrow}
360
- <div bind:this={arrowElement} class="tooltip-v2-arrow" style={arrowStyle}></div>
361
- {/if}
362
- </div>
363
- {/if}
364
- </div>
365
-
366
- <style>
367
- .tooltip-v2-container {
368
- display: inline-block;
369
- }
370
-
371
- .tooltip-v2-trigger {
372
- width: auto;
373
- display: inline-block;
374
- }
375
-
376
- .tooltip-v2-floating {
377
- position: fixed;
378
- z-index: var(--pui-z-tooltip);
379
- pointer-events: none;
380
- --tooltip-bg: var(--pui-color-gray-200);
381
- --tooltip-text: var(--pui-color-gray-800);
382
- }
383
-
384
- :global(.dark) .tooltip-v2-floating {
385
- --tooltip-bg: var(--pui-color-gray-800);
386
- --tooltip-text: var(--pui-color-gray-200);
387
- }
388
-
389
- .tooltip-v2-content {
390
- background: var(--tooltip-bg);
391
- color: var(--tooltip-text);
392
- padding: var(--pui-spacing-2) var(--pui-spacing-3);
393
- border-radius: var(--pui-radius-lg);
394
- font-size: var(--pui-font-size-sm);
395
- line-height: var(--pui-line-height-normal);
396
- position: relative;
397
- }
398
-
399
- :global(.dark) .tooltip-v2-content {
400
- box-shadow: var(--pui-shadow-md);
401
- }
402
-
403
- .tooltip-v2-arrow {
404
- position: absolute;
405
- width: 0;
406
- height: 0;
407
- pointer-events: none;
408
- }
409
-
410
- /* Handle HTML content spacing */
411
- .tooltip-v2-content :global(p) {
412
- margin: 0;
413
- }
414
-
415
- .tooltip-v2-content :global(p + p) {
416
- margin-top: 0.5rem;
417
- }
418
-
419
- .tooltip-v2-content :global(ul),
420
- .tooltip-v2-content :global(ol) {
421
- margin: 0;
422
- padding-left: 1.25rem;
423
- }
424
-
425
- .tooltip-v2-content :global(code) {
426
- background: rgba(255, 255, 255, 0.1);
427
- padding: 0.125rem 0.25rem;
428
- border-radius: 3px;
429
- font-size: 0.85em;
430
- }
431
-
432
- :global(.dark) .tooltip-v2-content :global(code) {
433
- background: rgba(255, 255, 255, 0.05);
434
- }
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { fade } from 'svelte/transition';
4
+
5
+ interface Props {
6
+ /** The element to attach the tooltip to */
7
+ children: Snippet;
8
+ /** Rich content using a Svelte snippet */
9
+ tooltipContent?: Snippet;
10
+ /** Simple text content (alternative to snippet) */
11
+ text?: string;
12
+ /** HTML content (use with caution - sanitize user input) */
13
+ html?: string;
14
+ /** Preferred tooltip position */
15
+ location?: 'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end';
16
+ /** Maximum width of tooltip */
17
+ maxWidth?: string;
18
+ /** Offset distance from target element (px) */
19
+ offsetDistance?: number;
20
+ /** Show arrow pointing to target */
21
+ showArrow?: boolean;
22
+ /** Delay before showing tooltip (ms) */
23
+ delay?: number;
24
+ /** Callback when tooltip opens/closes */
25
+ onOpen?: (open: boolean) => void;
26
+ /** Disable the tooltip */
27
+ disabled?: boolean;
28
+ }
29
+
30
+ let {
31
+ children,
32
+ tooltipContent,
33
+ text,
34
+ html,
35
+ location = 'top',
36
+ maxWidth = '300px',
37
+ offsetDistance = 4,
38
+ showArrow = true,
39
+ delay = 0,
40
+ onOpen,
41
+ disabled = false
42
+ }: Props = $props();
43
+
44
+ // State
45
+ let open = $state(false);
46
+ let referenceElement: HTMLElement | null = $state(null);
47
+ let tooltipElement: HTMLElement | null = $state(null);
48
+ let arrowElement: HTMLElement | null = $state(null);
49
+ let timeoutId: number | null = null;
50
+
51
+ // Position state
52
+ let tooltipStyle = $state('');
53
+ let arrowStyle = $state('');
54
+ let actualPlacement = $state<'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end'>('top');
55
+
56
+ // Sync actualPlacement with location prop
57
+ $effect(() => {
58
+ actualPlacement = location;
59
+ });
60
+
61
+ // Check if tooltip has any content
62
+ const hasContent = $derived(!!(text || html || tooltipContent));
63
+
64
+ function handleMouseEnter() {
65
+ if (disabled || !hasContent) return;
66
+
67
+ if (delay > 0) {
68
+ timeoutId = window.setTimeout(() => {
69
+ showTooltip();
70
+ }, delay);
71
+ } else {
72
+ showTooltip();
73
+ }
74
+ }
75
+
76
+ function handleMouseLeave() {
77
+ if (timeoutId) {
78
+ clearTimeout(timeoutId);
79
+ timeoutId = null;
80
+ }
81
+ hideTooltip();
82
+ }
83
+
84
+ function showTooltip() {
85
+ open = true;
86
+ onOpen?.(true);
87
+
88
+ // Position tooltip after it's rendered
89
+ // Use multiple RAF calls and verify dimensions to ensure layout is complete
90
+ requestAnimationFrame(() => {
91
+ requestAnimationFrame(() => {
92
+ if (referenceElement && tooltipElement) {
93
+ positionTooltip();
94
+
95
+ // Verify and reposition if dimensions changed
96
+ requestAnimationFrame(() => {
97
+ if (tooltipElement) {
98
+ const rect = tooltipElement.getBoundingClientRect();
99
+ if (rect.width > 0 && rect.height > 0) {
100
+ positionTooltip();
101
+ }
102
+ }
103
+ });
104
+ }
105
+ });
106
+ });
107
+ }
108
+
109
+ function hideTooltip() {
110
+ open = false;
111
+ onOpen?.(false);
112
+ }
113
+
114
+ function positionTooltip() {
115
+ if (!referenceElement || !tooltipElement) return;
116
+
117
+ const refRect = referenceElement.getBoundingClientRect();
118
+ const tooltipRect = tooltipElement.getBoundingClientRect();
119
+ const arrowSize = showArrow ? 12 : 0;
120
+ const offset = offsetDistance + arrowSize;
121
+
122
+ let position = calculatePosition(location, refRect, tooltipRect, offset);
123
+
124
+ // Check if tooltip would overflow viewport and flip if needed
125
+ const flippedPosition = checkAndFlipPosition(position, tooltipRect, location, refRect, offset);
126
+ position = flippedPosition.position;
127
+ actualPlacement = flippedPosition.placement as typeof actualPlacement;
128
+
129
+ // Apply position
130
+ tooltipStyle = `left: ${position.left}px; top: ${position.top}px;`;
131
+
132
+ // Position arrow
133
+ if (showArrow && arrowElement) {
134
+ arrowStyle = calculateArrowPosition(actualPlacement, refRect, position);
135
+ }
136
+ }
137
+
138
+ function calculatePosition(
139
+ placement: string,
140
+ refRect: DOMRect,
141
+ tooltipRect: DOMRect,
142
+ offset: number
143
+ ): { left: number; top: number } {
144
+ let left = 0;
145
+ let top = 0;
146
+
147
+ const [side, alignment] = placement.split('-');
148
+
149
+ switch (side) {
150
+ case 'top':
151
+ top = refRect.top - tooltipRect.height - offset;
152
+ left = refRect.left + refRect.width / 2 - tooltipRect.width / 2;
153
+ if (alignment === 'start') left = refRect.left;
154
+ if (alignment === 'end') left = refRect.right - tooltipRect.width;
155
+ break;
156
+
157
+ case 'bottom':
158
+ top = refRect.bottom + offset;
159
+ left = refRect.left + refRect.width / 2 - tooltipRect.width / 2;
160
+ if (alignment === 'start') left = refRect.left;
161
+ if (alignment === 'end') left = refRect.right - tooltipRect.width;
162
+ break;
163
+
164
+ case 'left':
165
+ left = refRect.left - tooltipRect.width - offset;
166
+ top = refRect.top + refRect.height / 2 - tooltipRect.height / 2;
167
+ if (alignment === 'start') top = refRect.top;
168
+ if (alignment === 'end') top = refRect.bottom - tooltipRect.height;
169
+ break;
170
+
171
+ case 'right':
172
+ left = refRect.right + offset;
173
+ top = refRect.top + refRect.height / 2 - tooltipRect.height / 2;
174
+ if (alignment === 'start') top = refRect.top;
175
+ if (alignment === 'end') top = refRect.bottom - tooltipRect.height;
176
+ break;
177
+ }
178
+
179
+ return { left, top };
180
+ }
181
+
182
+ function checkAndFlipPosition(
183
+ position: { left: number; top: number },
184
+ tooltipRect: DOMRect,
185
+ placement: string,
186
+ refRect: DOMRect,
187
+ offset: number
188
+ ): { position: { left: number; top: number }; placement: string } {
189
+ const viewportWidth = window.innerWidth;
190
+ const viewportHeight = window.innerHeight;
191
+ const padding = 5;
192
+
193
+ let newPosition = { ...position };
194
+ let newPlacement = placement;
195
+ const [side] = placement.split('-');
196
+
197
+ // Check vertical overflow
198
+ if (side === 'top' && position.top < padding) {
199
+ // Flip to bottom
200
+ newPlacement = placement.replace('top', 'bottom');
201
+ newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
202
+ } else if (side === 'bottom' && position.top + tooltipRect.height > viewportHeight - padding) {
203
+ // Flip to top
204
+ newPlacement = placement.replace('bottom', 'top');
205
+ newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
206
+ }
207
+
208
+ // Check horizontal overflow
209
+ if (side === 'left' && position.left < padding) {
210
+ // Flip to right
211
+ newPlacement = placement.replace('left', 'right');
212
+ newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
213
+ } else if (side === 'right' && position.left + tooltipRect.width > viewportWidth - padding) {
214
+ // Flip to left
215
+ newPlacement = placement.replace('right', 'left');
216
+ newPosition = calculatePosition(newPlacement, refRect, tooltipRect, offset);
217
+ }
218
+
219
+ // Shift within bounds if still overflowing
220
+ if (newPosition.left < padding) {
221
+ newPosition.left = padding;
222
+ } else if (newPosition.left + tooltipRect.width > viewportWidth - padding) {
223
+ newPosition.left = viewportWidth - tooltipRect.width - padding;
224
+ }
225
+
226
+ if (newPosition.top < padding) {
227
+ newPosition.top = padding;
228
+ } else if (newPosition.top + tooltipRect.height > viewportHeight - padding) {
229
+ newPosition.top = viewportHeight - tooltipRect.height - padding;
230
+ }
231
+
232
+ return { position: newPosition, placement: newPlacement };
233
+ }
234
+
235
+ function calculateArrowPosition(
236
+ placement: string,
237
+ refRect: DOMRect,
238
+ tooltipPos: { left: number; top: number }
239
+ ): string {
240
+ const [side] = placement.split('-');
241
+
242
+ const refCenter = {
243
+ x: refRect.left + refRect.width / 2,
244
+ y: refRect.top + refRect.height / 2
245
+ };
246
+
247
+ const tooltipHeight = tooltipElement?.offsetHeight || 0;
248
+ const tooltipWidth = tooltipElement?.offsetWidth || 0;
249
+
250
+ // Calculate arrow center position relative to tooltip
251
+ const arrowX = refCenter.x - tooltipPos.left;
252
+ const arrowY = refCenter.y - tooltipPos.top;
253
+
254
+ switch (side) {
255
+ case 'top':
256
+ // Arrow at bottom edge, pointing down
257
+ return `
258
+ left: ${arrowX}px;
259
+ top: ${tooltipHeight}px;
260
+ border-left: 12px solid transparent;
261
+ border-right: 12px solid transparent;
262
+ border-top: 12px solid var(--tooltip-bg);
263
+ transform: translateX(-50%) translateY(-50%);
264
+ `;
265
+
266
+ case 'bottom':
267
+ // Arrow at top edge, pointing up
268
+ return `
269
+ left: ${arrowX}px;
270
+ top: 0px;
271
+ border-left: 12px solid transparent;
272
+ border-right: 12px solid transparent;
273
+ border-bottom: 12px solid var(--tooltip-bg);
274
+ transform: translateX(-50%) translateY(-50%);
275
+ `;
276
+
277
+ case 'left':
278
+ // Arrow at right edge, pointing left
279
+ return `
280
+ left: ${tooltipWidth}px;
281
+ top: ${arrowY}px;
282
+ border-top: 12px solid transparent;
283
+ border-bottom: 12px solid transparent;
284
+ border-left: 12px solid var(--tooltip-bg);
285
+ transform: translateX(-50%) translateY(-50%);
286
+ `;
287
+
288
+ case 'right':
289
+ // Arrow at left edge, pointing right
290
+ return `
291
+ left: 0px;
292
+ top: ${arrowY}px;
293
+ border-top: 12px solid transparent;
294
+ border-bottom: 12px solid transparent;
295
+ border-right: 12px solid var(--tooltip-bg);
296
+ transform: translateX(-50%) translateY(-50%);
297
+ `;
298
+
299
+ default:
300
+ return '';
301
+ }
302
+ }
303
+
304
+ function handleKeydown(event: KeyboardEvent) {
305
+ if (event.key === 'Escape' && open) {
306
+ hideTooltip();
307
+ }
308
+ }
309
+
310
+ $effect(() => {
311
+ if (open) {
312
+ window.addEventListener('keydown', handleKeydown);
313
+ window.addEventListener('scroll', positionTooltip, true);
314
+ window.addEventListener('resize', positionTooltip);
315
+
316
+ return () => {
317
+ window.removeEventListener('keydown', handleKeydown);
318
+ window.removeEventListener('scroll', positionTooltip, true);
319
+ window.removeEventListener('resize', positionTooltip);
320
+ };
321
+ }
322
+ });
323
+ </script>
324
+
325
+ <div class="tooltip-v2-container">
326
+ <!-- Reference Element -->
327
+ <div
328
+ bind:this={referenceElement}
329
+ class="tooltip-v2-trigger"
330
+ onmouseenter={handleMouseEnter}
331
+ onmouseleave={handleMouseLeave}
332
+ onfocus={handleMouseEnter}
333
+ onblur={handleMouseLeave}
334
+ role="button"
335
+ tabindex="0"
336
+ >
337
+ {@render children()}
338
+ </div>
339
+
340
+ <!-- Floating Tooltip -->
341
+ {#if open && hasContent && !disabled}
342
+ <div
343
+ bind:this={tooltipElement}
344
+ class="tooltip-v2-floating"
345
+ style={tooltipStyle}
346
+ role="tooltip"
347
+ transition:fade={{ duration: 150 }}
348
+ >
349
+ <div class="tooltip-v2-content" style="max-width: {maxWidth}">
350
+ {#if text}
351
+ <span>{text}</span>
352
+ {:else if html}
353
+ {@html html}
354
+ {:else if tooltipContent}
355
+ {@render tooltipContent()}
356
+ {/if}
357
+ </div>
358
+
359
+ {#if showArrow}
360
+ <div bind:this={arrowElement} class="tooltip-v2-arrow" style={arrowStyle}></div>
361
+ {/if}
362
+ </div>
363
+ {/if}
364
+ </div>
365
+
366
+ <style>
367
+ .tooltip-v2-container {
368
+ display: inline-block;
369
+ }
370
+
371
+ .tooltip-v2-trigger {
372
+ width: auto;
373
+ display: inline-block;
374
+ }
375
+
376
+ .tooltip-v2-floating {
377
+ position: fixed;
378
+ z-index: var(--pui-z-tooltip);
379
+ pointer-events: none;
380
+ --tooltip-bg: var(--pui-color-gray-200);
381
+ --tooltip-text: var(--pui-color-gray-800);
382
+ }
383
+
384
+ :global(.dark) .tooltip-v2-floating {
385
+ --tooltip-bg: var(--pui-color-gray-800);
386
+ --tooltip-text: var(--pui-color-gray-200);
387
+ }
388
+
389
+ .tooltip-v2-content {
390
+ background: var(--tooltip-bg);
391
+ color: var(--tooltip-text);
392
+ padding: var(--pui-spacing-2) var(--pui-spacing-3);
393
+ border-radius: var(--pui-radius-lg);
394
+ font-size: var(--pui-font-size-sm);
395
+ line-height: var(--pui-line-height-normal);
396
+ position: relative;
397
+ }
398
+
399
+ :global(.dark) .tooltip-v2-content {
400
+ box-shadow: var(--pui-shadow-md);
401
+ }
402
+
403
+ .tooltip-v2-arrow {
404
+ position: absolute;
405
+ width: 0;
406
+ height: 0;
407
+ pointer-events: none;
408
+ }
409
+
410
+ /* Handle HTML content spacing */
411
+ .tooltip-v2-content :global(p) {
412
+ margin: 0;
413
+ }
414
+
415
+ .tooltip-v2-content :global(p + p) {
416
+ margin-top: 0.5rem;
417
+ }
418
+
419
+ .tooltip-v2-content :global(ul),
420
+ .tooltip-v2-content :global(ol) {
421
+ margin: 0;
422
+ padding-left: 1.25rem;
423
+ }
424
+
425
+ .tooltip-v2-content :global(code) {
426
+ background: rgba(255, 255, 255, 0.1);
427
+ padding: 0.125rem 0.25rem;
428
+ border-radius: 3px;
429
+ font-size: 0.85em;
430
+ }
431
+
432
+ :global(.dark) .tooltip-v2-content :global(code) {
433
+ background: rgba(255, 255, 255, 0.05);
434
+ }
435
435
  </style>