Создание компонента разделенной кнопки

Базовый обзор того, как создать доступный компонент с разделяющейся кнопкой.

В этом посте я хочу поделиться мыслями о том, как создать кнопку разделения. Попробуйте демо .

Демо

Если вы предпочитаете видео, вот версия этого поста на YouTube:

Обзор

Разделённые кнопки — это кнопки, скрывающие основную кнопку и список дополнительных кнопок. Они удобны для отображения общего действия и вложения второстепенных, менее часто используемых действий до момента их необходимости. Разделённая кнопка может играть ключевую роль в минималистичном дизайне, перегруженном деталями. Расширенная разделённая кнопка может даже запоминать последнее действие пользователя и выводить его на основную позицию.

В вашем почтовом клиенте можно найти распространённую кнопку разделения. Основное действие — отправить, но, возможно, вы сможете отправить письмо позже или сохранить его как черновик:

Пример кнопки разделения в приложении электронной почты.

Область общих действий удобна, поскольку пользователю не нужно смотреть по сторонам. Он знает, что основные действия с электронным письмом находятся в разделяющей кнопке.

Части

Давайте разберём основные элементы развёрнутой кнопки, прежде чем обсуждать их общую организацию и итоговый пользовательский опыт. Инструмент проверки доступности VisBug используется здесь для отображения макроса компонента, выявляя аспекты HTML, стиля и доступности для каждой основной части.

HTML-элементы, из которых состоит кнопка разделения.

Контейнер кнопок разделения верхнего уровня

Компонент самого высокого уровня — встроенный flexbox с классом gui-split-button , содержащий основное действие и .gui-popup-button .

Класс gui-split-button проверен и показывает свойства CSS, используемые в этом классе.

Кнопка основного действия

Первоначально видимая и фокусируемая <button> помещается в контейнер с двумя соответствующими угловыми формами для фокусировки , наведения и активных взаимодействий, которые отображаются внутри .gui-split-button .

Инспектор показывает CSS-правила для элемента кнопки.

Всплывающая кнопка-переключатель

Вспомогательный элемент «всплывающая кнопка» предназначен для активации и ссылки на список дополнительных кнопок. Обратите внимание, что это не <button> и не может быть сфокусирован. Однако он служит якорем позиционирования для .gui-popup и хостом для :focus-within используемых для отображения всплывающего окна.

Инспектор показывает CSS-правила для класса gui-popup-button.

Всплывающая карта

Это плавающая карточка, дочерняя по отношению к якорю .gui-popup-button , позиционируемая абсолютно и семантически оборачивающая список кнопок.

Инспектор показывает CSS-правила для класса gui-popup

Вторичное действие(я)

Фокусируемая <button> с немного меньшим размером шрифта, чем у основной кнопки действия, имеет значок и дополнительный стиль к основной кнопке.

Инспектор показывает CSS-правила для элемента кнопки.

Пользовательские свойства

Следующие переменные помогают создать цветовую гармонию и являются центральным местом для изменения значений, используемых во всем компоненте.

@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%);
  }
}

Макеты и цвет

Разметка

Элемент начинается с <div> с пользовательским именем класса.

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

Добавьте основную кнопку и элементы .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>

Обратите внимание на атрибуты aria: aria-haspopup и aria-expanded . Эти подсказки критически важны для программ чтения с экрана, чтобы они могли определить возможности и состояние кнопки-разделителя. Атрибут title полезен для всех.

Добавьте значок <svg> и элемент-контейнер .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>

Для простого размещения всплывающего окна .gui-popup является дочерним элементом кнопки, которая его раскрывает. Единственный недостаток этой стратегии заключается в том, что контейнер .gui-split-button не может использовать overflow: hidden , так как это приведёт к обрезанию всплывающего окна и его визуальному отображению.

Элемент <ul> , заполненный содержимым <li><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">
    <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>

Для разнообразия и цветового разнообразия я добавил на второстепенные кнопки иконки с сайта https://heroicons.com . И для основных, и для второстепенных кнопок иконки добавлять необязательно.

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

Стили

После того как HTML и контент будут готовы, стили будут обеспечивать цвет и макет.

Оформление контейнера разделенной кнопки

Для этого компонента-обертки хорошо подходит тип отображения inline-flex поскольку он должен вписываться в линию с другими разделенными кнопками, действиями или элементами.

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

Кнопка разделения.

Стиль <button>

Кнопки отлично скрывают объём необходимого кода. Возможно, вам придётся отменить или заменить стили браузера по умолчанию, но также потребуется обеспечить наследование, добавить состояния взаимодействия и адаптироваться к различным пользовательским предпочтениям и типам ввода. Стили кнопок быстро накапливаются.

Эти кнопки отличаются от обычных, поскольку имеют общий фон с родительским элементом. Обычно кнопка владеет фоном и цветом текста. Однако эти кнопки используют его совместно и применяют свой собственный фон только при взаимодействии.

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

Добавьте состояния взаимодействия с помощью нескольких псевдоклассов CSS и используйте соответствующие пользовательские свойства для состояния:

.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);
  }
}

Для придания основному кнопке законченного дизайна необходимо несколько специальных стилей:

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

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

Наконец, для пущей изысканности, кнопка и значок светлой темы получили тень :

.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));
    }
  }
}

Отличная кнопка, уделяющая внимание микровзаимодействиям и мельчайшим деталям.

Примечание о :focus-visible

