likes
comments
collection
share

或许你想要的并不是 clickOutsideClickOutside ClickOutside 常见于页面各种弹窗中,如

作者站长头像
站长
· 阅读数 30

ClickOutside

ClickOutside 常见于页面各种弹窗中,如下拉弹窗,页面 Modal 对话框,当用户点击弹出层以外的其他区域关闭当前弹窗。很常见的一个功能实现,在各种前端框架中一般会对此功能进行封装,在 Vue 中可能是个 v-click-outside="close" 的指令,在 React 可能是个 useClickOutside(ref, close) 的 hook。ClickOutside 的实现可以十分简单:

<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>

判断点击的元素是否在弹出容器内,如果不在则关闭弹层,这在大多数情况下都很好用,但并不完美,在一些特殊场景下会有问题。

Iframe 事件监听

先来看一个 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>

如果弹层外部如果包含 iframe用户在点击 iframe 时在 window 上无法监听到 click 事件的。当然也有些解决方法,如果 iframe 中页面没跨域则可以使用 iframe.contentWindow.addEventListener 来监听;跨域的页面如果可控则可以通过 postMessage 通知父级窗口......这些方法可行但都不太通用。

键盘可访问性

另一个例子是键盘控制的问题:

<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>

弹窗内部与外部都有 input 元素,此时可使用键盘的 Tab 来切换选中 input,当输入框从弹窗内部切换到外部时,弹窗关闭是一个比较正常的行为,使用 click 事件是无法处理键盘 Tab 的切换焦点元素的。

这里就引申出一个键盘可访问性的问题,在日常功能我们可能不太关注无障碍相关的功能实现,但很多时候只需要加几行代码即可使得应用满足可访问性的需要。这里建议大家可以看一下 React 的无障碍辅助功能指引,其中也给出上述问题的解决方案,使用焦点管理替换 click 的事件监听。

FocusOutside

焦点管理

焦点管理简单来说让目标元素具有聚焦(focus)和失焦(blur)状态并可以触发相应状态的行为。以弹窗举例,focus 就是弹窗打开的状态的,blur 则是弹窗关闭的关闭状态,我们可以按照这个思路实现来时弹窗开启关闭。

model.addEventListener('focus', () => { console.log('focus'); });
model.addEventListener('blur', () => { console.log('blur'); });

实现也简单,监听 focusblur 事件即可。

但直接监听 focusblur 事件是不生效的,因为普通元素并不支持焦点事件,让元素支持焦点事件需要为元素设置 tabindex 属性

它接受一个整数作为值,具有不同的结果,具体取决于整数的值:

  • tabindex=负值 (通常是 tabindex=“-1”),表示元素是可聚焦的,但是不能通过键盘导航来访问到该元素,用 JS 做页面小组件内部键盘导航的时候非常有用。
  • tabindex="0" ,表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素,它的相对顺序是当前处于的 DOM 结构来决定的。
  • tabindex=正值,表示元素是可聚焦的,并且可以通过键盘导航来访问到该元素;它的相对顺序按照 tabindex  的数值递增而滞后获焦。如果多个元素拥有相同的 tabindex,它们的相对顺序按照他们在当前 DOM 中的先后顺序决定。
model.setAttribute('tabIndex', '-1');

弹窗一类的组件一般是由按钮点击触发,这里使用 "-1" 避免被键盘直接触发。我们可以实现一个基于焦点管理的 focusOutside

<body>
    <div class="modal"></div>
    <input type="text" />
    <iframe></iframe>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex', '-1');
        // 弹窗打开时自动获取焦点
        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>

点击弹窗外的 iframe 与 Tab 切换都可以正常的关闭弹窗了。但这里为什么需要强调弹窗外呢,因为弹窗内部的焦点管理还需要处理。

事件冒泡

一般弹窗内部有个各式各样的组件,弹窗内如果存在可以聚焦的元素如 input 元素,在弹窗内部也是会发生焦点切换行为,看个例子:

<body>
    <div class="modal">
        <!-- 弹窗内部包含 input 元素 -->
        <input type="text" />
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex', 0);
        // 弹窗打开时自动获取焦点
        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>

当弹窗元素内部如果包含焦点元素时,内部的焦点元素被激活时,焦点会从容器切换到子元素上,造成容器失去焦点。这很不符合直觉,和 click 事件不一样,真正的原因是:focus/blur并不支持事件冒泡!!!

几乎所有 DOM 事件都会支持冒泡,但也有些特殊事件是不支持事件冒泡的,如:

  • scroll
  • blur & focus
  • Media 事件
  • mouseleave & mouseenter

