ruslansagitov/loud

View on GitHub
lib/a11y-node.js

Summary

Maintainability
F
5 days
Test Coverage
A
100%
/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2014-2024 Ruslan Sagitov
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to
 * deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */
'use strict';

const ROLE_FROM_TAG = require('./role-from-tag'),
    {flatten, toArray, capitalize, forEach} = require('./util');

const TAG_NO_ROLE = new Set([
    'base',
    'head',
    'html',
    'keygen',
    'label',
    'meta',
    'meter',
    'noscript',
    'optgroup',
    'param',
    'script',
    'source',
    'style',
    'template',
    'title',
]);

const TAG_STRONG_ROLE = new Set([
    'area',
    'datalist',
    'fieldset',
    'footer',
]);

const TAG_CAN_BE_PRESENTATION = new Set([
    'aside',
    'fieldset',
    'footer',
    'h1',
    'h2',
    'h3',
    'h4',
    'h5',
    'h6',
    'header',
    'hr',
    'iframe',
    'li',
    'main',
    'menu',
    'nav',
    'object',
    'ol',
    'ul',
    'div',
    'span',
]);

const TAG_NO_CLOSING = new Set([
    'hr',
    'img',
    'input',
    'menuitem',
    'progress',
]);

const INPUT_TYPE_NO_ROLE = new Set([
    'color',
    'date',
    'datetime',
    'file',
    'hidden',
    'month',
    'time',
    'week',
]);

const TAG_TO_ROLE_RESTRICTIONS = {
    address: {
        contentinfo: 1
    },
    a: {
        link: 1,
        button: 1,
        checkbox: 1,
        menuitem: 1,
        menuitemcheckbox: 1,
        menuitemradio: 1,
        tab: 1,
        treeitem: 1
    },
    article: {
        article: 1,
        document: 1,
        application: 1,
        main: 1
    },
    aside: {
        complementary: 1,
        note: 1,
        search: 1
    },
    audio: {
        application: 1
    },
    body: {
        application: 1
    },
    button: {
        button: 1,
        link: 1,
        menuitem: 1,
        menuitemcheckbox: 1,
        menuitemradio: 1,
        radio: 1
    },
    embed: {
        application: 1,
        document: 1,
        img: 1
    },
    h1: {
        heading: 1,
        tab: 1
    },
    h2: {
        heading: 1,
        tab: 1
    },
    h3: {
        heading: 1,
        tab: 1
    },
    h4: {
        heading: 1,
        tab: 1
    },
    h5: {
        heading: 1,
        tab: 1
    },
    h6: {
        heading: 1,
        tab: 1
    },
    iframe: {
        application: 1,
        document: 1,
        img: 1
    },
    li: {
        listitem: 1,
        menuitem: 1,
        menuitemcheckbox: 1,
        menuitemradio: 1,
        option: 1,
        tab: 1,
        treeitem: 1
    },
    menu: {
        directory: 1,
        list: 1,
        listbox: 1,
        menu: 1,
        menubar: 1,
        tablist: 1,
        toolbar: 1,
        tree: 1
    },
    object: {
        application: 1,
        document: 1,
        img: 1
    },
    ol: {
        directory: 1,
        list: 1,
        listbox: 1,
        menu: 1,
        menubar: 1,
        tablist: 1,
        toolbar: 1,
        tree: 1
    },
    ul: {
        directory: 1,
        group: 1,
        list: 1,
        listbox: 1,
        menu: 1,
        menubar: 1,
        tablist: 1,
        toolbar: 1,
        tree: 1
    },
    video: {
        application: 1
    }
};

const ROLE_CONTEXT = {
    columnheader: {
        row: 1
    },
    gridcell: {
        row: 1
    },
    listitem: {
        group: 1,
        list: 1
    },
    menuitem: {
        group: 1,
        menu: 1,
        menubar: 1
    },
    menuitemcheckbox: {
        menu: 1,
        menubar: 1
    },
    menuitemradio: {
        group: 1,
        menu: 1,
        menubar: 1
    },
    option: {
        listbox: 1,
        group: 1
    },
    row: {
        table: 1,
        grid: 1,
        rowgroup: 1,
        treegrid: 1
    },
    rowgroup: {
        table: 1,
        grid: 1
    },
    rowheader: {
        row: 1
    },
    tab: {
        tablist: 1
    },
    treeitem: {
        group: 1,
        tree: 1
    }
};

const ROLE_GROUP_CONTEXT = {
    group: {
        listitem: {
            list: 1
        },
        menuitem: {
            menu: 1
        },
        treeitem: {
            tree: 1
        }
    },
    rowgroup: {
        row: {
            grid: 1
        }
    }
};

