diff --git a/package.json b/package.json index b4d5c7a..3a2299d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@types/jsdom": "28.0.1", "@vitest/browser-playwright": "4.1.5", "@vitest/coverage-v8": "4.1.5", - "bits-ui": "2.18.1", "clsx": "^2.1.1", "dprint": "0.54.0", "jsdom": "29.1.1", diff --git a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte index 7d8c9bd..d628a42 100644 --- a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte +++ b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte @@ -11,11 +11,11 @@ import { Button, ComboControl, ControlGroup, + Popover, Slider, } from '$shared/ui'; import Settings2Icon from '@lucide/svelte/icons/settings-2'; import XIcon from '@lucide/svelte/icons/x'; -import { Popover } from 'bits-ui'; import { getContext } from 'svelte'; import { cubicOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -74,33 +74,21 @@ $effect(() => { {#if !hidden} {#if responsive.isMobileOrTablet}
- - - {#snippet child({ props })} - - {/snippet} - + + {#snippet trigger(props)} + + {/snippet} - -
@@ -112,17 +100,13 @@ $effect(() => { CONTROLS
- - {#snippet child({ props })} - - {/snippet} - +
@@ -136,9 +120,9 @@ $effect(() => { /> {/each} - - - + + {/snippet} + {:else}
+ + + + + {#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} +
diff --git a/src/shared/ui/Popover/Popover.svelte b/src/shared/ui/Popover/Popover.svelte new file mode 100644 index 0000000..c1443d1 --- /dev/null +++ b/src/shared/ui/Popover/Popover.svelte @@ -0,0 +1,225 @@ + + + +{@render trigger(triggerProps)} + + +
+ {@render children({ close })} +
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} +
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 }; +} diff --git a/src/shared/ui/Slider/Slider.stories.svelte b/src/shared/ui/Slider/Slider.stories.svelte index 2ccc455..80c1fb0 100644 --- a/src/shared/ui/Slider/Slider.stories.svelte +++ b/src/shared/ui/Slider/Slider.stories.svelte @@ -10,7 +10,7 @@ const { Story } = defineMeta({ docs: { description: { 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 }, @@ -39,8 +39,6 @@ const { Story } = defineMeta({