Como criar um componente de switch

Uma visão geral básica de como criar um componente de interruptor responsivo e acessível.

Nesta postagem, quero compartilhar ideias sobre uma maneira de criar componentes de interruptores. Teste a demonstração.

Demonstração

Se você preferir o vídeo, aqui está uma versão do YouTube desta postagem:

Visão geral

Um switch funciona de forma semelhante a uma caixa de seleção mas representa explicitamente os estados booleanos ativado e desativado.

Esta demonstração usa <input type="checkbox" role="switch"> na maior parte do funcionalidade de armazenamento, que tem a vantagem de não precisar de CSS ou JavaScript totalmente funcional e acessível. O carregamento de CSS possibilita o uso de idiomas, verticalidade, animação e muito mais. Carregar JavaScript faz a troca arrastáveis e tangíveis.

Propriedades personalizadas

As variáveis a seguir representam as várias partes da chave e os respectivos . Como a classe de nível superior, .gui-switch contém propriedades personalizadas usadas em todos os componentes secundários, e pontos de entrada para e personalização.

Faixa

O comprimento (--track-size), o padding e as duas cores:

.gui-switch {
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;

  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);

  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);

  @media (prefers-color-scheme: dark) {
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
}

Miniatura

O tamanho, a cor do plano de fundo e as cores de destaque da interação:

.gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);

  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);

  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
  }
}

Movimento reduzido

Para adicionar um alias claro e reduzir a repetição, um usuário com preferência de movimento reduzida consulta de mídia pode ser colocada em uma propriedade personalizada com o método PostCSS plug-in com base neste rascunho especificação em consultas de mídia 5.

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

Marcação

Decidi unir meu elemento <input type="checkbox" role="switch"> com uma <label>, agrupando o relacionamento para evitar a associação de caixas de seleção e rótulos ambiguidade, além de permitir que o usuário interaja com o rótulo para alternar a entrada.

Um
rótulo natural e sem estilo e caixa de seleção.

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

O <input type="checkbox"> vem pré-criado com um API e state. A navegador gerencia checked propriedade e input eventos como oninput e onchanged.

Layouts

Flexbox; grid e custom são essenciais na manutenção dos estilos desse componente. Eles centralizam valores, dão nomes a cálculos ou áreas ambíguas e permitir uma pequena propriedade personalizada para facilitar a personalização de componentes.

.gui-switch

O layout de nível superior do switch é flexbox. A classe .gui-switch contém as propriedades personalizadas públicas e particulares que os filhos usam para calcular seus layouts.

Flexbox DevTools sobrepondo uma etiqueta e um switch horizontal, mostrando o layout
do espaço.

.gui-switch {
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
}

Estender e modificar o layout flexbox é como alterar qualquer layout flexbox. Por exemplo, para colocar rótulos acima ou abaixo de um interruptor ou para alterar a flex-direction:

Flexbox DevTools sobrepondo um rótulo vertical e um interruptor.

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
  <input type="checkbox" role="switch" id="light-switch">
</label>

Faixa

A entrada da caixa de seleção é estilizada como uma faixa de mudança, removendo seu appearance: checkbox e fornecendo o próprio tamanho:

Grid DevTools sobrepondo a faixa de alternância, mostrando a faixa de grade nomeada
áreas com o nome &quot;track&quot;.

.gui-switch > input {
  appearance: none;

  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  padding: var(--track-padding);

  flex-shrink: 0;
  display: grid;
  align-items: center;
  grid: [track] 1fr / [track] 1fr;
}

A faixa também cria uma área de faixa de grade de célula por vez para que um polegar reivindicação.

Miniatura

O estilo appearance: none também remove a marca de seleção visual fornecida pelo navegador. Esse componente usa um pseudoelemento e o :checked pseudoclasse na entrada para substituir esse indicador visual.

O thumb é um pseudoelemento filho anexado à input[type="checkbox"] e pilhas no topo da pista em vez de abaixo dela, reivindicando a área da grade track:

DevTools mostrando o pseudoelemento thumb como posicionado dentro de uma grade CSS.

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

Estilos

As propriedades personalizadas permitem um componente de interruptor versátil que se adapta à cor esquemas, idiomas da direita para a esquerda e preferências de movimento.

Uma comparação lado a lado dos temas claro e escuro do interruptor e da
estados.

