flotilla/src/lib/components/Tippy.svelte

110 lines
2.6 KiB
Svelte
Raw Normal View History

2026-08-29 17:20:39 +00:00
<script lang="ts" module>
import type {Maybe} from "@welshman/lib"
export type TippyController<Content = Record<string, any>> = {
show: () => void
hide: () => void
toggle: () => void
visible: boolean
rect: () => Maybe<DOMRect>
content: Maybe<Content>
}
</script>
2024-09-25 20:46:10 +00:00
<script lang="ts">
2024-10-15 19:02:11 +00:00
import "tippy.js/animations/shift-away.css"
2024-10-14 19:35:08 +00:00
2025-02-03 23:40:00 +00:00
import tippy from "tippy.js"
2026-07-28 00:13:14 +00:00
import type {Instance} from "tippy.js"
import {onMount, mount, unmount} from "svelte"
2026-08-29 17:12:09 +00:00
import {getTippyTarget, isMobile} from "@lib/html"
2024-09-25 20:46:10 +00:00
2025-02-03 23:50:19 +00:00
let {
component,
children = undefined,
props = {},
params = {},
2026-08-29 17:20:39 +00:00
controller = $bindable(),
2025-02-03 23:50:19 +00:00
...restProps
} = $props()
2026-08-29 17:20:39 +00:00
const target = document.createElement("div")
2024-09-25 20:46:10 +00:00
2026-07-03 23:48:23 +00:00
// `mount` only tracks prop changes when the props come from a `$state` object,
// so sync incoming props into a reactive one. Without this the popover keeps
// the props it was first mounted with, showing stale data after the source updates.
const mountedProps = $state({...props})
2026-08-29 17:12:09 +00:00
// Building a tippy costs a popper element and a set of listeners, which is wasted on the
// hover menus of a chat row nobody ever opens. Only a real trigger needs the instance up
2026-08-29 17:20:39 +00:00
// front — tippy is the one listening for it. A manual one can wait to be shown.
2026-08-29 17:12:09 +00:00
const create = () => {
popover ??= tippy(element, {
2025-02-03 23:50:19 +00:00
content: target,
animation: "shift-away",
2026-08-29 17:12:09 +00:00
appendTo: getTippyTarget(),
trigger: isMobile ? "click" : "mouseenter focus",
2025-02-03 23:50:19 +00:00
...params,
2026-08-29 17:20:39 +00:00
onShow: (instance: Instance) => {
content ??= mount(component, {target, props: mountedProps})
visible = true
2025-02-03 23:50:19 +00:00
2026-08-29 17:20:39 +00:00
return params.onShow?.(instance)
},
onHidden: (instance: Instance) => {
visible = false
return params.onHidden?.(instance)
2026-07-28 00:13:14 +00:00
},
})
2025-02-03 23:50:19 +00:00
2026-08-29 17:12:09 +00:00
return popover
}
2026-08-29 17:20:39 +00:00
let element: Element
let popover: Maybe<Instance>
let content: Maybe<Record<string, any>>
let visible = $state(false)
2026-08-29 17:12:09 +00:00
2026-08-29 17:20:39 +00:00
controller = {
show: () => create().show(),
hide: () => popover?.hide(),
toggle: () => {
if (visible) {
popover?.hide()
} else {
create().show()
}
},
rect: () => popover?.popper.getBoundingClientRect(),
get visible() {
return visible
},
get content() {
return content
},
}
2026-08-29 17:12:09 +00:00
$effect(() => {
Object.assign(mountedProps, props)
})
onMount(() => {
if (params.trigger !== "manual") {
create()
}
2025-02-03 23:50:19 +00:00
return () => {
popover?.destroy()
2026-07-28 00:13:14 +00:00
2026-08-29 17:20:39 +00:00
if (content) {
unmount(content)
2026-07-28 00:13:14 +00:00
}
2024-09-25 20:46:10 +00:00
}
})
</script>
2025-02-03 23:40:00 +00:00
<div bind:this={element} class={restProps.class}>
{@render children?.()}
2024-10-03 21:32:55 +00:00
</div>