Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,6 +75,7 @@ console.log({
InteractionTag,
Label,
Link,
List,
Menu,
MessageBar,
Nav,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
## 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 type { ForwardRefComponent } from '@fluentui/react-utilities';
import type { JSXElement } from '@fluentui/react-utilities';
import { ListContextValue } from '@fluentui/react-list';
import { ListContextValues } from '@fluentui/react-list';
import { ListItemActionEventData } 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 { 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<ListProps>;

export { ListContextValue }

export { ListContextValues }

// @public
export const ListItem: ForwardRefComponent<ListItemProps>;

export { ListItemActionEventData }

export { ListItemProps }

export { ListItemSlots }

// @public (undocumented)
export type ListItemState = ListItemBaseState & {
root: {
'data-selectable'?: string;
'data-navigable'?: string;
'data-selected'?: string;
'data-disabled'?: string;
};
};

export { ListItemValue }

export { ListNavigationMode }

export { ListProps }

export { ListSlots }

// @public (undocumented)
export type ListState = ListState_2 & {
root: {
'data-navigation-mode'?: ListNavigationMode;
'data-selectable'?: string;
};
};

export { OnListSelectionChangeData }

export { renderList }

// @public
export const renderListItem: (state: ListItemState) => JSXElement;

// @public
export const useList: (props: ListProps, ref: React_2.Ref<HTMLDivElement | HTMLUListElement | HTMLOListElement>) => ListState;

export { useListContext }

// @public
export const useListContextValues: (state: ListState) => ListContextValues;

// @public
export const useListItem: (props: ListItemProps, ref: React_2.Ref<HTMLLIElement | HTMLDivElement>) => ListItemState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<List>
<ListItem value="one">First item</ListItem>
<ListItem value="two">Second item</ListItem>
</List>,
);

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(
<List>
<ListItem value="one">First item</ListItem>
</List>,
);

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(
<List navigationMode={navigationMode}>
<ListItem value="one">
<span role="gridcell">First item</span>
</ListItem>
</List>,
);

expect(container.firstElementChild).toHaveAttribute('data-navigation-mode', navigationMode);
});

it('sets data-selectable and the listbox semantics when selection is enabled', () => {
const { getByRole, getAllByRole } = render(
<List selectionMode="multiselect">
<ListItem value="one">First item</ListItem>
<ListItem value="two">Second item</ListItem>
</List>,
);

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(
<List selectionMode="multiselect">
<ListItem value="one">First item</ListItem>
</List>,
);

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(
<List selectionMode="multiselect" onSelectionChange={onSelectionChange}>
<ListItem value="one">First item</ListItem>
<ListItem value="two">Second item</ListItem>
</List>,
);

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(
<List selectionMode="single" selectedItems={['one']}>
<ListItem value="one">First item</ListItem>
<ListItem value="two">Second item</ListItem>
</List>,
);

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(
<List selectionMode="single" selectedItems={['two']}>
<ListItem value="one">First item</ListItem>
<ListItem value="two">Second item</ListItem>
</List>,
);

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(
<List data-selectable="nope">
<ListItem value="one">First item</ListItem>
</List>,
);

expect(getByRole('list')).not.toHaveAttribute('data-selectable');
});
});
Original file line number Diff line number Diff line change
@@ -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<ListProps> = React.forwardRef((props, ref) => {
const state = useList(props, ref);
const contextValues = useListContextValues(state);

return renderList(state, contextValues);
});

List.displayName = 'List';
Original file line number Diff line number Diff line change
@@ -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;
};
};
Loading
Loading