Files
workspace/code/g3soft-libs/packages/ui/src/checkbox/Checkbox.vue
T
2026-10-09 17:32:14 +08:00

173 lines
4.6 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, useSlots, watch } from 'vue'
import { Check, Minus } from '@lucide/vue'
import { useNamespace } from '../_composables/useNamespace'
import type { CheckboxProps } from './types'
/**
* 复选框 —— 支持选中 / 未选中 / 半选三种状态。
*
* 半选(indeterminate)是原生 DOM property,无法通过 attribute 绑定,组件内部手动同步;
* 勾选态优先,选中时会强制清除半选。
*/
defineOptions({ name: 'G3Checkbox' })
const props = withDefaults(defineProps<CheckboxProps>(), {
modelValue: false,
indeterminate: false,
disabled: false,
name: undefined,
value: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
change: [value: boolean, ev: Event]
focus: [ev: FocusEvent]
blur: [ev: FocusEvent]
}>()
const { b, e, m, is } = useNamespace('checkbox')
const slots = useSlots()
const inputRef = ref<HTMLInputElement | null>(null)
/** 选中态优先显示勾选;仅在未选中且半选时显示减号 */
const icon = computed(() => {
if (props.modelValue) return Check
if (props.indeterminate) return Minus
return null
})
const classes = computed(() => [
b(),
{
[is('checked')]: props.modelValue,
[is('indeterminate')]: props.indeterminate,
[is('disabled')]: props.disabled,
// 有文案时才需要「勾选框对齐首行」的定位;无文案保持默认居中
[is('with-label')]: !!slots.default,
},
])
function onChange(event: Event) {
const checked = (event.target as HTMLInputElement).checked
emit('update:modelValue', checked)
emit('change', checked, event)
}
function syncIndeterminate() {
if (inputRef.value) {
inputRef.value.indeterminate = props.indeterminate && !props.modelValue
}
}
onMounted(syncIndeterminate)
watch(() => [props.indeterminate, props.modelValue], syncIndeterminate)
</script>
<template>
<label :class="classes">
<input
ref="inputRef"
:class="e('input')"
type="checkbox"
:checked="modelValue"
:disabled="disabled"
:name="name"
:value="value"
@change="onChange"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
/>
<span :class="e('box')" aria-hidden="true">
<component :is="icon" v-if="icon" />
</span>
<span v-if="$slots.default" :class="e('label')">
<slot />
</span>
</label>
</template>
<style lang="scss">
@use '@g3soft/tokens/scss' as *;
.#{b('checkbox')} {
display: inline-flex;
vertical-align: middle;
align-items: center;
gap: v('space-2', 8px);
color: v('text-1', rgba(0, 0, 0, 0.88));
font-size: v('font-size', 14px);
line-height: v('line-height', 1.5);
cursor: pointer;
user-select: none;
}
/* 有文案时对齐到首行:root 顶对齐,勾选框下沉半行高差,使其中心落在
第一行文字中心;单行结果与默认 center 等价,多行富内容则对齐首行而非整块居中 */
.#{b('checkbox')}.#{is('with-label')} {
align-items: flex-start;
}
.#{b('checkbox')}.#{is('with-label')} .#{e('checkbox', 'box')} {
margin-top: calc((v('line-height', 1.5) * 1em - v('checkbox-size', 16px)) / 2);
}
.#{e('checkbox', 'input')} {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.#{e('checkbox', 'box')} {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
width: v('checkbox-size', 16px);
height: v('checkbox-size', 16px);
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-sm', 4px);
background: v('bg-container', #fff);
color: v('text-inverse', #fff);
transition:
background-color v('duration', 0.2s) v('ease', ease),
border-color v('duration', 0.2s) v('ease', ease);
svg {
width: 10px;
height: 10px;
}
}
.#{b('checkbox')}:hover:not(.#{is('disabled')}) .#{e('checkbox', 'box')} {
border-color: v('color-primary', #18181b);
}
.#{b('checkbox')}.#{is('checked')} .#{e('checkbox', 'box')},
.#{b('checkbox')}.#{is('indeterminate')} .#{e('checkbox', 'box')} {
border-color: v('color-primary', #18181b);
background: v('color-primary', #18181b);
}
.#{e('checkbox', 'input')}:focus-visible + .#{e('checkbox', 'box')} {
@include focus-ring();
}
.#{b('checkbox')}.#{is('disabled')} {
color: v('text-disabled', rgba(0, 0, 0, 0.25));
cursor: not-allowed;
}
.#{b('checkbox')}.#{is('disabled')} .#{e('checkbox', 'box')} {
border-color: v('border-color', #d9d9d9);
background: v('bg-disabled', rgba(0, 0, 0, 0.04));
color: v('text-disabled', rgba(0, 0, 0, 0.25));
}
</style>