How to keep focus within modal dialog?

[亡魂溺海] 提交于 2019-11-29 10:45:21

There is currently no easy way to achieve this. The inert attribute was proposed to try to solve this problem by making any element with the attribute and all of it's children inaccessible. However, adoption has been slow and only recently did it land in Chrome Canary behind a flag.

Another proposed solution is making a native API that would keep track of the modal stack, essentially making everything not currently the top of the stack inert. I'm not sure the status of the proposal, but it doesn't look like it will be implemented any time soon.

So where does that leave us?

Unfortunately without a good solution. One solution that is popular is to create a query selector of all known focusable elements and then trap focus to the modal by adding a keydown event to the last and first elements in the modal. However, with the rise of web components and shadow DOM, this solution can no longer find all focusable elements.

If you always control all the elements within the dialog (and you're not creating a generic dialog library), then probably the easiest way to go is to add an event listener for keydown on the first and last focusable elements, check if tab or shift tab was used, and then focus the first or last element to trap focus.

If you're creating a generic dialog library, the only thing I have found that works reasonably well is to either use the inert polyfill or make everything outside of the modal have a tabindex=-1.

var nonModalNodes;

function openDialog() {    
  var modalNodes = Array.from( document.querySelectorAll('dialog *') );

  // by only finding elements that do not have tabindex="-1" we ensure we don't
  // corrupt the previous state of the element if a modal was already open
  nonModalNodes = document.querySelectorAll('body *:not(dialog):not([tabindex="-1"])');

  for (var i = 0; i < nonModalNodes.length; i++) {
    var node = nonModalNodes[i];

    if (!modalNodes.includes(node)) {

      // save the previous tabindex state so we can restore it on close
      node._prevTabindex = node.getAttribute('tabindex');
      node.setAttribute('tabindex', -1);

      // tabindex=-1 does not prevent the mouse from focusing the node (which
      // would show a focus outline around the element). prevent this by disabling
      // outline styles while the modal is open
      // @see https://www.sitepoint.com/when-do-elements-take-the-focus/
      node.style.outline = 'none';
    }
  }
}

function closeDialog() {

  // close the modal and restore tabindex
  if (this.type === 'modal') {
    document.body.style.overflow = null;

    // restore or remove tabindex from nodes
    for (var i = 0; i < nonModalNodes.length; i++) {
      var node = nonModalNodes[i];
      if (node._prevTabindex) {
        node.setAttribute('tabindex', node._prevTabindex);
        node._prevTabindex = null;
      }
      else {
        node.removeAttribute('tabindex');
      }
      node.style.outline = null;
    }
  }
}

The different "working examples" do not work as expected with a screenreader.

They do not trap the screenreader visual focus inside the modal.

For this to work, you have to :

  1. Set the aria-hidden attribute on any other nodes
  2. disable keyboard focusable elements inside those trees (links using tabindex=-1, controls using disabled, ...)

  3. add a transparent layer over the page to disable mouse selection.

    • or you can use the css pointer-events: none property when the browser handles it with non SVG elements, not in IE

This focus-trap plugin is excellent at making sure that focus stays trapped inside of dialogue elements.

Don't use any solution requiring you to look up "tabbable" elements. Instead, use keydown and either click events or a backdrop in an effective manor.

(Angular1)

See Asheesh Kumar's answer at https://stackoverflow.com/a/31292097/1754995 for something similar to what I am going for below.

(Angular2-x, I haven't done Angular1 in a while)

Say you have 3 components: BackdropComponent, ModalComponent (has an input), and AppComponent (has an input, the BackdropComponent, and the ModalComponent). You display BackdropComponent and ModalComponent with the correct z-index, both are currently displayed/visible.

What you need to do is have a general window.keydown event with preventDefault() to stop all tabbing when the backdrop/modal component is displayed. I recommend you put that on a BackdropComponent. Then you need a keydown.tab event with stopPropagation() to handle tabbing for the ModalComponent. Both the window.keydown and keydown.tab could probably be in the ModalComponent but there is purpose in a BackdropComponent further than just modals.

This should prevent clicking and tabbing to the AppComponent input and only click or tab to the ModalComponent input [and browser stuffs] when the modal is shown.

If you don't want to use a backdrop to prevent clicking, you can use use click events similarly to the keydown events described above.

Backdrop Component:

@Component({
selector: 'my-backdrop',
host: {
    'tabindex': '-1',
    '(window:keydown)': 'preventTabbing($event)'
},
...
})
export class BackdropComponent {
    ...
    private preventTabbing(event: KeyboardEvent) {
        if (event.keyCode === 9) { // && backdrop shown?
            event.preventDefault();
        }
    }
    ...
}

Modal Component:

@Component({
selector: 'my-modal',
host: {
    'tabindex': '-1',
    '(keydown.tab)': 'onTab($event)'
},
...
})
export class ModalComponent {
    ...
    private onTab(event: KeyboardEvent) {
        event.stopPropagation();
    }
    ...
}

I used one of the methods suggested by Steven Lambert, namely, listening to keydown events and intercepting "tab" and "shift+tab" keys. Here's my sample code (Angular 5):

import { Directive, ElementRef, Attribute, HostListener, OnInit } from '@angular/core';

/**
 * This directive allows to override default tab order for page controls.
 * Particularly useful for working around the modal dialog TAB issue
 * (when tab key allows to move focus outside of dialog).
 *
 * Usage: add "custom-taborder" and "tab-next='next_control'"/"tab-prev='prev_control'" attributes
 * to the first and last controls of the dialog.
 *
 * For example, the first control is <input type="text" name="ctlName">
 * and the last one is <button type="submit" name="btnOk">
 *
 * You should modify the above declarations as follows:
 * <input type="text" name="ctlName" custom-taborder tab-prev="btnOk">
 * <button type="submit" name="btnOk" custom-taborder tab-next="ctlName">
 */

@Directive({
  selector: '[custom-taborder]'
})
export class CustomTabOrderDirective {

  private elem: HTMLInputElement;
  private nextElemName: string;
  private prevElemName: string;
  private nextElem: HTMLElement;
  private prevElem: HTMLElement;

  constructor(
    private elemRef: ElementRef
    , @Attribute('tab-next') public tabNext: string
    , @Attribute('tab-prev') public tabPrev: string
  ) {
    this.elem = this.elemRef.nativeElement;
    this.nextElemName = tabNext;
    this.prevElemName = tabPrev;
  }

  ngOnInit() {
    if (this.nextElemName) {
      var elems = document.getElementsByName(this.nextElemName);
      if (elems && elems.length && elems.length > 0)
        this.nextElem = elems[0];
    }

    if (this.prevElemName) {
      var elems = document.getElementsByName(this.prevElemName);
      if (elems && elems.length && elems.length > 0)
        this.prevElem = elems[0];
    }
  }

  @HostListener('keydown', ['$event'])
  onKeyDown(event: KeyboardEvent) {

    if (event.key !== "Tab")
      return;

    if (!event.shiftKey && this.nextElem) {
      this.nextElem.focus();
      event.preventDefault();
    }

    if (event.shiftKey && this.prevElem) {
      this.prevElem.focus();
      event.preventDefault();
    }

  }

}

To use this directive, just import it to your module and add to Declarations section.

Mesopotamia

It sounds like your problem can be broken down into 2 categories:

  1. focus on dialog box

Add a tabindex of -1 to the main container which is the DOM element that has role="dialog". Set the focus to the container.

  1. wrapping the tab key

I found no other way of doing this except by getting the tabbable elements within the dialog box and listening it on keydown. When I know the element in focus (document.activeElement) is the last one on the list, I make it wrap

Here's my solution. It traps Tab or Shift+Tab as necessary on first/last element of modal dialog (in my case found with role="dialog"). Eligible elements being checked are all visible input controls whose HTML may be input,select,textarea,button.

$(document).on('keydown', function(e) {
    var target = e.target;
    var shiftPressed = e.shiftKey;
    // If TAB key pressed
    if (e.keyCode == 9) {
        // If inside a Modal dialog (determined by attribute role="dialog")
        if ($(target).parents('[role=dialog]').length) {                            
            // Find first or last input element in the dialog parent (depending on whether Shift was pressed). 
            // Input elements must be visible, and can be Input/Select/Button/Textarea.
            var borderElem = shiftPressed ?
                                $(target).closest('[role=dialog]').find('input:visible,select:visible,button:visible,textarea:visible').first() 
                             :
                                $(target).closest('[role=dialog]').find('input:visible,select:visible,button:visible,textarea:visible').last();
            if ($(borderElem).length) {
                if ($(target).is($(borderElem))) {
                    return false;
                } else {
                    return true;
                }
            }
        }
    }
    return true;
});
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!