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

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

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

Демо

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

Обзор

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

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

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

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

Части

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

Элементы HTML, составляющие кнопку разделения.

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

Компонент самого высокого уровня — это встроенный флексбокс с классом 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 , положение и 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 кнопки, внутри которого они используются, за счет использования модуля ch в качестве inline-size . Каждому из них также даны несколько стилей, которые помогут сделать значки мягкими и гладкими.

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

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

Логические свойства выполняют всю сложную работу. Вот список используемых логических свойств: - display: inline-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)
})

Заключение

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

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

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