From 8072ecab76f0a0e9e3edc975f58e459588f41d3f Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 18 Aug 2026 15:39:49 +0200 Subject: [PATCH 1/3] feat(react-headless-components-preview): add headless List Add design-free List and ListItem wrappers, public exports, API docs, package wiring, bundle coverage, tests, and a beachball change file. --- ...-954cd8f9-e8ae-4727-937b-19903b24a000.json | 7 + .../bundle-size/AllComponents.fixture.js | 2 + .../library/etc/list.api.md | 93 ++++++++++ .../library/package.json | 11 ++ .../library/src/components/List/List.test.tsx | 153 +++++++++++++++++ .../library/src/components/List/List.tsx | 22 +++ .../library/src/components/List/List.types.ts | 24 +++ .../List/ListItem/ListItem.test.tsx | 159 ++++++++++++++++++ .../src/components/List/ListItem/ListItem.tsx | 21 +++ .../List/ListItem/ListItem.types.ts | 29 ++++ .../src/components/List/ListItem/index.ts | 10 ++ .../List/ListItem/renderListItem.ts | 9 + .../components/List/ListItem/useListItem.ts | 27 +++ .../library/src/components/List/index.ts | 15 ++ .../library/src/components/List/renderList.ts | 1 + .../library/src/components/List/useList.ts | 37 ++++ .../library/src/list.ts | 19 +++ yarn.lock | 1 + 18 files changed, 640 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-954cd8f9-e8ae-4727-937b-19903b24a000.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/list.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/List.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/List.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/List.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/renderListItem.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/useListItem.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/renderList.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/List/useList.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/list.ts diff --git a/change/@fluentui-react-headless-components-preview-954cd8f9-e8ae-4727-937b-19903b24a000.json b/change/@fluentui-react-headless-components-preview-954cd8f9-e8ae-4727-937b-19903b24a000.json new file mode 100644 index 00000000000000..dcc22ed0847117 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-954cd8f9-e8ae-4727-937b-19903b24a000.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless List and ListItem", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index dece7d02977c84..4fd5a18f97c281 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -20,6 +20,7 @@ import * as Input from '@fluentui/react-headless-components-preview/input'; import * as InteractionTag from '@fluentui/react-headless-components-preview/interaction-tag'; import * as Label from '@fluentui/react-headless-components-preview/label'; import * as Link from '@fluentui/react-headless-components-preview/link'; +import * as List from '@fluentui/react-headless-components-preview/list'; import * as Menu from '@fluentui/react-headless-components-preview/menu'; import * as MessageBar from '@fluentui/react-headless-components-preview/message-bar'; import * as Nav from '@fluentui/react-headless-components-preview/nav'; @@ -74,6 +75,7 @@ console.log({ InteractionTag, Label, Link, + List, Menu, MessageBar, Nav, diff --git a/packages/react-components/react-headless-components-preview/library/etc/list.api.md b/packages/react-components/react-headless-components-preview/library/etc/list.api.md new file mode 100644 index 00000000000000..d265b7443bd89e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/list.api.md @@ -0,0 +1,93 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ContextSelector } from '@fluentui/react-context-selector'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; +import { ListContextValue } from '@fluentui/react-list'; +import { ListContextValues as ListContextValues_2 } from '@fluentui/react-list'; +import { ListItemActionEventData } from '@fluentui/react-list'; +import type { ListItemBaseProps } from '@fluentui/react-list'; +import type { ListItemBaseSlots } from '@fluentui/react-list'; +import type { ListItemBaseState } from '@fluentui/react-list'; +import { ListItemValue } from '@fluentui/react-list'; +import { ListNavigationMode } from '@fluentui/react-list'; +import type { ListProps as ListProps_2 } from '@fluentui/react-list'; +import type { ListSlots as ListSlots_2 } from '@fluentui/react-list'; +import { ListState as ListState_2 } from '@fluentui/react-list'; +import { OnListSelectionChangeData } from '@fluentui/react-list'; +import type * as React_2 from 'react'; + +// @public +export const List: ForwardRefComponent; + +export { ListContextValue } + +// @public (undocumented) +export type ListContextValues = ListContextValues_2; + +// @public +export const ListItem: ForwardRefComponent; + +export { ListItemActionEventData } + +// @public (undocumented) +export type ListItemProps = ListItemBaseProps; + +// @public (undocumented) +export type ListItemSlots = ListItemBaseSlots; + +// @public (undocumented) +export type ListItemState = ListItemBaseState & { + root: { + 'data-selectable'?: string; + 'data-navigable'?: string; + 'data-selected'?: string; + 'data-disabled'?: string; + }; +}; + +export { ListItemValue } + +export { ListNavigationMode } + +// @public (undocumented) +export type ListProps = ListProps_2; + +// @public (undocumented) +export type ListSlots = ListSlots_2; + +// @public (undocumented) +export type ListState = ListState_2 & { + root: { + 'data-navigation-mode'?: ListNavigationMode; + 'data-selectable'?: string; + }; +}; + +export { OnListSelectionChangeData } + +// @public +export const renderList: (state: ListState_2, contextValues: ListContextValues_2) => JSXElement; + +// @public +export const renderListItem: (state: ListItemState) => JSXElement; + +// @public +export const useList: (props: ListProps, ref: React_2.Ref) => ListState; + +// @public +export const useListContext: (selector: ContextSelector) => T; + +// @public +export const useListContextValues: (state: ListState) => ListContextValues; + +// @public +export const useListItem: (props: ListItemProps, ref: React_2.Ref) => ListItemState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index ae979fed08df40..0393d1db9f9257 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -40,6 +40,7 @@ "@fluentui/react-jsx-runtime": "^9.4.5", "@fluentui/react-label": "^9.4.4", "@fluentui/react-link": "^9.8.4", + "@fluentui/react-list": "^9.6.17", "@fluentui/react-menu": "^9.25.3", "@fluentui/react-message-bar": "^9.7.5", "@fluentui/react-nav": "^9.4.4", @@ -287,6 +288,16 @@ "default": "./lib-commonjs/link.cjs" } }, + "./list": { + "import": { + "types": "./dist/list.d.ts", + "default": "./lib/list.js" + }, + "require": { + "types": "./dist/list.d.cts", + "default": "./lib-commonjs/list.cjs" + } + }, "./menu": { "import": { "types": "./dist/menu.d.ts", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/List.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/List/List.test.tsx new file mode 100644 index 00000000000000..76fcdb5e45d82a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/List.test.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { List } from './List'; +import { ListItem } from './ListItem/ListItem'; + +describe('List', () => { + isConformant({ + Component: List, + displayName: 'List', + }); + + // The list warns about role/navigation combinations that Tabster cannot verify in jsdom. + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => jest.fn()); + + afterAll(() => { + consoleWarn.mockRestore(); + }); + + it('renders a default state', () => { + const { getByRole, getAllByRole } = render( + + First item + Second item + , + ); + + expect(getByRole('list').tagName).toBe('UL'); + expect(getAllByRole('listitem')).toHaveLength(2); + expect(getAllByRole('listitem')[0].tagName).toBe('LI'); + }); + + it('does not set navigation or selection data attributes by default', () => { + const { getByRole } = render( + + First item + , + ); + + expect(getByRole('list')).not.toHaveAttribute('data-navigation-mode'); + expect(getByRole('list')).not.toHaveAttribute('data-selectable'); + }); + + it.each(['items', 'composite'] as const)('sets data-navigation-mode to %s', navigationMode => { + const { container } = render( + + + First item + + , + ); + + expect(container.firstElementChild).toHaveAttribute('data-navigation-mode', navigationMode); + }); + + it('sets data-selectable and the listbox semantics when selection is enabled', () => { + const { getByRole, getAllByRole } = render( + + First item + Second item + , + ); + + const list = getByRole('listbox'); + + expect(list).toHaveAttribute('data-selectable', ''); + expect(list).toHaveAttribute('aria-multiselectable', 'true'); + expect(getAllByRole('option')).toHaveLength(2); + }); + + it('renders a native checkbox as the checkmark slot', () => { + const { getAllByRole } = render( + + First item + , + ); + + const checkbox = getAllByRole('checkbox')[0]; + + expect(checkbox.tagName).toBe('INPUT'); + expect(checkbox).toHaveAttribute('type', 'checkbox'); + expect(checkbox.closest('.fui-Checkbox')).toBeNull(); + }); + + it('selects and deselects an item in multiselect mode', () => { + const onSelectionChange = jest.fn(); + + const { getAllByRole } = render( + + First item + Second item + , + ); + + const [first] = getAllByRole('option'); + + expect(first).toHaveAttribute('aria-selected', 'false'); + expect(first).not.toHaveAttribute('data-selected'); + + fireEvent.click(first); + expect(first).toHaveAttribute('aria-selected', 'true'); + expect(first).toHaveAttribute('data-selected', ''); + expect(onSelectionChange).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ selectedItems: ['one'] }), + ); + + fireEvent.click(first); + expect(first).toHaveAttribute('aria-selected', 'false'); + expect(first).not.toHaveAttribute('data-selected'); + expect(onSelectionChange).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ selectedItems: [] }), + ); + }); + + it('supports controlled selection', () => { + const { getAllByRole, rerender } = render( + + First item + Second item + , + ); + + const [first, second] = getAllByRole('option'); + expect(first).toHaveAttribute('data-selected', ''); + expect(second).not.toHaveAttribute('data-selected'); + + // Controlled state ignores the click until the consumer updates the prop. + fireEvent.click(second); + expect(first).toHaveAttribute('data-selected', ''); + + rerender( + + First item + Second item + , + ); + + expect(getAllByRole('option')[0]).not.toHaveAttribute('data-selected'); + expect(getAllByRole('option')[1]).toHaveAttribute('data-selected', ''); + }); + + it('does not let a consumer misrepresent the reserved selection attribute', () => { + const { getByRole } = render( + + First item + , + ); + + expect(getByRole('list')).not.toHaveAttribute('data-selectable'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/List.tsx b/packages/react-components/react-headless-components-preview/library/src/components/List/List.tsx new file mode 100644 index 00000000000000..cc45c819f6da87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/List.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ListProps } from './List.types'; +import { useList, useListContextValues } from './useList'; +import { renderList } from './renderList'; + +/** + * Represents a collection of `ListItem` children, optionally with selection support. + * + * The list owns the `list` / `listbox` / `grid` role and coordinates selection, so `ListItem` + * must always be rendered inside a `List`. + */ +export const List: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useList(props, ref); + const contextValues = useListContextValues(state); + + return renderList(state, contextValues); +}); + +List.displayName = 'List'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/List.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/List.types.ts new file mode 100644 index 00000000000000..a049de2aec77b9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/List.types.ts @@ -0,0 +1,24 @@ +import type { ListState as ListBaseState, ListNavigationMode } from '@fluentui/react-list'; + +export type { + ListSlots, + ListProps, + ListContextValues, + ListNavigationMode, + OnListSelectionChangeData, + ListContextValue, +} from '@fluentui/react-list'; + +export type ListState = ListBaseState & { + root: { + /** + * Data attribute reflecting the navigation mode. Value is `items` or `composite`, and the + * attribute is absent when no navigation mode is set. + */ + 'data-navigation-mode'?: ListNavigationMode; + /** + * Data attribute set when the list manages selection for its items. + */ + 'data-selectable'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.test.tsx new file mode 100644 index 00000000000000..aee3b7ee8ddd22 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.test.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { List } from '../List'; +import { ListItem } from './ListItem'; + +describe('ListItem', () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => jest.fn()); + + afterAll(() => { + consoleWarn.mockRestore(); + }); + + it('forwards ref, className and style to the root element', () => { + const ref = React.createRef(); + + const { getByRole } = render( + + + First item + + , + ); + + const item = getByRole('listitem'); + + expect(ref.current).toBe(item); + expect(item).toHaveClass('custom'); + expect(item).toHaveStyle({ marginBlockStart: '4px' }); + }); + + it('is not focusable or selectable by default', () => { + const { getByRole } = render( + + First item + , + ); + + const item = getByRole('listitem'); + + expect(item).not.toHaveAttribute('tabindex'); + expect(item).not.toHaveAttribute('data-navigable'); + expect(item).not.toHaveAttribute('data-selectable'); + expect(item).not.toHaveAttribute('data-disabled'); + }); + + it('sets data-navigable and data-selectable when the parent list is selectable', () => { + const { getByRole } = render( + + First item + , + ); + + const item = getByRole('option'); + + expect(item).toHaveAttribute('tabindex', '0'); + expect(item).toHaveAttribute('data-navigable', ''); + expect(item).toHaveAttribute('data-selectable', ''); + }); + + it('toggles selection with the Space key and reports the inverse action', () => { + const { getByRole } = render( + + First item + , + ); + + const item = getByRole('option'); + + fireEvent.keyDown(item, { key: ' ' }); + expect(item).toHaveAttribute('data-selected', ''); + + fireEvent.keyDown(item, { key: ' ' }); + expect(item).not.toHaveAttribute('data-selected'); + }); + + it('toggles selection through the checkmark slot', () => { + const { getByRole } = render( + + First item + , + ); + + const checkbox = getByRole('checkbox') as HTMLInputElement; + + fireEvent.click(checkbox); + expect(getByRole('option')).toHaveAttribute('data-selected', ''); + expect(checkbox.checked).toBe(true); + }); + + it('calls onAction with the item value on Enter', () => { + const onAction = jest.fn(); + + const { getByRole } = render( + + + First item + + , + ); + + fireEvent.keyDown(getByRole('listitem'), { key: 'Enter' }); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ value: 'one' })); + }); + + it('sets data-disabled and blocks selection when disabledSelection is set', () => { + const onSelectionChange = jest.fn(); + + const { getByRole } = render( + + + First item + + , + ); + + const item = getByRole('option'); + + expect(item).toHaveAttribute('data-disabled', ''); + expect(item).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.click(item); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(item).not.toHaveAttribute('data-selected'); + }); + + it('is not disabled when an action is available alongside disabledSelection', () => { + const onAction = jest.fn(); + + const { getByRole } = render( + + + First item + + , + ); + + const item = getByRole('option'); + + expect(item).not.toHaveAttribute('data-disabled'); + expect(item).not.toHaveAttribute('aria-disabled'); + + fireEvent.click(item); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('does not let a consumer misrepresent the reserved selected attribute', () => { + const { getByRole } = render( + + + First item + + , + ); + + expect(getByRole('option')).not.toHaveAttribute('data-selected'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.tsx b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.tsx new file mode 100644 index 00000000000000..dfdc16d3129729 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ListItemProps } from './ListItem.types'; +import { useListItem } from './useListItem'; +import { renderListItem } from './renderListItem'; + +/** + * Represents a single item within a `List`. + * + * Must be rendered inside a `List`, which provides the item role and the selection state. When the + * parent list is selectable the item renders a native `input[type="checkbox"]` checkmark slot. + */ +export const ListItem: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useListItem(props, ref); + + return renderListItem(state); +}); + +ListItem.displayName = 'ListItem'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.types.ts new file mode 100644 index 00000000000000..129f24b2202630 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/ListItem.types.ts @@ -0,0 +1,29 @@ +import type { ListItemBaseState } from '@fluentui/react-list'; + +export type { + ListItemBaseSlots as ListItemSlots, + ListItemBaseProps as ListItemProps, + ListItemValue, + ListItemActionEventData, +} from '@fluentui/react-list'; + +export type ListItemState = ListItemBaseState & { + root: { + /** + * Data attribute set when the parent list manages selection. + */ + 'data-selectable'?: string; + /** + * Data attribute set when the item is part of the parent list's focus order. + */ + 'data-navigable'?: string; + /** + * Data attribute set when the item is selected. + */ + 'data-selected'?: string; + /** + * Data attribute set when the item cannot be selected and exposes no action. + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/index.ts new file mode 100644 index 00000000000000..0ec35c8e1375c5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/index.ts @@ -0,0 +1,10 @@ +export { ListItem } from './ListItem'; +export { renderListItem } from './renderListItem'; +export { useListItem } from './useListItem'; +export type { + ListItemActionEventData, + ListItemProps, + ListItemSlots, + ListItemState, + ListItemValue, +} from './ListItem.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/renderListItem.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/renderListItem.ts new file mode 100644 index 00000000000000..b68407ffec895e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/renderListItem.ts @@ -0,0 +1,9 @@ +import { renderListItem_unstable } from '@fluentui/react-list'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import type { ListItemState } from './ListItem.types'; + +/** + * Renders the final JSX of the ListItem component, given the state. + */ +export const renderListItem = renderListItem_unstable as (state: ListItemState) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/useListItem.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/useListItem.ts new file mode 100644 index 00000000000000..705d6b4704ba0c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/ListItem/useListItem.ts @@ -0,0 +1,27 @@ +'use client'; + +import type * as React from 'react'; +import { useListItemBase_unstable } from '@fluentui/react-list'; + +import type { ListItemProps, ListItemState } from './ListItem.types'; +import { stringifyDataAttribute } from '../../../utils'; + +/** + * Returns the state for a ListItem component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderListItem`. + */ +export const useListItem = (props: ListItemProps, ref: React.Ref): ListItemState => { + const state: ListItemState = useListItemBase_unstable(props, ref); + + // Set data attributes for selection and navigation states to simplify styling of these states. + // eslint-disable-next-line react-hooks/immutability + state.root['data-selectable'] = stringifyDataAttribute(state.selectable); + // eslint-disable-next-line react-hooks/immutability + state.root['data-navigable'] = stringifyDataAttribute(state.navigable); + // eslint-disable-next-line react-hooks/immutability + state.root['data-selected'] = stringifyDataAttribute(state.root['aria-selected'] === true); + // eslint-disable-next-line react-hooks/immutability + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/index.ts new file mode 100644 index 00000000000000..8c6364097a1dc6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/index.ts @@ -0,0 +1,15 @@ +export { List } from './List'; +export { renderList } from './renderList'; +export { useList, useListContext, useListContextValues } from './useList'; +export type { + ListContextValue, + ListContextValues, + ListNavigationMode, + ListProps, + ListSlots, + ListState, + OnListSelectionChangeData, +} from './List.types'; + +export { ListItem, renderListItem, useListItem } from './ListItem'; +export type { ListItemActionEventData, ListItemProps, ListItemSlots, ListItemState, ListItemValue } from './ListItem'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/renderList.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/renderList.ts new file mode 100644 index 00000000000000..c4095c4d99ad43 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/renderList.ts @@ -0,0 +1 @@ +export { renderList_unstable as renderList } from '@fluentui/react-list'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/List/useList.ts b/packages/react-components/react-headless-components-preview/library/src/components/List/useList.ts new file mode 100644 index 00000000000000..dd728ed38b5034 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/List/useList.ts @@ -0,0 +1,37 @@ +'use client'; + +import type * as React from 'react'; +import { useListBase_unstable, useListContextValues_unstable } from '@fluentui/react-list'; + +import type { ListContextValues, ListProps, ListState } from './List.types'; +import { stringifyDataAttribute } from '../../utils'; + +export { useListContext_unstable as useListContext } from '@fluentui/react-list'; + +/** + * Returns the state for a List component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderList`. + */ +export const useList = ( + props: ListProps, + ref: React.Ref, +): ListState => { + const baseState = useListBase_unstable(props, ref); + + const state: ListState = { + ...baseState, + root: { + ...baseState.root, + 'data-navigation-mode': baseState.navigationMode, + 'data-selectable': stringifyDataAttribute(!!baseState.selection), + focusgroup: 'toolbar block', + }, + }; + + return state; +}; + +/** + * Maps the state of the list to the values that are passed through context to its children. + */ +export const useListContextValues = useListContextValues_unstable as (state: ListState) => ListContextValues; diff --git a/packages/react-components/react-headless-components-preview/library/src/list.ts b/packages/react-components/react-headless-components-preview/library/src/list.ts new file mode 100644 index 00000000000000..5d3e3dc7d07f0a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/list.ts @@ -0,0 +1,19 @@ +export { List, renderList, useList, useListContext, useListContextValues } from './components/List'; +export type { + ListContextValue, + ListContextValues, + ListNavigationMode, + ListProps, + ListSlots, + ListState, + OnListSelectionChangeData, +} from './components/List'; + +export { ListItem, renderListItem, useListItem } from './components/List'; +export type { + ListItemActionEventData, + ListItemProps, + ListItemSlots, + ListItemState, + ListItemValue, +} from './components/List'; diff --git a/yarn.lock b/yarn.lock index 408c26dfdd10b6..e618d672ab6294 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4336,6 +4336,7 @@ __metadata: "@fluentui/react-jsx-runtime": "npm:^9.4.5" "@fluentui/react-label": "npm:^9.4.4" "@fluentui/react-link": "npm:^9.8.4" + "@fluentui/react-list": "npm:^9.6.17" "@fluentui/react-menu": "npm:^9.25.3" "@fluentui/react-message-bar": "npm:^9.7.5" "@fluentui/react-nav": "npm:^9.4.4" From 4dda41bb843bf704c4628eb79c549fa4a28e6cda Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 18 Aug 2026 15:42:30 +0200 Subject: [PATCH 2/3] docs(react-headless-components-preview): add List stories Add structural and behavior-focused List examples for selection, composed content, actions, and state, plus browser support metadata. --- .../src/List/ListActionItems.stories.tsx | 26 +++ .../stories/src/List/ListDefault.stories.tsx | 23 +++ .../stories/src/List/ListDescription.md | 7 + .../src/List/ListLeadingContent.stories.tsx | 28 +++ .../src/List/ListMultiselect.stories.tsx | 42 ++++ .../ListSecondaryContentRight.stories.tsx | 24 +++ .../ListSecondaryContentUnder.stories.tsx | 23 +++ .../stories/src/List/ListSelected.stories.tsx | 21 ++ .../src/List/ListSingleSelection.stories.tsx | 21 ++ .../stories/src/List/index.stories.tsx | 26 +++ .../stories/src/List/list.module.css | 189 ++++++++++++++++++ .../src/shared/browserSupportNotice.ts | 1 + 12 files changed, 431 insertions(+) create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListActionItems.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListLeadingContent.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListMultiselect.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentRight.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentUnder.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListSelected.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/ListSingleSelection.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/index.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/List/list.module.css diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListActionItems.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListActionItems.stories.tsx new file mode 100644 index 00000000000000..e1e9c732cb3a07 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListActionItems.stories.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; +import { EditRegular, MoreHorizontalRegular, PersonRegular } from '@fluentui/react-icons'; + +import styles from './list.module.css'; + +export const ActionItems = (): React.ReactNode => ( + + {['Alex Wilber', 'Mona Kane', 'Allan Munger', 'Robin Counts'].map(person => ( + + + + {person} + + + + + + + ))} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListDefault.stories.tsx new file mode 100644 index 00000000000000..27a41420c7bcd2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListDefault.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; + +import styles from './list.module.css'; + +const items = [ + { value: 'inbox', title: 'Inbox', description: '12 unread conversations' }, + { value: 'drafts', title: 'Drafts', description: '3 messages waiting to be sent' }, + { value: 'archive', title: 'Archive', description: 'Everything you have filed away' }, +]; + +export const Default = (): React.ReactNode => ( + + {items.map(item => ( + + + {item.title} + {item.description} + + + ))} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListDescription.md b/packages/react-components/react-headless-components-preview/stories/src/List/ListDescription.md new file mode 100644 index 00000000000000..8d1eb271f423b9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListDescription.md @@ -0,0 +1,7 @@ +A list presents a collection of related items, optionally with selection. + +`List` owns the semantics of the collection — it resolves the `list`, `listbox`, or `grid` role, coordinates selection, and passes that state to its children. `ListItem` must always be rendered inside a `List`. + +When `selectionMode` is set, each item renders a native `input[type="checkbox"]` in its `checkmark` slot, exposes `aria-selected`, and can be toggled by clicking the item, clicking the checkmark, or pressing Space. Items with an `onAction` handler also respond to Enter. + +Provide an accessible name for the list with `aria-label` or `aria-labelledby` whenever the surrounding content does not already label it. diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListLeadingContent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListLeadingContent.stories.tsx new file mode 100644 index 00000000000000..54ffcd2c26ffe3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListLeadingContent.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; +import { PersonRegular } from '@fluentui/react-icons'; + +import styles from './list.module.css'; + +export const LeadingContent = (): React.ReactNode => { + const items = [ + { title: 'Plain item' }, + { title: 'Text only' }, + { title: 'Icon item', icon: true }, + { title: 'Icon row', icon: true }, + { title: 'Avatar item', avatar: true }, + { title: 'Avatar row', avatar: true }, + ]; + + return ( + + {items.map(item => ( + + {item.icon && } + {item.avatar && MK} + {item.title} + + ))} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListMultiselect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListMultiselect.stories.tsx new file mode 100644 index 00000000000000..2889909050d597 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListMultiselect.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; + +import styles from './list.module.css'; + +const items = [ + { value: 'design', title: 'Design review', description: 'Owned by the design system team' }, + { value: 'a11y', title: 'Accessibility audit', description: 'Blocked until the spec lands' }, + { value: 'perf', title: 'Performance budget', description: 'Locked by the release train', disabled: true }, +]; + +export const Multiselect = (): React.ReactNode => { + const [selectedItems, setSelectedItems] = React.useState>(['design']); + + return ( + <> + setSelectedItems(data.selectedItems)} + > + {items.map(item => ( + + + {item.title} + {item.description} + + + ))} + +

