从0搭建Vue3组件库之Switch组件

753 阅读5分钟

Switch 组件是用户界面中常见的交互元素之一,用于在两种状态之间进行切换。它通常用于开启或关闭某个功能、切换模式等场景。Element Plus 作为 Vue 3 的流行组件库,提供了功能丰富且易于使用的 Switch 组件。本文将详细介绍如何基于 Vue 3 和 TypeScript 实现一个仿照 Element Plus 设计的 Switch 组件,涵盖需求分析、设计思路、代码实现以及总结等方面。

image.png

组件分析

在原生HTML中,很难找到和Switch组件相似的标签,但是如果你去看过类似Element Plus等组件库的源码,你会发现Switch内部其实是由一个checkbox(复选框)实现的,checkbox允许用户选择多个选项,每个选项都是独立的,用户可以选择一个、多个或者都不选。而Switch组件设计的初衷也就是为了提供一个在两种状态之间简单切换的控件,这就与checkbox不谋而合了。

看到这里,你也许会问,为什么不适用radio(单选框)呢?相比于checkbox,radio用于在一组选择中选择一个,他要求的是用户从一组互斥的选项中做出选择,那么这样就与Switch组件的设计目的不相符了。对比之下,checkbox更适合作为Switch组件的内部实现。

需求分析

一个Switch组件需要的基本功能其实并不多:

  • 开/关两种状态:用户可以通过点击来切换开关的状态。
  • 不同状态对应的值:允许用户自定义打开和关闭状态下的值(如 true/false'on'/'off' 等)。
  • 文字描述:可以在开关的不同状态下显示相应的文字提示(如“开启”/“关闭”)。
  • 禁用状态:支持禁用开关,防止用户操作。
  • 尺寸控制:提供不同大小的开关(如 smalllarge),以适应不同的布局需求。
  • 键盘导航:支持通过键盘快捷键(如 Enter 键)切换开关状态。
  • 无障碍支持:确保组件符合 WAI-ARIA 规范,提供良好的无障碍体验。

通过Switch组件的需求分析,我们不难想到,前两个需求是比较容易实现的,样式现在反而成了这个组件比较难的一块儿。

确定方案

  • 属性
export type SwitchValueType = boolean | number | string

export interface SwitchProps {
  // 实现v-model必备属性
  modelValue: SwitchValueType
  disabled?: boolean
  // 打开状态下的文字描述
  activeText?: string
  // 关闭状态下的文字描述
  inactiveText?: string
  // 打开状态下的值
  activeValue?: SwitchValueType
  // 关闭状态下的值
  inactiveValue?: SwitchValueType
  name?: string
  // input 的 id
  id?: string
  size?: 'small' | 'large'
}
  • 事件
export interface SwitchEmits {
   // 切换状态改变派发事件 
  (e: 'change', value: SwitchValueType): void
  // 结合modelValue实现v-model
  (e: 'update:modelValue', value: SwitchValueType): void
}
  • 组件
<template>
    <div class="jd-switch">
        <input class="jd-switch__input" />
        <div class="jd-switch__core">
            <div class="jd-switch__core-inner">
                <span class="jd-switch__core-inner-text"></span>
            </div>
            <div class="jd-switch__core-action"></div>
        </div>
    </div>
</template>

代码实现

  • 开/关两种状态
const innerValue = ref(props.modelValue)
// 是否被选中
const checked = computed(() => innerValue.value === props.activeValue)
const switchValue = () => {
    if (props.disabled) return
    const newValue = checked.value ? props.inactiveValue : props.activeValue
    innerValue.value = newValue
    emits("update:modelValue", newValue)
    emits("change", newValue)
}
  • 不同状态对应的值以及文字描述
<div class="jd-switch__core-inner">
    <span class="jd-switch__core-inner-text" v-if="activeText || inactiveText">
        {{ checked ? activeText : inactiveText }}
    </span>
</div>

不同状态对应不同的描述

  • 自定义开/关对应的值
const props = withDefaults(defineProps<SwitchProps>(), {
    activeValue: true,
    inactiveValue: false
})

默认为true和false,使用时可自定义,如: <Switch v-model="test" activeValue="right" inactiveValue="wrong"/>

完整代码

<template>
  <div
    class="jd-switch"
    :class="{
      [`jd-switch--${size}`]: size,
      'is-diabled': disabled,
      'is-checked': checked,
    }"
    @click="switchValue"
  >
    <input
      class="jd-switch__input"
      ref="input"
      type="checkbox"
      :disabled="disabled"
      role="switch"
      :name="name"
      @keydown.enter="switchValue"
    />

    <div class="jd-switch__core">
      <div class="jd-switch__core-inner">
        <span
          class="jd-switch__core-inner-text"
          v-if="activeText || inactiveText"
        >
          {{ checked ? activeText : inactiveText }}
        </span>
      </div>
      <div class="jd-switch__core-action"></div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { computed, onMounted, ref, watch } from "vue";
import type { SwtichProps, SwtichEmits } from "./types";

