ClickOutside

ClickOutside is commonly used in various pop-ups on a page, such as a drop-down window, a Modal dialog box on a page, when the user clicks on an area other than the pop-up layer to close the current popup. React is a useClickOutside(ref, close) hook. In Vue, it is a v-click-outside=”close” directive. In React, it is a useClickOutside(ref, close) hook. ClickOutside’s implementation can be as simple as:

<body>
    <div class="modal">Modal</div>
    <script>
        const model = document.querySelector('.modal');
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        window.addEventListener('click'.(event) = > {
            if(! model.contains(event.target)) { toggle(false); }});</script>
</body>
Copy the code

Determine if the clicked element is in the popup container, and if not, close the shell. This works fine in most situations, but it’s not perfect and can be problematic in some special situations.

Iframe event listening

Let’s start with an example of an iframe:

<body>
    <div class="modal">Modal</div>
    <iframe width="300" height="200"></iframe>
    <script>
        const model = document.querySelector('.modal');
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        window.addEventListener('click'.(event) = > {
            if(! model.contains(event.target)) { toggle(false); }});</script>
</body>
Copy the code

If the shell contains an iframe, the user cannot hear the click event on the window when clicking the iframe. Also some solution, of course, if the iframe page no cross-domain use iframe. ContentWindow. AddEventListener to listen; Cross-domain pages can be notified via postMessage to the parent window…… if they are controllable These methods are feasible but not universally applicable.

Keyboard accessibility

Another example is the problem with keyboard controls:

<body>
    <div class="modal">
        <input type="text" />
    </div>
    <input type="text" />
    <script>
        const model = document.querySelector('.modal');
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        window.addEventListener('click'.(event) = > {
            if(! model.contains(event.target)) { toggle(false); }});</script>
</body>
Copy the code

There are input elements inside and outside the popover, and you can use the keyboard Tab to switch input. When the input box is switched from inside the popover to outside, it is a normal behavior to close the popover, and the click event cannot handle the switch focus element of the keyboard Tab.

This leads to the issue of keyboard accessibility. We may not pay much attention to accessibility implementation in everyday functions, but many times we just need to add a few lines of code to make the application accessible. Take a look at the React accessibility guide, which also provides a solution to this problem by replacing click’s event listener with focus management.

FocusOutside

The focus of management

Focus management simply means that the target element has focus and blur states and can trigger the behavior of the corresponding states. Take popover for example, focus is the state of popover opening, while blur is the state of popover closing. We can follow this idea to realize popover opening and closing.

model.addEventListener('focus'.() = > { console.log('focus'); });
model.addEventListener('blur'.() = > { console.log('blur'); });
Copy the code

Implementation is also simple, listen to focus and blur events can be.

But directly listening for focus and blur events does not work, because ordinary elements do not support focus events, and you need to set the tabIndex attribute for the element to support focus events.

It accepts an integer as a value, with different results depending on the value of the integer:

  • Tabindex = negative (usually tabIndex = “-1”), indicating that the element is focusable but cannot be queried by keyboard navigation, which is useful when using JS for internal keyboard navigation of page widgets.
  • tabindex="0", indicating that the element is focusable and can be focused to by keyboard navigation, and its relative order is determined by the DOM structure in which it is currently located.
  • Tabindex = positive, indicating that the element is focusable and can be queried by keyboard navigation; Its relative order increases with tabIndex and lags in focusing. If multiple elements have the same TabIndex, their relative order is determined by their order in the current DOM.
model.setAttribute('tabIndex'.'1');
Copy the code

Components such as popovers are usually triggered by button clicks. Use “-1” to avoid being triggered directly by the keyboard. We can implement a focusOutside based focus management

<body>
    <div class="modal"></div>
    <input type="text" />
    <iframe></iframe>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex'.'1');
        // Get focus automatically when the popover opens
        setTimeout(() = > {
            model.focus();
        });
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        let blurTimer = null;
        function onFocus(event) {
            clearTimeout(blurTimer);
        }
        function onBlur(event) {
            blurTimer = setTimeout(() = > {
                toggle(false);
            });
        }
        model.addEventListener('focus', onFocus);
        model.addEventListener('blur', onBlur);
    </script>
</body>
Copy the code

Click on iframe and Tab to close the popover. But why emphasize popover here, because the focus management inside popover still needs to be handled.

The event bubbling

Generally, there are various components inside the popover. If there is an element that can be focused, such as the input element, the focus switch behavior will also occur inside the popover. Here is an example:

<body>
    <div class="modal">
        <! Popover contains input elements inside -->
        <input type="text" />
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex'.0);
        // Get focus automatically when the popover opens
        setTimeout(() = > {
            model.focus();
        });
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        let blurTimer = null;
        function onFocus(event) {
            clearTimeout(blurTimer);
        }
        function onBlur(event) {
            blurTimer = setTimeout(() = > {
                toggle(false);
            });
        }
        model.addEventListener('focus', onFocus);
        model.addEventListener('blur', onBlur);
    </script>
</body>
Copy the code

When the focus element inside the popover element is activated, the focus is switched from the container to the child element, causing the container to lose focus. This is counter-intuitive, unlike the click event, for the real reason: Focus /blur does not support event bubbling!!

Bubbling is supported by almost all DOM events, but there are some special events that do not support event bubbling, such as:

  • scroll
  • blur & focus
  • Media event
  • mouseleave & mouseenter

The reason is known, and the treatment is very simple:

  • blur/focusChange to Event capture
  • usefocusin/focusoutEvent substitution (focusin/focusoutblur/focusBubbling version)
