diff --git a/apps/perf-test/package.json b/apps/perf-test/package.json index 4ca4d106d19c80..1d61c92396111d 100644 --- a/apps/perf-test/package.json +++ b/apps/perf-test/package.json @@ -15,6 +15,7 @@ "@fluentui/eslint-plugin": "*" }, "dependencies": { + "@fluentui/example-data": "^8.4.2", "@fluentui/react": "^8.95.1", "@fluentui/scripts": "^1.0.0", "@microsoft/load-themed-styles": "^1.10.26", diff --git a/apps/perf-test/src/scenarioIterations.js b/apps/perf-test/src/scenarioIterations.js index db017b5bbb8968..1e81dfe7943cbc 100644 --- a/apps/perf-test/src/scenarioIterations.js +++ b/apps/perf-test/src/scenarioIterations.js @@ -11,6 +11,11 @@ const scenarioIterations = { ComboBox: 1000, Persona: 1000, ContextualMenu: 1000, + /* List performance is generally more influenced by the size + * of the list rather than the number of lists on a page. + */ + GroupedList: 2, + GroupedListV2: 2, }; module.exports = scenarioIterations; diff --git a/apps/perf-test/src/scenarioRenderTypes.js b/apps/perf-test/src/scenarioRenderTypes.js index 2cdb377ceeced3..e30c3c4aaff06a 100644 --- a/apps/perf-test/src/scenarioRenderTypes.js +++ b/apps/perf-test/src/scenarioRenderTypes.js @@ -13,6 +13,8 @@ const DefaultRenderTypes = ['mount']; const scenarioRenderTypes = { ThemeProvider: AllRenderTypes, + GroupedList: AllRenderTypes, + GroupedListV2: AllRenderTypes, }; module.exports = { diff --git a/apps/perf-test/src/scenarios/GroupedList.tsx b/apps/perf-test/src/scenarios/GroupedList.tsx new file mode 100644 index 00000000000000..ed051e2130eea6 --- /dev/null +++ b/apps/perf-test/src/scenarios/GroupedList.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { createListItems, createGroups, IExampleItem } from '@fluentui/example-data'; +import { GroupedList, Selection, SelectionMode, DetailsRow, IGroup, IColumn } from '@fluentui/react'; + +const groupCount = 5; +const groupDepth = 5; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); +const groups = createGroups(groupCount, groupDepth, 0, groupCount); + +const columns = Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ); + +const selection = new Selection(); +selection.setItems(items); + +const onRenderCell = ( + nestingDepth?: number, + item?: IExampleItem, + itemIndex?: number, + group?: IGroup, +): React.ReactNode => { + return item && typeof itemIndex === 'number' && itemIndex > -1 ? ( + + ) : null; +}; + +const Scenario = () => { + return ( + + ); +}; + +export default Scenario; diff --git a/apps/perf-test/src/scenarios/GroupedListV2.tsx b/apps/perf-test/src/scenarios/GroupedListV2.tsx new file mode 100644 index 00000000000000..2e0d0123c6680f --- /dev/null +++ b/apps/perf-test/src/scenarios/GroupedListV2.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { createListItems, createGroups, IExampleItem } from '@fluentui/example-data'; +import { + GroupedListV2_unstable as GroupedListV2, + Selection, + SelectionMode, + DetailsRow, + IGroup, + IColumn, +} from '@fluentui/react'; + +const groupCount = 5; +const groupDepth = 5; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); +const groups = createGroups(groupCount, groupDepth, 0, groupCount); + +const columns = Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ); + +const selection = new Selection(); +selection.setItems(items); + +const onRenderCell = ( + nestingDepth?: number, + item?: IExampleItem, + itemIndex?: number, + group?: IGroup, +): React.ReactNode => { + return item && typeof itemIndex === 'number' && itemIndex > -1 ? ( + + ) : null; +}; + +const Scenario = () => { + return ( + + ); +}; + +export default Scenario; diff --git a/change/@fluentui-react-6c6ea224-d7e4-405c-92cc-8268da44ee7a.json b/change/@fluentui-react-6c6ea224-d7e4-405c-92cc-8268da44ee7a.json new file mode 100644 index 00000000000000..36c71653ac682a --- /dev/null +++ b/change/@fluentui-react-6c6ea224-d7e4-405c-92cc-8268da44ee7a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: improve groupedlist virtualization", + "packageName": "@fluentui/react", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-examples/src/react/GroupedList/GroupedListV2.Basic.Example.tsx b/packages/react-examples/src/react/GroupedList/GroupedListV2.Basic.Example.tsx new file mode 100644 index 00000000000000..8528c6e4b02465 --- /dev/null +++ b/packages/react-examples/src/react/GroupedList/GroupedListV2.Basic.Example.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { IGroup } from '@fluentui/react/lib/GroupedList'; +import { GroupedListV2_unstable as GroupedListV2 } from '@fluentui/react/lib/GroupedListV2'; +import { IColumn, DetailsRow } from '@fluentui/react/lib/DetailsList'; +import { Selection, SelectionMode, SelectionZone } from '@fluentui/react/lib/Selection'; +import { Toggle, IToggleStyles } from '@fluentui/react/lib/Toggle'; +import { useBoolean, useConst } from '@fluentui/react-hooks'; +import { createListItems, createGroups, IExampleItem } from '@fluentui/example-data'; + +const toggleStyles: Partial = { root: { marginBottom: '20px' } }; +const groupCount = 3; +const groupDepth = 3; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); +const columns = Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ); + +const groups = createGroups(groupCount, groupDepth, 0, groupCount); + +export const GroupedListV2BasicExample: React.FunctionComponent = () => { + const [isCompactMode, { toggle: toggleIsCompactMode }] = useBoolean(false); + const selection = useConst(() => { + const s = new Selection(); + s.setItems(items, true); + return s; + }); + + const onRenderCell = ( + nestingDepth?: number, + item?: IExampleItem, + itemIndex?: number, + group?: IGroup, + ): React.ReactNode => { + return item && typeof itemIndex === 'number' && itemIndex > -1 ? ( + + ) : null; + }; + + return ( +
+ + + + +
+ ); +}; + +// @ts-expect-error Storybook +GroupedListV2BasicExample.storyName = 'V2 Basic'; diff --git a/packages/react-examples/src/react/GroupedList/GroupedListV2.Custom.Example.tsx b/packages/react-examples/src/react/GroupedList/GroupedListV2.Custom.Example.tsx new file mode 100644 index 00000000000000..1b9c9840411274 --- /dev/null +++ b/packages/react-examples/src/react/GroupedList/GroupedListV2.Custom.Example.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { IGroup, IGroupHeaderProps, IGroupFooterProps } from '@fluentui/react/lib/GroupedList'; +import { GroupedListV2_unstable as GroupedListV2 } from '@fluentui/react/lib/GroupedListV2'; +import { Link } from '@fluentui/react/lib/Link'; +import { createListItems, createGroups, IExampleItem } from '@fluentui/example-data'; +import { getTheme, mergeStyleSets, IRawStyle } from '@fluentui/react/lib/Styling'; + +const theme = getTheme(); +const headerAndFooterStyles: IRawStyle = { + minWidth: 300, + minHeight: 40, + lineHeight: 40, + paddingLeft: 16, +}; +const classNames = mergeStyleSets({ + header: [headerAndFooterStyles, theme.fonts.xLarge], + footer: [headerAndFooterStyles, theme.fonts.large], + name: { + display: 'inline-block', + overflow: 'hidden', + height: 24, + cursor: 'default', + padding: 8, + boxSizing: 'border-box', + verticalAlign: 'top', + background: 'none', + backgroundColor: 'transparent', + border: 'none', + paddingLeft: 32, + }, +}); + +const onRenderHeader = (props?: IGroupHeaderProps): JSX.Element | null => { + if (props) { + const toggleCollapse = (): void => { + props.onToggleCollapse!(props.group!); + }; + return ( +
+ This is a custom header for {props.group!.name} +   ( + + {props.group!.isCollapsed ? 'Expand' : 'Collapse'} + + ) +
+ ); + } + + return null; +}; + +const onRenderCell = (nestingDepth?: number, item?: IExampleItem, itemIndex?: number): React.ReactNode => { + return item ? ( +
+ + {item.name} + +
+ ) : null; +}; + +const onRenderFooter = (props?: IGroupFooterProps): JSX.Element | null => { + return props ?
This is a custom footer for {props.group!.name}
: null; +}; + +const groupedListProps = { + onRenderHeader, + onRenderFooter, +}; +const items: IExampleItem[] = createListItems(20); +const groups: IGroup[] = createGroups(4, 0, 0, 5); + +export const GroupedListV2CustomExample: React.FunctionComponent = () => ( + +); + +// @ts-expect-error Storybook +GroupedListV2CustomExample.storyName = 'V2 Custom'; diff --git a/packages/react-examples/src/react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx b/packages/react-examples/src/react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx new file mode 100644 index 00000000000000..fc71599f6083c8 --- /dev/null +++ b/packages/react-examples/src/react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { + GroupHeader, + IGroupHeaderCheckboxProps, + IGroupHeaderProps, + IGroupRenderProps, + IGroup, +} from '@fluentui/react/lib/GroupedList'; +import { GroupedListV2_unstable as GroupedListV2 } from '@fluentui/react/lib/GroupedListV2'; +import { IColumn, IObjectWithKey, DetailsRow } from '@fluentui/react/lib/DetailsList'; +import { FocusZone } from '@fluentui/react/lib/FocusZone'; +import { Selection, SelectionMode, SelectionZone } from '@fluentui/react/lib/Selection'; +import { Icon } from '@fluentui/react/lib/Icon'; +import { useConst } from '@fluentui/react-hooks'; +import { createListItems, createGroups, IExampleItem } from '@fluentui/example-data'; + +const groupCount = 3; +const groupDepth = 1; + +const groupProps: IGroupRenderProps = { + onRenderHeader: (props?: IGroupHeaderProps): JSX.Element => ( + + ), +}; + +/* This is rendered within a checkbox, so it must not be interactive itself. */ +const onRenderGroupHeaderCheckbox = (props?: IGroupHeaderCheckboxProps) => { + const iconStyles = { root: { fontSize: '36px' } }; + + return props?.checked ? ( + + ) : ( + + ); +}; + +export const GroupedListV2CustomCheckboxExample: React.FunctionComponent = () => { + const items: IObjectWithKey[] = useConst(() => createListItems(Math.pow(groupCount, groupDepth + 1))); + const groups = useConst(() => createGroups(groupCount, groupDepth, 0, groupCount)); + const columns = useConst(() => + Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ), + ); + const selection = useConst(() => new Selection({ items })); + + const onRenderCell = React.useCallback( + (nestingDepth?: number, item?: IExampleItem, itemIndex?: number, group?: IGroup): React.ReactNode => ( + + ), + [columns, selection], + ); + + return ( +
+ + + + + +
+ ); +}; + +// @ts-expect-error Storybook +GroupedListV2CustomCheckboxExample.storyName = 'V2 Custom Checkbox'; diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md index cec44a35208fad..9ce91d036a9d99 100644 --- a/packages/react/etc/react.api.md +++ b/packages/react/etc/react.api.md @@ -2012,6 +2012,9 @@ export class GroupedListSection extends React_2.Component; + // @public (undocumented) export const GroupFooter: React_2.FunctionComponent; @@ -4535,6 +4538,8 @@ export interface IDetailsGroupDividerProps extends IGroupDividerProps, IDetailsI // @public (undocumented) export interface IDetailsGroupRenderProps extends IGroupRenderProps { + // (undocumented) + groupedListAs?: IComponentAs; // (undocumented) onRenderFooter?: IRenderFunction; // (undocumented) @@ -6735,6 +6740,7 @@ export interface IListProps extends React_2.HTMLAttributes | HT onPageRemoved?: (page: IPage) => void; onPagesUpdated?: (pages: IPage[]) => void; onRenderCell?: (item?: T, index?: number, isScrolling?: boolean) => React_2.ReactNode; + onRenderCellConditional?: (item?: T, index?: number, isScrolling?: boolean) => React_2.ReactNode | null; onRenderPage?: IRenderFunction>; onRenderRoot?: IRenderFunction>; onRenderSurface?: IRenderFunction>; @@ -9769,6 +9775,7 @@ export class List extends React_2.Component, IListState JSX.Element; + onRenderCellConditional: undefined; renderedWindowsAhead: number; renderedWindowsBehind: number; }; diff --git a/packages/react/package.json b/packages/react/package.json index eb218f95b2f01a..227d78f0deb3ca 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -507,6 +507,11 @@ "import": "./lib/components/GroupedList/GroupShowAll.styles.js", "require": "./lib-commonjs/components/GroupedList/GroupShowAll.styles.js" }, + "./lib/GroupedListV2": { + "types": "./lib/GroupedList.d.ts", + "import": "./lib/GroupedListV2.js", + "require": "./lib-commonjs/GroupedListV2.js" + }, "./lib/HoverCard": { "types": "./lib/HoverCard.d.ts", "import": "./lib/HoverCard.js", diff --git a/packages/react/src/GroupedListV2.ts b/packages/react/src/GroupedListV2.ts new file mode 100644 index 00000000000000..6c257b794af247 --- /dev/null +++ b/packages/react/src/GroupedListV2.ts @@ -0,0 +1 @@ +export * from './components/GroupedList/GroupedListV2'; diff --git a/packages/react/src/components/DetailsList/DetailsList.base.tsx b/packages/react/src/components/DetailsList/DetailsList.base.tsx index 47e41c1c84a02d..2186ed90ac7a7c 100644 --- a/packages/react/src/components/DetailsList/DetailsList.base.tsx +++ b/packages/react/src/components/DetailsList/DetailsList.base.tsx @@ -34,7 +34,7 @@ import { DEFAULT_CELL_STYLE_PROPS } from './DetailsRow.styles'; import { CHECK_CELL_WIDTH as CHECKBOX_WIDTH } from './DetailsRowCheck.styles'; // For every group level there is a GroupSpacer added. Importing this const to have the source value in one place. import { SPACER_WIDTH as GROUP_EXPAND_WIDTH } from '../GroupedList/GroupSpacer'; -import { composeRenderFunction, getId } from '@fluentui/utilities'; +import { composeComponentAs, composeRenderFunction, getId } from '@fluentui/utilities'; import { useConst } from '@fluentui/react-hooks'; import type { IRenderFunction } from '../../Utilities'; import type { @@ -597,8 +597,11 @@ const DetailsListInner: React.ComponentType = ( onBlur: focusZoneProps && focusZoneProps.onBlur ? focusZoneProps.onBlur : onBlur, }; + const FinalGroupedList = + groups && groupProps?.groupedListAs ? composeComponentAs(groupProps.groupedListAs, GroupedList) : GroupedList; + const list = groups ? ( - ; onRenderHeader?: IRenderFunction; + groupedListAs?: IComponentAs; } /** diff --git a/packages/react/src/components/DetailsList/DetailsListV2.test.tsx b/packages/react/src/components/DetailsList/DetailsListV2.test.tsx new file mode 100644 index 00000000000000..0f02aff354d8cb --- /dev/null +++ b/packages/react/src/components/DetailsList/DetailsListV2.test.tsx @@ -0,0 +1,1131 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as renderer from 'react-test-renderer'; +import { ReactWrapper } from 'enzyme'; +import { safeMount } from '@fluentui/test-utilities'; +import { EventGroup, KeyCodes, resetIds } from '../../Utilities'; +import { SelectionMode, Selection, SelectionZone } from '../../Selection'; +import { getTheme } from '../../Styling'; +import { DetailsHeader } from './DetailsHeader'; +import { DetailsList } from './DetailsList'; +import { DetailsListBase } from './DetailsList.base'; +import { CheckboxVisibility, DetailsListLayoutMode, IDetailsGroupRenderProps } from './DetailsList.types'; +import { DetailsRow } from './DetailsRow'; +import { DetailsRowCheck } from './DetailsRowCheck'; +import type { IDragDropEvents } from '../../DragDrop'; +import type { IGroup } from '../../GroupedList'; +import type { IRenderFunction } from '../../Utilities'; +import type { IDetailsColumnProps } from './DetailsColumn'; +import type { IDetailsHeaderProps } from './DetailsHeader'; +import type { IColumn, IDetailsGroupDividerProps, IDetailsList } from './DetailsList.types'; +import type { IDetailsRowProps } from './DetailsRow'; +import { GroupedListV2_unstable as GroupedListV2 } from '../GroupedList/GroupedListV2'; + +// Populate mock data for testing +function mockData(count: number, isColumn: boolean = false, customDivider: boolean = false): any { + const data = []; + let _data = {}; + + for (let i = 0; i < count; i++) { + _data = { + key: i, + name: 'Item ' + i, + value: i, + }; + if (isColumn) { + _data = { + ..._data, + key: `column_key_${i}`, + ariaLabel: `column_${i}`, + onRenderDivider: customDivider ? customColumnDivider : columnDividerWrapper, + }; + } + data.push(_data); + } + + return data; +} + +// Wrapper function which calls the defaultRenderer with the corresponding params +function columnDividerWrapper( + iDetailsColumnProps: IDetailsColumnProps, + defaultRenderer: (props?: IDetailsColumnProps) => JSX.Element | null, +): any { + return defaultRenderer(iDetailsColumnProps); +} + +// Using a bar sign as a custom divider along with the default divider +function customColumnDivider( + iDetailsColumnProps: IDetailsColumnProps, + defaultRenderer: (props?: IDetailsColumnProps) => JSX.Element | null, +): any { + return ( + + | + {defaultRenderer(iDetailsColumnProps)} + + ); +} + +const groupProps: IDetailsGroupRenderProps = { + groupedListAs: GroupedListV2, +}; + +/** + * NOTE: There isn't actually a DetailsListV2 control, rather + * this control uses GroupedListV2 for rendering. + */ +describe('DetailsListV2', () => { + let spy: jest.SpyInstance; + beforeAll(() => { + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + spy = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + }); + + afterAll(() => { + spy.mockRestore(); + }); + + beforeEach(() => { + resetIds(); + }); + + afterEach(() => { + if (((setTimeout as unknown) as jest.Mock).mock) { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + it('renders List correctly', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List correctly with onRenderDivider props', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List with custom icon as column divider', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List in fixed constrained layout correctly', () => { + const component = renderer.create( + null} + layoutMode={DetailsListLayoutMode.fixedColumns} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders a single proportional column with correct width', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('121'); + } else if (index === 2) { + expect(width[0]).toBe('348'); + } else if (index === 3) { + expect(width[0]).toBe('123'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders proportional columns with proper width ratios', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('336'); + } else if (index === 2) { + expect(width[0]).toBe('255'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders proportional columns with proper width ratios when delayFirstMeasure', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + delayFirstMeasure + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('336'); + } else if (index === 2) { + expect(width[0]).toBe('255'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders List in compact mode correctly', () => { + const component = renderer.create( + null} + compact={true} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List with hidden checkboxes correctly', () => { + const component = renderer.create( + false} + groups={[ + { + key: 'group0', + name: 'Group 0', + startIndex: 0, + count: 2, + }, + { + key: 'group1', + name: 'Group 1', + startIndex: 2, + count: 3, + }, + ]} + checkboxVisibility={CheckboxVisibility.hidden} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('focuses row by index', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + jest.runAllTimers(); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('2'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + }, + true /* attach */, + ); + }); + + it('focuses row by arrow key', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + component!.focusIndex(0); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-DetailsList-headerWrapper').simulate('keyDown', { which: KeyCodes.down }); + expect(onSelectionChanged).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not focus by arrow key when isSelectedOnFocus is `false`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + isSelectedOnFocus={false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + component!.focusIndex(0); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-DetailsList-headerWrapper').simulate('keyDown', { which: KeyCodes.down }); + expect(onSelectionChanged).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('clears selection when escape key is pressed and isSelectedOnFocus is `true`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + selection.setAllSelected(true); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-SelectionZone').simulate('keyDown', { which: KeyCodes.escape }); + expect(onSelectionChanged).toHaveBeenCalledTimes(1); + expect(selection.getSelectedCount()).toEqual(0); + }, + ); + }); + + it('does not clear selection when escape key is pressed and isSelectedOnFocus is `false`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + isSelectedOnFocus={false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + selection.setAllSelected(true); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-SelectionZone').simulate('keyDown', { which: KeyCodes.escape }); + expect(onSelectionChanged).toHaveBeenCalledTimes(0); + expect(selection.getSelectedCount()).toEqual(5); + }, + ); + }); + + it('invokes optional onRenderMissingItem prop once per missing item rendered', () => { + const onRenderMissingItem = jest.fn(); + const items = [...mockData(5), null, null]; + + safeMount( + , + () => { + expect(onRenderMissingItem).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('does not invoke optional onRenderMissingItem prop if no missing items are rendered', () => { + const onRenderMissingItem = jest.fn(); + const items = mockData(5); + + safeMount( + , + () => { + expect(onRenderMissingItem).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('executes onItemInvoked when double click is pressed', () => { + const items = mockData(5); + const onItemInvoked = jest.fn(); + + safeMount( + , + (wrapper: ReactWrapper) => { + wrapper.find('.ms-DetailsRow').first().simulate('dblclick'); + + expect(onItemInvoked).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('executes onItemInvoked when enter is pressed', () => { + const items = mockData(5); + const onItemInvoked = jest.fn(); + + safeMount( + , + (wrapper: ReactWrapper) => { + wrapper.find('.ms-DetailsRow').first().simulate('keydown', { which: KeyCodes.enter }); + + expect(onItemInvoked).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('respects changed dragDropEvents prop on re-renders.', () => { + const _dragDropEvents: IDragDropEvents = { + canDrag: jest.fn().mockReturnValue(true), + canDrop: jest.fn().mockReturnValue(true), + onDragEnd: jest.fn(), + onDragEnter: jest.fn(), + onDragLeave: jest.fn(), + onDragStart: jest.fn(), + onDrop: jest.fn(), + }; + + const _dragDropEvents2: IDragDropEvents = { + canDrag: jest.fn().mockReturnValue(true), + canDrop: jest.fn().mockReturnValue(true), + onDragEnd: jest.fn(), + onDragEnter: jest.fn(), + onDragLeave: jest.fn(), + onDragStart: jest.fn(), + onDrop: jest.fn(), + }; + + const _RaiseEvent = (target: any, _eventName: string, _clientX: number) => { + EventGroup.raise( + target, + _eventName, + { + clientX: _clientX, + button: 0, + } as DragEvent, + true, + ); + }; + + const container = document.createElement('div'); + const items = mockData(5); + const columns = mockData(5, true); + + ReactDOM.render( + , + container, + ); + + let detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + // original eventhandler should be fired + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(1); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(0); + + ReactDOM.render( + , + container, + ); + + detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(2); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(0); + + ReactDOM.render( + , + container, + ); + + detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(2); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(1); + }); + + it('focuses into row element', () => { + jest.useFakeTimers(); + + const onRenderColumn = (item: any, index: number, column: IColumn) => { + let value = item && column && column.fieldName ? item[column.fieldName] : ''; + if (value === null || value === undefined) { + value = ''; + } + return ( +
+ {value} +
+ ); + }; + + const getCellValueKey = (item: any, index: number, column: IColumn) => { + const valueKey = item && column && column.fieldName ? item[column.fieldName] : column.key + index; + return valueKey; + }; + + let component: IDetailsList | null; + safeMount( + (component = ref)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + onRenderItemColumn={onRenderColumn} + getCellValueKey={getCellValueKey} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(3); + jest.runOnlyPendingTimers(); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('3'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + + // Set element visibility manually as a test workaround + component!.focusIndex(4); + jest.runOnlyPendingTimers(); + ((document.activeElement as HTMLElement).children[1] as any).isVisible = true; + ((document.activeElement as HTMLElement).children[1].children[0] as any).isVisible = true; + ((document.activeElement as HTMLElement).children[1].children[0].children[0] as any).isVisible = true; + + component!.focusIndex(4, true); + jest.runOnlyPendingTimers(); + expect((document.activeElement as HTMLElement).textContent).toEqual('4'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('test-column'); + }, + true /* attach */, + ); + }); + + it('reset focusedItemIndex when setKey updates', () => { + jest.useFakeTimers(); + + let component: DetailsListBase | null; + + safeMount( + (component = value as any)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + (wrapper: ReactWrapper) => { + expect(component).toBeTruthy(); + component!.focusIndex(3); + jest.runAllTimers(); + expect(component!.state.focusedItemIndex).toEqual(3); + + // update props to new setKey + const newProps = { items: mockData(7), setKey: 'set2', initialFocusedIndex: 0 }; + wrapper.setProps(newProps); + wrapper.update(); + + // verify that focusedItemIndex is reset to 0 and 0th row is focused + jest.runAllTimers(); + expect(component!.state.focusedItemIndex).toEqual(0); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('0'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + }, + true /* attach */, + ); + }); + + it('invokes optional onColumnResize callback per IColumn if defined when columns are adjusted', () => { + jest.useFakeTimers(); + + const columns: IColumn[] = mockData(2, true); + columns[0].onColumnResize = jest.fn(); + columns[1].onColumnResize = jest.fn(); + + safeMount( + false} groupProps={groupProps} />, + () => { + jest.runOnlyPendingTimers(); + expect(columns[0].onColumnResize).toHaveBeenCalledTimes(1); + expect(columns[1].onColumnResize).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('invokes optional onRenderDetailsHeader prop to customize DetailsHeader rendering when provided', () => { + const onRenderDetailsHeaderMock = jest.fn(); + + safeMount( + false} + onRenderDetailsHeader={onRenderDetailsHeaderMock} + groupProps={groupProps} + />, + () => { + expect(onRenderDetailsHeaderMock).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('invokes onRenderColumnHeaderTooltip to customize DetailsColumn tooltip rendering when provided', () => { + const NUM_COLUMNS = 2; + const onRenderColumnHeaderTooltipMock = jest.fn(); + const onRenderDetailsHeader = ( + props: IDetailsHeaderProps, + defaultRenderer?: IRenderFunction, + ) => { + return ; + }; + + safeMount( + false} + onRenderDetailsHeader={onRenderDetailsHeader} + groupProps={groupProps} + />, + () => { + expect(onRenderColumnHeaderTooltipMock).toHaveBeenCalledTimes(4); + }, + ); + }); + + it('invokes optional onRenderCheckbox callback to customize checkbox rendering when provided', () => { + const onRenderCheckboxMock = jest.fn(); + const selection = new Selection(); + const theme = getTheme(); + + safeMount( + false} + onRenderCheckbox={onRenderCheckboxMock} + checkboxVisibility={CheckboxVisibility.always} + selectionMode={SelectionMode.multiple} + selection={selection} + groupProps={groupProps} + />, + () => { + expect(onRenderCheckboxMock).toHaveBeenCalledTimes(3); + expect(onRenderCheckboxMock.mock.calls[2][0]).toEqual({ checked: false, theme }); + + selection.setAllSelected(true); + + expect(onRenderCheckboxMock).toHaveBeenCalledTimes(6); + expect(onRenderCheckboxMock.mock.calls[5][0]).toEqual({ checked: true, theme }); + }, + ); + }); + + it('initializes the selection mode object with the selectionMode prop', () => { + safeMount( + , + (wrapper: ReactWrapper) => { + const selectionZone = wrapper.find(SelectionZone); + + expect(selectionZone.props().selection.mode).toEqual(SelectionMode.none); + }, + ); + }); + + it('handles updates to items and groups', () => { + const tableOneItems = [ + { f1: 'A1', f2: 'B1', f3: 'C1' }, + { f1: 'A2', f2: 'B2', f3: 'C2' }, + { f1: 'A3', f2: 'B3', f3: 'C3' }, + { f1: 'A4', f2: 'B4', f3: 'C4' }, + ]; + const tableTwoItems = [ + { f1: 'D1', f2: 'E1', f3: 'F1' }, + { f1: 'D2', f2: 'E2', f3: 'F2' }, + { f1: 'D3', f2: 'E3', f3: 'F3' }, + { f1: 'D4', f2: 'E4', f3: 'F4' }, + ]; + + const groupOneGroups: IGroup[] = [ + { key: 'one-1', name: 'one 1', count: 1, startIndex: 0 }, + { key: 'one-2', name: 'one 2', count: 1, startIndex: 1 }, + { key: 'one-3', name: 'one 3', count: 1, startIndex: 2 }, + { key: 'one-4', name: 'one 4', count: 1, startIndex: 3 }, + ]; + + const groupTwoGroups: IGroup[] = [ + { key: 'two-1', name: 'two 1', count: 2, startIndex: 0 }, + { key: 'two-2', name: 'two 2', count: 2, startIndex: 2 }, + ]; + + const onRenderDetailsHeader: IRenderFunction = (headerProps: IDetailsHeaderProps) => { + return ( +
+ {headerProps.columns.map((column: IColumn) => { + return
{column.name}
; + })} +
+ ); + }; + + const onRenderRow = (rowProps: IDetailsRowProps) => { + return ( +
+ {rowProps.columns.map((column: IColumn) => { + return
{rowProps.item[column.key]}
; + })} +
+ ); + }; + + const onRenderGroupHeader: IRenderFunction = ( + groupDividerProps: IDetailsGroupDividerProps, + ) => { + return
{groupDividerProps.group?.name}
; + }; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // Same items, new groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it('handles paged updates to items within groups', () => { + const roundOneItems = [{ f1: 'A1', f2: 'B1', f3: 'C1' }, undefined, { f1: 'A3', f2: 'B3', f3: 'C3' }, undefined]; + const roundTwoItems = [ + { f1: 'A1', f2: 'B1', f3: 'C1' }, + { f1: 'A2', f2: 'B2', f3: 'C2' }, + { f1: 'A3', f2: 'B3', f3: 'C3' }, + { f1: 'A4', f2: 'B4', f3: 'C4' }, + ]; + + const groups: IGroup[] = [ + { key: 'two-1', name: 'two 1', count: 2, startIndex: 0 }, + { key: 'two-2', name: 'two 2', count: 2, startIndex: 2 }, + ]; + + const onRenderDetailsHeader: IRenderFunction = (headerProps: IDetailsHeaderProps) => { + return ( +
+ {headerProps.columns.map((column: IColumn) => { + return
{column.name}
; + })} +
+ ); + }; + + const onRenderRow = (rowProps: IDetailsRowProps) => { + return ( +
+ {rowProps.columns.map((column: IColumn) => { + return
{rowProps.item[column.key]}
; + })} +
+ ); + }; + + const onRenderMissingItem = () => { + return
Placeholder
; + }; + + const onRenderGroupHeader: IRenderFunction = ( + groupDividerProps: IDetailsGroupDividerProps, + ) => { + return
{groupDividerProps.group?.name}
; + }; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it('returns an element with the correct text based on the second id passed in aria-labelledby', () => { + const container = document.createElement('div'); + const columns = [ + { + key: 'column1', + name: 'Name', + fieldName: 'name', + minWidth: 100, + maxWidth: 200, + isResizable: true, + isRowHeader: true, + }, + { key: 'column2', name: 'Value', fieldName: 'value', minWidth: 100, maxWidth: 200, isResizable: true }, + ]; + const items = mockData(1); + + ReactDOM.render( + , + container, + ); + + const checkbox = container.querySelector('div[role="checkbox"][aria-label="select row"]') as HTMLElement; + const rowHeaderId = checkbox?.getAttribute('aria-labelledby')?.split(' ')[1]; + + expect(container.querySelector(`#${rowHeaderId}`)!.textContent).toEqual(items[0].name); + }); + + it('has an aria-labelledby checkboxId that matches the id of the checkbox', () => { + const component = renderer.create( + , + ); + + const detailsRowSource = component.root.findAllByType(DetailsRow)[0]; + const detailsRowCheckSource = detailsRowSource.findByType(DetailsRowCheck).props; + const checkboxId = detailsRowCheckSource.id; + + expect(checkboxId).toEqual(detailsRowCheckSource[`aria-labelledby`].split(' ')[0]); + }); + + it('names group header checkboxes based on checkButtonGroupAriaLabel', () => { + const container = document.createElement('div'); + ReactDOM.render( + , + container, + ); + + const checkbox = container.querySelector('[role="checkbox"][aria-label="select section"]') as HTMLElement; + expect(checkbox).not.toBeNull(); + + const groupNameId = checkbox.getAttribute('aria-labelledby')?.split(' ')[1]; + expect(container.querySelector(`#${groupNameId} span`)!.textContent).toEqual('Group 0'); + }); +}); diff --git a/packages/react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap b/packages/react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap new file mode 100644 index 00000000000000..249d46f1ebc108 --- /dev/null +++ b/packages/react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap @@ -0,0 +1,9228 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailsListV2 handles paged updates to items within groups 1`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ two 1 +
+
+
+
+
+ A1 +
+
+ B1 +
+
+ C1 +
+
+
+
+
+ Placeholder +
+
+
+
+ two 2 +
+
+
+
+
+ A3 +
+
+ B3 +
+
+ C3 +
+
+
+
+
+ Placeholder +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 handles paged updates to items within groups 2`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ two 1 +
+
+
+
+
+ A1 +
+
+ B1 +
+
+ C1 +
+
+
+
+
+
+ A2 +
+
+ B2 +
+
+ C2 +
+
+
+
+
+ two 2 +
+
+
+
+
+ A3 +
+
+ B3 +
+
+ C3 +
+
+
+
+
+
+ A4 +
+
+ B4 +
+
+ C4 +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 handles updates to items and groups 1`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ one 1 +
+
+
+
+
+ A1 +
+
+ B1 +
+
+ C1 +
+
+
+
+
+ one 2 +
+
+
+
+
+ A2 +
+
+ B2 +
+
+ C2 +
+
+
+
+
+ one 3 +
+
+
+
+
+ A3 +
+
+ B3 +
+
+ C3 +
+
+
+
+
+ one 4 +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 handles updates to items and groups 2`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ one 1 +
+
+
+
+
+ D1 +
+
+ E1 +
+
+ F1 +
+
+
+
+
+ one 2 +
+
+
+
+
+ D2 +
+
+ E2 +
+
+ F2 +
+
+
+
+
+ one 3 +
+
+
+
+
+ D3 +
+
+ E3 +
+
+ F3 +
+
+
+
+
+ one 4 +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 handles updates to items and groups 3`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ two 1 +
+
+
+
+
+ D1 +
+
+ E1 +
+
+ F1 +
+
+
+
+
+
+ D2 +
+
+ E2 +
+
+ F2 +
+
+
+
+
+ two 2 +
+
+
+
+
+ D3 +
+
+ E3 +
+
+ F3 +
+
+
+
+
+
+ D4 +
+
+ E4 +
+
+ F4 +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 handles updates to items and groups 4`] = ` +
+
+
+
+
+
+ f1 +
+
+ f2 +
+
+ f3 +
+
+
+
+
+
+
+
+
+
+
+ two 1 +
+
+
+
+
+ A1 +
+
+ B1 +
+
+ C1 +
+
+
+
+
+
+ A2 +
+
+ B2 +
+
+ C2 +
+
+
+
+
+ two 2 +
+
+
+
+
+ A3 +
+
+ B3 +
+
+ C3 +
+
+
+
+
+
+ A4 +
+
+ B4 +
+
+ C4 +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List correctly 1`] = ` +
+
+
+
+
+
+ + + +
+
+ + + + key + + + +
+
+
+ + + + name + + + +
+
+
+ + + + value + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List correctly with onRenderDivider props 1`] = ` +
+
+
+
+
+
+ + + +
+
+ + + + Item 0 + + + +
+
+ + + + Item 1 + + + +
+
+ + + + Item 2 + + + +
+
+ + + + Item 3 + + + +
+
+ + + + Item 4 + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List in compact mode correctly 1`] = ` +
+
+
+
+
+
+ + + +
+
+ + + + key + + + +
+
+
+ + + + name + + + +
+
+
+ + + + value + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List in fixed constrained layout correctly 1`] = ` +
+
+
+
+
+
+ + + +
+
+ + + + key + + + +
+
+
+ + + + name + + + +
+
+
+ + + + value + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List with custom icon as column divider 1`] = ` +
+
+
+
+
+
+ + + +
+
+ + + + Item 0 + + + +
+ + | + +
+ + + + Item 1 + + + +
+ + | + +
+ + + + Item 2 + + + +
+ + | + +
+ + + + Item 3 + + + +
+ + | + +
+ + + + Item 4 + + + +
+ + | + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`DetailsListV2 renders List with hidden checkboxes correctly 1`] = ` +
+
+
+
+
+
+ +  + + +
+
+ + + + key + + + +
+
+
+ + + + name + + + +
+
+
+ + + + value + + + +
+
+
+
+
+
+
+
+
+
+
+
.ms-GroupHeader .ms-GroupHeader-dropIcon { + opacity: 1; + transform: rotate(0.2deg) scale(1); + transition-delay: 0.367s; + transition: transform 0.467s cubic-bezier(0.075, 0.820, 0.165, 1.000) opacity 0.167s cubic-bezier(0.390, 0.575, 0.565, 1.000); + } + .ms-GroupedList-group.is-dropping .ms-GroupHeader-check { + opacity: 0; + } + data-is-focusable={true} + data-selection-index={0} + data-selection-span={2} + onKeyUp={[Function]} + role="row" + style={Object {}} + > +
+
+ +  + +
+
+ +
+
+ + Group 0 + + + ( + 2 + ) + +
+
+
+
+
+
.ms-Link { + color: #004578; + } + &:hover .ms-DetailsRow-check { + opacity: 1; + } + .ms-Fabric--isFocusVisible &:focus .ms-DetailsRow-check { + opacity: 1; + } + & .ms-GroupSpacer { + flex-grow: 0; + flex-shrink: 0; + } + data-automationid="DetailsRow" + data-focuszone-id="FocusZone8" + data-is-focusable={true} + data-item-index={0} + data-selection-index={0} + data-selection-touch-invoke={true} + id="row0-0" + onFocus={[Function]} + onKeyDown={[Function]} + onMouseDownCapture={[Function]} + role="row" + style={ + Object { + "minWidth": 300, + } + } + > + +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="key" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 0 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="name" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + Item 0 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="value" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 0 +
+
+ +
+
+
+
.ms-Link { + color: #004578; + } + &:hover .ms-DetailsRow-check { + opacity: 1; + } + .ms-Fabric--isFocusVisible &:focus .ms-DetailsRow-check { + opacity: 1; + } + & .ms-GroupSpacer { + flex-grow: 0; + flex-shrink: 0; + } + data-automationid="DetailsRow" + data-focuszone-id="FocusZone10" + data-is-focusable={true} + data-item-index={1} + data-selection-index={1} + data-selection-touch-invoke={true} + id="row0-1" + onFocus={[Function]} + onKeyDown={[Function]} + onMouseDownCapture={[Function]} + role="row" + style={ + Object { + "minWidth": 300, + } + } + > + +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="key" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 1 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="name" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + Item 1 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="value" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 1 +
+
+ +
+
+
+
.ms-GroupHeader .ms-GroupHeader-dropIcon { + opacity: 1; + transform: rotate(0.2deg) scale(1); + transition-delay: 0.367s; + transition: transform 0.467s cubic-bezier(0.075, 0.820, 0.165, 1.000) opacity 0.167s cubic-bezier(0.390, 0.575, 0.565, 1.000); + } + .ms-GroupedList-group.is-dropping .ms-GroupHeader-check { + opacity: 0; + } + data-is-focusable={true} + data-selection-index={2} + data-selection-span={3} + onKeyUp={[Function]} + role="row" + style={Object {}} + > +
+
+ +  + +
+
+ +
+
+ + Group 1 + + + ( + 3 + ) + +
+
+
+
+
+
.ms-Link { + color: #004578; + } + &:hover .ms-DetailsRow-check { + opacity: 1; + } + .ms-Fabric--isFocusVisible &:focus .ms-DetailsRow-check { + opacity: 1; + } + & .ms-GroupSpacer { + flex-grow: 0; + flex-shrink: 0; + } + data-automationid="DetailsRow" + data-focuszone-id="FocusZone13" + data-is-focusable={true} + data-item-index={2} + data-selection-index={2} + data-selection-touch-invoke={true} + id="row0-2" + onFocus={[Function]} + onKeyDown={[Function]} + onMouseDownCapture={[Function]} + role="row" + style={ + Object { + "minWidth": 300, + } + } + > + +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="key" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 2 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="name" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + Item 2 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="value" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 2 +
+
+ +
+
+
+
.ms-Link { + color: #004578; + } + &:hover .ms-DetailsRow-check { + opacity: 1; + } + .ms-Fabric--isFocusVisible &:focus .ms-DetailsRow-check { + opacity: 1; + } + & .ms-GroupSpacer { + flex-grow: 0; + flex-shrink: 0; + } + data-automationid="DetailsRow" + data-focuszone-id="FocusZone15" + data-is-focusable={true} + data-item-index={3} + data-selection-index={3} + data-selection-touch-invoke={true} + id="row0-3" + onFocus={[Function]} + onKeyDown={[Function]} + onMouseDownCapture={[Function]} + role="row" + style={ + Object { + "minWidth": 300, + } + } + > + +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="key" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 3 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="name" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + Item 3 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="value" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 3 +
+
+ +
+
+
+
.ms-Link { + color: #004578; + } + &:hover .ms-DetailsRow-check { + opacity: 1; + } + .ms-Fabric--isFocusVisible &:focus .ms-DetailsRow-check { + opacity: 1; + } + & .ms-GroupSpacer { + flex-grow: 0; + flex-shrink: 0; + } + data-automationid="DetailsRow" + data-focuszone-id="FocusZone17" + data-is-focusable={true} + data-item-index={4} + data-selection-index={4} + data-selection-touch-invoke={true} + id="row0-4" + onFocus={[Function]} + onKeyDown={[Function]} + onMouseDownCapture={[Function]} + role="row" + style={ + Object { + "minWidth": 300, + } + } + > + +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="key" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 4 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="name" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + Item 4 +
+
button { + max-width: 100%; + } + & [data-is-focusable='true'] { + outline: transparent; + position: relative; + } + & [data-is-focusable='true']::-moz-focus-inner { + border: 0; + } + { + padding-right: 8px; + } + data-automation-key="value" + data-automationid="DetailsRowCell" + role="gridcell" + style={ + Object { + "width": 120, + } + } + > + 4 +
+
+ +
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/packages/react/src/components/GroupedList/GroupedList.test.tsx b/packages/react/src/components/GroupedList/GroupedList.test.tsx index 47f64615aac177..721a10988e7e9d 100644 --- a/packages/react/src/components/GroupedList/GroupedList.test.tsx +++ b/packages/react/src/components/GroupedList/GroupedList.test.tsx @@ -129,7 +129,7 @@ describe('GroupedList', () => { it("renders the number of rows specified by a group's count when startIndex is not zero", () => { const _selection = new Selection(); - const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }]; + const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' }]; const _groups: Array = [ { count: 3, @@ -172,7 +172,11 @@ describe('GroupedList', () => { ); const listRows = wrapper.find(DetailsRow); - expect(listRows).toHaveLength(1); + expect(listRows).toHaveLength(3); + + expect(listRows.at(0).parent().key()).toBe('3'); + expect(listRows.at(1).parent().key()).toBe('4'); + expect(listRows.at(2).parent().key()).toBe('5'); wrapper.unmount(); }); diff --git a/packages/react/src/components/GroupedList/GroupedListV2.base.tsx b/packages/react/src/components/GroupedList/GroupedListV2.base.tsx new file mode 100644 index 00000000000000..4046819b68e4a8 --- /dev/null +++ b/packages/react/src/components/GroupedList/GroupedListV2.base.tsx @@ -0,0 +1,584 @@ +import * as React from 'react'; +import { + initializeComponentRef, + classNamesFunction, + KeyCodes, + getRTLSafeKeyCode, + css, + getId, + EventGroup, + IRenderFunction, +} from '../../Utilities'; +import { List, ScrollToMode, IListProps } from '../../List'; +import { ISelection, SelectionMode, SELECTION_CHANGE } from '../../Selection'; +import { FocusZone, FocusZoneDirection } from '../../FocusZone'; +import type { IProcessedStyleSet } from '../../Styling'; +import type { + IGroupedList, + IGroupedListProps, + IGroup, + IGroupRenderProps, + IGroupedListStyleProps, + IGroupedListStyles, +} from './GroupedList.types'; +import { GroupHeader } from './GroupHeader'; +import { GroupShowAll } from './GroupShowAll'; +import { GroupFooter } from './GroupFooter'; +import type { IGroupHeaderProps } from './GroupHeader'; +import type { IGroupShowAllProps } from './GroupShowAll.styles'; +import type { IGroupFooterProps } from './GroupFooter.types'; + +export interface IGroupedListV2State { + selectionMode?: IGroupedListProps['selectionMode']; + compact?: IGroupedListProps['compact']; + groups?: IGroup[]; + items?: IGroupedListProps['items']; + listProps?: IGroupedListProps['listProps']; + version: {}; + groupExpandedVersion: {}; +} + +export interface IGroupedListV2Props extends IGroupedListProps { + listRef: React.Ref; + version: {}; + groupExpandedVersion: {}; +} + +type IITemGroupedItem = { + type: 'item'; + group: IGroup; + item: any; + itemIndex: number; +}; + +type IShowAllGroupedItem = { + type: 'showAll'; + group: IGroup; +}; + +type IFooterGroupedItem = { + type: 'footer'; + group: IGroup; +}; + +type IHeaderGroupedItem = { + type: 'header'; + group: IGroup; + groupId: string; +}; + +type IGroupedItem = IITemGroupedItem | IShowAllGroupedItem | IFooterGroupedItem | IHeaderGroupedItem; + +type FlattenItems = ( + groups: IGroup[] | undefined, + items: any[], + memoItems: IGroupedItem[], + groupProps: IGroupRenderProps['getGroupItemLimit'], +) => IGroupedItem[]; + +const flattenItems: FlattenItems = (groups, items, memoItems, getGroupItemLimit) => { + if (!groups) { + return items; + } + + if (memoItems.length < 1) { + // Not the exact final size but gets us in the ballpark. + // This helps avoid trashing memory when building + // the flattened list. + memoItems = new Array(items.length); + } + + let index = 0; + + const stack: IGroup[] = []; + let j = groups.length - 1; + while (j >= 0) { + stack.push(groups[j]); + j--; + } + + while (stack.length > 0) { + let group = stack.pop()!; + memoItems[index] = { + group, + groupId: getId('GroupedListSection'), + type: 'header', + }; + + index++; + + while (group.isCollapsed !== true && group?.children && group.children.length > 0) { + j = group.children.length - 1; + while (j > 0) { + stack.push(group.children[j]); + j--; + } + group = group.children[0]; + memoItems[index] = { + group, + groupId: getId('GroupedListSection'), + type: 'header', + }; + index++; + } + + if (group.isCollapsed !== true) { + let itemIndex = group.startIndex; + const renderCount = getGroupItemLimit ? getGroupItemLimit(group) : Infinity; + const count = !group.isShowingAll ? group.count : items.length; + const itemEnd = itemIndex + Math.min(count, renderCount); + while (itemIndex < itemEnd) { + memoItems[index] = { + group, + item: items[itemIndex], + itemIndex, // track the index in `item` for later rendering/selection + type: 'item', + }; + itemIndex++; + index++; + } + + const isShowAllVisible = + !group.children && + !group.isCollapsed && + !group.isShowingAll && + (group.count > renderCount || group.hasMoreData); + + if (isShowAllVisible) { + memoItems[index] = { + group, + type: 'showAll', + }; + index++; + } + } + + // Placeholder for a potential footer. + // Whether or not a footer is displayed is resolved + // by the footer render function so this is just a marker + // for where a footer may go. + memoItems[index] = { + group, + type: 'footer', + }; + index++; + } + + memoItems.length = index; + + // console.log('MEMO ITEMS', memoItems); + + return memoItems; +}; + +type UseIsGroupSelected = ( + startIndex: number, + count: number, + selection?: ISelection, + eventGroup?: EventGroup, +) => boolean; + +const useIsGroupSelected: UseIsGroupSelected = (startIndex, count, selection, eventGroup) => { + const [isSelected, setIsSelected] = React.useState(() => selection?.isRangeSelected(startIndex, count) ?? false); + + React.useEffect(() => { + if (selection && eventGroup) { + const changeHandler = () => { + setIsSelected(selection?.isRangeSelected(startIndex, count) ?? false); + }; + + eventGroup.on(selection, SELECTION_CHANGE, changeHandler); + + return () => { + eventGroup?.off(selection, SELECTION_CHANGE, changeHandler); + }; + } + }, [startIndex, count, selection, eventGroup]); + + return isSelected; +}; + +const computeIsSomeGroupExpanded = (groups: IGroup[] | undefined): boolean => { + return !!( + groups && groups.some(group => (group.children ? computeIsSomeGroupExpanded(group.children) : !group.isCollapsed)) + ); +}; + +const setGroupsCollapsedState = (groups: IGroup[] | undefined, isCollapsed: boolean): void => { + if (groups === undefined) { + return; + } + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + groups[groupIndex].isCollapsed = isCollapsed; + } +}; + +const isInnerZoneKeystroke = (ev: React.KeyboardEvent): boolean => { + // eslint-disable-next-line deprecation/deprecation + return ev.which === getRTLSafeKeyCode(KeyCodes.right); +}; + +const getClassNames = classNamesFunction(); + +const getKey: IListProps['getKey'] = (item, _index) => { + switch (item.type) { + case 'item': + return item.item?.key ?? null; + + case 'header': + return item.group.key; + + case 'footer': + return `${item.group.key}-footer`; + + case 'showAll': + return `${item.group.key}-showAll`; + } + + return null; +}; + +const renderGroupHeader = (props: IGroupHeaderProps): JSX.Element => { + return ; +}; + +const renderGroupShowAll = (props: IGroupShowAllProps): JSX.Element => { + return ; +}; + +const renderGroupFooter = (props: IGroupFooterProps): JSX.Element | null => { + if (props.group && props.footerText) { + return ; + } + + return null; +}; + +export const GroupedListV2FC: React.FC = props => { + const { + selection, + selectionMode = SelectionMode.multiple, + groupProps = {}, + compact = false, + items = [], + groups, + onGroupExpandStateChanged, + + className, + usePageCache, + onShouldVirtualize, + theme, + role = 'treegrid', + styles, + focusZoneProps = {}, + rootListProps = {}, + onRenderCell, + viewport, + listRef, + groupExpandedVersion, + } = props; + + const { + onRenderHeader = renderGroupHeader, + onRenderFooter = renderGroupFooter, + onRenderShowAll = renderGroupShowAll, + } = groupProps; + + const classNames: IProcessedStyleSet = getClassNames(styles, { + theme: theme!, + className, + compact, + }); + + const events = React.useRef(); + const flatList = React.useRef([]); + const isSomeGroupExpanded = React.useRef(computeIsSomeGroupExpanded(groups)); + + const [version, setVersion] = React.useState({}); + const [toggleVersion, setToggleVersion] = React.useState({}); + + // eslint-disable-next-line deprecation/deprecation + const { shouldEnterInnerZone = isInnerZoneKeystroke } = focusZoneProps; + + const listView = React.useMemo(() => { + return flattenItems(groups, items, flatList.current, groupProps?.getGroupItemLimit); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groups, groupProps?.getGroupItemLimit, items, toggleVersion, flatList]); + + const getPageSpecification = React.useCallback( + (flattenedIndex: number): { key?: string } => { + const pageGroup = listView[flattenedIndex]; + return { + key: pageGroup.type === 'header' ? pageGroup.group.key : undefined, + }; + }, + [listView], + ); + + React.useEffect(() => { + if (groupProps?.isAllGroupsCollapsed) { + setGroupsCollapsedState(groups, groupProps.isAllGroupsCollapsed); + } + events.current = new EventGroup(this); + + return () => { + events.current?.dispose(); + events.current = undefined; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + setVersion({}); + }, [compact]); + + React.useEffect(() => { + const newIsSomeGroupExpanded = computeIsSomeGroupExpanded(groups); + if (newIsSomeGroupExpanded !== isSomeGroupExpanded.current) { + isSomeGroupExpanded.current = newIsSomeGroupExpanded; + onGroupExpandStateChanged?.(newIsSomeGroupExpanded); + } + }, [groups, toggleVersion, onGroupExpandStateChanged, groupExpandedVersion]); + + const onToggleCollapse = React.useCallback( + (group: IGroup): void => { + const onToggleCollapseFn = groupProps?.headerProps?.onToggleCollapse; + + if (group) { + onToggleCollapseFn?.(group); + group.isCollapsed = !group.isCollapsed; + setToggleVersion({}); + setVersion({}); + } + }, + [setToggleVersion, groupProps], + ); + + const onToggleSelectGroup = (group: IGroup): void => { + if (group && selection && selectionMode === SelectionMode.multiple) { + selection.toggleRangeSelected(group.startIndex, group.count); + } + }; + + const onToggleSummarize = (group: IGroup): void => { + const onToggleSummarizeFn = groupProps?.showAllProps?.onToggleSummarize; + + if (onToggleSummarizeFn) { + onToggleSummarizeFn(group); + } else { + if (group) { + group.isShowingAll = !group.isShowingAll; + } + + setVersion({}); + setToggleVersion({}); + } + }; + + const getDividerProps = (group: IGroup, flattenedIndex: number) => { + const dividerProps = { + group, + groupIndex: flattenedIndex, + groupLevel: group.level ?? 0, + viewport, + selectionMode, + groups, + compact, + onToggleSelectGroup, + onToggleCollapse, + onToggleSummarize, + }; + + return dividerProps; + }; + + const renderHeader = (item: IHeaderGroupedItem, flattenedIndex: number): React.ReactNode => { + const group = item.group; + + const headerProps = { + ...groupProps!.headerProps, + ...getDividerProps(item.group, flattenedIndex), + key: group.key, + groupedListId: item.groupId, + ariaLevel: group.level ? group.level + 1 : 1, + ariaSetSize: groups ? groups.length : undefined, + ariaPosInSet: flattenedIndex !== undefined ? flattenedIndex + 1 : undefined, + }; + + return ( + + ); + }; + + const renderShowAll = (item: IShowAllGroupedItem, flattenedIndex: number): React.ReactNode => { + const group = item.group; + const groupShowAllProps = { + ...groupProps!.showAllProps, + ...getDividerProps(group, flattenedIndex), + key: group.key ? `${group.key}-show-all` : undefined, + }; + + return onRenderShowAll(groupShowAllProps, renderGroupShowAll); + }; + + const renderFooter = (item: IFooterGroupedItem, flattenedIndex: number): React.ReactNode => { + const group = item.group; + const groupFooterProps = { + ...groupProps!.footerProps, + ...getDividerProps(group, flattenedIndex), + key: group.key ? `${group.key}-footer` : undefined, + }; + + return onRenderFooter(groupFooterProps, renderGroupFooter); + }; + + const renderItem = (item: IGroupedItem, flattenedIndex: number): React.ReactNode => { + if (item.type === 'header') { + return renderHeader(item, flattenedIndex); + } else if (item.type === 'showAll') { + return renderShowAll(item, flattenedIndex); + } else if (item.type === 'footer') { + return renderFooter(item, flattenedIndex); + } else { + const level = item.group.level ? item.group.level + 1 : 1; + return onRenderCell(level, item.item, item.itemIndex ?? flattenedIndex); + } + }; + + return ( + + + + ); +}; + +interface IGroupItemProps { + props: T; + render: IRenderFunction; + defaultRender: (props?: T) => JSX.Element | null; + item: any; + selection?: ISelection; + eventGroup?: EventGroup; +} + +const GroupItem = ({ + render, + defaultRender, + item, + selection, + eventGroup, + props, +}: React.PropsWithChildren>): React.ReactElement | null => { + const group = item.group; + + const isSelected = useIsGroupSelected(group.startIndex, group.count, selection, eventGroup); + const mergedProps = { + ...props, + isSelected: isSelected, + selected: isSelected, + }; + return render(mergedProps, defaultRender); +}; + +export class GroupedListV2Wrapper + extends React.Component + implements IGroupedList { + public static displayName: string = 'GroupedListV2'; + private _list = React.createRef(); + + public static getDerivedStateFromProps( + nextProps: IGroupedListProps, + previousState: IGroupedListV2State, + ): IGroupedListV2State { + const { groups } = nextProps; + + if (groups !== previousState.groups) { + return { + ...previousState, + groups, + }; + } + + return previousState; + } + + constructor(props: IGroupedListProps) { + super(props); + initializeComponentRef(this); + + const { listProps: { version = {} } = {}, groups } = props; + this.state = { + version, + groupExpandedVersion: {}, + groups, + }; + } + + public scrollToIndex(index: number, measureItem?: (itemIndex: number) => number, scrollToMode?: ScrollToMode): void { + if (this._list.current) { + this._list.current.scrollToIndex(index, measureItem, scrollToMode); + } + } + + public getStartItemIndexInView(): number { + return this._list.current?.getStartItemIndexInView() || 0; + } + + public render(): JSX.Element { + return ; + } + + public forceUpdate() { + super.forceUpdate(); + this._forceListUpdate(); + } + + public toggleCollapseAll(allCollapsed: boolean): void { + const { groups } = this.state; + const { groupProps } = this.props; + + if (groups && groups.length > 0) { + groupProps?.onToggleCollapseAll?.(allCollapsed); + + setGroupsCollapsedState(groups, allCollapsed); + this.setState({ + groupExpandedVersion: {}, + }); + + this.forceUpdate(); + } + } + + private _forceListUpdate(): void { + this.setState({ + version: {}, + }); + } +} diff --git a/packages/react/src/components/GroupedList/GroupedListV2.test.tsx b/packages/react/src/components/GroupedList/GroupedListV2.test.tsx new file mode 100644 index 00000000000000..0cda3ab525fc6c --- /dev/null +++ b/packages/react/src/components/GroupedList/GroupedListV2.test.tsx @@ -0,0 +1,350 @@ +import * as React from 'react'; +import { mount } from 'enzyme'; +import { SelectionMode, Selection } from '../../Selection'; +import { GroupedListV2_unstable as GroupedListV2 } from './GroupedListV2'; +import { DetailsRow } from '../DetailsList/DetailsRow'; +import { List } from '../../List'; +import { GroupShowAll } from './GroupShowAll'; +import { Link } from '../../Link'; +import { GroupHeader } from './GroupHeader'; +import { getTheme } from '../../Styling'; +import * as path from 'path'; +import { isConformant } from '../../common/isConformant'; +import type { IGroup } from './GroupedList.types'; +import type { IColumn } from '../DetailsList/DetailsList.types'; + +describe('GroupedListV2', () => { + isConformant({ + Component: GroupedListV2, + displayName: 'GroupedListV2_unstable', + componentPath: path.join(__dirname, 'GroupedListV2.tsx'), + requiredProps: { + items: [], + onRenderCell: () => { + return
; + }, + }, + // Problem: Ref is not supported + // Solution: Convert to FunctionComponent and support using forwardRef + disabledTests: ['component-handles-ref', 'component-has-root-ref', 'has-top-level-file'], + }); + + it("sets inner List page key to IGroup's key attribute for uniqueness", () => { + const _selection = new Selection(); + const _items: Array = [ + { + key: 'item1', + name: 'item1', + value: 1, + }, + ]; + const _groups: Array = [ + { + count: 1, + key: 'group0', + name: 'group 0', + startIndex: 0, + level: 0, + children: [], + }, + ]; + + function _onRenderCell(nestingDepth: number, item: any, itemIndex: number): JSX.Element { + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300, + }; + }, + )} + groupNestingDepth={nestingDepth} + item={item} + itemIndex={itemIndex} + selection={_selection} + selectionMode={SelectionMode.multiple} + /> + ); + } + + const wrapper = mount( + , + ); + const listPage = wrapper.find(List).find('.ms-List-page').first(); + expect(listPage.key()).toBe('group0'); + + wrapper.unmount(); + }); + + it("renders the number of rows specified by a group's count when startIndex is zero", () => { + const _selection = new Selection(); + const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }]; + const _groups: Array = [ + { + count: 3, + hasMoreData: true, + isCollapsed: false, + key: 'group0', + name: 'group 0', + startIndex: 0, + level: 0, + children: [], + }, + ]; + + function _onRenderCell(nestingDepth: number, item: any, itemIndex: number): JSX.Element { + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300, + }; + }, + )} + groupNestingDepth={nestingDepth} + item={item} + itemIndex={itemIndex} + selection={_selection} + selectionMode={SelectionMode.multiple} + /> + ); + } + + const wrapper = mount( + , + ); + + const listRows = wrapper.find(DetailsRow); + expect(listRows).toHaveLength(3); + + wrapper.unmount(); + }); + + it("renders the number of rows specified by a group's count when startIndex is not zero", () => { + const _selection = new Selection(); + const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' }]; + const _groups: Array = [ + { + count: 3, + hasMoreData: true, + isCollapsed: false, + key: 'group0', + name: 'group 0', + startIndex: 2, + level: 0, + children: [], + }, + ]; + + function _onRenderCell(nestingDepth: number, item: any, itemIndex: number): JSX.Element { + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300, + }; + }, + )} + groupNestingDepth={nestingDepth} + item={item} + itemIndex={itemIndex} + selection={_selection} + selectionMode={SelectionMode.multiple} + /> + ); + } + + const wrapper = mount( + , + ); + + const listRows = wrapper.find(DetailsRow); + expect(listRows).toHaveLength(3); + + expect(listRows.at(0).parent().key()).toBe('3'); + expect(listRows.at(1).parent().key()).toBe('4'); + expect(listRows.at(2).parent().key()).toBe('5'); + + wrapper.unmount(); + }); + + it('renders no rows if group is collapsed', () => { + const _selection = new Selection(); + const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }]; + const _groups: Array = [ + { + count: 3, + hasMoreData: true, + isCollapsed: true, + key: 'group0', + name: 'group 0', + startIndex: 0, + level: 0, + children: [], + }, + ]; + + function _onRenderCell(nestingDepth: number, item: any, itemIndex: number): JSX.Element { + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300, + }; + }, + )} + groupNestingDepth={nestingDepth} + item={item} + itemIndex={itemIndex} + selection={_selection} + selectionMode={SelectionMode.multiple} + /> + ); + } + + const wrapper = mount( + , + ); + + const listRows = wrapper.find(DetailsRow); + expect(listRows).toHaveLength(0); + + wrapper.unmount(); + }); + + // eslint-disable-next-line @fluentui/max-len + it('renders the specified count of rows if "Show All" is to be displayed and all rows once "Show All" is clicked', () => { + const _selection = new Selection(); + const _items: Array<{ key: string }> = [{ key: '1' }, { key: '2' }, { key: '3' }]; + const _groups: Array = [ + { + count: 1, + hasMoreData: true, + isCollapsed: false, + key: 'group0', + name: 'group 0', + startIndex: 0, + level: 0, + }, + ]; + + function _onRenderCell(nestingDepth: number, item: any, itemIndex: number): JSX.Element { + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300, + }; + }, + )} + groupNestingDepth={nestingDepth} + item={item} + itemIndex={itemIndex} + selection={_selection} + selectionMode={SelectionMode.multiple} + /> + ); + } + + const wrapper = mount( + , + ); + + let listRows = wrapper.find(DetailsRow); + expect(listRows).toHaveLength(1); + + const groupShowAllElement = wrapper.find(GroupShowAll); + + groupShowAllElement.find(Link).simulate('click'); + + listRows = wrapper.find(DetailsRow); + expect(listRows).toHaveLength(3); + + wrapper.unmount(); + }); + + it('renders group header with custom checkbox render', () => { + const onRenderCheckboxMock = jest.fn(); + + mount( + , + ); + + expect(onRenderCheckboxMock).toHaveBeenCalledTimes(1); + expect(onRenderCheckboxMock.mock.calls[0][0]).toEqual({ checked: false, theme: getTheme() }); + }); + + it('re-renders when items change back to the initial items', () => { + const initialItems: Array<{ key: string }> = [{ key: 'initial' }]; + const nextItems: Array<{ key: string }> = [{ key: 'changed' }]; + const _groups: Array = [ + { + count: 1, + hasMoreData: true, + isCollapsed: false, + key: 'group0', + name: 'group 0', + startIndex: 0, + level: 0, + }, + ]; + + function _onRenderCell(nestingDepth: number, item: { key: string }, itemIndex: number): JSX.Element { + const id = `rendered-item-${item.key}`; + return
; + } + + const wrapper = mount(); + expect(wrapper.contains(
)).toEqual(true); + + wrapper.setProps({ items: nextItems }); + expect(wrapper.contains(
)).toEqual(true); + expect(wrapper.contains(
)).toEqual(false); + + wrapper.setProps({ items: initialItems }); + expect(wrapper.contains(
)).toEqual(true); + }); +}); diff --git a/packages/react/src/components/GroupedList/GroupedListV2.tsx b/packages/react/src/components/GroupedList/GroupedListV2.tsx new file mode 100644 index 00000000000000..47ffef49965731 --- /dev/null +++ b/packages/react/src/components/GroupedList/GroupedListV2.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { styled } from '../../Utilities'; +import { getStyles } from './GroupedList.styles'; +import { GroupedListV2Wrapper } from './GroupedListV2.base'; +import type { IGroupedListProps, IGroupedListStyles, IGroupedListStyleProps } from './GroupedList.types'; + +/** + * NOTE: GroupedListV2 is "unstable" and meant for preview use. It passes + * the same test suite as GroupedList but it is an entirely new implementation + * so it may have bugs and implementation details may change without notice. + * + * GroupedListV2 is an API-compatible replacement for GroupedList with a new implementation + * that addresses issues GroupedList has with virtualizing nested lists under certain + * conditions. + */ +const GroupedListV2: React.FunctionComponent = styled< + IGroupedListProps, + IGroupedListStyleProps, + IGroupedListStyles +>(GroupedListV2Wrapper, getStyles, undefined, { + scope: 'GroupedListV2', +}); + +GroupedListV2.displayName = 'GroupedListV2_unstable'; + +export { GroupedListV2 as GroupedListV2_unstable }; diff --git a/packages/react/src/components/GroupedList/index.ts b/packages/react/src/components/GroupedList/index.ts index b012ba695051b1..14f3e2991f2129 100644 --- a/packages/react/src/components/GroupedList/index.ts +++ b/packages/react/src/components/GroupedList/index.ts @@ -10,3 +10,5 @@ export * from './GroupedListSection'; export type { IGroupHeaderStyleProps, IGroupHeaderStyles, IGroupHeaderCheckboxProps } from './GroupHeader.types'; export type { IGroupFooterStyleProps, IGroupFooterStyles } from './GroupFooter.types'; export type { IGroupShowAllStyleProps, IGroupShowAllStyles } from './GroupShowAll.types'; + +export { GroupedListV2_unstable } from './GroupedListV2'; diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index cf7a254c158227..a377bfa4538be3 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -98,6 +98,7 @@ export class List extends React.Component, IListState> public static defaultProps = { startIndex: 0, onRenderCell: (item: any, index: number, containsFocus: boolean) => <>{(item && item.name) || ''}, + onRenderCellConditional: undefined, renderedWindowsAhead: DEFAULT_RENDERED_WINDOWS_AHEAD, renderedWindowsBehind: DEFAULT_RENDERED_WINDOWS_BEHIND, }; @@ -593,7 +594,7 @@ export class List extends React.Component, IListState> } private _onRenderPage = (pageProps: IPageProps, defaultRender?: IRenderFunction>): any => { - const { onRenderCell, role } = this.props; + const { onRenderCell, onRenderCellConditional, role } = this.props; const { page: { items = [], startIndex }, @@ -614,18 +615,24 @@ export class List extends React.Component, IListState> itemKey = index; } - cells.push( -
- {onRenderCell && - onRenderCell(item, index, !this.props.ignoreScrollingState ? this.state.isScrolling : undefined)} -
, - ); + const renderCell = onRenderCellConditional ?? onRenderCell; + + const cell = + renderCell?.(item, index, !this.props.ignoreScrollingState ? this.state.isScrolling : undefined) ?? null; + + if (!onRenderCellConditional || cell) { + cells.push( +
+ {cell} +
, + ); + } } return
{cells}
; diff --git a/packages/react/src/components/List/List.types.ts b/packages/react/src/components/List/List.types.ts index 8af810f754ca25..d2b922663770d9 100644 --- a/packages/react/src/components/List/List.types.ts +++ b/packages/react/src/components/List/List.types.ts @@ -139,6 +139,21 @@ export interface IListProps extends React.HTMLAttributes | HTML */ onRenderCell?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode; + /** + * Method to call when trying to render an item conditionally. + * + * When this method returns `null` the cell will be skipped in the render. + * + * This prop is mutually exclusive with `onRenderCell` and when `onRenderCellConditional` is set, + * `onRenderCell` will not be called. + * + * @param item - The data associated with the cell that is being rendered. + * @param index - The index of the cell being rendered. + * @param isScrolling - True if the list is being scrolled. May be useful for rendering a placeholder if your cells + * are complex. + */ + onRenderCellConditional?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode | null; + /** * Optional callback invoked when List rendering completed. * This can be on initial mount or on re-render due to scrolling.