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