From d97cd56867c83ceea37d17d47b270d986bd9735c Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:28:30 -0600 Subject: [PATCH] fix(ui): popover exit ux --- packages/ui/src/components/popover.tsx | 100 ++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx index 1c6588250..9644c8741 100644 --- a/packages/ui/src/components/popover.tsx +++ b/packages/ui/src/components/popover.tsx @@ -1,5 +1,15 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { ComponentProps, JSXElement, ParentProps, Show, splitProps, ValidComponent } from "solid-js" +import { + ComponentProps, + JSXElement, + ParentProps, + Show, + createEffect, + createSignal, + onCleanup, + splitProps, + ValidComponent, +} from "solid-js" import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" @@ -27,17 +37,96 @@ export function Popover(props: PopoverProps "description", "class", "classList", + "style", "children", "portal", + "open", + "defaultOpen", + "onOpenChange", + "modal", ]) + const [contentRef, setContentRef] = createSignal(undefined) + const [triggerRef, setTriggerRef] = createSignal(undefined) + const [dismiss, setDismiss] = createSignal<"escape" | "outside" | null>(null) + + const [uncontrolledOpen, setUncontrolledOpen] = createSignal(local.defaultOpen ?? false) + + const controlled = () => local.open !== undefined + const opened = () => { + if (controlled()) return local.open ?? false + return uncontrolledOpen() + } + + const onOpenChange = (next: boolean) => { + if (next) setDismiss(null) + if (local.onOpenChange) local.onOpenChange(next) + if (controlled()) return + setUncontrolledOpen(next) + } + + createEffect(() => { + if (!opened()) return + + const inside = (node: Node | null | undefined) => { + if (!node) return false + const content = contentRef() + if (content && content.contains(node)) return true + const trigger = triggerRef() + if (trigger && trigger.contains(node)) return true + return false + } + + const close = (reason: "escape" | "outside") => { + setDismiss(reason) + onOpenChange(false) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + close("escape") + event.preventDefault() + event.stopPropagation() + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (inside(target)) return + close("outside") + } + + const onFocusIn = (event: FocusEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (inside(target)) return + close("outside") + } + + window.addEventListener("keydown", onKeyDown, true) + window.addEventListener("pointerdown", onPointerDown, true) + window.addEventListener("focusin", onFocusIn, true) + + onCleanup(() => { + window.removeEventListener("keydown", onKeyDown, true) + window.removeEventListener("pointerdown", onPointerDown, true) + window.removeEventListener("focusin", onFocusIn, true) + }) + }) + const content = () => ( setContentRef(el)} data-component="popover-content" classList={{ ...(local.classList ?? {}), [local.class ?? ""]: !!local.class, }} + style={local.style} + onCloseAutoFocus={(event: Event) => { + if (dismiss() === "outside") event.preventDefault() + setDismiss(null) + }} > {/* */} @@ -60,8 +149,13 @@ export function Popover(props: PopoverProps ) return ( - - + + setTriggerRef(el)} + as={local.triggerAs ?? "div"} + data-slot="popover-trigger" + {...(local.triggerProps as any)} + > {local.trigger}