tab-indexer
v2.0.1
Published
Tab indexer counter with context to make tabIndex settings in a project easy.
Downloads
361
Maintainers
Readme
tab-indexer
A tiny context counter for tabIndex. Zero dependencies. ESM + CommonJS.
Setting tabIndex is one of those jobs that looks trivial until you actually do
it. Components render in a different order than they appear. Someone adds a field
and the whole tab order shifts by one. A modal opens and Tab happily walks you
through the sixty inputs behind it. In plain HTML it is annoying; in React it
is a part-time job.
I built a system once where every bit of navigation had to work from the keyboard
alone — modals opening and closing, focus that had to stay where it belonged.
tabIndex needed constant babysitting. So I wrote a counter.
That is all tabIndexer is: a counter, keyed by a string you choose. Ask it
for a number, it gives you the next one for that key. Everything else in this
README is convenience on top of that one idea.
Install
npm install tab-indexerThe counter
Call it as a template tag, or with a plain string — same thing:
import tabIndexer from "tab-indexer";
tabIndexer`nav`; // 1
tabIndexer`nav`; // 2
tabIndexer("nav"); // 3 — string form, handy for a dynamic key
tabIndexer`side`; // 1 — different key, its own countIn a component you just drop it into tabIndex:
function Header() {
return (
<>
<a href="/" tabIndex={tabIndexer`header`} /> {/* 1 */}
<input aria-label="search" tabIndex={tabIndexer`header`} /> {/* 2 */}
<button tabIndex={tabIndexer`header`} /> {/* 3 */}
</>
);
}Start value and step
Two optional arguments: the start value and the step. Pass them once, before you start counting:
tabIndexer`form${100}`; // sets start to 100, returns 100
tabIndexer`form`; // 101
tabIndexer`form`; // 102
tabIndexer`grid${0}${10}`; // start 0, step 10, returns 0
tabIndexer`grid`; // 10
tabIndexer`grid`; // 20I give each region of the app its own key and its own starting number — header at
1, main form at 100, footer at 900. tabIndex values do not have to be
contiguous; the browser only cares about the order, so leaving gaps between
regions costs nothing and saves you renumbering later.
The third argument used to be called
numerator. It was always just the step. The name is fixed; the behaviour is not.
Reading and resetting
tabIndexer.peek("nav"); // current value, WITHOUT advancing. Unknown key → 0
tabIndexer.reset("nav"); // back to the start value (0, or whatever you set)
tabIndexer.reset(); // reset every known key
tabIndexer.contexts(); // { header: 3, form: 102, grid: 20 } — a snapshotreset is the one you will actually need. Any time you call tabIndexer from
code that runs more than once — a component that re-renders — the counter keeps
climbing unless you reset it at the top of each pass. If you forget, you will get
a console warning after a thousand advances telling you exactly which key ran
away. (Development only. It is not going to nag your users.)
If you use React, @tab-indexer/react
does the resetting for you — skip to the bottom.
Isolated instances
The default export is a shared singleton. Every import in your app talks to
the same counters, which is usually what you want.
When it is not — a unit test, server-side rendering where one instance per request keeps users from leaking into each other, a self-contained widget — make your own:
import { createTabIndexer } from "tab-indexer";
const tabIndexer = createTabIndexer(); // its own keys, checkers and regionsNamed exports: createTabIndexer (the factory) and tabIndexer (the very same
singleton as the default export). In CommonJS the default is on .default:
const { tabIndexer, createTabIndexer } = require("tab-indexer");Regions — the modal problem
Here is the part that used to be hard. A modal opens. Everything behind it still
has a tabIndex, so Tab still visits it. A negative tabIndex takes an
element out of the order — but now you are the one tracking which elements to
flip, and when, and back again. That is the B.S. this utility was supposed to
save you from.
So: regions.
tabIndexer.region("modal").activate();While a region is active, only its key produces real numbers — every other key
returns -1. Deactivate it and everything comes back exactly where it was.
tabIndexer`page`; // 1
tabIndexer.region("modal").activate(); // modal's counter is reset for you
tabIndexer`modal`; // 1
tabIndexer`modal`; // 2
tabIndexer`page`; // -1 ← gated
tabIndexer.region("modal").deactivate();
tabIndexer`page`; // 2 ← carries onPut activate() where the modal opens, deactivate() where it closes. That is
the whole integration.
Nesting
Regions stack. Open a confirmation dialog on top of the modal and the modal is suspended, not cleared — close the dialog and the modal is the active region again:
tabIndexer.region("modal").activate(); // stack: [modal]
tabIndexer.region("confirm").activate(); // stack: [modal, confirm]
tabIndexer`modal`; // -1 ← suspended
tabIndexer`confirm`; // 1
tabIndexer.region("confirm").deactivate(); // stack: [modal]
tabIndexer`modal`; // resumesRegion API
const modal = tabIndexer.region("modal"); // cached handle, string or tag form
modal.activate(); // → the region (chainable); resets its counter
modal.activate({ reset: false }); // ...unless you say not to
modal.deactivate(); // → the region
modal.active; // is it the top of the stack right now?
tabIndexer.activeRegions(); // ["modal", "confirm"] — oldest firstRegions handle order. They do not trap focus and they do not restore focus when the modal closes — pair them with a focus-trap library for that. Ordering and trapping are two different jobs and this package only claims one of them.
Checkers (the old way, still here)
Before regions there were setChecker / clearChecker — a predicate that runs
on every advance and can force a -1. For the modal case, region() replaces
it and handles nesting properly, so prefer that. But a per-key checker is
still useful when you want to skip or veto individual values within a key:
// skip odd results in "grid"
tabIndexer.setChecker`grid${(currentValue, step) =>
(currentValue + step) % 2 ? -1 : 1}`;
tabIndexer.clearChecker`grid`; // remove itA checker returning 0, a positive number, or true lets the advance through;
< 0 or false forces -1. The global form — setChecker(fn) with no key — is
superseded by region() and may be deprecated in a future major.
React
@tab-indexer/react wraps
all of the above:
import { TabIndexProvider, TabScope, useTabIndex } from "@tab-indexer/react";
function Form() {
const tab = useTabIndex("form"); // resets itself every render
return (
<>
<input tabIndex={tab()} /> {/* 1 */}
<input tabIndex={tab()} /> {/* 2 */}
</>
);
}
// <TabScope name="modal"> around the dialog activates the region while mounted.
// <TabIndexProvider> at the root ties it all together (and isolates SSR).API
| | |
| --- | --- |
| tabIndexer`key` / tabIndexer("key") | advance and return the next index |
| tabIndexer`key${start}${step}` | set start and step (returns start) |
| tabIndexer.peek(key) | current value, no advance; unknown → 0 |
| tabIndexer.reset(key?) | reset one key, or all, to the start value |
| tabIndexer.contexts() | { [key]: value } snapshot |
| tabIndexer.region(name) | region handle: .activate({reset?}), .deactivate(), .active |
| tabIndexer.activeRegions() | active region names, oldest first |
| tabIndexer.setChecker / .clearChecker | per-key value veto (legacy) |
| createTabIndexer() | a fresh, isolated instance |
Have a good productive day :)
If this saved you an afternoon, you can buy me a coffee.
- MIT license
- Copyright © Itay Merchav
