diff --git a/packages/menu/__tests__/Menu.spec.tsx b/packages/menu/__tests__/Menu.spec.tsx index 8ac9a7d20..09889a0a6 100644 --- a/packages/menu/__tests__/Menu.spec.tsx +++ b/packages/menu/__tests__/Menu.spec.tsx @@ -1,7 +1,7 @@ import type { MenuProps } from '../src'; import { Popover } from '@launchpad-ui/popover'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent, waitFor } from '../../../test/utils'; import { Menu, MenuDivider, MenuItem, MenuSearch } from '../src'; @@ -31,6 +31,16 @@ const createMenu = ({ ); describe('Menu', () => { + // https://github.com/TanStack/virtual/issues/641#issuecomment-2851908893 + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + value: 800, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + value: 800, + }); + }); + it('renders', () => { render(createMenu({ size: 'sm' })); expect(screen.getByRole('menu')).toBeInTheDocument(); @@ -39,7 +49,7 @@ describe('Menu', () => { it('renders with virtualization', () => { render(createMenu({ enableVirtualization: true })); const items = screen.getAllByRole('presentation'); - expect(items).toHaveLength(5); + expect(items).toHaveLength(7); }); it('renders the search field', () => { @@ -155,7 +165,7 @@ describe('Menu', () => { const user = userEvent.setup(); await user.click(screen.getByText('Target')); - const items = screen.getAllByRole('menuitem'); + const items = await screen.findAllByRole('menuitem'); expect(items[0]).toHaveFocus(); await user.keyboard('{arrowdown}'); diff --git a/packages/menu/package.json b/packages/menu/package.json index 85435e119..9b2623a31 100644 --- a/packages/menu/package.json +++ b/packages/menu/package.json @@ -45,7 +45,7 @@ "@react-aria/focus": "3.21.0", "@react-aria/separator": "3.4.11", "classix": "2.2.0", - "react-virtual": "2.10.4" + "@tanstack/react-virtual": "3.13.12" }, "peerDependencies": { "react": "19.1.1", diff --git a/packages/menu/src/Menu.tsx b/packages/menu/src/Menu.tsx index 8e0dfd714..96be505e6 100644 --- a/packages/menu/src/Menu.tsx +++ b/packages/menu/src/Menu.tsx @@ -3,18 +3,9 @@ import type { KeyboardEvent, ReactElement, ReactNode } from 'react'; import type { MenuItemProps } from './MenuItem'; import { useFocusManager } from '@react-aria/focus'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { cx } from 'classix'; -import { - Children, - cloneElement, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; -import { useVirtual } from 'react-virtual'; +import { Children, cloneElement, useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { MenuBase } from './MenuBase'; import { MenuDivider } from './MenuDivider'; @@ -213,15 +204,13 @@ const ItemVirtualizer = (props: ItemVirtualizerProps< const parentRef = useRef(null); const searchRef = useRef(null); - const [nextFocusValue, setNextFocusValue] = useState(null); - const hasSearch = !!searchElement; const lastVirtualItemIndex = items ? items.length - 1 : 0; - const rowVirtualizer = useVirtual({ - size: items !== null ? items.length : 0, - parentRef, + const rowVirtualizer = useVirtualizer({ + count: items !== null ? items.length : 0, + getScrollElement: () => parentRef.current, estimateSize: useCallback(() => itemHeight, [itemHeight]), overscan, }); @@ -238,7 +227,8 @@ const ItemVirtualizer = (props: ItemVirtualizerProps< const focusMenuItem = useCallback( (index: number) => { rowVirtualizer.scrollToIndex(index); - setNextFocusValue(index); + const element = getNodeForIndex(index, menuId.current); + element?.focus(); }, [rowVirtualizer], ); @@ -314,16 +304,10 @@ const ItemVirtualizer = (props: ItemVirtualizerProps< }, [handleKeyboardFocusInteraction, menuItemClassName, onSelect], ); - useEffect(() => { - if (nextFocusValue !== null) { - requestAnimationFrame(() => { - const element = getNodeForIndex(nextFocusValue, menuId.current); - element?.focus(); - }); - setNextFocusValue(null); - } - }, [nextFocusValue]); + const element = getNodeForIndex(0, menuId.current); + element?.focus(); + }, []); /** * Calls handleFocusForward when the user is attempting to focus forward using @@ -365,29 +349,25 @@ const ItemVirtualizer = (props: ItemVirtualizerProps< [searchElement, lastVirtualItemIndex, focusMenuItem], ); - const renderItems = useMemo( - () => - rowVirtualizer.virtualItems.map((virtualRow) => { - if (!items) { - return null; - } - const elem = items[virtualRow.index]; - return ( -
- {cloneElement(elem, getItemProps(elem, virtualRow.index))} -
- ); - }), - [rowVirtualizer.virtualItems, items, getItemProps], - ); + const renderItems = rowVirtualizer.getVirtualItems().map((virtualRow) => { + if (!items) { + return null; + } + const elem = items[virtualRow.index]; + return ( +
+ {cloneElement(elem, getItemProps(elem, virtualRow.index))} +
+ ); + }); return ( <> @@ -397,7 +377,7 @@ const ItemVirtualizer = (props: ItemVirtualizerProps< role="presentation" className={styles['VirtualMenu-item-list']} style={{ - height: `${rowVirtualizer.totalSize}px`, + height: `${rowVirtualizer.getTotalSize()}px`, }} > {renderItems} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77d28959c..35faacae8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -543,12 +543,12 @@ importers: '@react-aria/separator': specifier: 3.4.11 version: 3.4.11(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@tanstack/react-virtual': + specifier: 3.13.12 + version: 3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) classix: specifier: 2.2.0 version: 2.2.0 - react-virtual: - specifier: 2.10.4 - version: 2.10.4(react@19.1.1) devDependencies: react: specifier: 19.1.1 @@ -1573,9 +1573,6 @@ packages: '@types/react': optional: true - '@reach/observe-rect@1.2.0': - resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==} - '@react-aria/autocomplete@3.0.0-beta.6': resolution: {integrity: sha512-/i0Y1nJNSDk5k49tlApYfFCylZO597KQSMy4AbG60W6VNUw51QrmY9bzO3zdGAEVdPSuMys/72KwvV6LOpllyQ==} peerDependencies: @@ -2497,6 +2494,15 @@ packages: '@swc/types@0.1.21': resolution: {integrity: sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -4705,11 +4711,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-virtual@2.10.4: - resolution: {integrity: sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==} - peerDependencies: - react: ^16.6.3 || ^17.0.0 - react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -6545,8 +6546,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.10 - '@reach/observe-rect@1.2.0': {} - '@react-aria/autocomplete@3.0.0-beta.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@react-aria/combobox': 3.13.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -7846,6 +7845,14 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/react-virtual@3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + + '@tanstack/virtual-core@3.13.12': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 @@ -10378,11 +10385,6 @@ snapshots: '@react-types/shared': 3.31.0(react@19.1.1) react: 19.1.1 - react-virtual@2.10.4(react@19.1.1): - dependencies: - '@reach/observe-rect': 1.2.0 - react: 19.1.1 - react@19.1.1: {} read-yaml-file@1.1.0: