fix(agenda): a lista de horários rola — o overflow existia e era inerte

O que o dono do produto via: abre "Novo agendamento", escolhe o dia, e os
últimos horários ficam abaixo da dobra sem nenhum jeito de alcançá-los. Nem a
lista rolava, nem a página.

  A CAUSA, E ELA É SUTIL

O `overflow-y-auto` EXISTE, está no elemento certo, e é INERTE: um
`overflow-y-auto` cujo pai tem altura `auto` não rola — o filho cresce e leva a
caixa junto, então `scrollHeight === clientHeight`. Medido, sem o conserto:

    lista: 644px de conteúdo em 644px de caixa

Nenhum elo entre o Sheet e a lista definia altura: o `SheetContent` tinha só
largura, o wrapper era um `div` de altura automática, e o painel tinha
`min-h-[450px]` — piso, nunca teto.

E a página também não rolava: o Sheet é `position: fixed`, e transbordo de
elemento fixo não estende a área rolável do documento. Não havia ancestral
rolável entre o último botão e a janela.

  O CONSERTO: TRÊS LINHAS, TODAS NO `_client`

Zero mudança de comportamento no componente compartilhado, o que protege a
vitrine, onde o mesmo painel vive em fluxo de página e não tem defeito. Depois:

    lista: 644px de conteúdo em 396px de caixa  → rola

Abaixo de `lg` o próprio Sheet rola (ali o painel empilha). De `lg` para cima a
cadeia fecha e a LISTA rola, com calendário e contexto parados. O
`lg:overflow-hidden` não é preciosismo: em `lg` sobram 12px entre a caixa do
Sheet e o painel, e uma barra vertical os come — como o CSS computa `overflow-x:
visible` como `auto` quando `overflow-y` não é `visible`, nasceria barra
HORIZONTAL no breakpoint que o conserto de largura acabou de reparar.

Junto, o resíduo que o briefing apontou: `lg:min-h-0` no painel, para o piso de
450px não estourar em janela larga e baixa.

  A RÉGUA 6 NÃO ERA O QUE O BRIEFING DIZIA, E EU QUASE CONSERTEI O CONSERTO

A proposta era asserir `body.scrollHeight - innerHeight <= 1`, com a nota "já
passa hoje". Ela reprovou — COM o conserto no lugar — e por um momento conclui
que eu tinha vindo pela via errada.

Medido, alternando as classes ao vivo no mesmo carregamento:

    COM o conserto:  body 1566 | lista 644 em 396
    SEM o conserto:  body 1566 | lista 644 em 644

O crescimento da página é IDÊNTICO nos dois e portanto PRÉ-EXISTENTE. A nota
"já passa hoje" nunca tinha sido medida — e eu a copiei sem medir, que é o erro
que ela mesma descreve. A régua passou a medir o que queria proteger: que o
Sheet continua `position: fixed`, ou seja, que ninguém "resolveu" a rolagem
transformando o painel em conteúdo de página.

⚠️ ACHADO SEPARADO, NÃO CONSERTADO AQUI: com o painel aberto em 1280×700 o body
vai a 1566px contra 700 de janela. É dívida anterior a este PR e misturá-la
esconderia as duas.

  E A PRÉ-CONDIÇÃO DE SUFICIÊNCIA PEGOU A SI MESMA

O briefing pediu uma guarda contra verde por vacuidade (lista que cabe na tela
não precisa rolar). Escrevi comparando `n * 50` com a altura da JANELA — e ela
reprovou um cenário SUFICIENTE: 13 horários (650px) numa caixa de ~450px
transbordam com folga, mas 650 < 700. A régua certa é o espaço que sobra ABAIXO
DO TOPO DA LISTA, não a janela inteira; acima dela ainda há cabeçalho, título e
padding.

