Como criar um componente Configurações

Uma visão geral básica de como criar um componente de configurações de controles deslizantes e caixas de seleção.

Nesta postagem, quero compartilhar ideias sobre como criar um componente de configurações para a Web que seja responsivo, ofereça suporte a várias entradas de dispositivo e funcione em navegadores. Teste a demonstração.

Demonstração

Se você preferir um vídeo ou quiser uma prévia da interface/UX do que estamos criando, aqui está um tutorial mais curto no YouTube:

Visão geral

Dividi os aspectos desse componente nas seguintes seções:

  1. Layouts
  2. Cor
  3. Entrada de intervalo personalizado
  4. Entrada personalizada da caixa de seleção
  5. Considerações sobre acessibilidade
  6. JavaScript

Layouts

Esta é a primeira demonstração do desafio de GUI do tipo todos os CSS Grid. Veja cada grade destacada com o Chrome DevTools para grade:

Contornos coloridos e sobreposições de espaçamento de lacunas que ajudam a mostrar todas as caixas do layout de configurações

Apenas para lacunas

O layout mais comum:

foo {
  display: grid;
  gap: var(--something);
}

Eu chamo esse layout de "apenas para lacunas", porque ele usa apenas a grade para adicionar lacunas entre os blocos.

Cinco layouts usam essa estratégia. Confira todos eles:

Layouts de grade vertical destacados com contornos e lacunas preenchidas

O elemento fieldset, que contém cada grupo de entrada (.fieldset-item), está usando gap: 1px para criar as bordas entre os elementos. Nenhuma solução de borda complicada!

Lacuna preenchida
.grid {
  display: grid;
  gap: 1px;
  background: var(--bg-surface-1);

  & > .fieldset-item {
    background: var(--bg-surface-2);
  }
}
Truque de borda
.grid {
  display: grid;

  & > .fieldset-item {
    background: var(--bg-surface-2);

    &:not(:last-child) {
      border-bottom: 1px solid var(--bg-surface-1);
    }
  }
}

Quebra de grade natural

O layout mais complexo acabou sendo o layout de macro, o sistema de layout lógico entre <main> e <form>.

Centralizar o conteúdo do wrapper

O flexbox e a grade oferecem recursos para align-items ou align-content e, ao lidar com elementos de agrupamento, os alinhamentos de layout content distribuem espaço entre os filhos como um grupo.

main {
  display: grid;
  gap: var(--space-xl);
  place-content: center;
}

O elemento principal está usando a abreviação de alinhamento place-content: center para que os filhos sejam centralizados vertical e horizontalmente nos layouts de uma e duas colunas.

Veja no vídeo acima como o "conteúdo" permanece centralizado, mesmo que o wrapper tenha ocorrido.

Repetir minmax de ajuste automático

O <form> usa um layout de grade adaptável para cada seção. Esse layout muda de uma para duas colunas com base no espaço disponível.

form {
  display: grid;
  gap: var(--space-xl) var(--space-xxl);
  grid-template-columns: repeat(auto-fit, minmax(min(10ch, 100%), 35ch));
  align-items: flex-start;
  max-width: 89vw;
}

Essa grade tem um valor diferente para row-gap (--space-xl) de column-gap (--space-xxl) para colocar esse toque personalizado no layout responsivo. Quando as colunas empilham, queremos uma grande lacuna, mas não tão grande quanto em uma tela widescreen.

A propriedade grid-template-columns usa três funções CSS: repeat(), minmax() e min(). Una Kravets tem uma ótima postagem do blog sobre layout (link em inglês) com o nome RAM.

Há três adições especiais no nosso layout, se compararmos com o de Una:

  • Transmitimos uma função min() extra.
  • Especificamos align-items: flex-start.
  • Há um estilo max-width: 89vw.

A função extra min() é bem descrita por Evan Minto no blog dele na postagem Intrinsically Responsive CSS Grid with minmax() e min(). Recomendo que leia esse texto. A correção de alinhamento flex-start remove o efeito de alongamento padrão para que os filhos desse layout não precisem ter a mesma altura, mas sim alturas intrínsecas naturais. O vídeo do YouTube detalha essa adição de alinhamento.