const ROLE_DESCENDANTS = {
    /*combobox: {
        listbox: 1,
        textbox: 1
    },*/
    grid: {
        row: 1,
        rowgroup: {
            row: 1
        }
    },
    list: {
        group: {
            listitem: 1
        },
        listitem: 1
    },
    listbox: {
        group: {
            option: 1
        },
        option: 1
    },
    menu: {
        group: {
            menuitemradio: 1
        },
        menuitem: 1,
        menuitemcheckbox: 1,
        menuitemradio: 1
    },
    radiogroup: {
        radio: 1
    },
    row: {
        columnheader: 1,
        gridcell: 1,
        rowheader: 1
    },
    rowgroup: {
        row: 1
    },
    table: {
        row: 1,
        rowgroup: {
            row: 1
        }
    },
    tablist: {
        tab: 1
    },
    tree: {
        group: {
            treeitem: 1
        },
        treeitem: 1
    },
    treegrid: {
        row: 1
    }
};

const ROLE_INLINE_VALUE = new Set([
    'textbox',
    'combobox',
    'menuitem',
    'listbox',
    'slider',
]);

const TAG_HAS_ALT = new Set([
    'applet',
    'area',
    'img',
    'input',
]);

const TAG_HAS_HREF = new Set([
    'a',
    'link',
]);

const HYPERLINK_TYPES = new Set([
    'alternative',
    'author',
    'help',
    'license',
    'next',
    'prev',
    'search',
]);

const ACCESSIBLE_NAME_FROM_CONTENTS = new Set([
    'button',
    'checkbox',
    'columnheader',
    /*'directory',*/
    'gridcell',
    'heading',
    'link',
    'listitem',
    'menuitem',
    'menuitemcheckbox',
    'menuitemradio',
    'option',
    'radio',
    /*'row',*/
    /*'rowgroup',*/
    'rowheader',
    'tab',
    'tooltip',
    'treeitem',
    'presentation',
]);

const ABSTRACT_ROLES = new Set([
    'command',
    'composite',
    'input',
    'landmark',
    'range',
    'roletype',
    'section',
    'sectionhead',
    'select',
    'structure',
    'widget',
    'window',
]);

const ATTR_VALUES = {
    autocomplete: {
        inline: 1,
        list: 1,
        both: 1,
        none: 1
    },
    orientation: {
        vertical: 1,
        horizontal: 1,
        none: 1
    },
    sort: {
        ascending: 1,
        descending: 1,
        none: 1,
        other: 1
    },
    dropeffect: {
        copy: 1,
        move: 1,
        link: 1,
        execute: 1,
        popup: 1
    },
    live: {
        polite: 1,
        assertive: 1
    },
    relevant: {
        additions: 1,
        removals: 1,
        text: 1,
        all: 1
    }
};

const TAG_HEADING_LEVEL = {
    h1: '1',
    h2: '2',
    h3: '3',
    h4: '4',
    h5: '5',
    h6: '6'
};

class A11yNode {
    constructor(node) {
        this.node = node;

        this.childs = [];

        this.nodeType = node.nodeType;
        this.nodeValue = node.nodeValue;
        this.value = node.value;
        this.textContent = node.textContent || node.innerText;
        this.tag = node.nodeName.toLowerCase();

        return this;
    }

    parse() {
        let node = this.node.firstChild,
            newNode;

        this.childs = [];
        for (; node; node = node.nextSibling) {
            newNode = new A11yNode(node).parse();
            newNode.parentNode = this;

            this.childs.push(newNode);
        }

        let that = this;

        this.childs.forEach((child, idx) => {
            child.nextSibling = that.childs[idx + 1];
        });

        this.firstChild = this.childs[0];

        return this;
    }

    free() {
        delete this.node;

        delete this.parentNode;
        delete this.firstChild;
        delete this.nextSibling;

        this.childs.forEach(child => child.free());

        delete this.childs;
    }

    setIds(inst) {
        if (this.nodeType === 1) {
            let id = this.getAttribute('id');
            if (id) {
                inst.setElementId(id, this);
            }
        }

        let node = this.firstChild;
        for (; node; node = node.nextSibling) {
            node.setIds(inst);
        }

        return this;
    }

    getElementsByTagName(name) {
        let node = this.firstChild,
            nodes = [];

        if (this.tag === name) {
            nodes.push(this);
        }

        for (; node; node = node.nextSibling) {
            nodes.push(node.getElementsByTagName(name));
        }

        return flatten(nodes);
    }

    /* proxy */
    hasAttribute(...args) {
        return this.node.hasAttribute(...args);
    }

    getAttribute(...args) {
        return this.node.getAttribute(...args);
    }

