Feature/popover #48
@@ -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,
|
default as PerspectivePlan,
|
||||||
} from './PerspectivePlan/PerspectivePlan.svelte';
|
} from './PerspectivePlan/PerspectivePlan.svelte';
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* Anchored popover on the native Popover API
|
||||||
|
*/
|
||||||
|
default as Popover,
|
||||||
|
} from './Popover/Popover.svelte';
|
||||||
export {
|
export {
|
||||||
/**
|
/**
|
||||||
* Specialized input with search icon and clear state
|
* Specialized input with search icon and clear state
|
||||||
|
|||||||
Reference in New Issue
Block a user