This commit is contained in:
oneao committed 2026-10-10 17:33:59 +08:00
1 parent 5f28a10d89
commit 32d423b759
59 files changed
+5101 -1098

No files matched your search

@@ -0,0 +1,121 @@
/**
* HSV / HSL 与 hex 的互转,供 ColorPicker 的取色面板使用。
*
* 面板用 HSV(色相 + 饱和度 + 明度)而不是 HSL:HSV 的 S/V 平面是标准的
* 「右上角纯色 → 左白 → 下黑」二维取色区,与设计工具一致;
* HSL 的 L 轴两端都是极端值,做二维面板会得到奇怪的可视区域。
*/
import { parseColor, rgbToHex, type RGB } from './color'
/** 色相 0..360,饱和度 0..1,明度 0..1 */
export interface HSV {
h: number
s: number
v: number
}
export function rgbToHsv({ r, g, b }: RGB): HSV {
const rn = r / 255
const gn = g / 255
const bn = b / 255
const max = Math.max(rn, gn, bn)
const min = Math.min(rn, gn, bn)
const d = max - min
let h = 0
if (d !== 0) {
if (max === rn) h = ((gn - bn) / d) % 6
else if (max === gn) h = (bn - rn) / d + 2
else h = (rn - gn) / d + 4
h *= 60
if (h < 0) h += 360
}
return { h, s: max === 0 ? 0 : d / max, v: max }
}
export function hsvToRgb({ h, s, v }: HSV): RGB {
const c = v * s
const hp = (((h % 360) + 360) % 360) / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
let rgb: [number, number, number]
if (hp < 1) rgb = [c, x, 0]
else if (hp < 2) rgb = [x, c, 0]
else if (hp < 3) rgb = [0, c, x]
else if (hp < 4) rgb = [0, x, c]
else if (hp < 5) rgb = [x, 0, c]
else rgb = [c, 0, x]
const m = v - c
return {
r: Math.round((rgb[0] + m) * 255),
g: Math.round((rgb[1] + m) * 255),
b: Math.round((rgb[2] + m) * 255),
}
}
/** hex → HSV;非 hex 输入回退为黑色。 */
export function hexToHsv(hex: string): HSV {
const rgb = parseColor(hex)
return rgb ? rgbToHsv(rgb) : { h: 0, s: 0, v: 0 }
}
/** HSV → hex(#rrggbb)。 */
export function hsvToHex(hsv: HSV): string {
return rgbToHex(hsvToRgb(hsv))
}
/** 色相(0..360)→ hex,固定高饱和高明度,用于色相条。 */
export function hueToHex(hue: number): string {
return hsvToHex({ h: hue, s: 1, v: 1 })
}
/** 判断字符串是否是合法的 hex 颜色。 */
export function isHexColor(value: string): boolean {
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim())
}
/**
* 把任意 CSS 颜色字符串归一为 #rrggbb。
* 支持 #rgb / #rrggbb / rgb() / rgba() / 'H S% L%' 三段式(本仓库主题色格式)。
* 无法识别时返回 null。
*/
export function normalizeToHex(input: string): string | null {
const value = input.trim()
if (!value) return null
// 本项目主题色用 'H S% L%' 三段式,parseColor 不认,这里先补成 hsl()
const parts = value.match(/^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/)
if (parts) {
const rgb = parseColor(`hsl(${parts[1]} ${parts[2]}% ${parts[3]}%)`)
if (rgb) return rgbToHex(rgb)
return hslToRgbHex(Number(parts[1]), Number(parts[2]) / 100, Number(parts[3]) / 100)
}
const rgb = parseColor(value)
return rgb ? rgbToHex(rgb) : null
}
/** HSL → hex;parseColor 不处理 hsl(),这里单独实现。 */
function hslToRgbHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s
const hp = (((h % 360) + 360) % 360) / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
let rgb: [number, number, number]
if (hp < 1) rgb = [c, x, 0]
else if (hp < 2) rgb = [x, c, 0]
else if (hp < 3) rgb = [0, c, x]
else if (hp < 4) rgb = [0, x, c]
else if (hp < 5) rgb = [x, 0, c]
else rgb = [c, 0, x]
const m = l - c / 2
return rgbToHex({
r: Math.round((rgb[0] + m) * 255),
g: Math.round((rgb[1] + m) * 255),
b: Math.round((rgb[2] + m) * 255),
})
}
@@ -0,0 +1,639 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Check, Pipette } from '@lucide/vue'
import { G3Popover } from '../popover'
import { useNamespace } from '../_composables/useNamespace'
import { useLocale } from '../_composables/useLocale'
import { hexToHsv, hsvToHex, normalizeToHex, type HSV } from '../_utils/color-convert'
import type { ColorPickerPresetGroup, ColorPickerProps, ColorPickerValue } from './types'
/**
* 颜色选择器 —— 触发器色块 + 浮层取色面板。
*
* 值统一为 `#rrggbb`(受控 v-model)。面板由三部分构成:
* 1. 饱和度 / 明度二维取色区(拖拽定位)
* 2. 色相条
* 3. hex 输入框
* 可选预设色板(presets),点一下即选中。
*/
defineOptions({ name: 'G3ColorPicker' })
/**
* 内置预设色板:只放最常用的一组,点一下即可选中,不必开取色面板。
*
* 色值取 Ant Design 的 6 号色(主色档),是业务系统里出现频率最高的那批颜色。
* 恰好 8 个,铺满预设区的 8 列网格(见 .g3-color-picker__preset-list)。
*
* 需要更多分组时由使用方通过 `presets` 传自定义数组,组件不预置更多。
*/
const BUILT_IN_PRESETS: ColorPickerPresetGroup[] = [
{
label: '常用',
colors: [
'#165dff',
'#2f54eb',
'#00b42a',
'#f5222d',
'#fa8c16',
'#fadb14',
'#13c2c2',
'#722ed1',
],
},
]
const props = withDefaults(defineProps<ColorPickerProps>(), {
modelValue: undefined,
defaultValue: '#165dff',
disabled: false,
presets: true,
placeholder: '',
popupContainer: undefined,
size: 'default',
})
const emit = defineEmits<{
'update:modelValue': [value: ColorPickerValue]
change: [value: ColorPickerValue]
}>()
const { b, e, m, is } = useNamespace('color-picker')
const locale = useLocale()
const open = ref(false)
const innerValue = ref(props.modelValue ?? props.defaultValue)
/** 受控优先,否则用内部值 */
const currentValue = computed(() =>
props.modelValue !== undefined ? props.modelValue : innerValue.value,
)
/** 当前值归一成 hex,供面板与色块展示;无法识别时退回默认色。 */
const currentHex = computed(() => normalizeToHex(currentValue.value) ?? '#000000')
const hsv = ref<HSV>(hexToHsv(currentHex.value))
/** hex 输入框的草稿文本:允许用户边输边改,不合法时不写回 */
const hexInput = ref(currentHex.value)
/** 预设分组归一:true → 内置,数组 → 自定义,false → 隐藏 */
const presetGroups = computed<ColorPickerPresetGroup[]>(() => {
if (props.presets === false) return []
if (props.presets === true) return BUILT_IN_PRESETS
return props.presets
})
const hasPresets = computed(() => presetGroups.value.some((group) => group.colors.length > 0))
const triggerHex = computed(() => (currentHex.value === '#000000' && !currentValue.value ? '' : currentHex.value))
const classes = computed(() => [
b(),
m(`size-${props.size}`),
{
[is('disabled')]: props.disabled,
[is('open')]: open.value,
},
])
/* ---------- 同步:外部值变化 → 面板状态 ---------- */
// 只在「外部值与面板当前值确实不同」时回写,避免拖拽面板时被自己的 emit 打断
watch(currentHex, (hex) => {
if (hex === hsvToHex(hsv.value)) return
hsv.value = hexToHsv(hex)
hexInput.value = hex
})
watch(open, (value) => {
if (value) hexInput.value = currentHex.value
})
/* ---------- 提交 ---------- */
function commit(hex: string): void {
if (props.disabled) return
if (props.modelValue === undefined) innerValue.value = hex
emit('update:modelValue', hex)
emit('change', hex)
}
function selectPreset(hex: string): void {
const normalized = normalizeToHex(hex)
if (!normalized) return
hsv.value = hexToHsv(normalized)
hexInput.value = normalized
commit(normalized)
}
/* ---------- 二维取色区拖拽 ---------- */
const areaRef = ref<HTMLElement | null>(null)
/** 取色区光标的百分比位置:x = 饱和度,y = 明度(上 100 下 0) */
const areaCursorStyle = computed(() => ({
left: `${hsv.value.s * 100}%`,
top: `${(1 - hsv.value.v) * 100}%`,
}))
/** 取色区背景:底色为纯色相,叠白→透明(横向)与黑(纵向) */
const areaBackground = computed(() => {
const pure = hsvToHex({ h: hsv.value.h, s: 1, v: 1 })
return {
backgroundColor: pure,
backgroundImage:
'linear-gradient(to right, #fff, rgba(255, 255, 255, 0)), linear-gradient(to top, #000, rgba(0, 0, 0, 0))',
}
})
function pickFromArea(event: MouseEvent | TouchEvent): void {
const el = areaRef.value
if (!el) return
const rect = el.getBoundingClientRect()
if (!rect.width || !rect.height) return
const point = 'touches' in event ? event.touches[0] : event
if (!point) return
const s = Math.min(1, Math.max(0, (point.clientX - rect.left) / rect.width))
const v = 1 - Math.min(1, Math.max(0, (point.clientY - rect.top) / rect.height))
hsv.value = { ...hsv.value, s, v }
const hex = hsvToHex(hsv.value)
hexInput.value = hex
commit(hex)
}
/** 取色区拖拽:按下即取色,移动持续取色,抬起解绑 */
function startAreaDrag(event: MouseEvent | TouchEvent): void {
if (props.disabled) return
event.preventDefault()
pickFromArea(event)
const onMove = (moveEvent: MouseEvent | TouchEvent) => pickFromArea(moveEvent)
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onUp)
}
/* ---------- 色相条拖拽 ---------- */
const hueRef = ref<HTMLElement | null>(null)
const hueCursorStyle = computed(() => ({ left: `${(hsv.value.h / 360) * 100}%` }))
function pickFromHue(event: MouseEvent | TouchEvent): void {
const el = hueRef.value
if (!el) return
const rect = el.getBoundingClientRect()
if (!rect.width) return
const point = 'touches' in event ? event.touches[0] : event
if (!point) return
const ratio = Math.min(1, Math.max(0, (point.clientX - rect.left) / rect.width))
const hue = ratio * 360
hsv.value = { ...hsv.value, h: hue }
const hex = hsvToHex(hsv.value)
hexInput.value = hex
commit(hex)
}
function startHueDrag(event: MouseEvent | TouchEvent): void {
if (props.disabled) return
event.preventDefault()
pickFromHue(event)
const onMove = (moveEvent: MouseEvent | TouchEvent) => pickFromHue(moveEvent)
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onUp)
}
/* ---------- hex 输入 ---------- */
function onHexInput(value: string | number): void {
const text = String(value)
hexInput.value = text
const normalized = normalizeToHex(text)
if (!normalized) return
hsv.value = hexToHsv(normalized)
commit(normalized)
}
function onHexBlur(): void {
// 失焦时把非法草稿还原成当前值,避免输入框停在半截内容
hexInput.value = currentHex.value
}
/* ---------- 原生取色器兜底 ---------- */
const nativeInputRef = ref<HTMLInputElement | null>(null)
/** 浏览器不支持 HTMLInputElement.showPicker 时回退到 click() */
function openNativePicker(): void {
if (props.disabled) return
const input = nativeInputRef.value
if (!input) return
// showPicker 需要用户手势内同步调用;部分浏览器未实现,故做能力检测
if (typeof input.showPicker === 'function') {
try {
input.showPicker()
return
} catch {
// 非用户手势等情况下会抛错,退回 click
}
}
input.click()
}
function onNativeChange(event: Event): void {
const hex = (event.target as HTMLInputElement).value
const normalized = normalizeToHex(hex)
if (!normalized) return
hsv.value = hexToHsv(normalized)
hexInput.value = normalized
commit(normalized)
}
/** 取色器已支持的浏览器才显示吸管按钮(能力检测一次即可,无需响应式生命周期) */
const canUseNativePicker =
typeof HTMLInputElement !== 'undefined' && 'showPicker' in HTMLInputElement.prototype
defineExpose({ open, close: () => (open.value = false) })
</script>
<template>
<G3Popover
v-model:open="open"
trigger="click"
placement="bottom-start"
:disabled="disabled"
:popup-container="popupContainer"
:content-class="e('panel-wrap')"
>
<template #trigger>
<button
type="button"
:class="classes"
:disabled="disabled"
:aria-label="placeholder || locale.common.selectPlaceholder"
>
<span :class="e('trigger')" :style="{ background: triggerHex || 'transparent' }">
<span v-if="!triggerHex" :class="e('trigger-empty')" />
</span>
</button>
</template>
<template #content>
<div :class="e('panel')">
<!-- 饱和度 / 明度取色区 -->
<div
ref="areaRef"
:class="e('area')"
:style="areaBackground"
role="slider"
:aria-valuenow="Math.round(hsv.s * 100)"
aria-label="饱和度与明度"
tabindex="0"
@mousedown="startAreaDrag"
@touchstart="startAreaDrag"
>
<span :class="e('area-cursor')" :style="areaCursorStyle" />
</div>
<!-- 色相条 -->
<div
ref="hueRef"
:class="e('hue')"
role="slider"
:aria-valuenow="Math.round(hsv.h)"
aria-valuemin="0"
aria-valuemax="360"
aria-label="色相"
tabindex="0"
@mousedown="startHueDrag"
@touchstart="startHueDrag"
>
<span :class="e('hue-cursor')" :style="hueCursorStyle" />
</div>
<!-- 当前色 + hex 输入 + 原生取色器 -->
<div :class="e('field')">
<span :class="e('preview')" :style="{ background: currentHex }" />
<input
:class="e('hex-input')"
:value="hexInput"
spellcheck="false"
:disabled="disabled"
aria-label="十六进制颜色值"
@input="onHexInput(($event.target as HTMLInputElement).value)"
@blur="onHexBlur"
/>
<button
v-if="canUseNativePicker"
type="button"
:class="e('pipette')"
:disabled="disabled"
aria-label="打开系统取色器"
@click="openNativePicker"
>
<Pipette :size="14" />
</button>
<input
ref="nativeInputRef"
:class="e('native')"
type="color"
:value="currentHex"
tabindex="-1"
aria-hidden="true"
@input="onNativeChange"
/>
</div>
<!-- 预设色板 -->
<div v-if="hasPresets" :class="e('presets')">
<div v-for="(group, index) in presetGroups" :key="index" :class="e('preset-group')">
<div v-if="group.label" :class="e('preset-label')">{{ group.label }}</div>
<div :class="e('preset-list')">
<button
v-for="color in group.colors"
:key="color"
type="button"
:class="e('preset')"
:style="{ background: color }"
:disabled="disabled"
:aria-label="color"
@click="selectPreset(color)"
>
<Check v-if="normalizeToHex(color) === currentHex" :size="12" :class="e('preset-check')" />
</button>
</div>
</div>
</div>
</div>
</template>
</G3Popover>
</template>
<style lang="scss">
@use '@g3soft/tokens/scss' as *;
.#{b('color-picker')} {
display: inline-flex;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
&.#{is('disabled')} {
cursor: not-allowed;
opacity: 0.5;
}
}
.#{e('color-picker', 'trigger')} {
position: relative;
display: block;
box-sizing: border-box;
width: 28px;
height: 28px;
border-radius: v('radius-sm', 4px);
/* 棋盘格背景:让透明/浅色也能看清边界 */
background-image: linear-gradient(45deg, #d9d9d9 25%, transparent 25%),
linear-gradient(-45deg, #d9d9d9 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #d9d9d9 75%),
linear-gradient(-45deg, transparent 75%, #d9d9d9 75%);
background-size: 8px 8px;
background-position:
0 0,
0 4px,
4px -4px,
-4px 0;
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
}
.#{e('color-picker', 'trigger-empty')} {
display: block;
width: 100%;
height: 100%;
}
.#{m('color-picker', 'size-small')} .#{e('color-picker', 'trigger')} {
width: 20px;
height: 20px;
}
.#{m('color-picker', 'size-large')} .#{e('color-picker', 'trigger')} {
width: 36px;
height: 36px;
}
/* ---------- 面板 ---------- */
/* 作用在 Popover 的内容盒上:Popover 默认给 8px,取色面板需要更宽松的留白 */
.#{e('color-picker', 'panel-wrap')} {
padding: v('space-3', 12px);
}
.#{e('color-picker', 'panel')} {
width: 220px;
}
.#{e('color-picker', 'area')} {
position: relative;
height: 140px;
border-radius: v('radius-sm', 4px);
cursor: crosshair;
}
.#{e('color-picker', 'area-cursor')} {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgb(0 0 0 / 30%);
transform: translate(-50%, -50%);
pointer-events: none;
}
.#{e('color-picker', 'hue')} {
position: relative;
height: 10px;
margin-top: v('space-3', 12px);
border-radius: 999px;
cursor: pointer;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.#{e('color-picker', 'hue-cursor')} {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgb(0 0 0 / 30%);
transform: translate(-50%, -50%);
pointer-events: none;
}
/* ---------- 当前色 / hex / 吸管 ---------- */
.#{e('color-picker', 'field')} {
position: relative;
display: flex;
align-items: center;
gap: v('space-2', 8px);
margin-top: v('space-3', 12px);
}
.#{e('color-picker', 'preview')} {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: v('radius-sm', 4px);
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
}
.#{e('color-picker', 'hex-input')} {
flex: 1;
min-width: 0;
box-sizing: border-box;
height: 28px;
padding: 0 v('space-2', 8px);
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-sm', 4px);
background: v('bg-container', #fff);
color: v('text-1', rgba(0, 0, 0, 0.88));
/* v() 只接受 (名称, 兜底值) 两个参数,多段字体栈要整体作为一个兜底值传 */
font-family: v('font-family-mono', 'ui-monospace, monospace');
font-size: v('font-size-sm', 12px);
outline: none;
&:focus {
border-color: v('color-primary', #165dff);
}
&:disabled {
background: v('bg-disabled', rgba(0, 0, 0, 0.04));
cursor: not-allowed;
}
}
.#{e('color-picker', 'pipette')} {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-sm', 4px);
background: v('bg-container', #fff);
color: v('text-2', rgba(0, 0, 0, 0.65));
cursor: pointer;
&:hover:not(:disabled) {
color: v('color-primary', #165dff);
border-color: v('color-primary', #165dff);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
/* 原生色板输入:不可见但可编程触发,用于「系统取色器」入口 */
.#{e('color-picker', 'native')} {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
/* ---------- 预设色板 ---------- */
.#{e('color-picker', 'presets')} {
margin-top: v('space-3', 12px);
padding-top: v('space-3', 12px);
border-top: 1px solid v('border-color', #d9d9d9);
}
.#{e('color-picker', 'preset-group')} + .#{e('color-picker', 'preset-group')} {
margin-top: v('space-2', 8px);
}
.#{e('color-picker', 'preset-label')} {
margin-bottom: v('space-2', 8px);
color: v('text-3', rgba(0, 0, 0, 0.45));
font-size: v('font-size-sm', 12px);
line-height: 1;
}
.#{e('color-picker', 'preset-list')} {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: v('space-2', 8px);
}
.#{e('color-picker', 'preset')} {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
width: 100%;
padding: 0;
border: 0;
border-radius: v('radius-sm', 4px);
cursor: pointer;
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 10%);
transition: transform v('duration-fast', 0.1s) v('ease', ease);
&:hover:not(:disabled) {
transform: scale(1.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.#{e('color-picker', 'preset-check')} {
color: #fff;
filter: drop-shadow(0 0 1px rgb(0 0 0 / 60%));
pointer-events: none;
}
</style>
@@ -0,0 +1,2 @@
export { default as G3ColorPicker } from './ColorPicker.vue'
export type { ColorPickerProps, ColorPickerValue, ColorPickerPresetGroup } from './types'
@@ -0,0 +1,30 @@
/** 颜色选择器的值:统一为 hex 字符串(#rrggbb)。 */
export type ColorPickerValue = string
/** 预设色板分组。 */
export interface ColorPickerPresetGroup {
/** 分组标题(可选) */
label?: string
/** 该组的颜色列表,hex 字符串 */
colors: string[]
}
export interface ColorPickerProps {
/** 双向绑定的颜色值;受控时以 hex 为准 */
modelValue?: ColorPickerValue
/** 非受控初始值 */
defaultValue?: ColorPickerValue
/** 禁用态 */
disabled?: boolean
/**
* 预设色板。传 `true` 用内置色板(对齐 vben 的 BUILT_IN_THEME_PRESETS),
* 传分组数组用自定义色板,不传则不显示预设区。
*/
presets?: boolean | ColorPickerPresetGroup[]
/** 无值时的占位文案 */
placeholder?: string
/** 浮层容器;默认 body */
popupContainer?: string | HTMLElement
/** 触发器尺寸 */
size?: 'small' | 'default' | 'large'
}
@@ -348,7 +348,7 @@ onBeforeUnmount(() => {
gap: v('space-2', 8px);
/* 同上:这里是 <header>,宿主的 `header { margin / padding / border }` 会命中 */
margin: 0;
padding: v('space-3', 12px) v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
border: 0;
}
@@ -398,7 +398,7 @@ onBeforeUnmount(() => {
.#{e('drawer', 'body')} {
flex: 1;
min-height: 0;
padding: v('space-3', 12px) v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
overflow: auto;
}
@@ -409,7 +409,7 @@ onBeforeUnmount(() => {
gap: v('space-2', 8px);
/* 同上:这里是 <footer>,宿主的 `footer { margin / padding / border }` 会命中 */
margin: 0;
padding: v('space-2', 8px) v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
border: 0;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, isVNode, nextTick, provide, ref, useSlots, type VNode } from 'vue'
import { computed, isVNode, nextTick, provide, ref, useSlots, watch, type VNode } from 'vue'
import { useNamespace } from '../_composables/useNamespace'
import G3Popover from '../popover/Popover.vue'
import G3DropdownItem from './DropdownItem.vue'
@@ -40,6 +40,14 @@ const menuEl = ref<HTMLElement | null>(null)
const internalOpen = ref(props.defaultOpen)
const activeIndex = ref(-1)
/**
* 上一次已知的打开状态,用于在 onOpenChange 里识别「真正的打开」。
*
* hover 触发下浮层会重复上报 open=true(触发器 mouseleave 之后浮层 mouseenter
* 又走一次 setOpen(true)),靠它区分「首次打开」与「重复上报」,避免后者误清高亮。
*/
const isOpenState = ref(props.defaultOpen)
const isOpen = computed(() => (props.open !== undefined ? props.open : internalOpen.value))
/**
@@ -102,12 +110,40 @@ function collectItems(): DropdownItemData[] {
return items
}
/**
* 声明式用法检测:默认插槽里是否出现过分量 G3DropdownItem。
*
* ⚠️ 必须是**稳定**信号,不能依赖某次 collectItems() 的返回长度。
* 曾经是 `collected.length ? collected : undefined`,于是 resolvedItems 会在
* 数组与 undefined 之间摆动,模板里的 `v-if="resolvedItems != null"` 跟着变假变真
* → G3DropdownMenu 整个被卸载重建 → 菜单项行全部换新节点。
*
* 后果(真实报障):hover 触发时鼠标就停在某一项上,行被重建后新节点不会再补发
* mouseenter,于是该行灰底「闪一下后消失」;click 触发时鼠标在触发器上,所以看不出来。
*
* 用「声明过就为真」的判断:一旦在插槽里见到 G3DropdownItem 就永久走数组渲染,
* 中间某次收集为空也不会退回逃生舱(那些空窗期本来就不该渲染出两套 DOM)。
*/
const usesDeclarativeItems = ref(false)
watch(
() => slots.default?.(),
(nodes) => {
if (usesDeclarativeItems.value) return
if ((nodes as VNode[] | undefined)?.some((vnode) => vnode?.type === G3DropdownItem)) {
usesDeclarativeItems.value = true
}
},
{ immediate: true },
)
const resolvedItems = computed<DropdownItemData[] | undefined>(() => {
// 传了 items:数组渲染(内容即 props.items,引用由调用方负责稳定)
if (props.items != null) return props.items
// 未传 items 时,仅当默认插槽确实声明了 G3DropdownItem 才启用数组渲染;
// 否则保留 undefined,回退到 #menu 逃生舱(避免空数组误吞 #menu 内容)
const collected = collectItems()
return collected.length ? collected : undefined
// 插槽里有 G3DropdownItem:数组渲染(空数组也是有效的「没有项」)
if (usesDeclarativeItems.value) return collectItems()
// 都没有:返回 undefined,回退到 #menu 逃生舱
return undefined
})
/** 可键盘导航的项索引(跳过分隔线 / 标签 / 禁用项) */
@@ -122,12 +158,31 @@ function onOpenChange(value: boolean) {
if (props.open === undefined) internalOpen.value = value
emit('update:open', value)
emit('openChange', value)
if (value) {
activeIndex.value = -1
nextTick(() => menuEl.value?.focus())
} else {
activeIndex.value = -1
/**
* 只在「真正的打开/关闭」时清高亮。
*
* ⚠️ 必须判重,不能见 true 就清。hover 触发的 popover 有个特性:
* 触发器与浮层是两棵 DOM(浮层 Teleport 到 body),指针从触发器移进菜单时
* 会先触发触发器的 mouseleave、再触发浮层自己的 mouseenter,
* 而浮层 mouseenter 会再走一次 setOpen(true) → 这里就会**重复收到一次 true**。
*
* 曾经无条件 `activeIndex.value = -1`,于是:
* hover 触发器打开 → 指针移到菜单项(高亮出现)→ 指针进浮层触发重复 open
* → 高亮被清成 -1 → 指针已停在原处、不会再派发 mouseenter → 高亮永久不恢复。
* 表现就是用户看到的「灰底闪一下然后消失」(约 100ms 后,即 mouseEnterDelay)。
* click 触发时鼠标在触发器上,不会产生这次重复 open,所以只有 hover 模式复现。
*/
const opening = value && !isOpenState.value
isOpenState.value = value
if (!opening) {
if (!value) activeIndex.value = -1
return
}
activeIndex.value = -1
nextTick(() => menuEl.value?.focus())
}
function selectItem(item: DropdownItemData) {
@@ -275,6 +330,22 @@ provide(dropdownActiveIndexKey, activeIndex)
background: v('fill', rgba(0, 0, 0, 0.04));
}
/*
* 选中项(持久态):与 item-active 的临时高亮区分开——
* 用主色底 + 主色文字,让「当前所在项」在菜单一打开就看得出来。
* 放在 item-active 之后:同时命中时选中态优先,鼠标划过不会把选中项洗掉。
*/
.#{e('dropdown', 'item')}.#{is('item-selected')} {
color: v('color-primary', #18181b);
/* font-weight 没有对应 token(全库都是字面量),与 Pagination / Select 的选中态一致用 500 */
font-weight: 500;
background: color-mix(in srgb, #{v('color-primary', #18181b)} 12%, transparent);
}
.#{e('dropdown', 'item')}.#{is('item-selected')}.#{is('item-active')} {
background: color-mix(in srgb, #{v('color-primary', #18181b)} 18%, transparent);
}
.#{e('dropdown', 'item')}.#{is('item-disabled')} {
color: v('text-disabled', rgba(0, 0, 0, 0.25));
cursor: not-allowed;
@@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import G3Dropdown from './Dropdown.vue'
/**
* 复现用户报告的真实症状:
* - 只有 trigger="hover" 才有
* - 不是「第一个 item」才有,而是「第一次 hover 任意一个 item」都有
* - 表现为灰底出现后立刻消失(闪一下),但点击正常
* - 之后再 hover 同一项就正常了
*
* 关键差别:click 模式下打开菜单时鼠标在触发器上、不在菜单项上,
* 所以不存在「打开动作把菜单项高亮冲掉」的窗口。
*/
afterEach(() => {
document.body.innerHTML = ''
})
const OPEN_DELAY = 150
function menus(): HTMLElement[] {
return [...document.body.querySelectorAll('.g3-dropdown__menu')] as HTMLElement[]
}
function itemsIn(menu: HTMLElement): HTMLElement[] {
return [...menu.querySelectorAll('.g3-dropdown__item')] as HTMLElement[]
}
async function settle(ms = OPEN_DELAY) {
await new Promise((r) => setTimeout(r, ms))
await nextTick()
}
function mountHover() {
return mount({
components: { G3Dropdown },
data() {
return {
items: [
{ key: 'a', label: '甲' },
{ key: 'b', label: '乙' },
{ key: 'c', label: '丙' },
],
}
},
template: `
<G3Dropdown trigger="hover" :items="items">
<template #trigger><button class="trigger">菜单</button></template>
</G3Dropdown>
`,
})
}
describe('hover 模式:第一次 hover 任意项都会闪', () => {
it('打开面板后立刻 hover 某一项,高亮必须稳定存在(不被打开动作冲掉)', async () => {
const wrapper = mountHover()
await wrapper.find('.trigger').trigger('mouseenter')
await settle()
const menu = menus()[0]
expect(menu).toBeTruthy()
// 关键:在「面板刚挂载」的同一次交互里 hover 一项
// 用户实际场景是:hover 触发器 → 面板出现 → 指针顺势落在某一项上
const row = itemsIn(menu)[1] // 取「乙」,故意不用第一个,验证不是只有第一项才有问题
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
expect(itemsIn(menu)[1].className).toContain('is-item-active')
// 让打开相关的异步(Transition / nextTick 队列)跑完
await settle(0)
await settle(30)
// 这一步在旧实现上会失败:高亮被打开动作冲刷掉
expect(itemsIn(menu)[1].className).toContain('is-item-active')
})
it('第二次 hover 应该同样稳定(对照组:用户说之后是正常的)', async () => {
const wrapper = mountHover()
await wrapper.find('.trigger').trigger('mouseenter')
await settle()
const menu = menus()[0]
// 第一次
itemsIn(menu)[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
await settle(30)
// 移到第二项(第二次 hover)
itemsIn(menu)[1].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
await settle(30)
expect(itemsIn(menu)[0].className).not.toContain('is-item-active')
expect(itemsIn(menu)[1].className).toContain('is-item-active')
})
it('click 模式作为对照:打开后 hover 项也不应丢高亮', async () => {
const wrapper = mount({
components: { G3Dropdown },
data() {
return {
items: [
{ key: 'a', label: '甲' },
{ key: 'b', label: '乙' },
],
}
},
template: `
<G3Dropdown trigger="click" :items="items">
<template #trigger><button class="trigger">菜单</button></template>
</G3Dropdown>
`,
})
await wrapper.find('.trigger').trigger('click')
await nextTick()
await settle(0)
const menu = menus()[0]
itemsIn(menu)[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
await settle(30)
expect(itemsIn(menu)[0].className).toContain('is-item-active')
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { inject, isVNode, type VNode } from 'vue'
import { inject, isVNode, provide, ref, type Ref, type VNode } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useNamespace } from '../_composables/useNamespace'
import G3Popover from '../popover/Popover.vue'
@@ -15,7 +15,7 @@ const props = withDefaults(
defineProps<{
/** 菜单项数组 */
items?: DropdownItemData[]
/** 是否参与主菜单的键盘 / 鼠标高亮联动;递归子菜单时关闭 */
/** 根层菜单:与 Dropdown 的键盘导航联动;子菜单为 false */
interactive?: boolean
}>(),
{ items: () => [], interactive: true },
@@ -24,18 +24,45 @@ const props = withDefaults(
const { b, e, m, is } = useNamespace('dropdown')
const selectItem = inject(dropdownSelectKey, null)
const activeIndex = inject(dropdownActiveIndexKey, null)
const injectedActiveIndex = inject(dropdownActiveIndexKey, null)
/**
* 本层生效的高亮索引(Ref<number>)。
*
* 根层(interactive=true):用 Dropdown 注入的那个 —— 键盘 ↑↓ 改的就是它,
* 鼠标划过也要回写同一个,两者才能联动(这是原有行为,必须保住)。
*
* 子菜单(interactive=false):**自持一个**,不碰注入值。原因见 setActive。
*/
const localActiveIndex = ref(-1)
const activeIndex: Ref<number> =
props.interactive && injectedActiveIndex ? injectedActiveIndex : localActiveIndex
function isActive(index: number): boolean {
return props.interactive && activeIndex !== null && activeIndex.value === index
return activeIndex.value === index
}
/** 鼠标移入时同步键盘高亮索引(子菜单不参与联动) */
function setActive(index: number) {
if (!props.interactive || activeIndex === null) return
/**
* 鼠标移入时更新本层高亮。
*
* 子菜单这里曾经是空操作(interactive=false 直接 return),导致子项完全没有 hover 反馈;
* 而且根层那个共享索引的残留值会命中子项索引(两层都从 0 开始),
* 表现为 hover 子项时灰底「一闪即消」—— 面板自身的 mouseenter 会触发
* setOpen(true) → onOpenChange 把根层 activeIndex 重置为 -1,子项又无力重新置位。
* 现在子菜单自持索引,面板重入再也冲不掉它。
*/
function setActive(index: number): void {
activeIndex.value = index
}
function clearActive(): void {
activeIndex.value = -1
}
/** 把这层的高亮索引 provide 给子菜单(子菜单会再 provide 自己那层覆盖掉) */
provide(dropdownActiveIndexKey, activeIndex)
function onItemClick(item: DropdownItemData) {
if (item.disabled) return
item.onClick?.(item)
@@ -69,10 +96,16 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
<span
:class="[
e('item'),
{ [is('item-active')]: isActive(index), [is('item-disabled')]: item.disabled },
{
[is('item-active')]: isActive(index),
[is('item-selected')]: item.selected,
[is('item-disabled')]: item.disabled,
},
]"
role="menuitem"
aria-haspopup="menu"
:aria-current="item.selected ? 'page' : undefined"
@mouseenter="!item.disabled && setActive(index)"
>
<span v-if="item.icon" :class="e('item-icon')">
<component :is="item.icon" v-if="!isVNode(item.icon)" />
@@ -83,7 +116,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
</span>
</template>
<template #content>
<div :class="e('menu')" role="menu">
<div :class="e('menu')" role="menu" @mouseleave="clearActive">
<G3DropdownMenu :items="item.children" :interactive="false" />
</div>
</template>
@@ -96,12 +129,14 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
e('item'),
{
[is('item-active')]: isActive(index),
[is('item-selected')]: item.selected,
[is('item-disabled')]: item.disabled,
[is('item-danger')]: item.danger,
},
]"
role="menuitem"
:aria-disabled="item.disabled || undefined"
:aria-current="item.selected ? 'page' : undefined"
@mouseenter="!item.disabled && setActive(index)"
@click="onItemClick(item)"
>
@@ -0,0 +1,134 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import G3Dropdown from './Dropdown.vue'
/**
* 回归:hover 子菜单项时灰底「一闪即消」。
*
* 旧行为(已修):
* - 子菜单以 :interactive="false" 渲染,isActive()/setActive() 直接短路,
* 子项没有任何稳定 hover 反馈;
* - 且根层共享的 activeIndex 残留值会命中子项索引(两层都从 0 起),
* 面板自身的 mouseenter → setOpen(true) → onOpenChange 把该索引重置为 -1,
* 于是一帧灰底后消失。
*
* 修复:每层 G3DropdownMenu 自持高亮索引;根层仍与 Dropdown 键盘导航联动。
*/
afterEach(() => {
document.body.innerHTML = ''
})
function menus(): HTMLElement[] {
return [...document.body.querySelectorAll('.g3-dropdown__menu')] as HTMLElement[]
}
function itemsIn(menu: HTMLElement): HTMLElement[] {
return [...menu.querySelectorAll('.g3-dropdown__item')] as HTMLElement[]
}
async function settle(ms = 150) {
await new Promise((r) => setTimeout(r, ms))
await nextTick()
}
function mountWithSubmenu() {
return mount({
components: { G3Dropdown },
data() {
return {
items: [
{
key: 'more',
label: '更多',
children: [
{ key: 'c1', label: '子项一' },
{ key: 'c2', label: '子项二' },
],
},
],
}
},
template: `
<G3Dropdown trigger="hover" :items="items">
<template #trigger><button class="trigger">菜单</button></template>
</G3Dropdown>
`,
})
}
describe('子菜单 hover 高亮', () => {
it('hover 子项会拿到 is-item-active,且面板重入后不会被冲掉', async () => {
const wrapper = mountWithSubmenu()
await wrapper.find('.trigger').trigger('mouseenter')
await settle()
// 悬停父项展开子菜单
itemsIn(menus()[0])[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await settle()
expect(menus().length).toBe(2)
// hover 第一个子项 —— 必须有高亮
const firstChild = itemsIn(menus()[1])[0]
firstChild.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
expect(itemsIn(menus()[1])[0].className).toContain('is-item-active')
// 模拟鼠标跨入面板边界:面板自身 mouseenter 会再次 setOpen(true)
menus()[1].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
await settle(0)
// 关键回归点:高亮必须还在(旧实现在这里被重置成 -1 → 灰底消失)
expect(itemsIn(menus()[1])[0].className).toContain('is-item-active')
})
it('hover 到第二个子项时,第一个的高亮会转移过去', async () => {
const wrapper = mountWithSubmenu()
await wrapper.find('.trigger').trigger('mouseenter')
await settle()
itemsIn(menus()[0])[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await settle()
itemsIn(menus()[1])[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
itemsIn(menus()[1])[1].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
await nextTick()
const rows = itemsIn(menus()[1])
expect(rows[0].className).not.toContain('is-item-active')
expect(rows[1].className).toContain('is-item-active')
})
it('根层键盘导航仍然生效(未被自持索引改坏)', async () => {
const wrapper = mount({
components: { G3Dropdown },
data() {
return {
items: [
{ key: 'a', label: '甲' },
{ key: 'b', label: '乙' },
],
}
},
template: `
<G3Dropdown trigger="click" :items="items">
<template #trigger><button class="trigger">菜单</button></template>
</G3Dropdown>
`,
})
await wrapper.find('.trigger').trigger('click')
await nextTick()
const menu = menus()[0]
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await nextTick()
expect(itemsIn(menu)[0].className).toContain('is-item-active')
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await nextTick()
expect(itemsIn(menu)[1].className).toContain('is-item-active')
})
})
@@ -6,7 +6,17 @@ export const dropdownSelectKey: InjectionKey<((item: DropdownItemData) => void)
'g3-dropdown-select',
)
/** 主 Dropdown 注入的键盘高亮索引;子菜单不参与联动 */
/**
* **当前这一层**菜单的高亮索引。
*
* 每层 G3DropdownMenu 各持一个并 provide 给自己的子菜单:
* - 根层用 Dropdown 注入的(键盘 ↑↓ 与鼠标共用,保持联动);
* - 子菜单自持一个,不再复用根层的。
*
* 早期是根层单例、子菜单 `interactive=false` 完全停用高亮,后果有二:
* 1. 子项没有任何 hover 反馈;
* 2. 根层残留索引会命中子项索引(两层都从 0 起),hover 子项时灰底一闪即消。
*/
export const dropdownActiveIndexKey: InjectionKey<Ref<number> | null> = Symbol(
'g3-dropdown-active',
)
@@ -16,6 +16,11 @@ export interface DropdownItemData {
danger?: boolean
/** 禁用项 */
disabled?: boolean
/**
* 选中项(持久态,区别于鼠标/键盘的临时高亮 item-active)。
* 用于「菜单项对应当前所在页面」这类场景:菜单一打开就应看得出哪一项是当前项。
*/
selected?: boolean
/** 快捷键提示文字 */
shortcut?: string
/** 渲染为分隔线(其余属性忽略) */
@@ -8,6 +8,7 @@ import './_styles/rtl.scss'
import { G3Button } from './button'
import { G3ButtonGroup, G3ButtonGroupSeparator } from './button-group'
import { G3Checkbox } from './checkbox'
import { G3ColorPicker } from './color-picker'
import { G3CalendarPanel, G3DatePicker, G3RangePicker, G3TimeSelect } from './date'
import { G3Drawer } from './drawer'
import { G3Dropdown, G3DropdownItem } from './dropdown'
@@ -35,6 +36,7 @@ import { G3Tree } from './tree'
export * from './button'
export * from './button-group'
export * from './checkbox'
export * from './color-picker'
export * from './date'
export * from './drawer'
export * from './dropdown'
@@ -121,6 +123,7 @@ const components = {
G3CalendarPanel,
G3Checkbox,
G3Col,
G3ColorPicker,
G3DatePicker,
G3Drawer,
G3Dropdown,
@@ -389,7 +389,7 @@ onBeforeUnmount(() => {
flex-shrink: 0;
align-items: center;
gap: v('space-2', 8px);
padding: v('space-3', 12px) v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
cursor: move;
user-select: none;
}
@@ -440,7 +440,7 @@ onBeforeUnmount(() => {
/* 面板缩放 / 内容过高时由 body 吃掉剩余高度并内部滚动,否则内容会溢出面板 */
flex: 1 1 auto;
min-height: 0;
padding: v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
overflow: auto;
}
@@ -450,7 +450,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: flex-end;
gap: v('space-2', 8px);
padding: v('space-3', 12px) v('space-5', 24px);
padding: v('panel-padding-y', 16px) v('panel-padding-x', 20px);
}
/* ---------- 缩放手柄 ---------- */