This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -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 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>