只在模态窗口内使用Tab键切换焦点

41

在我目前的项目中,我们有一些模态窗口在某些操作时会打开。我正在尝试让当该模态窗口打开时,你无法通过Tab键跳转到它外部的元素。jQuery UI对话框和Malsup jQuery阻止插件似乎可以做到这一点,但我正在尝试获取只有这一个功能,并将其应用在我的项目中,它不是立即明显我如何做到这一点。

我看到有些人认为不应禁用Tab键,我能理解这个观点,但我被要求禁用它。


我知道这已经过时了,但无意中发现这一点并想警告大家,绝不能这样做,因为它会破坏可访问性。您将为使用键盘浏览页面的用户创建噩梦。 - rbrundritt
1
@rbrundritt 那真的吗?我来这里是为了找到解决同样问题的方法:在模态框中,当最后一个元素之后按Tab键时,会导致键盘导航出现问题,因为它开始遍历一堆不可见元素。 - n_moen
如果您将用户锁定到模态框中,则不仅会禁用页面中的体验,还会影响用户如何切换到搜索栏或浏览器的下一个标签页。您最终会破坏浏览器的标准用户体验。绝对不能这样做。我发誓,如果我遇到这样的网站,我会立即放弃它并永远不再访问。 - rbrundritt
1
我认为rbrundritt和arcanemachine都接近了问题的核心,但并没有真正解决问题。禁用Tab键是不好的,永远不应该这样做。另一方面,在某些情况下控制Tab键顺序是可取的,而模态框就是其中之一。我听说过它被称为“漫游tabindex”和“模态键盘陷阱”。这是一个临时的键盘陷阱,确保您只在模态框显示时捕获焦点,然后在关闭模态框时将焦点恢复到先前聚焦的项目。 - Steve Woodson
参考链接 - "漫游tabindex" 和 "模态键盘陷阱"。 - Steve Woodson
5个回答

36

这只是对基督徒回答的进一步扩展,增加了其他输入类型,并考虑了shift+tab。

var inputs = $element.find('select, input, textarea, button, a').filter(':visible');
var firstInput = inputs.first();
var lastInput = inputs.last();

/*set focus on first input*/
firstInput.focus();

/*redirect last tab to first input*/
lastInput.on('keydown', function (e) {
   if ((e.which === 9 && !e.shiftKey)) {
       e.preventDefault();
       firstInput.focus();
   }
});

/*redirect first shift+tab to last input*/
firstInput.on('keydown', function (e) {
    if ((e.which === 9 && e.shiftKey)) {
        e.preventDefault();
        lastInput.focus();
    }
});

2
认为您可以使用 :tabbable - Omu
完美!正是我所需要的! - Michal Shulman
1
如果最后一个元素,即按钮,在开始时被禁用了,我该怎么做? - LeFex
禁用列表末尾的元素将破坏此解决方案。如果lastInput被禁用,在倒数第二个元素上按Tab键将使您跳出模态框。使用:tabbable来收集可制表的内容将通过排除禁用按钮来解决这个问题,但是一旦您启用该按钮,您必须进行额外的逻辑处理,否则用户将无法制表进入它。 - Mark
原生JS解决方案:https://uxdesign.cc/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700 - thdoan
如果您使用jQuery UI库,那么":tabbable"才可用。 - Heretic Monkey

16

当模态窗口打开时,我最终能够至少在一定程度上实现这一点,通过将焦点放在模态窗口内的第一个表单元素上,如果在模态窗口内的最后一个表单元素中按下 Tab 键,则焦点会返回到第一个表单元素,而不是转移到 DOM 中接收焦点的下一个元素。其中许多脚本来自于jQuery:如何捕获文本框内的TAB键按下事件

$('#confirmCopy :input:first').focus();

$('#confirmCopy :input:last').on('keydown', function (e) { 
    if ($("this:focus") && (e.which == 9)) {
        e.preventDefault();
        $('#confirmCopy :input:first').focus();
    }
});

我可能需要进一步完善这个检测流程,以检测其他按键的按下情况,例如箭头键,但基本思路已经在这里了。


1
有时候你想通过类名而不是ID来选择你的模态框(这样你就可以在页面上有一个可见的模态框和多个隐藏的模态框)。在这种情况下,还要将 :visible 添加到选择器中。另外,不要忘记这个最后的输入可能会有 tabindex=-1,所以你永远不会在那里使用 tab 键。 - Cibo FATA8

11

Christian和jfutch提供了不错的解决方案。

值得一提的是,劫持tab按键可能会遇到一些陷阱:

  • 在模态窗口内部的某些元素上设置tabindex属性时,可能导致元素的dom顺序与tab顺序不一致。(例如,在最后一个可选元素上设置tabindex =“10”可能使其成为tab顺序中的第一个元素)
  • 如果用户与模态框外的元素交互,但不触发模态框关闭,则可以在模态窗口外进行tab操作。(例如,单击位置栏并开始向页面进行tab操作,或者在像VoiceOver这样的屏幕阅读器中打开页面地标,并导航到页面的其他部分)
  • 检查元素是否:visible将在dom脏时触发回流
  • 文档可能没有:focussed元素。在chrome中,通过单击非可聚焦元素然后按tab,可以更改“光标”位置。用户可能会将光标位置设置为超过最后一个可选元素。
