Tucano v0.37.2

Modal

Diálogo que se sobrepõe a tudo, com o fundo desfocado e um brilho da cor do tom. Feito sobre o <dialog> nativo: é dele que vêm a camada acima de qualquer z-index, o foco preso, o Esc e o foco devolvido a quem abriu — o componente só desenha a caixa e anima.

Este <dialog> já estava na página. É o caminho para um formulário renderizado pelo servidor.

Exemplos

Todos abaixo são o mesmo Tucano.modal() com opções diferentes.

Tamanhos

size define a largura máxima da caixa; md é o padrão.

Tons

tone muda o brilho do fundo e a aura da caixa.

Confirmação com promessa

Tucano.confirm() resolve true ou false; fechar por fora é recusa.

Folha no celular

sheet: true sobe do rodapé em telas estreitas. Estreite a janela para ver.

Sem fechar por fora

closeOnBackdrop: false ignora o clique no fundo; closable: false tira o X e o Esc.

Motivo do fechamento

onClose recebe por onde a pessoa saiu. Feche de jeitos diferentes.

Conteúdo livre

content(nó) põe qualquer elemento no corpo, que rola quando passa da altura.

Ação que não fecha

closes: false numa ação mantém o modal aberto depois do clique.

Como usar

Há dois caminhos. Pelo JavaScript, o modal é montado na hora, entra no <body> ao abrir e sai ao fechar. Pelo template, o <dialog> já está na página e o componente só abre, fecha e anima.

Em JavaScript

Tucano.modal({
  title: 'Excluir contrato',
  text: 'Esta ação não pode ser desfeita.',
  tone: 'danger',     // default | danger | success | warning
  size: 'md',         // sm | md | lg | full
  sheet: true,        // no celular sobe do rodapé
  actions: [
    { text: 'Cancelar', variant: 'outline' },
    { text: 'Excluir', variant: 'danger', onClick: () => deleteContract() },
  ],
  onClose: (reason, modal) => console.log(reason),
});

// Só com texto, o atalho aceita uma string
Tucano.modal('Arquivo enviado.');

O atalho Tucano.modal() cria e abre num passo, e devolve a instância. Para montar antes e abrir depois, use a classe:

const m = new Tucano.Modal({ title: 'Novo contato', size: 'lg' });
m.content(form);         // qualquer nó, ou um array de nós
m.open();
m.close();               // anima a saída e chama onClose(reason)

Confirmação

O caso mais comum de modal num CRUD é perguntar antes de apagar. Tucano.confirm() devolve uma promessa, e o código continua lendo de cima para baixo:

if (await Tucano.confirm({ title: 'Excluir contrato?', text: 'Esta ação não pode ser desfeita.', confirm: 'Excluir' })) {
  deleteContract();
}
OpçãoPadrãoPara quê
confirm'Confirmar'Rótulo do botão que resolve true
cancel'Cancelar'Rótulo do botão que resolve false
tone'danger'Com danger o botão de confirmar é vermelho; com qualquer outro tom, primário

As demais opções do modal valem aqui também, menos actions, que o confirm() monta sozinho. Um onClose passado continua sendo chamado.

Fechar por fora é recusa, não limbo

Pelo X, pelo Esc ou pelo fundo, a promessa resolve false. Sem isso ela ficaria pendente para sempre, e o await nunca voltaria.

Ações

Cada item de actions vira um .tuc-btn no rodapé, na ordem em que foi escrito.

ChavePadrãoPara quê
text—Rótulo do botão
variant'outline'Variante do botão: primary, outline, ghost, danger…
onClick—Recebe a instância do modal
closestruefalse mantém o modal aberto depois do clique

Escrito no template

Quando o conteúdo vem renderizado pelo servidor — um formulário do Django, com erros e csrf_token —, o <dialog> mora no template. Todo dialog.tuc-modal é adotado no carregamento e a cada htmx:afterSwap; abrir não o insere e fechar não o remove, porque o nó é de quem escreveu o HTML.

<dialog class="tuc-modal is-md" id="delete" aria-labelledby="delete-title">
  <form class="tuc-modal__panel" method="post" action="{% url 'contract-delete' contract.pk %}">
    {% csrf_token %}
    <div class="tuc-modal__top">
      <div class="tuc-modal__header">
        <h2 class="tuc-modal__title" id="delete-title">Excluir contrato?</h2>
        <p class="tuc-modal__text">Esta ação não pode ser desfeita.</p>
      </div>
      <button type="button" class="tuc-btn is-ghost is-icon is-sm tuc-modal__close" aria-label="Fechar" data-tuc-modal-close>…</button>
    </div>
    <div class="tuc-modal__footer">
      <button type="button" class="tuc-btn is-outline" data-tuc-modal-close>Cancelar</button>
      <button class="tuc-btn is-danger">Excluir</button>
    </div>
  </form>
</dialog>

