This component has been open source, the source can be seen: github.com/bytedance/g…

Background of the component

Both existing and new users need guidance when a new version of the product is released, a new feature is released, or an existing feature is updated. Function guide components are indicators in Internet products. They are designed to guide users around the product and help them familiarize themselves with new interfaces, interactions and functions. Different from FAQs, product introduction videos, user manuals, and UI component help information, the function guide component is integrated with the PRODUCT UI, which will not give users a fragmented interactive feeling, and will be displayed in front of users without users’ initiative to trigger operations.

Pictures are more concrete than words. Here are two typical bootstrap components for beginners.

Function introduction

Step by step guide

The Guide component is a step-by-step Guide that takes the user from start to finish, section by section, like a guidepost. This kind of guidance is suitable for new features with long interaction flows or products with complex interfaces. It takes the user through the full operational link and quickly understands the location of each function point.

presentation

Montmorillonite layer model

As the name implies, mask guidance refers to the use of a translucent black mask on the product, the interface is highlighted above the mask, accompanied by a pop-up window to explain. This mode of guidance blocks the interaction between the user and the interface, so that the user’s attention is focused on the function points of the annotations, not being interfered by other elements.

Popup window mode

In many cases, we don’t want to use masks in order not to disturb the user. At this point, we can use unmasked mode, where a simple window boot pops up next to the function point.

Accurate positioning

The initial position

Guide provides 12 alignments to load popover guides onto selected elements. At the same time, it also allows you to customize the horizontal and vertical deviation value and adjust the position of the popover. The following images show popovers positioned as top-left and right-bottom respectively:

And when the user zooms or scrolls the page, the popover location is still accurate.

Automatic rolling

In many cases, several distant page elements need to be functional and connected to form a complete guide path. When the next feature to be circled is not in the user’s view, the Guide automatically scrolls the page to the appropriate location and pops up a Guide window.

keyboard

When the Guide Guide component pops up, we want the user’s attention to be fully engaged. To make the Guide visible to the user using the assistive reader, we moved the focus of the page to the popover and focused every single readable element in the popover. At the same time, the user can use the keyboard (TAB or TAB + Shift) to focus on the popover in sequence, or press the Escape key to exit the boot.

In the following figure, the user moves the focus in the popover with the TAB key. The focused elements are marked by dotted boxes. When focused on the “Next” button, press shift to jump to the next boot.

The technical implementation

The overall process

We will judge whether the component is expired before showing the steps of the component. There are two criteria for judging whether the component is expired: one is whether the unique key stored in localStorage of the boot component is true, which means that the step of the component is completed. The second is that the component receives a props. ExpireDate. If the current time is greater than expireDate, the component is expired and will not be displayed.

If the component is not expired, the corresponding content of the props. Steps passed is displayed. The steps structure is as follows:

interface Step {
    selector: string;
    title: string;
    content: React.Element | string;
    placement: 'top' | 'bottom' | 'left' | 'right'
        | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'.offset: Record<'top' | 'bottom' | 'left' | 'right', number>
}

const steps = Step[]
Copy the code

Get the highlighted element according to step. Selector, and then use step. Placement to pop up to the specific location of the highlighted element. Click next to display the next step in sequence. When all steps are displayed, we will store the bootstrap component in localStorage and set the unique key to true, which will not be displayed next time.

Let’s look at the bootstrap component implementation in detail.

Montmorillonite layer model

The current boot component supports two modes with or without a mask. The display effect with a mask is shown in the following figure.

A mask is easy to implement, just a div that fills the screen, but how do we get it to highlight the selector in the middle and also support rounded corners? 🤔, the truth is only one, that is — border-width

We take the selector element’s offsetTop, offsetRight, offsetBottom, offsetLeft, and set it to the corresponding border-width of the highlighted box, and set the border-color to gray, A mask with a highlight box is implemented! Add a pseudo-element :: After to the highlight div to give it border-radius, perfect!

Popover positioning

When users use a Guide, they pass in information about steps, each of which includes a CSS selector for the interface element to be guided to. We call the elements we want to annotate “anchor elements.” Guide needs to accurately position popovers based on the location information of anchor elements.

Each HTML element has a read-only attribute offsetParent that points to the nearest location element that contains the element or to the nearest table, TD,th, or body element. Each element is positioned according to its offsetParent element. For example, an absolute element is offset based on its nearest, non-static parent, which is its offsetParent.

So we came up with the idea of putting the popover element in the anchor element’s offsetParent and adjusting its position. Meanwhile, in order to prevent other elements in the anchor element offsetParent from being displaced, we set the popover element to absolute.

Location steps

The positioning calculation process of popover is roughly as follows:

