chore: merged with main, conflict resolved
This commit is contained in:
@@ -49,7 +49,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",
|
||||||
|
|||||||
@@ -11,11 +11,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';
|
||||||
@@ -74,33 +74,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">
|
||||||
@@ -112,17 +100,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 -->
|
||||||
@@ -136,9 +120,9 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
{/each}
|
{/each}
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Portal>
|
{/snippet}
|
||||||
</Popover.Root>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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';
|
||||||
import type {
|
import type {
|
||||||
ControlLabels,
|
ControlLabels,
|
||||||
@@ -103,9 +105,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(
|
||||||
@@ -138,14 +139,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}
|
||||||
@@ -154,8 +151,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 -->
|
||||||
|
|||||||
@@ -3,6 +3,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';
|
||||||
import { createNumericControlMock } from './testing/createNumericControlMock.svelte';
|
import { createNumericControlMock } from './testing/createNumericControlMock.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 };
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
|
'Single-value slider (native, no bits-ui) with brand accent. Diamond thumb (45° rotated square) with hover/active scale. Supports pointer drag, click-to-seek, touch, and keyboard (arrows, Home/End, PageUp/Down).',
|
||||||
},
|
},
|
||||||
story: { inline: false }, // Render stories in iframe for state isolation
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
},
|
},
|
||||||
@@ -39,8 +39,6 @@ const { Story } = defineMeta({
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
let value = $state(50);
|
let value = $state(50);
|
||||||
let valueLow = $state(25);
|
|
||||||
let valueHigh = $state(75);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: Slider
|
Component: Slider
|
||||||
Single-value slider using bits-ui Slider primitive.
|
Single-value slider built on a native role="slider" element (no bits-ui).
|
||||||
|
Supports pointer drag, click-to-seek, touch, and full keyboard nav.
|
||||||
Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
|
Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
type Orientation,
|
pointerToValue,
|
||||||
Slider,
|
snapToStep,
|
||||||
} from 'bits-ui';
|
} from './slider-math';
|
||||||
|
|
||||||
|
type Orientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -67,8 +70,122 @@ let {
|
|||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageUp/PageDown move by this multiple of `step`.
|
||||||
|
*/
|
||||||
|
const LARGE_STEP_MULTIPLIER = 10;
|
||||||
|
|
||||||
const isVertical = $derived(orientation === 'vertical');
|
const isVertical = $derived(orientation === 'vertical');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumb/range offset as a clamped percentage of the track.
|
||||||
|
*/
|
||||||
|
const percent = $derived.by(() => {
|
||||||
|
if (max <= min) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(((value - min) / (max - min)) * 100, 0), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
let trackEl: HTMLElement | undefined = $state();
|
||||||
|
let thumbEl: HTMLElement | undefined = $state();
|
||||||
|
let dragging = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a candidate value: snap, clamp, store, and notify only on change.
|
||||||
|
*/
|
||||||
|
function commit(raw: number): void {
|
||||||
|
const next = snapToStep(raw, { min, max, step });
|
||||||
|
if (next !== value) {
|
||||||
|
value = next;
|
||||||
|
onValueChange?.(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep an externally-supplied value normalized to the step grid and range.
|
||||||
|
* Mirrors the bits-ui primitive's behavior so out-of-range or off-grid
|
||||||
|
* props don't desync the thumb position from aria-valuenow / the label.
|
||||||
|
* Converges in one pass: once snapped, the value equals its own snap.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const normalized = snapToStep(value, { min, max, step });
|
||||||
|
if (normalized !== value) {
|
||||||
|
value = normalized;
|
||||||
|
onValueChange?.(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pointer event to a value using the live track rect.
|
||||||
|
*/
|
||||||
|
function seek(event: PointerEvent): void {
|
||||||
|
if (!trackEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = trackEl.getBoundingClientRect();
|
||||||
|
commit(pointerToValue(event, rect, { min, max, step, orientation }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent): void {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragging = true;
|
||||||
|
thumbEl?.focus();
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
|
||||||
|
seek(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (!dragging || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seek(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent): void {
|
||||||
|
if (!dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const large = step * LARGE_STEP_MULTIPLIER;
|
||||||
|
let next: number | undefined;
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowUp':
|
||||||
|
next = value + step;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowDown':
|
||||||
|
next = value - step;
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
next = value + large;
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
next = value - large;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
next = min;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
next = max;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
||||||
text-subtle
|
text-subtle
|
||||||
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
||||||
@@ -91,22 +208,21 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
|||||||
{format(value)}
|
{format(value)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Slider.Root
|
<div
|
||||||
type="single"
|
bind:this={trackEl}
|
||||||
orientation="vertical"
|
role="presentation"
|
||||||
bind:value
|
onpointerdown={handlePointerDown}
|
||||||
{min}
|
onpointermove={handlePointerMove}
|
||||||
{max}
|
onpointerup={handlePointerUp}
|
||||||
{step}
|
onpointercancel={handlePointerUp}
|
||||||
{disabled}
|
|
||||||
onValueChange={(v => onValueChange?.(v))}
|
|
||||||
class="
|
class="
|
||||||
relative flex flex-col items-center select-none touch-none
|
relative flex flex-col items-center select-none touch-none
|
||||||
w-5 h-full grow cursor-row-resize
|
w-5 h-full grow cursor-row-resize
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
"
|
"
|
||||||
|
class:opacity-50={disabled}
|
||||||
|
class:cursor-not-allowed={disabled}
|
||||||
>
|
>
|
||||||
{#snippet children({ thumbItems })}
|
|
||||||
<span
|
<span
|
||||||
class="
|
class="
|
||||||
bg-neutral-200 dark:bg-neutral-800
|
bg-neutral-200 dark:bg-neutral-800
|
||||||
@@ -115,37 +231,47 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
|||||||
transition-colors
|
transition-colors
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Slider.Range class="absolute bg-brand w-full" />
|
<span
|
||||||
|
class="absolute bottom-0 left-0 bg-brand w-full"
|
||||||
|
style="height: {percent}%"
|
||||||
|
></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#each thumbItems as thumb (thumb)}
|
<span
|
||||||
<Slider.Thumb
|
role="slider"
|
||||||
index={thumb.index}
|
bind:this={thumbEl}
|
||||||
class={thumbClasses}
|
tabindex={disabled ? -1 : 0}
|
||||||
aria-label="Value"
|
aria-label="Value"
|
||||||
/>
|
aria-orientation="vertical"
|
||||||
{/each}
|
aria-valuemin={min}
|
||||||
{/snippet}
|
aria-valuemax={max}
|
||||||
</Slider.Root>
|
aria-valuenow={value}
|
||||||
|
aria-valuetext={String(format(value))}
|
||||||
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
|
data-active={dragging ? '' : undefined}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
class="{thumbClasses} absolute left-1/2 -translate-x-1/2 translate-y-1/2"
|
||||||
|
style="bottom: {percent}%"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-4 group w-full {className ?? ''}">
|
<div class="flex items-center gap-4 group w-full {className ?? ''}">
|
||||||
<Slider.Root
|
<div
|
||||||
type="single"
|
bind:this={trackEl}
|
||||||
orientation="horizontal"
|
role="presentation"
|
||||||
bind:value
|
onpointerdown={handlePointerDown}
|
||||||
{min}
|
onpointermove={handlePointerMove}
|
||||||
{max}
|
onpointerup={handlePointerUp}
|
||||||
{step}
|
onpointercancel={handlePointerUp}
|
||||||
{disabled}
|
|
||||||
onValueChange={(v => onValueChange?.(v))}
|
|
||||||
class="
|
class="
|
||||||
relative flex items-center select-none touch-none
|
relative flex items-center select-none touch-none
|
||||||
w-full h-5 cursor-col-resize
|
w-full h-5 cursor-col-resize
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
"
|
"
|
||||||
|
class:opacity-50={disabled}
|
||||||
|
class:cursor-not-allowed={disabled}
|
||||||
>
|
>
|
||||||
{#snippet children({ thumbItems })}
|
|
||||||
<span
|
<span
|
||||||
class="
|
class="
|
||||||
bg-neutral-200 dark:bg-neutral-800
|
bg-neutral-200 dark:bg-neutral-800
|
||||||
@@ -154,18 +280,29 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
|||||||
transition-colors
|
transition-colors
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Slider.Range class="absolute bg-brand h-full" />
|
<span
|
||||||
|
class="absolute top-0 left-0 bg-brand h-full"
|
||||||
|
style="width: {percent}%"
|
||||||
|
></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#each thumbItems as thumb (thumb)}
|
<span
|
||||||
<Slider.Thumb
|
role="slider"
|
||||||
index={thumb.index}
|
bind:this={thumbEl}
|
||||||
class={thumbClasses}
|
tabindex={disabled ? -1 : 0}
|
||||||
aria-label="Value"
|
aria-label="Value"
|
||||||
/>
|
aria-orientation="horizontal"
|
||||||
{/each}
|
aria-valuemin={min}
|
||||||
{/snippet}
|
aria-valuemax={max}
|
||||||
</Slider.Root>
|
aria-valuenow={value}
|
||||||
|
aria-valuetext={String(format(value))}
|
||||||
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
|
data-active={dragging ? '' : undefined}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
class="{thumbClasses} absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
|
||||||
|
style="left: {percent}%"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Label: right of slider -->
|
<!-- Label: right of slider -->
|
||||||
<span class="{labelClasses} w-12 text-right">
|
<span class="{labelClasses} w-12 text-right">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
@@ -60,3 +61,109 @@ describe('Slider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Keyboard', () => {
|
||||||
|
it('increments by step on ArrowRight / ArrowUp', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(Slider, { value: 50, step: 5, onValueChange });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '55');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrements by step on ArrowLeft / ArrowDown', async () => {
|
||||||
|
render(Slider, { value: 50, step: 5 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowDown' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to min on Home and max on End', async () => {
|
||||||
|
render(Slider, { value: 50, min: 10, max: 90 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'Home' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '10');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves by step*10 on PageUp / PageDown', async () => {
|
||||||
|
render(Slider, { value: 50, step: 2 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'PageUp' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '70');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'PageDown' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at the bounds', async () => {
|
||||||
|
render(Slider, { value: 98, max: 100, step: 5 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when disabled', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(Slider, { value: 50, disabled: true, onValueChange });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
expect(onValueChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pointer', () => {
|
||||||
|
/**
|
||||||
|
* Force a deterministic track rect since jsdom has no layout.
|
||||||
|
*/
|
||||||
|
function mockTrackRect(container: HTMLElement) {
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('seeks to the clicked position (click-to-seek)', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100, onValueChange });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates while dragging after pointerdown', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 50, clientY: 10, pointerId: 1 });
|
||||||
|
await fireEvent.pointerMove(track, { clientX: 150, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores pointer when disabled', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, disabled: true });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the thumb on pointerdown so arrow keys work immediately', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toBe(document.activeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a vertical drag with the inverted axis (bottom→min, top→max)', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100, orientation: 'vertical' });
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 20, top: 0, bottom: 200, width: 20, height: 200 }) as DOMRect;
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 10, clientY: 50, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
pointerToValue,
|
||||||
|
snapToStep,
|
||||||
|
} from './slider-math';
|
||||||
|
|
||||||
|
describe('snapToStep', () => {
|
||||||
|
it('snaps a raw value to the nearest step on the grid', () => {
|
||||||
|
expect(snapToStep(53, { min: 0, max: 100, step: 10 })).toBe(50);
|
||||||
|
expect(snapToStep(56, { min: 0, max: 100, step: 10 })).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps below min and above max', () => {
|
||||||
|
expect(snapToStep(-20, { min: 0, max: 100, step: 1 })).toBe(0);
|
||||||
|
expect(snapToStep(200, { min: 0, max: 100, step: 1 })).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a non-zero min when snapping', () => {
|
||||||
|
expect(snapToStep(13, { min: 10, max: 90, step: 5 })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves fractional step precision', () => {
|
||||||
|
expect(snapToStep(1.34, { min: 0, max: 2, step: 0.05 })).toBe(1.35);
|
||||||
|
expect(snapToStep(0.31, { min: 0, max: 1, step: 0.1 })).toBe(0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointerToValue', () => {
|
||||||
|
const rect = { left: 100, right: 300, top: 50, bottom: 250, width: 200, height: 200 } as DOMRect;
|
||||||
|
|
||||||
|
it('maps horizontal pointer position left→min, right→max', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 100, clientY: 0 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 200, clientY: 0 }, rect, opts)).toBe(50);
|
||||||
|
expect(pointerToValue({ clientX: 300, clientY: 0 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverts vertical: bottom→min, top→max', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'vertical' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 250 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 150 }, rect, opts)).toBe(50);
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 50 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps when pointer is outside the track', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 0 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 9999, clientY: 0 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns min for a zero-size track without NaN', () => {
|
||||||
|
const zero = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 } as DOMRect;
|
||||||
|
const opts = { min: 5, max: 95, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 0 }, zero, opts)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
clampNumber,
|
||||||
|
roundToStepPrecision,
|
||||||
|
} from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geometry/range options shared by the math helpers.
|
||||||
|
*/
|
||||||
|
type SliderMathOpts = {
|
||||||
|
/**
|
||||||
|
* Minimum value (inclusive)
|
||||||
|
*/
|
||||||
|
min: number;
|
||||||
|
/**
|
||||||
|
* Maximum value (inclusive)
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
/**
|
||||||
|
* Step increment
|
||||||
|
*/
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a raw value onto the step grid, then clamp to [min, max].
|
||||||
|
*
|
||||||
|
* Snapping is anchored to `min` so non-zero ranges land on valid stops.
|
||||||
|
* `roundToStepPrecision` removes IEEE-754 drift from fractional steps.
|
||||||
|
*/
|
||||||
|
export function snapToStep(raw: number, { min, max, step }: SliderMathOpts): number {
|
||||||
|
if (step <= 0) {
|
||||||
|
return clampNumber(raw, min, max);
|
||||||
|
}
|
||||||
|
const snapped = min + Math.round((raw - min) / step) * step;
|
||||||
|
return clampNumber(roundToStepPrecision(snapped, step), min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a pointer coordinate into a slider value.
|
||||||
|
*
|
||||||
|
* Horizontal maps left→min, right→max. Vertical is inverted so that
|
||||||
|
* up→max, matching natural slider expectations. The DOMRect is passed in
|
||||||
|
* to keep this pure and unit-testable without layout.
|
||||||
|
*/
|
||||||
|
export function pointerToValue(
|
||||||
|
point: { clientX: number; clientY: number },
|
||||||
|
rect: DOMRect,
|
||||||
|
opts: SliderMathOpts & { orientation: 'horizontal' | 'vertical' },
|
||||||
|
): number {
|
||||||
|
const { min, max, orientation } = opts;
|
||||||
|
const size = orientation === 'vertical' ? rect.height : rect.width;
|
||||||
|
if (size <= 0) {
|
||||||
|
return snapToStep(min, opts);
|
||||||
|
}
|
||||||
|
const ratio = orientation === 'vertical'
|
||||||
|
? (rect.bottom - point.clientY) / size
|
||||||
|
: (point.clientX - rect.left) / size;
|
||||||
|
return snapToStep(min + clampNumber(ratio, 0, 1) * (max - min), opts);
|
||||||
|
}
|
||||||
@@ -104,6 +104,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
|
||||||
|
|||||||
@@ -44,3 +44,65 @@ Object.defineProperty(window, 'localStorage', {
|
|||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks PointerEvent; back it with MouseEvent so clientX/clientY survive.
|
||||||
|
if (typeof PointerEvent === 'undefined') {
|
||||||
|
class PointerEventPolyfill extends MouseEvent {
|
||||||
|
pointerId: number;
|
||||||
|
constructor(type: string, params: PointerEventInit = {}) {
|
||||||
|
super(type, params);
|
||||||
|
this.pointerId = params.pointerId ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-expect-error assigning polyfill to the global scope
|
||||||
|
global.PointerEvent = PointerEventPolyfill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.2":
|
"esm-env@npm:^1.2.1, esm-env@npm:^1.2.2":
|
||||||
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"
|
||||||
@@ -2672,13 +2628,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"
|
||||||
@@ -3743,23 +3692,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"
|
||||||
@@ -3949,15 +3881,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"
|
||||||
@@ -4040,19 +3963,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"
|
||||||
@@ -4132,13 +4042,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