From fff89e6ec128b515f0171966525f86ee536a3749 Mon Sep 17 00:00:00 2001 From: agustin Date: Wed, 14 Oct 2026 18:24:50 -0300 Subject: [PATCH] fix(collapsible): open the panel to the content's exact size --- .changeset/collapse-fractional-height.md | 5 +++++ .../collapse-transition.svelte.test.ts | 12 ++++++++++++ .../primitives/collapse-transition.svelte.ts | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/collapse-fractional-height.md diff --git a/.changeset/collapse-fractional-height.md b/.changeset/collapse-fractional-height.md new file mode 100644 index 00000000..5cb3db71 --- /dev/null +++ b/.changeset/collapse-fractional-height.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': patch +--- + +A collapsible panel opens to its content's exact size instead of the whole pixels `scrollHeight` rounds to. Content 57.5px tall used to open a 58px panel, half a pixel taller than the same page rendered on the server, so everything under the panel moved when the page hydrated. diff --git a/packages/ui/src/lib/primitives/collapse-transition.svelte.test.ts b/packages/ui/src/lib/primitives/collapse-transition.svelte.test.ts index 6f354a34..ccecfdcc 100644 --- a/packages/ui/src/lib/primitives/collapse-transition.svelte.test.ts +++ b/packages/ui/src/lib/primitives/collapse-transition.svelte.test.ts @@ -3,6 +3,7 @@ import { render } from 'vitest-browser-svelte'; import { userEvent } from 'vitest/browser'; import CollapseTransitionTest from './collapse-transition-test.svelte'; +import { exactSize } from './collapse-transition.svelte'; function panel() { return document.querySelector('[data-testid="panel"]'); @@ -42,3 +43,14 @@ describe('createCollapseTransition', () => { expect(panel()?.getAttribute('data-status')).toBe('idle'); }); }); + +describe('exactSize', () => { + it('keeps the half pixel the scroll size rounds away', () => { + expect(exactSize(58, 57.5)).toBe(57.5); + expect(exactSize(57, 57.4)).toBe(57.4); + }); + + it('opens to the scroll size when the content overflows its box', () => { + expect(exactSize(80, 57.5)).toBe(80); + }); +}); diff --git a/packages/ui/src/lib/primitives/collapse-transition.svelte.ts b/packages/ui/src/lib/primitives/collapse-transition.svelte.ts index 2c9b244f..07f88bdf 100644 --- a/packages/ui/src/lib/primitives/collapse-transition.svelte.ts +++ b/packages/ui/src/lib/primitives/collapse-transition.svelte.ts @@ -20,6 +20,19 @@ export type CollapseTransition = { setPanel: (panel: HTMLElement | null, content?: HTMLElement | null) => void; }; +/** + * The content's size without the rounding `scrollHeight`/`scrollWidth` apply. + * + * Those are whole pixels: content 57.5px tall measures 58, so an open panel came out half a pixel + * taller than the same content laid out without the variable (on the server, before hydration), + * and everything under it moved when the page hydrated. Within a pixel of the laid-out box the + * scroll size is only that rounding, so the box's own fractional size is the answer; beyond it the + * content really overflows its box, and the scroll size is what the panel has to open to. + */ +export function exactSize(scrollSize: number, boxSize: number): number { + return Math.abs(scrollSize - boxSize) < 1 ? boxSize : scrollSize; +} + /** * Drives a collapsible panel's enter/exit transitions the way Base UI's Collapsible does: * - keeps the content mounted for the full exit animation, then unmounts it (or, with @@ -51,8 +64,8 @@ export function createCollapseTransition( function measure() { // Freeze the size while collapsing so the exit animates from the open height, not from 0. if (!measured || status === 'ending') return; - height = measured.scrollHeight; - width = measured.scrollWidth; + height = exactSize(measured.scrollHeight, measured.getBoundingClientRect().height); + width = exactSize(measured.scrollWidth, measured.getBoundingClientRect().width); } function cancelRelease() {