diff --git a/change/@fluentui-react-shared-contexts-c8fe7f5c-9503-41d6-a0d6-dec1071446ac.json b/change/@fluentui-react-shared-contexts-c8fe7f5c-9503-41d6-a0d6-dec1071446ac.json new file mode 100644 index 0000000000000..a1edf785ce3d5 --- /dev/null +++ b/change/@fluentui-react-shared-contexts-c8fe7f5c-9503-41d6-a0d6-dec1071446ac.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "deprecate unused ListItemButton exports", + "packageName": "@fluentui/react-shared-contexts", + "email": "jirivyhnalek@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-list-preview/config/tests.js b/packages/react-components/react-list-preview/config/tests.js index 2e211ae9e2142..c6c67de97059e 100644 --- a/packages/react-components/react-list-preview/config/tests.js +++ b/packages/react-components/react-list-preview/config/tests.js @@ -1 +1,3 @@ /** Jest test setup file. */ + +require('@testing-library/jest-dom'); diff --git a/packages/react-components/react-list-preview/cypress.config.ts b/packages/react-components/react-list-preview/cypress.config.ts new file mode 100644 index 0000000000000..ca52cf041bbf2 --- /dev/null +++ b/packages/react-components/react-list-preview/cypress.config.ts @@ -0,0 +1,3 @@ +import { baseConfig } from '@fluentui/scripts-cypress'; + +export default baseConfig; diff --git a/packages/react-components/react-list-preview/docs/ListA11y.md b/packages/react-components/react-list-preview/docs/ListA11y.md new file mode 100644 index 0000000000000..c79b2aac622a8 --- /dev/null +++ b/packages/react-components/react-list-preview/docs/ListA11y.md @@ -0,0 +1,243 @@ +# Accessibility of Lists on the web: why we can't have nice things + +Rev. 1 - Initial draft +Rev. 2 - Added examples links, made conclusions in italic, explained bahavior for Space and Enter on lists with a primary action, some wording changed + +Accessibility in browsers is hard in general. When it comes to modern web applications with complex UIs, that statement is more true then ever. + +And then there are Lists. For some reason, there is not a single aria role that would support proper position narration, selection narration, and complex widget elements with secondary actions. + +That is why we need to make compromises, choosing the right tool (a11y role) for each scenario and maybe sometimes hack around a little. + +In this document I will go through a different variations and designs of List -- seemingly a simple component, but a nightmare to make accessible properly. + +We'll discuss the requirements from the design/functional POV, then proceed with defining accessibility requirement. Having these in mind, we can explore different List use cases and see which aria roles work and which don't, what should we pick and why, and what are the limitations and how we can work around them. + +## Functional requirements for List + +When I say List, I dont mean a simple `ul`/`li` list. A List component in the world of Fluent UI is a more complex component and supports these features: + +- **A simple `ul`/`li`** - base scenario; a simple list with no interactivity at all. +- **Single action** - Each List Item can have one primary action, this action can be triggered by pressing **Enter** on the focused list item. +- **Selection** - each List can be set as selectable and List Items need to be toggleable, both single and multiselect are supported. This needs to be properly narrated by screen readers. The selection can be triggered by pressing **Spacebar**. +- **ListItems with "secondary" action** - each List Item is kind of a widget, in addition to a primary action, can have multiple interactive elements inside. User needs to understand that those options are there and intuitively know how to focus those. + +## Accessibility requirements + +For the List to be accessible, these requirements are mandatory to work on major screen reading software. I will be focusing on NVDA, Jaws (Windows) and Voice Over (Mac OS). + +- **Position** - as user navigates, the current position in the list is announced +- **Actionable** - as user navigates, it should be obvious that the current item has an action that can be triggered (the action can be selection, but doesn't have to be). This should be implicit (role `button` implies there is an action) or explicit (the screen reader makes it known there is an action) +- **Selection** - as user changes the selection state on a List Item, it is announced + +In the following section, I will go through the each funcional requirement and describe the problems into more detail. + +## Analysis + +### Single action lists + +[example](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/iframe.html?viewMode=docs&id=preview-components-list--default#single-action) + +List with a single action is a collection of items with common action, specific to each item. One example would be a list of people, where clicking on a person will open a popup with details. + +For a List with a single action, there are generally 2 approaches we can take: + +a) Put a **Button component inside** of the List Item and navigate directly between them, skipping the List Items + +b) Make the **List Items focusable** and attach the action on them + +#### Making the Buttons inside focusable + +While this is a suggested approach for this case, lets see if it fits all of our a11y requirements: + +- Position: ❌ + - While Voice Over on Mac Works (when using proper VO keys to navigate), screen readers on Windows fail to announce the position inside of the list in Focus mode (preferred mode for comples web Apps). +- Actionable: ✅ + - Since it is a button with aria role `button` we are focusing, it's implicitly communicated that the user can trigger action. +- Selection: N/A + +#### Making the List Items Focusable + +If we want to put the action directly on the list item, we should choose the proper aria role, which would fill all of our a11y requirement, i.e. **announce position in the list** and **implicitly or explicitly communicate there is action attached to the List Item**. + +We have multiple aria roles we can explore and see if any of those fill all of our a11y needs: + +##### List/ListItem + +- Position: ✅ + - Focused items of role "listitem" are properly announced with their position +- Actionable: ✴️ + - The **action** on the list item is **not announced** in the Focus mode on Windows. + - This should be implicitly communicated by context, if that isn't necessary, it can be worked around by using `aria-roledescription` or `aria-label` with proper explanation. +- Selection: N/A + +_`listitem` role on it's own should be used if the fact that it has an action can be understood by the context it exists in. If this is not clear enough, `aria-label` or `aria-roledescription` can be used to further explain this._ + +##### Menu/Menuitem + +- Position: ✅ + - Focused `menuitem` elements properly announce their position in the `menu` +- Actionable: ✅ + - Users implicitly expect an action on a `menuitem` +- Selection: N/A +- Other considerations: + - While the `menu`/`menuitem` aria roles seem to check our a11y requirement boxes, there are other considerations that need to be taken in: + - "Menu" is not semantically correct for our example use case. List is different from a Menu in a way that in a List, each List item is of the same "category" (list of people, emails, conversations, applications) and each list item action triggers the same action, while in a Menu the user expects each option to do something else. + - Creates a communication barrier between the sighted user and user relying on a screen reader. If a sighted user instructs visually impaired user to go on "the list of people", they would only be able to find "a menu". + +_While the `menuitem` role seems to work, its semantically different from a list enough, that it would add confusion and noise._ + +#### Outcome + +Seems like for our "simple" use case of a single action in a list item, we don't have a perfect solution. Each of the three suggested variants have their cons. While some of the downsides of certain solutions are fundamental (confusion between listitem and menuitem), others can be worked around. + +_My suggestion for this usecase would to **make the List Item focusable, use `list` and `listitem` roles and add a translated string of "button" as `aria-roledescription` when an action on the list Item is present**._ + +This way we make sure that the user knows they are in a List, the position is properly announced and a translated "button" role description is present, making it clear you can press "Enter" to trigger it. + +**Examples of narration of the suggested solution:** +NVDA: + +> John Doe button 2 of 13 level 1 + +JAWS: + +> John Doe button 2 of 13 + +Voice Over (using VO navigation keys): + +> John Doe, button, 2 of 13 + +Voice Over (using just arrow keys): + +> John Doe, button + +### Single action lists - selection + +[Visit example](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/iframe.html?viewMode=docs&id=preview-components-list--default#single-action-selection) + +List with a single action where the action is toggling the selection. One example would be a list of people to add to a call, where clicking on a row/person will add them to the selection. There is no other action that can be triggered on the list items. + +The whole list item can be focused and the selection can be toggled with **spacebar**. Also, the whole item is clickable with a mouse and triggers the same action. + +Counter-intuitively, this case is more straightforward to handle than the previous, since this is usually called a **Listbox** and there is an appropriate aria role for this. + +#### Listbox/Option + +- Position: ✅ + - The position in the list is properly narrated in all screen readers. +- Actionable: ✅ + - The `listbox`/`option` role combination implicitly communicates that user can toggle the selection on an item using spacebar +- Selection: ✅ + - The `listbox`/`option` role support `aria-selected` attribute which is properly narrated as it changes. + +_This scenario is straightforward, there is an aria role which fits perfectly in our use case. It has one downside, the `option` role is [always presentational](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/option_role#all_descendants_are_presentational), which means that `listbox` with `option` cannot be used in a scenario where there are other actions inside of the list item, but more on that later._ + +### List with multiple actions - no primary action + +[example](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/index.html?path=/docs/preview-components-list--default#multiple-actions-no-primary-no-selection) + +When multiple actions are available in a list item, things become a bit more complicated, as some aria roles are not equipped to handle that at all (like `option` because of it's inherent property of [all descendants being presentational](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/option_role#all_descendants_are_presentational)). + +For the following scenarios we can establish some basic keyboard navigation that should be supported regardless of any a11y role we add. + +For simplicity, lets talk about a vertical list in LTR layout. Horizontal lists and lists in RTL layout should swap the arrow keys appropriately. + +- **Down/Up arrows** move to the **next, previous list item** +- **Right arrow enters** the list item and **focuses** on the **first focusable** element +- Once **in the list item**, **Left** and **Right** arrow keys navigate between **focusable elements inside**, this is **not cyclic** and when **left arrow is pressed on leftmost** element, the **list item itself is selected** +- Once in the list item, **Up and Down** arrows **focus** the list item **above/below** (if it exists) + +In general, we have 2 options for this scenario: + +#### List / ListItem + +- Position: ✅ + - as we established earlier, the `listitem` role is properly narrated together with its position +- Actionable: N/A + - in this case, the list items themselves are not actionable +- Selection: N/A + - in this case we don't have selection +- Other considerations: + - Would the user expect to click right arrow key to get inside the list item? + +_For this scenario, List/ListItem seems like a good choice, since we don't need support for selection or actionable rows (list items)._ + +#### Grid / Row / Gridcell + +While using Grid role for a list may seem unintuitive and irrelevant, it will come up later when we talk about selection in a complex list like this. + +I'm writing about it here for completion and to contrast it with the listitem role. + +In grid, each list item is of role `row` and each actionable element inside should be in its own `cell` role element. + +- Position: ❌ + - The position is not properly narrated, we get `row` but not `row x of y` +- Actionable: N/A +- Selection: N/A +- Other considerations: ⚠️ + - The nature of `grid`/`row`/`gridcell` roles forces the developers to actually stick to this strict HTML layout. A cell should be wrapping actionable element, but it should be a direct child of the `row`, preventing users from building more complex widgets with custom HTML structure. + +### List with multiple actions - with selection + +[example](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/index.html?path=/docs/preview-components-list--default#multiple-actions-primary-selection) + +Things become a bit more complicated when selection is involved, as we need the proper a11y announcements when the selection state is changed. This is not supported for the `listitem` role, which we deemed perfect for complex list without selection. Even if `aria-selected` is added to a `listitem`, the screen readers just ignore that property (since it's not valid for `listitem` role). + +When the list supports selection, the main action of the list item is **to toggle the selection** by default. + +**Left mouse button** always triggers _onClick_, which toggles the selection, if enabled. A custom action can be triggered on click instead, by passing a custom `onClick` handler to the `ListItem` component and calling `preventDefault()` on the event. See how this works [here](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/index.html?path=/docs/preview-components-list--default#multiple-actions-different-primary). + +**Spacebar** on the `ListItem` always toggles the selection. + +**Enter** on the `ListItem` triggers the main action, which can be changed by passing the `onClick` handler, i.e. by default it triggers selection, but this behavior can be overriden (by changing the onClick handler). + +Both keys behavior can be changed by passing the `onKeyDown` handler and preventing teh default by calling `preventDefault()` on the event. Please note that the uncontrolled selection can no longer be utilized in this case and you have to take control. + +#### Listbox / option + +Since [all descendats of option are presentational](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/option_role#all_descendants_are_presentational), this role is not viable for this case, since we need to allow the screen reader to go inside of the list item to focus on the secondary actions. + +While this role technically works on Windows using the screen reader Focus mode, it actually **completely breaks Voice Over navigation on Mac OS** and therefore is unusable. + +#### Grid / Row / Gridcell + +- Position: ✴️ + - While we get announcement for `row` as "row", we don't get the row number `row x of y` in any of the screen readers tested. This is a big limitation of this role, could be a chromium bug. + - This _can_ be worked around from the user world by passing the order as part of the `aria-label`. This is not without it's downsides though. +- Actionable: ✅ + - Since the rows can be selected, it is reasonable to expect that the users understand that they can trigger the action on the list item (row). +- Selection: ✅ + - Rows can be selected, and the selection is properly announced when it changes. +- Other considerations: ⚠️ + - As mentioned previously, when using this role, it is importand that the HTML structure is precisely `grid > row > gridcell > actionable element`. For some complex layouts, this may not be always easy / possible to do. + +_While grid role puts some constrain on the DOM structure to work properly, it is the only accessibility role I found that supports `aria-selected` and allows complex widgets inside._ + +### List with multiple actions, without selection + +[example](https://fluentuipr.z22.web.core.windows.net/pull/29760/public-docsite-v9/storybook/index.html?path=/docs/preview-components-list--default#multiple-actions-no-selection-with-primary) + +When no selection is involved in the equation, we don't have to limit ourselves to using a11y roles that support `aria-selected`, which makes things a bit easier. + +#### Grid / Row / Gridcell + +- Position: ✴️ + - dtto, `row` is announced, but no order +- Actionable: ✴️ + - While actions can be attached to the whole row, this is not a common pattern and it could lead to discoverability issue. When explicitely communicated in `aria-roledescription` or in `aria-label`, this could be solved. + - On the other hand, grid allows for better discoverability of secondary inside actions. +- Selection: N/A +- Other considerations: ⚠️ + - Again, the combination of these roles requires developers to strictly adhere to the required DOM layout. + +#### List / Listitem + +- Position: ✅ + - As established earlier, list has a great support for announcing list position +- Actionable: ✅ + - Action can be put directly on the List Item. Discoverability might be a small issue again, but a context, updated label or supporting aria attributes like `aria-roledescription` can easily solve that + - **It might be difficult to communicate that there are secondary actions that the user can navigate to.** +- Selection: N/A + +_Both `grid` and `list` could be used in this scenario, both have downsides. List gives us better position narration, but doesn't implicitely communicate that there actions that can be focused by moving right. Grid solves this issue, but it's row positions aren't properly narrated (this has been brought up with NVDA/Jaws teams)._ diff --git a/packages/react-components/react-list-preview/docs/MIGRATION.md b/packages/react-components/react-list-preview/docs/MIGRATION.md new file mode 100644 index 0000000000000..c82899517708e --- /dev/null +++ b/packages/react-components/react-list-preview/docs/MIGRATION.md @@ -0,0 +1,203 @@ +# List migration + +## Migration from v8 + +### Composition over configuration + +Compared to its v8 counterpart, the v9 `List` uses composition over configuration when it comes to rendering items, same as other components in Fluent UI React v9. This means that instead of passing an array of items to the `List` component, it's up to you to render `ListItem` components with appropriate content. + +Take this example in v8: + +```js +const items = [{ name: 'John' }, { name: 'Alice' }]; + +const MyList = () => { + return ; +}; +``` + +becomes this in v9: + +```js +const items = [{ name: 'John' }, { name: 'Alice' }]; + +const MyList = () => { + return ( + + {items.map(item => { + {item}; + })} + + ); +}; +``` + +### Virtualization approach + +Virtualization is **not part** of `List` in Fluent UI React v9. We don't want to force any particular solution for virtualization, but we provide [examples](https://react.fluentui.dev/?path=/story/preview-components-list--virtualized-list-with-actionable-items) how to use `List` with a popular library `react-window` to get the desired effect. + +This makes the API of `List` much simpler. + +### v8 Property mapping + +Most of the v8 props are for it's virtualization functionality. Since the v9 `List` takes a different approach, most of the props cannot be directly migrated. + +| v8 List | v9 List | +| ------------------------- | -------------------------------- | +| `className` | `className` | +| `componentRef` | `componentRef` | +| `getItemCountForPage` | N/A | +| `getKey` | N/A as you control the ListItems | +| `getPageHeight` | N/A | +| `getPageSpecification` | N/A | +| `getPageStyle` | N/A | +| `ignoreScrollingState` | N/A | +| `items` | render `` instead | +| `onPageAdded` | N/A | +| `onPagesUpdated` | N/A | +| `onRenderCell` | N/A | +| `onRenderCellConditional` | N/A | +| `onRenderPage` | N/A | +| `onRenderRoot` | N/A | +| `onRenderSurface` | N/A | +| `onShouldVirtualize` | N/A | +| `renderCount` | N/A | +| `renderEarly` | N/A | +| `renderedWindowsAhead` | N/A | +| `renderedWindowsBehind` | N/A | +| `role` | `role` | +| `startIndex` | N/A | +| `usePageCache` | N/A | +| `version` | N/A | +| - | `defaultSelectedItems` | +| - | `onSelectionChange` | +| - | `selectionMode` | + +## Migration from v0 + +### Composition, also known as "Children API" + +In Fluent UI React v9 we prefer to use composition over configuration where possible. List is no exception. the v0 list also supports composition API under a name of "Children API". + +#### Children API component mapping + +Migrating from a v9 Children API to v9 composition API is quite straighforward. You can replace the components like this: + +- Use v9 `List` instead of v0 `List` +- Use v9 `ListItem` instead of v0 `List.Item` + +For props please refer to [Property mapping](#v0-property-mapping) section. + +#### Shorthand API + +For Shorthand API things are a bit more complicated, as your code needs to me updated to use composition. + +Take this example in v0: + +```js +const items = [ + { + key: 'robert', + header: 'Robert Tolbert', + content: 'Program the sensor to the SAS alarm through the haptic SQL card!', + }, + { + key: 'celeste', + header: 'Celeste Burton', + content: 'Use the online FTP application to input the multi-byte application!', + }, +]; + +const MyList = () => { + return ; +}; +``` + +becomes this in v9: + +```js +const items = [ + { + key: 'robert', + header: 'Robert Tolbert', + content: 'Program the sensor to the SAS alarm through the haptic SQL card!', + }, + { + key: 'celeste', + header: 'Celeste Burton', + content: 'Use the online FTP application to input the multi-byte application!', + }, +]; + +const MyList = () => { + return ( + + {items.map(item => { + +

