Créer un composant de bouton "split-button"

Présentation des principes de base de la création d'un composant de bouton fractionné accessible.

Dans cet article, je veux partager notre réflexion sur la façon de créer un bouton de scission . Tester la fonctionnalité

Démonstration

Si vous préférez la vidéo, voici une version YouTube de ce post:

Présentation

Les boutons fractionnés sont des boutons qui masquent un bouton principal et une liste de boutons supplémentaires. Elles sont utiles pour exposer une action courante lors de l'imbrication d'actions secondaires, qui sont moins fréquemment utilisées jusqu'à ce que cela soit nécessaire. Un bouton de scission peut être crucial pour donner un aspect minimal à une conception chargée. Un bouton de fractionnement avancé peut même mémoriser la dernière action de l'utilisateur et la promouvoir en position principale.

Votre application de messagerie contient un bouton de division courant. L'action principale est l'envoi, mais vous pouvez peut-être l'envoyer plus tard ou enregistrer un brouillon:

Exemple de bouton de division dans une application de messagerie

La zone d'action partagée est agréable, car l'utilisateur n'a pas besoin de regarder autour de lui. Il sait que le bouton "Split" (Diviser) contient des actions essentielles par e-mail.

Pièces

Examinons les éléments essentiels d'un bouton de fractionnement avant d'aborder leur orchestration globale et l'expérience utilisateur finale. L'outil d'inspection de l'accessibilité de VisBug permet ici d'afficher une vue macro du composant, mettant en évidence des aspects du code HTML, du style et de l'accessibilité pour chaque partie principale.

Éléments HTML qui composent le bouton de fractionnement.

Conteneur du bouton de répartition de premier niveau

Le composant de niveau le plus élevé est un Flexbox intégré, avec une classe gui-split-button, contenant l'action principale et le .gui-popup-button.

La classe "gui-split-button" a été inspectée et affichée et les propriétés CSS utilisées dans cette classe sont affichées.

Le bouton d'action principal

Le <button> initialement visible et sélectionnable s'intègre dans le conteneur avec deux formes d'angle correspondantes pour que les interactions focus, hover et active apparaissent dans .gui-split-button.

Outil d&#39;inspection affichant les règles CSS pour l&#39;élément &quot;button&quot;.

Bouton d'activation de la fenêtre pop-up

L'élément de support "bouton pop-up" permet d'activer la liste des boutons secondaires et d'y faire allusion. Notez qu'il ne s'agit ni d'un <button>, ni d'un élément sélectionnable. Toutefois, il s'agit de l'ancre de positionnement pour .gui-popup et de l'hôte de :focus-within permettant de présenter le pop-up.

Outil d&#39;inspection affichant les règles CSS pour la classe &quot;gui-popup-button&quot;.

La fiche pop-up

Il s'agit d'une carte flottante enfant de son ancre .gui-popup-button, positionnée de manière absolue et encapsulant sémantiquement la liste de boutons.

Outil d&#39;inspection affichant les règles CSS pour la classe gui-pop-up

Les actions secondaires

Un élément <button> sélectionnable avec une taille de police légèrement plus petite que le bouton d'action principal présente une icône et un style complémentaire pour le bouton principal.

Outil d&#39;inspection affichant les règles CSS pour l&#39;élément &quot;button&quot;.

Propriétés personnalisées

Les variables suivantes aident à créer une harmonie de couleurs et un emplacement central pour modifier les valeurs utilisées dans le composant.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --light (prefers-color-scheme: light);

.gui-split-button {
  --theme:             hsl(220 75% 50%);
  --theme-hover:  hsl(220 75% 45%);
  --theme-active:  hsl(220 75% 40%);
  --theme-text:      hsl(220 75% 25%);
  --theme-border: hsl(220 50% 75%);
  --ontheme:         hsl(220 90% 98%);
  --popupbg:         hsl(220 0% 100%);

  --border: 1px solid var(--theme-border);
  --radius: 6px;
  --in-speed: 50ms;
  --out-speed: 300ms;

  @media (--dark) {
    --theme:             hsl(220 50% 60%);
    --theme-hover:  hsl(220 50% 65%);
    --theme-active:  hsl(220 75% 70%);
    --theme-text:      hsl(220 10% 85%);
    --theme-border: hsl(220 20% 70%);
    --ontheme:         hsl(220 90% 5%);
    --popupbg:         hsl(220 10% 30%);
  }
}

