Skip to content

Commit ca49b14

Browse files
committed
fix(console-crane): keep bundle list within the modal frame #86exzbh61
The bundle list was capped at pilotui's max-h-96 with an internal scroll, but the save section sits near the modal bottom, so the fixed downward panel still ran past the visible frame — the user had to scroll the modal AND the list. On open, measure the trigger against the nearest scroll frame and place the absolutely-positioned panel accordingly: flip it upward when it can't fully open downward and there's more room above, and cap its height to the available space (minus a gap) in whichever direction it opens. Reposition on resize/scroll while open; pilotui exposes no placement API, so this is done via inline styles on its rendered panel. The list scrolls internally within the cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsGMdHrjQfWRausa9T14kw
1 parent f9f1c8e commit ca49b14

1 file changed

Lines changed: 77 additions & 2 deletions

File tree

src/console-crane/components/SelectPhraseBundleV2.vue

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<template>
22
<div ref="rootRef" class="relative w-full">
33
<Select v-model="selected" :options="options" multiple custom labelKey="title" valueKey="_id"
4-
:placeholder="showSuggestion ? '' : 'Select Phrase Bundles to save...'" @open="isDropdownOpen = true"
5-
@close="isDropdownOpen = false">
4+
:placeholder="showSuggestion ? '' : 'Select Phrase Bundles to save...'" @open="onDropdownOpen"
5+
@close="onDropdownClose">
66
<template #selected="{
77
selectedOption,
88
selectedOptions,
@@ -319,6 +319,80 @@ function handleOutsidePointer(event: Event) {
319319
}
320320
}
321321
322+
/**
323+
* Size and place the open dropdown so it never runs past the modal frame
324+
* (ClickUp 86exzbh61 follow-up). The save section sits near the bottom of the
325+
* ConsoleCrane modal, so pilotui's fixed downward max-h-96 panel spilled below
326+
* the visible frame — forcing a modal scroll on top of the list's own scroll.
327+
*
328+
* pilotui has no placement API, so on open we measure the trigger against the
329+
* nearest scroll frame and set inline styles on its absolutely-positioned panel:
330+
* flip it upward when it can't fully open downward and there's more room above,
331+
* and cap its height to the available space (minus a gap) in whichever direction
332+
* it opens. The list scrolls internally within that cap (see scoped styles).
333+
*/
334+
function positionDropdown() {
335+
const root = rootRef.value;
336+
if (!root) return;
337+
const panel = root.querySelector<HTMLElement>('[role="listbox"]');
338+
const trigger = root.querySelector<HTMLElement>('button[aria-haspopup="true"]');
339+
if (!panel || !trigger) return;
340+
341+
const GAP = 12; // breathing room between the panel and the frame edge
342+
const MAX = 336; // pilotui's max-h-96 (24rem → 336px after the rem→px rewrite)
343+
344+
const frameEl =
345+
root.closest<HTMLElement>(".overflow-y-auto") ?? document.documentElement;
346+
const frame = frameEl.getBoundingClientRect();
347+
const t = trigger.getBoundingClientRect();
348+
349+
const below = frame.bottom - t.bottom - GAP;
350+
const above = t.top - frame.top - GAP;
351+
352+
// Flip up only when the list can't fully open downward and there's more room
353+
// above; otherwise keep the natural downward placement.
354+
const openUp = below < MAX && above > below;
355+
const avail = Math.max(0, openUp ? above : below);
356+
357+
panel.style.maxHeight = `${Math.min(MAX, avail)}px`;
358+
if (openUp) {
359+
panel.style.top = "auto";
360+
panel.style.bottom = "calc(100% + 4px)";
361+
panel.style.marginTop = "0";
362+
} else {
363+
// Clear any prior upward placement (panel is reused across reposition runs).
364+
panel.style.top = "";
365+
panel.style.bottom = "";
366+
panel.style.marginTop = "";
367+
}
368+
}
369+
370+
// Reposition the open dropdown when the viewport or modal body changes size /
371+
// scrolls; torn down again on close so the listeners only live while open.
372+
let removeReposition: (() => void) | null = null;
373+
374+
function onDropdownOpen() {
375+
isDropdownOpen.value = true;
376+
nextTick(positionDropdown);
377+
378+
if (removeReposition) return;
379+
const handler = () => positionDropdown();
380+
const frameEl: Window | HTMLElement =
381+
rootRef.value?.closest<HTMLElement>(".overflow-y-auto") ?? window;
382+
window.addEventListener("resize", handler);
383+
frameEl.addEventListener("scroll", handler, { passive: true });
384+
removeReposition = () => {
385+
window.removeEventListener("resize", handler);
386+
frameEl.removeEventListener("scroll", handler);
387+
};
388+
}
389+
390+
function onDropdownClose() {
391+
isDropdownOpen.value = false;
392+
removeReposition?.();
393+
removeReposition = null;
394+
}
395+
322396
onMounted(() => {
323397
fetchOptions();
324398
document.addEventListener("pointerdown", handleOutsidePointer);
@@ -329,6 +403,7 @@ onBeforeUnmount(() => {
329403
clearTimeout(searchDebounceTimer);
330404
}
331405
document.removeEventListener("pointerdown", handleOutsidePointer);
406+
removeReposition?.();
332407
});
333408
334409
defineExpose({

0 commit comments

Comments
 (0)