Feature/popover #48

Merged
ilia merged 10 commits from feature/popover into main 2026-06-02 18:47:18 +00:00
2 changed files with 85 additions and 60 deletions
Showing only changes of commit 7798c4bbdf - Show all commits
+50 -52
View File
@@ -6,11 +6,13 @@
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { Slider } from '$shared/ui'; import {
Popover,
Slider,
} from '$shared/ui';
import { Button } from '$shared/ui/Button'; import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui';
import TechText from '../TechText/TechText.svelte'; import TechText from '../TechText/TechText.svelte';
interface Props { interface Props {
@@ -114,59 +116,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 -->
@@ -4,6 +4,7 @@ import {
render, render,
screen, screen,
waitFor, waitFor,
within,
} from '@testing-library/svelte'; } from '@testing-library/svelte';
import ComboControl from './ComboControl.svelte'; import ComboControl from './ComboControl.svelte';
@@ -16,6 +17,16 @@ function makeControl(value: number, opts: { min?: number; max?: number; step?: n
}); });
} }
/**
* The trigger is the button wired to the popover (has popovertarget). The native
* Popover always renders its content (the vertical slider, which also displays the
* value) in the DOM, so value assertions must be scoped to the trigger to avoid
* matching the slider's own value label.
*/
function getTrigger(): HTMLElement {
return document.querySelector('button[popovertarget]') as HTMLElement;
}
describe('ComboControl', () => { describe('ComboControl', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('renders decrease and increase buttons', () => { it('renders decrease and increase buttons', () => {
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
it('renders the current integer value', () => { it('renders the current integer value', () => {
render(ComboControl, { control: makeControl(42) }); render(ComboControl, { control: makeControl(42) });
expect(screen.getByText('42')).toBeInTheDocument(); expect(within(getTrigger()).getByText('42')).toBeInTheDocument();
}); });
it('formats decimal value to 1 decimal place when step >= 0.1', () => { it('formats decimal value to 1 decimal place when step >= 0.1', () => {
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) }); render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
expect(screen.getByText('1.5')).toBeInTheDocument(); expect(within(getTrigger()).getByText('1.5')).toBeInTheDocument();
}); });
it('formats decimal value to 2 decimal places when step < 0.1', () => { it('formats decimal value to 2 decimal places when step < 0.1', () => {
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) }); render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
expect(screen.getByText('1.55')).toBeInTheDocument(); expect(within(getTrigger()).getByText('1.55')).toBeInTheDocument();
}); });
it('renders label when label prop is provided', () => { it('renders label when label prop is provided', () => {
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
const control = makeControl(50); const control = makeControl(50);
render(ComboControl, { control }); render(ComboControl, { control });
await fireEvent.click(screen.getByLabelText('Increase')); await fireEvent.click(screen.getByLabelText('Increase'));
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument()); await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
}); });
}); });
describe('Popover', () => { describe('Popover', () => {
it('opens popover with vertical slider on trigger click', async () => { /**
* The native Popover always renders its content; opening is driven by the
* browser's declarative popovertarget invoker, which jsdom does not simulate
* on click (mirrors Popover.svelte.test.ts). So assert the wired-but-closed
* state, then drive the open through the API the browser would call.
*/
it('exposes a popover trigger with the vertical slider as its content', async () => {
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' }); render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
await fireEvent.click(screen.getByText('Size control')); const trigger = getTrigger();
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument()); expect(trigger).toHaveAttribute('aria-expanded', 'false');
const content = document.getElementById(trigger.getAttribute('popovertarget')!) as HTMLElement;
expect(content).toHaveAttribute('data-state', 'closed');
// The vertical slider lives inside the popover content. While closed the
// content is visibility:hidden, so query including hidden elements.
expect(within(content).getByRole('slider', { hidden: true })).toBeInTheDocument();
content.showPopover();
await waitFor(() => expect(content).toHaveAttribute('data-state', 'open'));
expect(trigger).toHaveAttribute('aria-expanded', 'true');
}); });
}); });