How can restrict the tab key press only within the modal popup when its open?
Asked Answered
B

13

21

I have a modal popup opened. I have accessibility requirement. So added ARIA related labels. But when i click tab key continuously focus going to the page behind the actual page.

Added role="dialog" in html file

But when modal opened i want only the focus navigate within the modal popup.

Working on Angular4, HTML5 project. Better if we find a solution within HTML file itself. I mean without added any javascript/jQuery related stuffs to prevent this

Bathsheb answered 4/5, 2018 at 15:30 Comment(1)
I created a library in vanilla javascript. npmjs.com/package/focus-trap-js Let me know if it works for you.Cromagnon
D
16

You are asking about focus trap, it's nicely demonstrated in this demo: https://focus-trap.github.io/focus-trap/

Adding role="dialog" will not automatically provide trap the focus within that element. In fact, there's no native focus trap provided by browsers.

You need to go with one of following options:

Dynast answered 4/5, 2018 at 16:57 Comment(1)
Those examples seems to track keyboard quite correctly but do not handle screenreader focus trapping. Elements outside of the dialog are still read.Sauerkraut
H
9

A non-jquery solution that cycles only through the modal's input elements


// place this line in the dialog show function - to only add the listener when the dialog is shown
window.addEventListener('keydown', handleKey);

// uncomment and place this in the dialog close/hide function to remove the listener when dialog is closed/hidden
// window.removeEventListener('keydown', handleKey);

function handleKey(e) {
    if (e.keyCode === 9) {
        let focusable = document.querySelector('#modal').querySelectorAll('input,button,select,textarea');
        if (focusable.length) {
            let first = focusable[0];
            let last = focusable[focusable.length - 1];
            let shift = e.shiftKey;
            if (shift) {
                if (e.target === first) { // shift-tab pressed on first input in dialog
                    last.focus();
                    e.preventDefault();
                }
            } else {
                if (e.target === last) { // tab pressed on last input in dialog
                    first.focus();
                    e.preventDefault();
                }
            }
        }
    }
}
Han answered 2/2, 2020 at 23:57 Comment(0)
S
7

be careful of any method relying only on javascript events as it won't correctly handle screenreaders

However, this cannot be achieved without javascript like already indicated in multiple questions like How to keep focus within modal dialog?

You have three steps to do:

1. disable screenreader interaction with any other nodes by setting aria-hidden=true on them

For instance:

<main aria-hidden="true"><!-- main content here--></main>
<dialog>Your dialog here</dialog>

2. disable any keyboard interaction with them

This has to be done in Javascript / or jQuery.

This is a one-liner instruction in jQuery, using jquery-ui

$("main :focusable").addClass("disabled").attr("tabindex", -1);

The reverse can be achieved using:

$(".disabled").removeClass("disabled").attr("tabindex", 0);

3. remove any pointer event for those elements to disable mouse interaction

css sample:

main[aria-hidden='true'] { pointer-events: none;}
Sauerkraut answered 4/5, 2018 at 20:29 Comment(2)
This is a nice solution, however :focusable requires jQuery UI that is no longer maintained, i believe.Drayton
Jquery UI is still being updated. Check the commit logs there were commits this monthCordova
Z
3

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;
});
Zabrze answered 28/1, 2019 at 15:34 Comment(1)
This works nicely but I updated it slightly to support anchor tags and also to loop while tabbing instead of stopping at the first or last element. jsfiddle.net/ma1fzyv6Honegger
C
1

Right now also Angular CDK provides directive to add focus trap to the modal popup https://material.angular.io/cdk/a11y/api#CdkTrapFocus

Cartoon answered 30/8, 2019 at 12:46 Comment(0)
F
1

I'm not an expert on A11y, but I'm pretty sure this is okay, or pretty darn close to it. Please comment if you have any A11y feedback so I can update. I'll also update if this shows up as problematic on A11y scans that I run.

So this is a very simple solution, and it's working for me: Put this as the last element in your modal, and add the element id for where you want it to loop back to. This should most likely be the same place that receives focus when you first launch the modal.

<span tabindex="0" aria-hidden="true" onfocus="document.getElementById('the_element_id').focus()"></span>
<div 
    class="my-modal"
    id="my-modal-123"
    tabindex="-1"
    aria-modal="true"
    aria-label="Description for modal content"
    role="dialog"
>

    <!-- modal content -->
    ... 

    <span tabindex="0" aria-hidden="true" onfocus="document.getElementById('my-modal-123').focus()"></span>
