基礎總覽:如何建構無障礙的分割按鈕元件。
在這篇文章中,我想分享如何建構分割按鈕的思考過程。 立即試用。
如果比較喜歡看影片,可以觀看這篇貼文的 YouTube 版本:
總覽
分割按鈕會隱藏主要按鈕和額外按鈕清單。這類按鈕可顯示常見動作,並將次要動作 (不常使用的動作) 巢狀化,直到需要時再顯示。如果設計忙碌,分割按鈕可協助您盡量減少設計。進階分割按鈕甚至可以記住使用者上次執行的動作,並將其升級為主要動作。
電子郵件應用程式中常見的分割按鈕。主要動作是傳送,但您或許可以稍後再傳送,或改為儲存草稿:
共用動作區域很實用,使用者不必到處尋找。他們知道重要電子郵件動作都包含在分割按鈕中。
零件
在討論整體協調和最終使用者體驗之前,我們先來細分分割按鈕的重要部分。這裡使用 VisBug 的無障礙檢查工具,協助顯示元件的巨集檢視畫面,並列出每個主要部分的 HTML、樣式和無障礙功能。
頂層分割按鈕容器
最高層級的元件是內嵌彈性方塊,類別為 gui-split-button
,內含主要動作和 .gui-popup-button
。
主要動作按鈕
最初可見且可聚焦的 <button>
會在容器中,並有兩個相符的角落形狀,用於 focus、hover 和 active 互動,顯示在 .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
,因為這樣會裁剪彈出式視窗,導致無法顯示。
如果 <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 巢狀結構和 :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
會建立內嵌彈性元素。
- 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')
匯入上述程式庫,並選取元素並儲存至變數後,只要再使用幾個函式,就能完成升級體驗。
Roving index
當鍵盤或螢幕閱讀器將焦點放在 .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)
})
結論
現在您已瞭解我的做法,您會怎麼做呢?🙂
讓我們多元化方法,學習在網路上建構內容的所有方式。 建立試聽版,然後在推特上傳送連結給我,我會將連結加到下方的社群混音區!