Feature/popover #48

Merged
ilia merged 10 commits from feature/popover into main 2026-06-02 18:47:18 +00:00
2 changed files with 202 additions and 0 deletions
Showing only changes of commit 9e0c8f740b - Show all commits
+196
View File
@@ -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>
+6
View File
@@ -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