我认为更加健壮的解决方案是通过在所有可切换内容上设置tabindex为-1来“隐藏”页面的其余部分,然后在关闭时“取消隐藏”。这将保持模态窗口内的选项卡顺序,并尊重tabindex设置的顺序。
var focusable_selector = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';

var hide_rest_of_dom = function( modal_selector ) {

    var hide = [], hide_i, tabindex,
        focusable = document.querySelectorAll( focusable_selector ),
        focusable_i = focusable.length,
        modal = document.querySelector( modal_selector ),
        modal_focusable = modal.querySelectorAll( focusable_selector );

    /*convert to array so we can use indexOf method*/
    modal_focusable = Array.prototype.slice.call( modal_focusable );
    /*push the container on to the array*/
    modal_focusable.push( modal );

    /*separate get attribute methods from set attribute methods*/
    while( focusable_i-- ) {
        /*dont hide if element is inside the modal*/
        if ( modal_focusable.indexOf(focusable[focusable_i]) !== -1 ) {
            continue;
        }
        /*add to hide array if tabindex is not negative*/
        tabindex = parseInt(focusable[focusable_i].getAttribute('tabindex'));
        if ( isNaN( tabindex ) ) {
            hide.push([focusable[focusable_i],'inline']);
        } else if ( tabindex >= 0 ) {
            hide.push([focusable[focusable_i],tabindex]);
        } 

    }

    /*hide the dom elements*/
    hide_i = hide.length;
    while( hide_i-- ) {
        hide[hide_i][0].setAttribute('data-tabindex',hide[hide_i][1]);
        hide[hide_i][0].setAttribute('tabindex',-1);
    }

};

要取消隐藏DOM,您只需查询所有具有“data-tabindex”属性的元素,并将tabindex设置为属性值。
var unhide_dom = function() {

    var unhide = [], unhide_i, data_tabindex,
        hidden = document.querySelectorAll('[data-tabindex]'),
        hidden_i = hidden.length;

    /*separate the get and set attribute methods*/
    while( hidden_i-- ) {
        data_tabindex = hidden[hidden_i].getAttribute('data-tabindex');
        if ( data_tabindex !== null ) {
            unhide.push([hidden[hidden_i], (data_tabindex == 'inline') ? 0 : data_tabindex]);
        }
    }

    /*unhide the dom elements*/
    unhide_i = unhide.length;
    while( unhide_i-- ) {
        unhide[unhide_i][0].removeAttribute('data-tabindex');
        unhide[unhide_i][0].setAttribute('tabindex', unhide[unhide_i][1] ); 
    }

}

当模态框打开时,使其余dom对于aria隐藏稍微容易一些。循环遍历模态窗口的所有相关元素并将aria-hidden属性设置为true。
var aria_hide_rest_of_dom = function( modal_selector ) {

    var aria_hide = [],
        aria_hide_i,
        modal_relatives = [],
        modal_ancestors = [],
        modal_relatives_i,
        ancestor_el,
        sibling, hidden,
        modal = document.querySelector( modal_selector );


    /*get and separate the ancestors from the relatives of the modal*/
    ancestor_el = modal;
    while ( ancestor_el.nodeType === 1 ) {
        modal_ancestors.push( ancestor_el );
        sibling = ancestor_el.parentNode.firstChild;
        for ( ; sibling ; sibling = sibling.nextSibling ) {
            if ( sibling.nodeType === 1 && sibling !== ancestor_el ) {
                modal_relatives.push( sibling );
            }
        }
        ancestor_el = ancestor_el.parentNode;
    }

    /*filter out relatives that aren't already hidden*/
    modal_relatives_i = modal_relatives.length;
    while( modal_relatives_i-- ) {

        hidden = modal_relatives[modal_relatives_i].getAttribute('aria-hidden');
        if ( hidden === null || hidden === 'false' ) {
            aria_hide.push([modal_relatives[modal_relatives_i]]);
        }

    }

    /*hide the dom elements*/
    aria_hide_i = aria_hide.length;
    while( aria_hide_i-- ) {

        aria_hide[aria_hide_i][0].setAttribute('data-ariahidden','false');
        aria_hide[aria_hide_i][0].setAttribute('aria-hidden','true');

    }       

};

