preface

Select is one of the most frequently used UI components and can be used in many scenarios. In most cases, the native HTML tag behaves differently across browser versions. In such cases, most people will choose to implement a Select component that meets the UI requirements and product functionality requirements, or choose to use Select components provided by some open source component libraries. This paper mainly combs the Select component in GIO-design in the implementation process encountered some obstacles and need to pay attention to the place, hoping to provide some help to everyone in the design and implementation of Select component.

Data sources (dataSource)

There are two ways to use the Select component:

/ / the first writing const options = [{value: label: 'a', 'a'}, {value: label: 'b', 'b}]. <Select options={options} /> // second <Select> < select. Option value={'a'} >a</Select.Option> < select. Option value={'b'} >b</Select.Option> </Select>Copy the code

When using the Select component, there are generally two ways to set the dataSource.

  1. throughoptionsParameter, passed in a pure data format.
  2. JSXBy intercepting child components<Select.Option/>Is converted tonodeOptions. Compared withJSXIn terms ofoptionsParameter forms have better performance (JSXThe pattern will eventually turn similaroptionsParameter form).

This conversion is modeled after the rC-SELECT notation.

export function convertChildrenToData(nodes: React.ReactNode, group = {}): Option[] { let nodeOptions: Option[] = []; React.Children.forEach(nodes, (node: React.ReactElement & { type: { isSelectOptGroup? : boolean }; props: OptionProps }) => { if (! React.isValidElement(node)) return; const { type: { isSelectOptGroup }, props: { children, label, value }, } = node; if (! isSelectOptGroup) { // option nodeOptions.push(convertNodeToOption(node, group)); } else { // Group nodeOptions = concat(nodeOptions, convertChildrenToData(children, { groupLabel: label, groupValue: value })); }}); return nodeOptions; } // ReactNode To Options export function convertNodeToOption(node: React.ReactElement, group: group): Option { const { props: { value, children, ... restProps }, } = node as React.ReactElement & { props: OptionProps }; const { groupValue, groupLabel } = group; if (groupLabel && groupLabel) { return { value, label: children ! == undefined ? children : value, groupValue, groupLabel, ... restProps }; } return { value, label: children ! == undefined ? children : value, ... restProps }; }Copy the code

Group and Option definitions:

// group

export interface OptionGroupFC extends React.FC<OptGroupProps> {
  isSelectOptGroup: boolean;
}
export const OptGroup: OptionGroupFC = () => null;

OptGroup.isSelectOptGroup = true;

export default OptGroup;

// option

export interface OptionFC extends React.FC<OptionProps> {
  isSelectOption: boolean;
}
const Option: OptionFC = () => null;

Option.isSelectOption = true;

export default Option;
Copy the code

The above two methods are also quite clear, using isSelectOptGroup to distinguish groups and options. Group adds groupLabel and groupValue keys to the existing Option parameters.

When the two methods of passing arguments are mixed, they are parsed and merged into the complete options available to the List component. During the merging of options and nodeOptions, a C ‘ ‘acheOptions is generated.

CacheOptions is an object used to cache value and option mappings. (default: When value does not change, that is, the corresponding option is not changed), GetOptionByValue (using value to obtain option) and getOptionsByValue (using _value[]_ to obtain option[]) are provided.

In addition to these two methods, there is another way to manually enter the option, like this:

In scenarios where custom input is allowed, add a new option if the user input does not match the existing option.

const extendedOptions = () => {
  const result: Option[] = [];

  if (Array.isArray(value) && allowCustomOption) {
    value.forEach((v) => {
      const op = getOptionByValue(v);
      if (!op) {
        result.push(CustomOption(v, hasGroup, customOptionKey));
      }
    });
  }
  return [...mergedFlattenOPtions, ...result];
};
Copy the code

Mysql > select * from ‘dataSource’; mysql > select * from ‘dataSource’; mysql > select * from ‘dataSource’

const options = [
    {label:'a',value:'a',groupLabel:'A',groupValue:'A'}
    {label:'b',value:'b',groupLabel:'B',groupValue:'B'}
    {label:'aa',value:'aa',groupLabel:'A',groupValue:'A'}
]
Copy the code

The overall process looks something like this:

 options and nodeOptions -> cacheOptions -> extendedOptions -> filterOptions -> sortedOptions
Copy the code

The flow of the entire data is relatively clear. Data is processed in batches, such as data merge, search, filtering, sorting, etc., and each step is processed into a separate logic according to the requirements of components, which can better control the content processed by each layer of logic. Do not encapsulate some irrelevant logic together, making the whole process become bloated and difficult to deal with in the future expansion. After processing the dataSource, we can pass the data to the List component.

Value (the value and tempValue)

After the Select component has been formed and the overall logic has been designed, due to the particularity of some business scenarios, the Select component needs to support an additional useFooter method. After useFooter is enabled, the ok and cancel buttons will appear by default, as shown in the figure:

When clicking Ok, the onChange method is triggered. When clicking Cancel, the selected option needs to be cancelled (but the last selected result cannot be affected). When clicking a blank area of the page to close the drop-down menu, the logic is the same as that of canceling. In this case, how do we deal with a situation similar to the pre-selection without changing the structure as much as possible?

A new tempValue can be added to support pre-selected cases to distinguish pre-selected from selected cases.

  • valueCorresponds to the selected option
  • tempValueCorresponds to the option in the preselection
  • selectorValueThis corresponds to the options presented.

Every time an option is selected, the option is added to tempValue. When de-selecting an option, add it to tempValue if it exists in value (if both value and tempValue exist to indicate that the selected option is de-selected in the current instance), or remove it from tempValue if it does not exist. When confirming, merge tempValue with _value_, remove the existing options in both tempValue and value, and generate a new value. To cancel, simply reset the tempValue to an empty array.

value

tempValue

selectorValue

There are

There is no

show

There is no

There are

show

There are

There are

Don’t show

const selectorValue = () => { if (Array.isArray(value)) { // filter: if v in value and tempValue return value.concat(tempValue).filter((v) => ! value.includes(v) || ! tempValue.includes(v)); } if (multiple) { return tempValue; } return value; }Copy the code

In this way, we just add a tempValue (and the corresponding select logic) and a selectorValue to achieve the goal. We do not modify the original value logic and do the pre-selection with a small cost.

Portal

Buggy Select: When rendering selector and dropdown to the same level, one of two things can happen under certain circumstances:

  1. When the dropdown is expanded, scroll bars appear inside the parent container.

  1. Part or all of the dropdown is obscured by the parent element.

First, when the Dropdown is expanded, the Dropdown position should not affect other component positions. Second, other components should not affect the Dorpdown presentation. So how do we show Dropdown? The first thing that comes to mind is to use position to address the impact of Dropdown on other components within the component. Once out of the normal document flow, the location of other components is not affected. How do you resolve the parent component’s impact on Dropdown? The dropdown can be rendered outside of the parent so that the parent cannot affect the dropdown. You can use the React Portal to implement a similar Portal.

React introduces Portal like this:

Portal provides an excellent solution for rendering child nodes to DOM nodes that exist outside the parent component.

In this case, the dropdown part of the Select component is rendered to the body by default to avoid the parent component’s influence on it. Not only is the dropdown rendered to _body_ over and done, but we also need to calculate the relative position of the dropdown when scrollView, resize, etc. Take a look at some of the source code for the RC-Trigger, which helps us implement some of the lower level relative position calculations and the React official Portal.

// Rc-trigger removes some unnecessary code // If the getPopupContainer method is passed in, the dom is mounted using the getPopupContainer method, if not, the body is mounted by default. attachParent = (popupContainer: HTMLDivElement) => { const { getPopupContainer, getDocument } = this.props; const domNode = this.getRootDomNode(); let mountNode: HTMLElement; if (! getPopupContainer) { mountNode = getDocument(this.getRootDomNode()).body; } else if (domNode || getPopupContainer.length === 0) { mountNode = getPopupContainer(domNode); } if (mountNode) { mountNode.appendChild(popupContainer); }}; GetContainer = () => {const {getDocument} = this.props; const popupContainer = getDocument(this.getRootDomNode()).createElement('div',); popupContainer.style.position = 'absolute'; popupContainer.style.top = '0'; popupContainer.style.left = '0'; popupContainer.style.width = '100%'; this.attachParent(popupContainer); return popupContainer; }; let portal: React.ReactElement; If (popupVisible | | this. PopupRef. Current | | forceRender) = {portal (< PortalComponent PortalComponent / / here GetContainer ={this.getContainer} didUpdate={this.handlePortalUpdate} > {this.getComponent()} </PortalComponent> ); } return ( <TriggerContext.Provider value={this.triggerContextValue}> {trigger} {portal} </TriggerContext.Provider> ); }Copy the code

The RC-trigger ultimately renders a trigger (selector) and portal(dropdown). By default (when the getPopupContainer method is not passed), it is mounted to the body by default. In most cases, the Select component is static relative to the page. When we mount it to the body using the absolute positioning layout, we just calculate the position of the trigger(selector). The location of the portal(dropdown) can be determined by the location of the trigger(selector).

Rc-trigger, RC-util, rC-util, rC-util, rC-util, rC-util, rC-util

Keyboard interaction

Keyboard interaction can be described as a relatively complex design, which is also for the keyboard events of multiple versions, to achieve this effect.

Before keyboard interaction events are taken into account, elements such as selection, focus, and hover are left to the browser to handle, but Select is a virtual composite consisting of selector and dropdown. After custom keyboard events are taken into account, You need the components inside to simulate the effects of selecting, focusing, and levitating elements provided by the browser. Note that we used Portal to render selector and DorpDown to different DOM hierarchies. React’s official documentation says:

When working with Portal, it becomes important to remember to manage keyboard focus.

Tab-switches focus based on the order of DOM elements. The default drop-down List is render in body. When the focus state is transferred to the List, Tab switching can cause focus loss (skipping the original focus switching order, making the focus order look different from the normal performance). So, when the List loses focus, you need to transfer focus back to the selector before executing onBlur().

virtualList

Virtual lists are certainly one of the things that every list-related component needs to consider. In most cases, the source of data is a few requests from the server, which can be 1000 or 10. When the amount of data is too large, virtual lists can be considered for optimization.

A brief explanation of the logic of a virtual list is that it simply renders the viewable area, constantly changing the elements in the viewable area as the scrolling height changes. There are a lot of great articles on virtual list implementation on the web, but I won’t expand on them here, and will focus on some special cases.

In the selection of library, we use the RC-virtual-list library. Compared with other virtual list libraries, this library has the advantages of small size and few parameters (passing few parameters can achieve the purpose). Our Select component supports custom optionRender, and the RC-virtual-list automatically calculates the height of each Item. Each Item needs to be wrapped with the react.forwardref (). Get the true height of an Item automatically from the rc-virtual-list.

export default function useChildren<T>( list: T[], startIndex: number, endIndex: number, setNodeRef: (item: T, element: HTMLElement) => void, renderFunc: RenderFunc<T>, { getKey }: SharedConfig<T>, ) { return list.slice(startIndex, endIndex + 1).map((item, index) => { const eleIndex = startIndex + index; const node = renderFunc(item, eleIndex, {}) as React.ReactElement; const key = getKey(item); Return (<Item key={key} setRef={ele => setNodeRef(Item, ele)}> // setInstanceRef {node} </Item>); }); } const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig);Copy the code

ListChildren is used to generate Data in the Start- End range. The

component takes the react.cloneElement method to create the Item, which sets the ref to the current data alone (which is why we use the react.forwardref ()) and fires the setInstanceRef method.

function setInstanceRef(item: T, instance: HTMLElement) { const key = getKey(item); const origin = instanceRef.current.get(key); if (instance) { instanceRef.current.set(key, instance); collectHeight(); } else { instanceRef.current.delete(key); } // Instance changed if (! origin ! = =! instance) { if (instance) { onItemAdd? .(item); } else { onItemRemove? .(item); }}}Copy the code

Each time setInstanceRef executes, the current item is stored and the collectHeight method is emitted, which is emitted multiple times, but only for currentId === HeightupDateref.current.

  function collectHeight() {
    heightUpdateIdRef.current += 1;
    const currentId = heightUpdateIdRef.current;

    Promise.resolve().then(() => {
      // Only collect when it's latest call
      if (currentId !== heightUpdateIdRef.current) return;

      instanceRef.current.forEach((element, key) => {
        if (element && element.offsetParent) {
          const htmlElement = findDOMNode<HTMLElement>(element);
          const { offsetHeight } = htmlElement;
          if (heightsRef.current.get(key) !== offsetHeight) {
            heightsRef.current.set(key, htmlElement.offsetHeight);
          }
        }
      });
    });
  }
Copy the code

Traverse the current instanceRef to check the offsetHeight of each key, if heightsref.current.get (key)! == offsetHeight updates the height of the current key.

The last

When we design a component, the first thing we need to confirm is the parameter design. For an open source component, the frequent modification of parameters is very painful for users, so the parameter design is particularly important. When reconstructing the code logic, it is necessary to ensure that the existing parameter function is not lost, and to reconstruct the code based on the existing situation, and try not to repeatedly delete parameters. Select isn’t complicated to implement, but it’s not simple either, and as one of the most frequently used components, there’s a lot of detail, extra functionality to implement. Reasonable control of requirements and functions can make components more robust, rather than adding parameters blindly, which makes components become bloated and gradually impossible to maintain.

reference

  1. rc-trigger
  2. rc-util
  3. rc-virtual-list
  4. ant Design