Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/collapse-fractional-height.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/ui/src/lib/primitives/collapse-transition.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down Expand Up @@ -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);
});
});
17 changes: 15 additions & 2 deletions packages/ui/src/lib/primitives/collapse-transition.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Loading