使用类似的技术在模态框关闭时取消隐藏aria dom元素。这里最好删除aria-hidden属性,而不是将其设置为false,因为该元素上可能存在一些冲突的css可见性/显示规则,并且在这种情况下实现aria-hidden在不同浏览器中不一致(请参见https://www.w3.org/TR/2016/WD-wai-aria-1.1-20160721/#aria-hidden)。
var aria_unhide_dom = function() {

    var unhide = [], unhide_i, data_ariahidden,
        hidden = document.querySelectorAll('[data-ariahidden]'),
        hidden_i = hidden.length;

    /*separate the get and set attribute methods*/
    while( hidden_i-- ) {
        data_ariahidden = hidden[hidden_i].getAttribute('data-ariahidden');
        if ( data_ariahidden !== null ) {
            unhide.push(hidden[hidden_i]);
        }
    }

    /*unhide the dom elements*/
    unhide_i = unhide.length;
    while( unhide_i-- ) {
        unhide[unhide_i].removeAttribute('data-ariahidden');
        unhide[unhide_i].removeAttribute('aria-hidden');
    }

}

最后,我建议在元素动画结束后调用这些函数。以下是在transition_end上调用函数的抽象示例。
我正在使用modernizr检测加载时的过渡持续时间。transition_end事件会冒泡到dom,因此如果模态窗口打开时有多个元素进行过渡,则可以多次触发,因此在调用隐藏dom函数之前请检查event.target。
/* this can be run on page load, abstracted from 
 * http://dbushell.com/2012/12/22/a-responsive-off-canvas-menu-with-css-transforms-and-transitions/
 */
var transition_prop = Modernizr.prefixed('transition'),
    transition_end = (function() {
        var props = {
            'WebkitTransition' : 'webkitTransitionEnd',
            'MozTransition'    : 'transitionend',
            'OTransition'      : 'oTransitionEnd otransitionend',
            'msTransition'     : 'MSTransitionEnd',
            'transition'       : 'transitionend'
        };
        return props.hasOwnProperty(transition_prop) ? props[transition_prop] : false;
    })();


/*i use something similar to this when the modal window is opened*/
var on_open_modal_window = function( modal_selector ) {

    var modal = document.querySelector( modal_selector ),
        duration = (transition_end && transition_prop) ? parseFloat(window.getComputedStyle(modal, '')[transition_prop + 'Duration']) : 0;

    if ( duration > 0 ) {
        $( document ).on( transition_end + '.modal-window', function(event) {
            /*check if transition_end event is for the modal*/
            if ( event && event.target === modal ) {
                hide_rest_of_dom();
                aria_hide_rest_of_dom();    
                /*remove event handler by namespace*/
                $( document ).off( transition_end + '.modal-window');
            }               
        } );
    } else {
        hide_rest_of_dom();
        aria_hide_rest_of_dom();
    }
}

6

我刚刚对Alexander Puchkov的解决方案进行了一些修改,并将其制作成了JQuery插件。它解决了容器中动态DOM更改的问题。如果在条件下将任何控件添加到容器中,则此方法可行。

(function($) {

    $.fn.modalTabbing = function() {

        var tabbing = function(jqSelector) {
            var inputs = $(jqSelector).find('select, input, textarea, button, a[href]').filter(':visible').not(':disabled');

            //Focus to first element in the container.
            inputs.first().focus();

            $(jqSelector).on('keydown', function(e) {
                if (e.which === 9) {

                    var inputs = $(jqSelector).find('select, input, textarea, button, a[href]').filter(':visible').not(':disabled');

                    /*redirect last tab to first input*/
                    if (!e.shiftKey) {
                        if (inputs[inputs.length - 1] === e.target) {
                            e.preventDefault();
                            inputs.first().focus();
                        }
                    }
                    /*redirect first shift+tab to last input*/
                    else {
                        if (inputs[0] === e.target) {
                            e.preventDefault();
                            inputs.last().focus();
                        }
                    }
                }
            });
        };

        return this.each(function() {
            tabbing(this);
        });

    };
})(jQuery);

谢谢你,Rajesh!这太好了,解决了我在动态DOM更改方面遇到的确切问题。 - Matt.
我可以确认这对我们起作用了。我们的模态框在加载时没有设置焦点,你可以在模态框后面的页面上随意按Tab键。一旦我们包含了插件代码并调用了$("#myModalDialogId").modalTabbing();,焦点就被设置了,而且只能在模态框元素之间进行Tab键切换。 - Jeff Mergler
它可以工作,但是当模态框关闭时,在页面中进行制表操作会停止,而不是在页面的某个地方单击。有什么解决办法吗? - Scot's Scripts

1

如果有人像我一样最近才接触到这个问题,我已经采取了上面概述的方法,并进行了简化,以使其更易理解。感谢@niall.campbell在此提出的建议方法。

下面的代码可以在此CodeSandbox中找到,供参考和实际演示。

let tabData = [];

const modal = document.getElementById('modal');
preventTabOutside(modal);

// should be called when modal opens
function preventTabOutside(modal) {
  const tabbableElements = document.querySelectorAll(selector);
  tabData = Array.from(tabbableElements)
    // filter out any elements within the modal
    .filter((elem) => !modal.contains(elem))
    // store refs to the element and its original tabindex
    .map((elem) => {
      // capture original tab index, if it exists
      const tabIndex = elem.hasAttribute("tabindex")
        ? elem.getAttribute("tabindex")
        : null;
      // temporarily set the tabindex to -1
      elem.setAttribute("tabindex", -1);
      return { elem, tabIndex };
    });
}

// should be called when modal closes
function enableTabOutside() {
  tabData.forEach(({ elem, tabIndex }) => {
    if (tabIndex === null) {
      elem.removeAttribute("tabindex");
    } else {
      elem.setAttribute("tabindex", tabIndex);
    }
  });
  tabData = [];
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接