← Biblioteca componentes/overlay/modal-dialogo/ficha.md CódigoTela cheia
overlay/modal-dialogo

Modal de diálogo

🟢 estavel complexidade média prefixo mdl- overlay modal dialogo dialog texto-longo politica aviso rolagem-interna trava-de-scroll acessibilidade

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/overlay/modal-dialogo/estilo.css">
<script src="componentes/overlay/modal-dialogo/script.js"></script>

markup.html · 157 linhas · 9,2 KB

<!-- ==========================================================================
     MODAL DE DIÁLOGO — texto longo que não cabe na dobra e não merece página
     Prefixo: mdl-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css + script.js

     A IDEIA. Uma dobra resume um assunto em três linhas; quem quiser o texto
     inteiro clica e recebe um painel centrado, com cabeçalho parado e corpo
     que rola por dentro. É o modal genérico da casa: política de investimentos
     resumida, aviso ao investidor, descrição completa de um serviço, biografia
     inteira de um assessor cujo card mostra só o nome.

     O DIÁLOGO É UM <dialog> NATIVO, com showModal(). Dele vêm prontos, sem
     uma linha de script: foco preso dentro do painel, Esc, devolução do foco
     a quem abriu e o resto da página inerte para tecnologia assistiva. É o
     mesmo critério do midia/showreel-expansivel e do <input type="range"> do
     midia/comparador-antes-depois — usar o elemento nativo em vez de
     reimplementar o comportamento dele. O que o script acrescenta é só o que
     falta ao nativo: a coreografia de entrada e saída, o clique no fundo, a
     trava de rolagem e o corte em gradiente do corpo que rola.

     O DISPARO É DECLARATIVO, E POR ISSO CABEM VÁRIOS NA MESMA PÁGINA.
     <button data-mdl-abrir="id-do-modal"> casa com <dialog data-mdl id="…">.
     O script nunca procura "o modal" no documento: ele parte do PAR. Dois
     modais nesta demonstração, de propósito — é o que prova que convivem.

     SEM JAVASCRIPT o <dialog> sem [open] fica invisível e o botão não faz
     nada. Isso é aceitável e é o comportamento correto para conteúdo
     SECUNDÁRIO — mas cobra uma regra editorial: o que é obrigatório por lei
     ou por regulação vive numa PÁGINA, e o modal é só o atalho para ela. Por
     isso os dois rodapés trazem o link para a página aberta, e o resumo da
     dobra já diz o essencial antes de qualquer clique. Ver ficha.md.

     CONTEÚDO ILUSTRATIVO. Prazos e periodicidades são exemplo editorial;
     nenhum número aqui é promessa, projeção ou retorno. Trocar pelo texto
     que a área de compliance do cliente aprovar antes de publicar.
     ========================================================================== -->

<section class="sec-deep sec-pad mdl-sec">
  <div class="wrap">

    <div class="sec-head">
      <p class="eyebrow" data-rv>Método</p>
      <h2 class="sec-title ttl">Toda carteira começa por uma <em>política</em>.</h2>
      <p class="sec-lead mdl-resumo" data-rv data-d="1">Antes de qualquer alocação existe um documento: objetivo de cada parte do patrimônio, horizonte, faixas por classe de ativo e a liquidez que precisa estar disponível. É ele que o comitê revisa, e é dele que sai toda decisão depois.</p>
    </div>

    <!-- data-rv no invólucro, nunca no botão: o §5 do contrato manda separar
         o nó que revela do nó que se move ou que ganha transform próprio. -->
    <div class="mdl-acoes" data-rv data-d="2">
      <button class="btn btn-ghost" type="button" aria-haspopup="dialog" data-mdl-abrir="mdl-politica">
        Como definimos a política
      </button>
      <button class="mdl-link" type="button" aria-haspopup="dialog" data-mdl-abrir="mdl-aviso">
        Aviso ao investidor
      </button>
    </div>

  </div>
</section>

<!-- Os diálogos moram FORA da seção, como últimos filhos do <body>. Dentro de
     uma dobra herdariam o escopo de tom dela, e o painel tem fundo próprio —
     por isso cada <dialog> declara o seu escopo (.sec-deep), que é o padrão
     autorizado no §3 do manual: entrar num escopo que o core já define, e não
     redefinir --fg por conta própria.

     E nenhum deles leva data-rv. O reveal do core esconde o elemento até ele
     cruzar a dobra, e um <dialog> não está no fluxo da página: ele nunca
     cruzaria, e o painel abriria invisível.

     .dialog-casca ANTES da classe local, e nos dois: é a primitiva do core
     que zera a caixa que o navegador dá ao <dialog> — padding, borda, fundo e
     os limites de max-content — e pinta o ::backdrop com --veil. O padding
     deste componente, que usa o diálogo como palco de tela cheia, entra pela
     --dialog-pad dela (estilo.css). -->

