Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to control tabIndex and focus trapping of elements w/o using positive values?

Maybe this is a bad idea entirely and am happy to take that as feedback; however, I'm looking for a good approach to controlling tabbable element on a document.

Requirements:

  • Api for controlling the tabbing order that is out of flow with DOM order.
  • Focus traps that COULD be cyclical and must be explicitly escaped.
  • A method to put non-tabbable elements in tab flow.
  • Written in Vanilla JS.
  • No positive tabIndex values.

Note: This is a personal thought experiment not really sure if this is a good idea or not. Will take any kind of leads: articles, libraries, code snippets but obviously the best would be an answer that meets all the requirements (or any other ideas you can muster up)!

    button {
      padding: 6px 8px;
      margin: 12px;
    }
    
    .focus-trap{
      padding: 24px;
      background: rgba(150, 150, 48, .5);
    }

    
    .now-tabbable {
      display: inline-flex;
      padding: 15px 8px;
      background: pink;
      margin: 12px;
      min-width: 45px;
      justify-content: center;
    }
<!-- 
.focus-traps:

- Are meant to declare that tab focus are order by the programmer.
- Focus-traps can be cyclical. currently denoted by class but up for any ideas data-attr etc.

.now-tabbable:
- Are meant to declare that this element is part of the tab flow.

-->

<div class="focus-trap">
  <button>ZERO &times; escape</button>
  <button>two</button>
  <button>one</button>
  <button>three</button>
  <hr>

  <div class="focus-trap cyclical">
    <h1>Trap  focus in here until escape</h1>
    <button>four - B</button>
    <button>four - C</button>
    <button>four - A &times; escape</button>
  </div>
  <div class="now-tabbable">seven</div>
  <div class="now-tabbable">five</div>
  <div class="now-tabbable">six</div>
</div>
like image 798
Armeen Harwood Avatar asked Oct 21 '25 11:10

Armeen Harwood


1 Answers

Generally speaking, you should avoid focus traps, except for modal contexts, like a modal dialogue.

The suggested solution here will instead establish a focus group with a roving tab index in which you can navigate focus by means of the arrow keys. TAB will then leave the group.

Keep in mind though, that you're using the focus group only for UI patterns where the behaviour can be expected. The ARIA standard mentions regarding Keyboard Navigation Inside Components:

[…] strongly advised to use the same key bindings as similar components in common GUI operating systems as demonstrated in § 3. Design Patterns and Widgets.

You might for example also format your buttons visually in a way that they clearly make one group, similar to a Toolbar.

class focusGroup { 
  
  constructor(el, cyclical: false) {
    this.cyclical = cyclical;
    
    // store children sorted by data-tabindex attribute
    this.children = Array.from(el.querySelectorAll('[data-tabindex]')).sort((el1, el2) => el1.getAttribute('data-tabindex') > el2.getAttribute('data-tabindex'));
    
    // remove tab index for all children
    this.children.forEach(childEl => childEl.setAttribute('tabindex', '-1'));
         
    // make first child tabbable
    this.children[0].setAttribute('tabindex', '0');
    this.i = 0;
    
    // bind arrow keys
    el.addEventListener('keyup', e => {
      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') this.next();
      if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') this.prev();
    });
  }
  
  next() {
    if (this.i < this.children.length -1) this.i += 1
    else if (this.cyclical) this.i = 0;
      
    this.updateFocus();
  }
  
  prev() {
    if (this.i > 0) this.i -= 1
    else if (this.cyclical) this.i = this.children.length -1;
    
    this.updateFocus();
  }
  
  updateFocus() {
    this.children.forEach(el => el.setAttribute('tabindex', '-1'));
    this.children[this.i].setAttribute('tabindex', '0');
    this.children[this.i].focus();
  }
} 

document.querySelectorAll('.focus-trap:not(.cyclical)').forEach(el => new focusGroup(el));

document.querySelectorAll('.focus-trap.cyclical').forEach(el => new focusGroup(el, true));
button {
      padding: 6px 8px;
      margin: 12px 0;
    }
    
    .focus-trap{
      padding: 24px;
      background: rgba(150, 150, 48, .5);
    }

    
    .now-tabbable {
      display: inline-flex;
      padding: 15px 8px;
      background: pink;
      margin: 12px;
      min-width: 45px;
      justify-content: center;
    }
<!-- 
.focus-traps:

- Are meant to declare that tab focus are order by the programmer.
- Focus-traps can be cyclical. currently denoted by class but up for any ideas data-attr etc.

.now-tabbable:
- Are meant to declare that this element is part of the tab flow.

-->

<div class="focus-trap">
  <button data-tabindex="0">ZERO &times; escape</button>
  <button data-tabindex="2">two</button>
  <button data-tabindex="1">one</button>
  <button data-tabindex="3">three</button>
</div>

<div class="focus-trap cyclical">
  <button data-tabindex>four - B</button>
  <button data-tabindex>four - C</button>
  <button data-tabindex>four - A &times; escape</button>
</div>
<div>
  <button class="now-tabbable">seven</button>
  <div class="now-tabbable">five</div>
  <div class="now-tabbable">six</div>
</div>

Api for controlling the tabbing order that is out of flow with DOM order.

The visual order then will need to align with the focus order. In the example code, you can use data-tabindex="i" to control the

Focus traps that COULD be cyclical and must be explicitly escaped.

You can call the class and provide the second argument as true to establish a wrap over or cyclical order.

A method to put non-tabbable elements in tab flow.

Only elements with the attribute data-tabindex will be focussable.

No positive tabIndex values.

You'll need to use positive data-tabindexes, but the example code will always only use tabindex="0" to make one single element focussable. Meaning if you re-enter the group by means of tab, the last focussed element will again be focussed.

like image 56
Andy Avatar answered Oct 24 '25 02:10

Andy