From 9d87ada028d0f4bf55202eb31ad0910ff274d0c4 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 26 Apr 2026 14:46:07 +0200 Subject: [PATCH] test(react-list): add unit tests for useList_unstable and useListItem_unstable; fix operator precedence in as prop Tests cover default state, selection modes, navigation modes, and the composite-mode as-prop branching. The as-prop fix corrects a precedence bug where `props.as || navigationMode === 'composite' ? 'div' : ...` was evaluated as `(props.as || navigationMode === 'composite') ? ...`. Co-Authored-By: Claude Sonnet 4.6 --- ...-b1d5584a-1533-4c65-96b4-988e2060b18f.json | 7 + .../src/components/List/useList.test.ts | 96 +++++++++++++ .../library/src/components/List/useList.ts | 2 +- .../components/ListItem/useListItem.test.tsx | 131 ++++++++++++++++++ .../src/components/ListItem/useListItem.tsx | 2 +- 5 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 change/@fluentui-react-list-b1d5584a-1533-4c65-96b4-988e2060b18f.json create mode 100644 packages/react-components/react-list/library/src/components/List/useList.test.ts create mode 100644 packages/react-components/react-list/library/src/components/ListItem/useListItem.test.tsx diff --git a/change/@fluentui-react-list-b1d5584a-1533-4c65-96b4-988e2060b18f.json b/change/@fluentui-react-list-b1d5584a-1533-4c65-96b4-988e2060b18f.json new file mode 100644 index 0000000000000..0e4f6c31d5ee2 --- /dev/null +++ b/change/@fluentui-react-list-b1d5584a-1533-4c65-96b4-988e2060b18f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: fix useList and useListItem as prop handling", + "packageName": "@fluentui/react-list", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-list/library/src/components/List/useList.test.ts b/packages/react-components/react-list/library/src/components/List/useList.test.ts new file mode 100644 index 0000000000000..0c77a08407632 --- /dev/null +++ b/packages/react-components/react-list/library/src/components/List/useList.test.ts @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useList_unstable } from './useList'; + +describe('useList_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('returns default state for non-selectable list', () => { + const { result } = renderHook(() => useList_unstable({}, ref)); + + expect(result.current).toMatchObject({ + components: { + root: 'ul', + }, + root: expect.objectContaining({ + ref, + role: 'list', + }), + listItemRole: 'listitem', + navigationMode: undefined, + selection: undefined, + }); + }); + + it('returns selection state and listbox role when selectionMode is enabled', () => { + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useList_unstable( + { + selectionMode: 'single', + onSelectionChange, + }, + ref, + ), + ); + + expect(result.current.root).toMatchObject({ + role: 'listbox', + }); + expect(result.current.selection).toBeDefined(); + + act(() => { + result.current.selection?.toggleItem({} as React.SyntheticEvent, 'item-1'); + }); + + expect(result.current.selection?.selectedItems).toEqual(['item-1']); + expect(onSelectionChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: 'change', + selectedItems: ['item-1'], + }), + ); + }); + + it('uses div root and grid roles in composite navigation mode', () => { + const { result } = renderHook(() => useList_unstable({ navigationMode: 'composite' }, ref)); + + expect(result.current).toMatchObject({ + components: { + root: 'div', + }, + root: expect.objectContaining({ + role: 'grid', + }), + listItemRole: 'row', + navigationMode: 'composite', + }); + }); + + it('respects an explicit as prop in returned root component', () => { + const { result } = renderHook(() => useList_unstable({ as: 'ol' }, ref)); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components.root).toBe('ol'); + expect(result.current.root.role).toBe('list'); + }); + it('respects explicit as prop even in composite navigation mode', () => { + const { result } = renderHook(() => useList_unstable({ as: 'ol', navigationMode: 'composite' }, ref)); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components.root).toBe('ol'); + }); + + it('determines default root component based on composite navigation mode', () => { + const { result } = renderHook(() => useList_unstable({ navigationMode: 'composite' }, ref)); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components.root).toBe('div'); + }); +}); diff --git a/packages/react-components/react-list/library/src/components/List/useList.ts b/packages/react-components/react-list/library/src/components/List/useList.ts index 7b6bdc7833ddd..8e81df8fe6003 100644 --- a/packages/react-components/react-list/library/src/components/List/useList.ts +++ b/packages/react-components/react-list/library/src/components/List/useList.ts @@ -31,7 +31,7 @@ export const useList_unstable = ( ): ListState => { const { navigationMode, selectionMode, selectedItems, defaultSelectedItems, onSelectionChange } = props; - const as = props.as || navigationMode === 'composite' ? 'div' : DEFAULT_ROOT_EL_TYPE; + const as = props.as || (navigationMode === 'composite' ? 'div' : DEFAULT_ROOT_EL_TYPE); const arrowNavigationAttributes = useArrowNavigationGroup({ axis: 'vertical', diff --git a/packages/react-components/react-list/library/src/components/ListItem/useListItem.test.tsx b/packages/react-components/react-list/library/src/components/ListItem/useListItem.test.tsx new file mode 100644 index 0000000000000..d6b38f17f4fea --- /dev/null +++ b/packages/react-components/react-list/library/src/components/ListItem/useListItem.test.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { Space } from '@fluentui/keyboard-keys'; + +import { useListItem_unstable } from './useListItem'; +import { ListContextProvider, ListSynchronousContextProvider } from '../List/listContext'; +import type { ListContextValue, ListSynchronousContextValue } from '../List/List.types'; +import type { ListSelectionState } from '../../hooks/types'; + +describe('useListItem_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('returns default state when list context is not provided', () => { + const { result } = renderHook(() => useListItem_unstable({}, ref)); + + expect(result.current).toMatchObject({ + components: { + root: 'li', + }, + root: expect.objectContaining({ + role: 'listitem', + }), + selectable: false, + navigable: false, + checkmark: undefined, + }); + }); + + it('uses div root and row role in composite navigation mode', () => { + const wrapper: React.FC = ({ children }) => { + const listContextValue: ListContextValue = { + selection: undefined, + validateListItem: jest.fn(), + }; + const syncContextValue: ListSynchronousContextValue = { + navigationMode: 'composite', + listItemRole: 'row', + }; + + return ( + + {children} + + ); + }; + + const { result } = renderHook(() => useListItem_unstable({}, ref), { wrapper }); + + expect(result.current).toMatchObject({ + components: { + root: 'div', + }, + root: expect.objectContaining({ + role: 'row', + }), + navigable: true, + }); + }); + + it('respects an explicit as prop in returned root component', () => { + const { result } = renderHook(() => useListItem_unstable({ as: 'li' }, ref)); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components.root).toBe('li'); + }); + + it('returns selectable state and toggles selection with keyboard action', () => { + const toggleItem = jest.fn(); + const selection: ListSelectionState = { + isSelected: () => true, + toggleItem, + deselectItem: jest.fn(), + selectItem: jest.fn(), + clearSelection: jest.fn(), + toggleAllItems: jest.fn(), + setSelectedItems: jest.fn(), + selectedItems: ['item-1'], + }; + + const wrapper: React.FC = ({ children }) => { + const listContextValue: ListContextValue = { + selection, + validateListItem: jest.fn(), + }; + const syncContextValue: ListSynchronousContextValue = { + navigationMode: 'items', + listItemRole: 'option', + }; + + return ( + + {children} + + ); + }; + + const { result } = renderHook(() => useListItem_unstable({ value: 'item-1' }, ref), { wrapper }); + + expect(result.current).toMatchObject({ + selectable: true, + navigable: true, + root: expect.objectContaining({ + role: 'option', + 'aria-selected': true, + }), + checkmark: expect.objectContaining({ + checked: true, + }), + }); + + const target = document.createElement('div'); + const event = { + key: Space, + target, + currentTarget: target, + preventDefault: jest.fn(), + defaultPrevented: false, + } as unknown as React.KeyboardEvent & React.KeyboardEvent; + + act(() => { + result.current.root.onKeyDown?.(event); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(toggleItem).toHaveBeenCalledWith(event, 'item-1'); + }); +}); diff --git a/packages/react-components/react-list/library/src/components/ListItem/useListItem.tsx b/packages/react-components/react-list/library/src/components/ListItem/useListItem.tsx index d14a4c547fd35..7eb883bf3bf03 100644 --- a/packages/react-components/react-list/library/src/components/ListItem/useListItem.tsx +++ b/packages/react-components/react-list/library/src/components/ListItem/useListItem.tsx @@ -54,7 +54,7 @@ export const useListItem_unstable = ( const isSelected = useListContext_unstable(ctx => ctx.selection?.isSelected(value)) ?? false; const validateListItem = useListContext_unstable(ctx => ctx.validateListItem); - const as = props.as || navigationMode === 'composite' ? 'div' : DEFAULT_ROOT_EL_TYPE; + const as = props.as || (navigationMode === 'composite' ? 'div' : DEFAULT_ROOT_EL_TYPE); const finalListItemRole = role || listItemRole;