mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
Better popover
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
packages/components/src/element-visibility.ts
Normal file
55
packages/components/src/element-visibility.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user