Mises en page et couleur

Markup

L'élément commence par <div> avec un nom de classe personnalisé.

<div class="gui-split-button"></div>

Ajoutez le bouton principal et les éléments .gui-popup-button.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions"></span>
</div>

Notez les attributs aria aria-haspopup et aria-expanded. Ces repères sont essentiels pour que les lecteurs d'écran prennent en compte la capacité et l'état de l'expérience des boutons de fractionnement. L'attribut title est utile pour tous les utilisateurs.

Ajoutez une icône <svg> et l'élément de conteneur .gui-popup.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup"></ul>
  </span>
</div>

Pour un placement direct de la fenêtre pop-up, .gui-popup est l'élément enfant du bouton qui permet de le développer. Le seul problème avec cette stratégie est que le conteneur .gui-split-button ne peut pas utiliser overflow: hidden, car il empêche la présence visuelle du pop-up.

Une <ul> remplie de contenus <li><button> s'annoncera en tant que "liste de boutons" pour les lecteurs d'écran, ce qui correspond précisément à l'interface présentée.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li>
        <button>Schedule for later</button>
      </li>
      <li>
        <button>Delete</button>
      </li>
      <li>
        <button>Save draft</button>
      </li>
    </ul>
  </span>
</div>

Pour plus d'élégance et de plaisir avec la couleur, j'ai ajouté des icônes aux boutons secondaires de https://heroicons.com. Les icônes sont facultatives pour les boutons principal et secondaire.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
        </svg>
        Schedule for later
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        </svg>
        Delete
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        </svg>
        Save draft
      </button></li>
    </ul>
  </span>
</div>

Styles

Une fois le code HTML et le contenu en place, les styles sont prêts à fournir la couleur et la mise en page.

Appliquer un style au conteneur du bouton de fractionnement

Un type d'affichage inline-flex fonctionne bien pour ce composant d'encapsulation, car il doit s'intégrer aux autres boutons, actions ou éléments de fractionnement.

.gui-split-button {
  display: inline-flex;
  border-radius: var(--radius);
  background: var(--theme);
  color: var(--ontheme);
  fill: var(--ontheme);

  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

Bouton de fractionnement

Style <button>

Les boutons sont très efficaces pour masquer la quantité de code nécessaire. Vous devrez peut-être annuler ou remplacer les styles par défaut du navigateur, mais vous devrez également appliquer un certain héritage, ajouter des états d'interaction et vous adapter à différents types d'entrées et préférences utilisateur. Les styles de boutons s'accumulent rapidement.

Ces boutons sont différents des boutons standards, car ils partagent un arrière-plan avec un élément parent. En général, un bouton possède son arrière-plan et sa couleur de texte. Ceux-ci, cependant, le partagent et n’appliquent que leur propre arrière-plan sur l’interaction.

.gui-split-button button {
  cursor: pointer;
  appearance: none;
  background: none;
  border: none;

  display: inline-flex;
  align-items: center;
  gap: 1ch;
  white-space: nowrap;

  font-family: inherit;
  font-size: inherit;
  font-weight: 500;

  padding-block: 1.25ch;
  padding-inline: 2.5ch;

  color: var(--ontheme);
  outline-color: var(--theme);
  outline-offset: -5px;
}

Ajoutez des états d'interaction avec quelques pseudo-classes CSS et utilisez des propriétés personnalisées correspondantes pour l'état:

.gui-split-button button {
  …

  &:is(:hover, :focus-visible) {
    background: var(--theme-hover);
    color: var(--ontheme);

    & > svg {
      stroke: currentColor;
      fill: none;
    }
  }

  &:active {
    background: var(--theme-active);
  }
}

Le bouton principal nécessite quelques styles spéciaux pour compléter l'effet de conception:

.gui-split-button > button {
  border-end-start-radius: var(--radius);
  border-start-start-radius: var(--radius);

  & > svg {
    fill: none;
    stroke: var(--ontheme);
  }
}

Enfin, le bouton et l'icône du thème clair affichent une ombre:

.gui-split-button {
  @media (--light) {
    & > button,
    & button:is(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--theme-active);
    }
    & > .gui-popup-button > svg,
    & button:is(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--theme-active));
    }
  }
}