Обратите внимание, что стили кнопок используют :focus-visible вместо :focus . :focus — это важный штрих для создания доступного пользовательского интерфейса, но у него есть один недостаток: он не учитывает, нужно ли пользователю видеть его или нет, он будет применяться для любого фокуса.

В видео ниже сделана попытка разобрать это микровзаимодействие, чтобы показать, что :focus-visible является разумной альтернативой.

Стилизация всплывающей кнопки

4ch флексбокс для центрирования значка и привязки всплывающего списка кнопок. Как и основная кнопка, он прозрачен до тех пор, пока на него не наведен курсор или не происходит взаимодействия, и растягивается для заполнения.

Стрелка на разделенной кнопке, используемая для вызова всплывающего окна.

.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);
}

Наложение состояний наведения, фокуса и активности с помощью CSS Nesting и функционального селектора :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);
  }
}

Эти стили — основной механизм отображения и скрытия всплывающего окна. Когда .gui-popup-button находится в focus на любом из своих дочерних элементов, задайте свойства opacity , position и pointer-events для значка и всплывающего окна.

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

После завершения настройки стилей входа и выхода остается последний этап — условные переходы преобразований в зависимости от предпочтений пользователя в отношении движения:

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

Внимательный взгляд на код заметит, что непрозрачность по-прежнему изменяется для пользователей, предпочитающих меньше движения.

Стилизация всплывающего окна

Элемент .gui-popup представляет собой плавающий список кнопок с карточкой, использующий пользовательские свойства и относительные единицы измерения, что позволяет сделать его немного меньше, интерактивно сочетать с основной кнопкой и использовать фирменный цвет. Обратите внимание, что значки стали менее контрастными, тоньше, а тень имеет фирменный синий оттенок. Как и в случае с кнопками, продуманный пользовательский интерфейс и UX достигаются благодаря сочетанию этих мелких деталей.

Плавающий элемент карты.

.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%))
  ;
}

Значки и кнопки оформлены в фирменных цветах, чтобы гармонично вписываться в темную и светлую тематику каждой карточки:

Ссылки и значки для оформления заказа, быстрой оплаты и сохранения на будущее.

.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%;
  }
}

Всплывающее окно темной темы дополнено тенью текста и значков, а также немного более интенсивной тенью блока:

Всплывающее окно в тёмной теме.

.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));
    }
  }
}

Общие стили значков <svg>

Размер всех значков зависит от font-size кнопки, в которой они используются, и для inline-size используется единица измерения ch . Для каждого значка также предусмотрены стили, обеспечивающие плавность и мягкость контуров.

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

Расположение справа налево

Логические свойства выполняют всю сложную работу. Вот список используемых логических свойств: - display: inline-flex создаёт встроенный flex-элемент. - padding-block и padding-inline в паре, вместо сокращённого padding , используют преимущества отступа логических сторон. - border-end-start-radius и подобные свойства скругляют углы в зависимости от направления документа. - inline-size а не width гарантирует, что размер не будет привязан к физическим размерам. - border-inline-start добавляет границу к началу, которая может быть справа или слева в зависимости от направления текста.

JavaScript

Почти весь код JavaScript ниже предназначен для улучшения доступности. Две мои вспомогательные библиотеки используются для упрощения задач. BlingBlingJS используется для лаконичных DOM-запросов и простой настройки прослушивателя событий, а roving-ux помогает упростить взаимодействие с клавиатурой и геймпадом во всплывающем окне.

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

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

После импорта указанных выше библиотек, а также выбора и сохранения элементов в переменных, обновление интерфейса будет завершено всего за несколько функций.

Индекс ровинга

Когда клавиатура или программа чтения с экрана фокусируется на .gui-popup-button , мы хотим переместить фокус на первую (или последнюю находящуюся в фокусе) кнопку в кнопке .gui-popup . Библиотека помогает нам сделать это с помощью параметров element и target .

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

Теперь элемент передает фокус целевым дочерним элементам <button> и позволяет использовать стандартную навигацию с помощью клавиш со стрелками для просмотра параметров.

Включение/выключение aria-expanded

Хотя визуально очевидно, что всплывающее окно отображается и скрывается, скринридеру нужно больше, чем просто визуальные подсказки. JavaScript здесь используется для дополнения взаимодействия :focus-within управляемого CSS, путём переключения соответствующего атрибута скринридера.

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

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

Включение клавиши Escape

Фокус пользователя намеренно переключился на ловушку, поэтому необходимо предоставить возможность выхода. Самый распространённый способ — разрешить использование клавиши Escape . Для этого следите за нажатиями клавиш на всплывающей кнопке, поскольку любые события клавиатуры на дочерних элементах будут передаваться на этот родительский элемент.

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

Если всплывающая кнопка обнаружит какое-либо нажатие клавиши Escape , она уберет с себя фокус с помощью blur() .

Раздельные нажатия кнопок

Наконец, если пользователь щёлкает, нажимает или взаимодействует с клавиатурой, приложение должно выполнить соответствующее действие. Здесь снова используется всплытие событий, но на этот раз в контейнере .gui-split-button , для перехвата нажатий кнопок из дочернего всплывающего окна или основного действия.

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

Заключение

Теперь, когда вы знаете, как я это сделал, как бы вы поступили? 🙂

Давайте разнообразим наши подходы и изучим все способы разработки в интернете. Создайте демо, пришлите мне ссылку в Твиттер , и я добавлю её в раздел ремиксов сообщества ниже!

Ремиксы сообщества