</div> <!-- .my-modal -->
Furmark answered 31/1, 2023 at 17:37 Comment(0)
D
0

the parent dialog element should have role="dialog" to indicate this is a dialog. In your case, you're also missing the aria-modal="true" which should tell the browser and screen readers that the focus should stay only within the modal.

Using aria-modal="true" replaces the need to add aria-hidden="true" for elements that should be hidden from screen readers and not receive keyboard focus outside the modal while the modal is open.

If the above method does not work, you may still want to use aria-hidden="true" for the parent element outside the modal to prevent keyboard from leaving the modal.

If for some reason that still does not work and you need manual control over keyboard focus, you can check which elements receive keyboard focus outside the modal and then set their tabindex attribute to tabindex="-1", meaning they can still receive focus but not from the keyboard. In this approach you need to be careful, because when the modal closes you'll want to restore the functionality by either removing the tabindex="-1" from those elements or by setting it back to tabindex="0"

Source: W3C wai aria practices - dialog modal with example

Degraw answered 17/12, 2019 at 11:26 Comment(0)
I
0

Tried different solutions with tabindex='-1' and other HTML changes but nothing is worked in my case, so here is something which worked in my case.

Step 1: Add keydown event on dialog component

  @HostListener('document:keydown', ['$event'])
  handleTabKeyWInModel(event: any) {
       this.sharedService.handleTabKeyWInModel(event, '#modal_id', this.elementRef.nativeElement, 'input,button,select,textarea,a,[tabindex]:not([tabindex="-1"])');
  }

This will filters the elements which are preseneted in the Modal dialog.

Step 2: Add common method to handle focus in shared service (or you can add it in your component as well)

handleTabKeyWInModel(e, modelId: string, nativeElement, tagsList: string) {
        if (e.keyCode === 9) {
            const focusable = nativeElement.querySelector(modelId).querySelectorAll(tagsList);
            if (focusable.length) {
               const first = focusable[0];
               const last = focusable[focusable.length - 1];
               const shift = e.shiftKey;
               if (shift) {
                  if (e.target === first) { // shift-tab pressed on first input in dialog
                     last.focus();
                     e.preventDefault();
                  }
                } else {
                    if (e.target === last) { // tab pressed on last input in dialog
                        first.focus();
                        e.preventDefault();
                    }
                }
            }
        }
    }

Now this method will take the modal dialog native element and start evaluate on every tab key. Finally we will filter the event on first and last so that we can focus on appropriate elements (on first after last element tab click and on last shift+tab event on first element).

Happy Coding.. :)

Interoceptor answered 23/2, 2021 at 6:10 Comment(0)
S
0

You can try this below code. Only pure JavaScript without any third party library & it works for me :)

 Step 1: On Zoom Icon click:

      var currentFocus; /*As a global variable*/

     /*Current activeElement before PopUp Modal opens. Also define currentFocus variable as a global variable so that it value is stored and can we accessible inside other functions wherever needed.*/

     currentFocus = document.activeElement;

     window.addEventListener('keydown', handleTabKey)

     function handleTabKey(e){
        if (e.keyCode === 9 || e.key=== 'Tab')/*Tab Key Detection*/{

        /*PopUp Modal focusable elements, on which we want focus on tab key press*/

        let focusElement = document.querySelector('#sample-modal').querySelectorAll('.popup-cta > a.btn, a.closepop');
        let first = focusElement[0];
        let last = focusElement[focusElement.length - 1];
            e.preventDefault();
            /*Current activeElement on PopUp Modal*/
            let activeNode = document.activeElement;
            switch(activeNode){
                case first:
                    first.blur();
                    last.focus();
                break;

                case last:
                    last.blur();
                    first.focus();
                break;

                default:
                    first.focus();
            }
    }
     }

Step 2: On Close Icon Click

window.removeEventListener('keydown', handleTabKey);
currentFocus.focus();

If you understand this code, it will surely resolve your problems. Reference Link: https://watch.screencastify.com/v/EJjjYaFp5ex8zxvq2Ly6

Note: We can also replace above handleTabKey function with below one:

var shift = e.shiftKey;
        if (shift) { /* shift + tab */
            e.preventDefault();
            switch(document.activeElement){
                case first:
                    first.blur();
                    last.focus();
                break;
                case last:
                    last.blur();
                    first.focus();
                break;
                default:
                    last.focus();
            }
                    
        } else { /* tab */
            e.preventDefault();
            switch(document.activeElement){
                case first:
                        first.blur();
                        last.focus();
                break;
                case last:
                        last.blur();
                        first.focus();
                break;
                default:
                    first.focus();
                }
        }