Confira detalhes sobre max-width: 89vw nesta postagem. Vou mostrar o layout com e sem o estilo aplicado:

O que está acontecendo? Quando max-width é especificado, ele fornece contexto, dimensionamento explícito ou dimensionamento definido para o algoritmo de layout auto-fit para saber quantas repetições ele pode encaixar no espaço. Embora pareça óbvio que o espaço é "largura total", de acordo com a especificação da grade CSS, é necessário fornecer um tamanho ou tamanho máximo definido. Forneci um tamanho máximo.

Então, por que 89vw? Porque "funcionava" no meu layout. Eu e algumas outras pessoas do Chrome estamos investigando por que um valor mais razoável, como 100vw, não é suficiente e se isso é realmente um bug.

Espaçamento

A maior parte da harmonia desse layout vem de uma paleta limitada de espaçamento, 7, para ser exato.

:root {
  --space-xxs: .25rem;
  --space-xs:  .5rem;
  --space-sm:  1rem;
  --space-md:  1.5rem;
  --space-lg:  2rem;
  --space-xl:  3rem;
  --space-xxl: 6rem;
}

O uso desses fluxos é muito bom com grade, CSS @nest e sintaxe de nível 5 de @media. Veja um exemplo: o conjunto de estilos totalmente <main>.

main {
  display: grid;
  gap: var(--space-xl);
  place-content: center;
  padding: var(--space-sm);

  @media (width >= 540px) {
    & {
      padding: var(--space-lg);
    }
  }

  @media (width >= 800px) {
    & {
      padding: var(--space-xl);
    }
  }
}

Uma grade com conteúdo centralizado, moderadamente preenchido por padrão (como em dispositivos móveis). No entanto, à medida que mais espaço da janela de visualização se torna disponível, ele se espalha pelo aumento do padding. O CSS de 2021 está ótimo!

Lembra do layout anterior, "apenas para lacunas"? Confira uma versão mais completa de como eles ficam nesse componente:

header {
  display: grid;
  gap: var(--space-xxs);
}

section {
  display: grid;
  gap: var(--space-md);
}

Cor

O uso controlado da cor ajudou esse design a se destacar como expressivo, mas mínimo. Faço assim:

:root {
  --surface1: lch(10 0 0);
  --surface2: lch(15 0 0);
  --surface3: lch(20 0 0);
  --surface4: lch(25 0 0);

  --text1: lch(95 0 0);
  --text2: lch(75 0 0);
}

Nomeo as cores de superfície e texto com números, em vez de nomes como surface-dark e surface-darker, porque, em uma consulta de mídia, vou inverter as cores, e claro e escuro não terão significado.

Eu as inverto em uma consulta de mídia de preferência assim:

:root {
  ...

  @media (prefers-color-scheme: light) {
    & {
      --surface1: lch(90 0 0);
      --surface2: lch(100 0 0);
      --surface3: lch(98 0 0);
      --surface4: lch(85 0 0);

      --text1: lch(20 0 0);
      --text2: lch(40 0 0);
    }
  }
}

É importante ter uma noção rápida do panorama geral e da estratégia antes de nos aprofundarmos nos detalhes de sintaxe de cores. Mas, como me adiantou um pouco, vou voltar um pouco.

ALCO?

Sem se aprofundar muito no campo da teoria das cores, a LCH é uma sintaxe orientada por humanos, que atende ao modo como percebemos a cor, não a como medimos as cores com matemática (como 255). Isso proporciona uma vantagem exclusiva, já que os humanos podem programar com mais facilidade e outros usuários estarão em sintonia com esses ajustes.

Uma captura de tela da página da Web pod.link/csspodcast com a opção &quot;Cor 2: percepção&quot; aberta
Saiba mais sobre cores perceptivas e muito mais no Podcast de CSS

Por hoje, nesta demonstração, focaremos na sintaxe e nos valores que estou mudando para deixar claro e escuro. Vamos analisar uma superfície e uma cor de texto:

