javascriptevent-handlingaccessibilityframes

When supporting frames, How to get an event listener to respond to the whole document?


I am adding Accessibility to a legacy app which uses frames. I am presently working on the skip-nav feature.

I have tried various combinations of options to the event listener. Currently the options are: { capture:true, passive:true }, so as I understand it, this listener will get called first and the event cannot get cancelled by any bubbling handlers.

Here is the create method of my SkipNavigation object:

  // creates DOM elements, sets up event listeners
  create()
  {
    this.div = document.createElement('div');
    this.div.setAttribute('id', 'skip_nav_div');
    this.div.setAttribute('role', 'navigation');
    this.div.setAttribute('aria-label', 'skip navigation');
    this.div.setAttribute('tabindex', '-1');
    this.div.className = 'SkipNavigation';

    this.button = document.createElement('button');
    this.button.setAttribute('id', 'skip_nav_button');
    this.button.textContent = 'skip to main content';
    this.button.classList = 'btn btn-light';
    this.button.addEventListener('click', () => { this.skipNav(this) });

    this.div.appendChild(this.button);
    document.body.appendChild(this.div);

    this.current_element = null;

    // This event listener will abort itself after a Tab key is pressed for the
    // first time.
    // It will display a button to skip to main content if a Tab is pressed.
    this.tab_controller = new AbortController();
    document.addEventListener('keydown', (e) =>
    {
      switch (e.key)
      {
        case 'Tab':
          e.preventDefault();
          // save currently focused element
          this.current_element = document.activeElement;
          // check if there's content to skip to
          if (this.getContent())
          {
            // show the SkipNav
            this.show(e);
          }
          // turn off this event listener
          this.tab_controller.abort();
          break;
        default:
          // do nothing
      } 
    }, {signal: this.tab_controller.signal});

    this.abortOnClick = function (e)
    {
      // don't intercept clicks on the button itself
      if (e.target === this.button)
      {
        return;
      }
      this.tab_controller.abort();
      // also hide the SkipNav if currently shown
      if (this.div.classList.contains('is-open'))
      {
        this.hide();
        // and reset focus to where the user was
        if (this.current_element) this.current_element.focus();
      }
      document.removeEventListener('click', this.abortOnClick.bind(this), { capture:true, passive:true });
    }.bind(this);
    document.addEventListener('click', this.abortOnClick.bind(this), { capture:true, passive:true });
  }

In one of our inner frames, I have an event listener fulfilling a similar purpose for a different object which is added to document and that one properly responds to any click (and it does not use capture).
I am at a loss as to why this abortOnClick handler is only responding to the SkipNav div.

Any help is greatly appreciated.


Solution

  • The less you depend on scripting, the better.

    If I understand correctly, you are currently trying to add state to the skip navigation, making it usable only once. This adds additional complexity to the interface and is unexpected.

    It is unclear how the current solution actually hides the navigation via this.hide, it’s important that it’s not hidden from assistive technology, as display: none would do.

    My recommendation is to stick to the best practice of visually hiding the skip button via CSS and relying on the natural tab order.

    The following code is invalid HTML and will not work with a HTML 5 doctype. In a frameset doctype, no elements other than frameset and frame are allowed.

    const div = document.createElement('div');
    div.setAttribute('id', 'skip_nav_div');
    div.setAttribute('role', 'navigation');
    div.setAttribute('aria-label', 'skip navigation');
    div.className = 'SkipNavigation';
    
    const button = document.createElement('button');
    button.setAttribute('id', 'skip_nav_button');
    button.textContent = 'skip to main content';
    button.classList = 'btn btn-light visually-hidden';
    button.addEventListener('click', () => document.querySelector('#main').focus());
    
    div.appendChild(button);
    document.body.insertBefore(div, document.body.firstChild);
    frame {
      border: 1px dashed grey;
    }
    
    .SkipNavigation {
      position: absolute;
    }
    
    /* https://www.scottohara.me/blog/2017/04/14/inclusively-hidden.html#hiding-content-visually */
    
    .visually-hidden:not(:focus):not(:active) {
      clip: rect(0 0 0 0);
      clip-path: inset(50%);
      height: 1px;
      overflow: hidden;
      position: absolute;
      white-space: nowrap;
      width: 1px;
    }
    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">
    <html>
    
    <!--
    <frameset rows="100,">
      <frame title="Nav" src="https://example.com/" />
      <frame title="Main content" id="main" src="https://example.com/" />
    </frameset>
    -->
    
    <div id="main" tabindex="-1">Main for demonstration</div>
    
    <p><a href="#">Some link to demonstrate focus order</a></p>
    </html>

    Provide proper landmarks

    As far as I know, screen readers already have a frame-navigation built in, so we should support this by providing useful names.

    This example uses the attribute selector to find the frames by their src attribute, in case they don’t offer names or IDs.

    document.querySelector('frame[src*="navigation"]').title = "Navigation";
    document.querySelector('frame[src*="index"]').title ="Main content";
    …