Santanasantayana answered 19/4, 2022 at 9:55 Comment(0)
N
0

Best solution:

function getFocusable(context = 'document') {
    return Array.from(context.querySelectorAll('button, [href], input:not([type="hidden"]), textarea, select, [tabindex]:not([tabindex="-1"])')).filter(function (el) { return !el.closest('[hidden]'); });
}

const $dialog = document.querySelector('.mymodaldialog');
const focusableItems = getFocusable($dialog);

document.addEventListener("keydown", function (e) {
    if (e.keyCode === 9) { // Tab & Shift+Tab
        const focusedItem = e.target;
        const focusedItemIndex = focusableItems.indexOf(focusedItem);
        if (e.shiftKey) {
            if (!$dialog.contains(e.target) || focusedItemIndex == 0) {
                focusableItems[focusableItems.length - 1].focus();
                e.preventDefault();
            }
        } else {
            if (!$dialog.contains(e.target) || focusedItemIndex == focusableItems.length - 1) {
                focusableItems[0].focus();
                e.preventDefault();
            }
        }
    }
});
Natter answered 12/5, 2022 at 20:59 Comment(0)
O
0

Here is a fix of @Dmitry Shashurov:

Test here: https://jsfiddle.net/a271dwu0/

///////////////////////////////////////////////////////////
/////////////////////// Focus-trap ///////////////////////
/////////////////////////////////////////////////////////

////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const container = document.querySelector('#container'); // <----- Fill in your div container here and its done
//////////////////////////////////////////////////////////////////////////////////////////////////////////////

function getFocusable(context = 'document') {
  return Array.from(context.querySelectorAll('button, [href], input:not([type="hidden"]), textarea, select, [tabindex]:not([tabindex="-1"])')).filter(function (el) { return !el.closest('[hidden]'); });
}
const focusableItems = getFocusable(container);
document.addEventListener("keydown", function (e) {
  if ( window.getComputedStyle(container).display === "none") {
    return;
  }
  if (e.keyCode === 9) { // Tab & Shift+Tab
    const focusedItem = e.target;
    const focusedItemIndex = focusableItems.indexOf(focusedItem);
    if (e.shiftKey) {
      if (!container.contains(e.target) || focusedItemIndex === 0) {
        focusableItems[focusableItems.length - 1].focus();
        e.preventDefault();
      }
    } else {
      if (!container.contains(e.target) || focusedItemIndex == focusableItems.length - 1) {
        focusableItems[0].focus();
        e.preventDefault();
      }
    }
  }
});
Oread answered 8/6, 2023 at 11:39 Comment(1)
In Angular 15 you have the library @angular/cdk/a11y and the directive CdkTrapFocus that makes the work for youMichele
T
0

Some other answers and comment(s) mention the angular CDK to trap focus in the modal. Here is an article on how to do that.

The steps are:

  1. Install Angular CDK
 npm i @angular/cdk
  1. In your modal component, import A11yModule
import { A11yModule } from '@angular/cdk/a11y';

@Component({
  selector: 'app-sign-up-modal',
  ...
  imports: [A11yModule]
})
export class SignUpModalComponent {
}
  1. Use the directives in your modal HTML template
<div cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
   <app-sign-up-form (formSubmitted)="modalClose.emit()"></app-sign-up-form>
   ...
</div>
Titanite answered 6/2 at 9:23 Comment(0)
S
0

You can use the dialog element, or implement it (focus trap) with JS, here is my solution:

function trapFocus(dialogEl) {
  const focusableElements = dialogEl.querySelectorAll(
    `a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])`
  );
  const firstFocusableEl = focusableElements[0];
  const lastFocusableEl = focusableElements[focusableElements.length - 1];

  dialogEl.addEventListener(`keydown`, function (e) {
    if (e.key === `Tab`) {
      if (e.shiftKey) {
        if (document.activeElement === firstFocusableEl) {
          lastFocusableEl.focus();
          e.preventDefault();
        }
      } else {
        if (document.activeElement === lastFocusableEl) {
          firstFocusableEl.focus();
          e.preventDefault();
        }
      }
    }
  });
}
Stendhal answered 18/2 at 19:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.