:root {
  --surface1: lch(10 0 0);
  --text1:    lch(95 0 0);

  @media (prefers-color-scheme: light) {
    & {
      --surface1: lch(90 0 0);
      --text1:    lch(40 0 0);
    }
  }
}

--surface1: lch(10 0 0) se traduz em luminosidade 10%, 0 chroma e 0 hue: um cinza incolor muito escuro. Em seguida, na consulta de mídia para o modo claro, o brilho é alterado para 90% com --surface1: lch(90 0 0);. Essa é a essência da estratégia. Comece apenas mudando a claridade entre os dois temas, mantendo as proporções de contraste que o design exige ou o que pode manter a acessibilidade.

O bônus com lch() aqui é que a claridade é orientada pelas pessoas e podemos nos sentir bem com uma mudança de % nela, que será perceptiva e consistentemente que % diferente. hsl(), por exemplo, não é tão confiável.

mais para saber sobre espaços de cores e lch(), se você tiver interesse. Está chegando!

No momento, o CSS não pode acessar essas cores. Vou repetir: não temos acesso a um terço das cores na maioria dos monitores modernos. E essas não são qualquer cor, mas as cores mais vívidas que a tela pode exibir. Nossos sites estão desbotados porque o hardware de monitor evoluiu mais rápido do que as especificações de CSS e as implementações em navegadores.

Lea Verou

Controles adaptáveis de formulários com esquema de cores

Muitos navegadores incluem controles de tema escuro, atualmente o Safari e o Chromium, mas você precisa especificar em CSS ou HTML que seu design os use.

A imagem acima demonstra o efeito da propriedade do painel "Estilos" do DevTools. A demonstração usa a tag HTML, que, na minha opinião, é geralmente um local melhor:

<meta name="color-scheme" content="dark light">

Saiba tudo sobre isso neste artigo sobre color-scheme de Thomas Steiner (em inglês). Há muito mais a ganhar do que entradas de caixas de seleção escuras.

CSS accent-color

Houve uma atividade recente em accent-color em elementos de formulário, sendo um único estilo CSS que pode mudar a cor de tonalidade usada no elemento de entrada dos navegadores. Leia mais sobre isso aqui no GitHub (em inglês). Eu o incluí nos meus estilos desse componente. À medida que os navegadores forem compatíveis, minhas caixas de seleção estarão mais sobre o tema, com os destaques de cor rosa e roxo.

input[type="checkbox"] {
  accent-color: var(--brand);
}

Uma captura de tela do Chromium no Linux de caixas de seleção rosa

As cores se destacam com gradientes fixos e dentro do foco

A cor tem mais destaque quando é usada com moderação, e uma das maneiras que gosto de conseguir isso é com interações coloridas na interface.

Há muitas camadas de feedback e interação da interface no vídeo acima, que ajudam a dar personalidade à interação porque:

  • Destacar o contexto.
  • Fornecer feedback da interface sobre o "quão cheio" o valor está no intervalo.
  • Enviar feedback da interface informando que um campo está aceitando entrada.

Para fornecer feedback durante a interação com um elemento, o CSS está usando a pseudoclasse :focus-within para mudar a aparência de vários elementos. Vamos analisar o .fieldset-item, que é muito interessante:

.fieldset-item {
  ...

  &:focus-within {
    background: var(--surface2);

    & svg {
      fill: white;
    }

    & picture {
      clip-path: circle(50%);
      background: var(--brand-bg-gradient) fixed;
    }
  }
}

Quando um dos filhos desse elemento está em foco:

  1. O plano de fundo .fieldset-item recebe uma cor de superfície de maior contraste.
  2. O svg aninhado é preenchido em branco para maior contraste.
  3. O <picture> clip-path aninhado se expande para um círculo completo e o segundo plano é preenchido com o gradiente fixo brilhante.

Período personalizado

Com o seguinte elemento de entrada HTML, vou mostrar como personalizar a aparência dele:

<input type="range">

Precisamos personalizar três partes desse elemento:

  1. Elemento de intervalo / contêiner
  2. Acompanhamento
  3. Miniatura

Estilos de elementos de intervalo

