From 317c75b3ba7d76f40f08e5e00db7a7d342396d77 Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Thu, 13 Aug 2026 19:29:56 +0800 Subject: [PATCH 1/3] fix(sidebar): keep navigation hover under pointer --- src/__tests__/scroll-hover.test.ts | 106 ++++++++++++++++++++++ src/components/settings/NavItem.vue | 1 + src/components/sidebar/SidebarContent.vue | 10 ++ src/lib/scroll-hover.ts | 82 +++++++++++++++++ src/style.css | 12 ++- 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/scroll-hover.test.ts create mode 100644 src/lib/scroll-hover.ts diff --git a/src/__tests__/scroll-hover.test.ts b/src/__tests__/scroll-hover.test.ts new file mode 100644 index 0000000..28a2798 --- /dev/null +++ b/src/__tests__/scroll-hover.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { FrameScheduler } from '../lib/scroll-hover' +import { createScrollHover } from '../lib/scroll-hover' + +function createScheduler() { + let nextHandle = 1 + const callbacks = new Map() + const scheduler: FrameScheduler = { + request(callback) { + const handle = nextHandle++ + callbacks.set(handle, callback) + return handle + }, + cancel(handle) { + callbacks.delete(handle) + }, + } + return { + scheduler, + flush() { + const pending = [...callbacks.values()] + callbacks.clear() + pending.forEach(callback => callback(0)) + }, + pending: () => callbacks.size, + } +} + +function createFixture() { + function createItem() { + const attributes = new Set() + const item = { + closest: () => item, + setAttribute: (name: string) => attributes.add(name), + removeAttribute: (name: string) => attributes.delete(name), + } as unknown as HTMLElement + return { attributes, item } + } + + const first = createItem() + const second = createItem() + let hit: Element | null = first.item + const scope = { + contains: (target: Element) => target === first.item || target === second.item, + ownerDocument: { + elementFromPoint: () => hit, + }, + } as unknown as HTMLElement + return { + first, + second, + scope, + setHit: (target: Element | null) => hit = target, + } +} + +test('moves hover to the element under the stationary pointer while scrolling', () => { + const frame = createScheduler() + const fixture = createFixture() + const hover = createScrollHover(frame.scheduler) + + hover.pointerMove({ clientX: 20, clientY: 30, currentTarget: fixture.scope } as unknown as PointerEvent) + assert.equal(fixture.first.attributes.has('data-pointer-hover'), true) + + fixture.setHit(fixture.second.item) + hover.start({ currentTarget: fixture.scope } as unknown as Event) + frame.flush() + + assert.equal(fixture.first.attributes.has('data-pointer-hover'), false) + assert.equal(fixture.second.attributes.has('data-pointer-hover'), true) + assert.equal(frame.pending(), 1) +}) + +test('keeps syncing frames until scrollend', () => { + const frame = createScheduler() + const fixture = createFixture() + const hover = createScrollHover(frame.scheduler) + + hover.start({ currentTarget: fixture.scope } as unknown as Event) + assert.equal(frame.pending(), 1) + + frame.flush() + assert.equal(frame.pending(), 1) + + hover.end({ currentTarget: fixture.scope } as unknown as Event) + frame.flush() + assert.equal(frame.pending(), 0) +}) + +test('pointerleave and dispose clear managed hover state', () => { + const frame = createScheduler() + const fixture = createFixture() + const hover = createScrollHover(frame.scheduler) + + hover.pointerMove({ clientX: 20, clientY: 30, currentTarget: fixture.scope } as unknown as PointerEvent) + hover.pointerLeave() + assert.equal(fixture.first.attributes.has('data-pointer-hover'), false) + + hover.pointerMove({ clientX: 20, clientY: 30, currentTarget: fixture.scope } as unknown as PointerEvent) + hover.end({ currentTarget: fixture.scope } as unknown as Event) + hover.dispose() + + assert.equal(fixture.first.attributes.has('data-pointer-hover'), false) + assert.equal(frame.pending(), 0) +}) diff --git a/src/components/settings/NavItem.vue b/src/components/settings/NavItem.vue index a80c14b..075475c 100644 --- a/src/components/settings/NavItem.vue +++ b/src/components/settings/NavItem.vue @@ -26,6 +26,7 @@ const rowClass = 'w-full justify-start gap-2.5 pl-3.5! pr-3! font-[430] tracking