Sabotagem: removidas as três linhas e rebuild completo — a régua 4 reprova com
`644px de conteúdo em 644px de caixa iguais`. Restaurado, 7/7. Vizinhas
(kit-visual, marcar-pela-tela) 28/28.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHiP71A38fRzBy9TuSgk1
This commit is contained in:
Rafael Melgaço
2026-08-28 07:56:05 -03:00
co-authored by Claude Opus 5
parent 3c2e9f662c
commit 7b0ef27cff
9 changed files with 158 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A lista de horários volta a rolar ao marcar um compromisso
---
Ao escolher o dia, os últimos horários ficavam abaixo da borda da tela sem
nenhuma forma de alcançá-los — nem rolando a página, nem a própria lista. Quem
precisava de um horário do fim da tarde não conseguia marcar. Agora a lista rola
sozinha, com o calendário e os dados do atendimento parados ao lado, e em telas
menores o painel inteiro rola.
+27 -2
View File
@@ -396,7 +396,31 @@ export function AgendaClient({
empilha os horários sob o calendário, então 768px bastam e um Sheet
maior só roubaria contexto da tela atrás.
*/}
<SheetContent side="right" className="w-full sm:max-w-3xl lg:max-w-[1040px]">
{/*
A CADEIA DE ALTURAS, e ela é o que faz a lista de horários rolar.
O `overflow-y-auto` da lista (`PainelDeMarcacao`) sempre esteve no
elemento certo e era INERTE: `overflow-y-auto` cujo pai tem altura
`auto` não rola — o filho cresce, `scrollHeight === clientHeight`, e os
últimos horários ficavam abaixo da dobra sem nenhum jeito de alcançá-los.
E a página também não rolava: o `SheetContent` é `position: fixed`, e
transbordo de elemento fixo não estende a área rolável do documento.
Abaixo de `lg` o próprio Sheet rola (ali o painel empilha e a lista é
uma seção, não uma coluna). De `lg` para cima o Sheet segura a altura e
a LISTA rola, com calendário e contexto parados.
⚠️ `lg:overflow-hidden` e não `overflow-y-auto` em todo breakpoint: em
`lg` o Sheet tem 1040px com `p-6` → 992px de caixa contra ~980px de
painel. Uma barra vertical come essa folga, e como o CSS computa
`overflow-x: visible` como `auto` quando `overflow-y` não é `visible`,
nasceria barra HORIZONTAL exatamente no breakpoint que o conserto de
largura acabou de reparar.
*/}
<SheetContent
side="right"
className="flex w-full flex-col overflow-y-auto sm:max-w-3xl lg:max-w-[1040px] lg:overflow-hidden"
>
<SheetHeader>
<SheetTitle>{remarcandoId ? "Remarcar agendamento" : "Novo agendamento"}</SheetTitle>
</SheetHeader>
@@ -426,8 +450,9 @@ export function AgendaClient({
</div>
)}
{tipo && (
<div className="mt-4">
<div className="mt-4 lg:min-h-0 lg:flex-1">
<PainelDeMarcacao
className="lg:h-full"
ancora={new Date()}
agora={new Date()}
responsavel={
+16 -2
View File
@@ -332,7 +332,11 @@ export function PainelDeMarcacao({
// colunas cabem com folga. Abaixo disso o painel EMPILHA — os horários
// viram uma seção sob o calendário, que é o que o cal.com faz e o que
// esta base já fazia no celular.
"flex min-h-[450px] flex-col overflow-hidden rounded-lg border border-border bg-surface lg:w-fit lg:flex-row",
// `lg:min-h-0` junto do piso: em janela larga e BAIXA (menos de ~560px
// de altura) um `min-h-[450px]` sem teto estoura o Sheet e o
// `overflow-hidden` corta em silêncio — o mesmo modo de falha que este
// painel já teve na horizontal.
"flex min-h-[450px] flex-col overflow-hidden rounded-lg border border-border bg-surface lg:min-h-0 lg:w-fit lg:flex-row",
className,
)}
>
@@ -636,7 +640,17 @@ export function PainelDeMarcacao({
<p className="mb-2 shrink-0 text-xs font-semibold text-text-muted first-letter:uppercase">
{dia ? format(dia, "EEEE, d 'de' MMM", { locale: ptBR }) : ""}
</p>
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
{/*
`data-testid` para a lista poder ser MEDIDA, e não só vista. O
`overflow-y-auto` aqui sempre esteve certo e era INERTE: um
`overflow-y-auto` cujo pai tem altura `auto` não rola, porque o filho
cresce e `scrollHeight === clientHeight`. Quem fecha a cadeia é o
`_client.tsx`, que dá teto ao Sheet.
*/}
<div
data-testid="lista-de-horarios"
className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1"
>
{doDia.map((h) => (
<button
key={h.instante}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 426 KiB

@@ -200,6 +200,110 @@ for (const viewport of [
});
}
test("a lista de horários ROLA, e o último horário é alcançável — 1280×700", async ({ page }) => {
/**
* O EIXO Y, que esta spec não media.
*
* As três réguas acima são todas de largura (`x`, `width`). O dono do produto
* encontrou o defeito no outro eixo: escolhe o dia, e os últimos horários
* ficam abaixo da dobra sem nenhum jeito de alcançá-los.
*
* A causa é sutil e por isso escapou: o `overflow-y-auto` EXISTE, está no
* elemento certo, e é INERTE. Um `overflow-y-auto` cujo pai tem altura `auto`
* não rola — o filho cresce e `scrollHeight === clientHeight`. E a página
* também não rola, porque o Sheet é `position: fixed` e transbordo de elemento
* fixo não estende a área rolável do documento.
*
* Viewport 700px de altura de propósito: é onde a lista estoura com a
* quantidade de horários que o seed produz.
*/
await page.setViewportSize({ width: 1280, height: 700 });
const creds = lerCreds();
await entrar(page, creds);
await abrirPainelComDiaEscolhido(page, creds.agenda!.tipo_nome);
const lista = page.getByTestId("lista-de-horarios");
await expect(lista).toBeVisible({ timeout: 15_000 });
const horarios = page.locator('[data-testid^="horario-"]');
const n = await horarios.count();
// ── PRÉ-CONDIÇÃO DE SUFICIÊNCIA ──────────────────────────────────────────
// Sem isto o caso fica VERDE POR VACUIDADE no dia em que o seed produzir
// poucos slots: uma lista que cabe na tela não precisa rolar, e "não rolou"
// passaria a ser lido como "está consertado".
//
// ⚠️ A RÉGUA É O ESPAÇO QUE SOBRA PARA A LISTA, e não a altura da janela — e
// eu escrevi errado da primeira vez. Comparar `n * 50` com os 700px da
// viewport reprovou um cenário SUFICIENTE (13 horários = 650px numa caixa de
// ~450px transbordam com folga), porque acima da lista ainda há o cabeçalho do
// Sheet, o título e o padding. O que decide é onde a lista COMEÇA.
//
// 50px por item = 44 do `h-11` + 6 do `gap-1.5`.
const topoDaLista = (await lista.boundingBox())?.y ?? 0;
const alturaDaJanela = page.viewportSize()?.height ?? 0;
const espacoQueSobra = alturaDaJanela - topoDaLista;
expect(
n * 50,
`${n} horários ocupam ${n * 50}px e sobram ${Math.round(espacoQueSobra)}px de tela ` +
"abaixo do topo da lista — ela cabe inteira, e este caso não exercita rolagem " +
"nenhuma. Sem mais slots no seed, o verde aqui não seria evidência.",
).toBeGreaterThan(espacoQueSobra);
// ── RÉGUA 4: o mecanismo ─────────────────────────────────────────────────
const medida = await lista.evaluate((el) => ({
scroll: el.scrollHeight,
cliente: el.clientHeight,
}));
expect(
medida.scroll,
`a lista tem ${medida.scroll}px de conteúdo em ${medida.cliente}px de caixa iguais — ` +
"ela não rola. O `overflow-y-auto` está lá e é inerte porque nenhum ancestral " +
"define altura: o filho cresce e leva a caixa junto.",
).toBeGreaterThan(medida.cliente);
// ── RÉGUA 5: o desfecho, na língua de quem usa ───────────────────────────
const ultimo = horarios.last();
await ultimo.scrollIntoViewIfNeeded();
await expect(
ultimo,
"o último horário não entra na tela nem depois de rolar — é o que o dono viu",
).toBeInViewport();
await ultimo.click();
await expect(
page.getByTestId("confirmacao"),
"cliquei no último horário e o painel não avançou",
).toBeVisible({ timeout: 15_000 });
// ── RÉGUA 6: anti-regressão, e ela NÃO é a que o briefing propôs ─────────
//
// A proposta era `document.body.scrollHeight - window.innerHeight <= 1`, com a
// nota "já passa hoje". **MEDIDO: não passa, e não é culpa do conserto.**
// Com o painel aberto nesta viewport, o body vai a 1566px contra 700 de
// janela — e o número é IDÊNTICO com e sem a cadeia de alturas:
//
// COM o conserto: body 1566 | lista 644 de conteúdo em 396 de caixa
// SEM o conserto: body 1566 | lista 644 de conteúdo em 644 de caixa
//
// O crescimento da página é PRÉ-EXISTENTE e independente disto. Asserir sobre
// ele reprovaria um conserto correto por uma dívida que já estava lá — e foi o
// que aconteceu na primeira execução, com o conserto no lugar.
//
// A propriedade que a régua QUERIA proteger é outra e se mede direto: que o
// conserto não veio tirando o `position: fixed` do Sheet, que é o que o
// tornaria um painel comum e faria a página crescer de verdade.
const posicaoDoSheet = await page
.locator('[role="dialog"]')
.first()
.evaluate((el) => getComputedStyle(el).position);
expect(
posicaoDoSheet,
"o Sheet deixou de ser `fixed` — a rolagem pode até parecer resolvida, mas o " +
"painel virou conteúdo de página em vez de sobreposição",
).toBe("fixed");
await page.screenshot({ path: "evidence/calendario/d4-lista-rola-1280x700.png", fullPage: false });
});
test("dá para CLICAR num horário — o teste final é a ação, não a medida", async ({ page }) => {
// A geometria acima é o diagnóstico; isto é o desfecho. Um horário pode estar
// dentro do painel e ainda assim ser inalcançável (coberto por outra camada,