建立分割按鈕元件

基礎總覽:如何建構無障礙的分割按鈕元件。

在這篇文章中,我想分享如何建構分割按鈕的思考過程。 立即試用

示範

如果比較喜歡看影片,可以觀看這篇貼文的 YouTube 版本:

總覽

分割按鈕會隱藏主要按鈕和額外按鈕清單。這類按鈕可顯示常見動作,並將次要動作 (不常使用的動作) 巢狀化,直到需要時再顯示。如果設計忙碌,分割按鈕可協助您盡量減少設計。進階分割按鈕甚至可以記住使用者上次執行的動作,並將其升級為主要動作。

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

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

共用動作區域很實用,使用者不必到處尋找。他們知道重要電子郵件動作都包含在分割按鈕中。

零件

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

構成分割按鈕的 HTML 元素。

頂層分割按鈕容器

最高層級的元件是內嵌彈性方塊,類別為 gui-split-button,內含主要動作.gui-popup-button

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

主要動作按鈕

最初可見且可聚焦的 <button> 會在容器中,並有兩個相符的角落形狀,用於 focushoveractive 互動,顯示在 .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,因為這樣會裁剪彈出式視窗,導致無法顯示。

如果 <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-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')

匯入上述程式庫,並選取元素並儲存至變數後,只要再使用幾個函式,就能完成升級體驗。

Roving index

當鍵盤或螢幕閱讀器將焦點放在 .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)
})

結論

現在您已瞭解我的做法,您會怎麼做呢?🙂

讓我們多元化方法,學習在網路上建構內容的所有方式。 建立試聽版,然後在推特上傳送連結給我,我會將連結加到下方的社群混音區!

社群重混作品