原因知道了,处理方法也十分简单:

  • blur/focus 改为事件捕获
  • 使用 focusin/focusout 事件替代(focusin/focusoutblur/focus 的冒泡版本)
model.addEventListener('focus', onFocus, true);
model.addEventListener('blur', onBlur, true);
// or
model.addEventListener('focusin', onFocus);
model.addEventListener('focusout', onBlur);

窗口的焦点管理

焦点事件冒泡的问题处理完,再来看看弹窗容器内特殊元素 file inputiframe 元素:

<body>
    <div class="modal">
        <!-- 弹窗内部包含 input 元素 -->
        <input type="file" />
        <!-- 弹窗内部包含 iframe 元素 -->
        <iframe width="300" height="200"></iframe>
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex', '-1');
        // 弹窗打开时自动获取焦点
        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>

点击 input file 时:

或许你想要的并不是 clickOutsideClickOutside ClickOutside 常见于页面各种弹窗中,如

点击 iframe 时:

或许你想要的并不是 clickOutsideClickOutside ClickOutside 常见于页面各种弹窗中,如

点击时两者都触发了元素的 blur 事件,但实际我们上我点击的区域还在弹窗内,此刻虽然失去焦点但却不应该关闭弹窗,需要特殊处理下这种情况。

当打开文件选择弹窗与点击 iframe 时,本质都是都是当前窗口的焦点切换到其他窗口上了,这时候就需要监听 window 的焦点事件了。同时可以利用 activeElement 判断当前激活的元素是否处于容器内来排除掉这些特殊情况,实现如下:

<body>
    <div class="modal">
        <!-- 弹窗内部包含 input 元素 -->
        <input type="file" />
        <!-- 弹窗内部包含 iframe 元素 -->
        <iframe width="300" height="200"></iframe>
    </div>
    <script>
        const model = document.querySelector('.modal');
        model.setAttribute('tabIndex', '-1');
        // 弹窗打开时自动获取焦点
        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);
        // 窗口时取焦点时判断,当前激活元素是否在容器内,是的话取消弹窗关闭操作
        window.addEventListener('blur', () => {
            if (model.contains(document.activeElement)) {
                onFocus();
            }
        });
        // 窗口重新获取焦点时判断,当前激活元素是否在容器内,不在容器内则关闭弹窗
        window.addEventListener('focus', (event) => {
            if (!model.contains(document.activeElement)) {
                onBlur();
            }
        });
    </script>
</body>

这里有一点需要注意,file input 在文件选择完成后焦点会自动回到 input 上,弹窗容器会重新获取焦点后续可以触发 blur 事件关闭弹窗;但点击 iframe 失去焦点时,元素已经触发 blur 事件,则后续弹窗内容的无法再触发 blur 事件来关闭弹窗。这时点击容器外区域就需要监听下 window focus 事件来判断弹窗是否需要关闭。

工具封装

我们可以对上述的功能进行一个简单的封装方便其整合到框架使用:

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

    let blurTimer = null;
    // 弹窗聚焦,取消关闭弹窗
    function onTargetFocus() {
        clearTimeout(blurTimer);
    }
    // 弹窗失焦,关闭弹窗
    function onTargetBlur() {
        blurTimer = setTimeout(() => callback());
    }
    // 窗口失焦,判断是否需要取消关闭弹窗
    function onWindowBlur() {
        if (target.contains(document.activeElement)) {
            onTargetFocus();
        }
    }
    // 窗口聚焦,判断是否需要关闭弹窗
    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);
    // 弹窗打开时自动获取焦点
    setTimeout(() => {
        target.focus();
    });
    // 返回一个销毁事件函数
    return () => {
        clearTimeout(blurTimer);
        target.removeAttribute('tabIndex');
        target.removeEventListener('focus', onTargetFocus, true);
        target.removeEventListener('blur', onTargetBlur, true);
        window.removeEventListener('blur', onWindowBlur);
        window.removeEventListener('focus', onWindowFocus);
    };
}
// vanilla js
const model = document.querySelector('.modal');
function toggle(open) {
    model.style.display = open ? 'block' : 'none';
}
const destroy = createFouceOutside(model, () => {
    toggle(false);
});

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

其他

最近开发时遇到的小坑,一些页面上理所当然的行为遇到 iframe 后会有些怪异的行为,之前没有认真研究过,算时一个小总结吧~

  • 采用焦点管理方式替换 clickOutside 实现可满足键盘交互
  • 使用 tabIndex 为元素添加焦点事件
  • blur/focus 事件不会冒泡
  • file input 选择与 iframe 点击会触发当前窗口失焦事件
  • 使用 document.activeElement 可获取当前焦点元素

over~

参考:

转载自:https://juejin.cn/post/7056450195998900238
评论
请登录