Files
windmill/frontend/src/lib/components/meltComponents/Tooltip.svelte
T
Guilhem b718ea8495 fix: anchor overlays to their preview tab instead of the viewport (#10477)
* fix: anchor overlays to their host pane and mute them when hidden

* fix: portal hosted modals and menus into the pane they anchor to

* fix: keep non-listening dialogs off the overlay stack

* fix: drop the topmost gate from confirmation dialogs

* fix: silence overlays in a collapsed preview panel

* fix: keep overlays live in a full-screen preview of a collapsed session
2026-08-03 18:51:46 +00:00

142 lines
4.2 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte'
import type { Placement } from '@floating-ui/core'
import { InfoIcon } from 'lucide-svelte'
import { zIndexes } from '$lib/zIndexes'
import { createTooltip, melt } from '@melt-ui/svelte'
import { overlayPortalTarget } from '$lib/components/common/overlayHost.svelte'
import { fade } from 'svelte/transition'
import TooltipInner from '../TooltipInner.svelte'
interface Props {
light?: boolean
placement?: Placement | undefined
documentationLink?: string | undefined
small?: boolean
disablePopup?: boolean
openDelay?: number
closeDelay?: number
portal?: string | undefined | null
customBgClass?: string | undefined
style?: string
class?: string
// 'cursor' anchors the popup to the pointer position where hover started,
// instead of the trigger element's box. Useful for wide/full-width triggers
// where an element-anchored tooltip lands far from the cursor.
anchor?: 'element' | 'cursor'
children?: import('svelte').Snippet
text?: import('svelte').Snippet
}
let {
light = false,
placement = 'bottom',
documentationLink = undefined,
small = false,
disablePopup = false,
openDelay = 300,
closeDelay = 0,
portal = undefined,
customBgClass = undefined,
style = '',
class: className = '',
anchor = 'element',
children,
text
}: Props = $props()
// Overlays belong to the enclosing pane when there is one — see overlayHost.
// `null` is melt's "render in place" and must survive; only an absent prop defers to the host.
const hostPortal = overlayPortalTarget('body')
const effectivePortal = () => (portal === undefined ? hostPortal() : portal)
const {
elements: { trigger, content },
states: { open },
options: { portal: portalOption }
} = createTooltip({
positioning: {
placement: untrack(() => placement)
},
openDelay: untrack(() => openDelay),
closeDelay: untrack(() => closeDelay),
group: true,
portal: untrack(() => effectivePortal())
})
$effect(() => {
$portalOption = effectivePortal()
})
// Cursor anchoring: floating-ui positions against `reference.getBoundingClientRect()`.
// melt uses the trigger element as that reference, so we override its rect to a
// zero-size box at the pointer. The coords are frozen while the tooltip is open so
// it stays put (letting the pointer travel into the popup to reach the copy button);
// they only track the pointer while closed, capturing where the next open will land.
let triggerEl = $state<HTMLElement | undefined>(undefined)
let cursorX = 0
let cursorY = 0
// Until a pointer is seen, fall back to the element rect so keyboard-focus opens
// don't land the popup at (0, 0).
let hasCursor = false
$effect(() => {
if (anchor !== 'cursor' || !triggerEl) return
const el = triggerEl
// Listeners added imperatively (not `onpointermove` attrs) so the static span
// keeps no interaction handlers, avoiding an a11y_no_static_element warning.
const track = (e: PointerEvent) => {
if ($open) return
cursorX = e.clientX
cursorY = e.clientY
hasCursor = true
}
el.addEventListener('pointerenter', track)
el.addEventListener('pointermove', track)
const original = el.getBoundingClientRect.bind(el)
el.getBoundingClientRect = () =>
hasCursor
? ({
width: 0,
height: 0,
x: cursorX,
y: cursorY,
top: cursorY,
left: cursorX,
right: cursorX,
bottom: cursorY,
toJSON() {}
} as DOMRect)
: original()
return () => {
el.removeEventListener('pointerenter', track)
el.removeEventListener('pointermove', track)
el.getBoundingClientRect = original
}
})
</script>
<span bind:this={triggerEl} class={className} {style} use:melt={$trigger}>
{@render children?.()}
</span>
{#if !children}
<div
class="inline-flex w-3 mx-0.5 h-3 {light
? 'text-primary-inverse'
: 'text-primary'} {className} "
use:melt={$trigger}
>
<InfoIcon size={small ? 12 : 14} />
</div>
{/if}
{#if $open && !disablePopup}
<div use:melt={$content} transition:fade={{ duration: 100 }} style="z-index: {zIndexes.tooltip}">
<TooltipInner {documentationLink} {customBgClass}>
{#snippet children()}
{@render text?.()}
{/snippet}
</TooltipInner>
</div>
{/if}