建立切換元件

基本介紹如何建構回應式且無障礙的切換元件。

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

示範

如果你偏好使用影片,也可以觀看這篇 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 外掛程式將動作偏好設定較低的使用者媒體查詢放入自訂屬性中:

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

標記

我選擇將 <input type="checkbox" role="switch"> 元素納入 <label>,整合兩者關係,可避免核取方塊和標籤建立關聯,同時讓使用者能與標籤互動以切換輸入。

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

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

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

版面配置

Flexboxgrid自訂屬性對於維護此元件的樣式至關重要。這類函式會集中管理值、為名稱區分模糊計算或區域,以及啟用小型自訂屬性 API,以便輕鬆自訂元件。

.gui-switch

切換按鈕的頂層版面配置為 flexbox。.gui-switch 類別包含子項用於計算版面配置的私人和公開自訂屬性。

Flexbox DevTools 重疊在水平標籤和切換按鈕上,顯示空間的版面配置分佈情形。

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

擴充和修改 Flexbox 版面配置,就像變更任何 Flexbox 版面配置。例如,將標籤放在切換按鈕上方或下方,或變更 flex-direction

Flexbox DevTools 重疊在垂直標籤和切換按鈕上。

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

我以為這種分離的自動化調度管理方式非常實用。thumb 元素只需要一個樣式 (即 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 狀態。在這個狀態下,輸入的「track」背景會設為使用中的顏色,而拇指位置則設為「end」。

.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 自訂屬性。

由於在顯示這個指令碼之前,該元件已經達到 100% 的功用,因此維護現有行為需要不少工作,例如點選標籤切換輸入。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 的相同自訂屬性來處理,且能用來反轉邏輯並繼續支援 RTL。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)
}

這可計入稍後獲得此點擊的標籤,因為會取消勾選或勾選使用者執行的互動。

如果我再次執行此操作,我「可能」在使用者體驗升級期間,利用 JavaScript 調整 DOM,以建立能自行處理標籤點擊,且不干擾內建行為的元素。

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

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

結論

到目前為止,這個青少年交換元件最後是所有 GUI 挑戰的成就!現在既然你已經知道我怎麼做,你會怎麼做‽ 🙂?

讓我們帶您更多元的方法,並瞭解運用網路打造網站的所有方式。 請建立示範並透過 Twitter 推文連結,我就能將這項工具新增至下方的「社群重混」部分!

社群重混作品

資源

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