|
1 | 1 | <template> |
2 | | - <div class="relative w-full"> |
| 2 | + <div ref="rootRef" class="relative w-full"> |
3 | 3 | <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"> |
5 | 6 | <template #selected="{ |
6 | 7 | selectedOption, |
7 | 8 | selectedOptions, |
@@ -108,6 +109,11 @@ const isCreating = ref(false); |
108 | 109 | const searchedBundleName = ref(""); |
109 | 110 | const options = ref<PhraseBundleType[]>([]); |
110 | 111 |
|
| 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 | +
|
111 | 117 | // In-field suggested bundle (shown only when nothing is selected yet). |
112 | 118 | const isEditingSuggested = ref(false); |
113 | 119 | const editBuffer = ref(""); |
@@ -273,19 +279,133 @@ watch(searchedBundleName, () => { |
273 | 279 | }, 300); // 300ms debounce |
274 | 280 | }); |
275 | 281 |
|
| 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 | +
|
276 | 396 | onMounted(() => { |
277 | 397 | fetchOptions(); |
| 398 | + document.addEventListener("pointerdown", handleOutsidePointer); |
278 | 399 | }); |
279 | 400 |
|
280 | 401 | onBeforeUnmount(() => { |
281 | 402 | if (searchDebounceTimer) { |
282 | 403 | clearTimeout(searchDebounceTimer); |
283 | 404 | } |
| 405 | + document.removeEventListener("pointerdown", handleOutsidePointer); |
| 406 | + removeReposition?.(); |
284 | 407 | }); |
285 | 408 |
|
286 | | -// Expose method for compatibility (Select manages its own open state) |
287 | | -function closeDropdown() { } |
288 | | -
|
289 | 409 | defineExpose({ |
290 | 410 | closeDropdown, |
291 | 411 | }); |
@@ -324,4 +444,26 @@ defineExpose({ |
324 | 444 | .relative.w-full :deep(.flex.flex-col > .relative > .relative > button) { |
325 | 445 | flex: 1 1 auto; |
326 | 446 | } |
| 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 | +} |
327 | 469 | </style> |
0 commit comments