Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
18 changed files with 1275 additions and 260 deletions
Showing only changes of commit 431fb41a7f - Show all commits
-1
View File
@@ -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> <button
{#snippet child({ props })} onclick={close}
<button class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
{...props} aria-label="Close controls"
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors" >
aria-label="Close controls" <XIcon class="size-3.5 text-neutral-500" />
> </button>
<XIcon class="size-3.5 text-neutral-500" />
</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
+50 -52
View File
@@ -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,59 +105,55 @@ 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( 'flex flex-col flex-center w-14 py-1',
'flex flex-col flex-center w-14 py-1', 'select-none rounded-none transition-all duration-fast',
'select-none rounded-none transition-all duration-fast', 'border border-transparent',
'border border-transparent', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30', open
open ? 'surface-card-elevated'
? 'surface-card-elevated' : 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50', )}
)} aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined} >
> <!-- Label row -->
<!-- Label row --> {#if displayLabel}
{#if displayLabel} <span
<span class="
class=" text-3xs text-label-mono
text-3xs text-label-mono text-neutral-900 dark:text-neutral-100
text-neutral-900 dark:text-neutral-100 mb-0.5 leading-none
mb-0.5 leading-none "
" >
> {displayLabel}
{displayLabel} </span>
</span> {/if}
{/if}
<!-- Value row --> <!-- Value row -->
<TechText variant="muted" size="md"> <TechText variant="muted" size="md">
{formattedValue()} {formattedValue()}
</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" <Slider
side="top" class="h-full"
> bind:value={control.value}
<Slider min={control.min}
class="h-full" max={control.max}
bind:value={control.value} step={control.step}
min={control.min} orientation="vertical"
max={control.max} />
step={control.step} </div>
orientation="vertical" {/snippet}
/> </Popover>
</Popover.Content>
</Popover.Root>
</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>
+225
View File
@@ -0,0 +1,225 @@
<!--
Component: Popover
Anchored popover on the native Popover API (top-layer, light-dismiss, ESC,
focus return handled by the browser). Placement is computed by the pure
`popover-position` module and applied as fixed coordinates; it repositions
on scroll/resize/content-resize. `open` is two-way bindable. The trigger is
consumer-rendered via the `trigger` snippet, which spreads a props object
(an attachment captures the trigger element; `popovertarget` wires the
native invoker). `children` receives `close()` to dismiss programmatically.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import {
type Align,
type Side,
computePosition,
} from './popover-position';
interface Props {
/**
* Open state (two-way bindable)
* @default false
*/
open?: boolean;
/**
* Preferred side
* @default 'bottom'
*/
side?: Side;
/**
* Cross-axis alignment
* @default 'center'
*/
align?: Align;
/**
* Gap between trigger and content (px)
* @default 0
*/
sideOffset?: number;
/**
* CSS classes applied to the content element
*/
class?: string;
/**
* ARIA role for the content
* @default 'dialog'
*/
role?: 'dialog' | 'menu' | 'listbox';
/**
* Trigger snippet — spread the provided props onto your trigger element
*/
trigger: Snippet<[Record<string, unknown>]>;
/**
* Content snippet — receives `close()` for programmatic dismissal
*/
children: Snippet<[{ close: () => void }]>;
}
let {
open = $bindable(false),
side = 'bottom',
align = 'center',
sideOffset = 0,
class: className,
role = 'dialog',
trigger,
children,
}: Props = $props();
const uid = $props.id();
const contentId = `popover-${uid}`;
let triggerEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
/**
* Side actually used after flip. Seeded from the `side` prop; the authoritative
* value is written by updatePosition() on every open, so the seed only matters
* for the closed state (hence the intentional state_referenced_locally warning).
*/
let resolvedSide = $state(side);
/**
* True once updatePosition has applied coordinates for the current open.
* Gates visibility so the content never paints at its pre-positioned (0,0)
* top-layer default before the first measurement.
*/
let positioned = $state(false);
/**
* Resolved fixed-position coordinates. Applied through the reactive `style`
* attribute (not imperatively) so they can't be wiped when the attribute
* re-renders — mixing the two caused a one-frame top-left flash.
*/
let x = $state(0);
let y = $state(0);
/**
* Actual DOM open state, driven by the `toggle` event. Source of truth for
* whether the browser currently shows the popover; `open` is the public binding.
*/
let shown = $state(false);
/**
* Stable attachment that captures the consumer's trigger element for measuring.
* Created once so spreading reactive `triggerProps` doesn't re-run it.
*/
const attachKey = createAttachmentKey();
const attachTrigger = (node: HTMLElement) => {
triggerEl = node;
return () => {
if (triggerEl === node) {
triggerEl = undefined;
}
};
};
const triggerProps = $derived({
popovertarget: contentId,
'aria-haspopup': role,
'aria-expanded': open,
'aria-controls': contentId,
[attachKey]: attachTrigger,
});
/**
* Recompute and apply the fixed-position coordinates.
*/
function updatePosition(): void {
if (!triggerEl || !contentEl) {
return;
}
const result = computePosition({
triggerRect: triggerEl.getBoundingClientRect(),
contentRect: { width: contentEl.offsetWidth, height: contentEl.offsetHeight },
viewport: { width: window.innerWidth, height: window.innerHeight },
side,
align,
sideOffset,
});
resolvedSide = result.side;
x = result.x;
y = result.y;
positioned = true;
}
/**
* Mirror the `toggle` event into our state.
*/
function onToggle(event: ToggleEvent): void {
shown = event.newState === 'open';
open = shown;
if (!shown) {
positioned = false;
}
}
/**
* Programmatic dismiss for the content snippet.
*/
function close(): void {
open = false;
}
// state -> browser: open the popover when `open` flips true and it isn't shown,
// and close it when `open` flips false while shown. `shown` (from toggle) breaks
// the loop so we never call show/hide redundantly.
$effect(() => {
const el = contentEl;
if (!el) {
return;
}
if (open && !shown) {
el.showPopover();
} else if (!open && shown) {
el.hidePopover();
}
});
// Position while shown; reposition on scroll/resize/content-resize; auto-clean.
$effect(() => {
if (!shown || !contentEl || !triggerEl) {
return;
}
updatePosition();
const observer = new ResizeObserver(() => updatePosition());
observer.observe(contentEl);
const onScroll = () => updatePosition();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
observer.disconnect();
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
});
</script>
{@render trigger(triggerProps)}
<!--
inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
inset:0; margin:auto to center it) so the JS-applied left/top win.
visibility is hidden until updatePosition runs (see `positioned`).
-->
<div
bind:this={contentEl}
id={contentId}
popover="auto"
{role}
data-side={resolvedSide}
data-state={shown ? 'open' : 'closed'}
ontoggle={onToggle}
style={`position: fixed; inset: auto; left: ${x}px; top: ${y}px; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn(
'opacity-0 scale-95 transition-discrete transition-[opacity,transform] duration-fast',
'starting:opacity-0 starting:scale-95',
'[&:popover-open]:opacity-100 [&:popover-open]:scale-100',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top',
className,
)}
>
{@render children({ close })}
</div>
@@ -0,0 +1,49 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import Harness from './PopoverHarness.svelte';
/**
* Resolve the popover content element (the [popover] ancestor of the test content).
*/
function getContent(): HTMLElement {
return screen.getByTestId('content').closest('[popover]') as HTMLElement;
}
describe('Popover', () => {
it('renders the trigger with aria wiring, closed by default', () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('popovertarget');
expect(getContent()).toHaveAttribute('data-state', 'closed');
});
it('opens via the popover toggle and syncs aria-expanded + data-state', async () => {
render(Harness);
const trigger = screen.getByRole('button', { name: 'Open' });
// jsdom does not auto-invoke popovertarget; call the API the browser would.
getContent().showPopover();
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
it('opens when the parent sets open=true (state -> browser)', async () => {
render(Harness, { open: true });
await Promise.resolve();
expect(getContent()).toHaveAttribute('data-state', 'open');
});
it('close() hides the popover and resets aria-expanded', async () => {
render(Harness, { open: true });
await Promise.resolve();
const trigger = screen.getByRole('button', { name: 'Open' });
await fireEvent.click(screen.getByTestId('close'));
expect(getContent()).toHaveAttribute('data-state', 'closed');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
@@ -0,0 +1,21 @@
<!--
Component: PopoverHarness
Test-only fixture: renders Popover with a button trigger and simple content
exposing the close() callback.
-->
<script lang="ts">
import Popover from './Popover.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
</script>
<Popover bind:open>
{#snippet trigger(props)}
<button {...props}>Open</button>
{/snippet}
{#snippet children({ close })}
<div data-testid="content">
<button onclick={close} data-testid="close">Close</button>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,119 @@
import {
type Align,
type Side,
computePosition,
} from './popover-position';
/**
* Build a DOMRect-like object (jsdom/node has no layout).
*/
function rect(x: number, y: number, width: number, height: number): DOMRect {
return {
x,
y,
width,
height,
top: y,
left: x,
right: x + width,
bottom: y + height,
toJSON: () => ({}),
} as DOMRect;
}
const viewport = { width: 1000, height: 800 };
const content = { width: 200, height: 100 };
function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
}
describe('computePosition', () => {
it('places below the trigger for side="bottom"', () => {
const r = compute('bottom', 'center');
expect(r.side).toBe('bottom');
expect(r.y).toBe(440); // trigger.bottom (400+40)
});
it('places above the trigger for side="top"', () => {
const r = compute('top', 'center');
expect(r.side).toBe('top');
expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
});
it('applies sideOffset on the main axis', () => {
const r = compute('bottom', 'center', 8);
expect(r.y).toBe(448);
});
it('aligns center on the cross axis (vertical side)', () => {
const r = compute('bottom', 'center');
// trigger center x = 450; content half = 100 -> 350
expect(r.x).toBe(350);
});
it('aligns start and end on the cross axis (vertical side)', () => {
expect(compute('bottom', 'start').x).toBe(400); // trigger.left
expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
});
it('places left/right with vertical cross-axis alignment', () => {
const right = compute('right', 'start');
expect(right.side).toBe('right');
expect(right.x).toBe(500); // trigger.right
expect(right.y).toBe(400); // trigger.top (align start)
const left = compute('left', 'center');
expect(left.side).toBe('left');
expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
});
it('flips top->bottom when there is no room above', () => {
const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
const r = computePosition({
triggerRect: nearTop,
contentRect: content,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('bottom');
expect(r.y).toBe(60); // nearTop.bottom
});
it('does NOT flip when neither side fits (keeps requested side)', () => {
const tall = { width: 200, height: 700 };
const r = computePosition({
triggerRect: rect(400, 400, 100, 40),
contentRect: tall,
viewport,
side: 'top',
align: 'center',
sideOffset: 0,
});
expect(r.side).toBe('top');
});
it('shifts on the cross axis to stay within the viewport', () => {
const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
const r = computePosition({
triggerRect: nearRight,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
const nearLeft = rect(10, 400, 40, 40);
const r2 = computePosition({
triggerRect: nearLeft,
contentRect: content,
viewport,
side: 'bottom',
align: 'center',
sideOffset: 0,
});
expect(r2.x).toBe(0); // clamped to 0
});
});
+149
View File
@@ -0,0 +1,149 @@
import { clampNumber } from '$shared/lib/utils';
/**
* Side of the trigger the content prefers to open toward.
*/
export type Side = 'top' | 'bottom' | 'left' | 'right';
/**
* Cross-axis alignment of the content relative to the trigger.
*/
export type Align = 'start' | 'center' | 'end';
/**
* Inputs for a single placement computation. All geometry is injected
* (no DOM reads) so the function stays pure and unit-testable.
*/
type ComputeArgs = {
/**
* Trigger bounding rect (viewport coordinates).
*/
triggerRect: DOMRect;
/**
* Measured content size.
*/
contentRect: { width: number; height: number };
/**
* Viewport size.
*/
viewport: { width: number; height: number };
/**
* Preferred side.
*/
side: Side;
/**
* Cross-axis alignment.
*/
align: Align;
/**
* Gap between trigger and content on the main axis.
*/
sideOffset: number;
};
/**
* Resolved placement: fixed-position coordinates plus the side actually used
* (may differ from the requested side after a flip).
*/
type ComputeResult = { x: number; y: number; side: Side };
const OPPOSITE: Record<Side, Side> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
/**
* True for sides whose main axis is vertical (content sits above/below).
*/
function isVertical(side: Side): boolean {
return side === 'top' || side === 'bottom';
}
/**
* Main-axis coordinate (top for vertical sides, left for horizontal sides).
*/
function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number {
switch (side) {
case 'top':
return t.top - c.height - offset;
case 'bottom':
return t.bottom + offset;
case 'left':
return t.left - c.width - offset;
case 'right':
return t.right + offset;
}
}
/**
* Whether the content fits on the given side within the viewport.
*/
function fitsOnSide(
side: Side,
t: DOMRect,
c: { width: number; height: number },
v: { width: number; height: number },
offset: number,
): boolean {
const coord = mainAxisCoord(side, t, c, offset);
switch (side) {
case 'top':
return coord >= 0;
case 'left':
return coord >= 0;
case 'bottom':
return coord + c.height <= v.height;
case 'right':
return coord + c.width <= v.width;
}
}
/**
* Cross-axis coordinate for the requested alignment.
*/
function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number {
if (isVertical(side)) {
if (align === 'start') {
return t.left;
}
if (align === 'end') {
return t.right - c.width;
}
return t.left + t.width / 2 - c.width / 2;
}
if (align === 'start') {
return t.top;
}
if (align === 'end') {
return t.bottom - c.height;
}
return t.top + t.height / 2 - c.height / 2;
}
/**
* Compute an anchored placement with flip (to the opposite side when the
* preferred side doesn't fit but the opposite does) and shift (clamp the
* cross axis so the content stays within the viewport).
*/
export function computePosition(args: ComputeArgs): ComputeResult {
const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args;
let side = args.side;
if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) {
side = OPPOSITE[side];
}
let x: number;
let y: number;
if (isVertical(side)) {
y = mainAxisCoord(side, t, c, sideOffset);
x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width));
} else {
x = mainAxisCoord(side, t, c, sideOffset);
y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height));
}
return { x, y, side };
}
+1 -3
View File
@@ -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
+197 -60
View File
@@ -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,81 +208,101 @@ 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
class="
bg-neutral-200 dark:bg-neutral-800
relative grow w-px overflow-visible
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
transition-colors
"
>
<span <span
class=" class="absolute bottom-0 left-0 bg-brand w-full"
bg-neutral-200 dark:bg-neutral-800 style="height: {percent}%"
relative grow w-px overflow-visible ></span>
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700 </span>
transition-colors
"
>
<Slider.Range class="absolute bg-brand w-full" />
</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
class="
bg-neutral-200 dark:bg-neutral-800
relative grow h-px overflow-visible
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
transition-colors
"
>
<span <span
class=" class="absolute top-0 left-0 bg-brand h-full"
bg-neutral-200 dark:bg-neutral-800 style="width: {percent}%"
relative grow h-px overflow-visible ></span>
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700 </span>
transition-colors
"
>
<Slider.Range class="absolute bg-brand h-full" />
</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">
+107
View File
@@ -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');
});
});
+55
View File
@@ -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);
});
});
+59
View File
@@ -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 leftmin, rightmax. Vertical is inverted so that
* upmax, 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);
}
+6
View File
@@ -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
+62
View File
@@ -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;
}
+1 -98
View File
@@ -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"