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

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

Dans cet article, je vais vous expliquer comment créer un bouton fractionné. Tester la fonctionnalité

Démonstration

Si vous préférez regarder une vidéo, voici une version YouTube de cet article :

Présentation

Les boutons fractionnés sont des boutons qui cachent un bouton principal et une liste de boutons supplémentaires. Ils sont utiles pour exposer une action courante tout en imbriquant des actions secondaires, moins fréquemment utilisées, jusqu'à ce qu'elles soient nécessaires. Un bouton fractionné peut être essentiel pour donner un aspect minimaliste à 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.

Vous trouverez un bouton de répartition courant dans votre application de messagerie. L'action principale est l'envoi, mais vous pouvez peut-être envoyer le message plus tard ou enregistrer un brouillon :

Exemple de bouton de répartition 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 les actions essentielles pour les e-mails sont contenues dans le bouton de division.

Pièces

Décomposons les éléments essentiels d'un bouton fractionné avant d'examiner leur orchestration globale et l'expérience utilisateur finale. L'outil d'inspection de l'accessibilité de VisBug est utilisé ici pour afficher une vue macro du composant, en affichant 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 de bouton de fractionnement de niveau supérieur

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

Inspection de la classe gui-split-button et affichage des propriétés CSS utilisées dans cette classe.

Bouton d'action principal

Le <button>, initialement visible et pouvant être mis au point, s'intègre dans le conteneur avec deux formes angulaires correspondant pour que les interactions en mode focus, en survol et actives apparaissent dans .gui-split-button.

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

Bouton d'activation des pop-ups

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

L&#39;inspecteur affichant les règles CSS de la classe gui-popup-button.

Carte 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.

L&#39;inspecteur affichant les règles CSS de la classe gui-popup

Les actions secondaires

Un <button> pouvant être sélectionné avec une taille de police légèrement inférieure à celle du bouton d'action principal comporte une icône et un style complémentaire à celui du bouton principal.

L&#39;inspecteur affiche les règles CSS de l&#39;élément de bouton.

Propriétés personnalisées

Les variables suivantes permettent de créer une harmonie de couleurs et un emplacement central pour modifier les valeurs utilisées dans l'ensemble du 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

Annoter

L'élément commence par un <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 signaux sont essentiels pour que les lecteurs d'écran soient informés des fonctionnalités et de l'état de l'expérience du bouton fractionné. L'attribut title est utile pour tous.

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 positionnement simple du pop-up, .gui-popup est un enfant du bouton qui l'étend. Le seul inconvénient de cette stratégie est que le conteneur .gui-split-button ne peut pas utiliser overflow: hidden, car il empêcherait la fenêtre pop-up d'être visuellement présente.

Un élément <ul> rempli de contenus <li><button> s'annonce comme une "liste de boutons" auprès des lecteurs d'écran, ce qui est 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 ajouter du style et m'amuser avec les couleurs, j'ai ajouté des icônes aux boutons secondaires sur https://heroicons.com. Les icônes sont facultatives pour les boutons principaux et secondaires.

<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.

Attribuer un style au conteneur du bouton fractionné

Un type d'affichage inline-flex convient parfaitement à ce composant de mise en page, 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 à diverses préférences et types d'entrée 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 propre arrière-plan et sa propre couleur de texte. En revanche, elles le partagent et n'appliquent que leur propre arrière-plan à 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 a besoin de 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, pour apporter une touche d'élégance, le bouton et l'icône du thème clair présentent 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 bouton de qualité a été conçu en tenant compte des micro-interactions et des 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 détermine pas de manière intelligente si l'utilisateur doit le voir ou non. Il s'applique à tout focus.

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

Attribuer un style au bouton du pop-up

Une flexbox 4ch pour centrer une icône et ancrer une liste de boutons pop-up. Comme le bouton principal, il est transparent jusqu'à ce que l'utilisateur passe la souris dessus ou interagisse avec lui, et il s'étire pour remplir l'espace.

