From a8fee4ddc7e4c4c0d39310f0c31a4078ac46cf85 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 12 Dec 2022 14:11:06 -0800 Subject: [PATCH 01/21] cherry-pick: GroupedList: fix virtualization (unstable preview) (#24460) * GroupedList: add new version to address virtualization issues Introduces a new component, GroupedListV2, that is a drop-in replacement for GroupedList. GroupedListV2 addresses a bug with the virtualization implementation in GroupedList. As it is a significant re-write of the internals we've decided to make it a new component so users can opt in to the new component/behavior as needed rather than risk significant breakage with the existing GroupedList implementation. --- Virtualization in GroupedList is powered by List and groups in GroupedList are nested Lists. When nested with two or more levels of groups issues can arise with virtualization that result in the vertical size of the Lists being miscalculated resulting in items not rendering, the scrollbar repeatedly resizing (causing the list to "jump" about), or both. List does work asynchronously which contributes to the issue itself and makes debugging practically impossible as even a simple GroupedList will contain many Lists all of which are virtualized and rendering async. To address this issue we are introducing GroupedListV2 which is a drop-in replacement for GropedList (V1) as it adheres to the same API. Internally GroupedListV2 flattens virtualization into a single List eliminating the virtualization bug described above and making the list easier to reason about and debug. * List: add conditional rendering option Adds support to conditionally render cells in List which helps when rendering flattend GroupedLists as we don't really know if we need to render certain parts of the list (e.g., footers) until we call the render function. Ensure GroupedList <--> GroupedListV2 compatibility. * DetailsList: allow for custom GroupedList renderer This change allows users to provide a custom GroupedList renderer like GroupedListV2. * Update @fluentui/react API snapshot Add GroupedListV2 tests Add DetailsList tests A dd support for ungrouped lists * add perf tests for groupedlist/groupedlistv2 * change files * better types and refactor render functions. * refactor grouped items * typescript * WIP debugging * fix issues from tests - Add proper `getKey()` handling. - Remove selection dependency for "show all" and footer rendering. * Mark GroupedListV2 as unstable * groupedlistv2: update naming - Rename to GroupedListV2_unstable - Update tests to use this name * update api snapshot * update groupedlistv2 import for perf-test * update snapshots * pr feedback * update test snapshot --- apps/perf-test/src/scenarioIterations.js | 5 + apps/perf-test/src/scenarioRenderTypes.js | 2 + apps/perf-test/src/scenarios/GroupedList.tsx | 55 + .../perf-test/src/scenarios/GroupedListV2.tsx | 62 + ...-6c6ea224-d7e4-405c-92cc-8268da44ee7a.json | 7 + .../DetailsList/DetailsList.base.tsx | 7 +- .../DetailsList/DetailsList.types.ts | 1 + .../DetailsList/DetailsListV2.test.tsx | 1137 ++ .../__snapshots__/DetailsListV2.test.tsx.snap | 9228 +++++++++++++++++ .../GroupedList/GroupedList.test.tsx | 23 +- .../GroupedList/GroupedListV2.base.tsx | 583 ++ .../GroupedList/GroupedListV2.test.tsx | 368 + .../components/GroupedList/GroupedListV2.tsx | 26 + .../src/components/GroupedList/index.ts | 2 + .../src/components/List/List.tsx | 33 +- .../src/components/List/List.types.ts | 15 + .../GroupedListV2.Basic.Example.tsx | 81 + .../GroupedListV2.Custom.Example.tsx | 82 + .../GroupedListV2.CustomCheckbox.Example.tsx | 88 + packages/react/src/GroupedListV2.ts | 1 + 20 files changed, 11789 insertions(+), 17 deletions(-) create mode 100644 apps/perf-test/src/scenarios/GroupedList.tsx create mode 100644 apps/perf-test/src/scenarios/GroupedListV2.tsx create mode 100644 change/@fluentui-react-6c6ea224-d7e4-405c-92cc-8268da44ee7a.json create mode 100644 packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx create mode 100644 packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap create mode 100644 packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx create mode 100644 packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.test.tsx create mode 100644 packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.tsx create mode 100644 packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.Basic.Example.tsx create mode 100644 packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.Custom.Example.tsx create mode 100644 packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx create mode 100644 packages/react/src/GroupedListV2.ts diff --git a/apps/perf-test/src/scenarioIterations.js b/apps/perf-test/src/scenarioIterations.js index 62cd6cbc3cf4aa..0c9f14cc90651e 100644 --- a/apps/perf-test/src/scenarioIterations.js +++ b/apps/perf-test/src/scenarioIterations.js @@ -10,6 +10,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 cc92e1f06a7f01..4ee9008ad9c3ba 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/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx index 450d20af79741e..84c49bc666eb58 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx @@ -46,7 +46,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 '@uifabric/utilities'; +import { composeComponentAs, composeRenderFunction, getId } from '@uifabric/utilities'; import { useConst } from '@uifabric/react-hooks'; const getClassNames = classNamesFunction(); @@ -554,8 +554,11 @@ const DetailsListInner: React.ComponentType = ( onBlur: onBlur, }; + const FinalGroupedList = + groups && groupProps?.groupedListAs ? composeComponentAs(groupProps.groupedListAs, GroupedList) : GroupedList; + const list = groups ? ( - ; onRenderHeader?: IRenderFunction; + groupedListAs?: IComponentAs; } /** diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx new file mode 100644 index 00000000000000..009fbb108fcb1c --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx @@ -0,0 +1,1137 @@ +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 { IDragDropEvents } from '../../DragDrop'; +import { IGroup } from '../../GroupedList'; +import { IRenderFunction } from '../../Utilities'; +import { IDetailsColumnProps } from './DetailsColumn'; +import { IDetailsHeaderProps } from './DetailsHeader'; +import { IColumn, IDetailsGroupDividerProps, IDetailsList } from './DetailsList.types'; +import { 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/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap b/packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap new file mode 100644 index 00000000000000..249d46f1ebc108 --- /dev/null +++ b/packages/office-ui-fabric-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/office-ui-fabric-react/src/components/GroupedList/GroupedList.test.tsx b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.test.tsx index 3b31d935ee6ea8..0ecb02725c4086 100644 --- a/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.test.tsx +++ b/packages/office-ui-fabric-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,26 @@ 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/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx new file mode 100644 index 00000000000000..146806ff5365f2 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx @@ -0,0 +1,583 @@ +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 { IProcessedStyleSet } from '../../Styling'; +import { + IGroupedList, + IGroupedListProps, + IGroup, + IGroupRenderProps, + IGroupedListStyleProps, + IGroupedListStyles, +} from './GroupedList.types'; +import { GroupHeader } from './GroupHeader'; +import { GroupShowAll } from './GroupShowAll'; +import { GroupFooter } from './GroupFooter'; +import { IGroupHeaderProps } from './GroupHeader'; +import { IGroupShowAllProps } from './GroupShowAll.styles'; +import { 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/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.test.tsx b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.test.tsx new file mode 100644 index 00000000000000..d44e85e2d7fb7a --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.test.tsx @@ -0,0 +1,368 @@ +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 { IGroup } from './GroupedList.types'; +import { 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/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.tsx b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.tsx new file mode 100644 index 00000000000000..0f225ac1c0dd1e --- /dev/null +++ b/packages/office-ui-fabric-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 { 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/office-ui-fabric-react/src/components/GroupedList/index.ts b/packages/office-ui-fabric-react/src/components/GroupedList/index.ts index 653d428b29af04..5647c375f4b1ec 100644 --- a/packages/office-ui-fabric-react/src/components/GroupedList/index.ts +++ b/packages/office-ui-fabric-react/src/components/GroupedList/index.ts @@ -10,3 +10,5 @@ export { IGroupShowAllStyleProps, IGroupShowAllStyles } from './GroupShowAll.typ export { GroupSpacer } from './GroupSpacer'; export * from './GroupSpacer.types'; export * from './GroupedListSection'; + +export { GroupedListV2_unstable } from './GroupedListV2'; diff --git a/packages/office-ui-fabric-react/src/components/List/List.tsx b/packages/office-ui-fabric-react/src/components/List/List.tsx index c32bfcb3c3eda5..8430795b6f3465 100644 --- a/packages/office-ui-fabric-react/src/components/List/List.tsx +++ b/packages/office-ui-fabric-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, }; @@ -582,7 +583,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 }, @@ -603,18 +604,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/office-ui-fabric-react/src/components/List/List.types.ts b/packages/office-ui-fabric-react/src/components/List/List.types.ts index 2d44b81b24f4b2..3cc1a540ad4ea0 100644 --- a/packages/office-ui-fabric-react/src/components/List/List.types.ts +++ b/packages/office-ui-fabric-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. diff --git a/packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.Basic.Example.tsx b/packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.Basic.Example.tsx new file mode 100644 index 00000000000000..8528c6e4b02465 --- /dev/null +++ b/packages/react-examples/src/office-ui-fabric-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/office-ui-fabric-react/GroupedList/GroupedListV2.Custom.Example.tsx b/packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.Custom.Example.tsx new file mode 100644 index 00000000000000..1b9c9840411274 --- /dev/null +++ b/packages/react-examples/src/office-ui-fabric-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/office-ui-fabric-react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx b/packages/react-examples/src/office-ui-fabric-react/GroupedList/GroupedListV2.CustomCheckbox.Example.tsx new file mode 100644 index 00000000000000..fc71599f6083c8 --- /dev/null +++ b/packages/react-examples/src/office-ui-fabric-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/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'; From 2c8c3cea011cba109be132b5c443ae699b1a7808 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Mon, 12 Dec 2022 14:57:34 -0800 Subject: [PATCH 02/21] fix issues in DetailsList --- .../src/components/DetailsList/DetailsList.types.ts | 2 +- .../src/components/DetailsList/DetailsListV2.test.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.types.ts b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.types.ts index 4473afc898c716..8af099094c6021 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.types.ts +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.types.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { DetailsListBase } from './DetailsList.base'; import { ISelection, SelectionMode, ISelectionZoneProps } from '../../utilities/selection/index'; -import { IRefObject, IBaseProps, IRenderFunction, IStyleFunctionOrObject } from '../../Utilities'; +import { IRefObject, IBaseProps, IRenderFunction, IStyleFunctionOrObject, IComponentAs } from '../../Utilities'; import { IDragDropEvents, IDragDropContext, IDragDropHelper, IDragDropOptions } from './../../utilities/dragdrop/index'; import { IGroup, IGroupRenderProps, IGroupDividerProps, IGroupedListProps } from '../GroupedList/index'; import { IDetailsRowProps, IDetailsRowBaseProps } from '../DetailsList/DetailsRow'; diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx index 009fbb108fcb1c..489038e9a5da2e 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx @@ -2,8 +2,8 @@ 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 { safeMount } from '@uifabric/test-utilities'; +import { EventGroup, KeyCodes, resetIds, IRenderFunction } from '../../Utilities'; import { SelectionMode, Selection, SelectionZone } from '../../Selection'; import { getTheme } from '../../Styling'; import { DetailsHeader } from './DetailsHeader'; @@ -14,7 +14,6 @@ import { DetailsRow } from './DetailsRow'; import { DetailsRowCheck } from './DetailsRowCheck'; import { IDragDropEvents } from '../../DragDrop'; import { IGroup } from '../../GroupedList'; -import { IRenderFunction } from '../../Utilities'; import { IDetailsColumnProps } from './DetailsColumn'; import { IDetailsHeaderProps } from './DetailsHeader'; import { IColumn, IDetailsGroupDividerProps, IDetailsList } from './DetailsList.types'; @@ -374,7 +373,6 @@ describe('DetailsListV2', () => { ).toEqual('2'); expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); }, - true /* attach */, ); }); @@ -714,7 +712,6 @@ describe('DetailsListV2', () => { expect((document.activeElement as HTMLElement).textContent).toEqual('4'); expect((document.activeElement as HTMLElement).className.split(' ')).toContain('test-column'); }, - true /* attach */, ); }); @@ -752,7 +749,6 @@ describe('DetailsListV2', () => { ).toEqual('0'); expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); }, - true /* attach */, ); }); From e33301702db01d9cb60ea54b95d2c231bf9f95be Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Tue, 13 Dec 2022 09:59:36 -0800 Subject: [PATCH 03/21] fix type and build errors --- .../etc/office-ui-fabric-react.api.md | 7 +++++++ .../office-ui-fabric-react/src/GroupedListV2.ts | 1 + .../components/GroupedList/GroupedList.types.ts | 3 +-- .../GroupedList/GroupedListV2.base.tsx | 17 +++++++++++++---- packages/office-ui-fabric-react/src/index.ts | 1 + packages/react/src/GroupedListV2.ts | 2 +- 6 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 packages/office-ui-fabric-react/src/GroupedListV2.ts diff --git a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md index 8c23e373e4e517..ae50da3f05796b 100644 --- a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md +++ b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md @@ -1563,6 +1563,9 @@ export class GroupedListSection extends React.Component; + // @public (undocumented) export const GroupFooter: React.FunctionComponent; @@ -3788,6 +3791,8 @@ export interface IDetailsGroupDividerProps extends IGroupDividerProps, IDetailsI // @public (undocumented) export interface IDetailsGroupRenderProps extends IGroupRenderProps { + // (undocumented) + groupedListAs?: IComponentAs; // (undocumented) onRenderFooter?: IRenderFunction; // (undocumented) @@ -5968,6 +5973,7 @@ export interface IListProps extends React.HTMLAttributes | HTML onPageRemoved?: (page: IPage) => void; onPagesUpdated?: (pages: IPage[]) => void; onRenderCell?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode; + onRenderCellConditional?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode | null; onRenderPage?: IRenderFunction>; onRenderRoot?: IRenderFunction>; onRenderSurface?: IRenderFunction>; @@ -8964,6 +8970,7 @@ export class List extends React.Component, IListState> static defaultProps: { startIndex: number; onRenderCell: (item: any, index: number, containsFocus: boolean) => JSX.Element; + onRenderCellConditional: undefined; renderedWindowsAhead: number; renderedWindowsBehind: number; }; diff --git a/packages/office-ui-fabric-react/src/GroupedListV2.ts b/packages/office-ui-fabric-react/src/GroupedListV2.ts new file mode 100644 index 00000000000000..6c257b794af247 --- /dev/null +++ b/packages/office-ui-fabric-react/src/GroupedListV2.ts @@ -0,0 +1 @@ +export * from './components/GroupedList/GroupedListV2'; diff --git a/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.types.ts b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.types.ts index 7a31f9e2222824..a0ed3e1228907e 100644 --- a/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.types.ts +++ b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedList.types.ts @@ -2,12 +2,11 @@ import * as React from 'react'; import { GroupedListBase } from './GroupedList.base'; import { IList, IListProps } from '../../List'; import { IFocusZoneProps } from '../../FocusZone'; -import { IRefObject, IRenderFunction } from '../../Utilities'; +import { IRefObject, IRenderFunction, IStyleFunctionOrObject } from '../../Utilities'; import { IDragDropContext, IDragDropEvents, IDragDropHelper } from '../../utilities/dragdrop/index'; import { ISelection, SelectionMode } from '../../utilities/selection/index'; import { IViewport } from '../../utilities/decorators/withViewport'; import { ITheme, IStyle } from '../../Styling'; -import { IStyleFunctionOrObject } from '../../Utilities'; import { IGroupHeaderProps } from './GroupHeader.types'; import { IGroupShowAllProps } from './GroupShowAll.types'; import { IGroupFooterProps } from './GroupFooter.types'; diff --git a/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx index 146806ff5365f2..678ea129b269ba 100644 --- a/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx +++ b/packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.base.tsx @@ -238,15 +238,24 @@ const getKey: IListProps['getKey'] = (item, _index) => { return null; }; -const renderGroupHeader = (props: IGroupHeaderProps): JSX.Element => { +const renderGroupHeader = ( + props: IGroupHeaderProps, + _defaultRender?: (props: IGroupHeaderProps) => JSX.Element | null, +): JSX.Element => { return ; }; -const renderGroupShowAll = (props: IGroupShowAllProps): JSX.Element => { +const renderGroupShowAll = ( + props: IGroupShowAllProps, + _defaultRender?: (props: IGroupShowAllProps) => JSX.Element | null, +): JSX.Element => { return ; }; -const renderGroupFooter = (props: IGroupFooterProps): JSX.Element | null => { +const renderGroupFooter = ( + props: IGroupFooterProps, + _defaultRender?: (props: IGroupFooterProps) => JSX.Element | null, +): JSX.Element | null => { if (props.group && props.footerText) { return ; } @@ -403,7 +412,7 @@ export const GroupedListV2FC: React.FC = props => { ariaLevel: group.level ? group.level + 1 : 1, ariaSetSize: groups ? groups.length : undefined, ariaPosInSet: flattenedIndex !== undefined ? flattenedIndex + 1 : undefined, - }; + } as IGroupHeaderProps & { key: string; ariaLevel: number; ariaSetSize?: number; ariaPosInSet?: number }; return ( Date: Tue, 13 Dec 2022 16:27:55 -0800 Subject: [PATCH 04/21] fix types --- .../SiteDefinition.pages/Controls/web.tsx | 2 + .../DetailsListGroupedV2Page.doc.ts | 10 + .../DetailsListGroupedV2Page.tsx | 7 + .../DetailsListLargeGroupedV2Page.doc.ts | 10 + .../DetailsListLargeGroupedV2Page.tsx | 7 + ...-8cc34e48-8e55-4e4c-9ddb-f1f9cd3b0f3f.json | 7 + .../__snapshots__/DetailsListV2.test.tsx.snap | 10 +- .../GroupedList/GroupedListV2.base.tsx | 64 +- .../components/GroupedList/GroupedListV2.tsx | 5 +- .../GroupedList/GroupedListV2.types.ts | 27 + .../DetailsList.GroupedV2.Example.tsx | 137 + .../DetailsList.GroupedV2.Large.Example.tsx | 69 + .../DetailsList/DetailsList.doc.tsx | 18 + packages/react/etc/react.api.md | 11010 ++++++++++++++++ 14 files changed, 11351 insertions(+), 32 deletions(-) create mode 100644 apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts create mode 100644 apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx create mode 100644 apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts create mode 100644 apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx create mode 100644 change/@fluentui-react-8cc34e48-8e55-4e4c-9ddb-f1f9cd3b0f3f.json create mode 100644 packages/office-ui-fabric-react/src/components/GroupedList/GroupedListV2.types.ts create mode 100644 packages/react-examples/src/office-ui-fabric-react/DetailsList/DetailsList.GroupedV2.Example.tsx create mode 100644 packages/react-examples/src/office-ui-fabric-react/DetailsList/DetailsList.GroupedV2.Large.Example.tsx diff --git a/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx b/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx index 156a75f580e5ca..6a91df47757b2d 100644 --- a/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx +++ b/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx @@ -58,6 +58,8 @@ export const categories: { Other?: ICategory; [name: string]: ICategory } = { Compact: {}, Grouped: {}, LargeGrouped: { title: 'Large Grouped' }, + GroupedV2: { title: 'Grouped V2' }, + LargeGroupedV2: { title: 'Large Grouped V2' }, CustomColumns: { title: 'Custom Item Columns', url: 'customitemcolumns' }, CustomRows: { title: 'Custom Item Rows', url: 'customitemrows' }, CustomFooter: { title: 'Custom Footer' }, diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts new file mode 100644 index 00000000000000..10d06bd2c7e8ee --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts @@ -0,0 +1,10 @@ +import { TFabricPlatformPageProps } from '../../../interfaces/Platforms'; +import { DetailsListSimpleGroupedV2PageProps as ExternalProps } from '@fluentui/react-examples/lib/react/DetailsList/DetailsList.doc'; + +export const DetailsListGroupedV2PageProps: TFabricPlatformPageProps = { + web: { + ...(ExternalProps as any), + title: 'DetailsList - Grouped V2', + isFeedbackVisible: false, + }, +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx new file mode 100644 index 00000000000000..dac663349f9fe6 --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ControlsAreaPage, IControlsPageProps } from '../ControlsAreaPage'; +import { DetailsListGroupedV2PageProps } from './DetailsListGroupedV2Page.doc'; + +export const DetailsListGroupedV2Page: React.FunctionComponent = props => { + return ; +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts new file mode 100644 index 00000000000000..e21b2a1d08fe4d --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts @@ -0,0 +1,10 @@ +import { TFabricPlatformPageProps } from '../../../interfaces/Platforms'; +import { DetailsListLargeGroupedPageProps as ExternalProps } from '@fluentui/react-examples/lib/react/DetailsList/DetailsList.doc'; + +export const DetailsListLargeGroupedV2PageProps: TFabricPlatformPageProps = { + web: { + ...(ExternalProps as any), + title: 'DetailsList - Large Grouped V2', + isFeedbackVisible: false, + }, +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx new file mode 100644 index 00000000000000..0c55806c77397e --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ControlsAreaPage, IControlsPageProps } from '../ControlsAreaPage'; +import { DetailsListLargeGroupedV2PageProps } from './DetailsListLargeGroupedV2Page.doc'; + +export const DetailsListLargeGroupedV2Page: React.FunctionComponent = props => { + return ; +}; diff --git a/change/@fluentui-react-8cc34e48-8e55-4e4c-9ddb-f1f9cd3b0f3f.json b/change/@fluentui-react-8cc34e48-8e55-4e4c-9ddb-f1f9cd3b0f3f.json new file mode 100644 index 00000000000000..c687f27645c8fb --- /dev/null +++ b/change/@fluentui-react-8cc34e48-8e55-4e4c-9ddb-f1f9cd3b0f3f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: set correct aria-posinset and aria-rowindex values on GroupedListV2", + "packageName": "@fluentui/react", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap b/packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap index 249d46f1ebc108..bd1339f1ff1254 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/DetailsList/__snapshots__/DetailsListV2.test.tsx.snap @@ -7488,7 +7488,7 @@ exports[`DetailsListV2 renders List with hidden checkboxes correctly 1`] = ` role="presentation" >