From 58878748a265b08f3d7c042316e991c5bb69b4ae Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:27:58 +0300 Subject: [PATCH] Better popover --- .../web/src/lib/components/InfoPopover.svelte | 4 +- .../components/layout/SideNavUserPanel.svelte | 4 +- apps/web/src/lib/styles/vars.css | 2 + packages/components/src/Popover.svelte | 122 ++++++++++-------- packages/components/src/element-visibility.ts | 55 ++++++++ 5 files changed, 130 insertions(+), 57 deletions(-) create mode 100644 packages/components/src/element-visibility.ts diff --git a/apps/web/src/lib/components/InfoPopover.svelte b/apps/web/src/lib/components/InfoPopover.svelte index 2d8a3e9bd..06f561a87 100644 --- a/apps/web/src/lib/components/InfoPopover.svelte +++ b/apps/web/src/lib/components/InfoPopover.svelte @@ -16,9 +16,7 @@ let { children, tiny = false, class: className }: Props = $props(); diff --git a/apps/web/src/lib/components/layout/SideNavUserPanel.svelte b/apps/web/src/lib/components/layout/SideNavUserPanel.svelte index df7b8a747..1e4e9b6ef 100644 --- a/apps/web/src/lib/components/layout/SideNavUserPanel.svelte +++ b/apps/web/src/lib/components/layout/SideNavUserPanel.svelte @@ -51,9 +51,7 @@ const user = $derived(loggedInUser()); type="button" class="sideNavFooterButton" data-testid="notifications-button" - aria-expanded={triggerProps["aria-expanded"]} - aria-haspopup={triggerProps["aria-haspopup"]} - onclick={triggerProps.onclick} + {...triggerProps} > diff --git a/apps/web/src/lib/styles/vars.css b/apps/web/src/lib/styles/vars.css index 2ef18328a..83f3f633a 100644 --- a/apps/web/src/lib/styles/vars.css +++ b/apps/web/src/lib/styles/vars.css @@ -279,6 +279,8 @@ html, var(--layout-main-padding) + env(safe-area-inset-top) ); + /* Popover auto closes when scrolled above this viewport offset (under the header) */ + --popover-boundary-top: var(--layout-nav-height); /* Boxes are elements that contain other elements, like cards, containers, and modals diff --git a/packages/components/src/Popover.svelte b/packages/components/src/Popover.svelte index b10e68017..fa31e9011 100644 --- a/packages/components/src/Popover.svelte +++ b/packages/components/src/Popover.svelte @@ -1,10 +1,10 @@ - + {@render trigger(triggerProps)} -{#if open} -
setOpen(false)} aria-hidden="true">
-{/if} diff --git a/packages/components/src/element-visibility.ts b/packages/components/src/element-visibility.ts new file mode 100644 index 000000000..6f97839ed --- /dev/null +++ b/packages/components/src/element-visibility.ts @@ -0,0 +1,55 @@ +import { createSubscriber } from "svelte/reactivity"; + +export interface ElementVisibilityTarget { + element: Element; + /** viewport offset from the top treated as out of view (e.g. a sticky header) */ + marginTop?: number; + /** intersection ratio the observer reports crossings of */ + threshold?: number; +} + +// xxx: which folder it belongs to? +/** + * Reactive viewport visibility of an element, driven by an IntersectionObserver. + * + * Observation is lazy: the observer only runs while an effect reads `ratio`, + * and `getTarget` returning `null` (e.g. while a popover is closed) keeps it off. + */ +export class ElementVisibility { + #getTarget: () => ElementVisibilityTarget | null; + #subscribe: () => void; + #visibleRatio: number | null = null; + + constructor(getTarget: () => ElementVisibilityTarget | null) { + this.#getTarget = getTarget; + this.#subscribe = createSubscriber((update) => { + const target = this.#getTarget(); + if (!target) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries.at(-1); + if (!entry) return; + this.#visibleRatio = entry.intersectionRatio; + update(); + }, + { + threshold: target.threshold ?? [0, 1], + rootMargin: `${-(target.marginTop ?? 0)}px 0px 0px 0px`, + }, + ); + observer.observe(target.element); + + return () => { + observer.disconnect(); + this.#visibleRatio = null; + }; + }); + } + + /** visible intersection ratio of the target, `null` until the first measurement */ + get ratio(): number | null { + this.#subscribe(); + return this.#visibleRatio; + } +}