ZERADO
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
<!-- src/layout/concepcoes/ex-header-conceitual.vue -->
|
||||
<!-- ===========================================================
|
||||
TEMPLATE DE REFERÊNCIA — Hero Header Sticky
|
||||
Padrão utilizado em: AgendaTerapeutaPage, ProfilePage
|
||||
===========================================================
|
||||
|
||||
ESTRUTURA GERAL
|
||||
───────────────
|
||||
1. Sentinel (div 1px) + IntersectionObserver → detecta quando o header
|
||||
"cola" no topo da viewport e ativa a classe --stuck.
|
||||
2. Hero div com position:sticky, top: var(--layout-sticky-top, 56px).
|
||||
Layout 1 (classic): 56px (topbar fixed). Layout 2 (rail): 0px (topbar no fluxo).
|
||||
3. Dois estados:
|
||||
- Expandido : blobs decorativos visíveis, subtítulo, filtros e busca
|
||||
- Colado : comprimido (max-height), apenas brand + ações essenciais
|
||||
4. Responsividade:
|
||||
- ≥1200px : todos os controles inline (ag-hero__desktop-controls)
|
||||
- <1200px : botão "Ações" abre Menu popup (ag-hero__mobile-controls)
|
||||
O menu mobile DEVE incluir "Buscar" abrindo um Dialog com input + resultados.
|
||||
|
||||
SCRIPT (refs + onMounted)
|
||||
─────────────────────────
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerEl = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
const headerMenuRef = ref(null)
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ label: 'Ação principal', icon: 'pi pi-plus', command: () => acaoPrincipal() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchModalOpen.value = true } },
|
||||
{ separator: true },
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
|
||||
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
if (headerSentinelRef.value) {
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
|
||||
)
|
||||
io.observe(headerSentinelRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
CSS (scoped)
|
||||
────────────
|
||||
Ver seção <style> ao final deste arquivo.
|
||||
|
||||
BUSCA MOBILE
|
||||
────────────
|
||||
O Dialog de busca deve ter o InputText DENTRO do dialog (não só resultados),
|
||||
com autofocus, compartilhando o mesmo v-model="search" do header desktop.
|
||||
Estados: sem texto → instrução | buscando → loading | sem resultado | lista.
|
||||
=========================================================== -->
|
||||
|
||||
<!-- ── SENTINEL ─────────────────────────────────────────── -->
|
||||
<div ref="headerSentinelRef" class="pg-sentinel" />
|
||||
|
||||
<!-- ── HERO HEADER ──────────────────────────────────────── -->
|
||||
<div ref="headerEl" class="pg-hero mb-4" :class="{ 'pg-hero--stuck': headerStuck }">
|
||||
|
||||
<!-- Blobs decorativos (some automaticamente quando colado via overflow:hidden) -->
|
||||
<div class="pg-hero__blobs" aria-hidden="true">
|
||||
<div class="pg-hero__blob pg-hero__blob--1" />
|
||||
<div class="pg-hero__blob pg-hero__blob--2" />
|
||||
<div class="pg-hero__blob pg-hero__blob--3" />
|
||||
</div>
|
||||
|
||||
<!-- ── Linha 1: brand + controles ── -->
|
||||
<div class="pg-hero__row1">
|
||||
|
||||
<!-- Brand: ícone + título + subtítulo (some quando colado) -->
|
||||
<div class="pg-hero__brand">
|
||||
<div class="pg-hero__icon">
|
||||
<i class="pi pi-ICON_AQUI text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="pg-hero__title">Título da Página</div>
|
||||
<div v-if="!headerStuck" class="pg-hero__sub">Subtítulo ou data/contexto atual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="pg-hero__desktop-controls">
|
||||
|
||||
<!-- Grupo de busca (oculto quando colado) -->
|
||||
<div v-if="!headerStuck" class="w-[260px]">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" autocomplete="off" @keyup.enter="searchModalOpen = true" />
|
||||
</IconField>
|
||||
<label>Buscar…</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Ações secundárias (ocultas quando colado, ex: filtros contextuais) -->
|
||||
<div v-if="!headerStuck" class="flex items-center gap-2">
|
||||
<!-- SplitButton, Dropdown, etc. -->
|
||||
</div>
|
||||
|
||||
<!-- Ações primárias (sempre visíveis) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Ação principal" @click="acaoPrincipal" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão mobile (<1200px) -->
|
||||
<div class="pg-hero__mobile-controls">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full"
|
||||
@click="(e) => headerMenuRef.toggle(e)" />
|
||||
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Linha 2: filtros/KPIs — oculta quando colado ── -->
|
||||
<div v-if="!headerStuck" class="pg-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- SelectButtons, Tags de filtro, KPIs clicáveis, etc. -->
|
||||
<Button class="!rounded-full" outlined severity="secondary">
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-list" /> Total: <b>{{ total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Chips de filtros ativos + limpar -->
|
||||
<div v-if="hasActiveFilters" class="flex items-center gap-2">
|
||||
<Tag value="Filtro ativo" severity="secondary" />
|
||||
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearFilters" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ── DIALOG DE BUSCA (mobile + desktop) ───────────────── -->
|
||||
<!--
|
||||
REGRA: o InputText de busca FICA DENTRO do dialog.
|
||||
Isso garante boa UX no mobile (teclado não cobre resultados).
|
||||
O v-model="search" é o mesmo do header desktop — resultados sincronizados.
|
||||
-->
|
||||
<Dialog
|
||||
v-model:visible="searchModalOpen"
|
||||
modal
|
||||
header="Buscar"
|
||||
:style="{ width: '96vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" autocomplete="off" autofocus />
|
||||
</IconField>
|
||||
<label>Nome, e-mail, título…</label>
|
||||
</FloatLabel>
|
||||
|
||||
<Divider class="my-0" />
|
||||
|
||||
<div v-if="!searchTrim" class="text-color-secondary text-sm py-2">
|
||||
Digite para buscar.
|
||||
</div>
|
||||
<div v-else-if="searchLoading" class="text-color-secondary text-sm">Buscando…</div>
|
||||
<div v-else-if="!searchResults.length" class="text-color-secondary text-sm">
|
||||
Nenhum resultado para "<b>{{ searchTrim }}</b>".
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
|
||||
<div class="text-xs text-color-secondary mb-1">{{ searchResults.length }} resultado(s)</div>
|
||||
<button
|
||||
v-for="r in searchResults" :key="r.id"
|
||||
class="text-left rounded-2xl border border-[var(--surface-border)] p-3 transition hover:shadow-sm"
|
||||
@click="gotoResultFromModal(r)"
|
||||
>
|
||||
<div class="font-medium truncate">{{ r.titulo || r.nome }}</div>
|
||||
<div class="mt-1 text-xs opacity-70 truncate">{{ r.subtitulo }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
|
||||
<Button v-if="searchTrim" label="Limpar" icon="pi pi-eraser" severity="secondary" outlined class="rounded-full" @click="search = ''; searchModalOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
CSS DE REFERÊNCIA (copiar para <style scoped> da página)
|
||||
Prefixo: "pg-" → substitua pelo prefixo da página (ag-, prof-, etc.)
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<style scoped>
|
||||
/* Sentinel */
|
||||
.pg-sentinel { height: 1px; }
|
||||
|
||||
/* Hero base */
|
||||
.pg-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px); /* 56px Layout1 / 0px Layout2 (Rail) */
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* Estado colado */
|
||||
.pg-hero--stuck {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
max-height: 64px;
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
/* Blobs decorativos */
|
||||
.pg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.pg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.pg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
|
||||
.pg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
|
||||
.pg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.pg-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.pg-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex-shrink: 0; min-width: 0;
|
||||
}
|
||||
.pg-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.pg-hero__title {
|
||||
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-color); white-space: nowrap;
|
||||
}
|
||||
.pg-hero__sub {
|
||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pg-hero__desktop-controls {
|
||||
flex: 1; display: flex; align-items: center;
|
||||
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.pg-hero__mobile-controls { display: none; }
|
||||
|
||||
/* Linha 2 */
|
||||
.pg-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
margin-top: 0.875rem; padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Mobile < 1200px */
|
||||
@media (max-width: 1199px) {
|
||||
.pg-hero__desktop-controls { display: none; }
|
||||
.pg-hero__mobile-controls { display: flex; margin-left: auto; }
|
||||
.pg-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user