{item.header}

+

{item.content}>

+
; + })} +
+ ); +}; +``` + +### v0 Property mapping + +Compared to its v0 counterpart, the v9 List implementation is much more generic and it **doesn't have any opinion** on how it's content should look like. This means that you will **not** find layout specific props like `header`, `headerMedia`, `content` or layout specific components. This allows for much more flexible use of the component. + +We recommend using a component like `Persona` where possible, or creating a custom layout component where necessary. + +#### List + +| v0 List | v9 List | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accessibility` | built in, customize with `useArrowNavigationGroup` from `tabster` | +| `as` | `as` | +| `className` | `className` | +| `debug` | N/A | +| `defaultSelectedIndex` | `defaultSelectedItems` | +| `design` | N/A | +| `horizontal` | N/A - will be added in the future | +| `items` | N/A - use `ListItem` components as Children | +| `navigable` | `navigable` | +| `onSelectedIndexChange` | `onSelectionChange` | +| `ref` | `ref` | +| `selectable` | use `selectionMode` of value `single` or `multiselect` | +| `selectedIndex` | only in controlled mode, use `selection` state; see [example](https://react.fluentui.dev/?path=/story/preview-components-list--list-selection-controlled). | +| `styles` | use slots in combination with `className` | +| `truncateContent` | N/A - the `List` is not concerned about it's content | +| `truncateHeader` | N/A - the `List` is not concerned about it's content | +| `variables` | N/A - use slots in combination with `className` | +| `wrap` | N/A - the `List` is not concerned about it's content | + +#### ListItem + +| v0 ListItem | v9 ListItem | +| ----------------- | ------------------------------------------------------------------------------------- | +| `accessibility` | N/A | +| `as` | `as` | +| `className` | `className` | +| `content` | N/A - use children | +| `contentMedia` | N/A - use children | +| `debug` | N/A | +| `design` | N/A | +| `endMedia` | N/A - use children | +| `header` | N/A - use children | +| `headerMedia` | N/A - use children | +| `important` | N/A | +| `index` | N/A | +| `media` | N/A - use children | +| `navigable` | N/A - use `tabIndex={0}` or `navigable` on the `List` | +| `onClick` | `onAction` | +| `ref` | ref | +| `selectable` | N/A - use `List` props like `selectionMode`, `selectedItems` and `onSelectionChange` | +| `selected` | N/A - use `selectedItems` (or tracked internally when `defaultSelectedItems` is used) | +| `styles` | N/A - use `className` for any slot | +| `truncateContent` | N/A - the `List` is not concerned about it's content | +| `truncateHeader` | N/A - the `List` is not concerned about it's content | + +#### Other + +Other components like `ListItemContent`, `ListItemContentMedia`, `ListItemEndMedia`, `ListItemHeader`,`ListItemHeaderMedia` and `ListItemMedia` are _not_ currently present in v9 `List` implementation for the reasons mentioned above. diff --git a/packages/react-components/react-list-preview/docs/Spec.md b/packages/react-components/react-list-preview/docs/Spec.md index a3a755a6aa0de..cbf05e0b2ea7a 100644 --- a/packages/react-components/react-list-preview/docs/Spec.md +++ b/packages/react-components/react-list-preview/docs/Spec.md @@ -2,15 +2,19 @@ ## Background -_Description and use cases of this component_ +A List is a component that displays a set of vertically stacked components. -## Prior Art +If you are displaying more than one dimension of the data, the List probably isn't the proper component to use, instead, consider using Table or DataGrid. + +The List supports plain list items, interactive list items with one action or multiple actions. It also has support for single and multi selection built in. This can be utilized in either uncontrolled or controlled way. -_Include background research done for this component_ +All of the List scenarios are also accessible, as the whole component was built with accessibility in mind. It is easily navigable with a keyboard and supports different screen reader applications. + +## Prior Art -- _Link to Open UI research_ -- _Link to comparison of v7 and v0_ -- _Link to GitHub epic issue for the converged component_ +- [Fluent UI v0 docs](https://fluentsite.z22.web.core.windows.net/components/list/definition) +- [Fluent UI v8 docs](https://developer.microsoft.com/en-us/fluentui#/controls/web/list) +- [Open UI research](https://open-ui.org/components/list.research/) ## Sample Code diff --git a/packages/react-components/react-list-preview/etc/react-list-preview.api.md b/packages/react-components/react-list-preview/etc/react-list-preview.api.md index b065e9f82f8d8..e6d8e41b06262 100644 --- a/packages/react-components/react-list-preview/etc/react-list-preview.api.md +++ b/packages/react-components/react-list-preview/etc/react-list-preview.api.md @@ -4,84 +4,78 @@ ```ts +/// + +import { Checkbox } from '@fluentui/react-checkbox'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import type { EventData } from '@fluentui/react-utilities'; +import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import * as React_2 from 'react'; +import { SelectionItemId } from '@fluentui/react-utilities'; +import type { SelectionMode as SelectionMode_2 } from '@fluentui/react-utilities'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; -// @public +// @public (undocumented) export const List: ForwardRefComponent; // @public (undocumented) export const listClassNames: SlotClassNames; -// @public -export const ListItem: ForwardRefComponent; - -// @public -export const ListItemButton: ForwardRefComponent; - -// @public (undocumented) -export const listItemButtonClassNames: SlotClassNames; - -// @public -export type ListItemButtonProps = ComponentProps & {}; - // @public (undocumented) -export type ListItemButtonSlots = { - root: Slot<'div'>; -}; - -// @public -export type ListItemButtonState = ComponentState; +export const ListItem: ForwardRefComponent; // @public (undocumented) export const listItemClassNames: SlotClassNames; // @public -export type ListItemProps = ComponentProps & {}; +export type ListItemProps = ComponentProps & { + value?: string | number; + onAction?: (e: ListItemActionEvent) => void; +}; // @public (undocumented) export type ListItemSlots = { - root: Slot<'div'>; + root: NonNullable>; + checkmark?: Slot; }; // @public -export type ListItemState = ComponentState; +export type ListItemState = ComponentState & { + selectable: boolean; + navigable: boolean; +}; // @public -export type ListProps = ComponentProps & {}; +export type ListProps = ComponentProps & { + navigationMode?: ListNavigationMode; + selectionMode?: SelectionMode_2; + selectedItems?: SelectionItemId[]; + defaultSelectedItems?: SelectionItemId[]; + onSelectionChange?: EventHandler; +}; // @public (undocumented) export type ListSlots = { - root: Slot<'div'>; + root: NonNullable>; }; // @public -export type ListState = ComponentState; +export type ListState = ComponentState & ListContextValue; // @public -export const renderList_unstable: (state: ListState) => JSX.Element; +export const renderList_unstable: (state: ListState, contextValues: ListContextValues) => JSX.Element; // @public export const renderListItem_unstable: (state: ListItemState) => JSX.Element; // @public -export const renderListItemButton_unstable: (state: ListItemButtonState) => JSX.Element; - -// @public -export const useList_unstable: (props: ListProps, ref: React_2.Ref) => ListState; - -// @public -export const useListItem_unstable: (props: ListItemProps, ref: React_2.Ref) => ListItemState; - -// @public -export const useListItemButton_unstable: (props: ListItemButtonProps, ref: React_2.Ref) => ListItemButtonState; +export const useList_unstable: (props: ListProps, ref: React_2.Ref) => ListState; // @public -export const useListItemButtonStyles_unstable: (state: ListItemButtonState) => ListItemButtonState; +export const useListItem_unstable: (props: ListItemProps, ref: React_2.Ref) => ListItemState; // @public export const useListItemStyles_unstable: (state: ListItemState) => ListItemState; diff --git a/packages/react-components/react-list-preview/package.json b/packages/react-components/react-list-preview/package.json index 2deb5a999ed1d..b78e1d5aa3568 100644 --- a/packages/react-components/react-list-preview/package.json +++ b/packages/react-components/react-list-preview/package.json @@ -2,7 +2,7 @@ "name": "@fluentui/react-list-preview", "version": "0.0.0", "private": true, - "description": "New fluentui react package", + "description": "React List v9", "main": "lib-commonjs/index.js", "module": "lib/index.js", "typings": "./dist/index.d.ts", @@ -27,7 +27,9 @@ "storybook": "start-storybook", "test": "jest --passWithNoTests", "test-ssr": "test-ssr \"./stories/**/*.stories.tsx\"", - "type-check": "tsc -b tsconfig.json" + "type-check": "tsc -b tsconfig.json", + "e2e": "cypress run --component", + "e2e:local": "cypress open --component" }, "devDependencies": { "@fluentui/eslint-plugin": "*", @@ -37,7 +39,11 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { + "@fluentui/react-checkbox": "^9.2.18", + "@fluentui/react-context-selector": "^9.1.56", "@fluentui/react-jsx-runtime": "^9.0.34", + "@fluentui/keyboard-keys": "^9.0.7", + "@fluentui/react-tabster": "^9.19.5", "@fluentui/react-theme": "^9.1.19", "@fluentui/react-utilities": "^9.18.5", "@fluentui/react-shared-contexts": "^9.15.2", diff --git a/packages/react-components/react-list-preview/src/ListItemButton.ts b/packages/react-components/react-list-preview/src/ListItemButton.ts deleted file mode 100644 index 3737c764d3713..0000000000000 --- a/packages/react-components/react-list-preview/src/ListItemButton.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components/ListItemButton/index'; diff --git a/packages/react-components/react-list-preview/src/components/List/List.cy.tsx b/packages/react-components/react-list-preview/src/components/List/List.cy.tsx new file mode 100644 index 0000000000000..f850e8173e5a5 --- /dev/null +++ b/packages/react-components/react-list-preview/src/components/List/List.cy.tsx @@ -0,0 +1,450 @@ +import * as React from 'react'; +import { mount as mountBase } from '@cypress/react'; +import { FluentProvider } from '@fluentui/react-provider'; +import { teamsLightTheme } from '@fluentui/react-theme'; + +import { List } from './List'; +import { ListItem } from '../ListItem'; +import { SelectionItemId } from '@fluentui/react-utilities'; + +const mount = (element: JSX.Element) => { + mountBase({element}); +}; + +/** + * Validates focus movement based on the sequence of keybaord commands + * Use focused: to validate the focused element after a given step + * @param sequence - Array of commands to execute + * @example + * testSequence([ + * 'focused:list-item-1', + * 'DownArrow', + * 'focused:list-item-2', + * ]) + */ +const testSequence = (sequence: Array) => { + cy.get('li:first-of-type').focus(); + for (const command of sequence) { + if (command.startsWith('focused:')) { + const tid = command.split(':')[1]; + cy.focused().should('have.attr', 'data-test', tid); + } else { + cy.focused().type(`{${command}}`); + } + } +}; + +const mountSimpleList = () => { + mount( + + List Item 1 + List Item 2 + List Item 3 + , + ); +}; + +const mountListWithSecondaryActions = () => { + mount( + + + List Item 1 + + + + List Item 2 + + + + List Item 3 + + + , + ); +}; + +type SelectionTestListProps = { + selectionMode: React.ComponentProps['selectionMode']; + defaultSelectedItems?: React.ComponentProps['defaultSelectedItems']; + controlled?: boolean; +}; + +const SelectionTestList = ({ selectionMode, defaultSelectedItems, controlled }: SelectionTestListProps) => { + const [selectedItems, setSelectedItems] = React.useState(defaultSelectedItems || []); + + const onChange = React.useCallback((_, { selectedItems: selected }) => { + setSelectedItems(selected); + }, []); + + const onSelectLastClick = React.useCallback(_ => { + setSelectedItems(['list-item-3']); + }, []); + + return ( + <> + + + List Item 1 + + + List Item 2 + + + List Item 3 + + + +
{JSON.stringify(selectedItems)}
+ + ); +}; + +const mountListForSelection = ( + selectionMode: 'single' | 'multiselect', + defaultSelectedItems?: Array, + controlled?: boolean, +) => { + mount( + , + ); +}; + +/** + * Validates the state of the list items based on the expected states. + * It checks: + * - aria-selected attribute on each item + * - checkbox state on each item + * - presence of the item in the parent state + * + * @param expectedStates - Array of boolean values representing the expected state of the list items + */ +const validateSetOfListItems = (expectedStates: Array) => + expectedStates.forEach((checked, index) => { + const listItem = cy.get(`[data-test="list-item-${index + 1}"]`); + cy.log('Validate aria-selected attr on item', index + 1); + listItem.should('have.attr', 'aria-selected', checked.toString()); + + cy.log('Validate checkbox state on item', index + 1); + cy.get(`[data-test="list-item-${index + 1}"] .fui-Checkbox__indicator > svg`).should( + checked ? 'exist' : 'not.exist', + ); + + cy.log('Validate that the item is present/not present in the parent state (or stringified state)'); + cy.get(`[data-test="selected-items"]`).should(checked ? 'contain' : 'not.contain', `list-item-${index + 1}`); + }); + +/** Toggles list item based on "shortId" - just a number + * @param shortId - Short id of the list item + * @example + * toggleListItem('1'); + */ +const toggleListItem = (shortId: String) => cy.get(`[data-test="list-item-${shortId}"]`).click(); + +/** + * Toggles the last item in the list + * Useful for testing that the controlled selection works as expected, as we change the state from the parent + * component and expect the list to reflect the change. + * @returns + */ +const selectOnlyLastItem = () => cy.get(`[data-test="select-last-item"]`).click(); + +describe('List', () => { + describe('keyboard navigation', () => { + describe('Simple list with a single action', () => { + it('Up/Down arrow keys work', () => { + mountSimpleList(); + testSequence([ + 'focused:list-item-1', + 'DownArrow', + 'focused:list-item-2', + 'DownArrow', + 'focused:list-item-3', + 'DownArrow', + 'focused:list-item-3', + 'UpArrow', + 'focused:list-item-2', + 'UpArrow', + 'focused:list-item-1', + 'UpArrow', + 'focused:list-item-1', + ]); + }); + + it('Home/End arrow keys work', () => { + mountSimpleList(); + testSequence(['focused:list-item-1', 'End', 'focused:list-item-3', 'Home', 'focused:list-item-1']); + }); + + it('PgUp/PgDown arrow keys work', () => { + mountSimpleList(); + testSequence(['focused:list-item-1', 'PageDown', 'focused:list-item-3', 'PageUp', 'focused:list-item-1']); + }); + }); + describe('List with multiple actions', () => { + it('Up/Down arrows work', () => { + mountListWithSecondaryActions(); + testSequence([ + 'focused:list-item-1', + 'DownArrow', + 'focused:list-item-2', + 'DownArrow', + 'focused:list-item-3', + 'DownArrow', + 'focused:list-item-3', + 'UpArrow', + 'focused:list-item-2', + 'UpArrow', + 'focused:list-item-1', + 'UpArrow', + 'focused:list-item-1', + ]); + }); + + it('Home/End arrow keys work', () => { + mountListWithSecondaryActions(); + testSequence(['focused:list-item-1', 'End', 'focused:list-item-3', 'Home', 'focused:list-item-1']); + }); + + it('PgUp/PgDown arrow keys work', () => { + mountListWithSecondaryActions(); + testSequence(['focused:list-item-1', 'PageDown', 'focused:list-item-3', 'PageUp', 'focused:list-item-1']); + }); + + it('Left/Right arrow key moves focus horizontally in the list item', () => { + mountListWithSecondaryActions(); + testSequence([ + 'focused:list-item-1', + 'RightArrow', + 'focused:list-item-1-button-1', + 'RightArrow', + 'focused:list-item-1-button-2', + 'RightArrow', + 'focused:list-item-1-button-2', + 'LeftArrow', + 'focused:list-item-1-button-1', + 'LeftArrow', + 'focused:list-item-1', + ]); + }); + + it('Escape moves out of the secondary and focuses on the same row', () => { + mountListWithSecondaryActions(); + testSequence([ + 'focused:list-item-1', + 'RightArrow', + 'focused:list-item-1-button-1', + 'RightArrow', + 'focused:list-item-1-button-2', + 'Esc', + 'focused:list-item-1', + ]); + }); + + it('Arrow up/down on the secondary action focuses the item above/below', () => { + mountListWithSecondaryActions(); + testSequence([ + 'focused:list-item-1', + 'DownArrow', + 'RightArrow', + 'focused:list-item-2-button-1', + 'UpArrow', + 'focused:list-item-1', + 'DownArrow', + 'RightArrow', + 'focused:list-item-2-button-1', + 'DownArrow', + 'focused:list-item-3', + ]); + }); + + it('Keys like Enter and Space are ignored', () => { + mountListWithSecondaryActions(); + testSequence(['RightArrow', 'focused:list-item-1-button-1', 'Enter', ' ', 'focused:list-item-1-button-1']); + }); + }); + }); + + describe('selection', () => { + describe('single select', () => { + it('selects the item when clicked', () => { + mountListForSelection('single'); + validateSetOfListItems([false, false, false]); + toggleListItem('1'); + validateSetOfListItems([true, false, false]); + toggleListItem('2'); + validateSetOfListItems([false, true, false]); + }); + + it('uncontrolled selection with defaultSelectedItems works', () => { + mountListForSelection('single', ['list-item-2']); + validateSetOfListItems([false, true, false]); + toggleListItem('3'); + validateSetOfListItems([false, false, true]); + }); + + it('controlled selection works', () => { + mountListForSelection('single', ['list-item-2'], true); + validateSetOfListItems([false, true, false]); + toggleListItem('1'); + validateSetOfListItems([true, false, false]); + selectOnlyLastItem(); + validateSetOfListItems([false, false, true]); + }); + }); + + describe('multi select', () => { + it('selects the item when clicked', () => { + mountListForSelection('multiselect'); + validateSetOfListItems([false, false, false]); + toggleListItem('1'); + validateSetOfListItems([true, false, false]); + toggleListItem('2'); + validateSetOfListItems([true, true, false]); + }); + + it('uncontrolled selection with defaultSelectedItems works', () => { + mountListForSelection('multiselect', ['list-item-2']); + validateSetOfListItems([false, true, false]); + toggleListItem('3'); + validateSetOfListItems([false, true, true]); + toggleListItem('2'); + validateSetOfListItems([false, false, true]); + toggleListItem('1'); + validateSetOfListItems([true, false, true]); + }); + + it('controlled selection works', () => { + mountListForSelection('multiselect', ['list-item-2'], true); + validateSetOfListItems([false, true, false]); + toggleListItem('1'); + validateSetOfListItems([true, true, false]); + selectOnlyLastItem(); + validateSetOfListItems([false, false, true]); + toggleListItem('2'); + validateSetOfListItems([false, true, true]); + }); + }); + }); + + describe('Accessibility roles', () => { + describe('without focusable children', () => { + it('default list is list/listitem', () => { + mountSimpleList(); + cy.get('ul').should('have.attr', 'role', 'list'); + cy.get('li').should('have.attr', 'role', 'listitem'); + }); + + it("single select list is listbox/option and doesn't have multiselectable aria prop", () => { + mount( + + List Item 1 + List Item 2 + List Item 3 + , + ); + cy.get('ul').should('have.attr', 'role', 'listbox'); + cy.get('li').should('have.attr', 'role', 'option'); + }); + + it('multiple select list is listbox/option and has multiselectable aria prop', () => { + mount( + + List Item 1 + List Item 2 + List Item 3 + , + ); + cy.get('ul').should('have.attr', 'aria-multiselectable', 'true'); + cy.get('ul').should('have.attr', 'role', 'listbox'); + cy.get('li').should('have.attr', 'role', 'option'); + }); + + it('custom roles work', () => { + mount( + + + List Item 1 + + + List Item 2 + + + List Item 3 + + , + ); + cy.get('ul').should('have.attr', 'role', 'customListRole'); + cy.get('li').should('have.attr', 'role', 'customListItemRole'); + }); + }); + + describe('with focusable children', () => { + it('default list is grid/row for composite', () => { + mount( + + + List Item 1 + + + List Item 2 + + + List Item 3 + + , + ); + cy.get('ul').should('have.attr', 'role', 'grid'); + cy.get('li').should('have.attr', 'role', 'row'); + }); + + it("single select list is grid/row and doesn't have multiselectable aria prop", () => { + mount( + + + List Item 1 + + + List Item 2 + + + List Item 3 + + , + ); + cy.get('ul').should('have.attr', 'role', 'grid'); + cy.get('li').should('have.attr', 'role', 'row'); + }); + + it('multiple select list is grid/row and has multiselectable aria prop', () => { + mount( + + + List Item 1 + + + List Item 2 + + + List Item 3 + + , + ); + cy.get('ul').should('have.attr', 'aria-multiselectable', 'true'); + cy.get('ul').should('have.attr', 'role', 'grid'); + cy.get('li').should('have.attr', 'role', 'row'); + }); + }); + }); +}); diff --git a/packages/react-components/react-list-preview/src/components/List/List.test.tsx b/packages/react-components/react-list-preview/src/components/List/List.test.tsx index 91866c402c08d..2983734746fdb 100644 --- a/packages/react-components/react-list-preview/src/components/List/List.test.tsx +++ b/packages/react-components/react-list-preview/src/components/List/List.test.tsx @@ -1,18 +1,512 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render, within } from '@testing-library/react'; import { isConformant } from '../../testing/isConformant'; import { List } from './List'; +import { ListProps } from './List.types'; +import { ListItem } from '../ListItem/ListItem'; +import { ListItemActionEvent } from '../../events/ListItemActionEvent'; + +function expectListboxItemSelected(item: HTMLElement, selected: boolean) { + expect(item.getAttribute('aria-selected')).toBe(selected.toString()); +} describe('List', () => { isConformant({ - Component: List, + Component: List as React.FunctionComponent, displayName: 'List', }); - // TODO add more tests here, and create visual regression tests in /apps/vr-tests + // Mock the console.warn, because we're getting the legitimate about mismatched roles when testing custom roles + // and false warnings about the mismatched roles because of tabster not working reliably in tests. + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => jest.fn()); + + afterAll(() => { + consoleWarn.mockRestore(); + }); + + describe('rendering', () => { + it('renders a default state', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + expect(result.container).toMatchSnapshot(); + }); + + describe('checkbox indicator', () => { + it("doesn't render checkbox when selectionMode is not set", () => { + const result = render( + + First ListItem + Second ListItem + , + ); + expect(result.queryAllByRole('checkbox')).toHaveLength(0); + }); + it("renders checkbox when selectionMode is 'single'", () => { + const result = render( + + First ListItem + Second ListItem + , + ); + expect(result.queryAllByRole('checkbox')).toHaveLength(2); + }); + it("renders checkbox when selectionMode is 'multiselect'", () => { + const result = render( + + First ListItem + Second ListItem + , + ); + expect(result.queryAllByRole('checkbox')).toHaveLength(2); + }); + }); + + describe('render as', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => jest.fn()); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + it('div and li throws', () => { + expect(() => + render( + + First ListItem + Second ListItem + , + ), + ).toThrowError('ListItem cannot be rendered as a li when its parent is a div.'); + }); + + it('ul and div throws', () => { + expect(() => + render( + + + First ListItem + + + Second ListItem + + , + ), + ).toThrowError('ListItem cannot be rendered as a div when its parent is not a div.'); + }); + + it("div and div doesn't throw", () => { + const result = render( + + + First ListItem + + + Second ListItem + + , + ); + expect(result.container).toMatchSnapshot(); + }); + }); + }); + + describe('roles', () => { + it('default - should have list/listitem roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('list')).toHaveLength(1); + expect(result.getAllByRole('listitem')).toHaveLength(2); + }); + + it('selectable - should have listbox/option roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('listbox')).toHaveLength(1); + expect(result.getAllByRole('option')).toHaveLength(2); + }); + + it('custom - should have passed roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('test')).toHaveLength(1); + expect(result.getAllByRole('foo')).toHaveLength(2); + }); + + it('custom - should have passed roles when when navigationMode is "items"', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('test')).toHaveLength(1); + expect(result.getAllByRole('foo')).toHaveLength(2); + }); + + it('custom - should have passed roles when when navigationMode is "composite"', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('test')).toHaveLength(1); + expect(result.getAllByRole('foo')).toHaveLength(2); + }); + + it('navigationMode = items - should have list/listitem roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('list')).toHaveLength(1); + expect(result.getAllByRole('listitem')).toHaveLength(2); + }); + + it('navigationMode = items with selection- should have listbox/option roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('listbox')).toHaveLength(1); + expect(result.getAllByRole('option')).toHaveLength(2); + }); + it('navigationMode = composite should have grid/row roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('grid')).toHaveLength(1); + expect(result.getAllByRole('row')).toHaveLength(2); + }); + it('navigationMode = composite with selection should have grid/row roles', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.getAllByRole('grid')).toHaveLength(1); + expect(result.getAllByRole('row')).toHaveLength(2); + }); + }); + + describe('selection', () => { + it('single select items are properly rendered with all attributes', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.container).toMatchSnapshot(); + }); + it('multiselect items are properly rendered with all attributes', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + expect(result.container).toMatchSnapshot(); + }); + + it('Single mode should unselect previous', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + const secondItem = result.getByText('Second ListItem'); + + expectListboxItemSelected(firstItem, false); + expectListboxItemSelected(secondItem, false); + + firstItem.click(); + expectListboxItemSelected(firstItem, true); + expectListboxItemSelected(secondItem, false); + + secondItem.click(); + expectListboxItemSelected(firstItem, false); + expectListboxItemSelected(secondItem, true); + }); + + it('Multi mode should select an item when clicked', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + const secondItem = result.getByText('Second ListItem'); + + // [ ][ ] + expectListboxItemSelected(firstItem, false); + expectListboxItemSelected(secondItem, false); + + firstItem.click(); + // [x][ ] + expectListboxItemSelected(firstItem, true); + expectListboxItemSelected(secondItem, false); + + secondItem.click(); + // [x][x] + expectListboxItemSelected(firstItem, true); + expectListboxItemSelected(secondItem, true); + + secondItem.click(); + // [x][ ] + expectListboxItemSelected(firstItem, true); + expectListboxItemSelected(secondItem, false); + + firstItem.click(); + // [ ][ ] + expectListboxItemSelected(firstItem, false); + expectListboxItemSelected(secondItem, false); + }); + }); + + describe('mouse behavior', () => { + describe('no selection', () => { + it('Click should trigger onClick', () => { + const onClick = jest.fn(); + + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + it('Click should trigger onAction', () => { + const onAction = jest.fn(); + + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.click(); + expect(onAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('with selection', () => { + function interactWithFirstElement( + interaction: (firstItem: HTMLElement) => void, + customAction?: (e: ListItemActionEvent) => void, + ) { + const onAction = jest.fn(customAction); + + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + interaction(firstItem); + + return { listItem: firstItem, onAction }; + } + + it('Click should trigger selection and onAction callback by default', () => { + const { listItem, onAction } = interactWithFirstElement(item => item.click()); + expect(onAction).toHaveBeenCalledTimes(1); + expectListboxItemSelected(listItem, true); + }); + + it('preventDefault should prevent selection', () => { + const { listItem, onAction } = interactWithFirstElement( + item => item.click(), + e => e.preventDefault(), + ); + expect(onAction).toHaveBeenCalledTimes(1); + expectListboxItemSelected(listItem, false); + }); + + it("Click on the checkbox should trigger selection, onAction shouldn't be called", () => { + const { listItem, onAction } = interactWithFirstElement(item => { + within(item).getByRole('checkbox').click(); + }); + expect(onAction).not.toHaveBeenCalled(); + expectListboxItemSelected(listItem, true); + }); + }); + }); + + describe('focusable list items', () => { + it('should not be focusable by default', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.focus(); + expect(document.activeElement).not.toBe(firstItem); + }); + it('should be focusable when "navigationMode" is "items"', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.focus(); + expect(document.activeElement).toBe(firstItem); + }); + + it('should be focusable when "navigationMode" is "composite"', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.focus(); + expect(document.activeElement).toBe(firstItem); + }); + + it('should be focusable when list is selectable', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.focus(); + expect(document.activeElement).toBe(firstItem); + }); + it('should be focusable when tabIndex=0 is passed', () => { + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + firstItem.focus(); + expect(document.activeElement).toBe(firstItem); + }); + }); + + describe('keyboard behavior', () => { + describe('no selection', () => { + function pressKeyOnListItem(key: string) { + const onAction = jest.fn(); + + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + fireEvent.keyDown(firstItem, { key }); + return { onAction }; + } + + it('should NOT trigger onClick when random key is pressed', () => { + expect(pressKeyOnListItem('a').onAction).toHaveBeenCalledTimes(0); + }); + it('Space should trigger onClick', () => { + expect(pressKeyOnListItem(' ').onAction).toHaveBeenCalledTimes(1); + }); + it('Enter should trigger onClick', () => { + expect(pressKeyOnListItem('Enter').onAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('with selection', () => { + function pressOnListItem(key: string, customOnaction?: (e: ListItemActionEvent) => void) { + const onAction = jest.fn(customOnaction); + + const result = render( + + First ListItem + Second ListItem + , + ); + + const firstItem = result.getByText('First ListItem'); + fireEvent.keyDown(firstItem, { key }); + return { onAction, listItem: firstItem }; + } + + it('Spacebar toggles selection by default, onClick is not called', () => { + const { onAction, listItem } = pressOnListItem(' '); + expect(onAction).not.toHaveBeenCalled(); + expectListboxItemSelected(listItem, true); + }); - it('renders a default state', () => { - const result = render(Default List); - expect(result.container).toMatchSnapshot(); + it('Enter toggles selection by default, onClick is called', () => { + const { onAction, listItem } = pressOnListItem('Enter'); + expect(onAction).toHaveBeenCalledTimes(1); + expectListboxItemSelected(listItem, true); + }); + it("Enter doesn't toggle selection if default is prevented", () => { + const { onAction, listItem } = pressOnListItem('Enter', e => e.preventDefault()); + expect(onAction).toHaveBeenCalledTimes(1); + expectListboxItemSelected(listItem, false); + }); + }); }); }); diff --git a/packages/react-components/react-list-preview/src/components/List/List.tsx b/packages/react-components/react-list-preview/src/components/List/List.tsx index 7f1f75c8e429e..c3add40964ea9 100644 --- a/packages/react-components/react-list-preview/src/components/List/List.tsx +++ b/packages/react-components/react-list-preview/src/components/List/List.tsx @@ -5,19 +5,16 @@ import { useList_unstable } from './useList'; import { renderList_unstable } from './renderList'; import { useListStyles_unstable } from './useListStyles.styles'; import type { ListProps } from './List.types'; +import { useListContextValues_unstable } from './useListContextValues'; -/** - * List component - TODO: add more docs - */ export const List: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useList_unstable(props, ref); + const contextValues = useListContextValues_unstable(state); useListStyles_unstable(state); - - // TODO update types in packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts - // https://github.com/microsoft/fluentui/blob/master/rfcs/react-components/convergence/custom-styling.md useCustomStyleHook_unstable('useListStyles_unstable')(state); - return renderList_unstable(state); + + return renderList_unstable(state, contextValues); }); List.displayName = 'List'; diff --git a/packages/react-components/react-list-preview/src/components/List/List.types.ts b/packages/react-components/react-list-preview/src/components/List/List.types.ts index 2bd3e77ab99a3..b7f32f572a8d1 100644 --- a/packages/react-components/react-list-preview/src/components/List/List.types.ts +++ b/packages/react-components/react-list-preview/src/components/List/List.types.ts @@ -1,17 +1,49 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; + +import type { + ComponentProps, + ComponentState, + Slot, + SelectionMode, + SelectionItemId, + EventHandler, + EventData, +} from '@fluentui/react-utilities'; +import type { ListSelectionState } from '../../hooks/types'; export type ListSlots = { - root: Slot<'div'>; + root: NonNullable>; +}; + +export type OnListSelectionChangeData = EventData<'change', React.SyntheticEvent> & { + selectedItems: SelectionItemId[]; }; +export type ListNavigationMode = 'items' | 'composite'; + /** * List Props */ -export type ListProps = ComponentProps & {}; +export type ListProps = ComponentProps & { + navigationMode?: ListNavigationMode; + selectionMode?: SelectionMode; + selectedItems?: SelectionItemId[]; + defaultSelectedItems?: SelectionItemId[]; + onSelectionChange?: EventHandler; +}; + +export type ListContextValue = { + navigationMode: ListNavigationMode | undefined; + selection?: ListSelectionState; + listItemRole: string; + validateListItem: (listItemElement: HTMLElement) => void; +}; + +export type ListContextValues = { + listContext: ListContextValue; +}; /** * State used in rendering List */ -export type ListState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from ListProps. -// & Required> +export type ListState = ComponentState & ListContextValue; diff --git a/packages/react-components/react-list-preview/src/components/List/__snapshots__/List.test.tsx.snap b/packages/react-components/react-list-preview/src/components/List/__snapshots__/List.test.tsx.snap index c7e02b09599b4..96d19b7489497 100644 --- a/packages/react-components/react-list-preview/src/components/List/__snapshots__/List.test.tsx.snap +++ b/packages/react-components/react-list-preview/src/components/List/__snapshots__/List.test.tsx.snap @@ -1,11 +1,180 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`List renders a default state 1`] = ` +exports[`List rendering render as div and div doesn't throw 1`] = `
- Default List +
+ First ListItem +
+
+ Second ListItem +
`; + +exports[`List rendering renders a default state 1`] = ` +
+
    +
  • + First ListItem +
  • +
  • + Second ListItem +
  • +
+
+`; + +exports[`List selection multiselect items are properly rendered with all attributes 1`] = ` +
+
    +
  • + + +
  • +
  • + + +
  • +
+
+`; + +exports[`List selection single select items are properly rendered with all attributes 1`] = ` +
+
    +
  • + + +
  • +
  • + + +
  • +
+
+`; diff --git a/packages/react-components/react-list-preview/src/components/List/listContext.ts b/packages/react-components/react-list-preview/src/components/List/listContext.ts new file mode 100644 index 0000000000000..c84ddece60316 --- /dev/null +++ b/packages/react-components/react-list-preview/src/components/List/listContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import type { ContextSelector } from '@fluentui/react-context-selector'; +import { ListContextValue } from './List.types'; + +export const listContextDefaultValue: ListContextValue = { + navigationMode: undefined, + selection: undefined, + listItemRole: 'listitem', + validateListItem: () => { + /* noop */ + }, +}; + +const listContext = createContext(undefined); + +export const ListContextProvider = listContext.Provider; + +export const useListContext_unstable = (selector: ContextSelector): T => + useContextSelector(listContext, (ctx = listContextDefaultValue) => selector(ctx)); diff --git a/packages/react-components/react-list-preview/src/components/List/renderList.tsx b/packages/react-components/react-list-preview/src/components/List/renderList.tsx index 435304774c006..0bad35f828ead 100644 --- a/packages/react-components/react-list-preview/src/components/List/renderList.tsx +++ b/packages/react-components/react-list-preview/src/components/List/renderList.tsx @@ -2,14 +2,18 @@ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { assertSlots } from '@fluentui/react-utilities'; -import type { ListState, ListSlots } from './List.types'; +import type { ListState, ListSlots, ListContextValues } from './List.types'; +import { ListContextProvider } from './listContext'; /** * Render the final JSX of List */ -export const renderList_unstable = (state: ListState) => { +export const renderList_unstable = (state: ListState, contextValues: ListContextValues) => { assertSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + + + ); }; diff --git a/packages/react-components/react-list-preview/src/components/List/useList.ts b/packages/react-components/react-list-preview/src/components/List/useList.ts index 4f197513bb26d..a8a1b8370c8fe 100644 --- a/packages/react-components/react-list-preview/src/components/List/useList.ts +++ b/packages/react-components/react-list-preview/src/components/List/useList.ts @@ -1,6 +1,23 @@ import * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ListProps, ListState } from './List.types'; +import { + getIntrinsicElementProps, + OnSelectionChangeData, + slot, + useControllableState, + useEventCallback, +} from '@fluentui/react-utilities'; +import { useArrowNavigationGroup, useFocusFinders } from '@fluentui/react-tabster'; +import { ListProps, ListState } from './List.types'; +import { useListSelection } from '../../hooks/useListSelection'; +import { + calculateListItemRoleForListRole, + calculateListRole, + validateGridCellsArePresent, + validateProperElementTypes, + validateProperRolesAreUsed, +} from '../../utils'; + +const DEFAULT_ROOT_EL_TYPE = 'ul'; /** * Create the state required to render List. @@ -9,23 +26,81 @@ import type { ListProps, ListState } from './List.types'; * before being passed to renderList_unstable. * * @param props - props from this instance of List - * @param ref - reference to root HTMLDivElement of List + * @param ref - reference to root HTMLElement of List */ -export const useList_unstable = (props: ListProps, ref: React.Ref): ListState => { +export const useList_unstable = ( + props: ListProps, + ref: React.Ref, +): ListState => { + const { + navigationMode, + selectionMode, + selectedItems, + defaultSelectedItems, + as = DEFAULT_ROOT_EL_TYPE, + onSelectionChange, + } = props; + + const arrowNavigationAttributes = useArrowNavigationGroup({ + axis: 'vertical', + memorizeCurrent: true, + }); + + const [selectionState, setSelectionState] = useControllableState({ + state: selectedItems, + defaultState: defaultSelectedItems, + initialState: [], + }); + + const onChange = useEventCallback((e: React.SyntheticEvent, data: OnSelectionChangeData) => { + const selectedItemsAsArray = Array.from(data.selectedItems); + setSelectionState(selectedItemsAsArray); + onSelectionChange?.(e, { event: e, type: 'change', selectedItems: selectedItemsAsArray }); + }); + + const selection = useListSelection({ + onSelectionChange: onChange, + selectionMode: selectionMode || 'multiselect', + selectedItems: selectionState, + defaultSelectedItems, + }); + + const listRole = props.role || calculateListRole(navigationMode, !!selectionMode); + const listItemRole = calculateListItemRoleForListRole(listRole); + + const { findAllFocusable } = useFocusFinders(); + + const validateListItem = useEventCallback((listItemEl: HTMLElement) => { + if (process.env.NODE_ENV === 'production') { + return; + } + const itemRole = listItemEl.getAttribute('role') || ''; + const focusable = findAllFocusable(listItemEl); + validateProperElementTypes(as, listItemEl.tagName.toLocaleLowerCase()); + validateProperRolesAreUsed(listRole, itemRole, !!selectionMode, focusable.length > 0); + validateGridCellsArePresent(listRole, listItemEl); + }); + return { - // TODO add appropriate props/defaults components: { - // TODO add each slot's element type or component - root: 'div', + root: DEFAULT_ROOT_EL_TYPE, }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), root: slot.always( - getIntrinsicElementProps('div', { + getIntrinsicElementProps(DEFAULT_ROOT_EL_TYPE, { ref, + role: listRole, + ...(selectionMode && { + 'aria-multiselectable': selectionMode === 'multiselect' ? true : undefined, + }), + ...arrowNavigationAttributes, ...props, }), - { elementType: 'div' }, + { elementType: DEFAULT_ROOT_EL_TYPE }, ), + listItemRole, + validateListItem, + navigationMode, + // only pass down selection state if its handled internally, otherwise just report the events + selection: selectionMode ? selection : undefined, }; }; diff --git a/packages/react-components/react-list-preview/src/components/List/useListContextValues.ts b/packages/react-components/react-list-preview/src/components/List/useListContextValues.ts new file mode 100644 index 0000000000000..87a6965077925 --- /dev/null +++ b/packages/react-components/react-list-preview/src/components/List/useListContextValues.ts @@ -0,0 +1,16 @@ +import { ListContextValues, ListState } from './List.types'; + +export function useListContextValues_unstable(state: ListState): ListContextValues { + const { selection, navigationMode, listItemRole, validateListItem } = state; + + const listContext = { + selection, + listItemRole, + navigationMode, + validateListItem, + }; + + return { + listContext, + }; +} diff --git a/packages/react-components/react-list-preview/src/components/List/useListStyles.styles.ts b/packages/react-components/react-list-preview/src/components/List/useListStyles.styles.ts index 19986555d581b..e5fcf9633dcbc 100644 --- a/packages/react-components/react-list-preview/src/components/List/useListStyles.styles.ts +++ b/packages/react-components/react-list-preview/src/components/List/useListStyles.styles.ts @@ -1,33 +1,25 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { makeResetStyles, mergeClasses } from '@griffel/react'; import type { ListSlots, ListState } from './List.types'; export const listClassNames: SlotClassNames = { root: 'fui-List', - // TODO: add class names for all slots on ListSlots. - // Should be of the form `: 'fui-List__` }; -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element - }, - - // TODO add additional classes for different states and/or slots +const useRootBaseStyles = makeResetStyles({ + padding: 0, + margin: 0, + textIndent: 0, + listStyleType: 'none', }); /** * Apply styling to the List slots based on the state */ export const useListStyles_unstable = (state: ListState): ListState => { - const styles = useStyles(); - state.root.className = mergeClasses(listClassNames.root, styles.root, state.root.className); + const rootStyles = useRootBaseStyles(); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.root.className = mergeClasses(listClassNames.root, rootStyles, state.root.className); return state; }; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.test.tsx b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.test.tsx index 0b23f1dc97ad2..28096802579b4 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.test.tsx +++ b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.test.tsx @@ -2,15 +2,23 @@ import * as React from 'react'; import { render } from '@testing-library/react'; import { isConformant } from '../../testing/isConformant'; import { ListItem } from './ListItem'; +import { ListItemProps } from './ListItem.types'; describe('ListItem', () => { - isConformant({ - Component: ListItem, + isConformant({ + Component: ListItem as React.FunctionComponent, displayName: 'ListItem', + testOptions: { + 'has-static-classnames': [ + { + props: { + checkmark: { renderByDefault: true }, + }, + }, + ], + }, }); - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - it('renders a default state', () => { const result = render(Default ListItem); expect(result.container).toMatchSnapshot(); diff --git a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.tsx b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.tsx index e850346d78f47..c9ce02e04c8ac 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.tsx +++ b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.tsx @@ -6,18 +6,14 @@ import { renderListItem_unstable } from './renderListItem'; import { useListItemStyles_unstable } from './useListItemStyles.styles'; import type { ListItemProps } from './ListItem.types'; -/** - * ListItem component - TODO: add more docs - */ -export const ListItem: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useListItem_unstable(props, ref); +export const ListItem: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useListItem_unstable(props, ref); - useListItemStyles_unstable(state); - - // TODO update types in packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts - // https://github.com/microsoft/fluentui/blob/master/rfcs/react-components/convergence/custom-styling.md - useCustomStyleHook_unstable('useListItemStyles_unstable')(state); - return renderListItem_unstable(state); -}); + useListItemStyles_unstable(state); + useCustomStyleHook_unstable('useListItemStyles_unstable')(state); + return renderListItem_unstable(state); + }, +); ListItem.displayName = 'ListItem'; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.types.ts b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.types.ts index aa6539c260648..5306d735845ef 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/ListItem.types.ts +++ b/packages/react-components/react-list-preview/src/components/ListItem/ListItem.types.ts @@ -1,17 +1,22 @@ +import { Checkbox } from '@fluentui/react-checkbox'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { ListItemActionEvent } from '../../events/ListItemActionEvent'; export type ListItemSlots = { - root: Slot<'div'>; + root: NonNullable>; + checkmark?: Slot; }; /** * ListItem Props */ -export type ListItemProps = ComponentProps & {}; +export type ListItemProps = ComponentProps & { + value?: string | number; + // eslint-disable-next-line @nx/workspace-consistent-callback-type -- using custom event here with no data + onAction?: (e: ListItemActionEvent) => void; +}; /** * State used in rendering ListItem */ -export type ListItemState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from ListItemProps. -// & Required> +export type ListItemState = ComponentState & { selectable: boolean; navigable: boolean }; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/__snapshots__/ListItem.test.tsx.snap b/packages/react-components/react-list-preview/src/components/ListItem/__snapshots__/ListItem.test.tsx.snap index 21a24d5f673ba..5b89e2177ca23 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/__snapshots__/ListItem.test.tsx.snap +++ b/packages/react-components/react-list-preview/src/components/ListItem/__snapshots__/ListItem.test.tsx.snap @@ -2,10 +2,13 @@ exports[`ListItem renders a default state 1`] = `
-
Default ListItem -
+
`; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/renderListItem.tsx b/packages/react-components/react-list-preview/src/components/ListItem/renderListItem.tsx index 62c3e1e8a07f3..a9cb06033c966 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/renderListItem.tsx +++ b/packages/react-components/react-list-preview/src/components/ListItem/renderListItem.tsx @@ -10,6 +10,10 @@ import type { ListItemState, ListItemSlots } from './ListItem.types'; export const renderListItem_unstable = (state: ListItemState) => { assertSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + {state.checkmark && } + {state.root.children} + + ); }; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/useListItem.ts b/packages/react-components/react-list-preview/src/components/ListItem/useListItem.ts deleted file mode 100644 index 9130a31345c75..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItem/useListItem.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ListItemProps, ListItemState } from './ListItem.types'; - -/** - * Create the state required to render ListItem. - * - * The returned state can be modified with hooks such as useListItemStyles_unstable, - * before being passed to renderListItem_unstable. - * - * @param props - props from this instance of ListItem - * @param ref - reference to root HTMLDivElement of ListItem - */ -export const useListItem_unstable = (props: ListItemProps, ref: React.Ref): ListItemState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add each slot's element type or component - root: 'div', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: slot.always( - getIntrinsicElementProps('div', { - ref, - ...props, - }), - { elementType: 'div' }, - ), - }; -}; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/useListItem.tsx b/packages/react-components/react-list-preview/src/components/ListItem/useListItem.tsx new file mode 100644 index 0000000000000..f989aee9d74b1 --- /dev/null +++ b/packages/react-components/react-list-preview/src/components/ListItem/useListItem.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import { + dispatchGroupperMoveFocusEvent, + dispatchMoverMoveFocusEvent, + TabsterDOMAttribute, + TabsterTypes, + useArrowNavigationGroup, + useFocusableGroup, + useMergedTabsterAttributes_unstable, +} from '@fluentui/react-tabster'; +import { + elementContains, + getIntrinsicElementProps, + mergeCallbacks, + slot, + useEventCallback, + useId, + useMergedRefs, +} from '@fluentui/react-utilities'; +import type { ListItemProps, ListItemState } from './ListItem.types'; +import { useListContext_unstable } from '../List/listContext'; +import { Enter, Space, ArrowUp, ArrowDown, ArrowRight, ArrowLeft } from '@fluentui/keyboard-keys'; +import { Checkbox, CheckboxOnChangeData } from '@fluentui/react-checkbox'; +import { createListItemActionEvent, ListItemActionEvent } from '../../events/ListItemActionEvent'; + +const DEFAULT_ROOT_EL_TYPE = 'li'; + +/** + * Create the state required to render ListItem. + * + * The returned state can be modified with hooks such as useListItemStyles_unstable, + * before being passed to renderListItem_unstable. + * + * @param props - props from this instance of ListItem + * @param ref - reference to root HTMLLIElement | HTMLDivElementof ListItem + */ +export const useListItem_unstable = ( + props: ListItemProps, + ref: React.Ref, +): ListItemState => { + const id = useId('listItem'); + const { value = id, onKeyDown, onClick, tabIndex, role, onAction } = props; + + const toggleItem = useListContext_unstable(ctx => ctx.selection?.toggleItem); + const navigationMode = useListContext_unstable(ctx => ctx.navigationMode); + const isSelectionEnabled = useListContext_unstable(ctx => !!ctx.selection); + const isSelected = useListContext_unstable(ctx => ctx.selection?.isSelected(value)); + const listItemRole = useListContext_unstable(ctx => ctx.listItemRole); + const validateListItem = useListContext_unstable(ctx => ctx.validateListItem); + + const finalListItemRole = role || listItemRole; + + const focusableItems = Boolean(isSelectionEnabled || navigationMode || tabIndex === 0); + + const rootRef = React.useRef(null); + const checkmarkRef = React.useRef(null); + + const handleAction: (e: ListItemActionEvent) => void = useEventCallback(e => { + onAction?.(e); + + if (e.defaultPrevented) { + return; + } + + if (isSelectionEnabled) { + toggleItem?.(e.detail.originalEvent, value); + } + }); + + React.useEffect(() => { + if (rootRef.current) { + validateListItem(rootRef.current); + } + }, [validateListItem]); + + const triggerAction = (e: React.MouseEvent | React.KeyboardEvent) => { + const actionEvent = createListItemActionEvent(e); + handleAction(actionEvent); + e.target.dispatchEvent(actionEvent); + }; + + const focusableGroupAttrs = useFocusableGroup({ + ignoreDefaultKeydown: { Enter: true }, + tabBehavior: 'limited-trap-focus', + }); + + const handleClick: React.MouseEventHandler = useEventCallback(e => { + onClick?.(e); + + if (e.defaultPrevented) { + return; + } + + const isFromCheckbox = elementContains(checkmarkRef.current, e.target as Node); + if (isFromCheckbox) { + return; + } + + triggerAction(e); + }); + + const handleKeyDown: React.KeyboardEventHandler = useEventCallback(e => { + onKeyDown?.(e); + + if (e.defaultPrevented) { + return; + } + + // If the event is fired from an element inside the list item + if (e.target !== e.currentTarget) { + if (focusableItems) { + // If the items are focusable, we need to handle the arrow keys to move focus to them + switch (e.key) { + // If it's one of the Arrows defined, jump out of the list item to focus on the ListItem itself + // The ArrowLeft will only trigger if the target element is the leftmost, otherwise the + // arrowNavigationAttributes handles it and prevents it from bubbling here. + case ArrowLeft: + dispatchGroupperMoveFocusEvent(e.target as HTMLElement, TabsterTypes.GroupperMoveFocusActions.Escape); + break; + + case ArrowDown: + case ArrowUp: + e.preventDefault(); + // Press ESC on the original target to get focus to the parent group (List) + dispatchGroupperMoveFocusEvent(e.target as HTMLElement, TabsterTypes.GroupperMoveFocusActions.Escape); + // Now dispatch the original key to move up or down in the list + dispatchMoverMoveFocusEvent(e.currentTarget as HTMLElement, TabsterTypes.MoverKeys[e.key]); + } + return; + } + return; + } + + switch (e.key) { + case Space: + // we have to prevent default here otherwise the space key will scroll the page + e.preventDefault(); + + // Space always toggles selection (if enabled) + if (isSelectionEnabled) { + toggleItem?.(e, value); + } else { + triggerAction(e); + } + + break; + + case Enter: + triggerAction(e); + break; + + case ArrowRight: + if (navigationMode === 'composite') { + dispatchGroupperMoveFocusEvent(e.target as HTMLElement, TabsterTypes.GroupperMoveFocusActions.Enter); + } + + break; + } + }); + + const onCheckboxChange = useEventCallback((e: React.ChangeEvent, data: CheckboxOnChangeData) => { + if (!isSelectionEnabled || e.defaultPrevented) { + return; + } + + toggleItem?.(e, value); + }); + + const arrowNavigationAttributes = useArrowNavigationGroup({ + axis: 'horizontal', + }); + + const tabsterAttributes = useMergedTabsterAttributes_unstable( + focusableItems ? arrowNavigationAttributes : ({} as TabsterDOMAttribute), + focusableGroupAttrs, + ); + + const root = slot.always( + getIntrinsicElementProps(DEFAULT_ROOT_EL_TYPE, { + ref: useMergedRefs(rootRef, ref) as React.Ref, + tabIndex: focusableItems ? 0 : undefined, + role: finalListItemRole, + id: String(value), + ...(isSelectionEnabled && { + 'aria-selected': isSelected, + }), + ...tabsterAttributes, + ...props, + onKeyDown: handleKeyDown, + onClick: isSelectionEnabled || onClick || onAction ? handleClick : undefined, + }), + { elementType: DEFAULT_ROOT_EL_TYPE }, + ); + + const checkmark = slot.optional(props.checkmark, { + defaultProps: { + checked: isSelected, + tabIndex: -1, + }, + renderByDefault: isSelectionEnabled, + elementType: Checkbox, + }); + + const mergedCheckmarkRef = useMergedRefs(checkmark?.ref, checkmarkRef); + if (checkmark) { + checkmark.onChange = mergeCallbacks(checkmark.onChange, onCheckboxChange); + checkmark.ref = mergedCheckmarkRef; + } + + const state: ListItemState = { + components: { + root: DEFAULT_ROOT_EL_TYPE, + checkmark: Checkbox, + }, + root, + checkmark, + selectable: isSelectionEnabled, + navigable: focusableItems, + }; + + return state; +}; diff --git a/packages/react-components/react-list-preview/src/components/ListItem/useListItemStyles.styles.ts b/packages/react-components/react-list-preview/src/components/ListItem/useListItemStyles.styles.ts index 302e8cfffeae1..7c4d8c748a8db 100644 --- a/packages/react-components/react-list-preview/src/components/ListItem/useListItemStyles.styles.ts +++ b/packages/react-components/react-list-preview/src/components/ListItem/useListItemStyles.styles.ts @@ -1,33 +1,69 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { makeStyles, makeResetStyles, mergeClasses, shorthands } from '@griffel/react'; +import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; import type { ListItemSlots, ListItemState } from './ListItem.types'; +import { tokens } from '@fluentui/react-theme'; export const listItemClassNames: SlotClassNames = { root: 'fui-ListItem', - // TODO: add class names for all slots on ListItemSlots. - // Should be of the form `: 'fui-ListItem__` + checkmark: 'fui-ListItem__checkmark', }; +const useRootBaseStyles = makeResetStyles({ + padding: 0, + margin: 0, + textIndent: 0, + listStyleType: 'none', + ...createCustomFocusIndicatorStyle( + { + ...shorthands.outline(tokens.strokeWidthThick, 'solid', tokens.colorStrokeFocus2), + ...shorthands.borderRadius(tokens.borderRadiusMedium), + }, + { selector: 'focus' }, + ), +}); + +const useCheckmarkBaseStyles = makeStyles({ + root: { + alignSelf: 'center', + //eslint-disable-next-line + '& .fui-Checkbox__indicator': { + ...shorthands.margin('4px'), + }, + }, +}); /** * Styles for the root slot */ const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element + rootClickableOrSelectable: { + display: 'flex', + cursor: 'pointer', }, - - // TODO add additional classes for different states and/or slots }); /** * Apply styling to the ListItem slots based on the state */ export const useListItemStyles_unstable = (state: ListItemState): ListItemState => { + const rootBaseStyles = useRootBaseStyles(); + const checkmarkBaseStyles = useCheckmarkBaseStyles(); const styles = useStyles(); - state.root.className = mergeClasses(listItemClassNames.root, styles.root, state.root.className); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.root.className = mergeClasses( + listItemClassNames.root, + rootBaseStyles, + (state.selectable || state.navigable) && styles.rootClickableOrSelectable, + state.root.className, + ); + + if (state.checkmark) { + state.checkmark.className = mergeClasses( + listItemClassNames.checkmark, + checkmarkBaseStyles.root, + state.checkmark.className, + ); + } return state; }; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.test.tsx b/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.test.tsx deleted file mode 100644 index 7889695b1cf03..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { isConformant } from '../../testing/isConformant'; -import { ListItemButton } from './ListItemButton'; - -describe('ListItemButton', () => { - isConformant({ - Component: ListItemButton, - displayName: 'ListItemButton', - }); - - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - - it('renders a default state', () => { - const result = render(Default ListItemButton); - expect(result.container).toMatchSnapshot(); - }); -}); diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.tsx b/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.tsx deleted file mode 100644 index b293377c115c6..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import { useCustomStyleHook_unstable } from '@fluentui/react-shared-contexts'; -import { useListItemButton_unstable } from './useListItemButton'; -import { renderListItemButton_unstable } from './renderListItemButton'; -import { useListItemButtonStyles_unstable } from './useListItemButtonStyles.styles'; -import type { ListItemButtonProps } from './ListItemButton.types'; - -/** - * ListItemButton component - TODO: add more docs - */ -export const ListItemButton: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useListItemButton_unstable(props, ref); - - useListItemButtonStyles_unstable(state); - - // TODO update types in packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts - // https://github.com/microsoft/fluentui/blob/master/rfcs/react-components/convergence/custom-styling.md - useCustomStyleHook_unstable('useListItemButtonStyles_unstable')(state); - return renderListItemButton_unstable(state); -}); - -ListItemButton.displayName = 'ListItemButton'; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.types.ts b/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.types.ts deleted file mode 100644 index f1ede327f93bd..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/ListItemButton.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; - -export type ListItemButtonSlots = { - root: Slot<'div'>; -}; - -/** - * ListItemButton Props - */ -export type ListItemButtonProps = ComponentProps & {}; - -/** - * State used in rendering ListItemButton - */ -export type ListItemButtonState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from ListItemButtonProps. -// & Required> diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/__snapshots__/ListItemButton.test.tsx.snap b/packages/react-components/react-list-preview/src/components/ListItemButton/__snapshots__/ListItemButton.test.tsx.snap deleted file mode 100644 index 2345bfffaad67..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/__snapshots__/ListItemButton.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ListItemButton renders a default state 1`] = ` -
-
- Default ListItemButton -
-
-`; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/index.ts b/packages/react-components/react-list-preview/src/components/ListItemButton/index.ts deleted file mode 100644 index d0e6b911f3b00..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './ListItemButton'; -export * from './ListItemButton.types'; -export * from './renderListItemButton'; -export * from './useListItemButton'; -export * from './useListItemButtonStyles.styles'; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/renderListItemButton.tsx b/packages/react-components/react-list-preview/src/components/ListItemButton/renderListItemButton.tsx deleted file mode 100644 index 2dc4e639cc533..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/renderListItemButton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/** @jsxRuntime automatic */ -/** @jsxImportSource @fluentui/react-jsx-runtime */ - -import { assertSlots } from '@fluentui/react-utilities'; -import type { ListItemButtonState, ListItemButtonSlots } from './ListItemButton.types'; - -/** - * Render the final JSX of ListItemButton - */ -export const renderListItemButton_unstable = (state: ListItemButtonState) => { - assertSlots(state); - - // TODO Add additional slots in the appropriate place - return ; -}; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButton.ts b/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButton.ts deleted file mode 100644 index 859a19a3abbde..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButton.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ListItemButtonProps, ListItemButtonState } from './ListItemButton.types'; - -/** - * Create the state required to render ListItemButton. - * - * The returned state can be modified with hooks such as useListItemButtonStyles_unstable, - * before being passed to renderListItemButton_unstable. - * - * @param props - props from this instance of ListItemButton - * @param ref - reference to root HTMLDivElement of ListItemButton - */ -export const useListItemButton_unstable = ( - props: ListItemButtonProps, - ref: React.Ref, -): ListItemButtonState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add each slot's element type or component - root: 'div', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: slot.always( - getIntrinsicElementProps('div', { - ref, - ...props, - }), - { elementType: 'div' }, - ), - }; -}; diff --git a/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButtonStyles.styles.ts b/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButtonStyles.styles.ts deleted file mode 100644 index 3d02534c60488..0000000000000 --- a/packages/react-components/react-list-preview/src/components/ListItemButton/useListItemButtonStyles.styles.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; -import type { SlotClassNames } from '@fluentui/react-utilities'; -import type { ListItemButtonSlots, ListItemButtonState } from './ListItemButton.types'; - -export const listItemButtonClassNames: SlotClassNames = { - root: 'fui-ListItemButton', - // TODO: add class names for all slots on ListItemButtonSlots. - // Should be of the form `: 'fui-ListItemButton__` -}; - -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element - }, - - // TODO add additional classes for different states and/or slots -}); - -/** - * Apply styling to the ListItemButton slots based on the state - */ -export const useListItemButtonStyles_unstable = (state: ListItemButtonState): ListItemButtonState => { - const styles = useStyles(); - state.root.className = mergeClasses(listItemButtonClassNames.root, styles.root, state.root.className); - - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); - - return state; -}; diff --git a/packages/react-components/react-list-preview/src/events/ListItemActionEvent.ts b/packages/react-components/react-list-preview/src/events/ListItemActionEvent.ts new file mode 100644 index 0000000000000..d588d5d09d18e --- /dev/null +++ b/packages/react-components/react-list-preview/src/events/ListItemActionEvent.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; + +export const ListItemActionEventName = 'ListItemAction'; + +export interface ListItemActionEventDetail { + originalEvent: React.MouseEvent | React.KeyboardEvent; +} + +export type ListItemActionEvent = CustomEvent; + +export const createListItemActionEvent = ( + originalEvent: React.MouseEvent | React.KeyboardEvent, +): CustomEvent => + new CustomEvent(ListItemActionEventName, { + cancelable: true, + bubbles: true, + detail: { originalEvent }, + }); diff --git a/packages/react-components/react-list-preview/src/hooks/index.ts b/packages/react-components/react-list-preview/src/hooks/index.ts new file mode 100644 index 0000000000000..01a6959c7c21f --- /dev/null +++ b/packages/react-components/react-list-preview/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useListSelection'; diff --git a/packages/react-components/react-list-preview/src/hooks/types.ts b/packages/react-components/react-list-preview/src/hooks/types.ts new file mode 100644 index 0000000000000..da45dd1723fc7 --- /dev/null +++ b/packages/react-components/react-list-preview/src/hooks/types.ts @@ -0,0 +1,13 @@ +import { SelectionItemId } from '@fluentui/react-utilities'; +import * as React from 'react'; + +export type ListSelectionState = { + isSelected: (item: string | number) => boolean; + toggleItem: (e: React.SyntheticEvent, id: string | number) => void; + deselectItem: (e: React.SyntheticEvent, id: string | number) => void; + selectItem: (e: React.SyntheticEvent, id: string | number) => void; + clearSelection: (e: React.SyntheticEvent) => void; + toggleAllItems: (e: React.SyntheticEvent, itemIds: string[] | number[]) => void; + setSelectedItems: React.Dispatch>>; + selectedItems: SelectionItemId[]; +}; diff --git a/packages/react-components/react-list-preview/src/hooks/useListSelection.tsx b/packages/react-components/react-list-preview/src/hooks/useListSelection.tsx new file mode 100644 index 0000000000000..776b485c0be4c --- /dev/null +++ b/packages/react-components/react-list-preview/src/hooks/useListSelection.tsx @@ -0,0 +1,54 @@ +import { SelectionHookParams, useControllableState, useEventCallback, useSelection } from '@fluentui/react-utilities'; +import * as React from 'react'; +import type { ListSelectionState } from './types'; + +export function useListSelection(options: SelectionHookParams = { selectionMode: 'multiselect' }): ListSelectionState { + const { selectionMode, defaultSelectedItems, onSelectionChange } = options; + + const [selectedItems, setSelectedItems] = useControllableState({ + state: options.selectedItems, + defaultState: defaultSelectedItems, + initialState: [], + }); + + const [selected, selectionMethods] = useSelection({ + selectionMode, + defaultSelectedItems, + selectedItems, + onSelectionChange: (e, data) => { + setSelectedItems(data.selectedItems); + onSelectionChange?.(e, data); + }, + }); + + const toggleItem: ListSelectionState['toggleItem'] = useEventCallback((e, itemId) => + selectionMethods.toggleItem(e, itemId), + ); + + const toggleAllItems: ListSelectionState['toggleAllItems'] = useEventCallback((e, itemIds) => { + selectionMethods.toggleAllItems(e, itemIds); + }); + + const deselectItem: ListSelectionState['deselectItem'] = useEventCallback((e, itemId: string | number) => + selectionMethods.deselectItem(e, itemId), + ); + + const selectItem: ListSelectionState['selectItem'] = useEventCallback((e, itemId: string | number) => + selectionMethods.selectItem(e, itemId), + ); + + const clearSelection: ListSelectionState['clearSelection'] = useEventCallback(e => selectionMethods.clearItems(e)); + + const selectedArray = React.useMemo(() => Array.from(selected), [selected]); + + return { + selectedItems: selectedArray, + toggleItem, + toggleAllItems, + deselectItem, + selectItem, + setSelectedItems, + isSelected: (id: string | number) => selectionMethods.isSelected(id), + clearSelection, + }; +} diff --git a/packages/react-components/react-list-preview/src/index.ts b/packages/react-components/react-list-preview/src/index.ts index a7c3bc4e86c03..fc8c6238bad6a 100644 --- a/packages/react-components/react-list-preview/src/index.ts +++ b/packages/react-components/react-list-preview/src/index.ts @@ -1,4 +1,5 @@ export { List, listClassNames, renderList_unstable, useListStyles_unstable, useList_unstable } from './List'; + export type { ListProps, ListSlots, ListState } from './List'; export { ListItem, @@ -8,11 +9,3 @@ export { useListItem_unstable, } from './ListItem'; export type { ListItemProps, ListItemSlots, ListItemState } from './ListItem'; -export { - ListItemButton, - listItemButtonClassNames, - renderListItemButton_unstable, - useListItemButtonStyles_unstable, - useListItemButton_unstable, -} from './ListItemButton'; -export type { ListItemButtonProps, ListItemButtonSlots, ListItemButtonState } from './ListItemButton'; diff --git a/packages/react-components/react-list-preview/src/utils/calculateListItemRoleForListRole.ts b/packages/react-components/react-list-preview/src/utils/calculateListItemRoleForListRole.ts new file mode 100644 index 0000000000000..de4bb6055eb9b --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/calculateListItemRoleForListRole.ts @@ -0,0 +1,17 @@ +/** + * Calculate the role for the list item based on the list role. + * @param listRole - the role of the list + * @returns proper role for the list item + */ +export const calculateListItemRoleForListRole = (listRole: string): string => { + switch (listRole) { + case 'list': + return 'listitem'; + case 'listbox': + return 'option'; + case 'grid': + return 'row'; + default: + return 'listitem'; + } +}; diff --git a/packages/react-components/react-list-preview/src/utils/calculateListRole.ts b/packages/react-components/react-list-preview/src/utils/calculateListRole.ts new file mode 100644 index 0000000000000..aac2247a1fea4 --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/calculateListRole.ts @@ -0,0 +1,18 @@ +import { ListNavigationMode } from '../List'; + +/** + * Calculate the role for the list based on the navigation mode and selectable state + * @param navigationMode - the navigation mode of the list + * @param selectable - whether the list is selectable + * @returns 'grid' if navigationMode is 'composite', otherwise 'listbox' if selectable or 'list' if not + */ + +export const calculateListRole = (navigationMode: ListNavigationMode | undefined, selectable: boolean) => { + if (navigationMode === 'composite') { + return 'grid'; + } else if (selectable) { + return 'listbox'; + } else { + return 'list'; + } +}; diff --git a/packages/react-components/react-list-preview/src/utils/index.ts b/packages/react-components/react-list-preview/src/utils/index.ts new file mode 100644 index 0000000000000..059f9cde29e37 --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/index.ts @@ -0,0 +1,5 @@ +export { calculateListRole } from './calculateListRole'; +export { validateProperElementTypes } from './validateProperElementTypes'; +export { validateProperRolesAreUsed } from './validateProperRolesAreUsed'; +export { calculateListItemRoleForListRole } from './calculateListItemRoleForListRole'; +export { validateGridCellsArePresent } from './validateGridCellsArePresent'; diff --git a/packages/react-components/react-list-preview/src/utils/validateGridCellsArePresent.ts b/packages/react-components/react-list-preview/src/utils/validateGridCellsArePresent.ts new file mode 100644 index 0000000000000..5ca4178c27af4 --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/validateGridCellsArePresent.ts @@ -0,0 +1,21 @@ +/** + * Validates that grid cells are present in a grid list item. This is necessary for proper screen reader support. + * If grid cells are not present and we're not running in production mode, a warning will be logged to the console. + * @param listRole - The role of the list + * @param listItemEl - The list item element + * @returns + */ +export const validateGridCellsArePresent = (listRole: string, listItemEl: HTMLElement) => { + if (listRole !== 'grid') { + return; + } + + const gridCells = listItemEl.querySelectorAll(':scope > [role="gridcell"]'); + if (gridCells.length === 0) { + //eslint-disable-next-line no-console + console.warn( + `@fluentui/react-list-preview [useList]:\nList items in List with "grid" role (which is automatically assigned when navigationMode is set to "composite") must contain at least one "gridcell" as direct child of for proper screen reader support.`, + `Ideally, each focus target should be in it's own "gridcell", which is a direct child of .\n`, + ); + } +}; diff --git a/packages/react-components/react-list-preview/src/utils/validateProperElementTypes.ts b/packages/react-components/react-list-preview/src/utils/validateProperElementTypes.ts new file mode 100644 index 0000000000000..b5b41b8bd128f --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/validateProperElementTypes.ts @@ -0,0 +1,13 @@ +/** + * Validates that the List and ListItem elements are compatible + * @param listRenderedAs - the type of the parent element + * @param listItemRenderedAs - the type of the child element + */ +export function validateProperElementTypes(listRenderedAs?: string, listItemRenderedAs?: string) { + if (listItemRenderedAs === 'div' && listRenderedAs !== 'div') { + throw new Error('ListItem cannot be rendered as a div when its parent is not a div.'); + } + if (listItemRenderedAs === 'li' && listRenderedAs === 'div') { + throw new Error('ListItem cannot be rendered as a li when its parent is a div.'); + } +} diff --git a/packages/react-components/react-list-preview/src/utils/validateProperRolesAreUsed.ts b/packages/react-components/react-list-preview/src/utils/validateProperRolesAreUsed.ts new file mode 100644 index 0000000000000..4e8c8cbfae0c3 --- /dev/null +++ b/packages/react-components/react-list-preview/src/utils/validateProperRolesAreUsed.ts @@ -0,0 +1,45 @@ +/** + * Validate that the proper roles are used for the given combination of roles and states. + * If the roles are invalid and we're not running in production mode, an warning will be logged to the console. + * + * @param role - the role of the list + * @param listItemRole - the role of the list item + * @param hasSelection - whether the list has selection enabled + * @param hasFocusableChildren - whether the list has focusable children + * @returns + */ +export const validateProperRolesAreUsed = ( + role: string, + listItemRole: string, + hasSelection: boolean, + hasFocusableChildren: boolean, +) => { + // Explode when the pair of roles is invalid + if (role === 'list' && listItemRole !== 'listitem') { + throw new Error('When the List role is "list", ListItem role must be "listitem".'); + } + if (role === 'listbox' && listItemRole !== 'option') { + throw new Error('When the List role is "listbox", ListItem role must be "option".'); + } + if (role === 'grid' && listItemRole !== 'row') { + throw new Error('When the List role is "grid", ListItem role must be "row".'); + } + + const expectedRole = (() => { + if (hasFocusableChildren) { + return 'grid'; + } else { + if (hasSelection) { + return 'listbox'; + } else { + return 'list'; + } + } + })(); + + if (role !== expectedRole) { + /* eslint-disable-next-line no-console */ + console.warn(`@fluentui/react-list-preview [useList]:\nThe role "${role}" does not match the expected role "${expectedRole}".\nPlease use the "navigationMode" property for automatic role assignment and keyboard navigation.\nIf you are using this role intentionally, make sure to verify screen reader support. + `); + } +}; diff --git a/packages/react-components/react-list-preview/stories/List/ListActiveElement.stories.tsx b/packages/react-components/react-list-preview/stories/List/ListActiveElement.stories.tsx new file mode 100644 index 0000000000000..1fb360ab26fb1 --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/ListActiveElement.stories.tsx @@ -0,0 +1,134 @@ +import { + Button, + makeStyles, + Persona, + shorthands, + mergeClasses, + Text, + tokens, + SelectionItemId, +} from '@fluentui/react-components'; +import { Mic16Regular } from '@fluentui/react-icons'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +type Item = { + name: string; + id: string; + avatar: string; +}; + +const items: Item[] = [ + 'Melda Bevel', + 'Demetra Manwaring', + 'Eusebia Stufflebeam', + 'Israel Rabin', + 'Bart Merrill', + 'Sonya Farner', + 'Kristan Cable', +].map(name => ({ + name, + id: name, + avatar: + 'https://res-1.cdn.office.net/files/fabric-cdn-prod_20230815.002/office-ui-fabric-react-assets/persona-male.png', +})); + +const useStyles = makeStyles({ + selectedInfo: { + marginTop: '16px', + }, + buttonWrapper: { + alignSelf: 'center', + }, + item: { + cursor: 'pointer', + ...shorthands.padding('2px', '6px'), + justifyContent: 'space-between', + }, + itemSelected: { + backgroundColor: tokens.colorSubtleBackgroundSelected, + }, +}); + +export const ListActiveElement = () => { + const classes = useStyles(); + + const [selectedItems, setSelectedItems] = React.useState(['Melda Bevel']); + + const onSelectionChange = React.useCallback((_, data) => { + setSelectedItems(data.selectedItems); + }, []); + + const onFocus = React.useCallback(event => { + // Ignore bubbled up events from the children + if (event.target !== event.currentTarget) { + return; + } + setSelectedItems([event.target.dataset.value]); + }, []); + + return ( +
+ + {items.map(({ name, avatar }) => ( + + +
+
+
+ ))} +
+
+ Currently selected:{' '} + + {selectedItems[0]} + +
+
+ ); +}; + +ListActiveElement.parameters = { + docs: { + description: { + story: [ + 'You can use selection and custom styles to show the active element in a different way. This is useful for scenarios where you want to show the details of the selected item, for example.', + '', + 'In this example, we are also demonstrating how the `onFocus` prop can be utilized to change the selected item immediately upon receiving focus. This allows us to show the details of the selected item in the right panel as user navigates through the list with the keyboard.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/ListBestPractices.md b/packages/react-components/react-list-preview/stories/List/ListBestPractices.md index 08ff8ddeeb5f8..f798a97961968 100644 --- a/packages/react-components/react-list-preview/stories/List/ListBestPractices.md +++ b/packages/react-components/react-list-preview/stories/List/ListBestPractices.md @@ -2,4 +2,18 @@ ### Do +- Use `tabIndex={0}` property on the `List` where the items have no actionable elements inside. +- Use `navigationMode="items"` property on the `ListItem` when the list items should be focusable. +- Use `navigationMode="composite"` property on the `ListItem` when the list items should be and there are other focusable elements inside of them. +- use `onAction` callback to register primary action (click or `Enter` key) +- Use `aria-label` property on the `ListItem` for custom screen reader label. +- Rely on default accessibility roles, which are switched based on the `navigationMode` prop. +- When `navigationMode` is `composite`, wrap each interactive item in `ListItem` in its own element with `role="gridcell"`. + ### Don't + +- Don't use `tabIndex` on the `ListItem`, use `navigationMode` to get proper accessibility and keyboard navigation. +- Don't use `navigationMode` on the `ListItem` if the list items have zero actionable elements, use `tabIndex={0}` on the `List` instead. + This way the list itself is focusable and users can scroll by using up and down arrows after focusing it. +- Don't use `onClick` for the list action, use `onAction` instead which adds automatic support for `Enter` and `Spacebar` keys + and works well with selection (`Enter`/`click` triggers the `onAction` callback, `Space` or checkbox click trigger selection) diff --git a/packages/react-components/react-list-preview/stories/List/ListDefault.stories.tsx b/packages/react-components/react-list-preview/stories/List/ListDefault.stories.tsx index 0169f28c0a380..8cc470b3449c2 100644 --- a/packages/react-components/react-list-preview/stories/List/ListDefault.stories.tsx +++ b/packages/react-components/react-list-preview/stories/List/ListDefault.stories.tsx @@ -1,4 +1,14 @@ import * as React from 'react'; -import { List, ListProps } from '@fluentui/react-list-preview'; +import { List, ListItem } from '@fluentui/react-list-preview'; -export const Default = (props: Partial) => ; +export const Default = () => ( + + Asia + Africa + Europe + North America + South America + Australia/Oceania + Antarctica + +); diff --git a/packages/react-components/react-list-preview/stories/List/ListDescription.md b/packages/react-components/react-list-preview/stories/List/ListDescription.md index e69de29bb2d1d..cabdb721fbf2e 100644 --- a/packages/react-components/react-list-preview/stories/List/ListDescription.md +++ b/packages/react-components/react-list-preview/stories/List/ListDescription.md @@ -0,0 +1,89 @@ +The List is a component for rendering set of vertically stacked items (other layouts are being discussed). These items can be focusable, selectable, have one primary action and one or more secondary actions. + +There are 2 basic use cases for List, based on the elements it contains: + +(TL;DR at the end) + +## Non-interactive list + +A simple list with non-interactive elements inside of it. Imagine a list of ingredients for a dish or a list of requirements for a project. + +Generally these items would not be focusable, since they don't provide any actions. + +## Interactive lists + +An interactive list is a List where each of its items has at least one action attached to it. Imagine a list of emails (clicking will open in), a list of contacts (clicking will open a conversation) or a list of installed apps (clicking will open it's details.) + +To make the list interactive and navigable, the `navigationMode` should can be passed. Proper accessibility roles and keyboard navigation is used based on the navigation mode `items` or `composite`. More on this later. + +### Adding an action + +To add an action on the List Item, use `onAction` callback, which will be called when user clicks the list item, +presses `Enter` or `Space` (when selection is not enabled). + +Using the `onAction` callback instead of `onClick` has multiple advantages, namely: + +- you get the support of `Enter` and `Space` key for free +- when selection is enabled, only `Enter` triggers the action, and `Space` toggles selection + +### Selection + +Selection is a common feature for single and multi action list items. It's behavior is consistent across both use scenarios. Selection can be enabled by passing `selectionMode` property to the `List`. + +**Selection can be toggled by clicking the checkbox or pressing `Space` on selected list item.** + +When selection is enabled, the **selection is also the primary action** of the list item, which can be **triggered by mouse click or Enter**. + +This behavior for Enter and click can be overriden by passing a custom `onAction` where `event.preventDefault()` is called and custom primary action can be triggered. +In this case, `Space` will still be used to toggle selection, but `Enter` and `click` will trigger the custom action. + +**The `navigationMode` in case the selection is enabled defaults to `items`. If there are focusable elements inside each list item, make sure to change this to `composite` to get proper accessibility and keyboard navigation.** + +The interactive lists can then be further divided into 2 different categories. The selection behavior is common for both of these. + +### List items with a single action + +A list item with single action is a list item which doesn't contain any focusable child elements. It can be selectable. + +To ensure proper keyboard navigation and accessibility roles, pass the `navigationMode="items"` prop to the `List` This way the items will be made focusable and user will be able to navigate with up and down arrows. + +### List items with multiple actions + +If the list needs to support more than single click action, you can render additional focusable elements inside of the List Item. + +To tell the List component that it should enable navigation inside of the items, pass the `navigationMode="composite"` property to it. + +This makes sure the list is navigable with up and down arrows and user can enter the group (`ListItem`) to select the action they want to take with the list. It also switches the default roles to `grid` and `row`. + +**When multiple actions are present on the list item, the list item roles should be `grid`, and `row` (this is automatic when `navigationMode="composite"` is passed). You also need to make sure each direct child of the `ListItem` component has a role `gridcell` to adhere to the a11y roles used.** You will get a warning in the console during development if this requirement is not fulfilled. + +When List has multiple actions inside of the list item, the user can press **left and right arrow keys** to navigate inside of the list item. Pressing **up and down** arrow keys will select the previous/next list item immediately. + +**To summarize the navigation patterns:** + +In the most complex scenario, user will be navigating a **selectable** list with a **custom primary action** and multiple **secondary actions**. + +- When a list item is focused: + - `Space` toggles the selection + - `Enter` triggers the primary action + - `Up/Down arrows` arrows move to previous/next list item + - `Right arrow` enters the first focusable element **inside** the current list item + - `Tab` goes to the next focusable item after the List +- When one of the element inside of the list item is focused: + - `Up/Down arrows` select the previous/next list item + - `Left/Rigth arrows` navigate among the secondary options in the list item. If the leftmost item is focused already and left arrow key is pressed, the parent list item is focused. + - `Esc` focuses the parent list item + +## TL;DR + +- Use `navigationMode` prop to enable focusability of items and keyboard navigation +- Keyboard navigation and proper accessibility roles are inferred from the `navigationMode` prop: + - `undefined` - no focusable items, no keyboard navigation, roles are `listitem` and `list`. + - `items` - items are focusable, up and down arrow keys navigate between them. Default role is `list` and `listitem`, when selection is enabled, it is `listbox` and `option`. + - `composite` - use when there are other focusable elements inside of the list items. This enables up/down arrow keys to move between items and right/left arrow keys to focus on the items inside. + - composite navigation switches to role `grid` and `row`. **It is important for each direct child of `ListItem` in this case to have `gridcell` role, otherwise the screen readers get confused.** +- use `onAction` instead of `onClick` callback to register `click` mouse event and `Enter` / `Space` keyboard events +- Selection can be enabled with `selectionMode` property. When selectionMode is enabled, the List automatically behaves as if it was passed `navigationMode="single` and this doesn't have to be passed. Do not forget to set this to `composite`, if there are focusable elements inside. +- When Selection is enabled: + - `Spacebar` and checkbox `click` always toggle selection + - `Enter` and list item `click` toggle selection unless this behavior has been prevented in the `onAction` callback diff --git a/packages/react-components/react-list-preview/stories/List/MultipleActionsDifferentPrimary.stories.tsx b/packages/react-components/react-list-preview/stories/List/MultipleActionsDifferentPrimary.stories.tsx new file mode 100644 index 0000000000000..3309feb7f953f --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/MultipleActionsDifferentPrimary.stories.tsx @@ -0,0 +1,205 @@ +import { + Button, + Caption1, + Image, + makeResetStyles, + makeStyles, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + mergeClasses, + shorthands, + Text, + tokens, +} from '@fluentui/react-components'; +import { MoreHorizontal20Regular } from '@fluentui/react-icons'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +const useListItemRootStyles = makeResetStyles({ + position: 'relative', + flexGrow: '1', + gap: '8px', + border: '1px solid grey', + alignItems: 'center', + borderRadius: '8px', + gridTemplate: `"preview preview preview" auto + "header action secondary_action" auto / 1fr auto auto + `, +}); + +const useStyles = makeStyles({ + list: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap('16px'), + maxWidth: '300px', + }, + listItem: { + display: 'grid', + ...shorthands.padding('8px'), + }, + caption: { + color: tokens.colorNeutralForeground3, + }, + image: { + height: '160px', + maxWidth: '100%', + ...shorthands.borderRadius('5px'), + }, + title: { + fontWeight: 600, + display: 'block', + }, + checkmark: { + position: 'absolute', + left: '10px', + top: '10px', + zIndex: 1, + }, + preview: { + ...shorthands.gridArea('preview'), + ...shorthands.overflow('hidden'), + }, + header: { ...shorthands.gridArea('header') }, + action: { ...shorthands.gridArea('action') }, + secondaryAction: { ...shorthands.gridArea('secondary_action') }, +}); + +const CustomListItem = (props: { title: string; value: string }) => { + const listItemStyles = useListItemRootStyles(); + const styles = useStyles(); + const { value } = props; + + // This will be triggered by user pressing Enter or clicking on the list item + const onAction = React.useCallback(event => { + // This prevents the change in selection on click/Enter + event.preventDefault(); + alert(`Triggered custom action!`); + }, []); + + return ( + +
+ Presentation Preview +
+
+ {props.title} + You created 53m ago +
+
+ +
+
+ + + +
+
+ ); +}; + +export const MultipleActionsDifferentPrimary = () => { + const classes = useStyles(); + + const [selectedItems, setSelectedItems] = React.useState>([]); + + return ( + setSelectedItems(data.selectedItems)} + > + + + + + + + + + + + ); +}; + +MultipleActionsDifferentPrimary.parameters = { + docs: { + description: { + story: [ + 'Similar to previous example, but this one implements a custom `onAction` prop on the `ListItem`, ', + 'allowing us to trigger a different action than the selection when the user clicks ', + 'on the list item or presses Enter.', + '', + 'The primary action can be triggered by clicking on the list item or pressing `Enter`.', + '', + 'The selection can be toggled by clicking on the checkbox or pressing `Space` when the item is focused.', + '', + 'To focus on the secondary actions, you can navigate between them by using left and right arrows.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/MultipleActionsSelection.stories.tsx b/packages/react-components/react-list-preview/stories/List/MultipleActionsSelection.stories.tsx new file mode 100644 index 0000000000000..c6c16c615b3eb --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/MultipleActionsSelection.stories.tsx @@ -0,0 +1,196 @@ +import { + Button, + Caption1, + Image, + makeResetStyles, + makeStyles, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + mergeClasses, + shorthands, + Text, + tokens, +} from '@fluentui/react-components'; +import { MoreHorizontal20Regular } from '@fluentui/react-icons'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +const useListItemRootStyles = makeResetStyles({ + position: 'relative', + flexGrow: '1', + gap: '8px', + border: '1px solid grey', + alignItems: 'center', + borderRadius: '8px', + gridTemplate: `"preview preview preview" auto + "header action secondary_action" auto / 1fr auto auto + `, +}); + +const useStyles = makeStyles({ + list: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap('16px'), + maxWidth: '300px', + }, + listItem: { + display: 'grid', + ...shorthands.padding('8px'), + }, + caption: { + color: tokens.colorNeutralForeground3, + }, + image: { + height: '160px', + maxWidth: '100%', + ...shorthands.borderRadius('5px'), + }, + title: { + fontWeight: 600, + display: 'block', + }, + checkmark: { + position: 'absolute', + left: '10px', + top: '10px', + zIndex: 1, + }, + preview: { + ...shorthands.gridArea('preview'), + ...shorthands.overflow('hidden'), + }, + header: { ...shorthands.gridArea('header') }, + action: { ...shorthands.gridArea('action') }, + secondaryAction: { ...shorthands.gridArea('secondary_action') }, +}); + +const CustomListItem = (props: { title: string; value: string }) => { + const listItemStyles = useListItemRootStyles(); + const styles = useStyles(); + const { value } = props; + + return ( + +
+ Presentation Preview +
+
+ {props.title} + You created 53m ago +
+
+ +
+
+ + + +
+
+ ); +}; + +export const MultipleActionsSelection = () => { + const classes = useStyles(); + + const [selectedItems, setSelectedItems] = React.useState>([]); + + return ( + setSelectedItems(data.selectedItems)} + > + + + + + + + + + + + ); +}; +MultipleActionsSelection.parameters = { + docs: { + description: { + story: [ + "Item with multiple actions. It has selection enabled, which is also it's primary action.", + 'The selection can be toggled by clicking on the item or pressing the `Space` key.', + '', + 'Because the selection is the action on the item, to properly narrate the state of selection', + 'we are using the role grid / row / gridcell here to properly announce when the selection on the', + 'item is toggled.', + '', + 'To enable the user to navigate inside of the list items by pressing the `RightArrow` key,', + 'the `navigationMode` prop should be set to `composite`.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/MultipleActionsWithPrimary.stories.tsx b/packages/react-components/react-list-preview/stories/List/MultipleActionsWithPrimary.stories.tsx new file mode 100644 index 0000000000000..dfeeb1d40d69d --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/MultipleActionsWithPrimary.stories.tsx @@ -0,0 +1,189 @@ +import { + Button, + Caption1, + Image, + makeResetStyles, + makeStyles, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + mergeClasses, + shorthands, + Text, + tokens, +} from '@fluentui/react-components'; +import { MoreHorizontal20Regular } from '@fluentui/react-icons'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +const useListItemRootStyles = makeResetStyles({ + position: 'relative', + flexGrow: '1', + gap: '8px', + border: '1px solid grey', + alignItems: 'center', + borderRadius: '8px', + gridTemplate: `"preview preview preview" auto + "header action secondary_action" auto / 1fr auto auto + `, +}); + +const useStyles = makeStyles({ + list: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap('16px'), + maxWidth: '300px', + }, + listItem: { + display: 'grid', + ...shorthands.padding('8px'), + }, + caption: { + color: tokens.colorNeutralForeground3, + }, + image: { + height: '160px', + maxWidth: '100%', + ...shorthands.borderRadius('5px'), + }, + title: { + fontWeight: 600, + display: 'block', + }, + preview: { + ...shorthands.gridArea('preview'), + ...shorthands.overflow('hidden'), + }, + header: { ...shorthands.gridArea('header') }, + action: { ...shorthands.gridArea('action') }, + secondaryAction: { ...shorthands.gridArea('secondary_action') }, +}); + +const CustomListItem = (props: { title: string; value: string }) => { + const listItemStyles = useListItemRootStyles(); + const styles = useStyles(); + const { value } = props; + + // This will be triggered by user pressing Enter or clicking on the list item + const onAction = React.useCallback(event => { + // This prevents the change in selection on click/Enter + event.preventDefault(); + alert(`Triggered custom action!`); + }, []); + + return ( + +
+ Presentation Preview +
+
+ {props.title} + You created 53m ago +
+
+ +
+
+ + + +
+
+ ); +}; + +export const MultipleActionsWithPrimary = () => { + const classes = useStyles(); + + return ( + + + + + + + + + + + + ); +}; + +MultipleActionsWithPrimary.parameters = { + docs: { + description: { + story: [ + "Base item with multiple actions. Doesn't support selection, but the list items have a primary action ", + 'that can be triggered by clicking on the item or pressing Enter.', + '', + '__To make the navigation work properly, the `navigationMode` prop should be set to `composite`.__', + 'This will allow the user to navigate inside of the list items by pressing the `Right Arrow` key.', + 'It also sets the `grid` role automatically to the list.', + '', + '> ⚠️ _In cases where `grid` role is used, it is important that every direct children of `ListItem`_', + '_has role `gridcell`. Also each focusable item should be in its own "gridcell". This makes sure the _', + '_screen readers work properly._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/SingleAction.stories.tsx b/packages/react-components/react-list-preview/stories/List/SingleAction.stories.tsx new file mode 100644 index 0000000000000..ca9ceb5993c53 --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/SingleAction.stories.tsx @@ -0,0 +1,49 @@ +import { Persona } from '@fluentui/react-components'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +const names = [ + 'Melda Bevel', + 'Demetra Manwaring', + 'Eusebia Stufflebeam', + 'Israel Rabin', + 'Bart Merrill', + 'Sonya Farner', +]; + +export const SingleAction = () => { + return ( + + {names.map(name => ( + alert(`Triggered custom action!`)}> + + + ))} + + ); +}; + +SingleAction.parameters = { + docs: { + description: { + story: [ + 'When the list item should have a custom primary action on it, you can pass the `onAction` prop to the `ListItem` component.', + 'This callback will also be automatically called when the user presses the Enter or Space key on the list item.', + '', + 'To learn more about what event triggered the action, you can check the `event.details.originalEvent`.', + '', + 'To enable keyboard navigation between the list items, the `navigationMode` prop should be set to `items`.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/SingleActionSelection.stories.tsx b/packages/react-components/react-list-preview/stories/List/SingleActionSelection.stories.tsx new file mode 100644 index 0000000000000..cefa248fccade --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/SingleActionSelection.stories.tsx @@ -0,0 +1,62 @@ +import { Persona } from '@fluentui/react-components'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +type Item = { + name: string; + id: string; + avatar: string; +}; + +const items: Item[] = [ + 'Melda Bevel', + 'Demetra Manwaring', + 'Eusebia Stufflebeam', + 'Israel Rabin', + 'Bart Merrill', + 'Sonya Farner', +].map(name => ({ + name, + id: name, + avatar: + 'https://res-1.cdn.office.net/files/fabric-cdn-prod_20230815.002/office-ui-fabric-react-assets/persona-male.png', +})); + +export const SingleActionSelection = () => { + const defaultSelectedItems = ['Demetra Manwaring', 'Bart Merrill']; + + return ( + + {items.map(({ name, avatar }) => ( + + + + ))} + + ); +}; + +SingleActionSelection.parameters = { + docs: { + description: { + story: [ + 'Any List can be selectable. You have an option to control the selection state yourself or let the List manage it for you.', + '', + 'You can pass `selectionMode` prop with value "single" or "multiselect" to the List component to get support for selection.', + 'The items can be toggled by clicking on the list item, or pressing `Spacebar` or `Enter` when the item is focused. Keyboard navigation is automatically enabled and `navigationMode` is set to `items`.', + '', + "Also this example only has one action in the list item, and it's for toggling the selection. The roles for this one are listbox and option.", + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/SingleActionSelectionControlled.stories.tsx b/packages/react-components/react-list-preview/stories/List/SingleActionSelectionControlled.stories.tsx new file mode 100644 index 0000000000000..ce613707178b3 --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/SingleActionSelectionControlled.stories.tsx @@ -0,0 +1,80 @@ +import { Button, makeStyles, Persona, SelectionItemId } from '@fluentui/react-components'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +type Item = { + name: string; + id: string; + avatar: string; +}; + +const items: Item[] = [ + 'Melda Bevel', + 'Demetra Manwaring', + 'Eusebia Stufflebeam', + 'Israel Rabin', + 'Bart Merrill', + 'Sonya Farner', +].map(name => ({ + name, + id: name, + avatar: + 'https://res-1.cdn.office.net/files/fabric-cdn-prod_20230815.002/office-ui-fabric-react-assets/persona-male.png', +})); + +const useStyles = makeStyles({ + buttonControls: { + display: 'flex', + columnGap: '8px', + marginBottom: '16px', + }, +}); + +export const SingleActionSelectionControlled = () => { + const classes = useStyles(); + + const [selectedItems, setSelectedItems] = React.useState(['Demetra Manwaring', 'Bart Merrill']); + + return ( +
+
+ +
+ + setSelectedItems(data.selectedItems)} + > + {items.map(({ name, avatar }) => ( + + + + ))} + +
+ ); +}; + +SingleActionSelectionControlled.parameters = { + docs: { + description: { + story: [ + 'This example shows how to use the `selectedItems` and `onSelectionChange`', + 'props to control the selection state of the List and keep track of it in the parent component.', + '', + 'This is more in line with how we expect the selection to be used in production environment.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/SingleActionSelectionDifferentPrimary.stories.tsx b/packages/react-components/react-list-preview/stories/List/SingleActionSelectionDifferentPrimary.stories.tsx new file mode 100644 index 0000000000000..bbce803e41e5a --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/SingleActionSelectionDifferentPrimary.stories.tsx @@ -0,0 +1,80 @@ +import { Persona, SelectionItemId } from '@fluentui/react-components'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +import * as React from 'react'; + +type Item = { + name: string; + id: string; + avatar: string; +}; + +const items: Item[] = [ + 'Melda Bevel', + 'Demetra Manwaring', + 'Eusebia Stufflebeam', + 'Israel Rabin', + 'Bart Merrill', + 'Sonya Farner', +].map(name => ({ + name, + id: name, + avatar: + 'https://res-1.cdn.office.net/files/fabric-cdn-prod_20230815.002/office-ui-fabric-react-assets/persona-male.png', +})); + +export const SingleActionSelectionDifferentPrimary = () => { + const [selectedItems, setSelectedItems] = React.useState(['Demetra Manwaring', 'Bart Merrill']); + + // This will be triggered by user pressing Enter or clicking on the list item + const onAction = React.useCallback(event => { + // This prevents the change in selection on click/Enter + event.preventDefault(); + alert(`Triggered custom action!`); + }, []); + + return ( + setSelectedItems(data.selectedItems)} + > + {items.map(({ name, avatar }) => ( + + + + ))} + + ); +}; + +SingleActionSelectionDifferentPrimary.parameters = { + docs: { + description: { + story: [ + 'This example is similar to the previous one, but it implements a custom primary action on `ListItem`,', + 'allowing us to trigger a', + '__different action than the selection when the user clicks on the list item or presses Enter__', + '. This is useful when you want to have a primary action on the list item, but still want ', + 'to allow the user to select it.', + '', + 'To change the default action on the `ListItem` (when user clicks on it or presses Enter), you can use the', + '`onAction` prop. By calling `event.preventDefault()` in the `onAction` callback, you can prevent the default', + 'action (toggling the selection) from happening. This way, you can perform a completely custom action.', + 'In this example, the custom action is an alert that triggers when the user', + 'clicks on the list item or presses Enter.', + '', + '__The selection can still be toggled by clicking on the checkbox or pressing `Space` when the item is focused.__', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/VirtualizedList.stories.tsx b/packages/react-components/react-list-preview/stories/List/VirtualizedList.stories.tsx new file mode 100644 index 0000000000000..588f4df9cb4dc --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/VirtualizedList.stories.tsx @@ -0,0 +1,242 @@ +import * as React from 'react'; +import { FixedSizeList } from 'react-window'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +const countries = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Antigua & Deps', + 'Argentina', + 'Armenia', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bhutan', + 'Bolivia', + 'Bosnia Herzegovina', + 'Botswana', + 'Brazil', + 'Brunei', + 'Bulgaria', + 'Burkina', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Canada', + 'Cape Verde', + 'Central African Rep', + 'Chad', + 'Chile', + 'China', + 'Colombia', + 'Comoros', + 'Congo', + 'Congo {Democratic Rep}', + 'Costa Rica', + 'Croatia', + 'Cuba', + 'Cyprus', + 'Czech Republic', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'East Timor', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Eritrea', + 'Estonia', + 'Ethiopia', + 'Fiji', + 'Finland', + 'France', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Greece', + 'Grenada', + 'Guatemala', + 'Guinea', + 'Guinea-Bissau', + 'Guyana', + 'Haiti', + 'Honduras', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran', + 'Iraq', + 'Ireland {Republic}', + 'Israel', + 'Italy', + 'Ivory Coast', + 'Jamaica', + 'Japan', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kiribati', + 'Korea North', + 'Korea South', + 'Kosovo', + 'Kuwait', + 'Kyrgyzstan', + 'Laos', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macedonia', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Marshall Islands', + 'Mauritania', + 'Mauritius', + 'Mexico', + 'Micronesia', + 'Moldova', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Morocco', + 'Mozambique', + 'Myanmar, {Burma}', + 'Namibia', + 'Nauru', + 'Nepal', + 'Netherlands', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Norway', + 'Oman', + 'Pakistan', + 'Palau', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Poland', + 'Portugal', + 'Qatar', + 'Romania', + 'Russian Federation', + 'Rwanda', + 'St Kitts & Nevis', + 'St Lucia', + 'Saint Vincent & the Grenadines', + 'Samoa', + 'San Marino', + 'Sao Tome & Principe', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Slovakia', + 'Slovenia', + 'Solomon Islands', + 'Somalia', + 'South Africa', + 'South Sudan', + 'Spain', + 'Sri Lanka', + 'Sudan', + 'Suriname', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syria', + 'Taiwan', + 'Tajikistan', + 'Tanzania', + 'Thailand', + 'Togo', + 'Tonga', + 'Trinidad & Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Tuvalu', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + 'United States', + 'Uruguay', + 'Uzbekistan', + 'Vanuatu', + 'Vatican City', + 'Venezuela', + 'Vietnam', + 'Yemen', + 'Zambia', + 'Zimbabwe', +]; + +const CountriesList = React.forwardRef((props: React.ComponentProps, ref) => ( + +)); + +export const VirtualizedList = () => { + return ( + + {({ index, style, data }) => ( + + {data[index]} + + )} + + ); +}; + +VirtualizedList.parameters = { + docs: { + description: { + story: [ + 'When creating a list of large size, one way of making sure you are getting the best performance', + 'is to use virtualization. In this example we are leveraging the `react-window` package.', + '', + 'Please note that if the virtualized list contains non-actionable list items, scrolling should be achieved', + 'by using the `tabIndex={0}` property on the List.', + '', + '> ⚠️ _It is important to manually set `aria-setsize` and `aria-posinset` attributes on the list items, since_', + '_the virualization will only render the visible items. Relying on the DOM state for these attributes will not work._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/VirtualizedListWithActionableItems.stories.tsx b/packages/react-components/react-list-preview/stories/List/VirtualizedListWithActionableItems.stories.tsx new file mode 100644 index 0000000000000..2e3011d41cf4a --- /dev/null +++ b/packages/react-components/react-list-preview/stories/List/VirtualizedListWithActionableItems.stories.tsx @@ -0,0 +1,238 @@ +import * as React from 'react'; +import { FixedSizeList } from 'react-window'; +import { List, ListItem } from '@fluentui/react-list-preview'; + +const countries = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Antigua & Deps', + 'Argentina', + 'Armenia', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bhutan', + 'Bolivia', + 'Bosnia Herzegovina', + 'Botswana', + 'Brazil', + 'Brunei', + 'Bulgaria', + 'Burkina', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Canada', + 'Cape Verde', + 'Central African Rep', + 'Chad', + 'Chile', + 'China', + 'Colombia', + 'Comoros', + 'Congo', + 'Congo {Democratic Rep}', + 'Costa Rica', + 'Croatia', + 'Cuba', + 'Cyprus', + 'Czech Republic', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'East Timor', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Eritrea', + 'Estonia', + 'Ethiopia', + 'Fiji', + 'Finland', + 'France', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Greece', + 'Grenada', + 'Guatemala', + 'Guinea', + 'Guinea-Bissau', + 'Guyana', + 'Haiti', + 'Honduras', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran', + 'Iraq', + 'Ireland {Republic}', + 'Israel', + 'Italy', + 'Ivory Coast', + 'Jamaica', + 'Japan', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kiribati', + 'Korea North', + 'Korea South', + 'Kosovo', + 'Kuwait', + 'Kyrgyzstan', + 'Laos', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macedonia', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Marshall Islands', + 'Mauritania', + 'Mauritius', + 'Mexico', + 'Micronesia', + 'Moldova', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Morocco', + 'Mozambique', + 'Myanmar, {Burma}', + 'Namibia', + 'Nauru', + 'Nepal', + 'Netherlands', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Norway', + 'Oman', + 'Pakistan', + 'Palau', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Poland', + 'Portugal', + 'Qatar', + 'Romania', + 'Russian Federation', + 'Rwanda', + 'St Kitts & Nevis', + 'St Lucia', + 'Saint Vincent & the Grenadines', + 'Samoa', + 'San Marino', + 'Sao Tome & Principe', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Slovakia', + 'Slovenia', + 'Solomon Islands', + 'Somalia', + 'South Africa', + 'South Sudan', + 'Spain', + 'Sri Lanka', + 'Sudan', + 'Suriname', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syria', + 'Taiwan', + 'Tajikistan', + 'Tanzania', + 'Thailand', + 'Togo', + 'Tonga', + 'Trinidad & Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Tuvalu', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + 'United States', + 'Uruguay', + 'Uzbekistan', + 'Vanuatu', + 'Vatican City', + 'Venezuela', + 'Vietnam', + 'Yemen', + 'Zambia', + 'Zimbabwe', +]; + +const CountriesList = React.forwardRef((props: React.ComponentProps, ref) => ( + +)); + +export const VirtualizedListWithActionableItems = () => { + return ( + + {({ index, style, data }) => ( + alert(data[index])} + > + {data[index]} + + )} + + ); +}; + +VirtualizedListWithActionableItems.parameters = { + docs: { + description: { + story: ['Virtualized list can also be used with interactive elements.'].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-list-preview/stories/List/index.stories.tsx b/packages/react-components/react-list-preview/stories/List/index.stories.tsx index bda361cec97ed..a90149cbbdc23 100644 --- a/packages/react-components/react-list-preview/stories/List/index.stories.tsx +++ b/packages/react-components/react-list-preview/stories/List/index.stories.tsx @@ -4,6 +4,16 @@ import descriptionMd from './ListDescription.md'; import bestPracticesMd from './ListBestPractices.md'; export { Default } from './ListDefault.stories'; +export { SingleAction } from './SingleAction.stories'; +export { SingleActionSelection } from './SingleActionSelection.stories'; +export { SingleActionSelectionControlled } from './SingleActionSelectionControlled.stories'; +export { SingleActionSelectionDifferentPrimary } from './SingleActionSelectionDifferentPrimary.stories'; +export { MultipleActionsWithPrimary } from './MultipleActionsWithPrimary.stories'; +export { MultipleActionsSelection } from './MultipleActionsSelection.stories'; +export { MultipleActionsDifferentPrimary } from './MultipleActionsDifferentPrimary.stories'; +export { VirtualizedList } from './VirtualizedList.stories'; +export { VirtualizedListWithActionableItems } from './VirtualizedListWithActionableItems.stories'; +export { ListActiveElement } from './ListActiveElement.stories'; export default { title: 'Preview Components/List', diff --git a/packages/react-components/react-list-preview/stories/ListItem/ListItemBestPractices.md b/packages/react-components/react-list-preview/stories/ListItem/ListItemBestPractices.md deleted file mode 100644 index 08ff8ddeeb5f8..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItem/ListItemBestPractices.md +++ /dev/null @@ -1,5 +0,0 @@ -## Best practices - -### Do - -### Don't diff --git a/packages/react-components/react-list-preview/stories/ListItem/ListItemDefault.stories.tsx b/packages/react-components/react-list-preview/stories/ListItem/ListItemDefault.stories.tsx deleted file mode 100644 index e38a36d8cbcfc..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItem/ListItemDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { ListItem, ListItemProps } from '@fluentui/react-list-preview'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-components/react-list-preview/stories/ListItem/ListItemDescription.md b/packages/react-components/react-list-preview/stories/ListItem/ListItemDescription.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/react-components/react-list-preview/stories/ListItem/index.stories.tsx b/packages/react-components/react-list-preview/stories/ListItem/index.stories.tsx deleted file mode 100644 index fdd4db71031ff..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItem/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ListItem } from '@fluentui/react-list-preview'; - -import descriptionMd from './ListItemDescription.md'; -import bestPracticesMd from './ListItemBestPractices.md'; - -export { Default } from './ListItemDefault.stories'; - -export default { - title: 'Preview Components/ListItem', - component: ListItem, - parameters: { - docs: { - description: { - component: [descriptionMd, bestPracticesMd].join('\n'), - }, - }, - }, -}; diff --git a/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonBestPractices.md b/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonBestPractices.md deleted file mode 100644 index 08ff8ddeeb5f8..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonBestPractices.md +++ /dev/null @@ -1,5 +0,0 @@ -## Best practices - -### Do - -### Don't diff --git a/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonDefault.stories.tsx b/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonDefault.stories.tsx deleted file mode 100644 index 7ab8767f6cdcf..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { ListItemButton, ListItemButtonProps } from '@fluentui/react-list-preview'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonDescription.md b/packages/react-components/react-list-preview/stories/ListItemButton/ListItemButtonDescription.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/react-components/react-list-preview/stories/ListItemButton/index.stories.tsx b/packages/react-components/react-list-preview/stories/ListItemButton/index.stories.tsx deleted file mode 100644 index 21996d5ecd6c9..0000000000000 --- a/packages/react-components/react-list-preview/stories/ListItemButton/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ListItemButton } from '@fluentui/react-list-preview'; - -import descriptionMd from './ListItemButtonDescription.md'; -import bestPracticesMd from './ListItemButtonBestPractices.md'; - -export { Default } from './ListItemButtonDefault.stories'; - -export default { - title: 'Preview Components/ListItemButton', - component: ListItemButton, - parameters: { - docs: { - description: { - component: [descriptionMd, bestPracticesMd].join('\n'), - }, - }, - }, -}; diff --git a/packages/react-components/react-list-preview/tsconfig.cy.json b/packages/react-components/react-list-preview/tsconfig.cy.json new file mode 100644 index 0000000000000..93a140885851d --- /dev/null +++ b/packages/react-components/react-list-preview/tsconfig.cy.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "types": ["node", "cypress", "cypress-storybook/cypress", "cypress-real-events"], + "lib": ["ES2019", "dom"] + }, + "include": ["**/*.cy.ts", "**/*.cy.tsx"] +} diff --git a/packages/react-components/react-list-preview/tsconfig.json b/packages/react-components/react-list-preview/tsconfig.json index 1941a041d46c1..1317f81620ca5 100644 --- a/packages/react-components/react-list-preview/tsconfig.json +++ b/packages/react-components/react-list-preview/tsconfig.json @@ -20,6 +20,9 @@ }, { "path": "./.storybook/tsconfig.json" + }, + { + "path": "./tsconfig.cy.json" } ] } diff --git a/packages/react-components/react-list-preview/tsconfig.lib.json b/packages/react-components/react-list-preview/tsconfig.lib.json index 6f90cf95c005b..e17f808c03933 100644 --- a/packages/react-components/react-list-preview/tsconfig.lib.json +++ b/packages/react-components/react-list-preview/tsconfig.lib.json @@ -16,7 +16,9 @@ "**/*.test.ts", "**/*.test.tsx", "**/*.stories.ts", - "**/*.stories.tsx" + "**/*.stories.tsx", + "**/*.cy.ts", + "**/*.cy.tsx" ], "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts b/packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts index 044dfa2b07804..16534155d1d94 100644 --- a/packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts +++ b/packages/react-components/react-shared-contexts/src/CustomStyleHooksContext/CustomStyleHooksContext.ts @@ -32,6 +32,7 @@ export type CustomStyleHooksContextValue = Partial<{ useListboxStyles_unstable: CustomStyleHook; useListStyles_unstable: CustomStyleHook; useListItemStyles_unstable: CustomStyleHook; + /* @deprecated Use onClick handler on the ListItem itself instead. */ useListItemButtonStyles_unstable: CustomStyleHook; useOptionStyles_unstable: CustomStyleHook; useOptionGroupStyles_unstable: CustomStyleHook;