Artigo original: Line-by-line: advanced CSS tricks for click-to-open drop-down lists and menus

Escrito por: David Piepgrass

Desde que me lembro, sempre existiram dois tipos de seletores.

2abIBTqGwaakCmqxPmiGqUF8ldqbzZlfMBnR

Existia o tipo onde o texto no topo podia ser editado e o tipo onde isso não era possível. O HTML inclui o segundo tipo, sem problemas:

<select>
    <option>Apple</option>  
    <option>Banana</option>  
    <option>Cherry</option>  
    <option>Dewberry</option>
</select>

Fiquei, no entanto, chocado ao perceber que o primeiro tipo não existe em HTML. Bem, existe algo chamado datalist, mas não funciona bem — os utilizadores não podem clicar em algo para ver a lista completa. Além disso, à medida que começas a escrever, os itens começam imediatamente a desaparecer caso não comecem pela mesma string que o utilizador escreveu.

O CSS, contudo, é uma ferramenta de estilização com um poder impressionante: já foram criados jogos de vídeo completos com CSS, HTML e alguns ficheiros de imagens – que bom, acabei de perder metade da minha audiência.

Isto não quer dizer que o CSS pode fazer tudo, mas que existem pelo menos "atalhos" para alcançar uma grande variedade de truques. Aqueles de vocês que estão cansados de jogar estão provavelmente interessados em aprender sobre os truques do ofício. Acho que existe muito a aprender ao compreender como fazer uma caixa de combo.

Neste artigo, vais aprender como isto funciona:

biYH9OqxjvdVcIPN3FqUvR-VDiNaQilF2jwx

No Windows, chamamos isso de "caixas de combo" (em inglês, combo boxes), visto que elas combinam a parte superior (geralmente, um campo de texto) com uma parte de popup (geralmente, uma lista drop-down).

Como utilizar

A caixa de combo pode ser construída com divs e/ou spans. Lembra-te apenas que um analisador de HTML tem algumas regras de hierarquia. Por exemplo, não permite que um p seja um antecessor de div ou ul. Um span também não pode ser um antecessor de p ou div. Estas regras não se aplicam ao código JavaScript/React que edita o DOM.

O CSS estará à espera de três filhos: primeiro, a parte superior (conteúdo a ser sempre exibido), depois o <span class="downarrow" tabindex="-1"></span>, para a seta para baixo, e, por fim, o conteúdo a ser apresentado dentro da caixa drop-down:

<div class="combobox">
  <div>Caixa de combo simples</div>
  <div tabindex="-1" class="downarrow"></div>
  <div>
    O conteúdo do popup do drop-down vai aqui
  </div>
</div>

O drop-down, inicialmente, abrirá apenas quando a seta para baixo (▾) for clicada. Para fazer com que a caixa abra quando o conteúdo do topo for clicado, é necessário adicionares a classe dropdown a combobox e adicionar um atributo tabindex="0" ao primeiro filho:

<div class="combobox dropdown">
  <div tabindex="0">Caixa de combo simples</div>
  <div tabindex="-1" class="downarrow"></div>
  <div>
    O conteúdo do popup do drop-down vai aqui
  </div>
</div>

Observação: tabindex="-1" significa "podes clicar para lhe dar destaque, mas não podes destacá-lo utilizando a tecla Tab do teclado". tabindex="0" significa "podes dar-lhe destaque ao clicar ou com a tecla Tab, e o browser escolherá a ordem pela qual são destacados os diferentes elementos com a tecla Tab." Ao contrário de um elemento <select>, a caixa de popup não será capaz de sair da janela do browser (isto pode ser uma limitação intencional de todo o conteúdo definido pelo utilizador — caso o conteúdo definido pelo utilizador passe para lá dos limites da área da página, pode ser um risco de segurança para sites que tentam confundir ou enganar os utilizadores).

Como bónus, serás capaz de criar uma lista drop-down que o é uma caixa de combo apenas com a classe dropdown:

<div class="dropdown">
   *** <span tabindex="0">Menu drop-down</span> *** 
   <div>
     O conteúdo do popup do drop-down vai aqui
   </div>
</div>

Isto é um menu drop-down que se clica para abrir (caso queiras um menu drop-down que abre ao passar o rato por cima em vez de clicar, já existem muitos outros tutoriais sobre isso.)

Neste caso, o último elemento contém o conteúdo do drop-down e todos os outros filhos estão sempre visíveis, mas apenas os elementos com um atributo tabindex podem ser clicados para abrir a área de popup.

