fix: prevent layout shift when ComboBox dropdown opens inside modal

Two root causes of the shift were identified and fixed:

1. Melt UI's `usePopper` applies `position: absolute` to the dropdown
   via floating-ui after a `tick()` delay. During that one frame the
   `ul` was briefly in normal document flow, momentarily growing the
   `<dialog>` element and causing a reflow. Pre-setting
   `[data-melt-combobox-menu] { position: absolute }` in base.css
   removes the element from flow immediately at mount.

2. Melt UI's ComboBox has `preventScroll: true` by default. On open it
   calls `removeScroll()` which sets `body.overflow: hidden` and adds
   compensatory `padding-right` for the scrollbar width — a body
   reflow that shifts the dialog. Pre-setting `data-melt-scroll-lock`
   on the body in `modal.svelte` tells Melt UI the lock is already
   active so it returns early without touching the body.

Also fix `on:search` on `Input.ComboBox` (which the component never
dispatches) by replacing it with native `oninput`/`onfocusin` handlers
on the wrapper `<div>`, so typed text actually triggers debounced
server-side project search. Projects are now ordered newest-first
(`Query.orderDesc('')`) matching the org projects page.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
harsh mahajan
2026-05-29 15:07:54 +05:30
parent 3b15be9786
commit fb13353a67
3 changed files with 36 additions and 4 deletions
+10
View File
@@ -27,6 +27,16 @@
$: if (error) {
alert?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
}
// Melt UI's ComboBox (and other listbox-based components) call removeScroll()
// when they open, which adds overflow:hidden + compensatory padding-right
$: if (typeof document !== 'undefined') {
if (show) {
document.body.setAttribute('data-melt-scroll-lock', '');
} else {
document.body.removeAttribute('data-melt-scroll-lock');
}
}
</script>
<Form isModal {onSubmit}>
+12
View File
@@ -4,3 +4,15 @@
scrollbar-width: thin;
position: relative;
}
/*
* Melt UI applies `position: absolute` to the combobox menu via floating-ui,
* but only after `tick()` — a one-frame delay. During that initial frame the
* menu is in the normal document flow, momentarily expanding any ancestor with
* `overflow: visible` (e.g. the <dialog> element) and causing a visible layout
* shift. Pre-setting `position: absolute` here removes the element from flow
* immediately at mount, so there is no reflow before floating-ui takes over.
*/
[data-melt-combobox-menu] {
position: absolute;
}
@@ -35,6 +35,7 @@
rowSearching[index] = true;
const queries: string[] = [
Query.limit(25),
Query.orderDesc(''),
Query.equal('teamId', $organization.$id)
];
if (search) {
@@ -83,7 +84,16 @@
<Layout.Stack gap="s">
{#each projectAccess as access, i}
<Layout.Stack direction="row" gap="s" alignItems="flex-end">
<div style:flex="1">
<div
style:flex="1"
onfocusin={() => {
if (!rowOptions[i]?.length) loadProjects(i);
}}
oninput={(e) => {
if (e.target instanceof HTMLInputElement) {
getDebouncedSearch(i)(e.target.value);
}
}}>
<Input.ComboBox
id="project-{i}"
label={i === 0 ? 'Project' : ''}
@@ -91,9 +101,9 @@
placeholder="Search projects"
bind:value={access.projectId}
options={rowOptions[i] ?? []}
noResultsOption={rowSearching[i] ? { disabled: true, message: 'Searching...' } : undefined}
on:search={(e) => getDebouncedSearch(i)(e.detail)}
on:focus={() => { if (!rowOptions[i]?.length) loadProjects(i); }} />
noResultsOption={rowSearching[i]
? { disabled: true, message: 'Searching...' }
: undefined} />
</div>
<div style:width="140px">
<InputSelect