Selected: {selectedItems.join(', ') || 'nothing'}

+ + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentRight.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentRight.stories.tsx new file mode 100644 index 00000000000000..fcd6406579d4ab --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentRight.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; + +import styles from './list.module.css'; + +export const SecondaryContentRight = (): React.ReactNode => { + const items = [ + ['Design review', 'Today'], + ['Project files', '12 items'], + ['Release notes', 'Draft'], + ['Support queue', 'Open'], + ]; + + return ( + + {items.map(([title, secondary]) => ( + + {title} + {secondary} + + ))} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentUnder.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentUnder.stories.tsx new file mode 100644 index 00000000000000..d4e84c482a78fa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListSecondaryContentUnder.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; + +import styles from './list.module.css'; + +export const SecondaryContentUnder = (): React.ReactNode => ( + + {['Mona Kane', 'Allan Munger', 'Robin Counts', 'Nestor Wilke'].map(person => ( + + + {person + .split(' ') + .map(part => part[0]) + .join('')} + + + {person} + {person.toLowerCase().replace(' ', '.')}@example.com + + + ))} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListSelected.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListSelected.stories.tsx new file mode 100644 index 00000000000000..8176d440365be1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListSelected.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; +import { PersonRegular } from '@fluentui/react-icons'; + +import styles from './list.module.css'; + +export const Selected = (): React.ReactNode => ( + + {['Alex Wilber', 'Mona Kane', 'Allan Munger', 'Robin Counts'].map(person => ( + + + {person} + + ))} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/ListSingleSelection.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/ListSingleSelection.stories.tsx new file mode 100644 index 00000000000000..6af9b573b22146 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/ListSingleSelection.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; +import { PersonRegular } from '@fluentui/react-icons'; + +import styles from './list.module.css'; + +export const SingleSelection = (): React.ReactNode => ( + + {['Alex Wilber', 'Mona Kane', 'Allan Munger', 'Robin Counts'].map(person => ( + + + {person} + + ))} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/List/index.stories.tsx new file mode 100644 index 00000000000000..c931a1a84a37c0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/index.stories.tsx @@ -0,0 +1,26 @@ +import { List, ListItem } from '@fluentui/react-headless-components-preview/list'; + +import descriptionMd from './ListDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + +import './list.module.css'; + +export { Default } from './ListDefault.stories'; +export { SingleSelection } from './ListSingleSelection.stories'; +export { Multiselect } from './ListMultiselect.stories'; +export { SecondaryContentRight } from './ListSecondaryContentRight.stories'; +export { SecondaryContentUnder } from './ListSecondaryContentUnder.stories'; +export { ActionItems } from './ListActionItems.stories'; + +export default { + title: 'Components/List', + component: List, + subcomponents: { ListItem }, + parameters: { + docs: { + description: { + component: [descriptionMd, getBrowserSupportNotice('List')].join('\n'), + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/List/list.module.css b/packages/react-components/react-headless-components-preview/stories/src/List/list.module.css new file mode 100644 index 00000000000000..dc0670dfc32c8d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/List/list.module.css @@ -0,0 +1,189 @@ +.list { + display: flex; + flex-direction: column; + inline-size: 100%; + max-inline-size: 420px; + margin: 0; + padding: 0; + list-style: none; + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elev); + overflow: hidden; +} + +.wideList { + composes: list; + max-inline-size: 720px; +} + +.comparison { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: var(--space-5); + inline-size: 100%; +} + +.example { + min-inline-size: 0; +} + +.exampleTitle { + margin: 0 0 var(--space-2); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--space-3); + text-transform: capitalize; +} + +.item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-block-end: var(--stroke-thin) solid var(--border); + color: var(--text); + font-family: var(--font-sans); + transition: background var(--duration-fast) var(--ease-standard); +} + +.item:last-child { + border-block-end: none; +} + +.item[data-selectable], +.item[data-navigable] { + cursor: pointer; +} + +.item[data-selectable]:hover, +.item[data-navigable]:hover { + background: var(--surface-muted); +} + +.item[data-selected] { + background: var(--accent-soft); +} + +.item[data-disabled] { + color: var(--text-faint); + cursor: default; +} + +.item[data-disabled]:hover { + background: transparent; +} + +.item:focus-visible { + outline: none; + box-shadow: inset 0 0 0 var(--stroke-thick) var(--accent); +} + +.checkmark { + inline-size: var(--space-4); + block-size: var(--space-4); + flex-shrink: 0; + accent-color: var(--accent); +} + +.leadingIcon { + inline-size: var(--space-5); + block-size: var(--space-5); + flex: none; +} + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--space-8); + block-size: var(--space-8); + flex: none; + border-radius: var(--radius-pill); + background: var(--accent-soft); + color: var(--text); + font-size: var(--space-3); + font-weight: 600; +} + +.secondaryRight { + margin-inline-start: auto; + color: var(--text-muted); + font-size: var(--space-3); +} + +.actions { + display: inline-flex; + gap: var(--space-1); + margin-inline-start: auto; +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--space-8); + block-size: var(--space-8); + padding: 0; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + cursor: pointer; +} + +.action:hover { + background: var(--surface-muted); +} + +.action:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: calc(-1 * var(--stroke-thick)); +} + +.divider { + block-size: var(--stroke-thin); + margin: 0 var(--space-4); + background: var(--border); + list-style: none; +} + +.label { + flex: 1; + min-inline-size: 0; +} + +.title { + display: block; + font-size: var(--space-3); + font-weight: 600; + letter-spacing: var(--tracking-tight); +} + +.description { + display: block; + color: var(--text-muted); + font-size: var(--space-3); +} + +.item[data-selected] .description { + color: var(--text); +} + +@media (prefers-reduced-motion: reduce) { + .item { + transition: none; + } +} + +@media (forced-colors: active) { + .item[data-selected] { + background: Highlight; + color: HighlightText; + } + + .item:focus-visible { + outline: var(--stroke-thick) solid CanvasText; + outline-offset: calc(-1 * var(--stroke-thick)); + } +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts index 6b5d52f6861a0e..28dc1ea2307741 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -23,6 +23,7 @@ const COMPONENT_FEATURES: Record = { TabList: ['focusgroup'], Nav: ['focusgroup'], SwatchPicker: ['focusgroup'], + List: ['focusgroup'], }; const BROWSER_SUPPORT_DOCS_PATH = '?path=/docs/overview-browser-support--docs'; From 678483b761cf43a4271991b9988c1e5f2979d9cd Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 19 Aug 2026 09:40:27 +0200 Subject: [PATCH 3/3] update api snapshot --- .../library/etc/list.api.md | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/etc/list.api.md b/packages/react-components/react-headless-components-preview/library/etc/list.api.md index d265b7443bd89e..351cddda09df8f 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/list.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/list.api.md @@ -4,41 +4,39 @@ ```ts -import { ContextSelector } from '@fluentui/react-context-selector'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import { JSXElement } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; import { ListContextValue } from '@fluentui/react-list'; -import { ListContextValues as ListContextValues_2 } from '@fluentui/react-list'; +import { ListContextValues } from '@fluentui/react-list'; import { ListItemActionEventData } from '@fluentui/react-list'; -import type { ListItemBaseProps } from '@fluentui/react-list'; -import type { ListItemBaseSlots } from '@fluentui/react-list'; import type { ListItemBaseState } from '@fluentui/react-list'; +import { ListItemBaseProps as ListItemProps } from '@fluentui/react-list'; +import { ListItemBaseSlots as ListItemSlots } from '@fluentui/react-list'; import { ListItemValue } from '@fluentui/react-list'; import { ListNavigationMode } from '@fluentui/react-list'; -import type { ListProps as ListProps_2 } from '@fluentui/react-list'; -import type { ListSlots as ListSlots_2 } from '@fluentui/react-list'; -import { ListState as ListState_2 } from '@fluentui/react-list'; +import { ListProps } from '@fluentui/react-list'; +import { ListSlots } from '@fluentui/react-list'; +import type { ListState as ListState_2 } from '@fluentui/react-list'; import { OnListSelectionChangeData } from '@fluentui/react-list'; import type * as React_2 from 'react'; +import { renderList_unstable as renderList } from '@fluentui/react-list'; +import { useListContext_unstable as useListContext } from '@fluentui/react-list'; // @public export const List: ForwardRefComponent; export { ListContextValue } -// @public (undocumented) -export type ListContextValues = ListContextValues_2; +export { ListContextValues } // @public export const ListItem: ForwardRefComponent; export { ListItemActionEventData } -// @public (undocumented) -export type ListItemProps = ListItemBaseProps; +export { ListItemProps } -// @public (undocumented) -export type ListItemSlots = ListItemBaseSlots; +export { ListItemSlots } // @public (undocumented) export type ListItemState = ListItemBaseState & { @@ -54,11 +52,9 @@ export { ListItemValue } export { ListNavigationMode } -// @public (undocumented) -export type ListProps = ListProps_2; +export { ListProps } -// @public (undocumented) -export type ListSlots = ListSlots_2; +export { ListSlots } // @public (undocumented) export type ListState = ListState_2 & { @@ -70,8 +66,7 @@ export type ListState = ListState_2 & { export { OnListSelectionChangeData } -// @public -export const renderList: (state: ListState_2, contextValues: ListContextValues_2) => JSXElement; +export { renderList } // @public export const renderListItem: (state: ListItemState) => JSXElement; @@ -79,8 +74,7 @@ export const renderListItem: (state: ListItemState) => JSXElement; // @public export const useList: (props: ListProps, ref: React_2.Ref) => ListState; -// @public -export const useListContext: (selector: ContextSelector) => T; +export { useListContext } // @public export const useListContextValues: (state: ListState) => ListContextValues;