feat(popover): native Popover API component with anchored positioning
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<!--
|
||||
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?: string;
|
||||
/**
|
||||
* 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();
|
||||
let resolvedSide = $state(side);
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the `toggle` event into our state.
|
||||
*/
|
||||
function onToggle(event: Event & { newState?: string }): void {
|
||||
shown = event.newState === 'open';
|
||||
open = shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}
|
||||
|
||||
<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;"
|
||||
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>
|
||||
@@ -94,6 +94,12 @@ export {
|
||||
*/
|
||||
default as PerspectivePlan,
|
||||
} from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export {
|
||||
/**
|
||||
* Anchored popover on the native Popover API
|
||||
*/
|
||||
default as Popover,
|
||||
} from './Popover/Popover.svelte';
|
||||
export {
|
||||
/**
|
||||
* Specialized input with search icon and clear state
|
||||
|
||||
Reference in New Issue
Block a user