Podes editar de modo seguro a margem e o contorno de uma caixa de combo, assim como os seus filhos, sem comprometer o seu comportamento, com exceção para uma coisa: não deixes que o padding-right fique muito pequeno porque a seta para baixo ▾ é apresentada no padding — o seu tamanho deve ser de, pelo menos, 1em.

Resumo

  • A classe combobox é para a caixa de combo
  • A classe dropdown é para menus e caixas de combo que aparecem quando o conteúdo de cima for clicado (lembra-te de que tabindex="0")
  • A classe downarrow adiciona o ícone da seta para baixo (tabindex="-1" é obrigatório, porque não pode ser adicionado via CSS.)
  • O último filho de combobox ou dropdown é o conteúdo do drop-down.

Podes pré-visualizar a demonstração com código-fonte.

Funcionalidades do CSS de que vamos precisar

Vamos precisar de muitas coisas para isto. Aqui está uma lista (fica à vontade para saltar esta parte e ler mais tarde.)

Seletores

Seletores básicos:
.a significa "corresponde a elementos com class='a'".
A, B significa "corresponde ao seletor A ou seletor B".
A B significa "corresponde a um elemento B que tem um elemento A como antecessor".
A > B significa "corresponde a um elemento B cujo elemento pai é um elemento A".

Pseudosseletor :first-child:
*:first-child significa "corresponde a qualquer elemento desde que seja o primeiro descendente de algum elemento pai".

Pseudosseletor :last-child:
*:last-child significa "corresponde a qualquer elemento desde que seja o último descendente de outro elemento". Por exemplo, .combobox > *:last-child encontra o último descendente de qualquer elemento com class="combobox".

Pseudosseletor :empty:
.downarrow:empty significa "corresponde a um elemento com class="downarrow" caso não tenha nada dentro (nem mesmo texto simples)".

Pseudosseletor :only-child:
*:only-child significa "corresponde a qualquer elemento caso este seja o único descendente de algum elemento".

Pseudosseletor :not:
.dropdown:not(.sticky) significa "corresponde a um elemento com a classe dropdown caso este não tenha a classe sticky".

Pseudosseletor :focus:
.downarrow:focus significa "corresponde a um elemento com a classe downarrow caso este esteja destacado, porque este tem um tabindex e foi clicado com o rato ou selecionado com a tecla Tab".

Pseudosseletor :hover:
.foo:hover significa "corresponde a um elemento com a classe foo quando o ponteiro do rato está por cima dele".

A ~ B significa "corresponde a B caso um elemento irmão anterior tenha correspondido a A".

Estilos

Estilos básicos:
Certifica-te de que compreendes o modelo de caixas (texto em inglês) e os seus vários estilos associados (incluindo width, height, min-width e max-height) antes de continuares. Também deves ter conhecimentos sobre estilos básicos, tais como font-size, font-family, color e background-color.

Além disso, deves ter conhecimentos sobre unidades, especialmente as unidades mais comuns (texto em inglês):
px, em, rem, e %.

Estilo box-sizing: border-box
Isto significa que a largura e altura de um elemento incluem o padding e o contorno (texto em inglês).

Estilo display:
Vamos utilizar display: block, que apresenta um elemento como um "bloco", que é como um parágrafo em que dois blocos adjacentes têm quebras de linha entre eles.

Também vamos utilizar display: inline-block, que apresenta um elemento em linha, como um ícone dentro de um parágrafo, mas permite ter margens, contornos e padding.

Não vamos utilizar explicitamente o display: inline, que é utilizado para elementos que não têm margens, contornos ou padding, e não precisam de quebras de linha entre eles (<b>like this</b>).

Fica a saber mais sobre o display (texto em inglês).

Estilo position:
Na caixa de combo, vamos ver como é utilizado este estilo para remover elementos do fluxo normal do documento.

Os elementos normalmente têm o estilo position: static, que significa apenas "posiciona-o normalmente na página".

position: relative é como o static, excepto para duas coisas: primeiro, o elemento pode ser movido para a esquerda, direita, cima ou baixo, sem afetar qualquer um dos outros elementos.
No entanto, a caixa de combo não precisa desta funcionalidade. O segundo efeito de relative é marcar o elemento como "posicionado".

Isto é importante, pois outro tipo de posicionamento, absolute, posiciona um elemento em relação ao antecessor que estiver "posicionado" mais próximo. Especificamente, o popup do drop-down utilizará position: absolute de modo a posicionar-se em relação à parte superior da caixa de combo — portanto, a própria caixa de combo é marcada como relative.

