Skip to content

Instantly share code, notes, and snippets.

@Skateside
Created September 30, 2018 12:32
Show Gist options
  • Save Skateside/d595151f253d65739777a55282e606d7 to your computer and use it in GitHub Desktop.
Save Skateside/d595151f253d65739777a55282e606d7 to your computer and use it in GitHub Desktop.
Thinking aloud about a WAI-ARIA library
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");
@james-jlo-long
Copy link

Allow ARIA attributes to be aliased. For example, US English spelling would be "labeledby" but WAI-ARIA spec used British English spelling "labelledby".

ARIA.factories = {
    labelledby: ARIA.ReferenceCollection
};

ARIA.translate = {};


ARIA.addAlias = function (source, aliases) {

    if (!Array.isArray(aliases)) {
        aliases = [aliases];
    }

    if (!ARIA.factories[source]) {
        throw new ReferenceError(`ARIA.factories.${source} does not exist`);
    }

    aliases.forEach((alias) => {

        ARIA.translate[`aria-${alias}`] = `aria-${source}`;
        ARIA.factories[alias] = ARIA.factories[source];
        
    });

};

ARIA.addAlias("labelledby", "labeledby");

function normalise(attribute) {

    let normal = String(attribute).toLowerCase();

    if (!/^aria\-/.test(normal)) {
        normal = `aria-${normal}`;
    }

    return ARIA.translate[normal] || normal;

};

@james-jlo-long
Copy link

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);
    
};

@james-jlo-long
Copy link

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();
        }
        
    }

});

@james-jlo-long
Copy link

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