Skip to content

Commit 646094e

Browse files
authored
Merge pull request #37 from codebridger/CU-86exzbh61_Fix-bundle-selector-dropdown-in-save-phrase-modal-wont-close-overflows-wrapper
Fix SelectPhraseBundleV2 dropdown close in modal context
2 parents 2c84d80 + ca49b14 commit 646094e

2 files changed

Lines changed: 266 additions & 5 deletions

File tree

src/console-crane/components/SelectPhraseBundleV2.vue

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<template>
2-
<div class="relative w-full">
2+
<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...'">
4+
:placeholder="showSuggestion ? '' : 'Select Phrase Bundles to save...'" @open="onDropdownOpen"
5+
@close="onDropdownClose">
56
<template #selected="{
67
selectedOption,
78
selectedOptions,
@@ -108,6 +109,11 @@ const isCreating = ref(false);
108109
const searchedBundleName = ref("");
109110
const options = ref<PhraseBundleType[]>([]);
110111
112+
// Component root — boundary for our own outside-click close (see below).
113+
const rootRef = ref<HTMLElement | null>(null);
114+
// Mirrors pilotui Select's internal open state via its open/close events.
115+
const isDropdownOpen = ref(false);
116+
111117
// In-field suggested bundle (shown only when nothing is selected yet).
112118
const isEditingSuggested = ref(false);
113119
const editBuffer = ref("");
@@ -273,19 +279,133 @@ watch(searchedBundleName, () => {
273279
}, 300); // 300ms debounce
274280
});
275281
282+
/**
283+
* Close the pilotui Select dropdown.
284+
*
285+
* pilotui's Select owns its open state internally and exposes no close method or
286+
* `open` prop — the only outside-driven close it offers is its own document
287+
* click handler, which bails whenever the click lands inside ANY `.relative`
288+
* ancestor (Select.vue `handleClickOutside`). Inside the ConsoleCrane modal —
289+
* where almost everything sits under a Tailwind `relative` wrapper — that guard
290+
* matches on nearly every click, so the dropdown effectively never closes.
291+
*
292+
* We drive pilotui's own close path instead by dispatching the Escape key its
293+
* trigger button already handles (`handleKeydown` → `closeDropdown`). It's
294+
* idempotent: closing an already-closed dropdown is a no-op, so this can't
295+
* accidentally re-open. Used both for our outside-click handler and by
296+
* SaveWordSectionV2 after a successful save.
297+
*/
298+
function closeDropdown() {
299+
const trigger = rootRef.value?.querySelector<HTMLButtonElement>(
300+
'button[aria-haspopup="true"]'
301+
);
302+
trigger?.dispatchEvent(
303+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
304+
);
305+
}
306+
307+
/**
308+
* Close the dropdown when the user clicks anywhere outside this component.
309+
* Replaces pilotui's `.relative`-based outside detection, which misfires inside
310+
* the modal (see closeDropdown). We only act while open and only for clicks that
311+
* land outside our root, so in-dropdown interactions (multi-select toggles,
312+
* search, create, chip removal, suggestion edit) are untouched.
313+
*/
314+
function handleOutsidePointer(event: Event) {
315+
if (!isDropdownOpen.value) return;
316+
const root = rootRef.value;
317+
if (root && !root.contains(event.target as Node)) {
318+
closeDropdown();
319+
}
320+
}
321+
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+
276396
onMounted(() => {
277397
fetchOptions();
398+
document.addEventListener("pointerdown", handleOutsidePointer);
278399
});
279400
280401
onBeforeUnmount(() => {
281402
if (searchDebounceTimer) {
282403
clearTimeout(searchDebounceTimer);
283404
}
405+
document.removeEventListener("pointerdown", handleOutsidePointer);
406+
removeReposition?.();
284407
});
285408
286-
// Expose method for compatibility (Select manages its own open state)
287-
function closeDropdown() { }
288-
289409
defineExpose({
290410
closeDropdown,
291411
});
@@ -324,4 +444,26 @@ defineExpose({
324444
.relative.w-full :deep(.flex.flex-col > .relative > .relative > button) {
325445
flex: 1 1 auto;
326446
}
447+
448+
/*
449+
Keep the open dropdown panel inside its own max-height instead of spilling the
450+
bundle list out of the modal (ClickUp 86exzbh61). pilotui only gives the
451+
option list an internal scroll in `confirm` mode; in the `custom` mode we use
452+
here the list container is a plain `flex-1` with no overflow, so a long list
453+
grows past the panel's max-height and out of the modal. Make the panel a flex
454+
column and let the list region scroll within it.
455+
456+
Targets pilotui's internal markup (the absolutely-positioned listbox panel and
457+
its `flex-1` body); a pilotui nesting change would make this a cosmetic
458+
regression, not a functional break.
459+
*/
460+
.relative.w-full :deep([role="listbox"]) {
461+
display: flex;
462+
flex-direction: column;
463+
}
464+
465+
.relative.w-full :deep([role="listbox"] > .flex-1) {
466+
min-height: 0;
467+
overflow-y: auto;
468+
}
327469
</style>

tests/select-phrase-bundle.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { mount, flushPromises } from "@vue/test-utils";
3+
import { createPinia } from "pinia";
4+
import { nextTick } from "vue";
5+
import { Select } from "pilotui";
6+
7+
/**
8+
* Regression net for ClickUp 86exzbh61: the bundle selector dropdown in the save
9+
* modal wouldn't close. pilotui's Select only closes from outside via its own
10+
* `.relative`-based document handler, which misfires inside the ConsoleCrane
11+
* modal (almost everything sits under a Tailwind `relative` wrapper) and exposes
12+
* no close method/prop. SelectPhraseBundleV2 now runs its own outside-click
13+
* close and a working `closeDropdown()` (also used by SaveWordSectionV2 after a
14+
* save), both of which drive pilotui's own close path by dispatching the Escape
15+
* key its trigger button handles.
16+
*
17+
* These tests assert that mechanism: the Escape keydown reaches the Select's
18+
* trigger on an outside click / on closeDropdown(), and is NOT fired for clicks
19+
* inside the dropdown (so multi-select toggles keep it open). They deliberately
20+
* don't rely on pilotui actually rendering its open panel in happy-dom — that
21+
* round trip is covered by the real-browser flow; here we pin our own logic.
22+
*/
23+
24+
const { BUNDLES } = vi.hoisted(() => ({
25+
BUNDLES: Array.from({ length: 14 }, (_, i) => ({
26+
_id: `b${i}`,
27+
title: `Bundle ${i}`,
28+
})),
29+
}));
30+
31+
vi.mock("@modular-rest/client", () => ({
32+
dataProvider: {
33+
find: vi.fn().mockResolvedValue(BUNDLES),
34+
insertOne: vi.fn().mockResolvedValue({ _id: "new" }),
35+
},
36+
authentication: { user: { id: "u1" } },
37+
}));
38+
39+
import SelectPhraseBundleV2 from "../src/console-crane/components/SelectPhraseBundleV2.vue";
40+
41+
async function mountSelector() {
42+
const wrapper = mount(SelectPhraseBundleV2, {
43+
attachTo: document.body,
44+
props: { selectedBundles: [] as string[] },
45+
global: { plugins: [createPinia()] },
46+
});
47+
await flushPromises(); // fetchOptions() resolves
48+
49+
const triggerEl = wrapper
50+
.find('button[aria-haspopup="true"]')
51+
.element as HTMLButtonElement;
52+
53+
// Spy on the Escape keydown our close path dispatches at the trigger — this is
54+
// what reaches pilotui's own closeDropdown handler.
55+
const escapes: string[] = [];
56+
triggerEl.addEventListener("keydown", (e) => {
57+
escapes.push((e as KeyboardEvent).key);
58+
});
59+
60+
// Mirror pilotui opening its dropdown (we listen to its `open` event).
61+
const openDropdown = () => wrapper.findComponent(Select).vm.$emit("open");
62+
63+
return { wrapper, triggerEl, escapes, openDropdown };
64+
}
65+
66+
describe("SelectPhraseBundleV2 dropdown close behaviour", () => {
67+
let outside: HTMLElement;
68+
69+
beforeEach(() => {
70+
document.body.innerHTML = "";
71+
outside = document.createElement("div");
72+
document.body.appendChild(outside);
73+
});
74+
75+
it("closes on an outside click while open", async () => {
76+
const { wrapper, escapes, openDropdown } = await mountSelector();
77+
openDropdown();
78+
await nextTick();
79+
80+
outside.dispatchEvent(new Event("pointerdown", { bubbles: true }));
81+
await nextTick();
82+
83+
expect(escapes).toContain("Escape");
84+
wrapper.unmount();
85+
});
86+
87+
it("does nothing on an outside click while already closed", async () => {
88+
const { wrapper, escapes } = await mountSelector();
89+
// No openDropdown() — isDropdownOpen stays false.
90+
outside.dispatchEvent(new Event("pointerdown", { bubbles: true }));
91+
await nextTick();
92+
93+
expect(escapes).toHaveLength(0);
94+
wrapper.unmount();
95+
});
96+
97+
it("stays open when interacting inside the dropdown (multi-select)", async () => {
98+
const { wrapper, triggerEl, escapes, openDropdown } = await mountSelector();
99+
openDropdown();
100+
await nextTick();
101+
102+
// A pointer press inside our root (e.g. toggling an option) must not be
103+
// treated as an outside click.
104+
triggerEl.dispatchEvent(new Event("pointerdown", { bubbles: true }));
105+
await nextTick();
106+
107+
expect(escapes).toHaveLength(0);
108+
wrapper.unmount();
109+
});
110+
111+
it("exposes a working closeDropdown() for the post-save path", async () => {
112+
const { wrapper, escapes } = await mountSelector();
113+
(wrapper.vm as unknown as { closeDropdown: () => void }).closeDropdown();
114+
await nextTick();
115+
116+
expect(escapes).toContain("Escape");
117+
wrapper.unmount();
118+
});
119+
});

0 commit comments

Comments
 (0)