<dialog class="dialog-casca mdl sec-deep" id="mdl-politica" data-mdl aria-labelledby="mdl-politica-titulo">
  <article class="mdl-painel">

    <header class="mdl-topo">
      <div class="mdl-topo-texto">
        <p class="eyebrow">Política de investimentos</p>
        <h2 class="mdl-titulo" id="mdl-politica-titulo">Como definimos a política</h2>
      </div>
      <!-- .btn-disc é a primitiva do core para botão redondo só de ícone.
           Aqui ela serve inteira: o cabeçalho é fixo, o botão não se apoia em
           vidro e não tem coreografia própria — que eram justamente as duas
           exceções que a primitiva declara não cobrir. -->
      <button class="btn-disc mdl-fechar" type="button" data-mdl-fechar aria-label="Fechar">
        <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
          <path d="M6 6l12 12M18 6L6 18" fill="none" stroke-linecap="round"/>
        </svg>
      </button>
    </header>

    <!-- tabindex="0" no corpo que rola: sem ele, um bloco com overflow não
         entra na ordem de tabulação do Chrome, e quem navega por teclado não
         consegue rolar o texto. O autofocus põe o foco AQUI ao abrir, e não
         no botão de fechar — quem chegou pelo teclado quer ler, não sair. -->
    <div class="mdl-corpo" data-mdl-corpo tabindex="0" autofocus>
      <p>A política de investimentos é o documento que antecede qualquer aplicação. Ela nasce do diagnóstico: o patrimônio de hoje, os compromissos já assumidos, a liquidez necessária nos próximos meses e o que a família entende por risco — que quase nunca é o que um questionário padrão mede.</p>

      <p>Escrita a política, o comitê traduz cada linha dela em <strong>faixas</strong> de alocação por classe de ativo. Faixa, e não número cravado: um intervalo deixa o assessor acompanhar o mercado sem renegociar o documento a cada movimento, e deixa você cobrar quando a carteira sai dele.</p>

      <ul class="mdl-lista">
        <li><strong>Objetivo e horizonte.</strong> Para que serve cada parte do patrimônio, e em que prazo ela precisa estar disponível.</li>
        <li><strong>Faixas por classe.</strong> Mínimo e máximo de renda fixa, renda variável, exterior e alternativos — em intervalo, sempre.</li>
        <li><strong>Liquidez.</strong> Quanto tem de estar acessível em até trinta dias, e o que pode ficar imobilizado sem prejuízo do plano.</li>
        <li><strong>Revisão.</strong> Quando o documento volta à mesa: a cada semestre, e sempre que um evento de vida mudar a premissa — sucessão, venda de participação, mudança de residência fiscal.</li>
      </ul>

      <p>Nada disso produz projeção. A política define o desenho da carteira e os limites dentro dos quais ela se move; o resultado depende do mercado e não é objeto de promessa. Nossa remuneração é a taxa previamente acordada com você, e não muda conforme o produto alocado.</p>
    </div>

    <footer class="mdl-rodape">
      <p class="nota mdl-nota">Resumo editorial. A versão integral, com anexos e data da última revisão, fica na página aberta do site.</p>
      <!-- href="#": aponte para a página real da política. O modal é atalho,
           nunca o único lugar onde a informação existe. -->
      <a class="mdl-saida" href="#">Abrir a página completa</a>
    </footer>

  </article>
</dialog>

<dialog class="dialog-casca mdl sec-deep" id="mdl-aviso" data-mdl aria-labelledby="mdl-aviso-titulo">
  <!-- O segundo modal é curto de propósito: mesmo componente, mesma classe,
       corpo que não chega a rolar. É o que prova que a régua de rolagem e o
       corte em gradiente aparecem só quando há o que cortar. -->
  <article class="mdl-painel">

    <header class="mdl-topo">
      <div class="mdl-topo-texto">
        <p class="eyebrow">Informação obrigatória</p>
        <h2 class="mdl-titulo" id="mdl-aviso-titulo">Aviso ao investidor</h2>
      </div>
      <button class="btn-disc mdl-fechar" type="button" data-mdl-fechar aria-label="Fechar">
        <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
          <path d="M6 6l12 12M18 6L6 18" fill="none" stroke-linecap="round"/>
        </svg>
      </button>
    </header>

    <div class="mdl-corpo" data-mdl-corpo tabindex="0" autofocus>
      <p>Os conteúdos desta página têm caráter informativo e não constituem recomendação, oferta ou solicitação de investimento. Nenhuma informação aqui considera a situação particular de quem lê.</p>

      <p>Investimentos envolvem risco, inclusive de perda do capital aplicado. Rentabilidade passada não representa garantia de resultado futuro, e nenhum número apresentado no site deve ser lido como projeção.</p>

      <p>Antes de decidir, leia os documentos oficiais de cada produto e converse com o seu assessor.</p>
    </div>

    <footer class="mdl-rodape">
      <p class="nota mdl-nota">Este resumo não substitui os avisos legais completos.</p>
      <a class="mdl-saida" href="#">Ler os avisos completos</a>
    </footer>

  </article>
</dialog>

estilo.css · 312 linhas · 12,5 KB

/* ==========================================================================
   MODAL DE DIÁLOGO — prefixo mdl-
   Painel centrado sobre <dialog> nativo: cabeçalho parado, corpo que rola por
   dentro com corte em gradiente, rodapé com o link da página completa.

   Tokens usados:
     --bg               fundo do painel (vem do escopo .sec-deep do <dialog>)
     --fg --fg-2        tinta do título e do corpo
     --fg-muted         nota do rodapé e olho
     --rule --rule-2    filete do cabeçalho, do rodapé e da borda do painel
     --accent           marcador da lista, link do rodapé, hover do gatilho
     --veil             véu do ::backdrop — pintado pela .dialog-casca do core
     --display --sans   faces do título e do corpo
     --ease             curva do projeto
     --radius-media     canto do painel
     --disc-size        lado do .btn-disc do core (o botão de fechar)

   A CASCA VEM DO CORE. `class="dialog-casca mdl"`: a primitiva zera a caixa
   que o navegador dá ao <dialog> — padding, borda, fundo e os limites de
   max-content — e pinta o ::backdrop com --veil. O que ela não cobre, de
   propósito, e por isso continua aqui: o `display` de [open], a coreografia
   de entrada e a saída animada antes do close().

   Parafusos do componente:
     --dialog-pad       recuo do painel até a borda da janela. É parafuso DA
                        PRIMITIVA, e não do prefixo: o diálogo usado como
                        palco de tela cheia é o caso nomeado em que a
                        .dialog-casca não zera o padding, e o valor entra por
                        esta variável em vez de por uma declaração local.
     --mdl-dur          tempo da entrada E da saída. O SCRIPT LÊ ESTE VALOR
                        para saber quanto esperar antes de chamar close().
                        Mudar aqui muda os dois lados de uma vez.
     --mdl-max          largura de leitura do painel
     --mdl-corte        altura máxima da faixa de gradiente no topo e na base
                        do corpo que rola. O SCRIPT TAMBÉM LÊ ESTE VALOR
     --mdl-fade-cima    o corte de fato aplicado — ESCRITO PELO SCRIPT, 0 nas
     --mdl-fade-baixo   pontas (no topo não há o que cortar acima)

   O QUE NÃO ESTÁ AQUI, porque o <dialog> nativo entrega: foco preso, Esc,
   devolução do foco a quem abriu, página inerte por trás.
   ========================================================================== */