    /* utils */
    isEmbeddedControl() {
        return ROLE_INLINE_VALUE.has(this.role);
    }

    /* istanbul ignore next: untestable */ isHyperlink() {
        let rel = this.getAttribute('rel') || '';
        rel = rel.toLowerCase();
        return HYPERLINK_TYPES.has(rel);
    }

    isNodeNonEmpty(node) {
        node = node.firstChild;
        for (; node; node = node.nextSibling) {
            if (node.nodeType === 1) {
                if (node.hidden) {
                    continue;
                }

                if (TAG_NO_CLOSING.has(node.tag)) {
                    return true;
                }

                if (this.isNodeNonEmpty(node)) {
                    return true;
                }
            } else if (node.nodeType === 3 &&
                       node.nodeValue !== '') {
                return true;
            }
        }

        return false;
    }

    isEmpty() {
        return !this.isNodeNonEmpty(this);
    }

    isPresentation() {
        return this.role === 'presentation';
    }

    isStrongRole() {
        return TAG_STRONG_ROLE.has(this.tag);
    }

    hasParent(parentName) {
        let node = this.parentNode;
        for (; node; node = node.parentNode) {
            if (node.tag === parentName) {
                return node;
            }
        }

        return false;
    }

    mustNoRole() {
        if (this.tag === 'input') {
            let type = this.getAttribute('type') || 'text';
            type = type.toLowerCase();
            return INPUT_TYPE_NO_ROLE.has(type);
        }
        return TAG_NO_ROLE.has(this.tag);
    }

    mayBePresentation() {
        return TAG_CAN_BE_PRESENTATION.has(this.tag);
    }

    mayTransitionToRole(role) {
        let allowed = TAG_TO_ROLE_RESTRICTIONS[this.tag];
        if (!allowed) {
            return true;
        }
        return allowed && allowed[role];
    }

    mayAccessibleNameFromContents() {
        let role = this.role;
        return (!role || ACCESSIBLE_NAME_FROM_CONTENTS.has(role));
    }

    isInputInsideLabel() {
        let id = this.getAttribute('id');
        if (id && this.tag === 'input') {
            let node = this.parentNode;
            for (; node; node = node.parentNode) {
                if (node.tag === 'label' &&
                    node.getAttribute('for') === id) {
                    return true;
                }
            }
        }
    }

    mayHaveAlt() {
        return TAG_HAS_ALT.has(this.tag);
    }

    mayHaveHref() {
        return TAG_HAS_HREF.has(this.tag);
    }

    getRoleFromAttr() {
        if (this.nodeType !== 1) {
            return;
        }

        let roles = this.getAttribute('role');
        if (!roles) {
            return;
        }

        return roles
            .split(/\s+/)
            .filter(role => !ABSTRACT_ROLES.has(role))
            .filter(str => str)
            .shift();
    }

    hasOnlyTextChilds() {
        let node = this.firstChild;
        for (; node; node = node.nextSibling) {
            if (node.nodeType !== 3) {
                return false;
            }
        }

        return true;
    }

    getTextContentFromDirectChild(childName) {
        let node = this.firstChild,
            iter;
        for (; node; node = node.nextSibling) {
            if (node.nodeType !== 1) {
                continue;
            }

            if (node.tag === childName) {
                iter = node.firstChild;
                for (; iter; iter = iter.nextSibling) {
                    if (iter.nodeType !== 3) {
                        return '';
                    }
                }

                return node.textContent;
            }
        }
    }

    /* relationship */
    ownedByValidRolesFor(role) {
        let context = ROLE_CONTEXT[role];
        if (!context) {
            return true;
        }

        let node = this.parentNode,
            parentRole;
        for (; node; node = node.parentNode) {
            if (typeof node.role !== 'undefined') {
                parentRole = node.role;
                if (context[parentRole]) {
                    context = ROLE_GROUP_CONTEXT[parentRole];
                    if (!context) {
                        return true;
                    }

                    context = context[role];
                    if (!context) {
                        return true;
                    }
                } else {
                    break;
                }
            }
        }

        return false;
    }

    ownsValidRolesFor(role) {
        let limits = ROLE_DESCENDANTS[role];
        if (!limits) {
            return true;
        }

        return this.isDescendantsValid(limits);
    }

    isDescendantsValid(limits) {
        let node = this.firstChild,
            nodeCount = 0,
            role, lim;
        for (; node; node = node.nextSibling) {
            if (node.nodeType !== 1) {
                continue;
            }

            if (!node.part) {
                role = node.role;
                if (!role || role === 'presentation') {
                    if (!node.isEmpty() &&
                        !node.isDescendantsValid(limits)) {
                        return false;
                    }
                } else {
                    lim = limits[role];
                    if (!lim) {
                        return false;
                    }

                    if (lim !== 1) {
                        if (!node.isDescendantsValid(lim)) {
                            return false;
                        }
                    }
                }
            }

            nodeCount++;
        }

        return nodeCount !== 0;
    }

