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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user