/* ---- A dobra que dispara ------------------------------------------------ */

.mdl-acoes{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  justify-content:center;
  gap:16px 28px;
  margin-block-start:34px;
}

/* Gatilho secundário: é um <button> de verdade (teclado, Enter, Espaço e o
   anúncio de "botão" vêm de graça), com aparência de link de texto. */
.mdl-link{
  background:none;
  border:0;
  padding:0;
  font-family:var(--sans);
  font-size:.84rem;
  letter-spacing:.06em;
  color:var(--fg-muted);
  border-block-end:1px solid var(--rule-2);
  transition:color .35s var(--ease),border-color .35s var(--ease);
}
.mdl-link:hover{color:var(--accent);border-color:var(--accent)}

/* ---- O diálogo ---------------------------------------------------------- */

/* O <dialog> nasce display:none na folha do agente do usuário, e QUALQUER
   regra de autor com display vence essa. Escrever `.mdl{display:grid}` sem o
   [open] deixaria os dois diálogos visíveis o tempo todo, empilhados sobre a
   página. É a mesma armadilha do §6.5 do contrato ([hidden] que não esconde),
   na versão do elemento nativo — por isso o display mora em .mdl[open]. */
.mdl{
  /* O recuo até a borda da janela, pela variável da primitiva. Padding, borda,
     fundo e os limites de max-content são dela; o que fica aqui é o tamanho:
     o diálogo tem de ocupar a janela inteira. */
  --dialog-pad:clamp(16px,5vh,56px) clamp(16px,5vw,56px);
  --mdl-dur:.42s;
  --mdl-max:680px;
  --mdl-corte:30px;

  inline-size:100%;
  block-size:100%;
  /* O diálogo não rola: quem rola é .mdl-corpo, por dentro do painel. A
     primitiva já troca o `overflow:auto` do agente do usuário por `visible`,
     que também não rola; `hidden` fica porque durante a entrada o painel está
     18px abaixo e desfocado — clipar na borda mantém a caixa do diálogo igual
     à janela em qualquer quadro (medido: 1425x900 no desktop, 375x844 em
     390px de largura). */
  overflow:hidden;
}

/* O diálogo ocupa a janela inteira de propósito, e não é firula de layout: é
   o que faz o clique no vazio ter alvo. O ::backdrop não é alvo de evento —
   um clique fora de um painel pequeno cairia na página inerte por baixo e
   nada aconteceria. Com o <dialog> cobrindo tudo, `e.target === dialog`
   significa exatamente "clicou fora do painel". */
/* FLEX, e não grid, e a diferença é medível. Num grid de linha automática, a
   altura da linha é o conteúdo — então `max-block-size:100%` no painel resolve
   contra a própria linha que ele acabou de esticar, o limite nunca morde e o
   painel passa da janela. Medido a 1440x900 com display:grid: janela 900,
   diálogo 900, painel 919,5px, e o corpo sem régua de rolagem nenhuma. Em
   flex a porcentagem resolve contra a caixa de conteúdo do container, que é
   definida (900 menos o recuo), e o painel para onde deve. */
.mdl[open]{
  display:flex;
  align-items:center;
  justify-content:center;
}

/* O véu é da .dialog-casca — `background:var(--veil)`, na camada que o
   navegador já pinta acima de tudo, inclusive de nav fixa e cursor, sem caixa
   própria. Este componente escurecia 72% e passa aos 78% do token: seis pontos
   que ninguém vê, e um número que deixa de viver em dois lugares (ficha.md).

   O QUE SOBRA AQUI, e por que não pode vir da primitiva:
     · A SATURAÇÃO. A primitiva serve `blur(var(--dialog-blur,6px))`, e
       backdrop-filter é uma propriedade só — não há como somar saturate(120%)
       à declaração dela sem reescrevê-la inteira. Por isso o filtro completo
       fica aqui, e --dialog-blur não tem efeito neste componente.
     · A coreografia de opacidade, que a primitiva não cobre de propósito.

   O FALLBACK DENTRO DO var() NÃO É DESLEIXO. Até o Chrome 121 o ::backdrop não
   herdava custom property do <dialog> de origem: --mdl-dur e --ease chegavam
   vazios e a transição inteira era descartada. Neste Chrome a herança funciona
   (medido: transition-duration = 0.42s, que só pode ter vindo do --mdl-dur do
   diálogo); o fallback mantém a peça de pé onde ela ainda não existe.

   Aqui a máscara em gradiente do §6 não se aplica: a regra existe porque
   backdrop-filter em CAIXA RETA produz borda dura, e esta camada não tem
   borda nenhuma — cobre a janela inteira, de sangria a sangria. */
.mdl::backdrop{
  backdrop-filter:blur(10px) saturate(120%);
  -webkit-backdrop-filter:blur(10px) saturate(120%);
  opacity:0;
  transition:opacity var(--mdl-dur,.42s) var(--ease,ease);
}
.mdl.is-aberto::backdrop{opacity:1}

