建立切換元件

基礎概念總覽:如何建構回應式無障礙切換元件。

在這篇文章中,我想分享如何建構切換元件的思考過程。 立即試用

示範

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

總覽

切換按鈕的功能與核取方塊類似,但明確代表布林值的開啟和關閉狀態。

這個範例的大部分功能都使用 <input type="checkbox" role="switch">,優點是不需要 CSS 或 JavaScript 就能完全正常運作,且可供存取。載入 CSS 可支援從右到左的語言、直向、動畫等。載入 JavaScript 可讓切換鈕可拖曳且可觸摸。

自訂屬性

下列變數代表切換開關的各個部分及其選項。做為頂層類別,.gui-switch 包含整個元件子項使用的自訂屬性,以及集中式自訂的進入點。

追蹤

長度 (--track-size)、邊框間距和兩種顏色:

.gui-switch {
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;

  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);

  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);

  @media (prefers-color-scheme: dark) {
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
}

縮圖

大小、背景顏色和互動醒目顯示顏色:

.gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);

  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);

  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
  }
}

減少動態效果

如要新增明確別名並減少重複,可將減少動態效果偏好設定使用者媒體查詢,透過 PostCSS 外掛程式放入自訂屬性,依據 Media Queries 5 中的這項草案規格

@custom-media --motionOK (prefers-reduced-motion: no-preference);

標記

我選擇使用 <label> 包裝 <input type="checkbox" role="switch"> 元素,將兩者關係捆綁在一起,避免核取方塊和標籤關聯性不明確,同時讓使用者能夠與標籤互動,切換輸入內容。

未設定樣式的自然標籤和核取方塊。

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

<input type="checkbox"> 預先建構了 API狀態。瀏覽器會管理 checked 屬性和 input 事件,例如 oninputonchanged

版面配置

Flexbox格線自訂屬性對於維護這個元件的樣式至關重要。這些符記可集中管理值、為原本模稜兩可的計算或區域命名,並啟用小型自訂屬性 API,方便自訂元件。

.gui-switch

切換按鈕的頂層版面配置是彈性方塊。類別 .gui-switch 包含子項用來計算版面配置的私有和公開自訂屬性。

Flexbox 開發人員工具會疊加顯示水平標籤和切換鈕,並顯示空間的版面配置分配情形。

.gui-switch {
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
}

擴充及修改彈性方塊版面配置,就像變更任何彈性方塊版面配置一樣。 舉例來說,如要將標籤放在切換鈕上方或下方,或是變更 flex-direction

Flexbox 開發人員工具會重疊顯示垂直標籤和切換鈕。

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
  <input type="checkbox" role="switch" id="light-switch">
</label>

追蹤

移除核取方塊的正常 appearance: checkbox,並改為提供自己的尺寸,即可將核取方塊輸入內容設為切換軌:

格線開發人員工具重疊顯示切換軌,並顯示名為「track」的格線軌區域。

.gui-switch > input {
  appearance: none;

  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  padding: var(--track-padding);

  flex-shrink: 0;
  display: grid;
  align-items: center;
  grid: [track] 1fr / [track] 1fr;
}

軌道也會為縮圖建立單一儲存格格線軌道區域,供縮圖聲明。

縮圖

appearance: none 樣式也會移除瀏覽器提供的視覺勾號。這個元件會使用輸入內容的虛擬元素:checked 虛擬類別,取代這個視覺指標。

拇指是附加至 input[type="checkbox"] 的虛擬元素子項,會堆疊在軌道上方,而不是下方,並聲明格線區域 track

開發人員工具顯示偽元素拇指位於 CSS 格線內。

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

樣式

自訂屬性可啟用多功能切換元件,配合色彩配置、由右至左語言和動態偏好設定調整。

並排比較切換鈕的淺色和深色主題及其狀態。

觸控互動樣式

在行動裝置上,瀏覽器會為標籤和輸入內容新增輕觸醒目顯示和文字選取功能。這對切換所需的樣式和視覺互動意見回饋造成負面影響。只要幾行 CSS,我就可以移除這些效果,並加入自己的 cursor: pointer 樣式:

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

建議您不要移除這些樣式,因為這些樣式可提供有價值的視覺互動回饋。如果移除這些選項,請務必提供自訂替代方案。

追蹤

這個元素的樣式大多與形狀和顏色有關,可透過層疊從父項 .gui-switch 存取。

自訂軌道大小和顏色的切換鈕變體。

.gui-switch > input {
  appearance: none;
  border: none;
  outline-offset: 5px;
  box-sizing: content-box;

  padding: var(--track-padding);
  background: var(--track-color-inactive);
  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  border-radius: var(--track-size);
}

四個自訂屬性提供多種切換軌自訂選項。border: none,因為 appearance: none 無法在所有瀏覽器中移除核取方塊的邊框。

縮圖

拇指元素已位於右側 track,但需要圓形樣式:

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

開發人員工具顯示圓形滑桿控點虛擬元素。

互動

使用自訂屬性,為顯示懸停醒目顯示和縮圖位置變更的互動做好準備。系統也會在轉換動態或懸停醒目顯示樣式前檢查使用者偏好設定

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