Além disso, um elemento absolute não afeta o posicionamento dos outros itens na página, nem mesmo o seu elemento pai. Isto é exatamente o que queremos para uma caixa de popup.

Estilos left, top, right e bottom
Estes estilos são utilizados com position: relative e position: absolute, e funcionam de maneira um pouco diferente para cada um deles. Mais informação sobre isto depois.

Aprende mais sobre posicionamento (texto em inglês).

Estilo outline:
O Outline é um contorno extra desenhado por fora do contorno normal de um elemento. é normalmente utilizado para destacar um elemento, como por exemplo indicar que este foi "selecionado" por um utilizador. Como é esperado que os outlines sejam temporários, estes não ocupam espaço na página — por isso, adicionar um outline não vai empurrar os outros elementos.

Estilo box-shadow:
Desenha uma sombra "por baixo" do elemento (bem, na verdade a sombra é desenhada fora do elemento, que fica muito estranho no caso de o elemento não ter cor de fundo). Isto vai dar muito jeito para o popup do drop-down!

Estilo z-index:
Este estilo altera a ordem pela qual um elemento é desenhado pelo browser. Um z-index mais alto faz com que um elemento seja desenhado mais tarde, de modo a que apareça por cima das outras coisas na página.

Vamos precisar de um z-index grande para o nosso popup do drop-down para que apareça por cima de tudo. O filho do popup receberá um novo "contexto de sobreposição", que basicamente significa que vão ser desenhados por cima do popup, que é uma coisa boa.

Atenção (texto em inglês): z-index apenas funciona em elementos "posicionados".

Estilo cursor:
Controla a aparência (texto em inglês) do ponteiro do rato.

Estilo text-align:
Justificação horizontal (left, right ou center) – texto em inglês.

Estilo pointer-events:
A configuração none deste estilo torna um elemento "invisível" a cliques do rato (texto em inglês).

Estilo transform:
Permite-te rodar, aumentar ou diminuir a escala, inclinar, ou mover um elemento de bloco (ou bloco em linha). Estas transformações (texto em inglês) são inteligentes e também afetam o input do rato.

Por exemplo, poderias rodar o texto em 30 graus e mesmo assim selecioná-lo com o rato.

Estilo transition:
Permite animações (texto em inglês) quando o estilo muda.

Estilo opacity:
Um número que varia entre 0 e 1 e controla a visibilidade de um elemento:
1 é o valor normal que torna o elemento totalmente visível
0 torna o elemento totalmente invisível. Ao contrário de visibility: hidden e display: none, as outras formas de tornar um elemento invisível, opacity: 0 não impede que o rato interaja com o elemento.

Neste artigo, vamos utilizar a transparência para animações — ao animar a transição entre opacity: 0 e opacity: 1, podemos fazer com que o elemento apareça ou desapareça gradualmente.

Pseudoelemento

::before ou ::after:
Refere-se a um elemento virtual dentro de um elemento selecionado previamente, antes ou depois do seu conteúdo normal.

Por exemplo, se escreveres p::before { content: "!" }, então ! aparecerá no início de cada parágrafo.

Podemos utilizar o content com ::before ou ::after para desenhar a seta a apontar para baixo (▾).

Preparar a aparência inicial

.combobox e .dropdown precisam de ter um posicionamento relative para que o popup do drop-down possa ser posicionado relativo a eles. O display: inline-block permite que a caixa de combo tenha margens, padding e contorno. Ao contrário de display: block, este permite que apareçam outras coisas na mesma linha (tais como labels ou outras caixas de combo.)

.combobox, .dropdown { 
  /* "relative" e "inline-block" (ou apenas "block") são necessários aqui
     para que "absolute" funcione corretamente nos descendentes */
  position: relative;
  display: inline-block;
}

As caixas de combo terão um contorno embutido, mas as listas drop-down não:

.combobox {
  border: 1px solid #999;
  padding-right: 1.25em; /* deixa espaço para ▾ */
}

A cor #999 é ligeiramente mais escura do que o contorno do elemento <select> do Chrome e ligeiramente mais clara do que o contorno do elemento <select> do FireFox. Por isso, não fica muito diferente do que qualquer um deles.

Como desenhamos a pequena seta para baixo (▾)?

Aqui, a dificuldade é controlar a altura. A caixa de combo pode ter conteúdo com uma dimensão imprevisível: letra pequena, letra grande, uma linha ou duas linhas. O "botão" de seta tem de ter a mesma altura para que funcione independentemente de onde o utilizador clica — qualquer sítio dentro do contorno deve funcionar.