/* ---- O painel ----------------------------------------------------------- */

.mdl-painel{
  display:flex;
  flex-direction:column;
  inline-size:min(100%,var(--mdl-max));
  max-block-size:100%;
  background:var(--bg);
  border:1px solid var(--rule-2);
  border-radius:var(--radius-media);
  /* Nada entra seco (§5): blur indo a zero, junto do fade e de uma subida
     curta. A subida é de 18px, e não dos 22px do [data-rv] do core: o painel
     nasce no meio da tela, sem viagem de dobra a percorrer. */
  opacity:0;
  filter:blur(14px);
  transform:translateY(18px);
  transition:opacity var(--mdl-dur) var(--ease),
             filter var(--mdl-dur) var(--ease),
             transform var(--mdl-dur) var(--ease);
}
.mdl.is-aberto .mdl-painel{
  opacity:1;
  filter:blur(0);
  transform:none;
}
@media (prefers-reduced-motion:reduce){
  /* O diálogo continua abrindo — ele foi PEDIDO, não anda sozinho. Some só a
     coreografia: o painel e o véu nascem no estado final. O script lê
     LIB.reduce e fecha na hora, sem esperar transição que não existe. */
  .mdl-painel{opacity:1;filter:none;transform:none;transition:none}
  .mdl::backdrop{opacity:1;transition:none}
}

/* ---- Cabeçalho: parado enquanto o corpo rola ---------------------------- */

.mdl-topo{
  flex:0 0 auto;
  display:flex;
  align-items:flex-start;
  gap:20px;
  padding:28px 32px 20px;
  border-block-end:1px solid var(--rule);
}
/* min-inline-size:0 é o que permite o título quebrar dentro do flex em vez de
   empurrar o botão de fechar para fora do painel. */
.mdl-topo-texto{min-inline-size:0}

.mdl-titulo{
  font-family:var(--display);
  font-weight:700;
  letter-spacing:-.02em;
  font-size:clamp(21px,2.5vw,29px);
  line-height:1.16;
  color:var(--fg);
  margin-block-start:12px;
}

.mdl-fechar{margin-inline-start:auto}
.mdl-fechar svg{
  inline-size:42%;
  stroke:currentColor;
  stroke-width:1.7;
}

/* ---- Corpo: o único que rola ------------------------------------------- */

