From aceef645dbbc2e6a8dc6dbe4ade543842b90e680 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Fri, 21 Oct 2022 09:08:22 -0700 Subject: [PATCH 01/10] proof of concept for list + focuszone --- .../DetailsList/DetailsList.Basic.Example.tsx | 262 +++++++++++------- packages/react/src/components/List/List.tsx | 8 +- .../react/src/components/List/List.types.ts | 9 + 3 files changed, 172 insertions(+), 107 deletions(-) diff --git a/packages/react-examples/src/react/DetailsList/DetailsList.Basic.Example.tsx b/packages/react-examples/src/react/DetailsList/DetailsList.Basic.Example.tsx index 2a7d7e0bb884ba..767dc7dcb5d085 100644 --- a/packages/react-examples/src/react/DetailsList/DetailsList.Basic.Example.tsx +++ b/packages/react-examples/src/react/DetailsList/DetailsList.Basic.Example.tsx @@ -1,118 +1,168 @@ import * as React from 'react'; -import { Announced } from '@fluentui/react/lib/Announced'; -import { TextField, ITextFieldStyles } from '@fluentui/react/lib/TextField'; -import { DetailsList, DetailsListLayoutMode, Selection, IColumn } from '@fluentui/react/lib/DetailsList'; -import { MarqueeSelection } from '@fluentui/react/lib/MarqueeSelection'; -import { mergeStyles } from '@fluentui/react/lib/Styling'; -import { Text } from '@fluentui/react/lib/Text'; -const exampleChildClass = mergeStyles({ - display: 'block', - marginBottom: '10px', -}); - -const textFieldStyles: Partial = { root: { maxWidth: '300px' } }; - -export interface IDetailsListBasicExampleItem { - key: number; - name: string; - value: number; -} - -export interface IDetailsListBasicExampleState { - items: IDetailsListBasicExampleItem[]; - selectionDetails: string; -} +import { + Dialog, + DialogType, + DialogFooter, + PrimaryButton, + DefaultButton, + hiddenContentStyle, + mergeStyles, + Toggle, + ContextualMenu, + getRTL, + FocusZone, + FocusZoneDirection, + Image, + ImageFit, + Icon, + List, + ITheme, + mergeStyleSets, + getTheme, + getFocusStyle, +} from '@fluentui/react'; + +import { useId, useBoolean, useConst } from '@fluentui/react-hooks'; +import { createListItems, IExampleItem } from '@fluentui/example-data'; export class DetailsListBasicExample extends React.Component<{}, IDetailsListBasicExampleState> { - private _selection: Selection; - private _allItems: IDetailsListBasicExampleItem[]; - private _columns: IColumn[]; - constructor(props: {}) { super(props); - - this._selection = new Selection({ - onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() }), - }); - - // Populate with items for demos. - this._allItems = []; - for (let i = 0; i < 200; i++) { - this._allItems.push({ - key: i, - name: 'Item ' + i, - value: i, - }); - } - - this._columns = [ - { key: 'column1', name: 'Name', fieldName: 'name', minWidth: 100, maxWidth: 200, isResizable: true }, - { key: 'column2', name: 'Value', fieldName: 'value', minWidth: 100, maxWidth: 200, isResizable: true }, - ]; - - this.state = { - items: this._allItems, - selectionDetails: this._getSelectionDetails(), - }; } public render(): JSX.Element { - const { items, selectionDetails } = this.state; - - return ( -
-
{selectionDetails}
- - Note: While focusing a row, pressing enter or double clicking will execute onItemInvoked, which in this - example will show an alert. - - - - - - - -
- ); - } - - private _getSelectionDetails(): string { - const selectionCount = this._selection.getSelectedCount(); - - switch (selectionCount) { - case 0: - return 'No items selected'; - case 1: - return '1 item selected: ' + (this._selection.getSelection()[0] as IDetailsListBasicExampleItem).name; - default: - return `${selectionCount} items selected`; - } + return ; } +} - private _onFilter = (ev: React.FormEvent, text: string): void => { - this.setState({ - items: text ? this._allItems.filter(i => i.name.toLowerCase().indexOf(text) > -1) : this._allItems, - }); - }; +const dialogStyles = { main: { maxWidth: 450 } }; +const dragOptions = { + moveMenuItemText: 'Move', + closeMenuItemText: 'Close', + menu: ContextualMenu, + keepInBounds: true, +}; +const screenReaderOnly = mergeStyles(hiddenContentStyle); +const dialogContentProps = { + type: DialogType.normal, + title: 'Missing Subject', + closeButtonAriaLabel: 'Close', + subText: 'Do you want to send this message without a subject?', +}; + +const DialogBasicExample: React.FunctionComponent = () => { + const [hideDialog, { toggle: toggleHideDialog }] = useBoolean(true); + const [isDraggable, { toggle: toggleIsDraggable }] = useBoolean(false); + const labelId: string = useId('dialogLabel'); + const subTextId: string = useId('subTextLabel'); + + const modalProps = React.useMemo( + () => ({ + titleAriaId: labelId, + subtitleAriaId: subTextId, + isBlocking: false, + styles: dialogStyles, + dragOptions: isDraggable ? dragOptions : undefined, + }), + [isDraggable, labelId, subTextId], + ); + + return ( + <> + + + + + + + + ); +}; + +const theme: ITheme = getTheme(); +const { palette, semanticColors, fonts } = theme; + +const classNames = mergeStyleSets({ + itemCell: [ + getFocusStyle(theme, { inset: -1 }), + { + minHeight: 54, + padding: 10, + boxSizing: 'border-box', + borderBottom: `1px solid ${semanticColors.bodyDivider}`, + display: 'flex', + selectors: { + '&:hover': { background: palette.neutralLight }, + }, + }, + ], + itemImage: { + flexShrink: 0, + }, + itemContent: { + marginLeft: 10, + overflow: 'hidden', + flexGrow: 1, + }, + itemName: [ + fonts.xLarge, + { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + ], + itemIndex: { + fontSize: fonts.small.fontSize, + color: palette.neutralTertiary, + marginBottom: 10, + }, + chevron: { + alignSelf: 'center', + marginLeft: 10, + color: palette.neutralTertiary, + fontSize: fonts.large.fontSize, + flexShrink: 0, + }, +}); - private _onItemInvoked = (item: IDetailsListBasicExampleItem): void => { - alert(`Item invoked: ${item.name}`); - }; -} +const onRenderCell = (item: IExampleItem, index: number | undefined): JSX.Element => { + return ( +
+ +
+
{item.name}
+
{`Item ${index}`}
+
{item.description}
+
+ +
+ ); +}; + +const ListBasicExample: React.FunctionComponent = () => { + const items = useConst(() => createListItems(2)); + + return ( + + + + ); +}; diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index a377bfa4538be3..181f8c7748ed02 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -447,10 +447,16 @@ export class List extends React.Component, IListState> public render(): JSX.Element | null { const { className, role = 'list', onRenderSurface, onRenderRoot } = this.props; - const { pages = [] } = this.state; + const { pages: statePages = [] } = this.state; const pageElements: JSX.Element[] = []; const divProps = getNativeProps>(this.props, divProperties); + let pages = statePages; + if (this.props.renderEarly && !this._hasCompletedFirstRender) { + const stuff = this._updatePages(this.props, this.state); + pages = stuff.pages || []; + } + for (const page of pages) { pageElements.push(this._renderPage(page)); } diff --git a/packages/react/src/components/List/List.types.ts b/packages/react/src/components/List/List.types.ts index d2b922663770d9..1dd07bd738a044 100644 --- a/packages/react/src/components/List/List.types.ts +++ b/packages/react/src/components/List/List.types.ts @@ -279,6 +279,15 @@ export interface IListProps extends React.HTMLAttributes | HTML * This is a performance optimization to let List skip a render cycle by not updating its scrolling state. */ ignoreScrollingState?: boolean; + + /** + * Whether to render the list earlier than the default. + * Use this in scenarios where the list is contained in a FocusZone or FocuseTrapZone + * as in a Dialog. + * + * @defaultvalue false + */ + renderEarly?: boolean; } /** From f1df5597dcb1e283d5b976f31b8c0329e40f71b6 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Fri, 21 Oct 2022 15:02:02 -0700 Subject: [PATCH 02/10] update List to always render 'early' This ensures list children are always rendered on the first React render pass which allows thems to work reliably with FocusZone. --- packages/react/src/components/List/List.tsx | 58 ++++++++++----------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index 181f8c7748ed02..fc9a59e066b47d 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -46,6 +46,7 @@ export interface IListState { getDerivedStateFromProps(nextProps: IListProps, previousState: IListState): IListState; pagesVersion?: {}; + hasMounted: boolean; } interface IPageCacheItem { @@ -118,7 +119,6 @@ export class List extends React.Component, IListState> }; private _focusedIndex: number; private _scrollElement?: HTMLElement; - private _hasCompletedFirstRender: boolean; // surface rect relative to window private _surfaceRect: IRectangle | undefined; @@ -159,6 +159,7 @@ export class List extends React.Component, IListState> pages: [], isScrolling: false, getDerivedStateFromProps: this._getDerivedStateFromProps, + hasMounted: false, }; this._async = new Async(this); @@ -336,7 +337,15 @@ export class List extends React.Component, IListState> public componentDidMount(): void { this._scrollElement = findScrollableParent(this._root.current) as HTMLElement; this._scrollTop = 0; - this.setState(this._updatePages(this.props, this.state)); + + if (!this.props.getPageHeight) { + const heightsChanged = this._updatePageMeasurements(this.state.pages!); + if (heightsChanged) { + this._materializedRect = null; + this.setState(this._updatePages(this.props, this.state)); + } + } + this._measureVersion++; this._events.on(window, 'resize', this._onAsyncResize); @@ -361,15 +370,9 @@ export class List extends React.Component, IListState> // If measured version is invalid since we've updated the DOM const heightsChanged = this._updatePageMeasurements(finalState.pages!); - // On first render, we should re-measure so that we don't get a visual glitch. if (heightsChanged) { this._materializedRect = null; - if (!this._hasCompletedFirstRender) { - this._hasCompletedFirstRender = true; - this.setState(this._updatePages(finalProps, finalState)); - } else { - this._onAsyncScroll(); - } + this._onAsyncScroll(); } else { // Enqueue an idle bump. this._onAsyncIdle(); @@ -447,16 +450,10 @@ export class List extends React.Component, IListState> public render(): JSX.Element | null { const { className, role = 'list', onRenderSurface, onRenderRoot } = this.props; - const { pages: statePages = [] } = this.state; + const { pages = [] } = this.state; const pageElements: JSX.Element[] = []; const divProps = getNativeProps>(this.props, divProperties); - let pages = statePages; - if (this.props.renderEarly && !this._hasCompletedFirstRender) { - const stuff = this._updatePages(this.props, this.state); - pages = stuff.pages || []; - } - for (const page of pages) { pageElements.push(this._renderPage(page)); } @@ -495,7 +492,8 @@ export class List extends React.Component, IListState> nextProps.items !== this.props.items || nextProps.renderCount !== this.props.renderCount || nextProps.startIndex !== this.props.startIndex || - nextProps.version !== this.props.version + nextProps.version !== this.props.version || + !previousState.hasMounted ) { // We have received new items so we want to make sure that initially we only render a single window to // fill the currently visible rect, and then later render additional windows. @@ -540,7 +538,7 @@ export class List extends React.Component, IListState> const pageElement = onRenderPage( { - page: page, + page, className: 'ms-List-page', key: page.key, ref: (newRef: unknown) => { @@ -558,8 +556,8 @@ export class List extends React.Component, IListState> // first 30 items did not change, we still re-rendered all of them in this props.items change. if (usePageCache && page.startIndex === 0) { this._pageCache[page.key] = { - page: page, - pageElement: pageElement, + page, + pageElement, }; } return pageElement; @@ -989,7 +987,7 @@ export class List extends React.Component, IListState> // console.log('materialized: ', materializedRect); return { ...state, - pages: pages, + pages, measureVersion: this._measureVersion, }; } @@ -1015,8 +1013,8 @@ export class List extends React.Component, IListState> const { height = this._getPageHeight(itemIndex, visibleRect, itemCount) } = pageData; return { - itemCount: itemCount, - height: height, + itemCount, + height, data: pageData.data, key: pageData.key, }; @@ -1024,7 +1022,7 @@ export class List extends React.Component, IListState> const itemCount = this._getItemCountForPage(itemIndex, visibleRect); return { - itemCount: itemCount, + itemCount, height: this._getPageHeight(itemIndex, visibleRect, itemCount), }; } @@ -1069,13 +1067,13 @@ export class List extends React.Component, IListState> return { key: pageKey, - startIndex: startIndex, + startIndex, itemCount: count, - items: items, - style: style, + items, + style, top: 0, height: 0, - data: data, + data, isSpacer: isSpacer || false, }; } @@ -1152,9 +1150,9 @@ function _expandRect(rect: IRectangle, pagesBefore: number, pagesAfter: number): const height = rect.height + (pagesBefore + pagesAfter) * rect.height; return { - top: top, + top, bottom: top + height, - height: height, + height, left: rect.left, right: rect.right, width: rect.width, From 5e4d48399ca8374f9700935b83407e622ddc35b4 Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Tue, 25 Oct 2022 10:00:50 -0700 Subject: [PATCH 03/10] remove experimental prop --- packages/react/src/components/List/List.types.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/react/src/components/List/List.types.ts b/packages/react/src/components/List/List.types.ts index 1dd07bd738a044..d2b922663770d9 100644 --- a/packages/react/src/components/List/List.types.ts +++ b/packages/react/src/components/List/List.types.ts @@ -279,15 +279,6 @@ export interface IListProps extends React.HTMLAttributes | HTML * This is a performance optimization to let List skip a render cycle by not updating its scrolling state. */ ignoreScrollingState?: boolean; - - /** - * Whether to render the list earlier than the default. - * Use this in scenarios where the list is contained in a FocusZone or FocuseTrapZone - * as in a Dialog. - * - * @defaultvalue false - */ - renderEarly?: boolean; } /** From 49e9cd7cfbd021db1fe181e714a4d899b83cf18e Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Tue, 25 Oct 2022 10:01:13 -0700 Subject: [PATCH 04/10] update hasMounted state --- packages/react/src/components/List/List.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index fc9a59e066b47d..9f2db32afd429e 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -335,6 +335,7 @@ export class List extends React.Component, IListState> } public componentDidMount(): void { + this.setState({ hasMounted: true }); this._scrollElement = findScrollableParent(this._root.current) as HTMLElement; this._scrollTop = 0; From e650d1fe1fb167ce935848386a5995700ccce18a Mon Sep 17 00:00:00 2001 From: Sean Monahan Date: Tue, 25 Oct 2022 10:01:24 -0700 Subject: [PATCH 05/10] update detailslist snapshots --- .../__snapshots__/DetailsList.test.tsx.snap | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/DetailsList/__snapshots__/DetailsList.test.tsx.snap b/packages/react/src/components/DetailsList/__snapshots__/DetailsList.test.tsx.snap index 6877ec9e47d902..7ba54d6080d5e6 100644 --- a/packages/react/src/components/DetailsList/__snapshots__/DetailsList.test.tsx.snap +++ b/packages/react/src/components/DetailsList/__snapshots__/DetailsList.test.tsx.snap @@ -8114,7 +8114,7 @@ exports[`DetailsList renders List with hidden checkboxes correctly 1`] = ` flex-shrink: 0; } data-automationid="DetailsRow" - data-focuszone-id="FocusZone9" + data-focuszone-id="FocusZone7" data-is-focusable={true} data-item-index={0} data-selection-index={0} @@ -8416,7 +8416,7 @@ exports[`DetailsList renders List with hidden checkboxes correctly 1`] = ` flex-shrink: 0; } data-automationid="DetailsRow" - data-focuszone-id="FocusZone11" + data-focuszone-id="FocusZone9" data-is-focusable={true} data-item-index={1} data-selection-index={1} @@ -8662,7 +8662,7 @@ exports[`DetailsList renders List with hidden checkboxes correctly 1`] = ` >