Un excellent bouton a prêté attention aux micro-interactions et aux petits détails.

Remarque concernant :focus-visible

Notez que les styles de boutons utilisent :focus-visible au lieu de :focus. :focus est un élément essentiel pour créer une interface utilisateur accessible, mais il présente un inconvénient: il ne s'agit pas de déterminer si l'utilisateur a besoin de la voir ou non, elle s'applique à n'importe quel objectif.

La vidéo ci-dessous tente de décomposer cette micro-interaction pour montrer comment :focus-visible est une alternative intelligente.

Appliquer un style au bouton pop-up

Un Flexbox 4ch pour centrer une icône et ancrer une liste de boutons pop-up. Tout comme le bouton principal, il est transparent jusqu'à ce qu'il soit simplement survolé ou interagi avec, et il est étiré pour remplir l'espace.

Partie fléchée du bouton de fractionnement utilisée pour déclencher le pop-up.

.gui-popup-button {
  inline-size: 4ch;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-inline-start: var(--border);
  border-start-end-radius: var(--radius);
  border-end-end-radius: var(--radius);
}

Couche dans les états de survol, de focus et d'activité avec l'imbrication CSS et le sélecteur fonctionnel :is():

.gui-popup-button {
  …

  &:is(:hover,:focus-within) {
    background: var(--theme-hover);
  }

  /* fixes iOS trying to be helpful */
  &:focus {
    outline: none;
  }

  &:active {
    background: var(--theme-active);
  }
}

Ces styles sont le point d'ancrage principal pour afficher et masquer le pop-up. Lorsque .gui-popup-button comporte focus sur l'un de ses enfants, définissez opacity, la position et pointer-events sur l'icône et la fenêtre pop-up.

.gui-popup-button {
  …

  &:focus-within {
    & > svg {
      transition-duration: var(--in-speed);
      transform: rotateZ(.5turn);
    }
    & > .gui-popup {
      transition-duration: var(--in-speed);
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  }
}

Une fois les styles d'entrée et de sortie terminés, la dernière partie consiste à effectuer des transitions de transformations conditionnelles en fonction des préférences de mouvement de l'utilisateur:

.gui-popup-button {
  …

  @media (--motionOK) {
    & > svg {
      transition: transform var(--out-speed) ease;
    }
    & > .gui-popup {
      transform: translateY(5px);

      transition:
        opacity var(--out-speed) ease,
        transform var(--out-speed) ease;
    }
  }
}

Un œil attentif sur le code constaterait que l'opacité est toujours migrée pour les utilisateurs qui préfèrent réduire les mouvements.

Appliquer un style au pop-up

L'élément .gui-popup est une liste de boutons de carte flottante utilisant des propriétés personnalisées et des unités relatives qui sont légèrement plus petites, associées de manière interactive au bouton principal et sur la marque avec son utilisation de la couleur. Notez que les icônes ont moins de contraste, sont plus fines et que l'ombre a un soupçon de bleu marque. Comme pour les boutons, l'interface utilisateur et l'expérience utilisateur sont le résultat de ces petits détails qui s'empilent.

Élément de carte flottante.

.gui-popup {
  --shadow: 220 70% 15%;
  --shadow-strength: 1%;

  opacity: 0;
  pointer-events: none;

  position: absolute;
  bottom: 80%;
  left: -1.5ch;

  list-style-type: none;
  background: var(--popupbg);
  color: var(--theme-text);
  padding-inline: 0;
  padding-block: .5ch;
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  font-size: .9em;
  transition: opacity var(--out-speed) ease;

  box-shadow:
    0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
    0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
    0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
    0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
    0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
    0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
  ;
}

Les icônes et les boutons sont assortis de couleurs de marque pour un style parfaitement adapté à chaque carte sur le thème sombre et clair:

Liens et icônes pour payer, Quick Pay et Enregistrer pour plus tard.

.gui-popup {
  …

  & svg {
    fill: var(--popupbg);
    stroke: var(--theme);

    @media (prefers-color-scheme: dark) {
      stroke: var(--theme-border);
    }
  }

  & button {
    color: var(--theme-text);
    width: 100%;
  }
}

Le pop-up du thème sombre comporte des ajouts d'ombres de texte et d'icône, ainsi qu'une ombre de zone légèrement plus intense:

Pop-up avec le thème sombre.

.gui-popup {
  …

  @media (--dark) {
    --shadow-strength: 5%;
    --shadow: 220 3% 2%;

    & button:not(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--ontheme);
    }

    & button:not(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--ontheme));
    }
  }
}

