Created
September 30, 2018 12:32
-
-
Save Skateside/d595151f253d65739777a55282e606d7 to your computer and use it in GitHub Desktop.
Thinking aloud about a WAI-ARIA library
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let types = [ | |
[Aria.Property, [ | |
"atomic", | |
]], | |
[Aria.ReferenceCollection, [ | |
"controls", | |
]], | |
[Aria.State, [ | |
"busy", | |
"current", | |
]], | |
[Aria.Collection, [ | |
]], | |
[Aria.Reference, [ | |
]] | |
]; | |
class Aria { | |
constructor(element) { | |
this.element = element; | |
this.controls = new Aria.Collection(this.element, "controls"); | |
} | |
} | |
Object.assign(Aria, { | |
identify() { | |
}, | |
asArray() { | |
}, | |
isNode() { | |
} | |
}); | |
class Aria.Property { | |
constructor(element, attribute) { | |
// Wouldn't it be nicer to have a single MutationObserver listening for | |
// all WAI-ARIA attribute changes? | |
let observer = new MutationObserver((mutations) => { | |
mutations.forEach((mutation) => { | |
if ( | |
mutation.type === "attributes" | |
&& mutation.attributeName === this.attribute | |
) { | |
this.property.set( | |
element.getAttribute(mutation.attributeName) | |
); | |
} | |
}); | |
}); | |
observer.observe(element, { | |
attributes: true | |
}); | |
this.observer = observer; | |
this.attribute = this.normalise(attribute); | |
this.set(this.getAttribute()); | |
} | |
stopObserving() { | |
this.observer.disconnect(); | |
} | |
normalise(attribute) { | |
let normal = String(attribute).toLowerCase(); | |
if (!normal.startsWith("aria-")) { | |
normal = `aria-${normal}`; | |
} | |
return normal; | |
} | |
interpret(value) { | |
return value; | |
} | |
set(value) { | |
this.setAttribute(this.interpret(value)); | |
} | |
get() { | |
return this.getAttribute(); | |
} | |
has() { | |
return this.hasAttribute(); | |
} | |
remove() { | |
return this.removeAttribute(); | |
} | |
setAttribute(value) { | |
if (!this.isSetting) { | |
this.isSetting = true; | |
if (value) { | |
this.element.setAttribute(this.attribute, value); | |
} else { | |
this.removeAttribute(); | |
} | |
this.isSetting = false; | |
} | |
} | |
getAttribute() { | |
return this.element.getAttribute(this.attribute); | |
} | |
hasAttribute() { | |
return this.element.hasAttribute(this.attribute); | |
} | |
removeAttribute() { | |
this.element.removeAttribute(this.attribute); | |
} | |
} | |
class Aria.State extends Aria.Property { | |
interpret(value) { | |
return ( | |
value === "mixed" | |
? value | |
: value === "true" | |
); | |
} | |
} | |
// Aria.List | |
// https://github.com/Skateside/aria/blob/upgrade/v2/src/util.js#L64 | |
class Aria.Collection extends Aria.Property { | |
constructor(element, attribute) { | |
super(element, attribute); | |
this.list = new Aria.List(this.getAttribute()); | |
} | |
set(value) { | |
this.list.forEach((value) => this.list.remove(value)); | |
this.list.add(Aria.asArray(value)); | |
} | |
add(...values) { | |
this.list.add(...values.map((value) => this.interpret(value))); | |
this.setAttribute(this.list); | |
} | |
remove(...values) { | |
this.list.remove(...values.map((value) => this.interpret(value))); | |
this.setAttribute(this.list); | |
} | |
constains(value) { | |
return this.list.contains(this.interpret(value)); | |
} | |
} | |
class Aria.Reference extends Aria.Property { | |
interpret(value) { | |
return Aria.Reference.interpret(value); | |
}, | |
getRef() { | |
return document.getElementById(this.get()); | |
} | |
} | |
Aria.Reference.interpret = function (value) { | |
return ( | |
Aria.isNode(value) | |
? Aria.identify(value) | |
: value | |
); | |
}; | |
class Aria.ReferenceCollection extends Aria.Collection { | |
interpret(value) { | |
return Aria.Reference.interpret(value); | |
} | |
} | |
// https://github.com/LeaVerou/bliss/issues/49 | |
Object.defineProperty(Node.prototype, "aria", { | |
configurable: true, | |
get: function getter() { | |
Object.defineProperty(Node.prototype, "aria", { | |
get: undefined | |
}); | |
Object.defineProperty(this, "aria", { | |
get: new Aria(this) | |
}); | |
Object.defineProperty(Node.prototype, "aria", { | |
get: getter | |
}); | |
return this.aria; | |
} | |
}); | |
element.aria.controls.add(button); | |
button.aria.role.add("button"); |
Some ideas about replacing Proxy
with defineProperty
for IE11. Also worth pre-loading existing attributes.
ARIA.Element.addMethod("init", function (element) {
this.element = element;
this.observe();
Object.keys(ARIA.factories).forEach(function (attribute) {
var value;
Object.defineProperty(this, attribute, {
get: function () {
if (!value) {
value = new ARIA.factories[attribute](
this.element,
ARIA.normalise(attribute)
);
}
return value;
},
set: function (value) {
this[attribute].set(value);
}
});
}, this);
this.readAttributes();
});
ARIA.Element.prototype.readAttributes = function () {
Array.from(this.element.attributes, function (attribute) {
var name = attribute.name.replace(/^aria\-/, "");
if (Object.prototype.hasOwnProperty.call(this, name)) {
this[name] = attribute.value;
}
}, this);
};
Thinking through the chain, this doesn't seem to have any serious performance issues (tested extremely quickly/basically). Here's the chain for aria-hidden
and aria-checked
:
var ARIA = {};
// https://gist.github.com/Skateside/39e526240f1065203a04
ARIA.createClass = (function () {
'use strict';
// Basic no-operation function
var noop = function () {
return;
},
// Tests to see whether or not regular expressions can be called on
// Functions.
fnTest = (/return/).test(noop)
? (/[\.'"]\$super\b/)
: (/.*/);
// Basic function for looping over objects.
function forIn(obj, handler, context) {
Object.keys(obj).forEach(function (key) {
handler.call(context, key, obj[key]);
});
}
// Basic function for extending one object with keys of another.
function augment(source, additional) {
forIn(additional, function (name, method) {
source[name] = method;
});
return source;
}
function addMethod(name, method) {
var parent = this.parent;
this.prototype[name] = (typeof method === 'function' &&
typeof parent[name] === 'function' &&
fnTest.test(method))
? function () {
var hasSuper = '$super' in this,
temp = this.$super,
ret = null;
this.$super = parent[name];
ret = method.apply(this, arguments);
if (hasSuper) {
this.$super = temp;
} else {
delete this.$super;
}
return ret;
}
: method;
}
function addMethods(proto) {
forIn(proto, this.addMethod, this);
}
function extendClass(name, method) {
if (name && typeof name === 'object') {
addMethods.call(this, name);
} else {
addMethod.call(this, name, method);
}
}
return function (Base, proto) {
// Base function for the new class. All new classes push everything into
// an init method.
function Class() {
return this.init.apply(this, arguments);
}
// Allow the Base to be optional.
if (!proto) {
proto = Base;
Base = Object;
}
// Expose a prototype extension method that enables the $super magic
// method.
augment(Class, {
addMethod: addMethod,
addMethods: addMethods,
extend: extendClass,
parent: Base.prototype
});
// Inherit from Base.
Class.prototype = Object.create(Base.prototype);
// Add all methods to the new prototype.
addMethods.call(Class, proto);
// Basic constructor hack.
Class.prototype.constructor = Class;
// Allow a class to me made without a constructor function.
if (typeof Class.prototype.init !== 'function') {
Class.prototype.init = noop;
}
// Return the constructor.
return Class;
};
}());
ARIA.Property = ARIA.createClass({
init: function (element, attribute) {
var that = this;
that.element = element;
that.attribute = attribute;
that.set(that.get());
Object.defineProperty(that, "value", {
get: function () {
return that.get();
}
});
that.exists = that.has;
},
interpret: function (value) {
return value;
},
set: function (value) {
this.setAttribute(this.interpret(value));
},
get: function () {
return this.getAttribute();
},
has: function () {
return this.hasAttribute();
},
remove: function () {
return this.removeAttribute();
},
setAttribute: function (value) {
if (!this.isSetting) {
this.isSetting = true;
value = String(value);
if (value) {
this.element.setAttribute(this.attribute, value);
} else {
this.removeAttribute();
}
this.isSetting = false;
}
},
getAttribute: function () {
return this.element.getAttribute(this.attribute);
},
hasAttribute: function () {
return this.element.hasAttribute(this.attribute);
},
removeAttribute: function () {
this.element.removeAttribute(this.attribute);
}
});
ARIA.State = ARIA.createClass(ARIA.Property, {
interpret: function (value) {
return value === true || value === "true";
}
});
ARIA.MixedState = ARIA.createClass(ARIA.State, {
interpret: function (value) {
return (
value === "mixed"
? value
: this.$super(value)
);
}
});
ARIA.DisappearingState = ARIA.createClass(ARIA.State, {
set: function (value) {
var interpretted = this.interpret(value);
if (interpretted) {
this.setAttribute(interpretted);
} else {
this.removeAttribute();
}
}
});
Playing with lists and references.
var lists = new WeakMap();
var isValidToken = function (value) {
if (value === "") {
throw new Error("Empty value");
}
if (value.includes(" ")) {
throw new Error("Disallowed characer");
}
return true;
};
var makeIterator = function (instance, valueMaker) {
var index = 0;
var list = lists.get(instance) | [];
var length = list.length;
return {
next() {
var iteratorValue = {
value: valueMaker(list, index),
done: index < length
};
index += 1;
return iteratorValue;
}
};
};
ARIA.List = ARIA.createClass(ARIA.Property, {
init: function (element, attribute) {
let that = this;
lists.set(that, []);
Object.defineProperty(that, "length", {
get: function () {
return lists.get(that).length;
}
});
this.$super(element, attribute);
},
interpret: function (value) {
var string = String(value).trim();
return (
string.length
? string.split(/\s+/)
: []
);
},
set: function (value) {
var values = this.interpret(value);
this.remove.apply(this, this.toArray());
if (values.length) {
this.add.apply(this, values);
}
this.setAttribute(this.toString());
},
get: function () {
return this.toString();
},
has: function (item) {
return (
item === undefined
? this.hasAttribute()
: this.contains(item)
);
},
toString: function (glue) {
if (glue === undefined) {
glue = " ";
}
return lists.get(this).join(glue);
},
add: function () {
var list = lists.get(this);
if (arguments.length) {
Array.from(arguments, function (item) {
if (isValidToken(item) && list.indexOf(item) < 0) {
list.push(item);
}
});
this.setAttribute(this.toString());
}
},
remove: function () {
var list = lists.get(this);
if (arguments.length) {
Array.from(arguments, function (item) {
var index = isValidToken(item) && list.indexOf(item);
if (index > -1) {
list.splice(index, 1);
}
});
this.setAttribute(this.toString());
} else {
list.length = 0;
this.removeAttribute();
}
},
contains: function (item) {
return isValidToken(item) && lists.get(this).indexOf(item) > -1;
},
item: function (index) {
return lists.get(this)[Math.floor(index)] || null;
},
replace: function (oldToken, newToken) {
var isReplaced = false;
var list;
var index;
if (isValidToken(oldToken) && isValidToken(newToken)) {
list = lists.get(this);
index = list.indexOf(oldToken);
if (index > -1) {
list.splice(index, 1, newToken);
isReplaced = true;
}
}
return isReplaced;
},
forEach: function (handler, context) {
lists.get(this).forEach(handler, context);
},
toArray: function (map, context) {
return Array.from(lists.get(this), map, context);
},
entries: function () {
return makeIterator(this, function (list, index) {
return [index, list[index]];
});
},
keys: function () {
return makeIterator(this, function (list, index) {
return index;
});
},
values: function () {
return makeIterator(this, function (list, index) {
return list[index];
});
}
});
if (window.Symbol && Symbol.iterator) {
ARIA.List.prototype[Symbol.iterator] = ARIA.List.prototype.values;
}
var counter = 0;
ARIA.defaultPrefix = "anonymous-element-";
ARIA.identify = function (element, prefix) {
var id = element.id;
if (prefix === undefined) {
prefix = ARIA.defaultPrefix;
}
if (!id) {
do {
id = prefix + counter;
counter += 1;
} while (document.getElementById(id));
element.id = id;
}
return id;
};
// untested
ARIA.ReferenceList = ARIA.createClass(ARIA.List, {
// needs tidying.
interpret: function (value) {
if (value instanceof Node) {
value = [ARIA.identify(value)];
} else if (typeof value === "string") {
value = this.$super(value);
} else if (value.length) {
value = Array.from(value, function (item) {
if (item instanceof Node) {
item = ARIA.identify(item);
}
return item;
});
}
return value;
},
getById: function (id) {
return document.getElementById(id);
},
getRefs: function () {
return this.toArray(this.getById);
},
getRef: function () {
return this.getById(this.item(0));
},
hasRef: function () {
return this.getRef() !== null;
}
});
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Allow ARIA attributes to be aliased. For example, US English spelling would be "labeledby" but WAI-ARIA spec used British English spelling "labelledby".