Files
frontend-svelte/src/shared/ui/Popover/Popover.svelte
T

218 lines
6.0 KiB
Svelte
Raw Normal View History

<!--
Component: Popover
Anchored popover on the native Popover API (top-layer, light-dismiss, ESC,
focus return handled by the browser). Placement is computed by the pure
`popover-position` module and applied as fixed coordinates; it repositions
on scroll/resize/content-resize. `open` is two-way bindable. The trigger is
consumer-rendered via the `trigger` snippet, which spreads a props object
(an attachment captures the trigger element; `popovertarget` wires the
native invoker). `children` receives `close()` to dismiss programmatically.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import {
type Align,
type Side,
computePosition,
} from './popover-position';
interface Props {
/**
* Open state (two-way bindable)
* @default false
*/
open?: boolean;
/**
* Preferred side
* @default 'bottom'
*/
side?: Side;
/**
* Cross-axis alignment
* @default 'center'
*/
align?: Align;
/**
* Gap between trigger and content (px)
* @default 0
*/
sideOffset?: number;
/**
* CSS classes applied to the content element
*/
class?: string;
/**
* ARIA role for the content
* @default 'dialog'
*/
role?: 'dialog' | 'menu' | 'listbox';
/**
* Trigger snippet — spread the provided props onto your trigger element
*/
trigger: Snippet<[Record<string, unknown>]>;
/**
* Content snippet — receives `close()` for programmatic dismissal
*/
children: Snippet<[{ close: () => void }]>;
}
let {
open = $bindable(false),
side = 'bottom',
align = 'center',
sideOffset = 0,
class: className,
role = 'dialog',
trigger,
children,
}: Props = $props();
const uid = $props.id();
const contentId = `popover-${uid}`;
let triggerEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
/**
* Side actually used after flip. Seeded from the `side` prop; the authoritative
* value is written by updatePosition() on every open, so the seed only matters
* for the closed state (hence the intentional state_referenced_locally warning).
*/
let resolvedSide = $state(side);
/**
* True once updatePosition has applied coordinates for the current open.
* Gates visibility so the content never paints at its pre-positioned (0,0)
* top-layer default before the first measurement.
*/
let positioned = $state(false);
/**
* Actual DOM open state, driven by the `toggle` event. Source of truth for
* whether the browser currently shows the popover; `open` is the public binding.
*/
let shown = $state(false);
/**
* Stable attachment that captures the consumer's trigger element for measuring.
* Created once so spreading reactive `triggerProps` doesn't re-run it.
*/
const attachKey = createAttachmentKey();
const attachTrigger = (node: HTMLElement) => {
triggerEl = node;
return () => {
if (triggerEl === node) {
triggerEl = undefined;
}
};
};
const triggerProps = $derived({
popovertarget: contentId,
'aria-haspopup': role,
'aria-expanded': open,
'aria-controls': contentId,
[attachKey]: attachTrigger,
});
/**
* Recompute and apply the fixed-position coordinates.
*/
function updatePosition(): void {
if (!triggerEl || !contentEl) {
return;
}
const result = computePosition({
triggerRect: triggerEl.getBoundingClientRect(),
contentRect: { width: contentEl.offsetWidth, height: contentEl.offsetHeight },
viewport: { width: window.innerWidth, height: window.innerHeight },
side,
align,
sideOffset,
});
resolvedSide = result.side;
contentEl.style.left = `${result.x}px`;
contentEl.style.top = `${result.y}px`;
positioned = true;
}
/**
* Mirror the `toggle` event into our state.
*/
function onToggle(event: ToggleEvent): void {
shown = event.newState === 'open';
open = shown;
if (!shown) {
positioned = false;
}
}
/**
* Programmatic dismiss for the content snippet.
*/
function close(): void {
open = false;
}
// state -> browser: open the popover when `open` flips true and it isn't shown,
// and close it when `open` flips false while shown. `shown` (from toggle) breaks
// the loop so we never call show/hide redundantly.
$effect(() => {
const el = contentEl;
if (!el) {
return;
}
if (open && !shown) {
el.showPopover();
} else if (!open && shown) {
el.hidePopover();
}
});
// Position while shown; reposition on scroll/resize/content-resize; auto-clean.
$effect(() => {
if (!shown || !contentEl || !triggerEl) {
return;
}
updatePosition();
const observer = new ResizeObserver(() => updatePosition());
observer.observe(contentEl);
const onScroll = () => updatePosition();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
observer.disconnect();
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
});
</script>
{@render trigger(triggerProps)}
<!--
inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
inset:0; margin:auto to center it) so the JS-applied left/top win.
visibility is hidden until updatePosition runs (see `positioned`).
-->
<div
bind:this={contentEl}
id={contentId}
popover="auto"
{role}
data-side={resolvedSide}
data-state={shown ? 'open' : 'closed'}
ontoggle={onToggle}
style={`position: fixed; inset: auto; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn(
'opacity-0 scale-95 transition-discrete transition-all duration-fast',
'starting:opacity-0 starting:scale-95',
'[&:popover-open]:opacity-100 [&:popover-open]:scale-100',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top',
className,
)}
>
{@render children({ close })}
</div>