defineOptions({
  name: "jd-switch",
  inheritAttrs: false,
});

const props = withDefaults(defineProps<SwtichProps>(), {
  activeValue: true,
  inactiveValue: false, // 不继承父组件的属性,避免不必要的属性传递给内部元素
});
const emit = defineEmits<SwtichEmits>();

const innerValue = ref(props.modelValue);
// input原生dom属性
// 获取复选框的原生 DOM 元素,用于控制其 `checked` 状态
const input = ref<HTMLInputElement>();
// 计算属性,判断当前是否处于选中状态(即打开状态)
const checked = computed(() => innerValue.value === props.activeValue);

// 切换开关状态的方法
const switchValue = () => {
  if (props.disabled) return;
  // 切换 `innerValue` 的值,从 `activeValue` 切换到 `inactiveValue`,反之亦然
  const newValue = checked.value ? props.inactiveValue : props.activeValue;
  innerValue.value = newValue;
  // 发射 `update:modelValue` 事件,通知父组件更新绑定的值
  emit("update:modelValue", newValue);
  // 发射 `change` 事件,通知父组件开关状态发生了变化
  emit("change", newValue);
};

onMounted(() => {
  input.value!.checked = checked.value; // 将复选框的 `checked` 属性设置为当前的 `checked` 状态
});

watch(checked, () => {
  input.value!.checked = checked.value;
});

watch(
  () => props.modelValue,
  (newValue) => {
    innerValue.value = newValue;
  }
);
</script>

// types.ts
export type SwitchValueType = boolean | string | number;
export interface SwtichProps {
  modelValue: SwitchValueType;
  disabled?: boolean;
  activeText?: string;
  inactiveText?: string;
  activeValue?: SwitchValueType;
  inactiveValue?: SwitchValueType;
  name?: string;
  id?: string;
  size?: "small" | "large";
}

export interface SwtichEmits {
  (e: "update:modelValue", value: SwitchValueType): void;
  (e: "change", value: SwitchValueType): void;
}

//style.css
.jd-switch {
  --jd-switch-on-color: var(--jd-color-primary);
  --jd-switch-off-color: var(--jd-border-color);
  --jd-switch-on-border-color: var(--jd-color-primary);
  --jd-switch-off-border-color: var(--jd-border-color);
}

.jd-switch {
  display: inline-flex;
  align-items: center;
  font-size: 14px;
  line-height: 20px;
  height: 32px;
  .jd-switch__input {
    position: absolute;
    width: 0;
    height: 0;
    opacity: 0;
    margin: 0;
    &:focus-visible {
      & ~ .jd-switch__core {
        outline: 2px solid var(--jd-switch-on-color);
        outline-offset: 1px;
      }
    }
  }
  &.is-disabled {
    opacity: 0.6;
    .jd-switch__core {
      cursor: not-allowed;
    }
  }
  &.is-checked {
    .jd-switch__core {
      border-color: var(--jd-switch-on-border-color);
      background-color: var(--jd-switch-on-color);
      .jd-switch__core-action {
        left: calc(100% - 17px);
      }
      .jd-switch__core-inner {
        padding: 0 18px 0 4px;
      }
    }
  }
}
.jd-switch--large {
  font-size: 14px;
  line-height: 24px;
  height: 40px;
  .jd-switch__core {
    min-width: 50px;
    height: 24px;
    border-radius: 12px;
    .jd-switch__core-action {
      width: 20px;
      height: 20px;
    }
  }
  &.is-checked {
    .jd-switch__core .jd-switch__core-action {
      left: calc(100% - 21px);
      color: var(--jd-switch-on-color);
    }
  }
}
.jd-switch--small {
  font-size: 12px;
  line-height: 16px;
  height: 24px;
  .jd-switch__core {
    min-width: 30px;
    height: 16px;
    border-radius: 8px;
    .jd-switch__core-action {
      width: 12px;
      height: 12px;
    }
  }
  &.is-checked {
    .jd-switch__core .jd-switch-core-action {
      left: calc(100% - 13px);
      color: var(--jd-switch-on-color);
    }
  }
}
.jd-switch__core {
  display: inline-flex;
  align-items: center;
  position: relative;
  height: 20px;
  min-width: 40px;
  border: 1px solid var(--jd-switch-off-border-color);
  outline: none;
  border-radius: 10px;
  box-sizing: border-box;
  background: var(--jd-switch-off-color);
  cursor: pointer;
  transition: border-color var(--jd-transition-duration),
    background-color var(--jd-transition-duration);
  .jd-switch__core-action {
    position: absolute;
    left: 1px;
    border-radius: var(--jd-border-radius-circle);
    width: 16px;
    height: 16px;
    background-color: var(--jd-color-white);
    transition: all var(--jd-transition-duration);
  }
  .jd-switch__core-inner {
    width: 100%;
    transition: all var(--jd-transition-duration);
    height: 16px;
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
    padding: 0 4px 0 18px;
    .jd-switch__core-inner-text {
      font-size: 12px;
      color: var(--jd-color-white);
      user-select: none;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
}