Como criar um componente de seleção múltipla

Uma visão geral fundamental de como criar um componente de seleção múltipla responsivo, adaptável e acessível para experiências de usuário de classificação e filtragem.

Nesta postagem, quero compartilhar uma maneira de criar um componente de seleção múltipla. Teste a demonstração.

Demonstração

Se preferir vídeo, confira uma versão desta postagem no YouTube:

Visão geral

Os usuários geralmente recebem itens, às vezes muitos, e nesses casos, é uma boa ideia oferecer uma maneira de reduzir a lista para evitar a sobrecarga de opções. Esta postagem do blog mostra como usar a interface de filtragem para reduzir as opções. Isso é feito ao apresentar atributos de itens que os usuários podem selecionar ou desmarcar, reduzindo os resultados e, portanto, a sobrecarga de opções.

Interações

O objetivo é permitir a navegação rápida pelas opções de filtro para todos os usuários e seus diferentes tipos de entrada. Isso será entregue com um par de componentes adaptáveis e responsivos. Uma barra lateral tradicional de caixas de seleção para computadores, teclados e leitores de tela, além de um <select multiple> para usuários de dispositivos touch.

Captura de tela de comparação mostrando a versão clara e escura para computador com uma barra lateral de caixas de seleção e a versão para dispositivos móveis iOS e Android com um elemento de seleção múltipla.

Essa decisão de usar a multisseleção integrada para toque, e não para computador, economiza e cria trabalho, mas acredito que oferece experiências adequadas com menos dívida de código do que criar toda a experiência responsiva em um componente.

Tocar

O componente de toque economiza espaço e ajuda na precisão da interação do usuário em dispositivos móveis. Ele economiza espaço ao recolher uma barra lateral inteira de caixas de seleção em uma experiência de toque de sobreposição integrada <select>. Ele ajuda na precisão da entrada mostrando uma grande experiência de sobreposição de toque fornecida pelo sistema.

Uma prévia de captura de tela do elemento de seleção múltipla no Chrome para Android, iPhone e iPad. O iPad e o iPhone têm a opção de seleção múltipla ativada, e cada um recebe uma experiência exclusiva otimizada para o tamanho da tela.

Teclado e gamepad

Confira abaixo uma demonstração de como usar um <select multiple> do teclado.

Essa seleção múltipla integrada não pode ser estilizada e é oferecida apenas em um layout compacto, que não é adequado para apresentar muitas opções. Percebe como não é possível ver a variedade de opções nessa caixa pequena? Embora seja possível mudar o tamanho, ele ainda não é tão útil quanto uma barra lateral de caixas de seleção.

Marcação

Os dois componentes vão estar no mesmo elemento <form>. Os resultados desse formulário, sejam caixas de seleção ou uma seleção múltipla, serão observados e usados para filtrar a grade, mas também poderão ser enviados a um servidor.

<form>

</form>

Componente de caixas de seleção

Grupos de caixas de seleção precisam ser envolvidos em um elemento <fieldset> e receber um <legend>. Quando o HTML é estruturado dessa forma, os leitores de tela e o FormData entendem automaticamente a relação dos elementos.

<form>
  <fieldset>
    <legend>New</legend>
    … checkboxes …
  </fieldset>
</form>

Com o agrupamento no lugar, adicione um <label> e um <input type="checkbox"> para cada um dos filtros. Eu escolhi envolver o meu em um <div> para que a propriedade CSS gap possa espaçá-los uniformemente e manter o alinhamento quando os rótulos forem multilinhas.

<form>
  <fieldset>
    <legend>New</legend>
    <div>
      <input type="checkbox" id="last 30 days" name="new" value="last 30 days">
      <label for="last 30 days">Last 30 Days</label>
    </div>
    <div>
      <input type="checkbox" id="last 6 months" name="new" value="last 6 months">
      <label for="last 6 months">Last 6 Months</label>
    </div>
   </fieldset>
</form>

Uma captura de tela com uma sobreposição informativa para os elementos de legenda e
  fieldset, mostrando a cor e o nome do elemento.

Componente <select multiple>

Um recurso raramente usado do elemento <select> é multiple. Quando o atributo é usado com um elemento <select>, o usuário pode escolher vários itens da lista. É como mudar a interação de uma lista de opções para uma lista de caixas de seleção.

