diff --git a/packages/trees/package.json b/packages/trees/package.json index 7cc43a8dc..501636987 100644 --- a/packages/trees/package.json +++ b/packages/trees/package.json @@ -50,7 +50,6 @@ "test": "bun test", "coverage": "bun test --coverage", "test:e2e": "bun run build && (bun ./test/e2e/check-playwright-binary.ts || bun run test:e2e:installbinary) && bunx playwright test -c test/e2e/playwright.config.ts", - "test:e2e:docs-anchor": "bun run build && (bun ./test/e2e/check-playwright-binary.ts || bun run test:e2e:installbinary) && PATH_STORE_DOCS_E2E=1 bunx playwright test -c test/e2e/playwright.config.ts test/e2e/path-store-docs-anchor.pw.ts --reporter=line", "test:e2e:installbinary": "bunx playwright@1.51.1 install chromium", "test:e2e:server": "vite --config test/e2e/vite.config.ts", "tsc": "tsgo --noEmit --pretty", diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index dd6723726..d1bf0e66b 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -202,10 +202,6 @@ const BLOCKED_CONTEXT_MENU_NAV_KEYS = new Set([ 'PageUp', ]); -const CONTEXT_MENU_ROW_ANCHOR_NAME = '--path-store-context-row'; -let cachedAnchorSupportCss: typeof CSS | null | undefined; -let cachedAnchorSupportValue: boolean | null = null; - function isEventInContextMenu(event: Event): boolean { for (const entry of event.composedPath()) { if (!(entry instanceof HTMLElement)) { @@ -254,25 +250,6 @@ function getContextMenuAnchorTop( return itemRect.top - scrollRect.top + scrollElement.scrollTop; } -function supportsContextMenuAnchorPositioning(): boolean { - const currentCss = typeof CSS === 'undefined' ? null : CSS; - if ( - cachedAnchorSupportCss === currentCss && - cachedAnchorSupportValue != null - ) { - return cachedAnchorSupportValue; - } - - cachedAnchorSupportCss = currentCss; - cachedAnchorSupportValue = - currentCss != null && - currentCss.supports('anchor-name', CONTEXT_MENU_ROW_ANCHOR_NAME) && - currentCss.supports('position-anchor', CONTEXT_MENU_ROW_ANCHOR_NAME) && - currentCss.supports('top', 'anchor(top)'); - - return cachedAnchorSupportValue; -} - function createContextMenuItem( row: PathStoreTreesVisibleRow, path: string @@ -329,8 +306,6 @@ function renderStyledRow( controller: PathStoreTreesController, row: PathStoreTreesVisibleRow, visualFocusPath: string | null, - contextAnchorName: string | null, - contextAnchorPath: string | null, contextHoverPath: string | null, itemHeight: number, contextMenuEnabled: boolean, @@ -361,14 +336,6 @@ function renderStyledRow( ? { 'data-item-focused': true } : {}; const selectedProps = row.isSelected ? { 'data-item-selected': true } : {}; - const contextAnchorProps = - contextAnchorPath === targetPath - ? { 'data-item-context-anchor': 'true' } - : {}; - const contextAnchorStyle = - contextAnchorPath === targetPath && contextAnchorName != null - ? { anchorName: contextAnchorName } - : undefined; const contextHoverProps = contextHoverPath === targetPath ? { 'data-item-context-hover': 'true' } @@ -424,14 +391,9 @@ function renderStyledRow( onKeyDown={onKeyDown} role="treeitem" tabIndex={row.isFocused ? 0 : -1} - style={{ - minHeight: `${itemHeight}px`, - ...contextAnchorStyle, - ...style, - }} + style={{ minHeight: `${itemHeight}px`, ...style }} {...focusedProps} {...selectedProps} - {...contextAnchorProps} {...contextHoverProps} > {/* @@ -483,8 +445,6 @@ function renderRangeChildren( controller: PathStoreTreesController, range: { start: number; end: number }, activeItemPath: string | null, - contextAnchorName: string | null, - contextAnchorPath: string | null, contextHoverPath: string | null, itemHeight: number, contextMenuEnabled: boolean, @@ -515,8 +475,6 @@ function renderRangeChildren( controller, row, activeItemPath, - contextAnchorName, - contextAnchorPath, contextHoverPath, itemHeight, contextMenuEnabled, @@ -589,8 +547,6 @@ export function PathStoreTreesView({ composition?.contextMenu?.render != null || composition?.contextMenu?.onOpen != null || composition?.contextMenu?.onClose != null; - const contextMenuUsesAnchorPositioning = - supportsContextMenuAnchorPositioning(); const { resolveIcon } = useMemo( () => createPathStoreIconResolver(icons), [icons] @@ -647,11 +603,6 @@ export function PathStoreTreesView({ closeContextMenuRef.current = closeContextMenu; const updateTriggerPosition = useCallback( (itemButton: HTMLButtonElement | null): void => { - if (contextMenuUsesAnchorPositioning) { - setContextMenuAnchorTop(null); - return; - } - const nextTop = itemButton == null ? null @@ -660,7 +611,7 @@ export function PathStoreTreesView({ previousTop === nextTop ? previousTop : nextTop ); }, - [contextMenuUsesAnchorPositioning] + [] ); const openContextMenuForRow = useCallback( (row: PathStoreTreesVisibleRow, targetPath: string): void => { @@ -1185,12 +1136,7 @@ export function PathStoreTreesView({ const guideStyleText = getPathStoreGuideStyleText( focusedVisibleRow?.ancestorPaths.at(-1) ?? null ); - const contextAnchorName = - contextMenuUsesAnchorPositioning && triggerPath != null - ? CONTEXT_MENU_ROW_ANCHOR_NAME - : null; const visualFocusPath = contextMenuState?.path ?? activeItemPath; - const contextAnchorPath = triggerPath; const visualContextHoverPath = contextMenuState?.path ?? contextHoverPath; const triggerButton = triggerPath == null @@ -1199,21 +1145,14 @@ export function PathStoreTreesView({ const triggerButtonVisible = contextMenuEnabled && triggerButton != null && - triggerPath != null && - (contextMenuUsesAnchorPositioning || contextMenuAnchorTop != null); + contextMenuAnchorTop != null && + triggerPath != null; const contextMenuAnchorStyle = - contextMenuUsesAnchorPositioning && contextAnchorName != null + triggerButtonVisible && contextMenuAnchorTop != null ? { - positionAnchor: contextAnchorName, - top: 'anchor(top)', + top: `${contextMenuAnchorTop}px`, } - : !contextMenuUsesAnchorPositioning && - triggerButtonVisible && - contextMenuAnchorTop != null - ? { - top: `${contextMenuAnchorTop}px`, - } - : undefined; + : undefined; const openMenuFromTrigger = (): void => { if (triggerPath == null || triggerButton == null) { return; @@ -1278,8 +1217,6 @@ export function PathStoreTreesView({ controller, range, visualFocusPath, - contextAnchorName, - contextAnchorPath, visualContextHoverPath, itemHeight, contextMenuEnabled, @@ -1301,8 +1238,6 @@ export function PathStoreTreesView({ controller, parkedFocusedRow, visualFocusPath, - contextAnchorName, - contextAnchorPath, visualContextHoverPath, itemHeight, contextMenuEnabled, @@ -1336,9 +1271,6 @@ export function PathStoreTreesView({
diff --git a/packages/trees/test/e2e/path-store-composition.pw.ts b/packages/trees/test/e2e/path-store-composition.pw.ts index be7791adc..96dc9ea0d 100644 --- a/packages/trees/test/e2e/path-store-composition.pw.ts +++ b/packages/trees/test/e2e/path-store-composition.pw.ts @@ -33,6 +33,83 @@ async function pressFocusedRowKey(page: Page, key: string): Promise { } test.describe('path-store composition surfaces', () => { + test('hovering a scrolled tree does not change the visible slice or scroll position', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/path-store-composition.html'); + await page.waitForFunction( + () => window.__pathStoreCompositionFixtureReady === true + ); + + const measurement = await page.evaluate(async () => { + const host = document.querySelector('file-tree-container'); + const shadowRoot = host?.shadowRoot; + const scroll = shadowRoot?.querySelector( + '[data-file-tree-virtualized-scroll="true"]' + ); + if (!(scroll instanceof HTMLElement)) { + return null; + } + + const nextFrame = () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + + const pickPaths = [ + 'src/lib/theme.ts', + 'src/lib/utils.ts', + 'src/index.ts', + 'README.md', + ]; + const measure = () => ({ + rows: pickPaths.map((path) => { + const row = shadowRoot?.querySelector( + `button[data-item-path="${path}"]` + ); + return row instanceof HTMLElement + ? { + path, + top: row.getBoundingClientRect().top, + } + : { + path, + top: null, + }; + }), + scrollTop: scroll.scrollTop, + }); + + scroll.scrollTop = 60; + await nextFrame(); + await nextFrame(); + + const before = measure(); + const hoverRow = shadowRoot?.querySelector( + 'button[data-item-path="src/index.ts"]' + ); + if (!(hoverRow instanceof HTMLElement)) { + return { before, after: null }; + } + + hoverRow.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true, composed: true }) + ); + await nextFrame(); + await nextFrame(); + + return { + after: measure(), + before, + }; + }); + + expect(measurement).not.toBeNull(); + expect(measurement?.after).not.toBeNull(); + expect(measurement?.after?.scrollTop).toBe(measurement?.before.scrollTop); + expect(measurement?.after?.rows).toEqual(measurement?.before.rows); + }); + test('moves the floating trigger when the active row changes', async ({ page, }) => { diff --git a/packages/trees/test/e2e/path-store-docs-anchor.pw.ts b/packages/trees/test/e2e/path-store-docs-anchor.pw.ts deleted file mode 100644 index 019a371af..000000000 --- a/packages/trees/test/e2e/path-store-docs-anchor.pw.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, test } from '@playwright/test'; - -const PATH_STORE_DOCS_BASE_URL = - process.env.PATH_STORE_DOCS_BASE_URL ?? 'http://127.0.0.1:3103'; - -test.describe('path-store docs route', () => { - test.skip( - process.env.PATH_STORE_DOCS_E2E !== '1', - 'Manual docs-route verification; requires a running docs server.' - ); - - test('moves the floating trigger when the active row changes in the docs demo', async ({ - page, - }) => { - await page.goto(`${PATH_STORE_DOCS_BASE_URL}/trees-dev/path-store-powered`); - - const nextFrame = async () => { - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }) - ); - }; - - const getTriggerMeasurement = async ( - path: string - ): Promise<{ - anchoredPath: string | null; - rowTop: number; - triggerTop: number; - visible: string | null; - } | null> => - page.evaluate((nextPath) => { - const host = document.querySelector('file-tree-container'); - const shadowRoot = host?.shadowRoot; - const row = shadowRoot?.querySelector( - `button[data-item-path="${nextPath}"]` - ); - if (!(row instanceof HTMLElement)) { - return null; - } - - const trigger = shadowRoot?.querySelector( - 'button[data-type="context-menu-trigger"]' - ); - const anchoredRow = shadowRoot?.querySelector( - 'button[data-item-context-anchor="true"]' - ); - if (!(trigger instanceof HTMLElement)) { - return null; - } - - const triggerRect = trigger.getBoundingClientRect(); - const rowRect = row.getBoundingClientRect(); - return { - anchoredPath: - anchoredRow instanceof HTMLElement - ? (anchoredRow.dataset.itemPath ?? null) - : null, - rowTop: rowRect.top, - triggerTop: triggerRect.top, - visible: trigger.dataset.visible ?? null, - }; - }, path); - - await expect( - page.locator('file-tree-container button[data-item-path="arch/"]') - ).toBeVisible(); - - const firstRow = page.locator( - 'file-tree-container button[data-item-path="arch/"]' - ); - await firstRow.hover(); - await nextFrame(); - await nextFrame(); - const firstMeasurement = await getTriggerMeasurement('arch/'); - expect(firstMeasurement).not.toBeNull(); - expect(firstMeasurement?.visible).toBe('true'); - expect(firstMeasurement?.anchoredPath).toBe('arch/'); - - const secondRow = page.locator( - 'file-tree-container button[data-item-path="arch/alpha/boot/"]' - ); - await secondRow.hover(); - await nextFrame(); - await nextFrame(); - const secondMeasurement = await getTriggerMeasurement('arch/alpha/boot/'); - expect(secondMeasurement).not.toBeNull(); - expect(secondMeasurement?.visible).toBe('true'); - expect(secondMeasurement?.anchoredPath).toBe('arch/alpha/boot/'); - expect(secondMeasurement?.triggerTop).toBeGreaterThan( - (firstMeasurement?.triggerTop ?? 0) + 20 - ); - }); -}); diff --git a/packages/trees/test/path-store-composition-surfaces.test.ts b/packages/trees/test/path-store-composition-surfaces.test.ts index 8f5c4d9d7..cde7df237 100644 --- a/packages/trees/test/path-store-composition-surfaces.test.ts +++ b/packages/trees/test/path-store-composition-surfaces.test.ts @@ -767,75 +767,6 @@ describe('path-store composition surfaces', () => { } }); - test('uses CSS anchor positioning for the floating trigger when supported', async () => { - const { cleanup, dom } = installDom(); - const originalCss = Reflect.get(globalThis, 'CSS'); - try { - Object.assign(globalThis, { - CSS: { - supports(property: string, value?: string): boolean { - return ( - (property === 'anchor-name' && - value === '--path-store-context-row') || - (property === 'position-anchor' && - value === '--path-store-context-row') || - (property === 'top' && value === 'anchor(top)') - ); - }, - }, - }); - - const { PathStoreFileTree } = await import('../src/path-store'); - const mount = dom.window.document.createElement('div'); - dom.window.document.body.appendChild(mount); - - const fileTree = new PathStoreFileTree({ - composition: { - contextMenu: { - enabled: true, - }, - }, - flattenEmptyDirectories: true, - initialExpansion: 'open', - paths: ['README.md', 'src/index.ts'], - viewportHeight: 120, - }); - - fileTree.render({ containerWrapper: mount }); - await flushDom(); - - const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; - const itemButton = getItemButton(shadowRoot, dom, 'README.md'); - itemButton.dispatchEvent( - new dom.window.Event('pointerover', { bubbles: true, composed: true }) - ); - await flushDom(); - - const contextMenuAnchor = shadowRoot?.querySelector( - '[data-type="context-menu-anchor"]' - ); - expect(contextMenuAnchor?.getAttribute('data-anchor-positioning')).toBe( - 'true' - ); - expect(itemButton.dataset.itemContextAnchor).toBe('true'); - expect(itemButton.getAttribute('style')).toContain( - 'anchor-name: --path-store-context-row;' - ); - expect(contextMenuAnchor?.getAttribute('style')).toContain( - 'position-anchor: --path-store-context-row;' - ); - - fileTree.cleanUp(); - } finally { - if (originalCss === undefined) { - Reflect.deleteProperty(globalThis, 'CSS'); - } else { - Object.assign(globalThis, { CSS: originalCss }); - } - cleanup(); - } - }); - test('adds aria-haspopup=menu only when context menu is enabled', async () => { const { cleanup, dom } = installDom(); try {