u
This commit is contained in:
1 parent
5f28a10d89
commit
32d423b759
59 files changed
+5101
-1098
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 回归:**hover 触发**时菜单项高亮「闪一下然后消失」。
|
||||
*
|
||||
* 最小复现(用户指定):文档站 /ui/overlay/dropdown 的「触发方式 → hover 触发」示例。
|
||||
*
|
||||
* 根因(已由开关调用栈 + 逐帧采样证实):
|
||||
* hover 触发下触发器与浮层是两棵 DOM(浮层 Teleport 到 body)。指针从触发器移进
|
||||
* 菜单项时会先后触发:
|
||||
* 1. 触发器 mouseleave(排定关闭定时器,随后被浮层 mouseenter 清掉)
|
||||
* 2. 浮层自己的 mouseenter → setOpen(true) → **再上报一次 open=true**
|
||||
* Dropdown.onOpenChange 曾经「见 true 就 activeIndex = -1」,于是菜单项刚点亮的
|
||||
* 高亮被这次重复上报清掉;指针已停住、不会再派发 mouseenter → 高亮永久不恢复。
|
||||
* click 触发时鼠标停在触发器上,没有这次重复上报,所以只有 hover 模式复现。
|
||||
*
|
||||
* 判定方式刻意避开「指针轨迹」这类不稳定因素:
|
||||
* - 不用逐帧采样(依赖 Playwright 的移动时序,会飘);
|
||||
* - 改为「hover 到项上 → 等足够久 → 断言高亮仍在」。
|
||||
* 修复前:等 100ms(mouseEnterDelay) 后高亮被清 → 断言失败;
|
||||
* 修复后:高亮一直保持 → 断言通过。
|
||||
*/
|
||||
|
||||
test.use({ baseURL: 'http://localhost:3100' })
|
||||
|
||||
test.describe('hover 触发:菜单项高亮不得闪断', () => {
|
||||
test('hover 到菜单项并停留,高亮必须持续保持', async ({ page }) => {
|
||||
await page.goto('/ui/overlay/dropdown')
|
||||
|
||||
const trigger = page.getByRole('button', { name: 'hover 触发' }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
await trigger.hover()
|
||||
const menu = page.locator('.g3-dropdown__menu').first()
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const item = menu.locator('.g3-dropdown__item').first()
|
||||
|
||||
// hover 到菜单项上
|
||||
await item.hover()
|
||||
// 立刻应高亮
|
||||
await expect(item).toHaveClass(/is-item-active/)
|
||||
|
||||
// 关键等待:越过 mouseEnterDelay(100ms) + 若干帧,
|
||||
// 让「浮层重复上报 open → 清 activeIndex」这条路径有机会发生
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// 修复前这里会失败(高亮已被清掉且不会恢复)
|
||||
await expect(item).toHaveClass(/is-item-active/)
|
||||
// 菜单本身也不该关闭
|
||||
await expect(menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('在两个菜单项之间移动,高亮正确转移且不残留', async ({ page }) => {
|
||||
await page.goto('/ui/overlay/dropdown')
|
||||
const trigger = page.getByRole('button', { name: 'hover 触发' }).first()
|
||||
await trigger.hover()
|
||||
|
||||
const menu = page.locator('.g3-dropdown__menu').first()
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
const items = menu.locator('.g3-dropdown__item')
|
||||
|
||||
await items.nth(0).hover()
|
||||
await expect(items.nth(0)).toHaveClass(/is-item-active/)
|
||||
|
||||
await items.nth(1).hover()
|
||||
await expect(items.nth(1)).toHaveClass(/is-item-active/)
|
||||
await expect(items.nth(0)).not.toHaveClass(/is-item-active/)
|
||||
|
||||
// 停留后仍保持
|
||||
await page.waitForTimeout(400)
|
||||
await expect(items.nth(1)).toHaveClass(/is-item-active/)
|
||||
})
|
||||
|
||||
test('click 触发作为对照组', async ({ page }) => {
|
||||
await page.goto('/ui/overlay/dropdown')
|
||||
|
||||
const clickTrigger = page.getByRole('button', { name: '点击触发' }).first()
|
||||
if ((await clickTrigger.count()) === 0) {
|
||||
test.skip(true, '该页面没有「点击触发」示例按钮')
|
||||
return
|
||||
}
|
||||
|
||||
await clickTrigger.click()
|
||||
const menu = page.locator('.g3-dropdown__menu').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const item = menu.locator('.g3-dropdown__item').first()
|
||||
await item.hover()
|
||||
await expect(item).toHaveClass(/is-item-active/)
|
||||
await page.waitForTimeout(400)
|
||||
await expect(item).toHaveClass(/is-item-active/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 偏好设置「布局」卡片回归 —— 针对 ERP 应用。
|
||||
*
|
||||
* 背景:原来三张卡片(侧边/双列/顶部)用的是 lucide 抽象图标,看不出布局差异。
|
||||
* 改成 vben 那样的**布局示意图**(SVG 骨架图)后,需要确认:
|
||||
* 1. 三张卡片都渲染出 SVG(不是空白 / 不是抽象图标);
|
||||
* 2. 点击第三张(顶部菜单)能真正切换布局;
|
||||
* 3. 选中态描边生效(is-active)。
|
||||
*/
|
||||
|
||||
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
|
||||
|
||||
async function openPreferences(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ERP_URL}/`)
|
||||
// 顶栏的偏好设置按钮
|
||||
await page.getByTitle('偏好设置').click()
|
||||
// 抽屉里切到「布局」页签
|
||||
await page.getByRole('tab', { name: '布局' }).or(page.getByText('布局', { exact: true })).first().click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
test.describe('ERP 偏好设置 - 布局卡片', () => {
|
||||
test('三张布局卡片都渲染出示意图 SVG', async ({ page }) => {
|
||||
await openPreferences(page)
|
||||
|
||||
const cards = page.locator('.pref-card--layout')
|
||||
await expect(cards).toHaveCount(3)
|
||||
|
||||
// 每张卡片里都要有 SVG 骨架图,且不是 0 尺寸(防"渲染成空")
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const svg = cards.nth(i).locator('svg.pref-layout-icon')
|
||||
await expect(svg).toHaveCount(1)
|
||||
const box = await svg.boundingBox()
|
||||
expect(box, `第 ${i + 1} 张卡片的示意图应有实际尺寸`).not.toBeNull()
|
||||
expect(box!.width, `第 ${i + 1} 张示意图宽度应 > 40px`).toBeGreaterThan(40)
|
||||
expect(box!.height, `第 ${i + 1} 张示意图高度应 > 25px`).toBeGreaterThan(25)
|
||||
}
|
||||
|
||||
// 卡片标签
|
||||
const labels = await page.locator('.pref-card--layout .pref-card__name').allTextContents()
|
||||
expect(labels.join('|')).toContain('侧边菜单')
|
||||
expect(labels.join('|')).toContain('双列菜单')
|
||||
expect(labels.join('|')).toContain('顶部菜单')
|
||||
})
|
||||
|
||||
test('点击「双列菜单」卡片能切换到双列布局', async ({ page }) => {
|
||||
await openPreferences(page)
|
||||
|
||||
await page.locator('.pref-card--layout', { hasText: '双列菜单' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 关掉抽屉看布局
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
await expect(page.locator('.layout-mixed-rail')).toBeVisible()
|
||||
await expect(page.locator('.layout-mixed-column')).toBeVisible()
|
||||
})
|
||||
|
||||
test('点击「顶部菜单」卡片能切换回顶部菜单布局', async ({ page }) => {
|
||||
await openPreferences(page)
|
||||
|
||||
await page.locator('.pref-card--layout', { hasText: '顶部菜单' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
await expect(page.locator('.layout--header-nav')).toHaveCount(1)
|
||||
// 顶部布局下没有侧栏 / 图标栏
|
||||
await expect(page.locator('.layout-sidebar')).toHaveCount(0)
|
||||
await expect(page.locator('.layout-mixed-rail')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 双列菜单布局的头部区域回归 —— 针对 ERP 应用。
|
||||
*
|
||||
* 结构对照 vben(packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue):
|
||||
* 第一列(layout-mixed-rail,width = mixedWidth):
|
||||
* · 背景 bg-sidebar-deep(本项目按需求用白色)
|
||||
* · <slot name="logo"> 在这一列(:410,高度 = headerHeight)
|
||||
* 第二列(layout-mixed-column,extraWidth):
|
||||
* · 背景 bg-sidebar
|
||||
* · <slot name="extra-title"> 在这一列(:446,高度 = extraTitleHeight ?? headerHeight - 1)
|
||||
*
|
||||
* ⚠️ 注意区分:vben 的 `isMixedNav` = layout === 'mixed-nav'(顶栏混合),
|
||||
* 与本布局 `sidebar-mixed-nav` 不是一回事。vben-layout.vue:619 的
|
||||
* `isSideMode && !isMixedNav && sidebarLogoVisible` 对 sidebar-mixed-nav 成立,
|
||||
* 所以 logo 渲染在第一列。
|
||||
*
|
||||
* 即:logo 在第一列,标题在第二列。
|
||||
*/
|
||||
|
||||
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
|
||||
|
||||
async function useMixedNavLayout(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ERP_URL}/`)
|
||||
await page.evaluate(() => {
|
||||
const KEY = 'g3erp:preferences'
|
||||
const raw = localStorage.getItem(KEY)
|
||||
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
|
||||
|
||||
const app = pref.app || {}
|
||||
app.layout = 'sidebar-mixed-nav'
|
||||
pref.app = app
|
||||
|
||||
const sidebar = pref.sidebar || {}
|
||||
sidebar.enable = true
|
||||
sidebar.extraCollapse = false
|
||||
pref.sidebar = sidebar
|
||||
|
||||
const logo = pref.logo || {}
|
||||
logo.enable = true
|
||||
logo.showText = true
|
||||
pref.logo = logo
|
||||
|
||||
localStorage.setItem(KEY, JSON.stringify(pref))
|
||||
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
await page.reload()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
test.describe('ERP 双列菜单 - 头部结构', () => {
|
||||
test('logo 在第一列(layout-mixed-rail)', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const rail = page.locator('.layout-mixed-rail')
|
||||
await expect(rail).toBeVisible()
|
||||
|
||||
// 第一列顶部有 logo 方块
|
||||
const mark = rail.locator('.layout-mixed-rail__logo-mark')
|
||||
await expect(mark, '第一列应有 logo 方块').toBeVisible()
|
||||
await expect(mark).toHaveText('G3')
|
||||
|
||||
// 有实际尺寸,不是被压成 0
|
||||
const box = await mark.boundingBox()
|
||||
expect(box, 'logo 方块应有实际尺寸').not.toBeNull()
|
||||
expect(box!.width).toBeGreaterThan(10)
|
||||
expect(box!.height).toBeGreaterThan(10)
|
||||
|
||||
// 第二列不应再出现 logo
|
||||
await expect(page.locator('.layout-mixed-column__logo')).toHaveCount(0)
|
||||
// 顶栏也不应出现 logo(双列布局下由第一列承载)
|
||||
await expect(page.locator('.layout-header__logo')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('标题行展示的是 logo 文字(不是分组名)', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const title = page.locator('.layout-mixed-column__title')
|
||||
await expect(title, '第二列标题行应可见').toBeVisible()
|
||||
|
||||
// 应是 logo 文字(默认 G3Soft ERP),而不是当前分组名「海运」
|
||||
const text = (await title.innerText()).trim()
|
||||
expect(text, '标题行应是 logo 文字').not.toBe('海运')
|
||||
expect(text.length, '标题行应有内容').toBeGreaterThan(0)
|
||||
|
||||
// 与单列侧栏/顶栏的 logo 文字取自同一偏好项
|
||||
const logoText = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('g3erp:preferences')
|
||||
const pref = raw ? JSON.parse(raw) : {}
|
||||
return pref.logo ? pref.logo.text : null
|
||||
})
|
||||
expect(logoText, '应能读到 logo.text 偏好').toBeTruthy()
|
||||
expect(text, '标题行文本应等于 logo.text').toBe(logoText)
|
||||
|
||||
const box = await title.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
test('切换分组不影响标题行(它显示 logo 文字,与分组无关)', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const before = (await page.locator('.layout-mixed-column__title').innerText()).trim()
|
||||
|
||||
await page.locator('.layout-mixed-rail__item', { hasText: '财务' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const after = (await page.locator('.layout-mixed-column__title').innerText()).trim()
|
||||
expect(after, '标题行是 logo 文字,切分组不应变化').toBe(before)
|
||||
|
||||
// 但列表内容确实换成了财务的子项
|
||||
const columnText = await page.locator('.layout-mixed-column').innerText()
|
||||
expect(columnText, '第二列列表应切换为财务子项').toContain('应收费用')
|
||||
})
|
||||
|
||||
test('第一列背景为白色', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const rail = page.locator('.layout-mixed-rail')
|
||||
const bg = await rail.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
expect(bg, `图标栏背景应为白色,实际 ${bg}`).toMatch(/^rgba?\(255,\s*255,\s*255(,\s*1)?\)$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 双列菜单布局(layout = 'sidebar-mixed-nav')渲染回归 —— 针对 ERP 应用。
|
||||
*
|
||||
* 组件编过 ≠ 页面渲染对,所以这里在真实浏览器里断言 DOM 结构:
|
||||
* 1. 切到双列布局后,左侧图标栏 + 第二列都存在;
|
||||
* 2. 单列侧栏(.layout-sidebar)不再渲染;
|
||||
* 3. 第一列只显示顶级项(图标 + 标题),不含子项;
|
||||
* 4. 第二列显示当前顶级分组的子项;
|
||||
* 5. 点第一列另一个分组,第二列内容随之切换。
|
||||
*
|
||||
* 说明:放在组件库 e2e 工程里跑(已有 Playwright 配置),
|
||||
* 通过 ERP_URL 指向 ERP dev server,避免给 ERP 应用引入 Playwright 依赖。
|
||||
*/
|
||||
|
||||
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
|
||||
|
||||
/** 通过偏好设置把布局切成双列菜单(直接写 localStorage,避免点 UI 的耦合) */
|
||||
async function useMixedNavLayout(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ERP_URL}/`)
|
||||
await page.evaluate(() => {
|
||||
const KEY = 'g3erp:preferences'
|
||||
const raw = localStorage.getItem(KEY)
|
||||
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
|
||||
|
||||
const app = pref.app || {}
|
||||
app.layout = 'sidebar-mixed-nav'
|
||||
pref.app = app
|
||||
|
||||
const sidebar = pref.sidebar || {}
|
||||
sidebar.enable = true
|
||||
pref.sidebar = sidebar
|
||||
|
||||
localStorage.setItem(KEY, JSON.stringify(pref))
|
||||
|
||||
// 清掉页签持久化,保证每次从干净状态开始
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
await page.reload()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
test.describe('ERP 双列菜单布局', () => {
|
||||
test('渲染出图标栏 + 第二列,且不再渲染单列侧栏', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
|
||||
// 布局根节点带上了对应 class
|
||||
await expect(page.locator('.layout--sidebar-mixed-nav')).toHaveCount(1)
|
||||
|
||||
// 两列都在
|
||||
await expect(page.locator('.layout-mixed-rail')).toBeVisible()
|
||||
await expect(page.locator('.layout-mixed-column')).toBeVisible()
|
||||
|
||||
// 单列侧栏不该出现
|
||||
await expect(page.locator('.layout-sidebar')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('第一列只放顶级项,第二列放该分组的子项', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
|
||||
// 进入海运下的某个叶子,使「海运」成为当前顶级分组
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const railItems = page.locator('.layout-mixed-rail__item')
|
||||
await expect(railItems.first()).toBeVisible()
|
||||
|
||||
// 第一列项数应等于顶级菜单数(6 个业务域)
|
||||
const railCount = await railItems.count()
|
||||
expect(railCount, '第一列应为顶级菜单数量').toBeGreaterThanOrEqual(5)
|
||||
|
||||
// 第一列里不应出现二级标题(如「订舱单」)
|
||||
const railText = (await railItems.allTextContents()).join('|')
|
||||
expect(railText, '第一列不应包含子项「订舱单」').not.toContain('订舱单')
|
||||
expect(railText, '第一列应含顶级项「海运」').toContain('海运')
|
||||
|
||||
// 第一列里「海运」应是选中态
|
||||
const activeRail = page.locator('.layout-mixed-rail__item.is-active')
|
||||
await expect(activeRail).toHaveCount(1)
|
||||
await expect(activeRail).toContainText('海运')
|
||||
|
||||
// 第二列显示「海运」的子项
|
||||
const columnText = await page.locator('.layout-mixed-column').innerText()
|
||||
expect(columnText, '第二列应含「海运」的子项').toContain('订舱单')
|
||||
})
|
||||
|
||||
test('点第一列切换到别的分组,第二列内容随之变化', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const columnBefore = await page.locator('.layout-mixed-column').innerText()
|
||||
expect(columnBefore).toContain('订舱单')
|
||||
|
||||
// 点第一列的「财务」
|
||||
await page.locator('.layout-mixed-rail__item', { hasText: '财务' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const columnAfter = await page.locator('.layout-mixed-column').innerText()
|
||||
expect(columnAfter, '切到财务后第二列应显示财务的子项').toContain('应收费用')
|
||||
expect(columnAfter, '第二列不应再显示海运的子项').not.toContain('订舱单')
|
||||
|
||||
// 「财务」应变成选中态
|
||||
await expect(page.locator('.layout-mixed-rail__item.is-active')).toContainText('财务')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 双列菜单:第一列选中项样式回归 —— 针对 ERP 应用。
|
||||
*
|
||||
* 需求:layout-mixed-rail__item.is-active 的背景要比原来"深一点"、文字白色。
|
||||
*
|
||||
* 对照 vben(menu-ui/src/components/menu.vue):
|
||||
* @mixin menu-item-active (375-379):
|
||||
* color: var(--menu-item-active-color);
|
||||
* background: var(--menu-item-active-background-color);
|
||||
* 深色档赋值 (470-471):
|
||||
* --menu-item-active-color: hsl(var(--accent-foreground)) ← 高对比文字
|
||||
* --menu-item-active-background-color: hsl(var(--accent)) ← 强调底色
|
||||
*
|
||||
* 判定:选中项背景应比所在列背景明显更深,且文字为亮色(对比度足够)。
|
||||
*/
|
||||
|
||||
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
|
||||
|
||||
async function useMixedNavLayout(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ERP_URL}/`)
|
||||
await page.evaluate(() => {
|
||||
const KEY = 'g3erp:preferences'
|
||||
const raw = localStorage.getItem(KEY)
|
||||
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
|
||||
|
||||
const app = pref.app || {}
|
||||
app.layout = 'sidebar-mixed-nav'
|
||||
pref.app = app
|
||||
|
||||
const sidebar = pref.sidebar || {}
|
||||
sidebar.enable = true
|
||||
sidebar.extraCollapse = false
|
||||
pref.sidebar = sidebar
|
||||
|
||||
// 浅色模式,确保测的是浅色档
|
||||
const theme = pref.theme || {}
|
||||
theme.mode = 'light'
|
||||
pref.theme = theme
|
||||
|
||||
localStorage.setItem(KEY, JSON.stringify(pref))
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
await page.reload()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
/** 解析 rgb(a) 字符串 → [r,g,b] */
|
||||
function parseRgb(value: string): [number, number, number] {
|
||||
const m = value.match(/rgba?\(([^)]+)\)/)
|
||||
if (!m) throw new Error(`无法解析颜色: ${value}`)
|
||||
const parts = m[1].split(',').map((s) => Number(s.trim()))
|
||||
return [parts[0], parts[1], parts[2]]
|
||||
}
|
||||
|
||||
/** 相对亮度(WCAG) */
|
||||
function luminance([r, g, b]: [number, number, number]): number {
|
||||
const f = (c: number) => {
|
||||
const s = c / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
|
||||
/** 对比度 */
|
||||
function contrast(a: [number, number, number], b: [number, number, number]): number {
|
||||
const la = luminance(a)
|
||||
const lb = luminance(b)
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la]
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
test.describe('ERP 双列菜单 - 图标栏选中态', () => {
|
||||
test('选中项背景比列背景更深,且文字是亮色(对比度足够)', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const rail = page.locator('.layout-mixed-rail')
|
||||
const active = page.locator('.layout-mixed-rail__item.is-active')
|
||||
await expect(active, '应有一个选中项').toHaveCount(1)
|
||||
|
||||
const styles = await page.evaluate(() => {
|
||||
const railEl = document.querySelector('.layout-mixed-rail') as HTMLElement
|
||||
const itemEl = document.querySelector('.layout-mixed-rail__item.is-active') as HTMLElement
|
||||
const cs = getComputedStyle(itemEl)
|
||||
return {
|
||||
railBg: getComputedStyle(railEl).backgroundColor,
|
||||
itemBg: cs.backgroundColor,
|
||||
itemColor: cs.color,
|
||||
fontWeight: cs.fontWeight,
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[active-style]', JSON.stringify(styles))
|
||||
|
||||
const railBg = parseRgb(styles.railBg)
|
||||
const itemBg = parseRgb(styles.itemBg)
|
||||
const itemColor = parseRgb(styles.itemColor)
|
||||
|
||||
// 1) 选中背景确实比列背景更深(亮度更低)
|
||||
expect(
|
||||
luminance(itemBg),
|
||||
`选中背景应比列背景更深:item=${styles.itemBg} rail=${styles.railBg}`,
|
||||
).toBeLessThan(luminance(railBg))
|
||||
|
||||
// 2) 背景不是透明(否则测的是列背景)
|
||||
expect(styles.itemBg, '选中背景不应是透明').not.toMatch(/rgba\(0,\s*0,\s*0,\s*0\)/)
|
||||
|
||||
// 3) 文字与选中背景的对比度达标(可读)
|
||||
const ratio = contrast(itemColor, itemBg)
|
||||
expect(ratio, `文字/背景对比度应 >= 4.5,实际 ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5)
|
||||
|
||||
// 4) 文字比背景亮("白色/亮色文字")
|
||||
expect(
|
||||
luminance(itemColor),
|
||||
`文字应比选中背景更亮:text=${styles.itemColor} bg=${styles.itemBg}`,
|
||||
).toBeGreaterThan(luminance(itemBg))
|
||||
})
|
||||
|
||||
test('未选中项没有深色底(对照)', async ({ page }) => {
|
||||
await useMixedNavLayout(page)
|
||||
await page.goto(`${ERP_URL}/ocean/booking`)
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const inactive = page.locator('.layout-mixed-rail__item:not(.is-active)').first()
|
||||
const bg = await inactive.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
// 未选中项是透明(继承列的底色)
|
||||
expect(bg, `未选中项不应有背景填充,实际 ${bg}`).toMatch(/rgba\(0,\s*0,\s*0,\s*0\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 页签栏「点击后滚动居中」的判定回归 —— 针对 **ERP 应用**(不是组件库 playground)。
|
||||
*
|
||||
* 用户明确的规则:
|
||||
* - 点击的页签**左边还有被隐藏的** → 往前滚(居中)
|
||||
* - 点击的页签**右边还有被隐藏的** → 往后滚(居中)
|
||||
* - 自己被折叠了一部分 → 滚
|
||||
* - 自己完整可见、且左右邻域都没被隐藏 → 不滚
|
||||
*
|
||||
* 报障场景:右侧有多个被隐藏页签(C D F),A 完整可见,点 A 却纹丝不动;
|
||||
* 但用户点 A 正是因为「临近末尾,想看后面的」,此时必须滚。
|
||||
* 旧实现只看「自己是否完整可见」→ A 完整可见 → return → 不滚。
|
||||
*
|
||||
* 说明:本用例放在组件库的 e2e 工程里跑(那边已有 Playwright 配置),
|
||||
* 通过 ERP_URL 指向已启动的 ERP dev server,避免给 ERP 应用引入 Playwright 依赖。
|
||||
*
|
||||
* 运行:ERP_URL=http://localhost:5090 npx playwright test e2e/erp-tabbar-reveal.spec.ts
|
||||
*/
|
||||
|
||||
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
|
||||
|
||||
/**
|
||||
* 打开足够多的页签,让页签栏溢出。
|
||||
*
|
||||
* ⚠️ 必须先清掉持久化的页签状态再开:appStore 把已打开页签 persist 到 localStorage,
|
||||
* 同一个 origin 下各用例会互相继承上一轮遗留的页签,页签集合随执行顺序变化 →
|
||||
* 几何随之改变 → 失败用例在几次运行间「轮换」,看起来像随机失败。
|
||||
* 清掉后每个用例都从同一套页签出发,结果才可复现。
|
||||
*/
|
||||
async function openManyTabs(page: import('@playwright/test').Page) {
|
||||
const routes = [
|
||||
'/ocean/booking',
|
||||
'/ocean/bill',
|
||||
'/ocean/schedule',
|
||||
'/air/booking',
|
||||
'/air/awb',
|
||||
'/container/ledger',
|
||||
'/container/tracking',
|
||||
'/finance/receivable',
|
||||
'/finance/payable',
|
||||
'/finance/statement',
|
||||
]
|
||||
|
||||
// 先访问同源页面,才有权限清 localStorage
|
||||
await page.goto(`${ERP_URL}/`)
|
||||
await page.evaluate(() => {
|
||||
// 只清页签相关键,避免动到其他偏好(key 见 stores/app.ts: STORAGE_KEY)
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.startsWith('g3erp:app')) localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
await page.reload()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
for (const path of routes) {
|
||||
await page.goto(`${ERP_URL}${path}`)
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
}
|
||||
|
||||
interface TabGeo {
|
||||
key: string
|
||||
left: number
|
||||
right: number
|
||||
fullyVisible: boolean
|
||||
}
|
||||
|
||||
interface TabbarState {
|
||||
scrollLeft: number
|
||||
scrollWidth: number
|
||||
clientWidth: number
|
||||
overflowed: boolean
|
||||
tabs: TabGeo[]
|
||||
}
|
||||
|
||||
async function readTabbarState(page: import('@playwright/test').Page): Promise<TabbarState | null> {
|
||||
return page.evaluate(() => {
|
||||
const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null
|
||||
if (!track) return null
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const tabs = [...track.querySelectorAll('[data-tab-key]')] as HTMLElement[]
|
||||
return {
|
||||
scrollLeft: Math.round(track.scrollLeft),
|
||||
scrollWidth: track.scrollWidth,
|
||||
clientWidth: track.clientWidth,
|
||||
overflowed: track.scrollWidth > track.clientWidth,
|
||||
tabs: tabs.map((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
key: el.dataset.tabKey ?? '',
|
||||
left: Math.round(r.left - trackRect.left),
|
||||
right: Math.round(r.right - trackRect.left),
|
||||
fullyVisible: r.left >= trackRect.left - 1 && r.right <= trackRect.right + 1,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('ERP 页签点击后的滚动居中', () => {
|
||||
test('右侧还有被隐藏页签时,点一个完整可见的页签也必须滚动', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await openManyTabs(page)
|
||||
|
||||
const state = await readTabbarState(page)
|
||||
expect(state, '页签栏应已渲染').not.toBeNull()
|
||||
expect(state!.overflowed, '页签应已溢出(否则本用例无意义)').toBe(true)
|
||||
|
||||
// 诊断:打印实际几何,便于判断为何找不到目标页签
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[tabbar-geometry]', JSON.stringify(state, null, 2))
|
||||
|
||||
// 找「自己完整可见、但右侧还有不可见页签」的页签 —— 报障场景。
|
||||
//
|
||||
// ⚠️ 必须取**最后一个**符合条件的,不能取第一个:
|
||||
// 靠最左的页签虽然也「右侧有隐藏」,但它已经在滚动起点,
|
||||
// 居中偏移为负、被 Math.max(0, ...) 钳成 0 → 不动是正确行为,用它测会误判。
|
||||
// 用户报障的真实场景是「临近末尾时点那一个」,所以取最右侧的合格页签。
|
||||
let idx = -1
|
||||
for (let i = 0; i < state!.tabs.length; i++) {
|
||||
const hasHiddenRight = state!.tabs.slice(i + 1).some((n) => !n.fullyVisible)
|
||||
if (state!.tabs[i].fullyVisible && hasHiddenRight) idx = i
|
||||
}
|
||||
expect(idx, '应存在「完整可见但右侧仍有隐藏页签」的页签').toBeGreaterThanOrEqual(0)
|
||||
|
||||
const target = state!.tabs[idx]
|
||||
const before = state!.scrollLeft
|
||||
|
||||
await page.click(`[data-tab-key="${target.key}"]`)
|
||||
await page.waitForTimeout(800) // 等平滑滚动结束
|
||||
|
||||
const after = await readTabbarState(page)
|
||||
expect(
|
||||
after!.scrollLeft,
|
||||
`页签 ${target.key} 右侧还有隐藏页签,点击后必须滚动(滚动前 ${before})`,
|
||||
).not.toBe(before)
|
||||
|
||||
// 应被滚到接近居中
|
||||
const moved = after!.tabs.find((t) => t.key === target.key)!
|
||||
const trackCenter = after!.clientWidth / 2
|
||||
const tabCenter = (moved.left + moved.right) / 2
|
||||
expect(
|
||||
Math.abs(tabCenter - trackCenter),
|
||||
`被点页签应接近居中(实际偏离 ${Math.round(Math.abs(tabCenter - trackCenter))}px)`,
|
||||
).toBeLessThan(Math.max(140, after!.clientWidth * 0.3))
|
||||
})
|
||||
|
||||
test('最后一个页签完整可见时不滚动(避免无谓抽动)', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await openManyTabs(page)
|
||||
|
||||
await page.waitForSelector('.layout-tabbar__track', { state: 'attached' })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// 注意:轨道末尾有一段 24px 的纯占位 spacer(.layout-tabbar__spacer),
|
||||
// 它计入 scrollWidth,所以直接把 scrollLeft 设成 scrollWidth 时,
|
||||
// 最后一个页签距右缘还差 24px —— 不会被判为「完整可见」。
|
||||
// 这里按「最后一个页签的右边缘贴到轨道右缘」来滚,模拟用户手动滚到最右。
|
||||
await page.evaluate(() => {
|
||||
const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null
|
||||
if (!track) return
|
||||
const tabs = [...track.querySelectorAll('[data-tab-key]')] as HTMLElement[]
|
||||
const last = tabs[tabs.length - 1]
|
||||
if (!last) return
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const lastRect = last.getBoundingClientRect()
|
||||
// 需要再往右滚的距离
|
||||
track.scrollLeft += lastRect.right - trackRect.right
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const state = await readTabbarState(page)
|
||||
const last = state!.tabs[state!.tabs.length - 1]
|
||||
expect(last.fullyVisible, '滚到最右后最后一个页签应完整可见').toBe(true)
|
||||
|
||||
const before = state!.scrollLeft
|
||||
await page.click(`[data-tab-key="${last.key}"]`)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const after = await readTabbarState(page)
|
||||
expect(after!.scrollLeft, '末尾页签完整可见时不应滚动').toBe(before)
|
||||
})
|
||||
|
||||
test('左侧还有被隐藏页签时,点击完整可见的页签也应往前滚', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await openManyTabs(page)
|
||||
|
||||
// 等页签栏真的渲染出来再操作(openManyTabs 最后一次 goto 后可能还在挂载)
|
||||
await page.waitForSelector('.layout-tabbar__track', { state: 'attached' })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.evaluate(() => {
|
||||
const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null
|
||||
if (track) track.scrollLeft = track.scrollWidth
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const state = await readTabbarState(page)
|
||||
expect(state, '页签栏应已渲染').not.toBeNull()
|
||||
|
||||
// 取**第一个**符合条件的(最靠左、而又左侧有隐藏):
|
||||
// 与用例 1 镜像 —— 那里取最右,这里取最左,才落在能产生有效偏移的位置
|
||||
let idx = -1
|
||||
for (let i = 0; i < state!.tabs.length; i++) {
|
||||
const hasHiddenLeft = state!.tabs.slice(0, i).some((n) => !n.fullyVisible)
|
||||
if (state!.tabs[i].fullyVisible && hasHiddenLeft) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(idx, '应存在「完整可见但左侧仍有隐藏页签」的页签').toBeGreaterThanOrEqual(0)
|
||||
|
||||
const target = state!.tabs[idx]
|
||||
const before = state!.scrollLeft
|
||||
await page.click(`[data-tab-key="${target.key}"]`)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const after = await readTabbarState(page)
|
||||
expect(
|
||||
after!.scrollLeft,
|
||||
`页签 ${target.key} 左侧还有隐藏页签,点击后应往前滚(滚动前 ${before})`,
|
||||
).not.toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -148,4 +148,47 @@ test.describe('RTL 镜像(几何)', () => {
|
||||
const box = (await panel.boundingBox())!
|
||||
expect(box.x + box.width).toBeGreaterThanOrEqual(viewport.width - 2)
|
||||
})
|
||||
|
||||
test('ColorPicker:取色区与色相条在 RTL 下不镜像(横轴是颜色空间,不是行内方向)', async ({
|
||||
page,
|
||||
}) => {
|
||||
/**
|
||||
* 这条锁的是「刻意不镜像」:取色区底下是 `linear-gradient(to right, …)`(物理方向,
|
||||
* [dir='rtl'] 不翻转),所以 JS 的取值方向必须与渲染同向。
|
||||
* 若后人"顺手"把 left 改成 inset-inline-start,光标就会和它底下的颜色对不上。
|
||||
*/
|
||||
for (const dir of ['ltr', 'rtl'] as const) {
|
||||
await gotoDir(page, dir)
|
||||
// 用组件自己的类名定位:G3ColorPicker 根是 G3Popover(多根:触发器 + Teleport),
|
||||
// 多根组件不做 attr 透传,data-testid 会被 Vue 丢弃,不能作为定位锚点。
|
||||
await page.locator('#s-color-picker .g3-color-picker').first().click()
|
||||
|
||||
const area = page.locator('.g3-color-picker__area')
|
||||
await expect(area).toBeVisible()
|
||||
|
||||
// 取色区左下角应是低饱和 / 低明度(接近黑),右上角是高饱和高明度
|
||||
const readAt = async (fx: number, fy: number) => {
|
||||
const box = (await area.boundingBox())!
|
||||
await page.mouse.click(box.x + box.width * fx, box.y + box.height * fy)
|
||||
return page.locator('.g3-color-picker__hex-input').inputValue()
|
||||
}
|
||||
|
||||
// 左端取色 → 饱和度低;右端取色 → 饱和度高。两个方向都要是「左低右高」
|
||||
const left = await readAt(0.02, 0.5)
|
||||
const right = await readAt(0.98, 0.5)
|
||||
const satOf = (hex: string) => {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
const r = (n >> 16) & 255
|
||||
const g = (n >> 8) & 255
|
||||
const b = n & 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
return max === min ? 0 : (max - min) / max
|
||||
}
|
||||
|
||||
expect(satOf(right), `${dir}: 右端应比左端饱和`).toBeGreaterThan(satOf(left))
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
G3ButtonGroupSeparator,
|
||||
G3Checkbox,
|
||||
G3Col,
|
||||
G3ColorPicker,
|
||||
G3DatePicker,
|
||||
G3Drawer,
|
||||
G3Dropdown,
|
||||
@@ -74,6 +75,7 @@ const App = {
|
||||
G3ButtonGroupSeparator,
|
||||
G3Checkbox,
|
||||
G3Col,
|
||||
G3ColorPicker,
|
||||
G3DatePicker,
|
||||
G3Drawer,
|
||||
G3Dropdown,
|
||||
@@ -131,6 +133,8 @@ const App = {
|
||||
// 挂载即展开的浮层:验证「初始 open」也能拿到 z-index
|
||||
const popoverAOpen = ref(true)
|
||||
const popoverBOpen = ref(true)
|
||||
// 颜色选择器:给 RTL 用例验「取色区/色相条不镜像」用
|
||||
const color = ref('#165dff')
|
||||
|
||||
// RTL 开关:直接翻 <html dir>。CSS 侧靠 [dir='rtl'] 选择器,JS 侧靠 useDirection
|
||||
// 观察 documentElement,两边都跟得上。也支持 ?dir=rtl 直接以 RTL 进入,
|
||||
@@ -191,6 +195,7 @@ const App = {
|
||||
openDrawer,
|
||||
popoverAOpen,
|
||||
popoverBOpen,
|
||||
color,
|
||||
flatTreeData,
|
||||
treeMenuItems,
|
||||
roleOptions: ROLE_OPTIONS,
|
||||
@@ -287,6 +292,19 @@ const App = {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="s-color-picker">
|
||||
<h2>ColorPicker</h2>
|
||||
<div class="row">
|
||||
<!-- 注意:不给 G3ColorPicker 挂 data-testid —— 它的根是 G3Popover(多根:
|
||||
触发器 + Teleport),多根组件不做 attr 透传,testid 会被 Vue 丢弃。
|
||||
E2E 用 .g3-color-picker 类名定位。 -->
|
||||
<G3ColorPicker v-model="color" />
|
||||
<G3ColorPicker v-model="color" :presets="false" />
|
||||
<G3ColorPicker v-model="color" size="small" />
|
||||
<G3ColorPicker v-model="color" disabled />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="s-form">
|
||||
<h2>Form / Select / Date / Switch / Radio / Checkbox</h2>
|
||||
<G3Form :model="model" :label-width="90" data-testid="form">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ---------- 缩放手柄 ---------- */
|
||||
|
||||
Reference in new issue
Block a user