busca melissa: troca popover por Dialog Spotlight (CMD+K pattern)

Refatora MelissaBusca pra usar PrimeVue Dialog em vez de popover
absolute. Resolve definitivamente o bug do panel estourar viewport
quando ha muitos resultados.

Mudancas:

1. Trigger no dock: input -> <button> com aparencia de input. Clica
   ou Ctrl+K abre Dialog. Mantem placeholder + Ctrl+K kbd hint.

2. Dialog Spotlight: 640px max-width, posicionado 10vh do topo
   (estilo Spotlight macOS / Linear / GitHub). Backdrop blur escuro,
   dismissable mask, sem header, sem closable button (Esc cobre).

3. Input REAL dentro do Dialog: autofocus on open via nextTick.
   Mantem v-model="query" + @keydown="onKeydown" (Arrow/Enter).

4. Panel de resultados: era position:absolute com max-height:60vh
   (estourava em layouts com input perto do bottom). Agora vive
   DENTRO do Dialog (flex:1, max-height:70vh no content), scroll
   interno garantido por design — conteudo NUNCA passa do bottom
   da pagina.

5. Remove: onClickOutside (dismissableMask cobre), Transition
   mb-fade (Dialog tem sua animacao).

Comportamento end-user identico (Ctrl+K, navegacao com setas, Enter
seleciona, Esc fecha) mas visual + manutencao muito melhor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 12:52:19 -03:00
parent b40116fe5d
commit 30367392ff
+125 -69
View File
@@ -14,7 +14,7 @@
* Quando promover pra produção: trocar a busca por chamada à RPC * Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items. * `search_global` + manter a mesma estrutura de panel/items.
*/ */
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients'; import { useRecentPatients } from '@/composables/useRecentPatients';
@@ -161,22 +161,15 @@ function closePanel() {
showPanel.value = false; showPanel.value = false;
query.value = ''; query.value = '';
activeIndex.value = -1; activeIndex.value = -1;
inputEl.value?.blur();
} }
function onFocus() { function openDialog() {
showPanel.value = true; showPanel.value = true;
} // Foca input do Dialog após ele montar
nextTick(() => inputEl.value?.focus());
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
} }
function onKeydown(e) { function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1); activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
@@ -186,19 +179,15 @@ function onKeydown(e) {
} else if (e.key === 'Enter' && activeIndex.value >= 0) { } else if (e.key === 'Enter' && activeIndex.value >= 0) {
e.preventDefault(); e.preventDefault();
selectEntry(flatList.value[activeIndex.value]); selectEntry(flatList.value[activeIndex.value]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
} }
// Escape é tratado pelo Dialog (dismissableMask + closable)
} }
function onGlobalKeydown(e) { function onGlobalKeydown(e) {
// Ctrl+K / ⌘+K → foca input // Ctrl+K / ⌘+K → abre dialog
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); e.preventDefault();
showPanel.value = true; openDialog();
inputEl.value?.focus();
} }
} }
@@ -246,11 +235,9 @@ watch(query, (v) => {
}); });
onMounted(() => { onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown); window.addEventListener('keydown', onGlobalKeydown);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown); window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT); if (debounceT) clearTimeout(debounceT);
}); });
@@ -258,22 +245,45 @@ onBeforeUnmount(() => {
<template> <template>
<div ref="rootEl" class="mb-search"> <div ref="rootEl" class="mb-search">
<div class="mb-field"> <!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
<i class="pi pi-search mb-field__icon" /> <i class="pi pi-search mb-field__icon" />
<input <span class="mb-field__placeholder">Buscar paciente, agenda, atalho</span>
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span> <span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div> </button>
<Transition name="mb-fade"> <!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
<div v-if="showPanel" class="mb-panel" role="listbox"> <Dialog
v-model:visible="showPanel"
modal
:draggable="false"
:closable="false"
:dismissableMask="true"
:showHeader="false"
class="mb-dialog"
:style="{ width: '640px', maxWidth: '94vw' }"
pt:mask:class="mb-dialog__mask"
pt:content:class="mb-dialog__content"
@hide="closePanel"
>
<!-- Field do Dialog (input real, autofocus) -->
<div class="mb-dialog__field">
<i class="pi pi-search mb-dialog__field-icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-dialog__input"
@keydown="onKeydown"
autocomplete="off"
spellcheck="false"
/>
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
</div>
<!-- Painel de resultados (scroll interno) -->
<div class="mb-panel" role="listbox">
<div <div
v-if="query.trim().length >= 2 && !hasAnyResult" v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty" class="mb-empty"
@@ -429,7 +439,7 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
</div> </div>
</Transition> </Dialog>
</div> </div>
</template> </template>
@@ -438,16 +448,17 @@ onBeforeUnmount(() => {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
} }
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
.mb-field { .mb-field {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%;
background: var(--m-bg-soft); background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%); backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%); -webkit-backdrop-filter: blur(20px) saturate(140%);
@@ -455,11 +466,12 @@ onBeforeUnmount(() => {
border-radius: 12px; border-radius: 12px;
padding: 0 14px; padding: 0 14px;
height: 44px; height: 44px;
cursor: pointer;
text-align: left;
transition: background-color 160ms ease, border-color 160ms ease; transition: background-color 160ms ease, border-color 160ms ease;
} }
.mb-field:focus-within { .mb-field:hover {
background: var(--m-bg-soft-hover); background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
} }
.mb-field__icon { .mb-field__icon {
color: var(--m-text-muted); color: var(--m-text-muted);
@@ -467,18 +479,15 @@ onBeforeUnmount(() => {
margin-right: 10px; margin-right: 10px;
flex-shrink: 0; flex-shrink: 0;
} }
.mb-field__input { .mb-field__placeholder {
flex: 1; flex: 1;
background: transparent; color: var(--m-text-muted);
border: none;
outline: none;
color: var(--m-text, white); /* fallback white pro shell escuro do Melissa */
font-size: 0.9rem; font-size: 0.9rem;
font-family: inherit; font-family: inherit;
min-width: 0; min-width: 0;
} overflow: hidden;
.mb-field__input::placeholder { text-overflow: ellipsis;
color: var(--m-text-muted); white-space: nowrap;
} }
.mb-field__kbd { .mb-field__kbd {
color: var(--m-text-muted); color: var(--m-text-muted);
@@ -493,26 +502,82 @@ onBeforeUnmount(() => {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.mb-panel { /* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
position: absolute; :global(.mb-dialog__mask) {
top: calc(100% + 6px); backdrop-filter: blur(8px);
left: 0; -webkit-backdrop-filter: blur(8px);
right: 0; background: rgba(0, 0, 0, 0.55) !important;
z-index: 30; }
max-height: 60vh; :global(.mb-dialog) {
overflow-y: auto; border-radius: 14px !important;
/* Background do tema atual (claro ou escuro) — herda do PrimeVue overflow: hidden;
theme tokens. Antes era hardcoded escuro/transparente e ficava /* Posiciona mais alto que o centro (estilo Spotlight) */
texto-branco-em-fundo-branco no tema claro. */ margin-top: 10vh !important;
align-self: flex-start;
}
:global(.mb-dialog .mb-dialog__content) {
padding: 0 !important;
background: var(--surface-card) !important;
border-radius: 14px !important;
overflow: hidden;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.mb-dialog__field {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card); background: var(--surface-card);
backdrop-filter: blur(20px) saturate(140%); flex-shrink: 0;
-webkit-backdrop-filter: blur(20px) saturate(140%); }
.mb-dialog__field-icon {
color: var(--text-color-secondary);
font-size: 1.05rem;
flex-shrink: 0;
}
.mb-dialog__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 1.05rem;
font-family: inherit;
min-width: 0;
}
.mb-dialog__input::placeholder {
color: var(--text-color-secondary);
opacity: 0.7;
}
.mb-dialog__esc {
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--surface-ground);
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
border-radius: 12px; flex-shrink: 0;
letter-spacing: 0.05em;
opacity: 0.8;
}
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
resolve o bug de overflow — o conteúdo nunca passa do bottom porque
o Dialog tem max-height e o panel é flex:1. */
.mb-panel {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 6px; padding: 6px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); background: var(--surface-card);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent; scrollbar-color: var(--surface-border) transparent;
min-height: 0; /* permite shrink no flex */
} }
.mb-panel::-webkit-scrollbar { width: 6px; } .mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb { .mb-panel::-webkit-scrollbar-thumb {
@@ -624,13 +689,4 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style> </style>