input[type="range"] {
  /* style setting variables */
  --track-height: .5ex;
  --track-fill: 0%;
  --thumb-size: 3ex;
  --thumb-offset: -1.25ex;
  --thumb-highlight-size: 0px;

  appearance: none;         /* clear styles, make way for mine */
  display: block;
  inline-size: 100%;        /* fill container */
  margin: 1ex 0;            /* ensure thumb isn't colliding with sibling content */
  background: transparent;  /* bg is in the track */
  outline-offset: 5px;      /* focus styles have space */
}

As primeiras linhas de CSS são as partes personalizadas dos estilos, e espero que rotulá-los claramente ajude. Os demais estilos são, na maioria, redefinidos com o objetivo de fornecer uma base consistente para criar as partes complicadas do componente.

Estilos de faixa

input[type="range"]::-webkit-slider-runnable-track {
  appearance: none; /* clear styles, make way for mine */
  block-size: var(--track-height);
  border-radius: 5ex;
  background:
    /* hard stop gradient:
        - half transparent (where colorful fill we be)
        - half dark track fill
        - 1st background image is on top
    */
    linear-gradient(
      to right,
      transparent var(--track-fill),
      var(--surface1) 0%
    ),
    /* colorful fill effect, behind track surface fill */
    var(--brand-bg-gradient) fixed;
}

O segredo é "revelar" a cor vibrante de preenchimento. Isso é feito com o gradiente de parada forçada na parte superior. O gradiente é transparente até a porcentagem de preenchimento e, depois, usa a cor de superfície da faixa não preenchida. Atrás dessa superfície não preenchida, há uma cor de largura total, aguardando a transparência.

Estilo de preenchimento da faixa

Meu design exige JavaScript para manter o estilo de preenchimento. Existem estratégias exclusivas de CSS, mas elas exigem que o elemento de polegar tenha a mesma altura que a faixa, e não consegui encontrar uma harmonia dentro desses limites.

/* grab sliders on page */
const sliders = document.querySelectorAll('input[type="range"]')

/* take a slider element, return a percentage string for use in CSS */
const rangeToPercent = slider => {
  const max = slider.getAttribute('max') || 10;
  const percent = slider.value / max * 100;

  return `${parseInt(percent)}%`;
};

/* on page load, set the fill amount */
sliders.forEach(slider => {
  slider.style.setProperty('--track-fill', rangeToPercent(slider));

  /* when a slider changes, update the fill prop */
  slider.addEventListener('input', e => {
    e.target.style.setProperty('--track-fill', rangeToPercent(e.target));
  })
})

Acho que isso contribui para um bom upgrade visual. O controle deslizante funciona muito bem sem JavaScript. A propriedade --track-fill não é necessária, ele simplesmente não terá um estilo de preenchimento se não estiver presente. Se o JavaScript estiver disponível, preencha a propriedade personalizada enquanto observa as alterações do usuário, sincronizando a propriedade com o valor.

Confira uma ótima postagem sobre CSS-Tricks de Ana Tudor, que demonstra uma solução exclusiva do CSS para preenchimento de faixas. Também achei este elemento range muito inspirador.

Estilos de polegar

input[type="range"]::-webkit-slider-thumb {
  appearance: none; /* clear styles, make way for mine */
  cursor: ew-resize; /* cursor style to support drag direction */
  border: 3px solid var(--surface3);
  block-size: var(--thumb-size);
  inline-size: var(--thumb-size);
  margin-top: var(--thumb-offset);
  border-radius: 50%;
  background: var(--brand-bg-gradient) fixed;
}

A maioria desses estilos serve para fazer um bom círculo. Você vai notar novamente o gradiente fixo de plano de fundo, que unifica as cores dinâmicas das miniaturas, faixas e elementos SVG associados. Separei os estilos da interação para ajudar a isolar a técnica box-shadow que está sendo usada para o destaque ao passar o cursor:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