<form>
  <select multiple="true" title="Filter results by category">
    …
  </select>
</form>

Para rotular e criar grupos dentro de um <select>, use o elemento <optgroup> e atribua a ele um atributo e valor label. Esse elemento e o valor do atributo são semelhantes aos elementos <fieldset> e <legend>.

<form>
  <select multiple="true" title="Filter results by category">
    <optgroup label="New">
      …
    </optgroup>
  </select>
</form>

Agora adicione os elementos <option> para o filtro.

<form>
  <select multiple="true" title="Filter results by category">
    <optgroup label="New">
      <option value="last 30 days">Last 30 Days</option>
      <option value="last 6 months">Last 6 Months</option>
    </optgroup>
  </select>
</form>

Captura de tela da renderização de um elemento de seleção múltipla na área de trabalho.

Rastreamento de entrada com contadores para informar a tecnologia assistiva

A técnica de status role é usada nessa experiência do usuário para rastrear e manter a contagem de filtros para leitores de tela e outras tecnologias adaptativas. O vídeo do YouTube demonstra o recurso. A integração começa com HTML e o atributo role="status".

<div role="status" class="sr-only" id="applied-filters"></div>

Esse elemento vai ler em voz alta as mudanças feitas no conteúdo. Podemos atualizar o conteúdo com contadores CSS à medida que os usuários interagem com as caixas de seleção. Para isso, primeiro precisamos criar um contador com um nome em um elemento pai das entradas e do elemento de estado.

aside {
  counter-reset: filters;
}

Por padrão, a contagem será 0, o que é ótimo, nada é :checked por padrão neste design.

Em seguida, para incrementar o contador recém-criado, vamos segmentar os filhos do elemento <aside> que são :checked. Conforme o usuário muda o estado das entradas, o contador filters é atualizado.

aside :checked {
  counter-increment: filters;
}

O CSS agora reconhece a contagem geral da interface da caixa de seleção, e o elemento de função de status está vazio e aguardando valores. Como o CSS mantém a contagem na memória, a função counter() permite acessar o valor do conteúdo do pseudoelemento:

aside #applied-filters::before {
  content: counter(filters) " filters ";
}

O HTML do elemento de função de status agora vai anunciar "2 filtros " para um leitor de tela. Esse é um bom começo, mas podemos melhorar, como compartilhar a contagem de resultados que os filtros atualizaram. Vamos fazer esse trabalho em JavaScript, já que ele está fora do que os contadores podem fazer.

Captura de tela do leitor de tela do macOS anunciando o número de filtros ativos.

Aninhamento

O algoritmo de contadores funcionou muito bem com CSS nesting-1, já que consegui colocar toda a lógica em um bloco. Parece portátil e centralizado para leitura e atualização.

aside {
  counter-reset: filters;

  & :checked {
    counter-increment: filters;
  }

  & #applied-filters::before {
    content: counter(filters) " filters ";
  }
}

Layouts

Esta seção descreve os layouts entre os dois componentes. A maioria dos estilos de layout é para o componente de caixa de seleção de computador.

O formulário

Para otimizar a legibilidade e a capacidade de leitura dos usuários, o formulário recebe uma largura máxima de 30 caracteres, definindo essencialmente uma largura de linha óptica para cada rótulo de filtro. O formulário usa o layout de grade e a propriedade gap para espaçar os fieldsets.

form {
  display: grid;
  gap: 2ch;
  max-inline-size: 30ch;
}

O elemento <select>

A lista de rótulos e caixas de seleção consomem muito espaço em dispositivos móveis. Portanto, o layout verifica o dispositivo apontador principal do usuário para mudar a experiência de toque.

@media (pointer: coarse) {
  select[multiple] {
    display: block;
  }
}

Um valor de coarse indica que o usuário não poderá interagir com a tela com alta precisão usando o dispositivo de entrada principal. Em um dispositivo móvel, o valor do ponteiro geralmente é coarse, já que a interação principal é o toque. Em um dispositivo desktop, o valor do ponteiro geralmente é fine, já que é comum ter um mouse ou outro dispositivo de entrada de alta precisão conectado.

Os fieldsets

