简要介绍了如何构建无障碍分屏按钮组件的基础知识。
在本文中,我想分享有关构建分屏按钮的方法的思考。 试用演示版。
如果您更喜欢视频,请观看此帖子的 YouTube 版本:
概览
分屏按钮是指隐藏主要按钮和一组其他按钮的按钮。它们非常适合在需要时嵌套次要操作(使用频率较低)来显示常用操作。分屏按钮对于让繁杂的设计看起来简洁至关重要。高级分屏按钮甚至可以记住用户的最后一次操作,并将其提升到主要位置。
您可以在电子邮件应用中找到常见的分屏按钮。主要操作是发送,但您或许可以改为稍后发送或保存草稿:
共享操作区域很不错,因为用户无需四处寻找。他们知道分屏按钮中包含重要的电子邮件操作。
零部件
在讨论分屏按钮的整体协调和最终用户体验之前,我们先来拆解分屏按钮的关键部分。此处使用 VisBug 的可访问性检查工具来帮助显示组件的宏观视图,显示每个主要部分的 HTML、样式和可访问性方面。
顶级分屏按钮容器
顶级组件是一个内嵌 flexbox,类为 gui-split-button
,包含主要操作和 .gui-popup-button
。
主要操作按钮
最初可见且可聚焦的 <button>
会适合容器,并且两个匹配的角形可用于显示 .gui-split-button
中的聚焦、悬停和活动互动。
弹出式窗口切换按钮
“弹出式按钮”支持元素用于激活和提及辅助按钮列表。请注意,它不是 <button>
,也不可聚焦。不过,它是 .gui-popup
的位置锚点,也是用于呈现弹出式窗口的 :focus-within
的主机。
弹出式卡片
这是其锚点 .gui-popup-button
的浮动卡片子元素,采用绝对定位,并在语义上封装按钮列表。
次要操作
可聚焦的 <button>
的字体大小略小于主要操作按钮,并带有与主要按钮相称的图标和样式。
自定义属性
以下变量有助于创建色彩和谐,并提供一个集中位置来修改整个组件中使用的值。
@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-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
,因为它会剪裁弹出式窗口,使其无法在视觉上呈现。
填充了 <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>
图标样式
所有图标的尺寸都与其所使用的按钮 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
用于创建内嵌 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 通过切换适合屏幕阅读器的属性来补充 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 上构建的所有方式。 制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!