Estilos de interação de toque

Em dispositivos móveis, os navegadores adicionam destaques por toque e recursos de seleção de texto aos marcadores e de entrada. Eles afetaram negativamente o estilo e o feedback da interação visual que essa troca era necessária. Com algumas linhas de CSS, posso remover esses efeitos e adicionar meu próprio estilo cursor: pointer:

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

Nem sempre é aconselhável remover esses estilos, já que podem ser valiosos recursos visuais feedback de interação. Lembre-se de fornecer alternativas personalizadas caso você as remova.

Faixa

Os estilos deste elemento têm principalmente a forma e a cor, que ele acessa do .gui-switch pai pela cascade.

As variantes da chave com tamanhos e cores personalizados de faixas.

.gui-switch > input {
  appearance: none;
  border: none;
  outline-offset: 5px;
  box-sizing: content-box;

  padding: var(--track-padding);
  background: var(--track-color-inactive);
  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  border-radius: var(--track-size);
}

Uma grande variedade de opções de personalização para a faixa de mudança vem de quatro propriedades personalizadas. border: none foi adicionado porque appearance: none não foi adicionado remova as bordas da caixa de seleção em todos os navegadores.

Miniatura

O elemento "thumb" já está à direita da track, mas precisa de estilos de círculo:

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

Exibição do DevTools destacando o pseudoelemento de círculo.

Interação

Use propriedades personalizadas ao se preparar para interações que vão mostrar o passar o cursor destaques e mudanças na posição do polegar. A preferência do usuário também é marcada antes da transição da ou passar o cursor sobre os estilos de destaque.

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

Posição do polegar

As propriedades personalizadas fornecem um mecanismo de origem única para posicionar a marcação "Gostei" na trilha. À nossa disposição estão os tamanhos de trilho e polegar que usaremos cálculos para manter o polegar devidamente deslocado e entre dentro da faixa: 0% e 100%.

O elemento input é proprietário da variável de posição --thumb-position, e da Ele é usado como uma posição translateX:

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

Agora, podemos mudar o --thumb-position do CSS e das pseudoclasses. fornecidos nos elementos da caixa de seleção. Como já definimos transition: transform var(--thumb-transition-duration) ease condicionalmente nesse elemento, essas mudanças pode ser animado quando alterado:

/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
}

/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
}

Achei que essa orquestração separada funcionou bem. O elemento thumb é só se preocupa com um estilo, uma posição translateX. A entrada pode gerenciar todos a complexidade e os cálculos.

Vertical

O suporte foi feito com uma classe modificadora -vertical, que adiciona uma rotação com Transformações CSS para o elemento input.

No entanto, um elemento 3D girado em 3D não altera a altura total do componente, o que pode desviar do layout de blocos. Para isso, use --track-size e Variáveis --track-padding. Calcule a quantidade mínima de espaço necessária para um botão vertical para fluir no layout como esperado:

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

(RTL) da direita para a esquerda

Um amigo do CSS, Elad Schecter, e eu criamos o protótipo um menu lateral deslizante usando transformações CSS que lidam da direita para a esquerda idiomas virando uma única variável. Fizemos isso porque não há transformações de propriedade lógica no CSS, e talvez nunca haja. Elad teve a ótima ideia de usar um valor de propriedade personalizada para inverter porcentagens e permitir o gerenciamento de um único local lógica para transformações lógicas. Usei essa mesma técnica nessa troca e acho que funcionou muito bem:

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

Uma propriedade personalizada chamada --isLTR inicialmente contém um valor de 1, o que significa que true, já que nosso layout é da esquerda para a direita por padrão. Em seguida, usando o CSS pseudoclasse :dir(), o valor é definido como -1 quando o componente está em um layout da direita para a esquerda.

Coloque --isLTR em ação usando-o em um calc() dentro de uma transformação:

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

Agora, a rotação da chave vertical representa a posição do lado oposto exigidos pelo layout da direita para a esquerda.

As transformações translateX no pseudoelemento thumb também precisam ser atualizadas para considere o requisito do lado oposto:

.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
  --thumb-position: calc(
   ((var(--track-size) / 2) - (var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

Embora essa abordagem não funcione para resolver todas as necessidades relacionadas a um conceito como o CSS lógico isso oferece algumas princípios DRY para muitos em diferentes casos de uso de negócios.

Estados

O uso do input[type="checkbox"] integrado não seria completo sem processando os vários estados em que pode estar: :checked, :disabled, :indeterminate e :hover. :focus foi intencionalmente deixado sozinho, com um ajuste feito apenas em seu deslocamento; o anel de foco ficava ótimo no Firefox e Safari:

Captura de tela do anel de foco focado em uma chave no Firefox e no Safari.

Marcado

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

Esse estado representa o estado on. Neste estado, a entrada "track" plano de fundo é definido como a cor ativa e a posição do polegar é definida como fim".

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

Desativado

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

Um botão :disabled não apenas tem uma aparência visual diferente, mas também deve tornar a elemento imutável.A imutabilidade de interação é livre no navegador, mas o os estados visuais precisam de estilos devido ao uso de appearance: none.

.gui-switch > input:disabled {
  cursor: not-allowed;
  --thumb-color: transparent;

  &::before {
    cursor: not-allowed;
    box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

    @media (prefers-color-scheme: dark) { & {
      box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
    }}
  }
}

Botão de estilo escuro desativado, marcado e desmarcado
estados.

Esse estado é complicado, porque precisa de temas claros e escuros com as opções desativadas e estados marcados. Escolhi estilos mínimos para esses estados para facilitar a carga de manutenção das combinações de estilos.

Indeterminado

Um estado esquecido com frequência é :indeterminate, em que uma caixa de seleção não é está marcada ou desmarcada. Essa situação é divertida, convidativa e despretensiosa. Uma boa Lembre-se de que os estados booleanos podem ter formas ocultas entre estados.

É complicado definir uma caixa de seleção como indeterminada, apenas o JavaScript pode defini-la:

<label for="switch-indeterminate" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-indeterminate">
  <script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>

O estado indeterminado com a miniatura da faixa no
do meio, para indicar indeciso.

Como o estado é despretensioso e convidativo, para mim, era apropriado colocar a posição do botão do círculo no meio:

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

Passar cursor

As interações ao passar o cursor precisam oferecer suporte visual para a interface conectada e também que dão direções para a interface interativa. Essa chave destaca o polegar com um anel semitransparente quando é passado o mouse sobre o rótulo ou a entrada. Este cursor fornece a direção do elemento de polegar interativo.

O "destaque" efeito é feito com box-shadow. Ao passar o cursor, de uma entrada não desativada, aumente o tamanho de --highlight-size. Se o usuário concordar com o movimento, fazemos a transição da box-shadow e a vemos crescer. Se o usuário não concordar com o movimento, o destaque será exibido instantaneamente:

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

.gui-switch > input:not(:disabled):hover::before {
  --highlight-size: .5rem;
}

JavaScript

Para mim, uma interface de switch pode parecer estranha na tentativa de emular uma do usuário, especialmente desse tipo com um círculo dentro de uma faixa. O iOS acertou com o interruptor, é possível arrastá-los de um lado para o outro, e é muito satisfatório têm essa opção. Por outro lado, um elemento da interface pode parecer inativo se um gesto de arrastar for e nada acontece.

Ícones arrastáveis

O pseudoelemento de polegar recebe a posição dele de .gui-switch > input com escopo var(--thumb-position), o JavaScript pode fornecer um valor de estilo in-line no a entrada para atualizar dinamicamente a posição do polegar, fazendo com que pareça seguir o gesto do ponteiro. Quando o ponteiro for solto, remova os estilos em linha e determine se a ação de arrastar estava mais próxima de ativar ou desativar usando a propriedade personalizada --thumb-position: Essa é a espinha dorsal da solução. eventos de ponteiro que rastreia condicionalmente as posições do ponteiro para modificar as propriedades personalizadas do CSS.

Como o componente já estava 100% funcional antes de este script ser exibido é necessário um pouco de trabalho para manter o comportamento existente, como clicando em um rótulo para alternar a entrada. Nosso JavaScript não deve adicionar recursos em detrimento dos recursos existentes.

touch-action

Arrastar é um gesto personalizado, o que o torna uma ótima opção para Benefícios do touch-action. No caso dessa chave, um gesto horizontal ser processada pelo nosso script ou por um gesto vertical capturado para a chave vertical variante. Com touch-action, podemos dizer ao navegador quais gestos ele deve processar esse elemento, para que um script possa lidar com um gesto sem concorrência.

O CSS a seguir instrui o navegador que, quando um gesto de ponteiro é iniciado em nesta faixa de alternância, lida com gestos verticais, não faz nada com horizontal as seguintes:

.gui-switch > input {
  touch-action: pan-y;
}

O resultado desejado é um gesto horizontal que não mova nem role a tela página. Um ponteiro pode rolar verticalmente a partir da entrada e rolar o mas as horizontais são personalizadas.

Utilitários de estilo dos valores em pixels

Durante a configuração e durante a ação de arrastar, vários valores numéricos calculados precisam ser capturados dos elementos. As seguintes funções JavaScript retornam valores de pixels calculados determinada uma propriedade CSS. Ele é usado no script de configuração getStyle(checkbox, 'padding-left'):

​​const getStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}

const getPseudoStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}

export {
  getStyle,
  getPseudoStyle,
}

Observe como window.getComputedStyle() aceita um segundo argumento, um pseudoelemento de destino. O JavaScript pode ler tantos valores de elementos, até mesmo de pseudoelementos.

dragging

Esse é um momento importante para a lógica de arrastar e há algumas coisas a observar do manipulador de eventos da função:

const dragging = event => {
  if (!state.activethumb) return

  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')

  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0

  let pos = Math.round(event.offsetX - thumbsize / 2)

  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper

  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

O herói do script é state.activethumb, o pequeno círculo que esse script está. com um ponteiro. O objeto switches é um Map() em que o são .gui-switch, e os valores são limites e tamanhos em cache que mantêm eficiente do script. A escrita da direita para a esquerda é tratada usando a mesma propriedade personalizada que o CSS é --isLTR e pode usá-lo para inverter a lógica e continuar com suporte a RTL. O event.offsetX também é valioso, porque contém um delta. útil para posicionar o polegar.

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

Essa linha final de CSS define a propriedade personalizada usada pelo elemento thumb. Isso A atribuição de valor faria a transição com o tempo, mas um ponteiro anterior defina --thumb-transition-duration como 0s temporariamente, removendo o que teria sido uma interação lenta.

dragEnd

Para que o usuário possa arrastar para fora da chave e soltar, uma evento de janela global necessário registrado:

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

Acho muito importante que o usuário tenha liberdade para arrastar com flexibilidade e interface ser inteligente o suficiente para dar conta disso. Não demorou muito para lidar com isso mas exigiu uma consideração cuidadosa durante o desenvolvimento de desenvolvimento de software.

const dragEnd = event => {
  if (!state.activethumb) return

  state.activethumb.checked = determineChecked()

  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false

  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null

  padRelease()
}

A interação com o elemento foi concluída. É hora de configurar a entrada verificada e remova todos os eventos de gesto. A caixa de seleção é alterada com state.activethumb.checked = determineChecked():

determineChecked()

Essa função, chamada por dragEnd, determina onde está a corrente do polegar. dentro dos limites de sua faixa e retorna verdadeiro se for igual a ou acima no meio da pista:

const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)

  let curpos =
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))

  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }

  return curpos >= bounds.middle
}

Mais ideias

O gesto de arrastar gerou um pouco de dívida no código devido à estrutura HTML inicial. escolhido, principalmente em relação à entrada em um rótulo. O rótulo, sendo um pai receberia interações de clique após a entrada. No fim do ciclo de vida dragEnd, talvez você tenha notado padRelease() como algo estranho função.

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

Isso serve para considerar o rótulo que recebe esse clique mais tarde, já que seria desmarcado, a interação que um usuário realizou.

Se eu fizesse isso de novo, poderia considerar ajustar o DOM com JavaScript durante o upgrade da experiência do usuário, para criar um elemento que lide com os cliques do rótulo sem conflitos com o comportamento integrado.

Este tipo de JavaScript é o que eu menos gosto de escrever, não quero gerenciar propagação de eventos condicionais:

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

Conclusão

Esse pequeno componente de switch acabou sendo o mais trabalhoso de todos os desafios da GUI. até agora. Agora que você sabe como eu fiz isso, o que você faria‽ 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie um tweet para mim e adicione os links acesse a seção "Remixes da comunidade" abaixo.

Remixes da comunidade

Recursos

Encontre o código-fonte .gui-switch em GitHub.