建立分割按鈕元件

說明如何建構符合無障礙設計的 Split Button 元件。

在本篇文章中,我想分享如何建構分割按鈕的想法。試用示範模式

示範

如果你偏好觀看影片,請參閱這篇文章的 YouTube 版本:

總覽

分割按鈕是隱藏主要按鈕和其他按鈕清單的按鈕。這些元素可用於顯示常見動作,同時將次要 (較不常使用) 動作巢狀化,直到需要時才顯示。分割按鈕對於讓繁忙的設計看起來簡潔,具有關鍵作用。進階分割按鈕甚至可以記住使用者的上一個動作,並將該動作提升至主要位置。

您可以在電子郵件應用程式中找到常見的拆分按鈕。主要動作是傳送,但您或許可以稍後傳送或儲存草稿:

電子郵件應用程式中的分割按鈕範例。

共用動作區域很不錯,因為使用者不需要四處尋找。他們知道分割按鈕包含必要的電子郵件動作。

零件

在討論整體協調和最終使用者體驗之前,讓我們先分解分割按鈕的重要部分。這裡使用 VisBug 的無障礙檢查工具,協助顯示元件的宏觀檢視畫面,顯示每個主要元件的 HTML、樣式和無障礙功能。

組成分割按鈕的 HTML 元素。

頂層分割按鈕容器

最高層級元件是內嵌式彈性容器,類別為 gui-split-button,包含主要動作.gui-popup-button

檢查的 gui-split-button 類別,並顯示此類別使用的 CSS 屬性。

主要動作按鈕

最初可見且可聚焦的 <button> 會在容器中放入兩個相符的角落形狀,用於聚焦懸停啟用互動,以便顯示在 .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%);
  }
}

版面配置和顏色

標記

元素一開始是 <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 彈性容器,用於將圖示置中,並將彈出式按鈕清單固定在畫面上。就像主要按鈕一樣,在滑鼠游標懸停或與其互動之前,這類按鈕會保持透明,然後會拉長至填滿畫面。

用於觸發彈出式視窗的拆分按鈕箭頭部分。

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

結論

既然你知道我如何做到,你會怎麼做呢? 🙂?

讓我們多方嘗試,瞭解在網路上建構應用程式的所有方式。請製作示範作品,並在推特上傳連結,我會將其加入下方的社群重混曲目錄!

社群重混作品