reactjstypescriptoffice-ui-fabric

Office UI Fabric TextField focus/cursor issues


I have a CodePen that illustrates the issue here: https://codepen.io/elegault/pen/QzZwLO

Scenario: A DetailsList component and a search box (the TextField component). The list items can be filtered as the user types in the search box. Any selected project will still be selected in the search results IF it is in the search results. If it is not in the search results and a subsequent search does include that selection, it will be re-selected. (NOTE: the Office UI Fabric team seems aware that this should be handled natively, but I'm not sure of the plans to add this functionality, as per this GitHub issue).

Problem: after every key press the focus is lost, which makes entering and editing the search criteria difficult as the user has to reinsert the cursor every time.

What's not working: calling focus() on the TextField while it is already focused (isFocused = true) does nothing. Calling focus() only works when isFocused = false. But this is only true when DetailsList.focusIndex() is called after a selection is restored in a filtered list.

Pseudo-code:

componentDidUpdate(previousProps: any, previousState: AppProjectListState) {
  //Toggle off the current selection
  this._selection.toggleIndexSelected(currentIdx);
  //Set the new selection
  this._selection.toggleIndexSelected(newIdx);
  //Scroll the selection into view
  this._detailsListRef.current.focusIndex(newIdx, false);
}

Is this a bug of some sort in either the TextField or DetailsList component? Or with the way I'm doing this in the React component life cycle? Or is there a way to ensure that focus is not lost from the TextField while the user is typing and the list items are being recalculated and while the selected index is modified?


Solution

  • I recently stumbled upon on a similar feature request and came up with the following solution which allows to preserve the selection in DetailsList while the data is getting filtered.

    First a separate component is introduced which implements the logic to preserve the selection:

    export interface IViewSelection {}
    
    export interface IViewSelectionProps
      extends React.HTMLAttributes<HTMLDivElement> {
      componentRef?: IRefObject<IViewSelection>;
    
      /**
       * The selection object to interact with when updating selection changes.
       */
      selection: ISelection;
    
      items: any[];
    }
    
    export interface IViewSelectionState {}
    
    export class ViewSelection extends BaseComponent<
      IViewSelectionProps,
      IViewSelectionState
    > {
      private items: any[];
      private selectedIndices: any[];
      constructor(props: IViewSelectionProps) {
        super(props);
        this.state = {};
        this.items = this.props.items;
        this.selectedIndices = [];
      }
    
      public render() {
        const { children } = this.props;
        return <div>{children}</div>;
      }
    
      public componentWillUpdate(
        nextProps: IViewSelectionProps,
        nextState: IViewSelectionState
      ) {
        this.saveSelection();
      }
    
      public componentDidUpdate(
        prevProps: IViewSelectionProps,
        prevState: IViewSelectionState
      ) {
        this.restoreSelection();
      }
    
      private toListIndex(index: number) {
        const viewItems = this.props.selection.getItems();
        const viewItem = viewItems[index];
        return this.items.findIndex(listItem => listItem === viewItem);
      }
    
      private toViewIndex(index: number) {
        const listItem = this.items[index];
        const viewIndex = this.props.selection
          .getItems()
          .findIndex(viewItem => viewItem === listItem);
        return viewIndex;
      }
    
      private saveSelection(): void {
        const newIndices = this.props.selection
          .getSelectedIndices()
          .map(index => this.toListIndex(index))
          .filter(index => this.selectedIndices.indexOf(index) === -1);
    
        const unselectedIndices = this.props.selection
          .getItems()
          .map((item, index) => index)
          .filter(index => this.props.selection.isIndexSelected(index) === false)
          .map(index => this.toListIndex(index));
    
        this.selectedIndices = this.selectedIndices.filter(
          index => unselectedIndices.indexOf(index) === -1
        );
        this.selectedIndices = [...this.selectedIndices, ...newIndices];
      }
    
      private restoreSelection(): void {
        const indices = this.selectedIndices
          .map(index => this.toViewIndex(index))
          .filter(index => index !== -1);
        for (const index of indices) {
          this.props.selection.setIndexSelected(index, true, false);
        }
      }
    }
    

    Now DetailsList component needs to be wrapped with ViewSelection component to save and restore the selection while filtering is applied:

    const items = generateItems(20);
    
    export default class DetailsListBasicExample extends React.Component<
      {},
      {
        viewItems: any[];
      }
    > {
      private selection: Selection;
      private detailsList = React.createRef<IDetailsList>();
    
      constructor(props: {}) {
        super(props);
    
        this.selection = new Selection({
        });
        this.state = {
          viewItems: items
        };
        this.handleChange = this.handleChange.bind(this);
      }
    
      public render(): JSX.Element {
        return (
          <div>
            <TextField label="Filter by name:" onChange={this.handleChange} />
            <ViewSelection selection={this.selection} items={this.state.viewItems} >
              <DetailsList
                componentRef={this.detailsList}
                items={this.state.viewItems}
                columns={columns}
                setKey="set"
                layoutMode={DetailsListLayoutMode.fixedColumns}
                selection={this.selection}
                selectionMode={SelectionMode.multiple}
                selectionPreservedOnEmptyClick={true}
              />
            </ViewSelection>
          </div>
        );
      }
    
      private handleChange = (
        ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
        text: string
      ): void => {
        const viewItems = text
          ? items.filter(item => item.name.toLowerCase().indexOf(text.toLocaleLowerCase()) > -1)
          : items;
        this.setState({ viewItems });
      };
    }
    

    Here is a demo