Step 1. Get the anchor element

Using the selector in the step information passed to Guide, which is a CSS selector, we can get the anchor element from the following code:

const anchor = document.querySelector(selector);
Copy the code

How to get anchor’s offsetParent? This step is not as easy as you might think. Let’s talk about this step in detail.

Step 2. Obtain offsetParent

Typically, getting the offsetParent of an anchor element is a simple line of code:

const parent = anchor.offsetParent;
Copy the code

However, this line of code does not cover all scenarios, and we need to consider some special cases.

Scenario 1: Anchor element is fixed

Not all HTMLElements have the offsetParent attribute. When the anchor element is fixed, its offsetParent returns NULL. In this case, we need to use its containing block instead of offsetParent.

What is the containing block? In most cases, the contain block is the content area of the element’s nearest ancestor block element, but not always. An element’s contain block is determined by its position property.

  • If the position property isfixed, containing blocks are usuallydocument.documentElement.
  • If the position attribute is fixed, the include block may also consist of the edge of the inner margin area of the nearest parent element that satisfies the following conditions:

    • transform 或 perspectiveThe value is notnone
    • will-changeThe value istransform 或 perspective
    • filterThe value is notnone 或 will-changeThe value isfilter(Valid only in Firefox).
    • containThe value ispaint(example: contain: paint;)

Therefore, we can start with the anchor element and recursively look up for a parent element that meets the above criteria, and return document.documentElement if none is found.

Here is the code in Guide to find contained blocks:

const getContainingBlock = node= > {
  let currentNode = getDocument(node).documentElement;

  while( isHTMLElement(currentNode) && ! ['html'.'body'].includes(getNodeName(currentNode))
  ) {
    const css = getComputedStyle(currentNode);

    if( css.transform ! = ='none'|| css.perspective ! = ='none'|| (css.willChange && css.willChange ! = ='auto')) {return currentNode;
    }
    currentNode = currentNode.parentNode;
  }

  return currentNode;
};
Copy the code
Scenario 2: Use Guide in iframe

In Guide code, we often use the Window object. For example, we need to call getComputedStyle() on the Window object to get the style of the element, and we need the Window object as the pocket of the offsetParent element. But we can’t use the Window object directly. Why? At this point, we need to consider the iframe case.

Imagine if we use a Guide component in an application with an embedded iframe, the Guide component code is outside the iframe, and the guided function points are inside the iframe. The method provided by using the Window object is, We must want to make the call on the Window object where the circled function point is, not the Window where the current code is running.

Therefore, we use the following getWindow method to ensure that we get the Window of the node argument.

// Get the window object using this function rather then simply use `window` because
// there are cases where the window object we are seeking to reference is not in
// the same window scope as the code we are running. (https://stackoverflow.com/a/37638629)
const getWindow = node= > {
  // if node is not the window object
  if(node.toString() ! = ='[object Window]') {
    // get the top-level document object of the node, or null if node is a document.
    const { ownerDocument } = node;
    // get the window object associated with the document, or null if none is available.
    return ownerDocument ? ownerDocument.defaultView || window : window;
  }

  return node;
};
Copy the code

On line 8, we see a property called ownerDocument. If Node is a DOM Element, it has a property called ownerDocument, which returns a Document object that is the master object to which all child nodes in the actual HTML document belong. If you use this property on the document node itself, the result is NULL. When node is a Window object, we return Window; When the node is the Document object, we returned to the ownerDocument. DefaultView. Thus, the getWindow function covers all possibilities for the node parameter.

Step 3. Mount the pop-up window

As shown in the following code, A common usage scenario is to render A Guide in component A so that the elements it annotates are in component B and component C.

 / / component A
 const A = props= > (
    <>
        <Guide
            steps={[
                {
                    .
                    selector: '#btn1'}, {.
                    selector: '#btn2'}, {.
                    selector: '#btn3'}}] / >
        <button id="btn1">Button 1</button>
    </>
)
Copy the code

/ / component B
const B = props= > (<button id="btn2">Button 2</button>)
Copy the code

C / / component
const C = props= > (<button id="btn3">Button 3</button>)
Copy the code

In the above code, the Guide will be rendered naturally in the DOM structure of A component. How do we mount it in the offsetParent structure of B and C components? It’s time to introduce the powerful but little-known React Portals.

React Portals

When we need to render a component outside the DOM tree of its parent, we should first consider using React Portals. Portals are best used for scenarios where a child node needs to be visually rendered outside of its parent node. We can also see Portal in Antd’s Modal, Popover, and Tooltip component implementations.

We use reactdom.createPortal (child, container) to create a Portal. Child is the component to be mounted, and Container is the container component to which child is mounted.