<button class="tuc-btn is-outline" data-tuc-modal="#delete">Excluir</button>
AtributoOndePara quê
data-tuc-modal="#id"GatilhoAbre o <dialog> do seletor ao clicar
data-tuc-modal-closeBotão dentro do diálogoFecha, com o motivo 'button'
data-closable="false"<dialog>O Esc deixa de fechar
data-backdrop="false"<dialog>O clique no fundo deixa de fechar

As classes são as mesmas que o JavaScript monta: __panel é a caixa, __top guarda o __header (com __title e __text) e o __close, __body é o corpo que rola e __footer alinha as ações à direita. Tamanho e tom vão como classe no próprio <dialog>: is-lg, is-danger, is-sheet.

No template, o X e o nome do diálogo são seus

Pelo JavaScript o botão de fechar e o aria-labelledby saem prontos. No <dialog> escrito à mão, escreva os dois: sem o X a pessoa depende do Esc, e sem o aria-labelledby o leitor de tela anuncia só "diálogo".

Confirmar antes de enviar um formulário

Para um formulário de exclusão que já existe na página, o confirm() intercepta o envio e deixa passar só com o sim:

document.querySelector('#delete-contract').addEventListener('submit', async (e) => {
  e.preventDefault();
  const ok = await Tucano.confirm({ title: 'Excluir contrato?', confirm: 'Excluir' });
  if (ok) e.target.submit();
});

O <dialog> fechado precisa voltar a display: none — e a biblioteca já faz isso

A regra do modal declara display: grid, que vence a folha do navegador. Sem .tuc-modal:not([open]) { display: none }, um <dialog> parado no template ficaria renderizado, fixo e transparente sobre a página inteira: nada aparece, e nenhum clique funciona. Por isso ele não precisa de hidden.

Tons e brilho

O fundo não é só escuro: um brilho radial atrás da caixa separa o diálogo do que ficou embaixo, e o desfoque tira a página do foco. O tom troca a cor desse brilho e da aura em volta da caixa — não a cor dos botões, que continua sendo escolha de cada ação.

toneClasseCor do brilho
defaultis-default--tuc-accent — trocar o destaque troca o brilho
dangeris-danger--tuc-danger-fill
successis-success--tuc-success
warningis-warning--tuc-warning

Tamanhos

A caixa ocupa a largura disponível até o limite do tamanho, e o corpo rola quando o conteúdo passa da altura da tela.

sizeLargura máximaPara quê
sm22remConfirmação curta
md30remO padrão: aviso, formulário de poucos campos
lg44remTabela ou formulário largo
fulla tela, com folga de 1remLargura e altura inteiras: editor, visualização de documento

Abaixo de 40rem os botões do rodapé ocupam a linha, para o alvo de toque crescer. Com sheet, a caixa perde as bordas de baixo, encosta no rodapé e respeita a área segura do iPhone. É opcional porque nem todo modal quer virar gaveta no celular.

Teclado e acessibilidade

O showModal() põe o diálogo na top layer e deixa o resto da página inerte: o foco não escapa para trás dele, e ao fechar volta para quem abriu. Nada disso é JavaScript nosso — é o elemento nativo.

TeclaAção
Tab Shift+TabAnda entre os controles do diálogo, sem voltar para a página
Enter EspaçoAciona o botão focado
EscFecha com animação e devolve o foco; não faz nada com closable: false

Por que <dialog>, e não uma div com z-index

A top layer fica acima de qualquer z-index e é imune a ancestral com overflow: hidden ou transform — os três motivos de um modal artesanal aparecer cortado ou por baixo. O Esc nativo fecha na hora, sem animação; o componente intercepta o cancel para fechar pelo mesmo caminho dos outros, que anima e informa o motivo.

Com title, o diálogo ganha aria-labelledby apontando para ele, e o leitor de tela anuncia o título ao abrir. O X é um .tuc-btn com aria-label="Fechar". Com prefers-reduced-motion, a entrada dura 100 ms e não cresce.

Motivo em onCloseQuando
'button'O X, ou um data-tuc-modal-close no template
'action'Um botão de actions
'escape'A tecla Esc
'backdrop'Clique fora da caixa
'api'close() chamado sem argumento

O modal e a gaveta dividem essa mecânica: uma correção feita num deles vale para o outro.

API

Gerada do código a cada build — se algo não está aqui, não existe.

Marcação
dialog.tuc-modal [data-tuc-modal-close] [data-tuc-modal]
Em JS
new Tucano.Modal(opcoes) Tucano.modal() Tucano.confirm()
Atributos
data-backdrop data-closable data-tuc-modal
Métodos
open close content

Opções

OpçãoPadrãoPara quê
titlenull
text''
size'md'sm | md | lg | full
tone'default'default | danger | success | warning
sheetfalseno celular sobe do rodape em vez de surgir no centro
closabletruebotao X e Escape
closeOnBackdroptrue
actionsnull[{ text, variant, onClick, closes }] — closes:false mantem aberto
onClosenull
className''