From 4756682863f4a7f76e90bfc9a073702c0fecd303 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 10:50:46 +0300 Subject: [PATCH 01/17] feat(slider): add pure value/position math helpers --- src/shared/ui/Slider/slider-math.test.ts | 55 ++++++++++++++++++++++ src/shared/ui/Slider/slider-math.ts | 59 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/shared/ui/Slider/slider-math.test.ts create mode 100644 src/shared/ui/Slider/slider-math.ts diff --git a/src/shared/ui/Slider/slider-math.test.ts b/src/shared/ui/Slider/slider-math.test.ts new file mode 100644 index 0000000..3e7da60 --- /dev/null +++ b/src/shared/ui/Slider/slider-math.test.ts @@ -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); + }); +}); diff --git a/src/shared/ui/Slider/slider-math.ts b/src/shared/ui/Slider/slider-math.ts new file mode 100644 index 0000000..43b8a08 --- /dev/null +++ b/src/shared/ui/Slider/slider-math.ts @@ -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); +} From 9d6220d2ecc55a063dcb4ae0aa86a0cdbbfa11ff Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 10:54:54 +0300 Subject: [PATCH 02/17] feat(slider): reimplement natively without bits-ui --- src/shared/ui/Slider/Slider.svelte | 237 +++++++++++++++++++++-------- 1 file changed, 177 insertions(+), 60 deletions(-) diff --git a/src/shared/ui/Slider/Slider.svelte b/src/shared/ui/Slider/Slider.svelte index 081dd29..068e022 100644 --- a/src/shared/ui/Slider/Slider.svelte +++ b/src/shared/ui/Slider/Slider.svelte @@ -1,13 +1,16 @@ { }); let trackEl: HTMLElement | undefined = $state(); +let thumbEl: HTMLElement | undefined = $state(); let dragging = $state(false); /** @@ -131,6 +132,7 @@ function handlePointerDown(event: PointerEvent): void { return; } dragging = true; + thumbEl?.focus(); (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); seek(event); } @@ -237,6 +239,7 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand { 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; From ef9cd33e48bf4e9dd53a646b76c44c84d4351dd4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 15:59:58 +0300 Subject: [PATCH 08/17] feat(popover): add pure anchored-positioning math --- .../ui/Popover/popover-position.test.ts | 119 ++++++++++++++ src/shared/ui/Popover/popover-position.ts | 149 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/shared/ui/Popover/popover-position.test.ts create mode 100644 src/shared/ui/Popover/popover-position.ts diff --git a/src/shared/ui/Popover/popover-position.test.ts b/src/shared/ui/Popover/popover-position.test.ts new file mode 100644 index 0000000..25f13d8 --- /dev/null +++ b/src/shared/ui/Popover/popover-position.test.ts @@ -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 + }); +}); diff --git a/src/shared/ui/Popover/popover-position.ts b/src/shared/ui/Popover/popover-position.ts new file mode 100644 index 0000000..f9b1a11 --- /dev/null +++ b/src/shared/ui/Popover/popover-position.ts @@ -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 = { + 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 }; +} From b1b5177e02ce3d824e4128f1d8dc603bd843360f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:03:54 +0300 Subject: [PATCH 09/17] test: add jsdom Popover API shim --- vitest.setup.jsdom.ts | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/vitest.setup.jsdom.ts b/vitest.setup.jsdom.ts index 228df6a..36b8174 100644 --- a/vitest.setup.jsdom.ts +++ b/vitest.setup.jsdom.ts @@ -61,3 +61,48 @@ if (typeof PointerEvent === 'undefined') { // jsdom lacks pointer capture HTMLElement.prototype.setPointerCapture = vi.fn(); HTMLElement.prototype.releasePointerCapture = vi.fn(); + +// jsdom lacks the Popover API. Minimal shim: methods toggle an internal flag, +// dispatch a `toggle` event ({ oldState, newState }), and make +// matches(':popover-open') reflect the flag so components can sync state. +if (typeof HTMLElement.prototype.showPopover !== 'function') { + const openFlag = new WeakSet(); + + 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; +} From 9e0c8f740bad9f101a32de8479b27b7d3460f2ab Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:06:20 +0300 Subject: [PATCH 10/17] feat(popover): native Popover API component with anchored positioning --- src/shared/ui/Popover/Popover.svelte | 196 +++++++++++++++++++++++++++ src/shared/ui/index.ts | 6 + 2 files changed, 202 insertions(+) create mode 100644 src/shared/ui/Popover/Popover.svelte diff --git a/src/shared/ui/Popover/Popover.svelte b/src/shared/ui/Popover/Popover.svelte new file mode 100644 index 0000000..6ebdb09 --- /dev/null +++ b/src/shared/ui/Popover/Popover.svelte @@ -0,0 +1,196 @@ + + + +{@render trigger(triggerProps)} + +
+ {@render children({ close })} +
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index d02efb3..8400a36 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -94,6 +94,12 @@ export { */ default as PerspectivePlan, } from './PerspectivePlan/PerspectivePlan.svelte'; +export { + /** + * Anchored popover on the native Popover API + */ + default as Popover, +} from './Popover/Popover.svelte'; export { /** * Specialized input with search icon and clear state From 93c52dd1322efd2e7b04112e3057789266f37464 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:12:11 +0300 Subject: [PATCH 11/17] fix(popover): gate visibility until positioned, tighten types --- src/shared/ui/Popover/Popover.svelte | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/Popover/Popover.svelte b/src/shared/ui/Popover/Popover.svelte index 6ebdb09..27e7efc 100644 --- a/src/shared/ui/Popover/Popover.svelte +++ b/src/shared/ui/Popover/Popover.svelte @@ -47,7 +47,7 @@ interface Props { * ARIA role for the content * @default 'dialog' */ - role?: string; + role?: 'dialog' | 'menu' | 'listbox'; /** * Trigger snippet — spread the provided props onto your trigger element */ @@ -74,8 +74,20 @@ 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); + /** * 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. @@ -122,14 +134,18 @@ function updatePosition(): void { resolvedSide = result.side; contentEl.style.left = `${result.x}px`; contentEl.style.top = `${result.y}px`; + positioned = true; } /** * Mirror the `toggle` event into our state. */ -function onToggle(event: Event & { newState?: string }): void { +function onToggle(event: ToggleEvent): void { shown = event.newState === 'open'; open = shown; + if (!shown) { + positioned = false; + } } /** @@ -175,6 +191,11 @@ $effect(() => { {@render trigger(triggerProps)} +
{ data-side={resolvedSide} data-state={shown ? 'open' : 'closed'} ontoggle={onToggle} - style="position: fixed; inset: auto; margin: 0;" + style={`position: fixed; inset: auto; margin: 0;${positioned ? '' : ' visibility: hidden;'}`} class={cn( 'opacity-0 scale-95 transition-discrete transition-all duration-fast', 'starting:opacity-0 starting:scale-95', From ffa897ee5424c372f3af150d2e371015e99c737d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:13:40 +0300 Subject: [PATCH 12/17] test(popover): cover open/close state and aria wiring --- src/shared/ui/Popover/Popover.svelte.test.ts | 49 ++++++++++++++++++++ src/shared/ui/Popover/PopoverHarness.svelte | 21 +++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/shared/ui/Popover/Popover.svelte.test.ts create mode 100644 src/shared/ui/Popover/PopoverHarness.svelte diff --git a/src/shared/ui/Popover/Popover.svelte.test.ts b/src/shared/ui/Popover/Popover.svelte.test.ts new file mode 100644 index 0000000..603c7a9 --- /dev/null +++ b/src/shared/ui/Popover/Popover.svelte.test.ts @@ -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'); + }); +}); diff --git a/src/shared/ui/Popover/PopoverHarness.svelte b/src/shared/ui/Popover/PopoverHarness.svelte new file mode 100644 index 0000000..566e154 --- /dev/null +++ b/src/shared/ui/Popover/PopoverHarness.svelte @@ -0,0 +1,21 @@ + + + + + {#snippet trigger(props)} + + {/snippet} + {#snippet children({ close })} +
+ +
+ {/snippet} +
From 3ae22ad515c0b63eb24b11d90bed580064ff8f1a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:16:28 +0300 Subject: [PATCH 13/17] docs(popover): add storybook stories --- src/shared/ui/Popover/Popover.stories.svelte | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/shared/ui/Popover/Popover.stories.svelte diff --git a/src/shared/ui/Popover/Popover.stories.svelte b/src/shared/ui/Popover/Popover.stories.svelte new file mode 100644 index 0000000..a141ad1 --- /dev/null +++ b/src/shared/ui/Popover/Popover.stories.svelte @@ -0,0 +1,117 @@ + + + + + + {#snippet template()} +
+ + {#snippet trigger(props)} + + {/snippet} + {#snippet children()} +
Popover content
+ {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template()} +
+ + {#snippet trigger(props)} + + {/snippet} + {#snippet children()} +
Popover content
+ {/snippet} +
+
+ {/snippet} +
+ + + + {#snippet template()} +
+ + {#snippet trigger(props)} + + {/snippet} + {#snippet children({ close })} +
+

Menu header

+

+ Aligned to the trigger's end edge. +

+ +
+ {/snippet} +
+
+ {/snippet} +
+ + + + {#snippet template()} +
+ + {#snippet trigger(props)} + + {/snippet} + {#snippet children()} +
+ +
+ {/snippet} +
+
+ {/snippet} +
From 7798c4bbdfa94c5457d63b5eece23082943ddcfc Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 16:21:32 +0300 Subject: [PATCH 14/17] refactor(combo-control): use native Popover instead of bits-ui The native Popover always renders its content (the vertical slider), so the slider's value label is in the DOM even when closed, and opening is driven by the browser's declarative popovertarget invoker (not simulated by jsdom on click). Update the tests to scope value assertions to the trigger and drive open via showPopover(), matching Popover.svelte.test.ts. --- .../ui/ComboControl/ComboControl.svelte | 102 +++++++++--------- .../ComboControl/ComboControl.svelte.test.ts | 43 ++++++-- 2 files changed, 85 insertions(+), 60 deletions(-) diff --git a/src/shared/ui/ComboControl/ComboControl.svelte b/src/shared/ui/ComboControl/ComboControl.svelte index 778274e..1371c6d 100644 --- a/src/shared/ui/ComboControl/ComboControl.svelte +++ b/src/shared/ui/ComboControl/ComboControl.svelte @@ -6,11 +6,13 @@