::-webkit-slider-thumb {
  …

  /* shadow spread is initally 0 */
  box-shadow: 0 0 0 var(--thumb-highlight-size) var(--thumb-highlight-color);

  /* if motion is OK, transition the box-shadow change */
  @media (--motionOK) {
    & {
      transition: box-shadow .1s ease;
    }
  }

  /* on hover/active state of parent, increase size prop */
  @nest input[type="range"]:is(:hover,:active) & {
    --thumb-highlight-size: 10px;
  }
}

O objetivo era oferecer um destaque visual animado e fácil de gerenciar para o feedback dos usuários. Ao usar uma sombra de caixa, posso evitar o acionamento do layout com esse efeito. Para isso, crio uma sombra que não esteja desfocada e corresponda à forma circular do elemento de polegar. Depois, mudo e faço a transição do tamanho da propagação ao passar o cursor.

Se só o efeito de destaque fosse tão fácil nas caixas de seleção...

Seletores de navegadores diferentes

Achei que precisava desses seletores -webkit- e -moz- para manter a consistência entre navegadores:

input[type="range"] {
  &::-webkit-slider-runnable-track {}
  &::-moz-range-track {}
  &::-webkit-slider-thumb {}
  &::-moz-range-thumb {}
}

Caixa de seleção personalizada

Com o seguinte elemento de entrada HTML, vou mostrar como personalizar a aparência dele:

<input type="checkbox">

Precisamos personalizar três partes desse elemento:

  1. Elemento da caixa de seleção
  2. Rótulos associados
  3. Efeito de destaque

Elemento da caixa de seleção

input[type="checkbox"] {
  inline-size: var(--space-sm);   /* increase width */
  block-size: var(--space-sm);    /* increase height */
  outline-offset: 5px;            /* focus style enhancement */
  accent-color: var(--brand);     /* tint the input */
  position: relative;             /* prepare for an absolute pseudo element */
  transform-style: preserve-3d;   /* create a 3d z-space stacking context */
  margin: 0;
  cursor: pointer;
}

Os estilos transform-style e position se preparam para o pseudoelemento que vamos apresentar posteriormente para definir o estilo do destaque. Fora isso, são principalmente coisas de estilo menores e opinativas. Gosto que o cursor seja ponteiro, gosto de deslocamentos de contorno, caixas de seleção padrão são muito pequenas e, se accent-color tiver suporte, inclua essas caixas de seleção no esquema de cores da marca.

Identificadores da caixa de seleção

É importante fornecer etiquetas nas caixas de seleção por dois motivos. A primeira é representar para que o valor da caixa de seleção é usado, responder "ligado ou desativado para quê?" Em segundo lugar, para UX, os usuários da Web se acostumaram a interagir com caixas de seleção pelos rótulos associados.

entrada
<input
  type="checkbox"
  id="text-notifications"
  name="text-notifications"
>
Rótulo
<label for="text-notifications">
  <h3>Text Messages</h3>
  <small>Get notified about all text messages sent to your device</small>
</label>

No rótulo, coloque um atributo for que aponte para uma caixa de seleção pelo ID: <label for="text-notifications">. Na caixa de seleção, duplique o nome e o ID para garantir que ele seja encontrado com ferramentas e tecnologias variadas, como um mouse ou um leitor de tela: <input type="checkbox" id="text-notifications" name="text-notifications">. :hover, :active e muito mais vêm sem custo financeiro com a conexão, aumentando as maneiras de interagir com o formulário.

Destaque da caixa de seleção

Quero manter minhas interfaces consistentes, e o elemento deslizante tem um bom destaque na miniatura que eu gostaria de usar com a caixa de seleção. A miniatura conseguiu usar box-shadow e a propriedade spread para dimensionar uma sombra para cima e para baixo. No entanto, esse efeito não funciona aqui, porque nossas caixas de seleção são e precisam ser quadradas.

Consegui alcançar o mesmo efeito visual com um pseudoelemento e uma infeliz quantidade de CSS complicado:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

