Compare commits
11 Commits
f3c76df2c5
...
db6384110e
| Author | SHA1 | Date | |
|---|---|---|---|
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 |
@@ -44,7 +44,6 @@
|
|||||||
"@types/jsdom": "28.0.1",
|
"@types/jsdom": "28.0.1",
|
||||||
"@vitest/browser-playwright": "4.1.5",
|
"@vitest/browser-playwright": "4.1.5",
|
||||||
"@vitest/coverage-v8": "4.1.5",
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
"bits-ui": "2.18.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "0.54.0",
|
"dprint": "0.54.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
ControlGroup,
|
ControlGroup,
|
||||||
|
Popover,
|
||||||
Slider,
|
Slider,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import { Popover } from 'bits-ui';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
@@ -73,33 +73,21 @@ $effect(() => {
|
|||||||
{#if !hidden}
|
{#if !hidden}
|
||||||
{#if responsive.isMobileOrTablet}
|
{#if responsive.isMobileOrTablet}
|
||||||
<div class={className}>
|
<div class={className}>
|
||||||
<Popover.Root bind:open>
|
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||||
<Popover.Trigger>
|
{#snippet trigger(props)}
|
||||||
{#snippet child({ props })}
|
|
||||||
<Button variant="primary" {...props}>
|
<Button variant="primary" {...props}>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<Settings2Icon class="size-4" />
|
<Settings2Icon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
{#snippet children({ close })}
|
||||||
<Popover.Content
|
<div
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
class={cn(
|
class={cn(
|
||||||
'z-50 w-72 p-4 rounded-none',
|
'w-72 p-4 rounded-none',
|
||||||
'surface-popover',
|
'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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||||
@@ -111,17 +99,13 @@ $effect(() => {
|
|||||||
CONTROLS
|
CONTROLS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Popover.Close>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
<button
|
||||||
{...props}
|
onclick={close}
|
||||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
aria-label="Close controls"
|
aria-label="Close controls"
|
||||||
>
|
>
|
||||||
<XIcon class="size-3.5 text-neutral-500" />
|
<XIcon class="size-3.5 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
|
||||||
</Popover.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
@@ -135,9 +119,9 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
{/each}
|
{/each}
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Portal>
|
{/snippet}
|
||||||
</Popover.Root>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TypographyControl } from '$shared/lib';
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { cn } 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 { Button } from '$shared/ui/Button';
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import { Popover } from 'bits-ui';
|
|
||||||
import TechText from '../TechText/TechText.svelte';
|
import TechText from '../TechText/TechText.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -114,9 +116,8 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
|
|
||||||
<!-- Trigger -->
|
<!-- Trigger -->
|
||||||
<div class="relative mx-1">
|
<div class="relative mx-1">
|
||||||
<Popover.Root bind:open>
|
<Popover bind:open side="top" align="center">
|
||||||
<Popover.Trigger>
|
{#snippet trigger(props)}
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -149,14 +150,10 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
</TechText>
|
</TechText>
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<!-- Vertical slider popover -->
|
<!-- Vertical slider popover -->
|
||||||
<Popover.Content
|
{#snippet children()}
|
||||||
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
|
<div class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated">
|
||||||
align="center"
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
<Slider
|
<Slider
|
||||||
class="h-full"
|
class="h-full"
|
||||||
bind:value={control.value}
|
bind:value={control.value}
|
||||||
@@ -165,8 +162,9 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
step={control.step}
|
step={control.step}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
/>
|
/>
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Root>
|
{/snippet}
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Increase button -->
|
<!-- Increase button -->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
within,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import ComboControl from './ComboControl.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('ComboControl', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders decrease and increase buttons', () => {
|
it('renders decrease and increase buttons', () => {
|
||||||
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
|
|||||||
|
|
||||||
it('renders the current integer value', () => {
|
it('renders the current integer value', () => {
|
||||||
render(ComboControl, { control: makeControl(42) });
|
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', () => {
|
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
|
||||||
render(ComboControl, { control: makeControl(1.5, { 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', () => {
|
it('formats decimal value to 2 decimal places when step < 0.1', () => {
|
||||||
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
|
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', () => {
|
it('renders label when label prop is provided', () => {
|
||||||
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
|
|||||||
const control = makeControl(50);
|
const control = makeControl(50);
|
||||||
render(ComboControl, { control });
|
render(ComboControl, { control });
|
||||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||||
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
|
await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Popover', () => {
|
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' });
|
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
|
||||||
await fireEvent.click(screen.getByText('Size control'));
|
const trigger = getTrigger();
|
||||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
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>
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -61,3 +61,48 @@ if (typeof PointerEvent === 'undefined') {
|
|||||||
// jsdom lacks pointer capture
|
// jsdom lacks pointer capture
|
||||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
HTMLElement.prototype.releasePointerCapture = 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -565,32 +565,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@internationalized/date@npm:3.12.1":
|
||||||
version: 3.12.1
|
version: 3.12.1
|
||||||
resolution: "@internationalized/date@npm:3.12.1"
|
resolution: "@internationalized/date@npm:3.12.1"
|
||||||
@@ -1891,23 +1865,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"bundle-name@npm:^4.1.0":
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
resolution: "bundle-name@npm:4.1.0"
|
resolution: "bundle-name@npm:4.1.0"
|
||||||
@@ -2382,7 +2339,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.2.2
|
||||||
resolution: "esm-env@npm:1.2.2"
|
resolution: "esm-env@npm:1.2.2"
|
||||||
checksum: 10c0/3d25c973f2fd69c25ffff29c964399cea573fe10795ecc1d26f6f957ce0483d3254e1cceddb34bf3296a0d7b0f1d53a28992f064ba509dfe6366751e752c4166
|
checksum: 10c0/3d25c973f2fd69c25ffff29c964399cea573fe10795ecc1d26f6f957ce0483d3254e1cceddb34bf3296a0d7b0f1d53a28992f064ba509dfe6366751e752c4166
|
||||||
@@ -2569,7 +2526,6 @@ __metadata:
|
|||||||
"@types/jsdom": "npm:28.0.1"
|
"@types/jsdom": "npm:28.0.1"
|
||||||
"@vitest/browser-playwright": "npm:4.1.5"
|
"@vitest/browser-playwright": "npm:4.1.5"
|
||||||
"@vitest/coverage-v8": "npm:4.1.5"
|
"@vitest/coverage-v8": "npm:4.1.5"
|
||||||
bits-ui: "npm:2.18.1"
|
|
||||||
clsx: "npm:^2.1.1"
|
clsx: "npm:^2.1.1"
|
||||||
dprint: "npm:0.54.0"
|
dprint: "npm:0.54.0"
|
||||||
jsdom: "npm:29.1.1"
|
jsdom: "npm:29.1.1"
|
||||||
@@ -2671,13 +2627,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ip-address@npm:^10.0.1":
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
resolution: "ip-address@npm:10.1.0"
|
resolution: "ip-address@npm:10.1.0"
|
||||||
@@ -3742,23 +3691,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"sade@npm:^1.7.4":
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
resolution: "sade@npm:1.8.1"
|
resolution: "sade@npm:1.8.1"
|
||||||
@@ -3948,15 +3880,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"supports-color@npm:^7.1.0":
|
||||||
version: 7.2.0
|
version: 7.2.0
|
||||||
resolution: "supports-color@npm:7.2.0"
|
resolution: "supports-color@npm:7.2.0"
|
||||||
@@ -4026,19 +3949,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"svelte2tsx@npm:^0.7.44":
|
||||||
version: 0.7.46
|
version: 0.7.46
|
||||||
resolution: "svelte2tsx@npm:0.7.46"
|
resolution: "svelte2tsx@npm:0.7.46"
|
||||||
@@ -4118,13 +4028,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"tailwind-merge@npm:3.5.0":
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
resolution: "tailwind-merge@npm:3.5.0"
|
resolution: "tailwind-merge@npm:3.5.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user