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
+118 -62
View File
@@ -14,7 +14,7 @@
* Quando promover pra produção: trocar a busca por chamada à RPC
* `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 { useRecentPatients } from '@/composables/useRecentPatients';
@@ -161,22 +161,15 @@ function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
function openDialog() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
// Foca input do Dialog após ele montar
nextTick(() => inputEl.value?.focus());
}
function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
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) {
e.preventDefault();
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) {
// Ctrl+K / ⌘+K → foca input
// Ctrl+K / ⌘+K → abre dialog
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
openDialog();
}
}
@@ -246,11 +235,9 @@ watch(query, (v) => {
});
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
@@ -258,22 +245,45 @@ onBeforeUnmount(() => {
<template>
<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" />
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho</span>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</button>
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
<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-field__input"
@focus="onFocus"
class="mb-dialog__input"
@keydown="onKeydown"
autocomplete="off"
spellcheck="false"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
</div>
<Transition name="mb-fade">
<div v-if="showPanel" class="mb-panel" role="listbox">
<!-- Painel de resultados (scroll interno) -->
<div class="mb-panel" role="listbox">
<div
v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty"
@@ -429,7 +439,7 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
</Transition>
</Dialog>
</div>
</template>
@@ -438,16 +448,17 @@ onBeforeUnmount(() => {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
.mb-field {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
@@ -455,11 +466,12 @@ onBeforeUnmount(() => {
border-radius: 12px;
padding: 0 14px;
height: 44px;
cursor: pointer;
text-align: left;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
.mb-field:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
@@ -467,18 +479,15 @@ onBeforeUnmount(() => {
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
.mb-field__placeholder {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--m-text, white); /* fallback white pro shell escuro do Melissa */
color: var(--m-text-muted);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-field__kbd {
color: var(--m-text-muted);
@@ -493,26 +502,82 @@ onBeforeUnmount(() => {
letter-spacing: 0.05em;
}
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
overflow-y: auto;
/* Background do tema atual (claro ou escuro) — herda do PrimeVue
theme tokens. Antes era hardcoded escuro/transparente e ficava
texto-branco-em-fundo-branco no tema claro. */
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
:global(.mb-dialog__mask) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.55) !important;
}
:global(.mb-dialog) {
border-radius: 14px !important;
overflow: hidden;
/* Posiciona mais alto que o centro (estilo Spotlight) */
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);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
flex-shrink: 0;
}
.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-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;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
background: var(--surface-card);
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
min-height: 0; /* permite shrink no flex */
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
@@ -624,13 +689,4 @@ onBeforeUnmount(() => {
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>