Skip to content

Commit f72b07a

Browse files
committed
feat (core): 增加 Switch 组件
1 parent d1a3c3c commit f72b07a

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

src/ui/Switch.vue

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
<template>
2+
<div :class="wrapperClasses" class="inline-flex items-center">
3+
<!-- Switch 主体 -->
4+
<button :id="id"
5+
:disabled="disabled || loading"
6+
:class="switchClasses"
7+
:aria-checked="modelValue"
8+
:aria-describedby="describedBy"
9+
role="switch"
10+
type="button"
11+
class="relative inline-flex shrink-0 cursor-pointer transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
12+
@click="handleToggle"
13+
v-bind="$attrs">
14+
15+
<!-- 加载状态 -->
16+
<div v-if="loading" :class="loaderClasses" class="absolute inset-0 flex items-center justify-center">
17+
<div class="animate-spin rounded-full border-2 border-current border-t-transparent opacity-60"></div>
18+
</div>
19+
20+
<!-- 滑块 -->
21+
<span :class="thumbClasses"
22+
class="pointer-events-none inline-block transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out">
23+
24+
<!-- 选中状态图标 -->
25+
<component v-if="checkedIcon && modelValue && !loading"
26+
:is="checkedIcon"
27+
:class="iconClasses"
28+
class="absolute inset-0 flex items-center justify-center text-current"/>
29+
30+
<!-- 未选中状态图标 -->
31+
<component v-else-if="uncheckedIcon && !modelValue && !loading"
32+
:is="uncheckedIcon"
33+
:class="iconClasses"
34+
class="absolute inset-0 flex items-center justify-center text-current"/>
35+
</span>
36+
</button>
37+
38+
<!-- 标签文本 -->
39+
<label v-if="label || $slots.default"
40+
:for="id"
41+
:class="labelClasses"
42+
class="cursor-pointer select-none">
43+
<slot>{{ label }}</slot>
44+
</label>
45+
</div>
46+
</template>
47+
48+
<script setup lang="ts">
49+
import type { Component } from 'vue'
50+
import { computed, useAttrs } from 'vue'
51+
52+
interface Props
53+
{
54+
// v-model 绑定值
55+
modelValue?: boolean
56+
// 开关尺寸
57+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
58+
// 开关颜色主题
59+
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'secondary'
60+
// 禁用状态
61+
disabled?: boolean
62+
// 加载状态
63+
loading?: boolean
64+
// 标签文本
65+
label?: string
66+
// 标签位置
67+
labelPosition?: 'left' | 'right'
68+
// 选中状态图标
69+
checkedIcon?: Component | string
70+
// 未选中状态图标
71+
uncheckedIcon?: Component | string
72+
// 自定义选中颜色
73+
checkedColor?: string
74+
// 自定义未选中颜色
75+
uncheckedColor?: string
76+
// 组件 ID
77+
id?: string
78+
// aria-describedby
79+
describedBy?: string
80+
// 自定义类名
81+
customClass?: string | string[]
82+
}
83+
84+
const props = withDefaults(defineProps<Props>(), {
85+
modelValue: false,
86+
size: 'md',
87+
color: 'primary',
88+
disabled: false,
89+
loading: false,
90+
labelPosition: 'right'
91+
})
92+
93+
const emit = defineEmits<{
94+
'update:modelValue': [value: boolean]
95+
change: [value: boolean, event: Event]
96+
}>()
97+
98+
const attrs = useAttrs()
99+
100+
// 生成唯一 ID
101+
const switchId = computed(() => props.id || `switch-${ Math.random().toString(36).substr(2, 9) }`)
102+
103+
// 包装器样式
104+
const wrapperClasses = computed(() => [
105+
'inline-flex items-center gap-3',
106+
props.labelPosition === 'left' ? 'flex-row-reverse' : 'flex-row',
107+
...(Array.isArray(props.customClass) ? props.customClass : [props.customClass]).filter(Boolean)
108+
])
109+
110+
// 尺寸配置
111+
const sizeConfig = computed(() => {
112+
const configs = {
113+
xs: {
114+
switch: 'h-4 w-7',
115+
thumb: 'h-3 w-3',
116+
translate: 'translate-x-3',
117+
loader: 'w-2 h-2 border-[1px]',
118+
icon: 'w-2 h-2',
119+
label: 'text-xs'
120+
},
121+
sm: {
122+
switch: 'h-5 w-9',
123+
thumb: 'h-4 w-4',
124+
translate: 'translate-x-4',
125+
loader: 'w-2.5 h-2.5 border-[1px]',
126+
icon: 'w-2.5 h-2.5',
127+
label: 'text-sm'
128+
},
129+
md: {
130+
switch: 'h-6 w-11',
131+
thumb: 'h-5 w-5',
132+
translate: 'translate-x-5',
133+
loader: 'w-3 h-3 border-[1.5px]',
134+
icon: 'w-3 h-3',
135+
label: 'text-sm'
136+
},
137+
lg: {
138+
switch: 'h-7 w-12',
139+
thumb: 'h-6 w-6',
140+
translate: 'translate-x-5',
141+
loader: 'w-3.5 h-3.5 border-[1.5px]',
142+
icon: 'w-3.5 h-3.5',
143+
label: 'text-base'
144+
},
145+
xl: {
146+
switch: 'h-8 w-14',
147+
thumb: 'h-7 w-7',
148+
translate: 'translate-x-6',
149+
loader: 'w-4 h-4 border-2',
150+
icon: 'w-4 h-4',
151+
label: 'text-base'
152+
}
153+
}
154+
return configs[props.size]
155+
})
156+
157+
// 颜色配置
158+
const colorConfig = computed(() => {
159+
if (props.checkedColor && props.uncheckedColor) {
160+
return {
161+
checked: props.checkedColor,
162+
unchecked: props.uncheckedColor,
163+
focus: 'ring-blue-500'
164+
}
165+
}
166+
167+
const configs = {
168+
primary: {
169+
checked: 'bg-blue-600',
170+
unchecked: 'bg-gray-200',
171+
focus: 'focus:ring-blue-500'
172+
},
173+
success: {
174+
checked: 'bg-green-600',
175+
unchecked: 'bg-gray-200',
176+
focus: 'focus:ring-green-500'
177+
},
178+
warning: {
179+
checked: 'bg-yellow-500',
180+
unchecked: 'bg-gray-200',
181+
focus: 'focus:ring-yellow-500'
182+
},
183+
danger: {
184+
checked: 'bg-red-600',
185+
unchecked: 'bg-gray-200',
186+
focus: 'focus:ring-red-500'
187+
},
188+
info: {
189+
checked: 'bg-cyan-600',
190+
unchecked: 'bg-gray-200',
191+
focus: 'focus:ring-cyan-500'
192+
},
193+
secondary: {
194+
checked: 'bg-gray-600',
195+
unchecked: 'bg-gray-200',
196+
focus: 'focus:ring-gray-500'
197+
}
198+
}
199+
return configs[props.color]
200+
})
201+
202+
// Switch 主体样式
203+
const switchClasses = computed(() => [
204+
sizeConfig.value.switch,
205+
props.modelValue ? colorConfig.value.checked : colorConfig.value.unchecked,
206+
colorConfig.value.focus,
207+
'rounded-full border-2 border-transparent'
208+
])
209+
210+
// 滑块样式
211+
const thumbClasses = computed(() => [
212+
sizeConfig.value.thumb,
213+
props.modelValue ? sizeConfig.value.translate : 'translate-x-0'
214+
])
215+
216+
// 加载器样式
217+
const loaderClasses = computed(() => [
218+
sizeConfig.value.loader
219+
])
220+
221+
// 图标样式
222+
const iconClasses = computed(() => [
223+
sizeConfig.value.icon,
224+
props.modelValue
225+
? (props.color === 'warning' ? 'text-yellow-600' : 'text-white')
226+
: 'text-gray-400'
227+
])
228+
229+
// 标签样式
230+
const labelClasses = computed(() => [
231+
sizeConfig.value.label,
232+
props.disabled ? 'text-gray-400' : 'text-gray-700',
233+
'font-medium'
234+
])
235+
236+
// 切换处理
237+
const handleToggle = (event: Event) => {
238+
if (props.disabled || props.loading) {
239+
return
240+
}
241+
242+
const newValue = !props.modelValue
243+
emit('update:modelValue', newValue)
244+
emit('change', newValue, event)
245+
}
246+
</script>

0 commit comments

Comments
 (0)