大拇指位置

自訂屬性提供單一來源機制,可將拇指放在軌道中。我們可用的軌道和滑桿大小,將用於計算,確保滑桿適當偏移並位於軌道內:0%100%

input 元素擁有位置變數 --thumb-position,而滑桿虛擬元素會將其做為 translateX 位置:

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

現在可以從 CSS 和核取方塊元素提供的虛擬類別中,自由變更 --thumb-position。由於我們先前在這個元素上設定了 transition: transform var(--thumb-transition-duration) ease,因此這些變更可能會在變更時產生動畫效果:

/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
}

/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
}

我認為這種分離式協調機制運作良好。滑桿元素只會處理一種樣式,也就是 translateX 位置。輸入內容可管理所有複雜度和計算。

產業

支援作業是透過修飾符類別 -vertical 完成,該類別會將 CSS 轉換的旋轉效果新增至 input 元素。

不過,3D 旋轉元素不會改變元件的整體高度,這可能會導致區塊版面配置出錯。請使用 --track-size--track-padding 變數來處理這項情況。計算垂直按鈕在版面配置中正常流動所需空間下限:

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

(RTL) 由右至左

我和 CSS 朋友 Elad Schecter 共同製作原型,使用 CSS 轉換製作滑出式側邊選單,並透過翻轉單一變數來處理由右至左的語言。我們這麼做是因為 CSS 中沒有邏輯屬性轉換,而且可能永遠不會有。Elad 提出絕妙的點子,就是使用自訂屬性值反轉百分比,以便透過單一位置管理我們自己的自訂邏輯,進行邏輯轉換。我在這個切換器中使用了相同的技巧,而且我覺得效果很棒:

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

名為 --isLTR 的自訂屬性一開始會保留 1 值,也就是 true,因為我們的版面配置預設為由左至右。然後使用 CSS 虛擬類別 :dir(),在元件位於由右至左的版面配置中時,將值設為 -1

在轉換內的 calc() 中使用 --isLTR,即可將其付諸實行:

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

現在,垂直切換的旋轉會考量從右到左版面配置所需的對向位置。

此外,也需要更新拇指虛擬元素上的 translateX 轉換,以因應另一側的需求:

.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
  --thumb-position: calc(
   ((var(--track-size) / 2) - (var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

雖然這種方法無法解決與邏輯 CSS 轉換等概念相關的所有需求,但確實為許多用途提供了一些 DRY 原則。

使用內建的 input[type="checkbox"] 時,請務必處理各種可能狀態::checked:disabled:indeterminate:hover:focus 刻意保持不變,只調整了偏移值;Firefox 和 Safari 上的焦點環看起來很棒:

螢幕截圖:Firefox 和 Safari 中的切換鈕有焦點環。

已勾選

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

此狀態代表 on 狀態。在此狀態下,輸入的「軌跡」背景會設為有效顏色,而滑桿位置則會設為「結尾」。

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

已停用

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

:disabled 按鈕不僅外觀不同,也應讓元素保持不變。互動不變性可免除瀏覽器負擔,但由於使用 appearance: none,視覺狀態需要樣式。

.gui-switch > input:disabled {
  cursor: not-allowed;
  --thumb-color: transparent;

  &::before {
    cursor: not-allowed;
    box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

    @media (prefers-color-scheme: dark) { & {
      box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
    }}
  }
}

深色樣式的切換鈕,處於已停用、已勾選和未勾選狀態。

這個狀態很棘手,因為需要深色和淺色主題,且必須同時包含已停用和已勾選的狀態。我為這些狀態選擇了最少的樣式,以減輕樣式組合的維護負擔。

未確定

經常被遺忘的狀態是 :indeterminate,也就是核取方塊既未勾選也未取消勾選。這是一種有趣的狀態,充滿吸引力且不造作。提醒您,布林值狀態可能會有隱藏的中間狀態。

將核取方塊設為不確定狀態很棘手,只有 JavaScript 才能設定:

<label for="switch-indeterminate" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-indeterminate">
  <script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>

不確定狀態,軌跡滑桿位於中間,表示尚未決定。

對我來說,這個狀態是樸實且吸引人的,因此將切換鈕的滑桿位置放在中間是合適的:

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

懸停

懸停互動應為連結的 UI 提供視覺支援,並引導使用者前往互動式 UI。當滑鼠懸停在標籤或輸入內容上時,這個切換鈕會以半透明圓環醒目顯示滑桿。這個懸停動畫會指引使用者前往互動式拇指元素。

「醒目顯示」效果是使用 box-shadow 製作,如果輸入內容未停用,請在懸停時增加 --highlight-size 的大小。如果使用者接受動態效果,我們會轉換 box-shadow 並觀察其成長情況;如果使用者不接受動態效果,精彩片段會立即顯示:

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

.gui-switch > input:not(:disabled):hover::before {
  --highlight-size: .5rem;
}

JavaScript

對我來說,開關介面試圖模擬實體介面,尤其是軌道內有圓圈的這類介面,感覺非常詭異。iOS 的開關就做得很好,你可以左右拖曳,而且有這個選項非常令人滿意。反之,如果嘗試拖曳手勢但沒有任何反應,UI 元素可能會感覺處於非使用中狀態。

可拖曳的拇指

JavaScript 可在輸入內容中提供內嵌樣式值,動態更新滑桿位置,讓滑桿看起來像是跟隨指標手勢。.gui-switch > inputvar(--thumb-position)放開指標後,請移除內嵌樣式,並使用自訂屬性 --thumb-position 判斷拖曳動作是靠近「關閉」還是「開啟」。這是解決方案的骨幹;指標事件會視情況追蹤指標位置,以修改 CSS 自訂屬性。

由於元件在顯示這個指令碼前已完全正常運作,因此要維持現有行為 (例如點選標籤來切換輸入) 需要相當多的工作。我們的 JavaScript 不應以犧牲現有功能為代價新增功能。

touch-action

拖曳是自訂手勢,因此非常適合使用 touch-action 效益。如果是這個切換鈕,水平手勢應由指令碼處理,或是為垂直切換鈕變體擷取垂直手勢。有了 touch-action,我們可以告知瀏覽器要在這個元素上處理哪些手勢,因此指令碼可以處理手勢,不會發生競爭。

以下 CSS 會指示瀏覽器,當指標手勢從這個切換軌內開始時,處理垂直手勢,對水平手勢則不執行任何動作:

.gui-switch > input {
  touch-action: pan-y;
}

期望結果是水平手勢,不會同時平移或捲動網頁。指標可以從輸入內容內垂直捲動並捲動頁面,但水平指標是自訂處理。

Pixel 值樣式公用程式

在設定和拖曳期間,需要從元素中擷取各種計算出的數值。下列 JavaScript 函式會傳回指定 CSS 屬性的計算像素值。設定指令碼會使用這個值,如下所示:getStyle(checkbox, 'padding-left')

​​const getStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}

const getPseudoStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}

export {
  getStyle,
  getPseudoStyle,
}

請注意,window.getComputedStyle() 接受第二個引數,也就是目標虛擬元素。JavaScript 可以從元素 (甚至是虛擬元素) 讀取這麼多值,真是太厲害了。

dragging

這是拖曳邏輯的核心時刻,函式事件處理常式有幾件事需要注意:

const dragging = event => {
  if (!state.activethumb) return

  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')

  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0

  let pos = Math.round(event.offsetX - thumbsize / 2)

  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper

  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

指令碼英雄是 state.activethumb,這個小圓圈是指令碼沿著指標定位的位置。switches 物件是 Map(),其中的鍵為 .gui-switch,值則是快取界線和大小,可確保指令碼的效率。系統會使用 CSS --isLTR 的相同自訂屬性處理從右到左的語言,並可使用該屬性反轉邏輯,繼續支援從右到左的語言。event.offsetX 也很有價值,因為它包含可用於定位拇指的增量值。

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

最後一行 CSS 會設定縮圖元素使用的自訂屬性。否則這個值指派作業會隨時間轉換,但先前的指標事件已暫時將 --thumb-transition-duration 設為 0s,移除原本會緩慢的互動。

dragEnd

如要允許使用者將切換鈕拖曳到遠離切換鈕的位置,並放開滑鼠按鈕,需要註冊全域視窗事件:

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

我認為使用者有自由拖曳的權利,而介面也應夠聰明,能將此納入考量,這點非常重要。使用這個切換開關處理這項問題並不困難,但在開發過程中,確實需要仔細考量。

const dragEnd = event => {
  if (!state.activethumb) return

  state.activethumb.checked = determineChecked()

  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false

  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null

  padRelease()
}

與元素的互動已完成,現在可以設定輸入的已勾選屬性,並移除所有手勢事件。核取方塊會隨著 state.activethumb.checked = determineChecked() 變更。

determineChecked()

這個函式由 dragEnd 呼叫,可判斷滑桿目前在軌道範圍內的位置,如果滑桿位於軌道中點或超過中點,則傳回 true:

const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)

  let curpos =
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))

  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }

  return curpos >= bounds.middle
}

其他想法

由於選擇了初始 HTML 結構 (最明顯的是將輸入內容包裝在標籤中),拖曳手勢產生了一些程式碼債務。標籤是父項元素,因此會在輸入後收到點擊互動。在 dragEnd 事件的結尾,您可能會注意到 padRelease() 這個聽起來很奇怪的函式。

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

這是為了因應標籤稍後取得的點擊,因為這樣會取消勾選或勾選使用者執行的互動。

如果我再次執行這項操作,可能會考慮在 UX 升級期間使用 JavaScript 調整 DOM,建立可自行處理標籤點擊的元素,且不會與內建行為衝突。

這類 JavaScript 是我最不喜歡撰寫的內容,我不想管理條件式事件冒泡:

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

結論

這個微小的切換元件,最後竟成為目前所有 GUI 挑戰中最耗費心力的項目!現在您已瞭解我的做法,您會怎麼做呢?🙂

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

社群重混作品

資源

.gui-switch GitHub 上查看原始碼