Então, como podemos fazer com que a seta se adapte à altura do seu irmão à esquerda?
O CSS Grid (texto em inglês) consegue fazer isto muito rapidamente, mas não é suportado por todos os browsers. O Flexbox também pode conseguir fazer o trabalho, mas optei por utilizar um truque antigo para compatibilidade com browsers mais antigos: o posicionamento absoluto.

Com o posicionamento absoluto, posso obrigar a seta a ter a mesma altura do seu recipiente.

A desvantagem desta abordagem é que a seta existirá fora do fluxo normal do documento, então o browser não reservará nenhum espaço para ela. Em vez disso, vamos atribuir à caixa de combo algum padding do lado direito (acima de 1.25em), e a seta existirá dentro do padding.

No modo de posicionamento absoluto, o top alinha o limite superior do elemento em relação ao limite superior do seu recipiente: top: 0 significa que os dois limites superiores vão estar no mesmo local. De maneira semelhante, left: 0 alinha o lado esquerdo do elemento ao lado esquerdo do recipiente e por aí a fora.

Coordenadas positivas empurram o elemento "para dentro" em relação ao recipiente. Então, top: 10px quer dizer "coloca o topo do elemento 10px abaixo do topo do elemento pai", enquanto que bottom: 10px quer dizer "coloca o fundo do elemento 10px acima do fundo do elemento pai."

Neste caso, precisamos de top: 0; bottom: 0; right: 0; width: 1.25em para colocar a seta do lado direito, de cima para baixo.

.combobox > .downarrow, .dropdown > .downarrow {
  display: block;     /* Permite margin/border/padding/size */
  position: absolute; /* Coloca fora do fluxo normal */
  top: 0;    /* Alinha o topo da downarrow com a borda superior da combobox */
  bottom: 0; /* Alinha o fundo da downarrow com o fundo da combobox */
  right: 0; /* Alinha o limite direito da downarrow com o limite direito da combobox*/
  width: 1.25em;
  
  cursor: default; /* Utiliza o ponteiro em forma de seta em vez de I-beam */
  nav-index: -1; /* Define tabindex, não-funcional na maioria dos browsers */
  border-width: 0;        /* Desativado de início */
  border-color: inherit;  /* Copia a cor da borda do elemento pai */
  border-left: inherit;   /* Copia a borda do elemento pai */
}

Aqui, display: block e display: inline-block têm o mesmo efeito. Por isso, utilizei o mais curto. Também desativei o ponteiro do rato que normalmente aparece ao passar por cima de texto (visto que a seta para baixo conta como texto).

Na verdade, existe um modo de definir tabindex em CSS, chamado nav-index. Mas a maior parte dos browsers não a suporta. Por isso, se descobrires que a tua caixa de combo funciona apenas no Opera, já sabes a razão.

Deves, então, adicionar tabindex="-1" ao lado de class="downarrow".

Este código desabilita os contornos, com a ressalva de que a cor/estilo do contorno deve ser herdada do elemento pai (a caixa de combo), caso outro CSS aumente border-left-width. Já agora, podes utilizar a opção inherit em qualquer atributo que não herde atributos do elemento pai desde o início.

Decidi que deve existir um contorno à esquerda no caso do popup não abrir quando o lado esquerdo é clicado. Desse modo, a seta para baixo parece-se com um botão, sugerindo subtilmente que pode ser clicado. Lembra-te do plano: apenas dropdown, e não a combobox por si só, abrirá quando o lado esquerdo é destacado.

Portanto, vou adicionar um contorno quando combobox é utilizado por si só:

.combobox:not(.dropdown) > .downarrow {
  border-left-width: 1px;
}

De seguida, caso o utilizador nos tenha fornecido um <span class="downarrow"></span> vazio, precisamos de adicionar magicamente o caractere de seta em falta através de ::before (ou ::after) e content:

.downarrow:empty::before {
  content: '▾';
}

A seta para baixo também precisa de estar centrada no elemento .downarrow. text-align: center irá centrar o texto horizontalmente, mas centrar verticalmente é mais complicado. vertical-align: middle não funciona porque é desenhado para alinhar elementos inline com o texto à volta. O que nós queremos é alinhar o nosso pseudoelemento de seta para baixo com o recipiente pai .downarrow.

Aqui está um truque para isto:

.downarrow::before, .downarrow > *:only-child {
  text-align: center; /* Centra horizontalmente */
  /* Truque para centralizar verticalmente */
  position: relative; /* Permite que o elemento se mova */
  top: 50%;           /* Move para baixo 50% da dimensão do recipiente */
  transform: translateY(-50%); /* Move para cima 50% do tamanho do elemento */
  display: block;     /* `transform` exige block/inline-block */
}

