Melissa Agenda: breakpoint compact + drawer mobile teleportado

Dois pontos de quebra agora:
- <xl (<=1279px) "compact": view-switcher (Dia/Semana/Mes/Lista) sai da
  toolbar e entra no menu "Acoes" com check icon no ativo. Filtros
  tambem migram pra dentro pra nao inflar a barra.
- <lg (<=1023px) "mobile": .ma-side e .ma-widgets viajam pra fora do
  .ma-page via Teleport, num <aside class="ma-mobile-drawer"> sempre
  presente no DOM (v-show controla display) — garante target valido
  desde o mount. Botao "Menu" mobile-only aparece a esquerda do header.
  Backdrop entre drawer e .ma-page com Transition de fade.

Bonus styles.scss: fix borda dupla do FullCalendar.
.fc-scrollgrid em light mode mantinha borda externa que somada com a
borda das celulas da ponta dava 2px na borda do calendario. Zera o
contorno do contairner — celulas (td/th) ja desenham a grade visual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-28 17:11:56 -03:00
parent dac3198873
commit 7b67bd083a
2 changed files with 264 additions and 120 deletions
+6 -1
View File
@@ -279,13 +279,18 @@
background: var(--surface-hover);
}
/* Bordas do FullCalendar — solução pra "borda dupla":
- células (td/th) MANTÊM a borda → forma a grade visual
- contêiner .fc-scrollgrid ZERA → sem isso, a borda externa fica
dobrada (1px do contêiner + 1px da célula da ponta = 2px na borda) */
.app-dark .fc-theme-standard td,
.app-dark .fc-theme-standard th {
border: 1px solid var(--surface-border);
}
.fc-theme-standard .fc-scrollgrid,
.app-dark .fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--surface-border);
border: none;
}
.app-dark .fc-timegrid-event-harness-inset .fc-timegrid-event,
+241 -102
View File
@@ -68,27 +68,40 @@ const onlySessionsOptions = [
{ label: 'Tudo', value: false }
];
// ── Drawer mobile ────────────────────────────────────────────
// Quando largura <1024px, .ma-side e .ma-widgets viram off-canvas
// dentro de .ma-drawer. drawerOpen controla translateX via CSS.
// ── Breakpoints ──────────────────────────────────────────────
// Dois pontos:
// <xl (≤1279px) → "compacto" — filtros viajam pra dentro do botão
// "Ações" da toolbar
// <lg (≤1023px) → "mobile" — aside+widgets viram drawer off-canvas
// (Teleportado pra fora do .ma-page) e o botão "Menu"
// aparece à esquerda do título no header
const drawerOpen = ref(false);
const isMobile = ref(false);
const isCompact = ref(false);
let _mqMobile = null;
function _onMqChange(e) {
let _mqCompact = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
// Cruzou pra desktop → fecha o drawer pra evitar layout zoado
if (!e.matches) drawerOpen.value = false;
if (!e.matches) drawerOpen.value = false; // saiu do mobile, fecha drawer
}
function _onMqCompactChange(e) {
isCompact.value = e.matches;
}
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqChange);
_mqMobile.addEventListener('change', _onMqMobileChange);
_mqCompact = window.matchMedia('(max-width: 1279px)');
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqChange);
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
});
function toggleDrawer() {
@@ -108,6 +121,13 @@ function goSettings() {
// pra não inflar a toolbar em telas pequenas.
const mobileActionsRef = ref(null);
const mobileActionsItems = computed(() => [
// Views (Dia/Semana/Mês/Lista) — view-switcher inline some em <xl,
// entra no menu aqui. Check icon marca o ativo.
{ label: 'Dia', icon: calendarView.value === 'dia' ? 'pi pi-check' : 'pi pi-calendar', command: () => setView('dia') },
{ label: 'Semana', icon: calendarView.value === 'semana' ? 'pi pi-check' : 'pi pi-calendar', command: () => setView('semana') },
{ label: 'Mês', icon: calendarView.value === 'mes' ? 'pi pi-check' : 'pi pi-calendar', command: () => setView('mes') },
{ label: 'Lista', icon: calendarView.value === 'lista' ? 'pi pi-check' : 'pi pi-list', command: () => setView('lista') },
{ separator: true },
{
label: onlySessions.value ? 'Mostrar tudo' : 'Apenas sessões',
icon: onlySessions.value ? 'pi pi-list' : 'pi pi-filter',
@@ -818,22 +838,45 @@ defineExpose({
</script>
<template>
<!-- Drawer host (multi-root, fora do .ma-page pra full viewport height
e single-scroll). Sempre presente no DOM (v-show controla display)
pra ser um Teleport target válido em todo momento.
O backdrop fica logo depois pra ficar entre drawer e .ma-page. -->
<aside
class="ma-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Menu lateral da agenda"
>
<div id="ma-mobile-drawer-target" class="ma-mobile-drawer__scroll" />
</aside>
<Transition name="ma-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="ma-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="ma-page">
<header class="ma-page__head">
<!-- Menu mobile only, abre o drawer com aside+widgets.
Posicionado à esquerda do header (primeiro elemento). Em
mobile o ícone+título do .ma-page__title some (substituído
pelo "Menu Agenda" não duplica). Estilo primary filled. -->
<button
class="ma-menu-btn ma-menu-btn--mobile-only"
v-tooltip.bottom="'Pacientes & widgets'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Agenda</span>
</button>
<div class="ma-page__title">
<i class="pi pi-calendar text-emerald-300" />
<span>Agenda</span>
</div>
<div class="ma-page__actions">
<!-- Pacientes mobile only, abre o drawer com aside+widgets -->
<button
class="ma-head-btn ma-head-btn--mobile-only"
v-tooltip.bottom="'Pacientes & widgets'"
@click="toggleDrawer"
>
<i class="pi pi-users" />
<span>Pacientes</span>
</button>
<!-- Configurações da agenda abre /configuracoes/agenda -->
<button
class="ma-head-btn"
@@ -850,21 +893,11 @@ defineExpose({
</header>
<div class="ma-body">
<!-- Backdrop: visível em mobile com drawer aberto -->
<Transition name="ma-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="ma-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<!-- DRAWER (envolve COL 1 + COL 3)
Desktop: display:contents children flow como flex items
do .ma-body (side, cal, widgets via order CSS).
Mobile (<lg): position:fixed off-canvas, slide da esquerda. -->
<div class="ma-drawer" :class="{ 'is-open': drawerOpen }">
<!-- COL 1: Hoje + Pacientes -->
<!-- COL 1: Hoje + Pacientes
Em desktop renderiza in-place. Em mobile (<lg) o Teleport
move pra dentro de #ma-mobile-drawer (fora do .ma-page,
fullheight, single-scroll). -->
<Teleport to="#ma-mobile-drawer-target" :disabled="!isMobile">
<aside class="ma-side">
<!-- Hoje (stats + lista de sessões) movido da col 3 -->
<div class="ma-w ma-w--side">
@@ -988,6 +1021,7 @@ defineExpose({
</div>
</div>
</aside>
</Teleport>
<!-- COL 2: Calendar central -->
<div class="ma-cal">
@@ -1037,11 +1071,10 @@ defineExpose({
/>
</div>
<!-- Bloquear: ícone-only com Menu popup. Some em
mobile (vai pra dentro de "Ações"). Disabled
em modo standalone (sem composable M). -->
<!-- Bloquear: ícone-only com Menu popup. Visível
em xl. Em <xl vai pra dentro de "Ações". -->
<button
class="ma-cal__icon ma-cal__icon--desktop-only"
class="ma-cal__icon ma-cal__btn--xl-only"
:disabled="!M"
v-tooltip.top="'Bloquear horário/dia'"
@click="openBloqueioMenu"
@@ -1050,19 +1083,21 @@ defineExpose({
</button>
<Menu ref="bloqueioMenuRef" :model="bloqueioMenuItems" :popup="true" />
<!-- Ações mobile only. Concentra timeMode +
onlySessions + bloquear quando a toolbar fica
apertada em telas <lg. -->
<!-- Ações aparece em <xl (1279px). Concentra
filtros (timeMode/onlySessions) + bloquear
quando a toolbar fica apertada. Texto "Ações"
pra dar affordance, não ícone. -->
<button
class="ma-cal__icon ma-cal__icon--mobile-only"
v-tooltip.top="'Ações'"
class="ma-cal__btn ma-cal__btn--compact-only"
v-tooltip.top="'Ações da agenda'"
@click="openMobileActions"
>
<i class="pi pi-ellipsis-v" />
<span>Ações</span>
</button>
<Menu ref="mobileActionsRef" :model="mobileActionsItems" :popup="true" />
<div class="ma-cal__view">
<div class="ma-cal__view ma-cal__view--xl-only">
<button
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]"
:key="opt.v"
@@ -1095,7 +1130,9 @@ defineExpose({
</div>
</div>
<!-- COL 3: Widgets direita -->
<!-- COL 3: Widgets direita
Mesma lógica da COL 1 Teleport pro drawer em mobile. -->
<Teleport to="#ma-mobile-drawer-target" :disabled="!isMobile">
<aside class="ma-widgets">
<!-- Mini calendar -->
<div class="ma-w">
@@ -1152,7 +1189,7 @@ defineExpose({
/>
</aside>
</div> <!-- /.ma-drawer (envolve side + cal + widgets) -->
</Teleport>
</div>
<!-- Popover + dialogs de cadastro (montados fora do scroll/aside
@@ -1286,20 +1323,26 @@ defineExpose({
flex: 1;
display: flex;
min-height: 0;
position: relative; /* âncora do backdrop fixed-relative em mobile */
position: relative;
}
/* Drawer wrapper — display:contents desktop, virtual (não gera box).
Em mobile (<lg) só serve como marcador `.is-open` pra CSS abaixo. */
.ma-drawer {
display: contents;
/* Header — Menu (mobile, esquerda) + título (flex:1) + actions (direita).
Title ganha flex:1 pra empurrar actions pra ponta. Em desktop sem o
Menu, layout é title (flex:1) + actions na ponta. */
.ma-page__title {
flex: 1;
min-width: 0;
}
.ma-page__title > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Header actions cluster — Pacientes (mobile) + Configurações + Fechar. */
.ma-page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ma-head-btn {
display: inline-flex;
@@ -1319,17 +1362,70 @@ defineExpose({
.ma-head-btn:hover { background: var(--m-bg-soft-hover); }
.ma-head-btn > i { font-size: 0.85rem; }
/* Filtros desktop (timeMode + onlySessions). PrimeVue SelectButton
absorve seu próprio styling — só precisamos do gap. */
/* Menu button (mobile, esquerda do header) — primary filled, igual ao
"+ Paciente"/"+ Agendar". Aparece só em <lg. Substitui o título
visualmente — "Menu Agenda" já tem o nome da seção. */
.ma-menu-btn {
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
transition: background-color 140ms ease, transform 140ms ease;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
}
.ma-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.ma-menu-btn:active { transform: translateY(0); }
.ma-menu-btn > i { font-size: 0.85rem; }
/* Filtros desktop (timeMode + onlySessions). Visível em ≥xl,
somem em <xl (vão pra dentro do menu "Ações"). */
.ma-cal__filters {
display: flex;
gap: 6px;
}
/* Default: mobile-only fica oculto, desktop-only aparece (ambos os
.ma-cal__icon de bloquear e ações). Inverte em @media abaixo. */
.ma-cal__icon--mobile-only,
.ma-head-btn--mobile-only { display: none; }
/* Botão "Ações" da toolbar — mesmo estilo do .ma-menu-btn (primary
filled), texto + ícone. Aparece em <xl. Concentra view-switcher,
filtros e bloquear quando a toolbar fica apertada. */
.ma-cal__btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 11px;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.ma-cal__btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.ma-cal__btn:active { transform: translateY(0); }
.ma-cal__btn > i { font-size: 0.85rem; }
/* Default (desktop ≥xl): xl-only visível, compact-only oculto.
Inverte em @media (max-width: 1279px). */
.ma-cal__btn--compact-only,
.ma-menu-btn--mobile-only { display: none; }
/* ═══ COL 1: Aside Pacientes ═════════════════════════════════ */
.ma-side {
@@ -2392,25 +2488,18 @@ html:not(.app-dark) .ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
}
/* ═══════════════════════════════════════════════════════════════
Responsivo <lg (≤1023px)
Mobile drawer (off-canvas)
───────────────────────────────────────────────────────────────
- .ma-cal vira fullwidth
- .ma-side e .ma-widgets viram drawer off-canvas (slide da esquerda)
- .ma-drawer.is-open ativa o slide-in
- Filtros desktop (timeMode/onlySessions/bloquear icon) somem;
"Ações" + Pacientes header buttons aparecem
Renderizado fora do .ma-page (multi-root template) → ocupa 100vh
real, single-scroll. Recebe .ma-side e .ma-widgets via Teleport
quando isMobile (<lg). Em desktop fica display:none via v-show.
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1023px) {
.ma-body {
flex-direction: column;
}
/* Drawer: panels separados mas com transform sincronizado.
Stack vertical: side ocupa 55% top, widgets 45% bottom. */
.ma-drawer .ma-side,
.ma-drawer .ma-widgets {
.ma-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh; /* full viewport, sem encapsular no .ma-page */
height: 100dvh; /* iOS toolbar dynamic */
width: min(360px, 88vw);
z-index: 50;
background: var(--m-bg-medium, rgba(20, 20, 20, 0.92));
@@ -2419,44 +2508,47 @@ html:not(.app-dark) .ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.ma-drawer .ma-side {
top: 0;
height: 55%;
border-bottom: 1px solid var(--m-border);
}
.ma-drawer .ma-widgets {
top: 55%;
height: 45%;
}
.ma-drawer.is-open .ma-side,
.ma-drawer.is-open .ma-widgets {
.ma-mobile-drawer.is-open {
transform: translateX(0);
}
/* Calendar: full width, sem border-right (drawer agora é off-canvas) */
.ma-cal {
/* Single-scroll container — o que faz o drawer ter UM scroll só.
Side e widgets dentro perdem seu próprio overflow no mobile. */
.ma-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ma-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.ma-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Quando teleportadas pra dentro do drawer, .ma-side e .ma-widgets
precisam não competir com o scroll do drawer e ocupar largura toda. */
.ma-mobile-drawer__scroll .ma-side,
.ma-mobile-drawer__scroll .ma-widgets {
width: 100%;
flex-shrink: 0;
height: auto;
overflow: visible; /* perde o scroll próprio — o pai (.scroll) cuida */
border-right: none;
background: transparent;
padding: 0;
}
/* Toolbar mobile — filtros desktop somem, "Ações" e bloquear-mobile aparecem.
Pacientes button no header também. */
.ma-cal__filters,
.ma-cal__icon--desktop-only { display: none; }
.ma-cal__icon--mobile-only { display: grid; place-items: center; }
.ma-head-btn--mobile-only { display: inline-flex; }
/* Toolbar pode estourar com tantos elementos — permite wrap. */
.ma-cal__toolbar {
flex-wrap: wrap;
gap: 8px;
}
}
/* Backdrop: fica entre o drawer e o resto. Click fecha. */
.ma-drawer__backdrop {
/* Backdrop: cobre tudo entre drawer e resto. Click fecha. */
.ma-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
@@ -2472,4 +2564,51 @@ html:not(.app-dark) .ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
.ma-drawer-fade-leave-to {
opacity: 0;
}
/* ═══════════════════════════════════════════════════════════════
Responsivo <xl (≤1279px) — "compacto"
───────────────────────────────────────────────────────────────
- .ma-cal__filters (timeMode + onlySessions) saem da toolbar
(vão pro menu "Ações")
- .ma-cal__btn--xl-only (Bloquear-icon) some
- .ma-cal__btn--compact-only ("Ações") aparece
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1279px) {
.ma-cal__filters,
.ma-cal__view--xl-only,
.ma-cal__btn--xl-only { display: none; }
.ma-cal__btn--compact-only { display: inline-flex; }
}
/* ═══════════════════════════════════════════════════════════════
Responsivo <lg (≤1023px) — "mobile"
───────────────────────────────────────────────────────────────
- .ma-side e .ma-widgets somem do .ma-body (Teleport os move pro
.ma-mobile-drawer fora do layout)
- .ma-cal vira fullwidth
- Botão "Menu" aparece à esquerda do header
- Toolbar pode wrap
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1023px) {
.ma-body {
flex-direction: column;
}
.ma-cal {
width: 100%;
border-right: none;
}
/* Título some — "Menu Agenda" já carrega o nome da seção, evita
redundância e libera espaço pra Configurações + Fechar. */
.ma-page__title { display: none; }
/* Menu button (header esquerda) aparece */
.ma-menu-btn--mobile-only { display: inline-flex; }
/* Toolbar pode estourar com tantos elementos — permite wrap. */
.ma-cal__toolbar {
flex-wrap: wrap;
gap: 8px;
}
}
</style>