.mdl-corpo{
  --mdl-fade-cima:0px;
  --mdl-fade-baixo:0px;
  flex:1 1 auto;
  /* min-block-size:0 NÃO é redundante, e sem ele o componente inteiro não
     funciona. Item de flex nasce com min-block-size:auto, que o proíbe de
     encolher abaixo do próprio conteúdo: o painel crescia até a altura do
     texto e passava da janela, e o corpo nunca chegava a rolar. Medido a
     1440x900: painel de 920px numa janela de 900, com scrollHeight igual ao
     clientHeight — ou seja, régua de rolagem nenhuma. */
  min-block-size:0;
  overflow-y:auto;
  /* Chegar ao fim do texto não pode continuar rolando a página por baixo. */
  overscroll-behavior:contain;
  padding:26px 32px 28px;
  color:var(--fg-2);
  /* O corte do texto é MÁSCARA, não sombra: sombra dura desenha uma linha
     onde não há borda nenhuma e o texto parece passar por baixo de um degrau.
     As duas faixas são escritas pelo script e valem 0 nas pontas — no topo da
     rolagem não há o que cortar acima. */
  mask-image:linear-gradient(to bottom,transparent 0,#000 var(--mdl-fade-cima),#000 calc(100% - var(--mdl-fade-baixo)),transparent 100%);
}

/* O foco entra PARA DENTRO. O :focus-visible do core desenha o contorno com
   outline-offset:3px, do lado de fora da caixa — e a máscara acima recorta
   tudo que é pintado fora da borda do elemento, inclusive o contorno. O anel
   ficaria invisível justamente no elemento que recebe o foco ao abrir. */
.mdl-corpo:focus-visible{outline-offset:-3px}

/* Prosa livre: elemento descendente do prefixo é o que o §6.3 autoriza, e é o
   caso em que exigir classe não paga — o texto vem de quem escreve conteúdo,
   não de quem escreve markup. */
.mdl-corpo p{
  margin-block-end:1.05em;
  font-size:.97rem;
  line-height:1.72;
}
.mdl-corpo p:last-child{margin-block-end:0}
.mdl-corpo strong{color:var(--fg);font-weight:500}

.mdl-lista{
  display:grid;
  gap:14px;
  margin-block:0 1.5em;
}
.mdl-lista li{
  position:relative;
  padding-inline-start:24px;
  font-size:.93rem;
  line-height:1.62;
}
/* Traço, e não bolinha: é o mesmo sinal do ::before do .eyebrow do core. */
.mdl-lista li::before{
  content:"";
  position:absolute;
  inset-inline-start:0;
  inset-block-start:.72em;
  inline-size:11px;
  block-size:1px;
  background:var(--accent);
}

/* ---- Rodapé: a saída para a página de verdade -------------------------- */

.mdl-rodape{
  flex:0 0 auto;
  display:flex;
  flex-wrap:wrap;
  align-items:baseline;
  justify-content:space-between;
  gap:10px 24px;
  padding:18px 32px 22px;
  border-block-start:1px solid var(--rule);
}
/* Tipografia pela primitiva .nota do core. Os dois parafusos: o corpo é o
   .74rem do rodapé do diálogo, um degrau abaixo do padrão porque a nota
   divide a linha com o link de saída; 44ch é a medida dessa metade. */
.mdl-nota{
  --nota-size:.74rem;
  --nota-max:44ch;
}
.mdl-saida{
  font-size:.72rem;
  letter-spacing:.14em;
  text-transform:uppercase;
  color:var(--accent);
  border-block-end:1px solid transparent;
  transition:border-color .35s var(--ease);
  white-space:nowrap;
}
.mdl-saida:hover{border-color:var(--accent)}

/* ---- Mobile ------------------------------------------------------------- */

@media (max-width:640px){
  /* Recuo menor: em tela estreita, 56px de margem comem meia coluna de
     leitura. O painel encosta quase na borda e o corpo ganha o resto. */
  .mdl{--dialog-pad:14px 12px}
  .mdl-topo{padding:22px 20px 16px;gap:14px}
  .mdl-corpo{padding:20px 20px 22px}
  /* A nota e o link empilham: lado a lado em 320px o link quebraria no meio. */
  .mdl-rodape{padding:16px 20px 18px;justify-content:flex-start}
  .mdl-fechar{--disc-size:38px}
}

script.js · 199 linhas · 8,5 KB

/* ==========================================================================
   MODAL DE DIALOGO — mdl-
   Casa cada <button data-mdl-abrir="id"> com o <dialog data-mdl id="id">.

   O QUE NAO ESTA' AQUI, porque o <dialog> nativo ja' entrega: prender o foco
   dentro do painel, fechar no Esc, devolver o foco ao botao que abriu (por
   isso nao ha' variavel `opener` nenhuma neste arquivo) e tornar o resto da
   pagina inerte para tecnologia assistiva. Mesmo criterio do
   midia/showreel-expansivel.

   O QUE O NATIVO NAO FAZ, e por isso esta' aqui:
     1. animar a entrada e a SAIDA. close() tira o dialogo da tela no mesmo
        quadro, entao a saida tem de terminar ANTES dele -- o tempo vem da
        custom property --mdl-dur do CSS, para nao viver em dois lugares;
     2. fechar ao clique no fundo;
     3. pedir a trava da rolagem ao core (LIB.travarRolagem) na abertura e
        devolve-la no fechamento;
     4. cortar o corpo que rola em gradiente, so' onde ha' o que cortar.

   BUSCA GLOBAL, e aqui ela e' o certo. O §7.6 manda partir da raiz do proprio
   componente porque ele pode repetir na pagina. Neste caso a raiz E' O PAR:
   um botao em qualquer dobra e um <dialog> que mora fora de toda seccao, no
   fim do <body>. Procurar "o modal dentro da seccao" nao acharia nada. O que
   nao se faz nunca e' procurar "o dialogo" no singular: cada gatilho resolve
   o SEU pelo id, e por isso dois, tres ou dez modais convivem na pagina.
   ========================================================================== */
(function () {
  var gatilhos = document.querySelectorAll('[data-mdl-abrir]');
  if (!gatilhos.length) return;                 // guard: saida silenciosa

  var reduz = !!(window.LIB && window.LIB.reduce);
  var corpos = [];        // os [data-mdl-corpo] abertos, para o corte e o resize

  /* ---------- tempo ----------
     O CSS e' o dono da duracao (--mdl-dur no .mdl). Ler dali evita o classico
     de mudar a transicao e esquecer o setTimeout -- ou o contrario, e o
     dialogo sumir no meio do fade. Aceita "s" e "ms". */
  function duracao(dlg) {
    if (reduz) return 0;
    var v = getComputedStyle(dlg).getPropertyValue('--mdl-dur').trim();
    if (!v) return 0;
    var n = parseFloat(v);
    if (isNaN(n)) return 0;
    return v.indexOf('ms') > -1 ? n : n * 1000;
  }

  /* ---------- trava da rolagem de fundo ----------
     Do core, e nao mais daqui: LIB.travarRolagem escreve overflow:hidden no
     <html>, guarda o valor anterior e conta as travas -- inclusive as de
     OUTRA sobreposicao da mesma pagina, que era o que o contador local deste
     arquivo nao podia enxergar. Foi este componente que estabeleceu o alvo
     (<html>, e nao <body>) e o criterio; o motion.js registra as tres razoes.

     O guard nao e' zelo: um consumidor pode colar markup, estilo e script sem
     o motion.js. Sem a trava a pagina rola atras do modal, o que e' feio;
     sem o guard o modal nao abre, o que e' bug. */
  function travarFundo() {
    if (window.LIB && window.LIB.travarRolagem) window.LIB.travarRolagem();
  }
  function soltarFundo() {
    if (window.LIB && window.LIB.soltarRolagem) window.LIB.soltarRolagem();
  }

  /* ---------- corte em gradiente do corpo que rola ----------
     Escreve as duas faixas que o mask-image do CSS consome. Zero nas pontas:
     no topo nao ha' texto acima para desvanecer, e cortar ali comeria a
     primeira linha. A altura maxima da faixa vem do CSS (--mdl-corte), pela
     mesma razao de --mdl-dur: o numero mora num lugar so'. */
  function corteMax(corpo) {
    var v = parseFloat(getComputedStyle(corpo).getPropertyValue('--mdl-corte'));
    return isNaN(v) ? 0 : v;
  }

  function pintarCorte(corpo) {
    var teto = corteMax(corpo);
    var topo = corpo.scrollTop;
    var resto = corpo.scrollHeight - corpo.clientHeight - topo;
    corpo.style.setProperty('--mdl-fade-cima', Math.max(0, Math.min(topo, teto)) + 'px');
    corpo.style.setProperty('--mdl-fade-baixo', Math.max(0, Math.min(resto, teto)) + 'px');
  }

  /* rAF entre o evento e a escrita, como manda o §7.4: o listener so' agenda,
     e um quadro nunca recebe mais de uma passagem. Nao ha' nada a desligar sob
     movimento reduzido -- isto nao e' animacao, e' a borda do texto seguindo a
     posicao da rolagem que a pessoa mesma produziu. */
  var agendado = false;
  function agendarCorte() {
    if (agendado) return;
    agendado = true;
    requestAnimationFrame(function () {
      agendado = false;
      for (var i = 0; i < corpos.length; i++) pintarCorte(corpos[i]);
    });
  }

  addEventListener('resize', agendarCorte, { passive: true });

  /* ---------- abrir e fechar ---------- */

  function abrir(dlg) {
    if (dlg.open) return;
    dlg.showModal();                     // showModal, nunca show: e' ele que
    travarFundo();                       // traz foco preso, ::backdrop e inert

    var corpo = dlg.querySelector('[data-mdl-corpo]');
    if (corpo) {
      corpos.push(corpo);
      pintarCorte(corpo);
    }

    if (reduz) { dlg.classList.add('is-aberto'); return; }

    /* Dois quadros de espera, nao um. O painel nasce com opacity:0 e
       blur(14px); com uma unica espera o navegador as vezes agrupa o
       showModal com a troca de classe e a transicao nao chega a existir --
       o painel aparece pronto no lugar. O mesmo vale para o ::backdrop, que
       so' passa a existir depois do showModal. */
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { dlg.classList.add('is-aberto'); });
    });
  }

  function fechar(dlg) {
    if (!dlg.open || dlg.dataset.mdlFechando) return;   // clique duplo, Esc em
    dlg.classList.remove('is-aberto');                  // cima do botao, etc.

    var espera = duracao(dlg);
    if (!espera) { dlg.close(); return; }

    /* A saida tem de TERMINAR antes do close(): o <dialog> some no mesmo
       quadro em que close() e' chamado, e o fade nao teria onde acontecer. */
    dlg.dataset.mdlFechando = '1';
    setTimeout(function () {
      delete dlg.dataset.mdlFechando;
      dlg.close();
    }, espera);
  }

  /* 'close' dispara pelo botao, pelo Esc e pelo close() daqui: um lugar so'
     para soltar a rolagem e largar o corpo, em vez de tres caminhos que podem
     divergir. */
  function aoFechar(dlg) {
    dlg.classList.remove('is-aberto');
    soltarFundo();
    var corpo = dlg.querySelector('[data-mdl-corpo]');
    var i = corpo ? corpos.indexOf(corpo) : -1;
    if (i > -1) corpos.splice(i, 1);
  }

  /* ---------- ligacao ---------- */

  var vistos = [];

  for (var g = 0; g < gatilhos.length; g++) {
    (function (btn) {
      var dlg = document.getElementById(btn.getAttribute('data-mdl-abrir'));
      if (!dlg || !dlg.hasAttribute('data-mdl')) return;   // par incompleto:
                                                          // silencio, nao erro
      btn.addEventListener('click', function () { abrir(dlg); });

      if (vistos.indexOf(dlg) > -1) return;   // dois gatilhos, um so' modal:
      vistos.push(dlg);                       // nao duplicar os listeners dele

      var fechares = dlg.querySelectorAll('[data-mdl-fechar]');
      for (var f = 0; f < fechares.length; f++) {
        fechares[f].addEventListener('click', function () { fechar(dlg); });
      }

      /* Esc: o nativo chamaria close() na hora e a saida nao aconteceria.
         Cancelar o evento e refazer o fechamento pelo caminho animado e' o
         unico jeito de o Esc sair igual ao botao. */
      dlg.addEventListener('cancel', function (e) {
        e.preventDefault();
        fechar(dlg);
      });

      dlg.addEventListener('close', function () { aoFechar(dlg); });

      /* Clique no fundo. O ::backdrop nao e' alvo de evento -- quem recebe o
         clique e' o proprio <dialog>, que o CSS faz cobrir a janela inteira.
         Alvo igual ao dialogo significa que o clique caiu FORA do painel;
         dentro, o alvo seria o painel, o texto ou um botao.

         O mousedown tambem e' conferido, e nao e' zelo excessivo: quem comeca
         a selecionar um paragrafo e solta o mouse fora do painel produz um
         clique cujo alvo e' o dialogo -- e o modal fecharia levando a selecao
         junto, no meio de uma leitura. */
      var alvoDown = null;
      dlg.addEventListener('mousedown', function (e) { alvoDown = e.target; });
      dlg.addEventListener('click', function (e) {
        if (e.target === dlg && alvoDown === dlg) fechar(dlg);
        alvoDown = null;
      });

      var corpo = dlg.querySelector('[data-mdl-corpo]');
      if (corpo) corpo.addEventListener('scroll', agendarCorte, { passive: true });
    })(gatilhos[g]);
  }
})();

