說明如何建構符合無障礙設計的 Split Button 元件。
在本篇文章中,我想分享如何建構分割按鈕的想法。立即試用。
如果你偏好觀看影片,請參閱這篇文章的 YouTube 版本:
總覽
「分割按鈕」是隱藏主要按鈕和額外按鈕清單的按鈕。如要在需要顯示常用動作時顯示共同動作,為這類動作建立巢狀結構,這些動作就非常實用。分割按鈕對於讓繁忙的設計看起來簡潔,具有關鍵作用。進階分割按鈕甚至可以記住使用者的上一個動作,並將該動作提升至主要位置。
電子郵件應用程式中會顯示常見的拆分按鈕。主要動作是傳送,但您或許可以稍後傳送或儲存草稿:
共用動作區域很不錯,因為使用者不需要四處尋找。他們知道分割按鈕包含必要的電子郵件動作。
零件
在討論整體協調和最終使用者體驗之前,讓我們先分解分割按鈕的重要部分。這裡使用 VisBug 的無障礙檢查工具,協助顯示元件的宏觀檢視畫面,顯示每個主要元件的 HTML、樣式和無障礙功能。
頂層分割按鈕容器
最高層級元件是內嵌彈性容器,類別為 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%);
}
}
版面配置和顏色
標記
元素一開始會以含有自訂類別名稱的 <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
彈性容器,用於將圖示置中,並將彈出式按鈕清單固定在畫面上。就像主要按鈕一樣,這類按鈕會在滑鼠游標懸停或互動時顯示,並且會延伸至填滿畫面。
.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
元素是浮動資訊卡按鈕清單,使用自訂屬性和相對單位,以便與主要按鈕互動配對,並根據品牌使用顏色。請注意,圖示的對比度較低、較細,陰影則帶有品牌藍色調。就像按鈕一樣,優異的 UI 和 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
會建立內嵌 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)
})
結論
既然你知道我如何做到,你會怎麼做呢? 🙂
讓我們來體驗更多元的方法,並瞭解在網路上建立內容的所有方式。 請製作示範作品,並在推特上傳連結,我會將其加入下方的社群重混曲目錄!