Better popover

This commit is contained in:
Kalle
2026-08-16 21:27:58 +03:00
parent 4e731b0d8a
commit 58878748a2
5 changed files with 130 additions and 57 deletions

View File

@@ -16,9 +16,7 @@ let { children, tiny = false, class: className }: Props = $props();
<button
type="button"
class={["trigger", className, { triggerTiny: tiny }]}
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
{...triggerProps}
>
?
</button>

View File

@@ -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}
>
<Bell />
</button>

View File

@@ -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

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { ElementVisibility } from "./element-visibility.ts";
export interface PopoverTriggerProps {
readonly "aria-expanded": boolean;
readonly popovertarget: string;
readonly "aria-haspopup": "dialog";
onclick: () => void;
}
interface Props {
@@ -17,21 +17,54 @@ interface Props {
let { trigger, popoverClass, onOpenChange, isOpen, children }: Props = $props();
const VISIBLE_RATIO_THRESHOLD = 0.98;
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = isOpen !== undefined;
let uncontrolledOpen = $state(false);
const open = $derived(isControlled ? Boolean(isOpen) : uncontrolledOpen);
let triggerContainer = $state<HTMLSpanElement | null>(null);
const uid = $props.id();
const popoverId = `${uid}-popover`;
const anchorName = `--popover-anchor-${uid}`;
let popoverElement = $state<HTMLDivElement | null>(null);
$effect(() => {
if (open) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => popoverElement?.focus());
} else {
popoverElement?.hidePopover();
if (!popoverElement) return;
if (open && !popoverElement.matches(":popover-open")) {
popoverElement.showPopover();
} else if (!open && popoverElement.matches(":popover-open")) {
popoverElement.hidePopover();
}
});
const visibility = new ElementVisibility(() => {
if (!open || !popoverElement) return null;
return {
element: popoverElement,
marginTop: popoverBoundaryTop(popoverElement),
threshold: VISIBLE_RATIO_THRESHOLD,
};
});
// a popover too tall to ever fit fully (or one measured before it is shown)
// must not close itself; only a fully visible popover that scroll clips does
let wasFullyVisible = false;
$effect(() => {
if (!open) {
wasFullyVisible = false;
return;
}
const ratio = visibility.ratio;
if (ratio === null) return;
if (ratio >= VISIBLE_RATIO_THRESHOLD) {
wasFullyVisible = true;
} else if (wasFullyVisible) {
setOpen(false);
}
});
@@ -42,69 +75,62 @@ function setOpen(next: boolean) {
onOpenChange?.(next);
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
const popoverRect = popover.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (popoverRect.height + 8 > spaceBelow && rect.top > spaceBelow) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 8}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 8}px`;
function onPopoverToggle(event: Event) {
const toggleEvent = event as ToggleEvent;
const next = toggleEvent.newState === "open";
if (next !== open) {
setOpen(next);
}
const centered = rect.left + rect.width / 2 - popoverRect.width / 2;
popover.style.left = `${Math.max(8, Math.min(centered, window.innerWidth - popoverRect.width - 8))}px`;
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
if (next) {
popoverElement?.focus();
}
}
const triggerProps: PopoverTriggerProps = {
get "aria-expanded"() {
return open;
},
popovertarget: popoverId,
"aria-haspopup": "dialog",
onclick: () => setOpen(!open),
};
function popoverBoundaryTop(element: Element) {
return (
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--popover-boundary-top"),
) || 0
);
}
</script>
<span class="triggerContainer" bind:this={triggerContainer}>
<span class="triggerContainer" style:--popover-anchor={anchorName}>
{@render trigger(triggerProps)}
</span>
<div
bind:this={popoverElement}
popover="manual"
id={popoverId}
popover="auto"
class={["content", popoverClass]}
style:position-anchor={anchorName}
role="dialog"
tabindex="-1"
onkeydown={onPopoverKeydown}
ontoggle={onPopoverToggle}
>
{@render children()}
</div>
{#if open}
<div class="backdrop" onclick={() => setOpen(false)} aria-hidden="true"></div>
{/if}
<style>
.triggerContainer {
display: contents;
> :global(*) {
anchor-name: var(--popover-anchor);
}
}
.content {
position: fixed;
margin: 0;
position-area: block-end;
position-try-fallbacks: flip-block;
justify-self: anchor-center;
margin: var(--s-2);
max-width: min(20rem, calc(100vw - var(--s-4)));
overflow: auto;
padding: var(--s-2);
@@ -117,10 +143,4 @@ const triggerProps: PopoverTriggerProps = {
color: var(--color-text);
outline: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -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;
}
}