2dae4a11ae
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela user_recent_access — escolhi localStorage por simplicidade (sem migration adicional + zero round-trip por visita). composables/useRecentPatients.js: - useRecentPatients() — composable reativo Tipo A: items + hasItems + addVisit + remove + clear + refresh - registerPatientVisit(patient) — helper stateless pra usar fora de setup (ex: navigation guards, action handlers) - Sincroniza entre instancias na mesma aba via CustomEvent + 'storage' - Max 5 items. Dedup por id, novo no topo. Wire-up de visita (registra ao carregar prontuario): - MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load - PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved Wire-up de visualizacao (mostra quando query vazia): - GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos. goTo("recent") navega pra /therapist/patients/:id. - MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente') reusando a logica do MelissaLayout que ja navega pra /melissa/paciente?id=X. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
593 lines
22 KiB
Vue
593 lines
22 KiB
Vue
<script setup>
|
||
/*
|
||
* MelissaBusca
|
||
* --------------------------------------------------
|
||
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
|
||
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
|
||
*
|
||
* Diferenças vs. GlobalSearch:
|
||
* - Não chama Supabase — recebe pacientes/eventos via prop (preview)
|
||
* - Visual glass (white-on-glass) ao invés de surface-card
|
||
* - Sem loading state (busca client-side, instantâneo)
|
||
* - Emite ações pro parent decidir o que fazer
|
||
*
|
||
* 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 { supabase } from '@/lib/supabase/client';
|
||
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||
|
||
const props = defineProps({
|
||
pacientes: { type: Array, default: () => [] },
|
||
eventos: { type: Array, default: () => [] },
|
||
atalhos: {
|
||
type: Array,
|
||
default: () => [
|
||
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
|
||
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
|
||
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
|
||
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
|
||
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
|
||
]
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
|
||
|
||
const rootEl = ref(null);
|
||
const inputEl = ref(null);
|
||
const query = ref('');
|
||
const showPanel = ref(false);
|
||
const activeIndex = ref(-1);
|
||
|
||
// RPC search_global results — preenchidos por debounce conforme o usuário digita.
|
||
// Cliente-side (props.pacientes / props.eventos) continua sendo fallback rápido
|
||
// pra digitação curta (1 char) e como complemento aos primeiros resultados.
|
||
const rpcResults = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
|
||
const searching = ref(false);
|
||
let debounceT = null;
|
||
let searchSeq = 0;
|
||
|
||
// Recently viewed (localStorage) — só aparece quando o input está vazio
|
||
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
|
||
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
|
||
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
|
||
|
||
function normalize(s) {
|
||
return String(s || '')
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.toLowerCase()
|
||
.trim();
|
||
}
|
||
|
||
function fmtHora(h) {
|
||
const horas = Math.floor(h);
|
||
const mins = Math.round((h - horas) * 60);
|
||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||
}
|
||
|
||
const filteredAtalhos = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
|
||
return props.atalhos.filter((a) => {
|
||
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
|
||
return hay.includes(q);
|
||
}).slice(0, 5);
|
||
});
|
||
|
||
// Pacientes — combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
|
||
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
|
||
const filteredPacientes = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (q.length < 2) return [];
|
||
|
||
const rpc = rpcResults.value.patients || [];
|
||
if (rpc.length) {
|
||
return rpc.slice(0, 5).map(p => ({
|
||
id: p.id,
|
||
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
|
||
email: p.email,
|
||
telefone: p.telefone
|
||
}));
|
||
}
|
||
// Fallback client-side
|
||
return props.pacientes
|
||
.filter((p) => normalize(p.nome).includes(q))
|
||
.slice(0, 5);
|
||
});
|
||
|
||
const filteredEventos = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (q.length < 2) return [];
|
||
return props.eventos
|
||
.filter((e) => {
|
||
const hay = normalize(
|
||
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
|
||
);
|
||
return hay.includes(q);
|
||
})
|
||
.slice(0, 5);
|
||
});
|
||
|
||
// Resultados exclusivos da RPC (não há fallback client-side)
|
||
const rpcAppointments = computed(() => rpcResults.value.appointments || []);
|
||
const rpcDocuments = computed(() => rpcResults.value.documents || []);
|
||
const rpcIntakes = computed(() => rpcResults.value.intakes || []);
|
||
|
||
const flatList = computed(() => {
|
||
const out = [];
|
||
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
|
||
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
|
||
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
|
||
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
|
||
rpcAppointments.value.forEach((a, i) => out.push({ group: 'rpc-appointments', item: a, idx: i }));
|
||
rpcDocuments.value.forEach((d, i) => out.push({ group: 'rpc-documents', item: d, idx: i }));
|
||
rpcIntakes.value.forEach((r, i) => out.push({ group: 'rpc-intakes', item: r, idx: i }));
|
||
return out;
|
||
});
|
||
|
||
const hasAnyResult = computed(() => flatList.value.length > 0);
|
||
|
||
function findFlatIndex(group, idx) {
|
||
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
|
||
}
|
||
|
||
function selectEntry(entry) {
|
||
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
||
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
|
||
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
|
||
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
|
||
closePanel();
|
||
}
|
||
|
||
function closePanel() {
|
||
showPanel.value = false;
|
||
query.value = '';
|
||
activeIndex.value = -1;
|
||
inputEl.value?.blur();
|
||
}
|
||
|
||
function onFocus() {
|
||
showPanel.value = true;
|
||
}
|
||
|
||
function onClickOutside(e) {
|
||
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||
showPanel.value = false;
|
||
activeIndex.value = -1;
|
||
}
|
||
}
|
||
|
||
function onKeydown(e) {
|
||
if (!showPanel.value) return;
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||
} 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();
|
||
}
|
||
}
|
||
|
||
function onGlobalKeydown(e) {
|
||
// Ctrl+K / ⌘+K → foca input
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||
e.preventDefault();
|
||
showPanel.value = true;
|
||
inputEl.value?.focus();
|
||
}
|
||
}
|
||
|
||
// ── RPC search_global debounced ───────────────────────────────────────────
|
||
// Mesmo padrão do GlobalSearch.vue: query >= 2 chars dispara em 200ms,
|
||
// controla ordem via searchSeq pra ignorar respostas obsoletas.
|
||
function resetRpcResults() {
|
||
rpcResults.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
|
||
}
|
||
|
||
watch(query, (v) => {
|
||
if (debounceT) clearTimeout(debounceT);
|
||
const q = String(v || '').trim();
|
||
if (q.length < 2) {
|
||
resetRpcResults();
|
||
searching.value = false;
|
||
return;
|
||
}
|
||
searching.value = true;
|
||
const mySeq = ++searchSeq;
|
||
debounceT = setTimeout(async () => {
|
||
try {
|
||
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
|
||
if (mySeq !== searchSeq) return;
|
||
if (error) {
|
||
console.error('[MelissaBusca search_global]', error);
|
||
resetRpcResults();
|
||
} else {
|
||
rpcResults.value = {
|
||
patients: Array.isArray(data?.patients) ? data.patients : [],
|
||
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
|
||
documents: Array.isArray(data?.documents) ? data.documents : [],
|
||
services: Array.isArray(data?.services) ? data.services : [],
|
||
intakes: Array.isArray(data?.intakes) ? data.intakes : []
|
||
};
|
||
}
|
||
} catch (e) {
|
||
if (mySeq !== searchSeq) return;
|
||
console.error('[MelissaBusca search_global exception]', e);
|
||
resetRpcResults();
|
||
} finally {
|
||
if (mySeq === searchSeq) searching.value = false;
|
||
}
|
||
}, 200);
|
||
});
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('mousedown', onClickOutside);
|
||
window.addEventListener('keydown', onGlobalKeydown);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('mousedown', onClickOutside);
|
||
window.removeEventListener('keydown', onGlobalKeydown);
|
||
if (debounceT) clearTimeout(debounceT);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootEl" class="mb-search">
|
||
<div class="mb-field">
|
||
<i class="pi pi-search mb-field__icon" />
|
||
<input
|
||
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>
|
||
</div>
|
||
|
||
<Transition name="mb-fade">
|
||
<div v-if="showPanel" class="mb-panel" role="listbox">
|
||
<div
|
||
v-if="query.trim().length >= 2 && !hasAnyResult"
|
||
class="mb-empty"
|
||
>
|
||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||
</div>
|
||
|
||
<!-- Acessados recentemente (só quando query vazia) -->
|
||
<div v-if="showRecent" class="mb-group">
|
||
<div class="mb-group__title">Acessados recentemente</div>
|
||
<button
|
||
v-for="(p, i) in recentItems"
|
||
:key="'mr-' + p.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'recent', item: p })"
|
||
@mouseenter="activeIndex = findFlatIndex('recent', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-history" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ p.nome }}</span>
|
||
<span class="mb-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Atalhos -->
|
||
<div v-if="filteredAtalhos.length" class="mb-group">
|
||
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||
<button
|
||
v-for="(a, i) in filteredAtalhos"
|
||
:key="'a-' + a.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'atalhos', item: a })"
|
||
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
|
||
>
|
||
<span class="mb-item__icon"><i :class="a.icon" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ a.label }}</span>
|
||
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Pacientes -->
|
||
<div v-if="filteredPacientes.length" class="mb-group">
|
||
<div class="mb-group__title">Pacientes</div>
|
||
<button
|
||
v-for="(p, i) in filteredPacientes"
|
||
:key="'p-' + p.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'pacientes', item: p })"
|
||
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-user" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ p.nome }}</span>
|
||
<span class="mb-item__sub">Abrir prontuário</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Eventos -->
|
||
<div v-if="filteredEventos.length" class="mb-group">
|
||
<div class="mb-group__title">Agenda de hoje</div>
|
||
<button
|
||
v-for="(e, i) in filteredEventos"
|
||
:key="'e-' + e.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'eventos', item: e })"
|
||
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
|
||
>
|
||
<span
|
||
class="mb-item__icon"
|
||
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
|
||
>
|
||
<i class="pi pi-clock" />
|
||
</span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ e.label }}</span>
|
||
<span class="mb-item__sub">{{ fmtHora(e.startH) }} – {{ fmtHora(e.endH) }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Sessões/agendamentos (qualquer data) -->
|
||
<div v-if="rpcAppointments.length" class="mb-group">
|
||
<div class="mb-group__title">Sessões</div>
|
||
<button
|
||
v-for="(e, i) in rpcAppointments"
|
||
:key="'rpc-a-' + e.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-appointments', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-appointments', item: e })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
|
||
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Documentos -->
|
||
<div v-if="rpcDocuments.length" class="mb-group">
|
||
<div class="mb-group__title">Documentos</div>
|
||
<button
|
||
v-for="(d, i) in rpcDocuments"
|
||
:key="'rpc-d-' + d.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-documents', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-documents', item: d })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-file" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
|
||
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Cadastros recebidos (intakes) -->
|
||
<div v-if="rpcIntakes.length" class="mb-group">
|
||
<div class="mb-group__title">Cadastros recebidos</div>
|
||
<button
|
||
v-for="(r, i) in rpcIntakes"
|
||
:key="'rpc-i-' + r.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-intakes', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-intakes', item: r })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
|
||
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mb-search {
|
||
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;
|
||
}
|
||
|
||
.mb-field {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--m-bg-soft);
|
||
backdrop-filter: blur(20px) saturate(140%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 12px;
|
||
padding: 0 14px;
|
||
height: 44px;
|
||
transition: background-color 160ms ease, border-color 160ms ease;
|
||
}
|
||
.mb-field:focus-within {
|
||
background: var(--m-bg-soft-hover);
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
.mb-field__icon {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.95rem;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.mb-field__input {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
color: white;
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
min-width: 0;
|
||
}
|
||
.mb-field__input::placeholder {
|
||
color: var(--m-text-muted);
|
||
}
|
||
.mb-field__kbd {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.62rem;
|
||
font-weight: 500;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
margin-left: 8px;
|
||
flex-shrink: 0;
|
||
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: var(--m-bg-medium);
|
||
backdrop-filter: blur(28px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 12px;
|
||
padding: 6px;
|
||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--m-border-strong) transparent;
|
||
}
|
||
.mb-panel::-webkit-scrollbar { width: 6px; }
|
||
.mb-panel::-webkit-scrollbar-thumb {
|
||
background: var(--m-border-strong);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.mb-empty {
|
||
padding: 18px 14px;
|
||
text-align: center;
|
||
color: var(--m-text-muted);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.mb-group + .mb-group {
|
||
margin-top: 4px;
|
||
padding-top: 4px;
|
||
border-top: 1px solid var(--m-border);
|
||
}
|
||
.mb-group__title {
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.18em;
|
||
color: var(--m-text-faint);
|
||
font-size: 0.6rem;
|
||
font-weight: 600;
|
||
padding: 8px 10px 4px;
|
||
}
|
||
|
||
.mb-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: white;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: background-color 100ms ease;
|
||
}
|
||
.mb-item:hover,
|
||
.mb-item.is-active {
|
||
background: var(--m-bg-soft);
|
||
}
|
||
.mb-item__icon {
|
||
width: 30px;
|
||
height: 30px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--m-bg-soft);
|
||
border-radius: 7px;
|
||
color: var(--m-text-muted);
|
||
flex-shrink: 0;
|
||
font-size: 0.85rem;
|
||
}
|
||
.mb-item__main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.mb-item__label {
|
||
font-size: 0.85rem;
|
||
color: white;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mb-item__sub {
|
||
font-size: 0.7rem;
|
||
color: var(--m-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mb-item__go {
|
||
color: var(--m-text-faint);
|
||
font-size: 0.7rem;
|
||
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>
|