Um botão numa dobra qualquer e um painel centrado que aparece por cima da página: cabeçalho parado com título e botão de fechar, corpo que rola por dentro com as bordas dissolvidas em gradiente, rodapé com a nota e o link para a página completa. O fundo escurece e desfoca.

O que ele resolve: o texto que não cabe na dobra e não merece uma página própria. Numa assessoria isso é sempre a mesma família — a política de investimentos resumida, o aviso ao investidor, a descrição inteira de um serviço que na dobra aparece em três linhas, a biografia completa de um assessor cujo card mostra só o nome e o cargo. Sem o modal, esse texto ou incha a seção ou vira uma página órfã que ninguém visita.

Ele é construído sobre o <dialog> nativo, com showModal(). Foco preso dentro do painel, Esc, devolução do foco a quem abriu e o resto da página inerte para tecnologia assistiva não estão escritos no script deste componente — vêm do elemento.


Quando usar

Quando não usar

Qual dos irmãos de overlay/

overlay/modal-dialogo overlay/card-expansivel-fullscreen
De onde vem o conteúdo HTML de prosa, escrito à mão os data-* de um card de trilho
Como entra centrado, com blur indo a zero cresce do lugar exato do card
Quantos por página quantos precisar um trilho deles
Diálogo <dialog> nativo <div role="dialog">
Para quê política, aviso, biografia, metodologia case, portfólio, detalhe com foto e CTA