Lembra-te que apenas adicionamos o conteúdo ::before se .downarrow estiver vazio. Se o utilizador forneceu o seu próprio elemento de seta personalizado, vamos querer centrá-lo na mesma, daí o seletor .downarrow > *:only-child.

Se a caixa de combo tiver um elemento <input>, esta não deve ter contorno:

.combobox > input {
  border: 0 /* Caixa de combo já tem um contorno */
}

A próxima parte é opcional, mas geralmente o primeiro descendente de uma caixa de combo deve ter 100% da largura da sua .combobox pai, para o caso da caixa de combo ser mais larga do que o primeiro descendente, o primeiro descendente estica para corresponder ao tamanho do elemento. Para o caso do utilizador ter construído a caixa de combo com spans em vez de divs (talvez para que possa ser colocada dentro de um <p>), pode fazer sentido definir o primeiro descendente como inline-block de modo a que possa ter padding e margens.

.combobox > *:first-child {
  width: 100%;
  box-sizing: border-box; /* para que 100% inclua contorno e padding */
  display: inline-block;
}

Preparar a lista drop-down

Inicialmente, só a queremos oculta, por isso podemos utilizar display: none.

Em preparação para quando estiver visível, no entanto, vamos definir mais algumas propriedades. Começa por position: absolute, para que fique fora do fluxo normal do documento (lembra-te de que um elemento absolute é posicionado relativo ao seu antecessor relative mais próximo, que é .combobox ou .dropdown). Quando estiver visível, é claro que deve ter um contorno e cor de fundo, além de uma sombra por baixo.

Aqui, podes ver box-shadow: 1px 2px 4px 1px #4448, que significa "apresenta uma sombra de 1px à direita do elemento, 2px para baixo, desfocada em 4px, e torna a sombra 1px mais larga do que o próprio elemento, com a cor #4448". Também precisamos de um grande z-index para que o popup apareça por cima de tudo:

.dropdown > *:last-child,
.combobox > *:last-child {
  display: none;          /* Oculto de início */
  position: absolute;     /* Fora do fluxo do documento */
  left: 0;          /* Lado esquerdo do popup = lado esquerdo do elemento pai */
  top: 100%;        /* Topo do popup = 100% abaixo do topo do elemento pai */
  border: 1px solid #999; /* Contorno cinzento */
  background-color: #fff; /* Fundo branco */
  box-shadow: 1px 2px 4px 1px #4448; /* Sombra */
  z-index: 9999;          /* Desenha por cima de tudo */
  min-width: 100%;        /* >= 100% tão largo como o recipiente */
  box-sizing: border-box; /* A largura inclui contorno e padding */
}

Aqui, utilizei left: 0 e top: 100% para posicionar corretamente o popup, mas, neste caso, acontece que o posicionamento predefinido do popup é praticamente o mesmo. Por isso, esses estilos não são realmente necessários.

Para tornar a caixa drop-down visível, tudo o que precisamos é display: block.

Que seletores vamos precisar para alcançar isto?

??? {
  display: block;
}

O mais óbvio, o drop-down deve ser apresentado nos três casos abaixo:

  1. O utilizador clicou na .downarrow
  2. O utilizador clicou ou utilizou a tecla tab até .dropdown
  3. O utilizador clicou ou utilizou a tecla tab até um descendente de .dropdown

A caixa drop-down é o último descendente. Por isso, vamos ter de combinar o seletor *:last-child com o :focus para detetar quando uma das coisas acima for clicada ou destacada com a tecla tab:

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus              > *:last-child,
.dropdown > *:focus          ~ *:last-child {
  display: block;
}

Porém, ainda não terminamos. O que fazer se o utilizador clicar numa caixa de texto ou num link dentro da caixa drop-down? O clique vai fazer com que a .downarrow ou o .dropdown perca o destaque, o que faz com que a caixa drop-down desapareça instantaneamente.

No caso de um link, o browser destaca o link quando o botão do rato é pressionado, mas não segue o link até que o botão seja largado. Por isso, se o drop-down desaparecer instantaneamente, qualquer link no drop-down deixará de poder ser seguido!

Para corrigir isto, devemos manter a caixa aberta sempre que algo dentro do :last-child tenha o destaque:

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > *:last-child:focus-within,
.dropdown > *:last-child:focus-within {
  display: block;
}

Atenção: Isto não funciona no Edge/IE (abaixo é descrita uma alternativa).