input[type="checkbox"]::before {
  --thumb-scale: .01;                        /* initial scale of highlight */
  --thumb-highlight-size: var(--space-xl);

  content: "";
  inline-size: var(--thumb-highlight-size);
  block-size: var(--thumb-highlight-size);
  clip-path: circle(50%);                     /* circle shape */
  position: absolute;                         /* this is why position relative on parent */
  top: 50%;                                   /* pop and plop technique (https://web.dev/centering-in-css#5-pop-and-plop) */
  left: 50%;
  background: var(--thumb-highlight-color);
  transform-origin: center center;            /* goal is a centered scaling circle */
  transform:                                  /* order here matters!! */
    translateX(-50%)                          /* counter balances left: 50% */
    translateY(-50%)                          /* counter balances top: 50% */
    translateZ(-1px)                          /* PUTS IT BEHIND THE CHECKBOX */
    scale(var(--thumb-scale))                 /* value we toggle for animation */
  ;
  will-change: transform;

  @media (--motionOK) {                       /* transition only if motion is OK */
    & {
      transition: transform .2s ease;
    }
  }
}

/* on hover, set scale custom property to "in" state */
input[type="checkbox"]:hover::before {
  --thumb-scale: 1;
}

Criar um pseudoelemento de círculo é um trabalho simples, mas colocá-lo por trás do elemento ao qual ele está anexado foi mais difícil. Veja antes e depois da correção:

É definitivamente uma interação micro, mas é importante para mim manter a consistência visual. A técnica de dimensionamento da animação é a mesma usada em outros lugares. Definimos um novo valor a uma propriedade personalizada e deixamos o CSS fazer a transição com base nas preferências de movimento. O recurso principal aqui é translateZ(-1px). O pai criou um espaço 3D e esse pseudoelemento filho tocou nele colocando-se levemente de volta no espaço z.

Acessibilidade

O vídeo do YouTube faz uma ótima demonstração das interações do mouse, teclado e leitor de tela para esse componente de configurações. Vou chamar alguns dos detalhes aqui.

Opções de elementos HTML

<form>
<header>
<fieldset>
<picture>
<label>
<input>

Cada um desses contém dicas para a ferramenta de navegação do usuário. Alguns elementos oferecem dicas de interação, alguns de conexão de interatividade e outros ajudam a moldar a árvore de acessibilidade que um leitor de tela percorre.

Atributos HTML

Podemos ocultar elementos que não são necessários para os leitores de tela. Neste caso, o ícone ao lado do controle deslizante:

<picture aria-hidden="true">

O vídeo acima demonstra o fluxo do leitor de tela no Mac OS. Observe como o foco de entrada se move direto de um controle deslizante para o próximo. Isso ocorre porque ocultamos o ícone que pode ter sido uma parada no caminho para o próximo controle deslizante. Sem esse atributo, o usuário precisaria parar, detectar e passar a imagem, que talvez ele não consiga ver.

O SVG é um monte de cálculos. Vamos adicionar um elemento <title> para um título que fica ao passar o mouse e um comentário legível sobre o que o cálculo está criando:

<svg viewBox="0 0 24 24">
  <title>A note icon</title>
  <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>

Além disso, usamos HTML marcado com clareza suficiente para que o formato funcione muito bem com mouse, teclado, controles de videogame e leitores de tela.

JavaScript

Já expliquei como a cor de preenchimento da faixa era gerenciada no JavaScript, então vamos conferir o JavaScript relacionado ao <form>:

const form = document.querySelector('form');

form.addEventListener('input', event => {
  const formData = Object.fromEntries(new FormData(form));
  console.table(formData);
})

Sempre que houver interação e alteração com o formulário, o console o registra como um objeto em uma tabela para facilitar a revisão antes do envio para um servidor.

Uma captura de tela dos resultados de console.table(), em que os dados do formulário são mostrados em uma tabela.

Conclusão

Agora que você sabe como eu fiz isso, como você iria?! Isso resulta em uma arquitetura de componentes divertida. Quem vai fazer a 1a versão com slots no framework favorito? 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie um tweet para mim e vou adicioná-los à seção Remixes da comunidade abaixo.

Remixes da comunidade

  • @tomayac pelo estilo em relação à área de passagem do cursor para os rótulos da caixa de seleção. Essa versão não tem lacunas de passagem de cursor entre elementos: demo e source.