构建拆分按钮组件

简要介绍如何构建无障碍拆分按钮组件。

在这篇博文中,我想分享关于构建分屏按钮的想法。试用演示版

演示

如果你更喜欢视频,可以参考本博文的 YouTube 版本:

概览

拆分按钮是用于隐藏主要按钮和其他按钮列表的按钮。它们对于公开常见的操作很有用,同时可以根据需要嵌套不太常用的次要操作。拆分按钮对于帮助简化繁杂的设计至关重要。高级拆分按钮甚至可以记住用户上次执行的操作并将其提升到主要位置。

您可以在电子邮件应用中找到常用的拆分按钮。主要操作是发送,但或许您可以稍后发送或改为保存草稿:

电子邮件应用中显示的拆分按钮示例。

共享操作区域非常实用,因为用户不需要环顾四周。他们知道拆分按钮中包含重要的电子邮件操作。

零部件

在讨论拆分按钮的整体编排和最终用户体验之前,我们先来了解一下拆分按钮的基本部分。此处使用 VisBug 的无障碍功能检查工具来帮助显示组件的宏视图,并显示每个主要部分的 HTML 各方面、样式和无障碍功能。

构成拆分按钮的 HTML 元素。

顶层拆分按钮容器

最高级别的组件是内嵌 Flexbox,其类为 gui-split-button,其中包含主要操作.gui-popup-button

gui-split-button 类已检查并显示此类中使用的 CSS 属性。

主要操作按钮

最初可见且可聚焦的 <button> 位于容器内,具有两个匹配的角形状,可用于聚焦hover主动互动,它们会显示在 .gui-split-button 中。

显示按钮元素的 CSS 规则的检查器。

弹出式切换按钮

“弹出式按钮”支持元素用于激活和暗示辅助按钮列表。请注意,它不是 <button> 且不可聚焦。不过,它是 .gui-popup 的定位锚点和用于显示弹出式窗口的 :focus-within 的托管方。

显示 gui-popup-button 类的 CSS 规则的检查器。

弹出式卡片

这是其锚点 .gui-popup-button 的悬浮卡片子级,其定位为绝对定位,并在语义上封装按钮列表。

显示 gui-popup 类的 CSS 规则的检查器

次要操作

一个可聚焦 <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%);
  }
}

布局和颜色

Markup

该元素以包含自定义类名称的 <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-haspopuparia-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,因为它会裁剪弹出式窗口,使其无法显示在视觉上。

填充了 <li><button> 内容的 <ul> 会以“按钮列表”的形式向屏幕阅读器读出,这正是所呈现的界面。

<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 Flexbox,用于将图标居中并锚定弹出式按钮列表。与主按钮一样,按钮在其悬停或互动之前处于透明状态,并拉伸以填充。

用于触发弹出式窗口的分屏按钮的箭头部分。

.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 嵌套: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 元素是一个悬浮卡片按钮列表,使用自定义属性和相对单位(略微缩小),以交互方式与主要按钮匹配,并通过颜色的使用呈现品牌形象。请注意,图标对比度较低,较薄,并且阴影带有一丝品牌蓝色。与按钮一样,出色的界面和用户体验是由这些小细节堆叠在一起的结果。

悬浮卡片元素。

.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> 图标样式

通过将 ch 单元用作 inline-size,所有图标的尺寸都相对其使用的 font-size 按钮而言。此外,每个字段还提供了一些样式,以帮助呈现柔和平滑的图标。

.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-blockpadding-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 中的第一个(或最近聚焦)按钮。该库可以使用 elementtarget 参数帮助我们完成此操作。

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

该元素现在将焦点传递给目标 <button> 子元素,并启用标准箭头键导航以浏览选项。

切换aria-expanded

虽然很明显,弹出窗口是显示和隐藏的,但屏幕阅读器需要的不仅仅是视觉提示。此处使用 JavaScript 通过切换屏幕阅读器的相应属性来补充 CSS 驱动的 :focus-within 互动。

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

总结

现在你已经知道我是怎么做的,希望你怎么办 ‽ 🙂?

下面,我们就来介绍一下我们的方法多样化,并了解在 Web 上构建网站的所有方法。 只需创建一个演示,点击 tweet me 链接,我就会将其添加到下方的“社区混剪”部分中!

社区混剪作品