Faça seus próprios painéis de conteúdo de expansão e contratação
Publicados: 2022-03-10Nós os chamamos de 'painel de abertura e fechamento' até agora, mas eles também são descritos como painéis de expansão, ou mais simplesmente, painéis de expansão.
Para esclarecer exatamente do que estamos falando, acesse este exemplo no CodePen:
É isso que vamos construir neste breve tutorial.
Do ponto de vista da funcionalidade, existem algumas maneiras de obter a abertura e o fechamento animados que estamos procurando. Cada abordagem com seus próprios benefícios e compensações. Vou compartilhar os detalhes do meu método 'go-to' em detalhes neste artigo. Vamos considerar as abordagens possíveis primeiro.
Abordagens
Existem variações dessas técnicas, mas, em termos gerais, as abordagens se enquadram em uma das três categorias:
- Animar/transição da
height
ou alturamax-height
do conteúdo. - Use
transform: translateY
para mover os elementos para uma nova posição, dando a ilusão de um painel fechando e, em seguida, renderize novamente o DOM assim que a transformação estiver concluída com os elementos em sua posição final. - Use uma biblioteca que faça alguma combinação/variação de 1 ou 2!
Considerações de cada abordagem
De uma perspectiva de desempenho, usar uma transformação é mais eficaz do que animar ou fazer a transição da altura/altura máxima. Com uma transformação, os elementos móveis são rasterizados e deslocados pela GPU. Esta é uma operação barata e fácil para uma GPU, então o desempenho tende a ser muito melhor.
As etapas básicas ao usar uma abordagem de transformação são:
- Obtenha a altura do conteúdo a ser recolhido.
- Mova o conteúdo e tudo depois pela altura do conteúdo a ser recolhido usando
transform: translateY(Xpx)
. Opere a transformação com a transição de escolha para dar um efeito visual agradável. - Use JavaScript para ouvir o evento de final de
transitionend
. Quando disparar,display: none
o conteúdo e remova a transformação e tudo deve estar no lugar certo.
Não soa muito mal, certo?
No entanto, há uma série de considerações com essa técnica, então eu costumo evitá-la para implementações casuais, a menos que o desempenho seja absolutamente crucial.
Por exemplo, com a abordagem transform: translateY
, você precisa considerar o z-index
dos elementos. Por padrão, os elementos que se transformam estão depois do elemento gatilho no DOM e, portanto, aparecem no topo das coisas antes deles quando traduzidos.
Você também precisa considerar quantas coisas aparecem após o conteúdo que deseja recolher no DOM. Se você não quer um grande buraco em seu layout, pode achar mais fácil usar JavaScript para envolver tudo o que deseja mover em um elemento de contêiner e apenas movê-lo. Gerenciável, mas acabamos de introduzir mais complexidade! Este é, no entanto, o tipo de abordagem que usei ao mover os jogadores para cima e para baixo no In/Out. Você pode ver como isso foi feito aqui.
Para necessidades mais casuais, costumo fazer a transição da max-height
do conteúdo. Essa abordagem não funciona tão bem quanto uma transformação. A razão é que o navegador está interpolando a altura do elemento em colapso durante a transição; isso causa muitos cálculos de layout que não são tão baratos para o computador host.
No entanto, esta abordagem ganha do ponto de vista da simplicidade. A recompensa de sofrer o impacto computacional mencionado acima é que o re-flow do DOM cuida da posição e da geometria de tudo. Temos muito pouco em termos de cálculos para escrever, mais o JavaScript necessário para executá-lo bem é comparativamente simples.
O elefante na sala: detalhes e elementos de resumo
Aqueles com um conhecimento profundo dos elementos do HTML saberão que existe uma solução HTML nativa para este problema na forma de details
e elementos de summary
. Veja alguns exemplos de marcação:
<details> <summary>Click to open/close</summary> Here is the content that is revealed when clicking the summary... </details>
Por padrão, os navegadores fornecem um pequeno triângulo de divulgação próximo ao elemento de resumo; clique no resumo e o conteúdo abaixo do resumo é revelado.
Ótimo, hein? Os detalhes até suportam o evento de toggle
em JavaScript para que você possa fazer esse tipo de coisa para executar coisas diferentes com base em se está aberto ou fechado (não se preocupe se esse tipo de expressão JavaScript parecer estranho; chegaremos a isso em mais detalhe em breve):
details.addEventListener("toggle", () => { details.open ? thisCoolThing() : thisOtherThing(); })
OK, eu vou parar sua excitação ali mesmo. Os detalhes e elementos de resumo não são animados. Não por padrão e atualmente não é possível fazer com que eles sejam animados/transicionados abertos e fechados com CSS e JavaScript adicionais.
Se você sabe o contrário, eu adoraria estar errado.
Infelizmente, como precisamos de uma estética de abertura e fechamento, teremos que arregaçar as mangas e fazer o melhor e mais acessível trabalho que pudermos com as outras ferramentas à nossa disposição.
Certo, com as notícias deprimentes fora do caminho, vamos fazer isso acontecer.
Padrão de marcação
A marcação básica ficará assim:
<div class="container"> <button type="button" class="trigger">Show/Hide content</button> <div class="content"> All the content here </div> </div>
Temos um container externo para envolver o expansor e o primeiro elemento é o botão que serve como gatilho para a ação. Observe o atributo type no botão? Eu sempre incluo isso, pois por padrão um botão dentro de um formulário realizará um envio. Se você estiver perdendo algumas horas imaginando por que seu formulário não está funcionando e os botões estão envolvidos em seu formulário; certifique-se de verificar o atributo type!
O próximo elemento após o botão é a própria gaveta de conteúdo; tudo o que você quer esconder e mostrar.
Para dar vida às coisas, usaremos propriedades personalizadas de CSS, transições de CSS e um pouco de JavaScript.
Lógica Básica
A lógica básica é esta:
- Deixe a página carregar, meça a altura do conteúdo.
- Defina a altura do conteúdo no contêiner como o valor de uma propriedade personalizada CSS.
- Oculte imediatamente o conteúdo adicionando um atributo
aria-hidden: "true"
a ele. O usoaria-hidden
garante que a tecnologia assistiva saiba que o conteúdo também está oculto. - Conecte o CSS para que a
max-height
da classe de conteúdo seja o valor da propriedade customizada. - Pressionar nosso botão de gatilho alterna a propriedade aria-hidden de true para false que, por sua vez, alterna a
max-height
do conteúdo entre0
e a altura definida na propriedade personalizada. Uma transição nessa propriedade fornece o toque visual - ajuste a gosto!
Nota: Agora, este seria um caso simples de alternar uma classe ou atributo se max-height: auto
for igual à altura do conteúdo. Infelizmente não. Vá e grite sobre isso para o W3C aqui.
Vamos dar uma olhada em como essa abordagem se manifesta no código. Os comentários numerados mostram as etapas lógicas equivalentes acima no código.
Aqui está o JavaScript:
// Get the containing element const container = document.querySelector(".container"); // Get content const content = document.querySelector(".content"); // 1. Get height of content you want to show/hide const heightOfContent = content.getBoundingClientRect().height; // Get the trigger element const btn = document.querySelector(".trigger"); // 2. Set a CSS custom property with the height of content container.style.setProperty("--containerHeight", `${heightOfContent}px`); // Once height is read and set setTimeout(e => { document.documentElement.classList.add("height-is-set"); 3. content.setAttribute("aria-hidden", "true"); }, 0); btn.addEventListener("click", function(e) { container.setAttribute("data-drawer-showing", container.getAttribute("data-drawer-showing") === "true" ? "false" : "true"); // 5. Toggle aria-hidden content.setAttribute("aria-hidden", content.getAttribute("aria-hidden") === "true" ? "false" : "true"); })
O CSS:
.content { transition: max-height 0.2s; overflow: hidden; } .content[aria-hidden="true"] { max-height: 0; } // 4. Set height to value of custom property .content[aria-hidden="false"] { max-height: var(--containerHeight, 1000px); }
Pontos de Observação
E as várias gavetas?
Quando você tem várias gavetas de abrir e ocultar em uma página, você precisará percorrer todas elas, pois provavelmente serão de tamanhos diferentes.
Para lidar com isso, precisaremos fazer um querySelectorAll
para obter todos os contêineres e, em seguida, executar novamente sua configuração de variáveis personalizadas para cada conteúdo dentro de um forEach
.
Esse setTimeout
Eu tenho um setTimeout
com duração 0
antes de definir o contêiner para ser oculto. Isso é sem dúvida desnecessário, mas eu o uso como uma abordagem de 'cinto e chaves' para garantir que a página seja renderizada primeiro para que as alturas do conteúdo estejam disponíveis para leitura.
Só dispare isso quando a página estiver pronta
Se você tiver outras coisas acontecendo, você pode optar por agrupar o código da gaveta em uma função que é inicializada no carregamento da página. Por exemplo, suponha que a função de gaveta foi envolvida em uma função chamada initDrawers
, poderíamos fazer isso:
window.addEventListener("load", initDrawers);
Na verdade, adicionaremos isso em breve.
Atributos de dados-* adicionais no contêiner
Há um atributo de dados no contêiner externo que também é alternado. Isso é adicionado no caso de haver algo que precise ser alterado com o gatilho ou contêiner à medida que a gaveta abre/fecha. Por exemplo, talvez queiramos alterar a cor de algo ou revelar ou alternar um ícone.
Valor padrão na propriedade personalizada
Há um valor padrão definido na propriedade personalizada em CSS de 1000px
. Esse é o bit após a vírgula dentro do valor: var(--containerHeight, 1000px)
. Isso significa que se o --containerHeight
for danificado de alguma forma, você ainda deverá ter uma transição decente. Obviamente, você pode definir isso para o que for adequado ao seu caso de uso.
Por que não usar apenas um valor padrão de 100000px?
Dado que max-height: auto
não faz a transição, você pode estar se perguntando por que não opta apenas por uma altura definida de um valor maior do que você jamais precisaria. Por exemplo, 10000000px?
O problema com essa abordagem é que ela sempre fará a transição dessa altura. Se a duração da sua transição for definida como 1 segundo, a transição 'viajará' 10000000px em um segundo. Se o seu conteúdo tiver apenas 50px de altura, você obterá um efeito de abertura/fechamento bastante rápido!
Operador ternário para alternar
Usamos um operador ternário algumas vezes para alternar atributos. Algumas pessoas os odeiam, mas eu e outros os amo. Eles podem parecer um pouco estranhos e um pouco 'golfe de código' no começo, mas uma vez que você se acostuma com a sintaxe, acho que eles são uma leitura mais direta do que um if/else padrão.
Para os não iniciados, um operador ternário é uma forma condensada de if/else. Eles são escritos para que a coisa a verificar seja primeiro, depois o ?
separa o que executar se a verificação for verdadeira, e depois o :
para distinguir o que deve ser executado se a verificação for falsa.
isThisTrue ? doYesCode() : doNoCode();
Nossas alternâncias de atributos funcionam verificando se um atributo está definido como "true"
e, em caso afirmativo, defina-o como "false"
, caso contrário, defina-o como "true"
.
O que acontece no redimensionamento da página?
Se um usuário redimensionar a janela do navegador, há uma grande probabilidade de que as alturas do nosso conteúdo sejam alteradas. Portanto, convém executar novamente a configuração da altura dos contêineres nesse cenário. Agora que estamos considerando essas eventualidades, parece um bom momento para refatorar um pouco as coisas.
Podemos fazer uma função para definir as alturas e outra função para lidar com as interações. Em seguida, adicione dois ouvintes na janela; um para quando o documento for carregado, conforme mencionado acima, e outro para escutar o evento de redimensionamento.
Um pouco mais de A11Y
É possível adicionar um pouco mais de consideração para acessibilidade usando os atributos aria-expanded
, aria-controls
e aria-labelledby
. Isto dará uma melhor indicação à tecnologia assistida quando as gavetas forem abertas/expandidas. Adicionamos aria-expanded="false"
à nossa marcação de botão junto com aria-controls="IDofcontent"
, em que IDofcontent
é o valor de um id que adicionamos ao contêiner de conteúdo.
Em seguida, usamos outro operador ternário para alternar o atributo aria-expanded
ao clicar no JavaScript.
Todos juntos
Com o carregamento da página, várias gavetas, trabalho A11Y extra e manipulação de eventos de redimensionamento, nosso código JavaScript fica assim:
var containers; function initDrawers() { // Get the containing elements containers = document.querySelectorAll(".container"); setHeights(); wireUpTriggers(); window.addEventListener("resize", setHeights); } window.addEventListener("load", initDrawers); function setHeights() { containers.forEach(container => { // Get content let content = container.querySelector(".content"); content.removeAttribute("aria-hidden"); // Height of content to show/hide let heightOfContent = content.getBoundingClientRect().height; // Set a CSS custom property with the height of content container.style.setProperty("--containerHeight", `${heightOfContent}px`); // Once height is read and set setTimeout(e => { container.classList.add("height-is-set"); content.setAttribute("aria-hidden", "true"); }, 0); }); } function wireUpTriggers() { containers.forEach(container => { // Get each trigger element let btn = container.querySelector(".trigger"); // Get content let content = container.querySelector(".content"); btn.addEventListener("click", () => { btn.setAttribute("aria-expanded", btn.getAttribute("aria-expanded") === "false" ? "true" : "false"); container.setAttribute( "data-drawer-showing", container.getAttribute("data-drawer-showing") === "true" ? "false" : "true" ); content.setAttribute( "aria-hidden", content.getAttribute("aria-hidden") === "true" ? "false" : "true" ); }); }); }
Você também pode jogar com ele no CodePen aqui:
Resumo
É possível continuar por algum tempo refinando e atendendo a mais e mais situações, mas a mecânica básica de criar uma gaveta confiável de abertura e fechamento para o seu conteúdo deve estar agora ao seu alcance. Espero que você também esteja ciente de alguns dos perigos. O elemento de details
não pode ser animado, max-height: auto
não faz o que você esperava, você não pode adicionar de forma confiável um valor massivo de max-height e esperar que todos os painéis de conteúdo sejam abertos conforme o esperado.
Para reiterar nossa abordagem aqui: meça o contêiner, armazene sua altura como uma propriedade personalizada CSS, oculte o conteúdo e, em seguida, use uma alternância simples para alternar entre max-height
de 0 e a altura armazenada na propriedade personalizada.
Pode não ser o método de melhor desempenho absoluto, mas descobri que para a maioria das situações é perfeitamente adequado e se beneficia de ser comparativamente simples de implementar.