Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plugin): add focus tracker plugin #139

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions plugins/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ let InfoPanel = require("./shared/info-panel");
require("./style.less");

class Plugin {
constructor() {
this.panel = new InfoPanel(this);
/**
* Create a new instance of Plugin
* @param {Object} [options={}] Object defining options for the Plugin
* @param {Object} options.panel Object with Plugin-specific options for the InfoPanel class
* @param {Boolean} options.panel.disableAnnotation enable/disable the annotation checkbox
* @param {Boolean} options.panel.statusPanelView switch to the smaller Status Panel view
*/
constructor(options = {}) {
ericrallen marked this conversation as resolved.
Show resolved Hide resolved
this.panel = new InfoPanel(this, options.panel);
this.$checkbox = null;
}

Expand Down
222 changes: 222 additions & 0 deletions plugins/focus-tracker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Keep track of a user's focus as it moves through the page.
*/
ericrallen marked this conversation as resolved.
Show resolved Hide resolved

const Plugin = require("../base");

const annotate = require("../shared/annotate")("tabIndex");

// the `focus` and `blur` events do not bubble, so we're going to leverage
// the `focusin` and `focusout` events that will bubble up to the document
const FOCUS_EVENT = "focusin";
const BLUR_EVENT = "focusout";

// this will let us get a shorter info panel that just
// lets the user know we are tracking their focus
const PANEL_OPTIONS = {
statusPanelView: true
};

// map our listener events to the relevant class we want them to apply
// to the target element
const FOCUS_STATES = {
[FOCUS_EVENT]: "tota11y-outlined",
[BLUR_EVENT]: "tota11y-was-focused"
};

// we're going to highlight thos elements that are focusable and those
// that are programmatically focusable
const FOCUSABLE_CLASSES = {
natural: "tota11y-is-focusable",
programmatic: "tota11y-is-programmatically-focusable"
};

// we'll use this attribute to store anelement's tab order for our annotations
const TAB_INDEX_ATTRIBUTE = "tota11yTabIndex";

// we'll use this to make sure we don't apply the was-focused
// indicator to our tota11y panels
const IGNORE = "tota11y"

// we're going to attempt to visualize the tab order of the page
// based on the source order of the tabbable elements
// this Array contains our tabbable element selectors
// NOTE: these come primarily from the following sources
// - https://stackoverflow.com/q/1599660/656011
// - https://allyjs.io/data-tables/focusable.html
const TABABLE_ELEMENTS = [
// anchors with an href are the primary naturally focusable elements
// on any page
"a[href]:not([tabIndex^=\"-\"])",

// any inputs or buttons that aren't disabled should be naturally
// focusable and make up the rest of the common elements we'll be
// dealing with
"input:not([disabled]):not([tabIndex^=\"-\"])",
"select:not([disabled]):not([tabIndex^=\"-\"])",
"textarea:not([disabled]):not([tabIndex^=\"-\"])",
"button:not([disabled]):not([tabIndex^=\"-\"])",

// audio/video elements with controls should be naturally focusable
// though they are less common
"audio[controls]:not([tabIndex^=\"-\"])",
"video[controls]:not([tabIndex^=\"-\"])",

// these are some more obscure and esoteric elements that are also
// naturally focusable
"iframe:not([tabIndex^=\"-\"])",
"area[href]:not([tabIndex^=\"-\"])",
"summary:not([tabIndex^=\"-\"])",
"keygen:not([tabIndex^=\"-\"])",

// if an element has the contenteditable attribute, a user can focus on
// it and manipulate its content, this includes elements that normally
// would not receive focus
"[contenteditable]:not([tabIndex^=\"-\"])",

// this gives us any element with a defined tabindex that isn't negative
// an element with a non-negative tabindex is naturally focusable, but
// an element with a negative tabindex is only programmatically focusable
// and will not receive focus during normal tabbing through the page
"[tabIndex]:not([tabIndex^=\"-\"])",
];

// convenient method to quickly remove any focused element classes this
// plugin applied
// it is outside of the class because it doesn't really need
// to access this and it lets us now worry about binding
// our event handlers
const removeFocusClasses = (element) => {
element.classList.remove(...Object.values(FOCUS_STATES));
};

// convenient method to quickly remove any focusable element classes this
// plugin applied
// it is outside of the class because it doesn't really need
// to access this and it lets us now worry about binding
// our event handlers
const removeHighlightClasses = (element) => {
element.classList.remove(...Object.values(FOCUSABLE_CLASSES));
}

require("./style.less");

class FocusTracker extends Plugin {
constructor(...args) {
const options = Object.assign({}, args, { panel: PANEL_OPTIONS });

super(options);

this.currentTab = 0;

this.applyFocusClass = this.applyFocusClass.bind(this);
}

getTitle() {
return "Focus Tracker";
}

getDescription() {
return "Keep track of what's been focused as you tab through the page.";
}

applyFocusClass(event) {
// get the event target and event name
const { target, type } = event;

// remove any focused or was-focused indicators on the element
removeFocusClasses(target);

removeHighlightClasses(target);

// we want to ignore our tota11y toggle and panel because we only care
// about the user's regular DOM for this treatment
const isNotTota11y = !target.closest(`.${IGNORE}`) && !target.classList.contains(IGNORE);

if (isNotTota11y) {
// choose the class we want to add to this element
// based on whether this is the focusin or focusout event
target.classList.add(FOCUS_STATES[type]);

// when we focus in, we'll want to make sure we annitate if needed
if (type === FOCUS_EVENT) {
const tabOrder = target.dataset[TAB_INDEX_ATTRIBUTE];

const validTabOrder = !isNaN(parseInt(tabOrder, 10));

if (!validTabOrder) {
this.currentTab++;

target.dataset[TAB_INDEX_ATTRIBUTE] = this.currentTab;

annotate.label(target, `#${this.currentTab}`);
}
}
}
}

// highlight any naturally focusable elements
highlightNaturallyFocusableElements() {
const selector = TABABLE_ELEMENTS.join(", ");

[...document.querySelectorAll(selector)]
.filter((element) => {
return !element.closest(`.${IGNORE}`) && !element.classList.contains(IGNORE);
})
.forEach((element) => {
element.classList.add(FOCUSABLE_CLASSES.natural);
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I wonder if this is useful this way. Perhaps instead we could add the annotations as we tab through them? So as they get focused, we add the index accordingly?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea for the annotations was to show the expected tab order based on the source order, but I'll make an update that adds them as you tab.

I'm going to leave in the negative tabIndex annotations, though, since the user won't be ale to tab to those.

;
}

highlightProgrammaticallyFocusableElements() {
[...document.querySelectorAll("[tabIndex^=\"-\"]")].forEach((element) => {
element.classList.add(FOCUSABLE_CLASSES.programmatic);
annotate.label(element, `tabIndex: ${element.tabIndex}`);
});
ericrallen marked this conversation as resolved.
Show resolved Hide resolved
}

addHighlights() {
this.highlightNaturallyFocusableElements();
this.highlightProgrammaticallyFocusableElements();
}

run() {
// pop up our info panel to let the user know what we're doing
this.summary("Tracking Focus.");
this.panel.render();

// dynamically apply our event listeners by looping through
// our defined focus states and adding an event handler
Object.keys(FOCUS_STATES).forEach((key) => {
document.addEventListener(key, this.applyFocusClass);
});

this.addHighlights();
}

cleanup() {
// clear annotations
annotate.removeAll();

// dynamically remove our event listeners by looping through
// our defined focus states and removing the event handler
Object.keys(FOCUS_STATES).forEach((key) => {
document.removeEventListener(key, this.applyFocusClass);

// we'll also want to clean up all of the classes we added
[...document.querySelectorAll(`.${FOCUS_STATES[key]}`)].forEach((element) => {
removeFocusClasses(element);
});
});

// clean up our focusable highlights
Object.keys(FOCUSABLE_CLASSES).forEach((key) => {
[...document.querySelectorAll(`.${FOCUSABLE_CLASSES[key]}`)].forEach((element) => {
removeHighlightClasses(element);
});
});
}
}

module.exports = FocusTracker;
17 changes: 17 additions & 0 deletions plugins/focus-tracker/style.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import "../../less/variables.less";

.tota11y-is-focusable {
outline: 1px solid fadein(@highlightColor, 50%);
}

.tota11y-is-programmatically-focusable {
outline: 1px dashed fadein(@highlightColor, 50%);
}

.tota11y-was-focused {
outline: 3px dashed fadein(@highlightColor, 80%);
}

.tota11y-outlined {
outline: 3px solid fadein(@highlightColor, 100%);
}
16 changes: 9 additions & 7 deletions plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
* Exposes an array of plugin instances.
*/

let AltTextPlugin = require("./alt-text");
let ContrastPlugin = require("./contrast");
let HeadingsPlugin = require("./headings");
let LabelsPlugin = require("./labels");
let LandmarksPlugin = require("./landmarks");
let LinkTextPlugin = require("./link-text");
let A11yTextWand = require("./a11y-text-wand");
const AltTextPlugin = require("./alt-text");
const ContrastPlugin = require("./contrast");
const HeadingsPlugin = require("./headings");
const LabelsPlugin = require("./labels");
const LandmarksPlugin = require("./landmarks");
const LinkTextPlugin = require("./link-text");
const A11yTextWand = require("./a11y-text-wand");
const FocusTracker = require("./focus-tracker");

module.exports = {
default: [
Expand All @@ -24,5 +25,6 @@ module.exports = {

experimental: [
new A11yTextWand(),
new FocusTracker(),
],
};
19 changes: 15 additions & 4 deletions plugins/shared/annotate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ require("./style.less");
// and across.
const MIN_HIGHLIGHT_SIZE = 25;

// typecast to jQuery collection in case our plugin is jquery-less
const ensureJqueryCollection = (el) => (el instanceof $) ? el : $(el);
ericrallen marked this conversation as resolved.
Show resolved Hide resolved

// Polyfill fallback for IE < 10
window.requestAnimationFrame = window.requestAnimationFrame ||
function(callback) {
Expand Down Expand Up @@ -102,7 +105,9 @@ module.exports = (namespace) => {
return {
// Places a small label in the top left corner of a given jQuery
// element. By default, this label contains the element's tagName.
label($el, text=$el.prop("tagName").toLowerCase()) {
label(el, text=$el.prop("tagName").toLowerCase()) {
const $el = ensureJqueryCollection(el);

let $label = createAnnotation($el, "tota11y-label");
return $label.html(text);
},
Expand All @@ -115,7 +120,9 @@ module.exports = (namespace) => {
// object will contain a "show()" method when the info panel is
// rendered, allowing us to externally open the entry in the info
// panel corresponding to this error.
errorLabel($el, text, expanded, errorEntry) {
errorLabel(el, text, expanded, errorEntry) {
const $el = ensureJqueryCollection(el);

let $innerHtml = $(errorLabelTemplate({
text: text,
detail: expanded,
Expand Down Expand Up @@ -143,7 +150,9 @@ module.exports = (namespace) => {

// Highlights a given jQuery element by placing a translucent
// rectangle directly over it
highlight($el) {
highlight(el) {
const $el = ensureJqueryCollection(el);

let $highlight = createAnnotation($el, "tota11y-highlight");
return $highlight.css({
// include margins
Expand All @@ -154,7 +163,9 @@ module.exports = (namespace) => {

// Toggles a highlight on a given jQuery element `$el` when `$trigger`
// is hovered (mouseenter/mouseleave) or focused (focus/blur)
toggleHighlight($el, $trigger) {
toggleHighlight(el, $trigger) {
const $el = ensureJqueryCollection(el);

let $highlight;

$trigger.on("mouseenter focus", () => {
Expand Down
Loading