O estilo e o layout padrão de um <fieldset> com um <legend> são únicos:

Captura de tela dos estilos padrão de um fieldset e uma legenda.

Normalmente, para espaçar meus elementos filhos, eu usaria a propriedade gap, mas o posicionamento exclusivo do <legend> dificulta a criação de um conjunto de filhos igualmente espaçados. Em vez de gap, são usados o seletor de irmão adjacente e margin-block-start.

fieldset {
  padding: 2ch;

  & > div + div {
    margin-block-start: 2ch;
  }
}

Isso evita que o espaço do <legend> seja ajustado segmentando apenas os filhos <div>.

Captura de tela mostrando o espaçamento da margem entre as entradas, mas não a legenda.

O rótulo e a caixa de seleção do filtro

Como um filho direto de um <fieldset> e dentro da largura máxima do 30ch do formulário, o texto do rótulo pode ser ajustado se for muito longo. A quebra de texto é ótima, mas o desalinhamento entre o texto e a caixa de seleção não é. O Flexbox é ideal para isso.

fieldset > div {
  display: flex;
  gap: 2ch;
  align-items: baseline;
}
Captura de tela mostrando como a marca de seleção se alinha à
    primeira linha de texto em um cenário de quebra de várias linhas.
Jogue mais neste Codepen

A grade animada

A animação de layout é feita pelo Isotope. Um plug-in eficiente e avançado para classificação e filtragem interativas.

JavaScript

Além de ajudar a orquestrar uma grade animada e interativa, o JavaScript é usado para refinar alguns detalhes.

Normalizar a entrada do usuário

Esse design tem um formulário com duas maneiras diferentes de fornecer entrada, e elas não serializam da mesma forma. Com um pouco de JavaScript, podemos normalizar os dados.

Captura de tela do console JavaScript do DevTools que
  mostra a meta e os resultados de dados normalizados.

Escolhi alinhar a estrutura de dados do elemento <select> à estrutura das caixas de seleção agrupadas. Para fazer isso, um listener de eventos input é adicionado ao elemento <select>, momento em que os selectedOptions são mapeados.

document.querySelector('select').addEventListener('input', event => {
  // make selectedOptions iterable then reduce a new array object
  let selectData = Array.from(event.target.selectedOptions).reduce((data, opt) => {
    // parent optgroup label and option value are added to the reduce aggregator
    data.push([opt.parentElement.label.toLowerCase(), opt.value])
    return data
  }, [])
})

Agora é seguro enviar o formulário ou, no caso desta demonstração, instruir o Isotope sobre o que filtrar.

Como concluir o elemento de função de status

O elemento está apenas contabilizando e anunciando a contagem de filtros com base na interação da caixa de seleção, mas achei uma boa ideia compartilhar também o número de resultados e garantir que as opções do elemento <select> também sejam contabilizadas.

Opção de elemento <select> refletida no counter()

Na seção de normalização de dados, um listener já foi criado na entrada. No final dessa função, o número de filtros escolhidos e o número de resultados para esses filtros são conhecidos. Os valores podem ser transmitidos para o elemento de função de estado desta forma.

let statusRoleElement = document.querySelector('#applied-filters')
statusRoleElement.style.counterSet = selectData.length

Resultados refletidos no elemento role="status"

O :checked oferece uma maneira integrada de transmitir o número de filtros escolhidos para o elemento de função de status, mas não mostra o número filtrado de resultados. O JavaScript pode monitorar a interação com as caixas de seleção e, depois de filtrar a grade, adicionar textContent, como fez o elemento <select>.

document
  .querySelector('aside form')
  .addEventListener('input', e => {
    // isotope demo code
    let filterResults = IsotopeGrid.getFilteredItemElements().length
    document.querySelector('#applied-filters').textContent = `giving ${filterResults} results`
})

Assim, esse trabalho conclui o anúncio "2 filtros gerando 25 resultados".

Captura de tela do leitor de tela do macOS anunciando resultados.

Agora, nossa excelente experiência de tecnologia assistiva será oferecida a todos os usuários, seja qual for a forma de interação.

Conclusão

Agora que você sabe como eu fiz, como você faria? 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, me envie um tweet com o link, e eu vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada disponível.