Compare commits

...

10 Commits

Author SHA1 Message Date
Ilia Mashkov cbd95350bb fix(popover): stop animating left/top so first open doesn't slide from corner
Workflow / build (pull_request) Successful in 1m18s
Workflow / e2e (pull_request) Successful in 1m15s
Workflow / publish (pull_request) Has been skipped
2026-06-02 21:38:48 +03:00
Ilia Mashkov a8a985ee6a chore: remove bits-ui dependency 2026-06-02 17:08:58 +03:00
Ilia Mashkov be073286dc refactor(typography-menu): use native Popover instead of bits-ui 2026-06-02 16:28:05 +03:00
Ilia Mashkov 7798c4bbdf refactor(combo-control): use native Popover instead of bits-ui
The native Popover always renders its content (the vertical slider), so the
slider's value label is in the DOM even when closed, and opening is driven by
the browser's declarative popovertarget invoker (not simulated by jsdom on
click). Update the tests to scope value assertions to the trigger and drive
open via showPopover(), matching Popover.svelte.test.ts.
2026-06-02 16:21:32 +03:00
Ilia Mashkov 3ae22ad515 docs(popover): add storybook stories 2026-06-02 16:16:28 +03:00
Ilia Mashkov ffa897ee54 test(popover): cover open/close state and aria wiring 2026-06-02 16:13:40 +03:00
Ilia Mashkov 93c52dd132 fix(popover): gate visibility until positioned, tighten types 2026-06-02 16:12:11 +03:00
Ilia Mashkov 9e0c8f740b feat(popover): native Popover API component with anchored positioning 2026-06-02 16:06:20 +03:00
Ilia Mashkov b1b5177e02 test: add jsdom Popover API shim 2026-06-02 16:03:54 +03:00
Ilia Mashkov ef9cd33e48 feat(popover): add pure anchored-positioning math 2026-06-02 15:59:58 +03:00
13 changed files with 839 additions and 197 deletions
-1
View File
@@ -44,7 +44,6 @@
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"bits-ui": "2.18.1",
"clsx": "^2.1.1",
"dprint": "0.54.0",
"jsdom": "29.1.1",
@@ -16,11 +16,11 @@ import {
Button,
ComboControl,
ControlGroup,
Popover,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -73,33 +73,21 @@ $effect(() => {
{#if !hidden}
{#if responsive.isMobileOrTablet}
<div class={className}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover bind:open side="top" align="end" sideOffset={8}>
{#snippet trigger(props)}
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
<Popover.Portal>
<Popover.Content
side="top"
align="end"
sideOffset={8}
{#snippet children({ close })}
<div
class={cn(
'z-50 w-72 p-4 rounded-none',
'w-72 p-4 rounded-none',
'surface-popover',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=top]:slide-in-from-bottom-2',
'data-[side=bottom]:slide-in-from-top-2',
)}
interactOutsideBehavior="close"
escapeKeydownBehavior="close"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
@@ -111,17 +99,13 @@ $effect(() => {
CONTROLS
</span>
</div>
<Popover.Close>
{#snippet child({ props })}
<button
{...props}
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
{/snippet}
</Popover.Close>
<button
onclick={close}
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
</div>
<!-- Controls -->
@@ -135,9 +119,9 @@ $effect(() => {
/>
</ControlGroup>
{/each}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
{/snippet}
</Popover>
</div>
{:else}
<div
+50 -52
View File
@@ -6,11 +6,13 @@
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { cn } from '$shared/lib';
import { Slider } from '$shared/ui';
import {
Popover,
Slider,
} from '$shared/ui';
import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui';
import TechText from '../TechText/TechText.svelte';
interface Props {
@@ -114,59 +116,55 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- Trigger -->
<div class="relative mx-1">
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'flex flex-col flex-center w-14 py-1',
'select-none rounded-none transition-all duration-fast',
'border border-transparent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
open
? 'surface-card-elevated'
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
)}
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
>
<!-- Label row -->
{#if displayLabel}
<span
class="
text-3xs text-label-mono
text-neutral-900 dark:text-neutral-100
mb-0.5 leading-none
"
>
{displayLabel}
</span>
{/if}
<Popover bind:open side="top" align="center">
{#snippet trigger(props)}
<button
{...props}
class={cn(
'flex flex-col flex-center w-14 py-1',
'select-none rounded-none transition-all duration-fast',
'border border-transparent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
open
? 'surface-card-elevated'
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
)}
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
>
<!-- Label row -->
{#if displayLabel}
<span
class="
text-3xs text-label-mono
text-neutral-900 dark:text-neutral-100
mb-0.5 leading-none
"
>
{displayLabel}
</span>
{/if}
<!-- Value row -->
<TechText variant="muted" size="md">
{formattedValue()}
</TechText>
</button>
{/snippet}
</Popover.Trigger>
<!-- Value row -->
<TechText variant="muted" size="md">
{formattedValue()}
</TechText>
</button>
{/snippet}
<!-- Vertical slider popover -->
<Popover.Content
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
align="center"
side="top"
>
<Slider
class="h-full"
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
orientation="vertical"
/>
</Popover.Content>
</Popover.Root>
{#snippet children()}
<div class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated">
<Slider
class="h-full"
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
orientation="vertical"
/>
</div>
{/snippet}
</Popover>
</div>
<!-- Increase button -->
@@ -4,6 +4,7 @@ import {
render,
screen,
waitFor,
within,
} from '@testing-library/svelte';
import ComboControl from './ComboControl.svelte';
@@ -16,6 +17,16 @@ function makeControl(value: number, opts: { min?: number; max?: number; step?: n
});
}
/**
* The trigger is the button wired to the popover (has popovertarget). The native
* Popover always renders its content (the vertical slider, which also displays the
* value) in the DOM, so value assertions must be scoped to the trigger to avoid
* matching the slider's own value label.
*/
function getTrigger(): HTMLElement {
return document.querySelector('button[popovertarget]') as HTMLElement;
}
describe('ComboControl', () => {
describe('Rendering', () => {
it('renders decrease and increase buttons', () => {
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
it('renders the current integer value', () => {
render(ComboControl, { control: makeControl(42) });
expect(screen.getByText('42')).toBeInTheDocument();
expect(within(getTrigger()).getByText('42')).toBeInTheDocument();
});
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
expect(screen.getByText('1.5')).toBeInTheDocument();
expect(within(getTrigger()).getByText('1.5')).toBeInTheDocument();
});
it('formats decimal value to 2 decimal places when step < 0.1', () => {
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
expect(screen.getByText('1.55')).toBeInTheDocument();
expect(within(getTrigger()).getByText('1.55')).toBeInTheDocument();
});
it('renders label when label prop is provided', () => {
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
const control = makeControl(50);
render(ComboControl, { control });
await fireEvent.click(screen.getByLabelText('Increase'));
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
});
});
describe('Popover', () => {
it('opens popover with vertical slider on trigger click', async () => {
/**
* The native Popover always renders its content; opening is driven by the
* browser's declarative popovertarget invoker, which jsdom does not simulate
* on click (mirrors Popover.svelte.test.ts). So assert the wired-but-closed
* state, then drive the open through the API the browser would call.
*/
it('exposes a popover trigger with the vertical slider as its content', async () => {
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
await fireEvent.click(screen.getByText('Size control'));
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
const trigger = getTrigger();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
const content = document.getElementById(trigger.getAttribute('popovertarget')!) as HTMLElement;
expect(content).toHaveAttribute('data-state', 'closed');
// The vertical slider lives inside the popover content. While closed the
// content is visibility:hidden, so query including hidden elements.
expect(within(content).getByRole('slider', { hidden: true })).toBeInTheDocument();
content.showPopover();
await waitFor(() => expect(content).toHaveAttribute('data-state', 'open'));
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});
@@ -0,0 +1,117 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Popover from './Popover.svelte';
const { Story } = defineMeta({
title: 'Shared/Popover',
component: Popover,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Anchored popover on the native Popover API (top-layer, light-dismiss, ESC, focus return). Hand-rolled side/align/offset positioning with flip + shift.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
side: {
control: 'select',
options: ['top', 'bottom', 'left', 'right'],
description: 'Preferred side',
},
align: {
control: 'select',
options: ['start', 'center', 'end'],
description: 'Cross-axis alignment',
},
sideOffset: {
control: 'number',
description: 'Gap between trigger and content (px)',
},
},
});
</script>
<script lang="ts">
import { Slider } from '$shared/ui';
let open = $state(false);
let value = $state(50);
</script>
<Story name="Bottom">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="bottom" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
{/snippet}
{#snippet children()}
<div class="surface-popover p-4 w-56">Popover content</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<Story name="Top">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
{/snippet}
{#snippet children()}
<div class="surface-popover p-4 w-56">Popover content</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<!--
Mirrors TypographyMenu: top/end placement with a programmatic Close button
wired to the `close()` param of the children snippet.
-->
<Story name="AlignedEnd">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="end" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Open menu</button>
{/snippet}
{#snippet children({ close })}
<div class="surface-popover p-4 w-72">
<h3 class="text-sm font-medium mb-3">Menu header</h3>
<p class="text-sm text-muted-foreground mb-4">
Aligned to the trigger's end edge.
</p>
<button class="surface-card-elevated px-3 py-1.5 text-sm" onclick={close}>
Close
</button>
</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
<!-- Mirrors ComboControl: a vertical Slider lives inside the popover content. -->
<Story name="WithSlider">
{#snippet template()}
<div class="p-32 flex-center min-h-screen">
<Popover bind:open side="top" align="center" sideOffset={8}>
{#snippet trigger(props)}
<button {...props} class="surface-card-elevated px-4 py-2">Adjust value</button>
{/snippet}
{#snippet children()}
<div class="surface-card-elevated p-3 h-64 flex-center">
<Slider orientation="vertical" min={0} max={100} bind:value />
</div>
{/snippet}
</Popover>
</div>
{/snippet}
</Story>
+225
View File
@@ -0,0 +1,225 @@
<!--
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);
/**
* Resolved fixed-position coordinates. Applied through the reactive `style`
* attribute (not imperatively) so they can't be wiped when the attribute
* re-renders — mixing the two caused a one-frame top-left flash.
*/
let x = $state(0);
let y = $state(0);
/**
* 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;
x = result.x;
y = result.y;
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; left: ${x}px; top: ${y}px; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn(
'opacity-0 scale-95 transition-discrete transition-[opacity,transform] 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>
@@ -0,0 +1,49 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import Harness from './PopoverHarness.svelte';
/**
* Resolve the popover content element (the [popover] ancestor of the test content).
*/
function getContent(): HTMLElement {
return screen.getByTestId('content').closest('[popover]') as HTMLElement;
}
describe('Popover', () => {
it('renders the trigger with aria wiring, closed by default', () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('popovertarget');
expect(getContent()).toHaveAttribute('data-state', 'closed');
});
it('opens via the popover toggle and syncs aria-expanded + data-state', async () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
// jsdom does not auto-invoke popovertarget; call the API the browser would.
getContent().showPopover();
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
it('opens when the parent sets open=true (state -> browser)', async () => {
render(Harness, { open: true });
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
});
it('close() hides the popover and resets aria-expanded', async () => {
render(Harness, { open: true });
await Promise.resolve();
const trigger = screen.getByRole('button', { name: 'Open' });
await fireEvent.click(screen.getByTestId('close'));
expect(getContent()).toHaveAttribute('data-state', 'closed');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
@@ -0,0 +1,21 @@
<!--
Component: PopoverHarness
Test-only fixture: renders Popover with a button trigger and simple content
exposing the close() callback.
-->
<script lang="ts">
import Popover from './Popover.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
</script>
<Popover bind:open>
{#snippet trigger(props)}
<button {...props}>Open</button>
{/snippet}
{#snippet children({ close })}
<div data-testid="content">
<button onclick={close} data-testid="close">Close</button>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,119 @@
import {
type Align,
type Side,
computePosition,
} from './popover-position';
/**
* Build a DOMRect-like object (jsdom/node has no layout).
*/
function rect(x: number, y: number, width: number, height: number): DOMRect {
return {
x,
y,
width,
height,
top: y,
left: x,
right: x + width,
bottom: y + height,
toJSON: () => ({}),
} as DOMRect;
}
const viewport = { width: 1000, height: 800 };
const content = { width: 200, height: 100 };
function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
}
describe('computePosition', () => {
it('places below the trigger for side="bottom"', () => {
const r = compute('bottom', 'center');
expect(r.side).toBe('bottom');
expect(r.y).toBe(440); // trigger.bottom (400+40)
});
it('places above the trigger for side="top"', () => {
const r = compute('top', 'center');
expect(r.side).toBe('top');
expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
});
it('applies sideOffset on the main axis', () => {
const r = compute('bottom', 'center', 8);
expect(r.y).toBe(448);
});
it('aligns center on the cross axis (vertical side)', () => {
const r = compute('bottom', 'center');
// trigger center x = 450; content half = 100 -> 350
expect(r.x).toBe(350);
});
it('aligns start and end on the cross axis (vertical side)', () => {
expect(compute('bottom', 'start').x).toBe(400); // trigger.left
expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
});
it('places left/right with vertical cross-axis alignment', () => {
const right = compute('right', 'start');
expect(right.side).toBe('right');
expect(right.x).toBe(500); // trigger.right
expect(right.y).toBe(400); // trigger.top (align start)
const left = compute('left', 'center');
expect(left.side).toBe('left');
expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
});
it('flips top->bottom when there is no room above', () => {
const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
const r = computePosition({
triggerRect: nearTop,
contentRect: content,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('bottom');
expect(r.y).toBe(60); // nearTop.bottom
});
it('does NOT flip when neither side fits (keeps requested side)', () => {
const tall = { width: 200, height: 700 };
const r = computePosition({
triggerRect: rect(400, 400, 100, 40),
contentRect: tall,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('top');
});
it('shifts on the cross axis to stay within the viewport', () => {
const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
const r = computePosition({
triggerRect: nearRight,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
const nearLeft = rect(10, 400, 40, 40);
const r2 = computePosition({
triggerRect: nearLeft,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r2.x).toBe(0); // clamped to 0
});
});
+149
View File
@@ -0,0 +1,149 @@
import { clampNumber } from '$shared/lib/utils';
/**
* Side of the trigger the content prefers to open toward.
*/
export type Side = 'top' | 'bottom' | 'left' | 'right';
/**
* Cross-axis alignment of the content relative to the trigger.
*/
export type Align = 'start' | 'center' | 'end';
/**
* Inputs for a single placement computation. All geometry is injected
* (no DOM reads) so the function stays pure and unit-testable.
*/
type ComputeArgs = {
/**
* Trigger bounding rect (viewport coordinates).
*/
triggerRect: DOMRect;
/**
* Measured content size.
*/
contentRect: { width: number; height: number };
/**
* Viewport size.
*/
viewport: { width: number; height: number };
/**
* Preferred side.
*/
side: Side;
/**
* Cross-axis alignment.
*/
align: Align;
/**
* Gap between trigger and content on the main axis.
*/
sideOffset: number;
};
/**
* Resolved placement: fixed-position coordinates plus the side actually used
* (may differ from the requested side after a flip).
*/
type ComputeResult = { x: number; y: number; side: Side };
const OPPOSITE: Record<Side, Side> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
/**
* True for sides whose main axis is vertical (content sits above/below).
*/
function isVertical(side: Side): boolean {
return side === 'top' || side === 'bottom';
}
/**
* Main-axis coordinate (top for vertical sides, left for horizontal sides).
*/
function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number {
switch (side) {
case 'top':
return t.top - c.height - offset;
case 'bottom':
return t.bottom + offset;
case 'left':
return t.left - c.width - offset;
case 'right':
return t.right + offset;
}
}
/**
* Whether the content fits on the given side within the viewport.
*/
function fitsOnSide(
side: Side,
t: DOMRect,
c: { width: number; height: number },
v: { width: number; height: number },
offset: number,
): boolean {
const coord = mainAxisCoord(side, t, c, offset);
switch (side) {
case 'top':
return coord >= 0;
case 'left':
return coord >= 0;
case 'bottom':
return coord + c.height <= v.height;
case 'right':
return coord + c.width <= v.width;
}
}
/**
* Cross-axis coordinate for the requested alignment.
*/
function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number {
if (isVertical(side)) {
if (align === 'start') {
return t.left;
}
if (align === 'end') {
return t.right - c.width;
}
return t.left + t.width / 2 - c.width / 2;
}
if (align === 'start') {
return t.top;
}
if (align === 'end') {
return t.bottom - c.height;
}
return t.top + t.height / 2 - c.height / 2;
}
/**
* Compute an anchored placement with flip (to the opposite side when the
* preferred side doesn't fit but the opposite does) and shift (clamp the
* cross axis so the content stays within the viewport).
*/
export function computePosition(args: ComputeArgs): ComputeResult {
const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args;
let side = args.side;
if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) {
side = OPPOSITE[side];
}
let x: number;
let y: number;
if (isVertical(side)) {
y = mainAxisCoord(side, t, c, sideOffset);
x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width));
} else {
x = mainAxisCoord(side, t, c, sideOffset);
y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height));
}
return { x, y, side };
}
+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
+45
View File
@@ -61,3 +61,48 @@ if (typeof PointerEvent === 'undefined') {
// jsdom lacks pointer capture
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
// jsdom lacks the Popover API. Minimal shim: methods toggle an internal flag,
// dispatch a `toggle` event ({ oldState, newState }), and make
// matches(':popover-open') reflect the flag so components can sync state.
if (typeof HTMLElement.prototype.showPopover !== 'function') {
const openFlag = new WeakSet<HTMLElement>();
const fireToggle = (el: HTMLElement, oldState: string, newState: string) => {
const event = new Event('toggle') as Event & { oldState: string; newState: string };
event.oldState = oldState;
event.newState = newState;
el.dispatchEvent(event);
};
HTMLElement.prototype.showPopover = function showPopover(this: HTMLElement) {
if (openFlag.has(this)) {
return;
}
openFlag.add(this);
fireToggle(this, 'closed', 'open');
};
HTMLElement.prototype.hidePopover = function hidePopover(this: HTMLElement) {
if (!openFlag.has(this)) {
return;
}
openFlag.delete(this);
fireToggle(this, 'open', 'closed');
};
HTMLElement.prototype.togglePopover = function togglePopover(this: HTMLElement) {
if (openFlag.has(this)) {
this.hidePopover();
return !openFlag.has(this);
}
this.showPopover();
return openFlag.has(this);
};
const originalMatches = Element.prototype.matches;
Element.prototype.matches = function matches(this: Element, selector: string): boolean {
if (selector === ':popover-open') {
return this instanceof HTMLElement && openFlag.has(this);
}
return originalMatches.call(this, selector);
} as typeof Element.prototype.matches;
}
+1 -98
View File
@@ -565,32 +565,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/core@npm:^1.7.1, @floating-ui/core@npm:^1.7.3":
version: 1.7.3
resolution: "@floating-ui/core@npm:1.7.3"
dependencies:
"@floating-ui/utils": "npm:^0.2.10"
checksum: 10c0/edfc23800122d81df0df0fb780b7328ae6c5f00efbb55bd48ea340f4af8c5b3b121ceb4bb81220966ab0f87b443204d37105abdd93d94846468be3243984144c
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.7.1":
version: 1.7.4
resolution: "@floating-ui/dom@npm:1.7.4"
dependencies:
"@floating-ui/core": "npm:^1.7.3"
"@floating-ui/utils": "npm:^0.2.10"
checksum: 10c0/da6166c25f9b0729caa9f498685a73a0e28251613b35d27db8de8014bc9d045158a23c092b405321a3d67c2064909b6e2a7e6c1c9cc0f62967dca5779f5aef30
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.10":
version: 0.2.10
resolution: "@floating-ui/utils@npm:0.2.10"
checksum: 10c0/e9bc2a1730ede1ee25843937e911ab6e846a733a4488623cd353f94721b05ec2c9ec6437613a2ac9379a94c2fd40c797a2ba6fa1df2716f5ce4aa6ddb1cf9ea4
languageName: node
linkType: hard
"@internationalized/date@npm:3.12.1":
version: 3.12.1
resolution: "@internationalized/date@npm:3.12.1"
@@ -1891,23 +1865,6 @@ __metadata:
languageName: node
linkType: hard
"bits-ui@npm:2.18.1":
version: 2.18.1
resolution: "bits-ui@npm:2.18.1"
dependencies:
"@floating-ui/core": "npm:^1.7.1"
"@floating-ui/dom": "npm:^1.7.1"
esm-env: "npm:^1.1.2"
runed: "npm:^0.35.1"
svelte-toolbelt: "npm:^0.10.6"
tabbable: "npm:^6.2.0"
peerDependencies:
"@internationalized/date": ^3.8.1
svelte: ^5.33.0
checksum: 10c0/1ed513a994d66449ab00c091f70111de30182856a167110ec3a413317014e7b949c50a8501aaa8a7603829394e5499bcb5a2b7c4a38a541b3820aad03e01f3cf
languageName: node
linkType: hard
"bundle-name@npm:^4.1.0":
version: 4.1.0
resolution: "bundle-name@npm:4.1.0"
@@ -2382,7 +2339,7 @@ __metadata:
languageName: node
linkType: hard
"esm-env@npm:^1.0.0, esm-env@npm:^1.1.2, esm-env@npm:^1.2.1":
"esm-env@npm:^1.2.1":
version: 1.2.2
resolution: "esm-env@npm:1.2.2"
checksum: 10c0/3d25c973f2fd69c25ffff29c964399cea573fe10795ecc1d26f6f957ce0483d3254e1cceddb34bf3296a0d7b0f1d53a28992f064ba509dfe6366751e752c4166
@@ -2569,7 +2526,6 @@ __metadata:
"@types/jsdom": "npm:28.0.1"
"@vitest/browser-playwright": "npm:4.1.5"
"@vitest/coverage-v8": "npm:4.1.5"
bits-ui: "npm:2.18.1"
clsx: "npm:^2.1.1"
dprint: "npm:0.54.0"
jsdom: "npm:29.1.1"
@@ -2671,13 +2627,6 @@ __metadata:
languageName: node
linkType: hard
"inline-style-parser@npm:0.2.7":
version: 0.2.7
resolution: "inline-style-parser@npm:0.2.7"
checksum: 10c0/d884d76f84959517430ae6c22f0bda59bb3f58f539f99aac75a8d786199ec594ed648c6ab4640531f9fc244b0ed5cd8c458078e592d016ef06de793beb1debff
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
@@ -3742,23 +3691,6 @@ __metadata:
languageName: node
linkType: hard
"runed@npm:^0.35.1":
version: 0.35.1
resolution: "runed@npm:0.35.1"
dependencies:
dequal: "npm:^2.0.3"
esm-env: "npm:^1.0.0"
lz-string: "npm:^1.5.0"
peerDependencies:
"@sveltejs/kit": ^2.21.0
svelte: ^5.7.0
peerDependenciesMeta:
"@sveltejs/kit":
optional: true
checksum: 10c0/ea6c6ba684b52075a5991a0b79d4c381d987f802eabe5689afd495589fdf6fa5aae7eae6843091364b8602643b342deda85f99267c2ff837c83c28d5d9e771ce
languageName: node
linkType: hard
"sade@npm:^1.7.4":
version: 1.8.1
resolution: "sade@npm:1.8.1"
@@ -3948,15 +3880,6 @@ __metadata:
languageName: node
linkType: hard
"style-to-object@npm:^1.0.8":
version: 1.0.14
resolution: "style-to-object@npm:1.0.14"
dependencies:
inline-style-parser: "npm:0.2.7"
checksum: 10c0/854d9e9b77afc336e6d7b09348e7939f2617b34eb0895824b066d8cd1790284cb6d8b2ba36be88025b2595d715dba14b299ae76e4628a366541106f639e13679
languageName: node
linkType: hard
"supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
@@ -4026,19 +3949,6 @@ __metadata:
languageName: node
linkType: hard
"svelte-toolbelt@npm:^0.10.6":
version: 0.10.6
resolution: "svelte-toolbelt@npm:0.10.6"
dependencies:
clsx: "npm:^2.1.1"
runed: "npm:^0.35.1"
style-to-object: "npm:^1.0.8"
peerDependencies:
svelte: ^5.30.2
checksum: 10c0/1d8edc5ba5daba4b97e427f1a324f86157b0e9efd98acdd88e852a3c901a7e0ad06170422376d24bf9dad8016ef06075f298778b37b91335ba51599b5ae9c8af
languageName: node
linkType: hard
"svelte2tsx@npm:^0.7.44":
version: 0.7.46
resolution: "svelte2tsx@npm:0.7.46"
@@ -4118,13 +4028,6 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.2.0":
version: 6.3.0
resolution: "tabbable@npm:6.3.0"
checksum: 10c0/57ba019d29b5cfa0c862248883bcec0e6d29d8f156ba52a1f425e7cfeca4a0fc701ab8d035c4c86ddf74ecdbd0e9f454a88d9b55d924a51f444038e9cd14d7a0
languageName: node
linkType: hard
"tailwind-merge@npm:3.5.0":
version: 3.5.0
resolution: "tailwind-merge@npm:3.5.0"