一、使用效果
<template>
<QqThreeSwitch v-model="value" />
</template>
<script setup>
import SqThreeSwitch from './components/SqThreeSwitch.vue'
import { ref } from 'vue'
const value = ref(0)
</script>
二、SqThreeSwitch.vue源码
<template>
<div class="sq-three-switch">
<button class="focus-btn" :style="focusBtnStyle" @click="handleBtnClick">
按下空格切换主题, 当前选择:{{ selectedOption }}
</button>
<div v-show="isMouseEnter" class="tooltip" tabindex="-1" :style="tooltipStyle">
<div class="tip-text">{{ tooltipText }}</div>
<svg class="tip-arrow" width="16px" height="8px" :style="tipArrowStyle">
<polygon points="0,-1 8,7 16,-1" />
</svg>
</div>
<div ref="selectedOptionRef" class="selected-option">
<span>{{ selectedOption }}</span>
</div>
<div
ref="controlRef"
class="control plane-border"
@click="handleClick"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousemove="debouncedHandleMouseMove"
></div>
<div class="plane"></div>
<div class="badge-dots">
<div
v-for="(dot, index) in [0, 1, 2]"
:key="index"
class="dot"
:class="{ 'dot-animate': dotAnimateFlag }"
@animationend="handleAnimationEnd"
></div>
</div>
<div class="handle" :style="handleStyle">
<slot v-if="modelValue === 0" name="left-action"></slot>
<slot v-if="modelValue === 1" name="middle-action"></slot>
<slot v-if="modelValue === 2" name="right-action"></slot>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
options: {
type: Array,
default: () => ['选项A', '选项B', '选项C']
}
})
const emit = defineEmits(['update:modelValue'])
const selectedOptionRef = ref(null)
const focusBtnStyle = ref({})
nextTick(() => {
focusBtnStyle.value = {
width: `${selectedOptionRef.value.getBoundingClientRect().width + 50}px`
}
})
watch(
() => props.modelValue,
() => {
nextTick(() => {
focusBtnStyle.value = {
width: `${selectedOptionRef.value.getBoundingClientRect().width + 50}px`
}
})
}
)
const controlRef = ref(null)
const haveTooltipSpace = ref(false)
const tipArrowStyle = computed(() => {
return {
transform: haveTooltipSpace.value ? '' : 'translateY(-26px) rotate(180deg)'
}
})
function checkTooltipSpace(deadline) {
if (deadline.timeRemaining() > 0) {
const rect = controlRef.value?.getBoundingClientRect()
if (rect) {
haveTooltipSpace.value = rect.top >= 20
}
}
}
const debouncedCheckTooltipSpace = useDebounceFn(
() => requestIdleCallback(checkTooltipSpace, { timeout: 200 }),
200
)
let intervalId
onMounted(() => {
debouncedCheckTooltipSpace()
window.addEventListener('scroll', debouncedCheckTooltipSpace)
window.addEventListener('resize', debouncedCheckTooltipSpace)
intervalId = setInterval(debouncedCheckTooltipSpace, 2000)
console.log('作者主页: https://blog.csdn.net/qq_39124701')
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', debouncedCheckTooltipSpace)
window.removeEventListener('resize', debouncedCheckTooltipSpace)
if (intervalId !== null) {
clearInterval(intervalId)
}
})
const isMouseEnter = ref(false)
const tooltipText = ref(props.options[props.modelValue])
const tooltipStyle = ref({
left: props.modelValue === 0 ? '0px' : props.modelValue === 1 ? '20px' : '40px',
top: haveTooltipSpace.value ? '0px' : '54px'
})
const selectedOption = computed(() => {
return props.options[props.modelValue]
})
const dotAnimateFlag = ref(false)
const handleStyle = ref({
left:
props.modelValue === 0
? '2px'
: props.modelValue === 1
? 'calc(50% - 9px)'
: 'calc(100% - 19px)'
})
watch(
() => props.modelValue,
(newValue) => {
handleStyle.value = {
left: newValue === 0 ? '2px' : newValue === 1 ? 'calc(50% - 9px)' : 'calc(100% - 19px)'
}
}
)
function handleClick( event) {
const eventTarget = event.target
const rect = eventTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
const oneThirdWidth = rect.width / 3
if (clickX < oneThirdWidth) {
if (props.modelValue === 0) {
dotAnimateFlag.value = true
}
emit('update:modelValue', 0)
} else if (clickX > oneThirdWidth * 2) {
if (props.modelValue === 2) {
dotAnimateFlag.value = true
}
emit('update:modelValue', 2)
} else {
if (props.modelValue === 1) {
dotAnimateFlag.value = true
}
emit('update:modelValue', 1)
}
}
function handleBtnClick() {
if (props.modelValue === 0) {
emit('update:modelValue', 1)
} else if (props.modelValue === 1) {
emit('update:modelValue', 2)
} else if (props.modelValue === 2) {
emit('update:modelValue', 0)
}
}
function handleMouseEnter() {
isMouseEnter.value = true
}
function handleMouseLeave() {
isMouseEnter.value = false
}
const debouncedHandleMouseMove = useDebounceFn(handleMouseMove, 40)
function handleMouseMove(event) {
if (!isMouseEnter.value) {
return
}
const rect = event.target.getBoundingClientRect()
const clickX = event.clientX - rect.left
const oneThirdWidth = rect.width / 3
if (clickX < oneThirdWidth) {
tooltipText.value = props.options[0]
tooltipStyle.value = { left: '0px', top: haveTooltipSpace.value ? '0px' : '54px' }
} else if (clickX > oneThirdWidth * 2) {
tooltipText.value = props.options[2]
tooltipStyle.value = { left: 'calc(100% - 21px)', top: haveTooltipSpace.value ? '0px' : '54px' }
} else {
tooltipText.value = props.options[1]
tooltipStyle.value = { left: 'calc(50% - 11px)', top: haveTooltipSpace.value ? '0px' : '54px' }
}
}
function handleAnimationEnd() {
dotAnimateFlag.value = false
}
</script>
<style scoped>
.sq-three-switch {
position: relative;
width: 60px;
height: 20px;
}
.sq-three-switch > * {
position: absolute;
}
.sq-three-switch > .plane,
.sq-three-switch > .badge-dots,
.sq-three-switch > .handle {
pointer-events: none;
}
.sq-three-switch > .focus-btn {
height: 100%;
border-radius: 10px;
border: 0;
outline-offset: 1px;
font-size: 0;
}
.sq-three-switch > .focus-btn:focus {
outline: 2px solid #409eff;
}
.sq-three-switch > .tooltip {
z-index: 1;
transform: translateY(-27px);
white-space: nowrap;
background-color: #e6e6e6;
border: 1px solid gray;
border-radius: 4px;
padding: 1px 11px;
transition: left 0.2s;
}
.sq-three-switch > .tooltip > .tip-text {
font-size: 12px;
color: black;
}
.sq-three-switch > .tooltip > .tip-arrow {
position: absolute;
top: 18px;
left: 1px;
}
.sq-three-switch > .tooltip > .tip-arrow polygon {
fill: #e6e6e6;
stroke: gray;
stroke-width: 1;
}
.sq-three-switch > .selected-option {
height: 100%;
background: linear-gradient(to right, #a8d4ff, #409eff 16px);
border-radius: 10px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
transform: translateX(50px);
display: flex;
justify-content: center;
font-size: 14px;
color: white;
white-space: nowrap;
}
.sq-three-switch > .selected-option > span {
padding-left: 16px;
padding-right: 10px;
user-select: none;
}
.sq-three-switch > .control {
width: 100%;
height: 20px;
border-radius: 10px;
background: #409eff;
cursor: pointer;
}
.sq-three-switch > .plane {
top: 1px;
left: 1px;
width: calc(100% - 2px);
height: 18px;
border-radius: 10px;
background: #409eff;
}
.sq-three-switch > .badge-dots > .dot {
position: absolute;
top: 8px;
left: 8px;
width: 4px;
height: 4px;
border-radius: 100%;
transition: all 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
background-color: white;
}
.sq-three-switch > .badge-dots > .dot:nth-child(2) {
left: 27px;
}
.sq-three-switch > .badge-dots > .dot:nth-child(3) {
left: 47px;
}
.dot-animate {
animation: dotAnimation 0.3s;
}
@keyframes dotAnimation {
0% {
background-color: white;
}
25% {
background-color: black;
}
50% {
background-color: white;
}
75% {
background-color: black;
}
100% {
background-color: white;
}
}
.sq-three-switch > .handle {
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 100%;
transition: all 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
background-color: white;
}
html.dark .sq-three-switch > .tooltip {
background-color: #303133;
}
html.dark .sq-three-switch > .tooltip > .tip-arrow polygon {
fill: #303133;
}
html.dark .sq-three-switch > .tooltip > .tip-text {
color: white;
}
</style>