    getChecked() {
        /* Firefox defines node.checked on <menuitem>. */
        if (this.tag === 'menuitem') {
            return null;
        }

        if (this.tag === 'input' &&
            this.getAttribute('type') === 'checkbox' &&
            this.node.indeterminate) {
            return 'mixed';
        }

        if (this.hasAttribute('aria-checked')) {
            return this.getAttribute('aria-checked') === 'true';
        }

        return this.node.checked;
    }

    getSelected() {
        if (this.hasAttribute('aria-selected')) {
            return this.getAttribute('aria-selected') === 'true';
        }
    }

    getReadonly() {
        return (this.hasAttribute('readonly') ||
                this.getAttribute('aria-readonly') === 'true');
    }

    getRequired() {
        return this.hasAttribute('required') ||
               this.getAttribute('aria-required') === 'true';
    }

    getMultiselectable() {
        return this.hasAttribute('multiple') ||
               this.getAttribute('aria-multiselectable') === 'true';
    }

    getLevel() {
        if (this.hasAttribute('aria-level')) {
            return this.getAttribute('aria-level');
        }

        return TAG_HEADING_LEVEL[this.tag];
    }

    /* states */
    isSelected() {
        return this.node.selected;
    }

    isDisabled() {
        let fieldset = this.hasParent('fieldset');

        return (this.node.disabled ||
                this.getAttribute('aria-disabled') === 'true' ||

                this.tag === 'fieldset' &&
                this.hasAttribute('disabled') ||

                fieldset &&
                (fieldset.node.disabled ||
                 /* istanbul ignore next: untestable */
                 fieldset.hasAttribute('disabled')) &&
                !this.hasParent('legend'));
    }

    isHidden(inst) {
        let node = this.node;

        if (this.nodeType !== 1) {
            return false;
        }

        /* istanbul ignore next: untestable */
        if (this.tag === 'datalist') {
            let id = this.getAttribute('id');
            if (id) {
                let elems = inst.getElementsByTagName('input');
                elems = toArray.call(elems)
                    .filter(input => input.getAttribute('list') === id);
                if (elems.length) {
                    return true;
                }
            }
        } else if ((this.tag === 'option' || this.tag === 'optgroup') &&
                   this.hasParent('select')) {
            return false;
        }

        if (this.hasAttribute('hidden') ||
            this.getAttribute('aria-hidden') === 'true' ||
            this.tag === 'input' &&
            this.getAttribute('type') === 'hidden' ||
            node.style.visibility === 'hidden' ||
            node.style.display === 'none') {
            return true;
        }

        if (node.offsetWidth || node.offsetHeight ||
            node.getClientRects && node.getClientRects().length) {
            return false;
        }

        return true;
    }

    isInvalid() {
        return this.getAttribute('aria-invalid') === 'true';
    }
}

forEach([
    'setRole',
    'fixRole'
], item => {
    A11yNode.prototype[item] = function(inst) {
        ROLE_FROM_TAG[item].call(inst, this);

        let node = this.firstChild;
        for (; node; node = node.nextSibling) {
            if (node.nodeType === 1) {
                node[item](inst);
            }
        }

        return this;
    };
});


/* attributes */
forEach([
    'autocomplete',
    'orientation',
    'sort',
    'live'
], item => {
    let funcName = `get${capitalize(item)}`;

    A11yNode.prototype[funcName] = function() {
        let data = this.getAttribute(`aria-${item}`);
        if (data) {
            data = data.toLowerCase();
            if (ATTR_VALUES[item][data]) {
                return data;
            }
        }
    };
});

forEach([
    'dropeffect',
    'relevant'
], item => {
    let funcName = `get${capitalize(item)}`;

    A11yNode.prototype[funcName] = function() {
        let data = this.getAttribute(`aria-${item}`);
        if (data) {
            return data
                .toLowerCase()
                .split(/\s+/)
                .filter(value => ATTR_VALUES[item][value])
                .join(' ');
        }
    };
});

forEach([
    'expanded',
    'pressed',
    'grabbed'
], item => {
    let funcName = `get${capitalize(item)}`;

    A11yNode.prototype[funcName] = function() {
        let attr = `aria-${item}`;
        if (this.hasAttribute(attr)) {
            let a = this.getAttribute(attr);
            return a === 'true' ? true : (a === 'false' ? false : a);
        }
    };
});

module.exports = A11yNode;