Styles d'icônes <svg> génériques

Toutes les icônes sont relativement dimensionnées par rapport au bouton font-size dans lequel elles sont utilisées en utilisant l'unité ch comme inline-size. Chacun est également doté de styles pour aider à définir le contour des icônes de manière douce et lisse.

.gui-split-button svg {
  inline-size: 2ch;
  box-sizing: content-box;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2px;
}

Mise en page de droite à gauche

Les propriétés logiques effectuent tout le travail complexe. Voici la liste des propriétés logiques utilisées : - display: inline-flex crée un élément Flex intégré. - padding-block et padding-inline en tant que paire, au lieu du raccourci padding, bénéficiez des avantages d'un remplissage sur les côtés logiques. - border-end-start-radius et ses amis arrondiront les coins en fonction du sens du document. - inline-size plutôt que width garantit que la taille n'est pas liée aux dimensions physiques. - border-inline-start ajoute une bordure au début, qui peut se trouver à droite ou à gauche selon le sens du script.

JavaScript

La quasi-totalité du code JavaScript suivant vise à améliorer l'accessibilité. Deux de mes bibliothèques d'aide sont utilisées pour faciliter les tâches. BlingBlingJS est utilisé pour les requêtes DOM concises et la configuration facile des écouteurs d'événements, tandis que roving-ux facilite l'accessibilité des interactions avec le clavier et la manette de jeu pour le pop-up.

import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

Une fois les bibliothèques ci-dessus importées, et les éléments sélectionnés et enregistrés dans des variables, la mise à niveau de l'expérience n'est pas terminée.

Indice itinérant

Lorsqu'un clavier ou un lecteur d'écran sélectionne le .gui-popup-button, nous devons le transférer sur le premier bouton (ou le dernier) sélectionné dans .gui-popup. La bibliothèque nous aide à le faire avec les paramètres element et target.

popupButtons.forEach(element =>
  rovingIndex({
    element,
    target: 'button',
  }))

L'élément transmet désormais le curseur aux enfants <button> cibles et active la navigation standard à l'aide des touches fléchées pour parcourir les options.

Activation/Désactivation de aria-expanded...

Bien qu'il soit visuellement évident qu'un pop-up s'affiche et se cache, un lecteur d'écran a besoin de plus que des repères visuels. Le code JavaScript est utilisé ici pour compléter l'interaction :focus-within pilotée par CSS en activant/désactivant l'attribut approprié du lecteur d'écran.

popupButtons.on('focusin', e => {
  e.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons.on('focusout', e => {
  e.currentTarget.setAttribute('aria-expanded', false)
})

Activer la clé Escape

L'attention de l'utilisateur a été intentionnellement orientée vers un piège, ce qui signifie que nous devons lui proposer un moyen de partir. La méthode la plus courante consiste à autoriser l'utilisation de la clé Escape. Pour ce faire, surveillez les pressions de touches sur le bouton pop-up, car tous les événements de clavier sur les enfants s'afficheront dans ce parent.

popupButtons.on('keyup', e => {
  if (e.code === 'Escape')
    e.target.blur()
})

Si le bouton pop-up détecte que vous avez appuyé sur la touche Escape, il supprime la sélection de lui-même avec blur().

Clics sur le bouton de répartition

Enfin, si l'utilisateur clique ou appuie sur les boutons, ou si le clavier interagit avec les boutons, l'application doit effectuer l'action appropriée. L'ébullition d'événements est à nouveau utilisée ici, mais cette fois sur le conteneur .gui-split-button, pour détecter les clics sur les boutons provenant d'un pop-up enfant ou de l'action principale.

splitButtons.on('click', event => {
  if (event.target.nodeName !== 'BUTTON') return
  console.info(event.target.innerText)
})

Conclusion

Maintenant que vous savez comment je l'ai fait, comment le feriez-vous‽ 😃 ?

Diversissons nos approches et apprenons toutes les façons de créer sur le Web. Créez une démo, cliquez sur les liens tweet me, et je l'ajouterai à la section "Remix" de la communauté ci-dessous.

Remix de la communauté