Se a seta para baixo for clicada uma segunda vez, devemos ocultar a caixa drop-down. Isto pode ser alcançado deste modo:

.downarrow:focus {
  pointer-events: none; /* Faz com que o segundo clique feche */
}

Isto faz com que .downarrow seja invisível para eventos do rato quando tem destaque. Por isso, quando clicas nela, estás na realidade a clicar atrás dela (na .combobox). Isto faz com que perca o destaque, o que, por sua vez, faz com que a caixa drop-down desapareça.

Podemos fazer a mesma coisa para .dropdown, para que clicar novamente na área superior de um .dropdown faça com que desapareça:

.dropdown > *:not(:last-child):focus,
.downarrow:focus,
.dropdown:focus {
  pointer-events: none; /* Faz com que o segundo clique feche */
}

Isto funciona em grande parte. No entanto, caso a tua área superior contenha uma caixa de texto, existe um efeito secundário, visto que a caixa de texto não vai processar o input do rato normalmente. Descobri, contudo, que a caixa de texto ainda pode ser utilizada.

No Firefox, podes clicar e arrastar para selecionar texto se começares quando o popup está fechado, mas não funciona quando o popup está aberto. No Edge, é o oposto: podes clicar e arrastar para selecionar texto apenas quando o popup está aberto. De qualquer modo, é basicamente utilizável, visto que é provável que o utilizador tente novamente uma vez no caso do seu input não funcionar à primeira.

O comportamento do Chrome é… inconsistente. De qualquer modo, para obter um comportamento perfeito — onde um clique fecha a caixa sem causar que a caixa de texto perca o destaque — penso que é necessário utilizar JavaScript.

Toques finais

A caixa de combo normalmente deve ter uma margem. Isso, porém, parece ser opcional, visto que os controlos do <input> não têm uma em princípio:

.combobox {
  margin: 5px;
}

Vamos tornar isso mais bonito ao abrir a caixa com uma animação.

A propriedade transition é o modo mais fácil de fazer animações. Na realidade, para o nosso objetivo, um simples comando como transition: 0.4s; habilita animações para todos os estilos suportados. No entanto, até agora, o único estilo que vamos alterar é o display e as alterações ao display não podem ser animadas.

Então, vamos tentar animar uma transição de opacity: 0 para opacity: 1 ao modificar os nossos estilos existentes…

.dropdown > *:last-child,
.combobox > *:last-child {
  display: none;
  /* 
     ... outros estilos como estavam antes ...
  */
  opacity: 0;
  transition: 0.4s;
}

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > *:last-child:focus-within,
.dropdown > *:last-child:focus-within {
  display: block;
  opacity: 1;
  transition: 0.15s;
}

O tempo da transição controla quanto tempo leva a entrar no estado atual. Ou seja, este código quer dizer "demora 0.15 segundos para mostrar e 0.4 segundos para ocultar."

Acontece que a animação não funciona. O problema está no facto de que display: hidden bloqueia animações (texto em inglês). Em vez disso, precisamos de utilizar uma das outras formas de ocultar coisas. Outra forma de ocultar coisas é o visibility: hidden. Infelizmente, ele também bloqueia parcialmente animações — a animação para mostrar o popup funciona, mas a animação para ocultar, não.

Não podemos depender de opacity: 0 por si só para ocultar um elemento, porque o rato pode interagir na mesma com um elemento que tenha opacity: 0. No entanto, podemos corrigir isso com pointer-events: none.

Então, o fade-in e o fade-out em funcionamento têm este aspeto:

.dropdown > *:last-child,
.combobox > *:last-child {
  display: block;
  /* 
     ... outros estilos como estavam antes ...
  */
  transition: 0.4s;
  opacity: 0;
  pointer-events: none;
}

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > *:last-child:focus-within,
.dropdown > *:last-child:focus-within {
  display: block;
  transition: 0.15s;
  opacity: 1;
  pointer-events: auto;
}

Outra forma de embelezar que podemos adicionar é mover o popup para a posição, tal como animar o top:

.dropdown > *:last-child,
.combobox > *:last-child {
  display: block;
  /* 
     ... outros estilos como estavam antes ...
  */
  top: 0;
  opacity: 0;
  transition: 0.4s;
  pointer-events: none;
}

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > *:last-child:focus-within,
.dropdown > *:last-child:focus-within {
  display: block;
  top: 100%;
  opacity: 1;
  transition: 0.15s;
  pointer-events: auto;
}
mf6Fg50wbA1iptPpoqMItU5cEm2Uo0LwzErg

Decidi que isto é um pouco "excessivo" e não incluí esta parte na versão final.