Padrão editorial

Slot Regra
Olho (.eyebrow do core) classifica o documento: Política de investimentos, Informação obrigatória, Metodologia. Duas ou três palavras
Título o mesmo assunto que o botão prometeu, sem repetir a frase do botão ao pé da letra
Corpo prosa: p, ul.mdl-lista, strong. De 2 a 8 parágrafos — acima disso vira página
Nota do rodapé uma linha dizendo que aquilo é resumo, e onde está o inteiro
Link do rodapé a página real. Nunca # em produção

O botão que abre é uma promessa de conteúdo, não um verbo de interface. Como definimos a política funciona; Saiba mais e Clique aqui não dizem o que vai aparecer, e um modal que decepciona custa mais que um que não abre.

A dobra tem de sobreviver sem o modal. Se apagar o botão deixa a seção incompreensível, o texto não era secundário e devia estar na página.

Dois modais na mesma dobra é o limite. Três botões lado a lado transformam a seção num índice, e aí o que se quer é uma lista de links para páginas de verdade.

Especificação dos assets

Nenhum. O componente é texto — não há foto, ícone proprietário nem fonte de terceiro. O único ícone é o × do botão de fechar, um <path> de duas linhas desenhado aqui mesmo.


Como usar

<link rel="stylesheet" href="componentes/overlay/modal-dialogo/estilo.css">
<script src="componentes/overlay/modal-dialogo/script.js"></script>

O gatilho vai onde fizer sentido, dentro de qualquer dobra. O <dialog> vai fora de toda seção, como último filho do <body>: dentro de uma dobra ele herdaria o escopo de tom dela, e o painel tem fundo próprio. Cada <dialog> declara o seu escopo (.sec-deep), que é o padrão autorizado no §3 do manual — entrar num escopo que o core já define, e não redefinir --fg por conta própria.

<button type="button" aria-haspopup="dialog" data-mdl-abrir="mdl-politica">Como definimos a política</button>
…
<dialog class="dialog-casca mdl sec-deep" id="mdl-politica" data-mdl aria-labelledby="mdl-politica-titulo"> … </dialog>

.dialog-casca antes da classe local, e ela não é opcional. É a primitiva do core que zera a caixa que o navegador dá ao <dialog> — padding, borda, fundo e os limites de max-content — e pinta o ::backdrop com --veil. Sem ela o diálogo abre com fundo branco, borda e o teto de calc(100% - 6px - 2em) do agente do usuário. O que a primitiva não cobre, de propósito, é o display de [open] e a coreografia de entrada: isso é forma, e continua no estilo.css deste componente.

Para um segundo modal, repita o par com outro id. Não há limite, e não há configuração.

Ganchos

Gancho O quê
[data-mdl-abrir="id"] o <button> que abre. O valor é o id do diálogo
[data-mdl] o <dialog>. Sem este atributo o par não é reconhecido
[data-mdl-fechar] o botão de fechar. Pode haver mais de um
[data-mdl-corpo] o bloco que rola. Recebe as duas faixas de corte
.is-aberto classe posta pelo script um quadro depois do showModal(); é ela que dispara a entrada do painel e do ::backdrop
--mdl-dur tempo da entrada e da saída. .42s. O script lê este valor para saber quanto esperar antes do close()
--mdl-max largura de leitura do painel. 680px
--dialog-pad recuo do painel até a borda da janela. É parafuso da .dialog-casca, não do prefixo: o diálogo usado como palco de tela cheia é o caso nomeado em que a primitiva não zera o padding. clamp(16px,5vh,56px) clamp(16px,5vw,56px) — 45px × 56px medidos a 1440×900, 14px × 12px abaixo de 640px
--mdl-corte altura máxima da faixa de gradiente. 30px. O script lê este valor
--mdl-fade-cima / --mdl-fade-baixo o corte de fato aplicado — escritas pelo script conforme a posição da rolagem

Variações

Variação Como
Modal curto dois ou três parágrafos: o painel encolhe, some a régua de rolagem e as faixas ficam em 0. É o segundo modal do preview
Sem rodapé apagar o <footer class="mdl-rodape"> — só quando o link para a página completa já estiver no corpo
Largura de leitura --mdl-max: 560px para aviso curto, 820px quando o corpo tem tabela
Ritmo --mdl-dur — muda a entrada e a saída de uma vez
Folha de borda a borda no celular --dialog-pad:0 na media query de 640px e border:0 no painel. Some o véu nas laterais, que é o que dá a leitura de camada

Gotchas


Sem JavaScript

O <dialog> sem [open] fica invisível e o botão não faz nada. Isso é aceitável e é o comportamento correto para conteúdo secundário — a dobra continua legível, o resumo está lá, nada fica em branco.

Mas cobra a regra editorial que a seção Compliance detalha: o modal é atalho, e o conteúdo dele tem de existir em outro lugar acessível sem script.

Movimento reduzido / toque

Com prefers-reduced-motion: reduce o diálogo continua abrindo — ele foi pedido, não anda sozinho. Some só a coreografia: painel e véu nascem no estado final (medido: transition-duration 0s nos dois), o script lê LIB.reduce e fecha na hora, sem esperar transição que não existe.

No toque não há nada que dependa de hover. O corpo rola com o dedo, e overscroll-behavior:contain impede que chegar ao fim do texto continue rolando a página por baixo. Em 390×844 o painel mede 366×816 dentro da janela, com o corpo rolando 533px por dentro.

