建立切換元件

概略說明如何建構可回應且無障礙的切換按鈕元件。

在本篇文章中,我想分享建構切換元件的方法。試用示範模式

示範

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

總覽

switch 的功能類似核取方塊,但會明確代表布林值開啟和關閉狀態。

這個示範使用 <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 屬性和 輸入事件,例如 oninputonchanged

版面配置

Flexbox格線自訂屬性對於維持此元件的樣式至關重要。這些屬性可集中值、為不清晰的計算或區域命名,並啟用小型自訂屬性 API,方便自訂元件。

.gui-switch

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

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

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

擴充及修改 Flexbox 版面配置的方式,與變更任何 Flexbox 版面配置相同。例如,如要將標籤放在切換鈕上方或下方,或變更 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,並提供其自身大小,藉此將核取方塊輸入內容設為切換軌道樣式:

格線 DevTools 疊加切換軌道,顯示命名格線軌道區域,名稱為「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);
}

切換軌道的多種自訂選項來自四個自訂屬性。由於 appearance: none 不會在所有瀏覽器中移除核取方塊的邊框,因此新增了 border: 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 提供支援,該類別會在 input 元素中加入旋轉和 CSS 轉換。

不過,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 元素可能會處於非活動狀態。

可拖曳的喜歡按鈕

拇指圖示偽元素會從 .gui-switch > input 範圍的 var(--thumb-position) 接收位置,JavaScript 可在輸入內容上提供內嵌樣式值,以動態更新拇指圖示位置,讓拇指圖示看起來會追隨指標手勢。當您放開指標時,請移除內嵌樣式,並使用自訂屬性 --thumb-position 判斷拖曳動作是否更接近開啟或關閉。這是解決方案的基礎;指標事件會依條件追蹤指標位置,以修改 CSS 自訂屬性。

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

touch-action

拖曳是一種手勢,也是自訂手勢,因此非常適合用於 touch-action 的優點。在這個切換鈕的情況下,水平手勢應由指令碼處理,或是為垂直切換鈕變化版本擷取的垂直手勢。透過 touch-action,我們可以告訴瀏覽器要處理這個元素上的哪些手勢,這樣指令碼就能處理手勢,而不會發生競爭。

下列 CSS 會指示瀏覽器,當游標手勢從此切換軌道開始時,請處理垂直手勢,而不要處理水平手勢:

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

期望的結果是橫向手勢,且不會平移或捲動頁面。指標可以從輸入內容內開始垂直捲動並捲動頁面,但水平捲動則由自訂處理。

像素值樣式公用程式

在設定和拖曳期間,系統需要從元素擷取各種計算的數值。下列 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 也相當實用,因為它包含 delta 值,可用於定位拇指。

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

這行 CSS 會設定 thumb 元素使用的自訂屬性。這個值指派作業會隨著時間改變,但先前的指標事件已將 --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()
}

與元素的互動已完成,現在是時候設定 input checked 屬性,並移除所有手勢事件。核取方塊已變更為 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)
}

這麼做是為了讓標籤接收這個較晚的點擊,因為標籤會取消勾選或勾選使用者執行的互動。

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

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

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

結論

這個小小的切換元件,是目前為止所有 GUI 挑戰中,最費時的項目!既然你知道我如何做到,你會怎麼做呢? 🙂?

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

社群重混作品

資源

GitHub 上找出 .gui-switch 的原始碼。