Por fim, devemos ter um retângulo — um contorno a mostrar quando a caixa de combo está "ativa".

Primeiro, vamos adicionar o retângulo de destaque para essa seta para baixo:

.downarrow:focus {
  outline: 2px solid #48F8;
}

Idealmente, teríamos um retângulo de destaque para a própria caixa de combo, como isto:

.combobox:focus-within {
  outline: 2px solid #48F;
}

Isto funciona bem no Chrome. No Firefox, porém, o outline é expandido para além do contorno para englobar toda a caixa de popup, que fica com um aspeto um pouco estranho, especialmente se a caixa de popup não tiver a mesma largura da parte do topo. No Edge, o contorno nem sequer aparece porque o Edge não suporta :focus-within (ver abaixo). Então, o que podemos fazer em vez disso?

Decidi utilizar isto:

.combobox > *:not(:last-child):focus {
  outline: 2px solid #48F8;
}

Isso desenha um contorno à volta do elemento filho destacado em vez da própria caixa de combo. Por vezes, no entanto, também fica estranho se o filho não tiver o mesmo tamanho da caixa de combo que o engloba. Por isso, adicionei transparência (#48F8 em vez de #48F) para o tornar menos visível, e consequentemente, com um aspeto menos estranho no pior dos casos.

Manter aberto

Os estilos que temos para já mantêm a caixa aberta apenas quando algo tem destaque. Por isso, se clicares em texto na área popup, o popup fecha. Para a última versão, expandi a lista de razões para manter o popup aberto para incluir um estilo sticky, que manterá o drop-down aberto ao passar o rato por cima, de modo a que, ao clicar, não feche a caixa.

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > *:last-child:focus-within,
.dropdown > *:last-child:focus-within,
.combobox > .sticky:last-child:hover,
.dropdown > .sticky:last-child:hover {
  display: block;
  top: 100%;
  opacity: 1;
  transition: 0.15s;
  pointer-events: auto;
}

Tal como abordei anteriormente, ocorrem erros quando a área do topo de uma caixa de combo tem uma caixa de texto. Para te permitir evitar facilmente este problema, ajustei o CSS existente para que o estilo pointer-events: none o seja aplicado quando o elemento .dropdown também tem a classe sticky:

.dropdown:not(.sticky) > *:not(:last-child):focus,
.downarrow:focus,
.dropdown:focus {
  pointer-events: none; /* Faz com que o segundo clique feche */
}

Por fim, se uma lista .dropdown contém links, existe uma pequena inconveniência. Após clicar num link, a lista não fechará automaticamente porque o link tem o destaque e programamos o drop-down para não fechar quando um descendente tem o destaque.

Para evitar isso, adicionei suporte para uma nova classe less-sticky. Tal como sticky, less-sticky mantém o popup aberto quando o rato passa por cima. Ao contrário de sticky, less-sticky não mantém o popup aberto quando um descendente tem o destaque.

Então, a nossa nova lista de seletores está a ficar bastante longa:

.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > .sticky:last-child:hover,
.dropdown > .sticky:last-child:hover,
.combobox > .less-sticky:last-child:hover,
.dropdown > .less-sticky:last-child:hover,
.combobox > *:last-child:focus-within:not(.less-sticky),
.dropdown > *:last-child:focus-within:not(.less-sticky) {
  display: block;
  opacity: 1;
  transition: 0.15s;
  pointer-events: auto;
  top: 100%;
}

Ainda nem sequer terminamos, porque isto ainda não é compatível com o Edge e com o Internet Explorer.

Casos do Edge

Assim que coloquei a minha caixa de combo a trabalhar perfeitamente no Firefox e no Chrome, fiquei transtornado ao ver que ficava feia e completamente inutilizável no Edge. O que correu mal?

Em primeiro lugar, os contornos desapareceram porque o Edge e o IE não suportam transparência nos contornos, como em rgb(200,150,100,50) ou #8888. Utilizei #8888 para o contorno. Para fazer com que funcione no Edge, alterei-o para #999.

Outra alternativa é fornecer um contorno não opaco só para o Edge:

border: 1px solid #888;  /* Edge/IE não têm suporte para transparência do contorno */
border: 1px solid #8888; /* Restantes browsers */

Em segundo lugar, não importa quantas vezes clicas — as div do drop-down simplesmente não abrem!

Ao resolver este problema, aprendi algo novo — se um browser não compreender um seletor utilizado numa declaração CSS, este irá ignorar todo o bloco.

Por exemplo, se escreveres .x, .y, .z:unknown { margin:1em }, então x e y não vão ter margens simplesmente porque o browser não compreende o unknown.

Acontece que o Edge não compreende o :focus-within, que é o responsável por permitir que a área do drop-down permaneça aberta quando um elemento input dentro da área drop-down é clicado. O problema foi ter misturado seletores suportados e não suportados.

De maneira a fazer com que funcione no Edge, precisava de repetir todo o bloco de estilos "como-abrir-a-lista-de-drop-down" separadamente para os seletores que utilizam :focus-within, de modo que esses seletores não impeçam que outros seletores funcionem.

Depois, como alternativa para a falta de :focus-within, decidi tentar detetar o Edge (texto em inglês) e manter automaticamente qualquer lista .dropdown aberta quando o rato está a passar por cima, no estado :hover, neste caso. Assim, ainda é possível utilizar um elemento destacado (tal como um a href ou input) dentro da área drop-down, embora desapareça precocemente caso o rato saia de cima.

O código para tudo isso é o seguinte:

/* Lista de situações para as quais apresentar a lista dropdown. */
.combobox > .downarrow:focus ~ *:last-child,
.dropdown:focus > *:last-child,
.dropdown > *:focus ~ *:last-child,
.combobox > .sticky:last-child:hover,
.dropdown > .sticky:last-child:hover,
.combobox > .less-sticky:last-child:hover,
.dropdown > .less-sticky:last-child:hover,
.combobox > *:last-child:focus:not(.less-sticky),
.dropdown > *:last-child:focus:not(.less-sticky) {
  display: block;
  opacity: 1;
  transition: 0.15s;
  pointer-events: auto;
}

/* focus-within não é suportado pelo Edge/IE. Seletores não suportados fazem com que todo o bloco seja ignorado, por isso temos de repetir todos os estilos de modo separado para o focus-within. */
.combobox > *:last-child:focus-within:not(.less-sticky),
.dropdown > *:last-child:focus-within:not(.less-sticky) {
  display: block;
  opacity: 1;
  transition: 0.15s;
  pointer-events: auto;
}

/* Deteta o Edge/IE e comporta-se como se less-sticky estivesse ativo para todos os dropdowns (caso contrário não é possível clicar nos links) */
@supports (-ms-ime-align:auto) {
  .dropdown > *:last-child:hover {
    display: block;
    opacity: 1;
    pointer-events: auto;
  }
}

/* Deteta o IE e faz a mesma coisa. */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
  .dropdown > *:last-child:hover {
    display: block;
    opacity: 1;
    pointer-events: auto;
  }
}