model.addEventListener('focus', onFocus, true);
model.addEventListener('blur', onBlur, true);
// or
model.addEventListener('focusin', onFocus);
model.addEventListener('focusout', onBlur);
Copy the code

Window focus management

After dealing with the bubbling of focus events, let’s look at the special elements file Input and iframe inside the popover container:

<body>
    <div class="modal">
        <! Popover contains input elements inside -->
        <input type="file" />
        <! -- Popover contains iframe elements -->
        <iframe width="300" height="200"></iframe>
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex'.'1');
        // Get focus automatically when the popover opens
        setTimeout(() = > {
            model.focus();
        });
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        let blurTimer = null;
        function onFocus(event) {
            console.log('focus', event.target);
            clearTimeout(blurTimer);
        }
        function onBlur(event) {
            console.log('blur', event.target);
            blurTimer = setTimeout(() = > {
                toggle(false);
            });
        }
        model.addEventListener('focus', onFocus, true);
        model.addEventListener('blur', onBlur, true);
    </script>
</body>
Copy the code

Click Input File:

When you click iframe:

When the element is clicked, both of them trigger the blur event of the element, but actually the area I clicked on is still inside the popover. Although the focus is lost at this moment, the popover should not be closed, and this situation needs special treatment.

When you open the file selection popover and click iframe, essentially the focus of the current window is switched to another window, so you need to listen for the focus event of the window. ActiveElement can also be used to determine whether the activeElement is in the container to rule out these special cases, as follows:

<body>
    <div class="modal">
        <! Popover contains input elements inside -->
        <input type="file" />
        <! -- Popover contains iframe elements -->
        <iframe width="300" height="200"></iframe>
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex'.'1');
        // Get focus automatically when the popover opens
        setTimeout(() = > {
            model.focus();
        });
        function toggle(open) {
            model.style.display = open ? 'block' : 'none';
        }
        let blurTimer = null;
        function onFocus() {
            clearTimeout(blurTimer);
        }
        function onBlur() {
            blurTimer = setTimeout(() = > {
                toggle(false);
            });
        }
        model.addEventListener('focus', onFocus, true);
        model.addEventListener('blur', onBlur, true);
        // If the active element is in the container, cancel the popover closing operation
        window.addEventListener('blur'.() = > {
            if (model.contains(document.activeElement)) { onFocus(); }});// When the window regains focus, determine whether the currently active element is in the container or close the popover if it is not
        window.addEventListener('focus'.(event) = > {
            if(! model.contains(document.activeElement)) { onBlur(); }});</script>
</body>
Copy the code

One thing to note here is that file input, once the file is selected, the focus automatically goes back to the input, the popover container gets the focus back and then you can trigger the blur event to close the popover; However, when you click iframe to lose focus, the element already triggers the Blur event, and subsequent popover contents cannot trigger the blur event to close the popover. Click the area outside the container to listen for the Window Focus event to determine whether the popover needs to be closed.

Tool encapsulation

We can make a simple encapsulation of the above functionality to facilitate its integration into the framework:

function createFouceOutside(target, callback) {
    target.setAttribute('tabIndex'.'1');

    let blurTimer = null;
    // Popover focus, cancel closing popover
    function onTargetFocus() {
        clearTimeout(blurTimer);
    }
    // Popover out of focus, close popover
    function onTargetBlur() {
        blurTimer = setTimeout(() = > callback());
    }
    // The window is out of focus to determine whether to cancel closing the popover
    function onWindowBlur() {
        if (target.contains(document.activeElement)) { onTargetFocus(); }}// Window focus, determine whether to close the popover
    function onWindowFocus() {
        if(! target.contains(document.activeElement)) {
            onTargetBlur();
        }
    }
    target.addEventListener('focus', onTargetFocus, true);
    target.addEventListener('blur', onTargetBlur, true);
    window.addEventListener('blur', onWindowBlur);
    window.addEventListener('focus', onWindowFocus);
    // Get focus automatically when the popover opens
    setTimeout(() = > {
        target.focus();
    });
    // Returns a destruction event function
    return () = > {
        clearTimeout(blurTimer);
        target.removeAttribute('tabIndex');
        target.removeEventListener('focus', onTargetFocus, true);
        target.removeEventListener('blur', onTargetBlur, true);
        window.removeEventListener('blur', onWindowBlur);
        window.removeEventListener('focus', onWindowFocus);
    };
}
Copy the code
// vanilla js
const model = document.querySelector('.modal');
function toggle(open) {
    model.style.display = open ? 'block' : 'none';
}
const destroy = createFouceOutside(model, () = > {
    toggle(false);
});

/ / remove fouceOutside
// destroy();
Copy the code
// react
import { useEffect } from 'react';
export function useOnClickOutside(ref, callback) {
  useEffect(() = > {
    return createFouceOutside(ref.current, callback);
  }, [ref, callback]);
}
Copy the code

other

Recently, I encountered a small pit in the development, some of the page for granted behavior when iframe will be some weird behavior, I did not seriously study before, it is a small summary ~

  • Replace clickOutside with focus management for keyboard interaction
  • usetabIndexAdd focus events to the element
  • blur/focusEvents do not bubble up
  • file inputThe selection andiframeClicking will trigger whenFront window out of focus event
  • usedocument.activeElementGets the current focus element

Students interested in iframe pits can also poke instanceof in the iframe meng circle.

over~

Reference:

  • Accessibility accessibility
  • Focus: focus/blur
  • Bubble and capture