Por teclado: Tab chega ao gatilho, Enter ou Espaço abre, o foco entra no corpo do painel, Tab circula entre corpo, botão de fechar e link do rodapé sem sair do diálogo, Esc fecha e o foco volta para o botão que abriu (medido). Nada disso foi escrito aqui — é tudo do <dialog>.


Compliance

O componente não calcula, não compara e não captura dado nenhum. O que ele pisa é o terreno do texto regulado, e são três regras:

  1. O modal nunca é o único lugar onde a informação vive. Aviso ao investidor, política de privacidade, termos de uso, política de investimentos, informação de remuneração — tudo isso tem de ter página própria, com URL, indexável e linkável. O modal é atalho para ela, e é por isso que o rodapé do painel traz o link. Em produção, aquele href="#" aponta para a página real. Sem script, o modal não abre: se a informação obrigatória só existisse ali, ela deixaria de existir.

  2. O texto de exemplo é editorial, não jurídico. Os dois modais do preview trazem prazos e periodicidades ilustrativos (a cada semestre, até trinta dias) e a descrição do método em faixas. Nada disso foi revisado por área técnica ou jurídica de ninguém. Trocar pelo texto aprovado pelo compliance do cliente antes de publicar.

  3. Nada aqui promete resultado. O texto de exemplo diz explicitamente que a política define desenho e limites, e que o resultado depende do mercado; que rentabilidade passada não garante futuro; e que a remuneração é a taxa previamente acordada, que não muda conforme o produto alocado. Se o consumidor trocar o conteúdo, essas três afirmações são o piso, não o teto.

Nada é gravado: o componente não usa localStorage, sessionStorage, cookie nem chamada de rede. O estado do modal morre no recarregamento.


Histórico

Origem Quando O quê
autoral 2026-09-06 Construído do zero. Padrão de domínio comum, sem referência única

Origem. O modal de texto longo sobre <dialog> nativo — cabeçalho fixo, corpo que rola, rodapé com a saída — aparece em praticamente todo site institucional e de produto: política de privacidade, termos, aviso legal, biografia inteira de uma pessoa cujo card mostra só o nome. Padrão sem dono. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O registro consolidado vai em projetos/autorais.md.

Por que existe ao lado do irmão. O overlay/card-expansivel-fullscreen já abre uma sobreposição de conteúdo, e a categoria não precisava de dois modais. A divisão é pela origem do conteúdo: lá o detalhe nasce dos data-* de um card de um trilho, e a caixa cresce do lugar exato dele — pressupõe um card de origem, uma foto e um trilho. Aqui o conteúdo é HTML de prosa escrito à mão, o gatilho é um botão de texto em qualquer dobra, e não há card nenhum de onde crescer. Tentar usar o irmão para uma política de investimentos significaria empurrar quatro parágrafos e uma lista para dentro de um atributo data-desc.

Decisões da tradução para o contrato. <dialog> nativo com showModal, pelo mesmo critério do midia/showreel-expansivel e do <input type="range"> do midia/comparador-antes-depois: usar o elemento nativo em vez de reimplementar o comportamento dele. A saída animada antes do close(), com o tempo lido de --mdl-dur. O Esc interceptado no evento cancel para sair pelo mesmo caminho do botão. O corte do texto em máscara de gradiente escrita pelo script, e não em sombra dura. A trava de rolagem em overflow:hidden no <html>, guardando e devolvendo o valor anterior, com contador para modais empilhados. E o painel centrado por flex, e não por grid, pela razão medida nos gotchas.

06/09/2026 — o que era daqui virou do core. No mesmo dia em que nasceu, este componente entregou ao core/ três coisas que estavam escritas em quatro sobreposições ao mesmo tempo, e passou a consumi-las: a casca do <dialog> (.dialog-casca), a trava de rolagem (LIB.travarRolagem, cujo alvo <html> e cujo critério saíram daqui) e o véu (--veil). Saíram do arquivo local seis declarações de CSS (padding, border, background e os dois max-* do diálogo, mais o background do ::backdrop) e sete linhas de JS (o miolo das duas funções de trava, o contador abertos e a variável overflowAnterior); --mdl-margem não sumiu, virou --dialog-pad. Nada mudou na tela: padding do diálogo 45px 56px a 1440×900 e 14px 12px a 390, painel 680×810, corpo 608px de altura com 718 de conteúdo, foco entrando no corpo e voltando ao gatilho, Esc igual. Mudou de propósito um valor só: o véu, de 72% para 78%.

Reverificação (2026-09-06, Chrome headless, roteiro próprio, antes e depois da adoção): o mesmo roteiro em 1440×900, 390×844 e --reduce, comparando 741 valores medidos nos dois componentes de overlay/. Bateram todos, menos os esperados: a classe nova no <dialog>, max-width/max-height passando de 100% para none sem que uma única caixa mudasse (diálogo 1425×900, painel 680×810, corpo 678×608,1 — idênticos ao pixel), o --mdl-margem virando --dialog-pad com o mesmo computado, o alvo da trava saindo do script para o core, e o véu de 72% para 78%. scrollY 120 e clientWidth 1440 nos três momentos da trava; console limpo nos seis cenários; bin/conformidade.py em 0 alertas.

Divergências conhecidas vs. produção: nenhuma — o componente nasceu aqui. Calibragem no primeiro uso: espere ajustar --mdl-max ao corpo de texto do cliente (680px é confortável para a --sans do tema liberta-wealth; uma fonte de corpo maior pede mais coluna) e a altura de --mdl-corte se o tema usar entrelinha muito aberta, onde 30px podem comer mais de meia linha.