Partie de la flèche 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);
}

Superposez les états survol, focus et actif 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 principal crochet pour afficher et masquer le pop-up. Lorsque .gui-popup-button contient focus sur l'un de ses enfants, définissez opacity, la position et pointer-events sur l'icône et le 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 étape consiste à effectuer une transition de manière conditionnelle 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;
    }
  }
}

En observant attentivement le code, vous remarquerez que l'opacité est toujours en transition pour les utilisateurs qui préfèrent réduire le mouvement.

Styliser le pop-up

L'élément .gui-popup est une liste de boutons de carte flottante qui utilise des propriétés personnalisées et des unités relatives pour être légèrement plus petite, correspondre de manière interactive au bouton principal et respecter la marque avec son utilisation de la couleur. Notez que les icônes ont moins de contraste, sont plus fines et que l'ombre est légèrement bleue. Comme pour les boutons, une UI et une expérience utilisateur solides sont le résultat de l'empilement de ces petits détails.

Élément de carte flottant.

.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 associés à des couleurs de marque pour s'intégrer parfaitement dans chaque carte au thème sombre et clair :

Liens et icônes pour le paiement, le paiement rapide et l&#39;enregistrement 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 ombres de texte et d'icône, ainsi qu'une ombre de zone légèrement plus intense:

Fenêtre pop-up dans 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 dimensionnées par rapport au bouton font-size dans lequel elles sont utilisées en utilisant l'unité ch comme inline-size. Chacune d'elles est également associée à des styles pour aider à dessiner des icônes douces et fluides.

.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 paire, au lieu de l'abréviation padding, profitez des avantages du rembourrage des côtés logiques. - border-end-start-radius et ses amis arrondissent les coins en fonction de l'orientation 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 être à droite ou à gauche, en fonction du sens du script.

JavaScript

La quasi-totalité du code JavaScript suivant vise à améliorer l'accessibilité. Deux de mes bibliothèques d'assistance sont utilisées pour faciliter les tâches. BlingBlingJS est utilisé pour des requêtes DOM succinctes et une configuration facile de l'écouteur 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 ne nécessite que quelques fonctions.

Indice itinérant

Lorsqu'un clavier ou un lecteur d'écran met en surbrillance le .gui-popup-button, nous souhaitons transmettre la sélection au premier bouton (ou au bouton sélectionné en dernier) de la .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 la sélection aux enfants <button> cibles et permet de naviguer dans les options à l'aide des touches fléchées standards.

Activer/Désactiver aria-expanded

Bien qu'il soit visuellement évident qu'une fenêtre pop-up s'affiche et se ferme, un lecteur d'écran a besoin de plus que de repères visuels. JavaScript est utilisé ici pour compléter l'interaction :focus-within basée sur le CSS en activant un attribut adapté aux lecteurs 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 envoyée vers un piège, ce qui signifie que nous devons lui fournir un moyen de sortir. La méthode la plus courante consiste à autoriser l'utilisation de la clé Escape. Pour ce faire, surveillez les frappes sur le bouton du pop-up, car tous les événements de clavier sur les enfants remonteront jusqu'à ce parent.

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

Si le bouton du pop-up détecte une pression sur la touche Escape, il supprime le focus sur lui-même avec blur().

Clics sur le bouton de fractionnement

Enfin, si l'utilisateur clique, appuie ou interagit avec les boutons à l'aide du clavier, l'application doit effectuer l'action appropriée. La remontée d'événements est à nouveau utilisée ici, mais cette fois sur le conteneur .gui-split-button, pour capturer les clics sur les boutons 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 j'ai fait, comment procéderiez-vous ? 🙂

Diversifions nos approches et découvrons toutes les façons de créer sur le Web. Créez une démo, envoyez-moi des tweets via des liens et je l'ajouterai à la section "Remix de la communauté" ci-dessous.

Remix de la communauté