Simplify tippy api

This commit is contained in:
Jon Staab 2026-08-29 10:20:39 -07:00
parent 749e87f228
commit a6bf874280
13 changed files with 149 additions and 146 deletions

View file

@ -1,7 +1,7 @@
<script lang="ts">
import cx from "classnames"
import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Thunks} from "@welshman/app"
import {isMobile} from "@lib/html"
@ -10,6 +10,7 @@
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import TapTarget from "@lib/components/TapTarget.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import {publishWrappedReaction, retractWrappedReaction} from "@app/reactions"
import Content from "@app/components/Content.svelte"
@ -52,17 +53,9 @@
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
const togglePopover = () => {
if (popoverIsVisible) {
popover?.hide()
} else {
showPopover()
}
}
const togglePopover = () => tippy?.toggle()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let popoverIsVisible = $state(false)
let tippy: Maybe<TippyController> = $state()
</script>
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
@ -71,20 +64,10 @@
class={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
{#if !isMobile}
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo, edit}}
params={{
interactive: true,
trigger: "manual",
onShow() {
popoverIsVisible = true
},
onHidden() {
popoverIsVisible = false
},
}}>
props={{event, pubkeys, tippy, replyTo, edit}}
params={{interactive: true, trigger: "manual"}}>
<button
type="button"
class="opacity-0 transition-all"

View file

@ -8,13 +8,13 @@
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
const {event, pubkeys, popover, replyTo, edit} = $props()
const {event, pubkeys, tippy, replyTo, edit} = $props()
const reply = () => replyTo(event)
const onEdit = () => edit?.()
const showInfo = () => {
popover.hide()
tippy.hide()
pushModal(EventInfo, {event})
}
</script>

View file

