构建 switch 组件

关于如何构建响应迅速且易于访问的开关组件的基本概览。

在这篇博文中,我想分享一下构建开关组件的想法。 试用演示版

<ph type="x-smartling-placeholder">
</ph>
演示

如果你更喜欢视频,可以参阅此博文的 YouTube 版本:

概览

开关的功能类似于复选框 但明确表示布尔值的开启和关闭状态。

此演示大部分内容都使用 <input type="checkbox" role="switch"> 此功能的优点是无需 CSS 或 JavaScript 功能齐全且易于访问。加载 CSS 可支持从右到左 语言、垂直度、动画等。加载 JavaScript 会使这一转换 可拖动和有形

自定义属性

以下变量代表 switch 的各个部分,以及它们 选项。作为顶级类,.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 将媒体查询放入自定义属性中, 基于此草稿构建而成的 媒体查询规范中的 5:

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

Markup

我选择使用<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">自带 API状态。通过 用来管理 checked 属性和输入 事件 例如 oninputonchanged

布局

Flexboxgridcustom 属性至关重要 来保持该组件的样式它们将值集中在一起,为值指定名称 其他不明确的计算或区域,并启用小型自定义属性 用于轻松自定义组件的 API。

.gui-switch

switch 的顶层布局是 flexbox。.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,并改为提供自己的大小:

网格开发者工具叠加在开关轨道上,显示已命名的网格轨道
并将其命名为“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;
}

该轨道还会创建一个 1x1 单元的网格轨迹区域,以供拇指操作 声明。

缩略图展示次数

样式 appearance: none 还会移除 。该组件使用 伪元素:checked 伪类, 替换此可见指示器。

Thumb 是附加到 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)
  );
}

我认为这种分离的编排效果很好。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));
}

现在,垂直开关的旋转考虑了相反的一侧位置 从右到左布局所需的资源。

Thumb 伪元素上的 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)
  );
}

悬停

悬停互动应为已连接的界面提供视觉支持 为交互式界面提供方向此开关会突出显示拇指图标, 一个半透明圆环。此次悬停 然后,动画提供指向交互式拇指元素的方向。

“突出显示”使用 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 做得不错 两个人可以随意拖动两个朋友 相反,如果拖动手势,界面元素会让人感觉不活跃 已尝试,但没有任何反应。

可拖动的拇指

Thumb 伪元素从 .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,能够用它反转逻辑并继续 支持 RTL。event.offsetX 也很有价值,因为它包含增量 值。

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

已完成与该元素的互动,现在该检查“设置”输入源了 属性并移除所有手势事件。该复选框会更改为 state.activethumb.checked = determineChecked()

determineChecked()

此函数由 dragEnd 调用,用于确定拇指当前位于何处 在其轨迹边界内,如果其值等于或大于 0,则返回 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()
}

总结

这个小小的 switch 组件最终成为了所有 GUI 挑战中最出色的工作 !现在您已经知道我是怎么做到的了,您该怎么做 ‽ 🙂?

让我们一起采用多样化的方法,学习所有在 Web 上构建应用的方法。 创建演示,在 Twitter 微博上添加链接,然后我会添加 到下面的社区混剪部分!

社区混剪作品

资源

在以下位置查找 .gui-switch 源代码: GitHub 了解详情。