Em terceiro, o estilo outline não estava a funcionar no Edge. O problema era novamente que o Edge não tem suporte para contornos não opacos.

A solução é um estilo especial de transparência para o Edge:

outline: 2px solid #8AF; /* Edge/IE não consegue lidar com a transparência do contorno */  
outline: 2px solid #48F8;

Em quarto, eu tinha colocado duas caixas de combo dentro de um elemento <label>. Tentar abrir a segunda abre sempre a primeira, em vez disso. Acontece que, no Edge, se estiveres a utilizar um rato, podes selecionar apenas o primeiro elemento input dentro de uma label.

Em quinto, as caixas de drop-down não tinham sombras. Mais uma vez, isto era por ter utilizado uma sombra não opaca. Mais uma vez, o Edge precisava do seu próprio CSS especial:

box-shadow: 1px 2px 4px 1px #666; /* O Edge não consegue lidar com a transparência da sombra */
box-shadow: 1px 2px 4px 1px #4448;

O Internet Explorer 11 tem praticamente as mesmas limitações. Por isso, corrigir o Edge consertou praticamente o IE, com a exceção de que era necessária uma deteção de browser diferente para o IE em comparação com o Edge.

Sincronizar o popup com a área superior

Infelizmente, o CSS não pode fazer isso por nós. Por isso, na demonstração final, é utilizado JavaScript para atualizar a parte superior da caixa de combo quando a parte do popup é alterada. Por exemplo, utilizei este código com base em jQuery para atualizar a parte superior do selecionador de cor:

function parentComboBox(el) {
  for (el = el.parentNode; el && 
    Array.prototype.indexOf.call(el.classList, "combobox") <= -1;)
    el = el.parentNode;
  return el;
}
$(".combobox .color").mousedown(function() {
  var c = this.style.backgroundColor;
  $(parentComboBox(this)).find(".color")[0].
    style.backgroundColor = c;
});

Versão final

Clica aqui para veres uma demonstração com código-fonte no CodePen.