Although Portal is rendered outside the DOM structure of its parent element, it does not create a completely separate React DOM tree. A Portal, like any other child node in the React tree, can get props and context from the parent component, and can bubble events.

Also, unlike the ReactDOM tree created by reactdom.render, reactDom.createPortal is applied to the render function of the component, so manual uninstallation is not required.

In Guide, with each jump, the previous popover is unloaded, and the new popover is loaded into the offsetParent of the element circled for that step. The pseudocode is as follows:

const Modal = props= > (
    ReactDOM.createPortal(
        <div>.</div>,
    offsetParent);
)
Copy the code

After rendering the popover into offsetParent, the next step in Guide is to calculate the offset of the popover relative to offsetParent. This step is very complicated, and some special cases need to be considered. Let’s take a closer look at this calculation.

Step 4. Calculate the offset

Take a placement = left, a popover boot that needs to be displayed to the left of a function point. If we mounted the popover directly to the anchor element’s offsetParent via React Portal and gave it an absolute position, its position would look like this — the upper left corner is aligned with the upper left corner of the offsetParent element.

In the following figure, the koala picture represented by blue box is the element to be marked in Guide, namely the anchor element. The red box identifies the offsetParent element of the anchor element.

The expected positioning results are as follows:

To move the popover from its original position to the desired position, we need to move the moving window down on the Y-axis offsetTop + h1/2-H2/px. Where h1 is the height of the anchor element, h2 is the height of the popover.

However, the above calculation still ignores a scenario where the anchor element is positioned as fixed. If the anchor element is positioned as fixed, its position relative to the screen viewport is fixed no matter how the interface on which the anchor element is located slides. Naturally, the popover used to guide the fixed anchor element also needs to have these properties, i.e. it also needs to position the fixed.

Arrow implementation and positioning

Arrow is a child element of Modal and is absolutely positioned relative to Modal. As shown in the figure below, there are twelve display positions. We divide the twelve positions into two types:

  1. The four centers of purple;
  2. The remaining eight bevel angles are yellow.

In the first case

The arrow is always centered relative to the popover edge. For top and bottom, the right value of the arrow is always (modal.width-arrow.diagonalWidth)/2. The top or bottom value is always -arrow.diagonalWidth/2.

For left and right, the top value of the arrow is (modal.height-arrow. diagonalWidth)/2, while left or right is -arrow.diagonalWidth/2.

Note:diagonalWidthIs the diagonal width,getReversePosition\(placement\)To get the reverse position of the passed argument, top corresponds to bottom and left corresponds to right.

The pseudocode is as follows:

const placement = 'top' | 'bottom' | 'left' | 'right';
const diagonalWidth = 10;

const style = {
  right: ['bottom'.'top'].includes(placement)
    ? (modal.width - diagonalWidth) / 2
    : ' '.top: ['left'.'right'].includes(placement)
    ? (modal.height - diagonalWidth) / 2
    : ' ',
    [getReversePosition(placement)]: -diagonalWidth / 2};Copy the code

For the second case

For the position a-B, it can be seen from the following figure that the displacement of B is always fixed. For example, for a popover with a placement value of top-left, the arrow left value is always fixed, while the bottom value is -arrow.diagonalWidth/2.

The following is pseudocode:

const [firstPlacement, lastPlacement] = placement.split(The '-');
const diagonalWidth = 10;
const margin = 24;

const style =  {
    [lastPlacement]: margin,
    [getReversePosition(placement)]: -diagonalWidth / 2,}Copy the code

Hotspot implementation and positioning

The bootstrap component supports hotspot, which animates a div element to change its box-shadow size to look like a breathing light, as shown in the image below, where the location of the hotspot is calculated relative to the position of the arrow, so I won’t go into details here.

conclusion

At the beginning of the development of Guide, we did not think that such a widget would need to take these technical points into account. It can be seen that it is difficult to make a small component suitable for all scenarios and make it universal enough, which requires constant trial and reflection.

Recruitment hard wide

Our team is hiring!!

Welcome to join bytedance’s commercial realization front end team. The technical construction we are doing includes: Front-end engineering system upgrade, team Node infrastructure construction, the front-end one-click CI publishing tools, support of service components, the internationalization of the front-end general micro front-end solutions, heavy reliance on business system reform, visual page structures, systems, business intelligence (BI, front end test automation and so on, has the nearly hundred people of the north grand front team, There is bound to be an area of interest for you!

If you want to join us, please click on our inpush channel:

✨ ✨ ✨ ✨ ✨

WAU8ZHR: WAU8ZHR

If you want to know about our department’s daily life and work environment,

You can also click to read the original article about oh ~

✨ ✨ ✨ ✨ ✨