@ -1,9 +1,10 @@
<script lang="ts">
import {spec} from "@welshman/lib"
import cx from "classnames"
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import type {Instance} from "tippy.js"
import {spec} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {preventDefault} from "@lib/html"
import {createSearch} from "@welshman/app"
import {currencyOptions, displayCurrency} from "@lib/currency"
@ -12,6 +13,7 @@
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
interface Props {
value: string
@ -36,7 +38,7 @@
const selectCurrency = (code: string) => {
value = code
term.set("")
popover?.hide()
tippy?.hide()
}
const clearAndFocus = () => {
@ -46,7 +48,7 @@
}
const onKeyDown = (e: Event) => {
if (instance?.onKeyDown(e)) {
if (tippy?.content?.onKeyDown(e)) {
e.preventDefault()
}
}
@ -54,15 +56,13 @@
const currency = $derived(currencyOptions.find(spec({code: value})))
let wrapper: Element | undefined = $state()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state()
let tippy: Maybe<TippyController> = $state()
$effect(() => {
if ($term) {
showPopover()
tippy?.show()
} else {
popover?.hide()
tippy?.hide()
}
})
</script>
@ -82,9 +82,7 @@
<input {autofocus} class="grow" type="text" bind:value={$term} onkeydown={onKeyDown} />
{/if}
<Tippy
bind:popover
bind:show={showPopover}
bind:instance
bind:controller={tippy}
component={Suggestions}
props={{
term,

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js"
import type {Maybe} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
@ -9,6 +9,7 @@
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
@ -28,7 +29,9 @@
const shouldProtect = $relays.hasNip(url, 70)
const hidePopover = () => popover?.hide()
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
const onEmoji = async (emoji: NativeEmoji) => {
const protect = await shouldProtect
@ -45,8 +48,7 @@
return command.publishToRelays([url])
}
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let tippy: Maybe<TippyController> = $state()
</script>
<div class="items-center join">
@ -60,8 +62,7 @@
</EmojiButton>
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>

View file

@ -1,9 +1,10 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Instance} from "tippy.js"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileMenuList from "@app/components/ProfileMenuList.svelte"
@ -15,16 +16,16 @@
const {pubkey, url, customActions}: Props = $props()
const hidePopover = () => popover?.hide()
const showPopover = () => tippy?.show()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
const hidePopover = () => tippy?.hide()
let tippy: Maybe<TippyController> = $state()
</script>
<Button onclick={showPopover} class="button button-circle button-ghost button-sm">
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
component={ProfileMenuList}
props={{pubkey, url, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>

View file

@ -3,12 +3,14 @@
import type {Writable} from "svelte/store"
import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {decodePubkey} from "@lib/util"
import Suggestions from "@lib/components/Suggestions.svelte"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import Badge from "@lib/components/Badge.svelte"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
@ -31,7 +33,7 @@
const selectPubkey = (pubkey: string) => {
term.set("")
popover?.hide()
tippy?.hide()
value = uniq(append(pubkey, value))
}
@ -48,24 +50,22 @@
}
const onKeyDown = (e: Event) => {
if (instance?.onKeyDown(e)) {
if (tippy?.content?.onKeyDown(e)) {
e.preventDefault()
}
}
let label: Element | undefined = $state()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state()
let tippy: Maybe<TippyController> = $state()
$effect(() => {
// @ts-ignore
oninput?.($term)
if ($term) {
showPopover()
tippy?.show()
} else {
popover?.hide()
tippy?.hide()
}
})
</script>
@ -97,9 +97,7 @@
onkeydown={onKeyDown} />
</label>
<Tippy
bind:popover
bind:show={showPopover}
bind:instance
bind:controller={tippy}
component={Suggestions}
props={{
term,

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type {Instance} from "tippy.js"
import {writable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@ -9,6 +9,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
@ -52,7 +53,9 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const hidePopover = () => popover?.hide()
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
const submit = async () => {
if ($uploading) return
@ -67,8 +70,7 @@
ed.chain().clearContent().run()
}
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let tippy: Maybe<TippyController> = $state()
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
@ -124,8 +126,7 @@
onclick={showPopover}
class="join-item h-10 w-10 min-w-10 button button-neutral">
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
component={ComposeMenu}
props={{url, h, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>

View file

@ -1,48 +1,42 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {between} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
const {url, event} = $props()
const onClick = () => popover?.hide()
const open = () => tippy?.show()
const onShow = () => {
visible = true
}
const onClick = () => tippy?.hide()
const onHidden = () => {
visible = false
}
const onMouseMove = ({clientX, clientY}: MouseEvent) => {
const rect = tippy?.rect()
const onMouseMove = ({clientX, clientY}: any) => {
if (popover) {
const {x, y, width, height} = popover.popper.getBoundingClientRect()
if (rect) {
const {x, y, width, height} = rect
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
popover.hide()
tippy!.hide()
}
}
}
let popover: Instance | undefined = $state()
let open: () => void = $state(() => {})
let visible = $state(false)
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={visible ? onMouseMove : undefined} />
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<Button onclick={open} class="button button-xs button-neutral join-item">
<Tippy
bind:popover
bind:show={open}
bind:controller={tippy}
component={RoomItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true, onShow, onHidden}}>
params={{trigger: "manual", interactive: true}}>
<Icon icon={MenuDots} size={4} />
</Tippy>
</Button>

View file

@ -3,6 +3,7 @@
import type {Writable} from "svelte/store"
import type {Instance} from "tippy.js"
import {remove, reject, spec, uniq} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {Topics, createSearch} from "@welshman/app"
import {normalizeTopic} from "@lib/util"
import Suggestions from "@lib/components/Suggestions.svelte"
@ -10,6 +11,7 @@
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import TopicSuggestion from "@app/components/TopicSuggestion.svelte"
import {app} from "@app/core"
@ -41,7 +43,7 @@
}
term.set("")
popover?.hide()
tippy?.hide()
}
const removeTopic = (topic: string) => {
@ -49,7 +51,7 @@
}
const onKeyDown = (e: KeyboardEvent) => {
if (instance?.onKeyDown(e)) {
if (tippy?.content?.onKeyDown(e)) {
e.preventDefault()
return
}
@ -62,19 +64,17 @@
const onBlur = () => {
term.set("")
popover?.hide()
tippy?.hide()
}
let label: Element | undefined = $state()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state()
let tippy: Maybe<TippyController> = $state()
$effect(() => {
if ($term.trim()) {
showPopover()
tippy?.show()
} else {
popover?.hide()
tippy?.hide()
}
})
</script>
@ -101,9 +101,7 @@
onblur={onBlur} />
</label>
<Tippy
bind:popover
bind:show={showPopover}
bind:instance
bind:controller={tippy}
component={Suggestions}
props={{
term,

View file

@ -1,51 +1,45 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import {between, throttle} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
const {tippyParams = {}, ...props} = $props()
const open = () => tippy?.show()
const onClick = (emoji: NativeEmoji) => {
props.onEmoji(emoji)
popover?.hide()
}
const onShow = () => {
visible = true
}
const onHidden = () => {
visible = false
tippy?.hide()
}
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
if (popover) {
const {x, y, width, height} = popover.popper.getBoundingClientRect()
const rect = tippy?.rect()
if (rect) {
const {x, y, width, height} = rect
if (!between([x, x + width], clientX) || !between([y - 100, y + height + 100], clientY)) {
popover.hide()
tippy!.hide()
}
}
})
let popover: Instance | undefined = $state()
let open: () => void = $state(() => {})
let visible = $state(false)
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={visible ? onMouseMove : undefined} />
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<Button onclick={open} class={props.class}>
<Tippy
bind:popover
bind:show={open}
bind:controller={tippy}
class="flex"
component={EmojiPicker}
props={{onClick}}
params={{trigger: "manual", interactive: true, ...tippyParams, onShow, onHidden}}>
params={{trigger: "manual", interactive: true, ...tippyParams}}>
{@render props.children?.()}
</Tippy>
</Button>

View file

@ -1,9 +1,10 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {between, throttle} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {isMobile} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import IconPickerModal from "@app/components/IconPickerModal.svelte"
import IconPickerPopover from "@app/components/IconPickerPopover.svelte"
import {pushModal, popModal} from "@app/modal"
@ -14,7 +15,7 @@
if (isMobile) {
pushModal(IconPickerModal, {onSelect: onClick}, {nested: true})
} else {
showPopover()
tippy?.show()
}
}
@ -22,7 +23,7 @@
if (isMobile) {
popModal()
} else {
popover?.hide()
tippy?.hide()
}
}
@ -31,25 +32,21 @@
close()
}
const onMouseMove = throttle(300, ({clientX, clientY}: any) => {
if (popover) {
const {x, width} = popover.popper.getBoundingClientRect()
const onMouseMove = throttle(300, ({clientX}: MouseEvent) => {
const rect = tippy?.rect()
if (!between([x - 50, x + width + 50], clientX)) {
popover.hide()
}
if (rect && !between([rect.x - 50, rect.x + rect.width + 50], clientX)) {
tippy!.hide()
}
})
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={onMouseMove} />
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
component={IconPickerPopover}
props={{onSelect: onClick}}
params={{trigger: "manual", interactive: true, placement: "top-end"}}>

View file

@ -1,9 +1,10 @@
<script lang="ts">
import type {Component} from "svelte"
import type {Instance} from "tippy.js"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
@ -22,16 +23,16 @@
"aria-label": ariaLabel,
}: Props = $props()
const hidePopover = () => popover?.hide()
const showPopover = () => tippy?.show()
let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
const hidePopover = () => tippy?.hide()
let tippy: Maybe<TippyController> = $state()
</script>
<Button class={className} aria-label={ariaLabel} onclick={showPopover}>
<Tippy
bind:popover
bind:show={showPopover}
bind:controller={tippy}
{component}
props={{...componentProps, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>

View file

@ -1,3 +1,16 @@
<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>
<script lang="ts">
import "tippy.js/animations/shift-away.css"
@ -11,13 +24,11 @@
children = undefined,
props = {},
params = {},
popover = $bindable(),
instance = $bindable(),
show = $bindable(),
controller = $bindable(),
...restProps
} = $props()
let element: Element
const target = document.createElement("div")
// `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
@ -26,7 +37,7 @@
// 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
// front — tippy is the one listening for it. A manual one can wait for `show`.
// front — tippy is the one listening for it. A manual one can wait to be shown.
const create = () => {
popover ??= tippy(element, {
content: target,
@ -34,19 +45,45 @@
appendTo: getTippyTarget(),
trigger: isMobile ? "click" : "mouseenter focus",
...params,
onShow: (tippyInstance: Instance) => {
instance ??= mount(component, {target, props: mountedProps})
onShow: (instance: Instance) => {
content ??= mount(component, {target, props: mountedProps})
visible = true
return params.onShow?.(tippyInstance)
return params.onShow?.(instance)
},
onHidden: (instance: Instance) => {
visible = false
return params.onHidden?.(instance)
},
})
return popover
}
const target = document.createElement("div")
let element: Element
let popover: Maybe<Instance>
let content: Maybe<Record<string, any>>
let visible = $state(false)
show = () => create().show()
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
},
}
$effect(() => {
Object.assign(mountedProps, props)
@ -60,8 +97,8 @@
return () => {
popover?.destroy()
if (instance) {
unmount(instance)
if (content) {
unmount(content)
}
}
})