New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

html-aria

Package Overview
Dependencies
Maintainers
0
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

html-aria

Utilities for creating accessible HTML based on the latest ARIA 1.3 specs. Lightweight, performant, tree-shakeable, and 0 dependencies.

  • 0.1.9
  • latest
  • Source
  • npm
  • Socket score

Version published
Maintainers
0
Created
Source

html-aria

Utilities for creating accessible HTML based on the latest ARIA 1.3 specs and latest HTML in ARIA recommendations. Lightweight, performant, tree-shakeable, and 0 dependencies.

This is designed to be a better replacement for aria-query when working with HTML. The reasons are:

  • aria-query neglects the critical HTML to ARIA spec. With just the ARIA spec alone, it’s insufficient for working with HTML.
  • html-aria supports ARIA 1.3 while aria-query is still on ARIA 1.2

html-aria is also designed to be easier-to-use to prevent mistakes, smaller, is ESM tree-shakeable, and more performant (~100× faster than aria-query).

Setup

npm i html-aria

Examples

Though this library is NOT a lint plugin, it can do most of the work for you. You only need to traverse the AST of the language you’re using (e.g. HTML vs React vs Svelte), and html-aria can validate the nodes.

ESLint + React plugin

import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import {
  getSupportedAttributes,
  type AriaAttribute,
  type TagName,
} from "html-aria";

const createRule = ESLintUtils.RuleCreator(
  (name) => `https://example.com/rule/${name}`
);

export default createRule({
  name: "no-unsupported-aria",
  meta: {
    type: "problem",
    docs: { description: "Ensure that ARIA attributes match their role" },
    messages: {
      "not-allowed": "Attribute {{ name }} not allowed",
    },
  },
  create(context) {
    return {
      JSXOpeningElement(node) {
        if (node.name.type !== TSESTree.AST_NODE_TYPES.JSXIdentifier) {
          return; // this is a React component; ignore
        }

        const tagName = node.name.name as TagName;

        // 1. assemble attributes into a map
        const attributes: Record<
          string,
          string | number | boolean | undefined | null
        > = {};
        for (const attr of node.attributes) {
          if (
            attr.type === TSESTree.JSXSpreadAttribute ||
            attr.name.type === TSESTree.JSXNamespacedName ||
            attr.value?.type === TSESTree.Literal
          ) {
            continue;
          }
          attributes[attr.name.name] = attr.value.value as
            | string
            | number
            | boolean
            | undefined
            | null;
        }

        // 2. get supported attributes from html-aria (which MUST include the attributes to work properly)
        const tag: VirtualElement = { tagName, attributes };
        const supportedAttributes = getSupportedAttributes(tag);

        // 3. validate
        for (const attr of node.attributes) {
          if (attr.type !== TSESTree.AST_NODE_TYPES.JSXAttribute) {
            continue;
          }
          const name =
            typeof attr.name.name === "string"
              ? attr.name.name
              : (attr.name.name.name as ARIAAttribute);
          if (name.startsWith("aria-") && !supportedAttributes.includes(name)) {
            context.report({
              node: name,
              messageId: "not-allowed",
              data: { name },
            });
          }
        }
      },
    };
  },
});

Have an improvement to suggest? Please open a PR!

API

getRole()

Determine which HTML maps to which default ARIA role.

import { getRole } from "html-aria";

getRole(document.createElement("article")); // "article"
getRole({ tagName: "input", attributes: { type: "checkbox" } }); // "checkbox"
getRole({ tagName: "div", attributes: { role: "button" } }); // "button"

It’s important to note that inferring ARIA roles from HTML isn’t always straightforward! There are 3 types of role inference:

  1. Tag map: 1 tag → 1 ARIA role.
  2. Tag + attribute map: Tags + attributes are needed to determine the ARIA role (e.g. input[type="radio"]radio)
  3. Tag + DOM tree: Tags + DOM tree structure are needed to determine the ARIA role.

See a list of all elements.

getSupportedRoles() / isSupportedRole()

The spec dictates that certain elements may NOT receive certain roles. For example, <div role="button"> is allowed (not recommended, but allowed), but <select role="button"> is not. getSupportedRoles() will return all valid roles for a given element + attributes.

import { getSupportedRoles } from "html-aria";

getSupportedRoles(document.createElement("img")); // ["none", "presentation", "img"]
getSupportedRoles({ tagName: "img", attributes: { alt: "Image caption" } }); //  ["button", "checkbox", "link", (15 more)]

There is also a helper method isSupportedRole() to make individual assertions:

import { isSupportedRole } from "html-aria";

isSupportedRole({ tagName: "select" }, "combobox"); // true
isSupportedRole(
  { tagName: "select", attributes: { multiple: true } },
  "listbox"
); // true
isSupportedRole({ tagName: "select" }, "listbox"); // false
isSupportedRole({ tagName: "select" }, "button"); // false

getSupportedAttributes() / isSupportedAttribute()

For any element, list all supported aria-* attributes, including attributes inherited from superclasses. This takes in an HTML element, not an ARIA role, because in some cases the HTML element actually affects the list (see full list).

import { getSupportedAttributes } from "html-aria";

getSupportedAttributes({ tagName: "button" }); // ["aria-atomic", "aria-braillelabel", …]

If you want to look up by ARIA role instead, just pass in a placeholder element:

getSupportedAttributes({ tagName: "div", attributes: { role: "combobox" } });

There’s also a helper method isSupportedAttribute() to test individual attributes:

import { isSupportedAttribute } from "html-aria";

isSupportedAttribute({ tagName: "button" }, "aria-pressed"); // true
isSupportedAttribute({ tagName: "button" }, "aria-checked"); // false

It’s worth noting that HTML elements may factor in according to the spec—providing the role isn’t enough. See aria-* attributes from HTML.

getElements()

Return all HTML elements that represent a given ARIA role, if any. If no HTML elements represent this role, undefined will be returned. This is essentially the inverse of getRole().

import { getElements } from "html-aria";

getElements("button"); // [{ tagName: "button" }]
getElements("radio"); // [{ tagName: 'input', attributes: { type: "radio" } }]
getElements("rowheader"); // [{ tagName: "th", attributes: { scope: "row" } }]
getElements("tab"); // undefined

Worth noting that this is slightly-different from a related concept or base concept.

isInteractive()

Return true if a given HTML tag may be interacted with or not.

isInteractive({ tagName: "button" }); // true
isInteractive({ tagName: "div" }); // false
isInteractive({ tagName: "div", attributes: { tabindex: 0 } }); // false
isInteractive({ tagName: "div", attributes: { role: "button", tabindex: 0 } }); // true
isInteractive({ tagName: "hr" }); // false
isInteractive({
  tagName: "hr",
  attributes: { tabindex: 0, "aria-valuenow": 10 },
}); // true (see https://www.w3.org/TR/wai-aria-1.3/#separator)

The methodology for this is somewhat complex to follow the complete ARIA specification:

  1. If the role is a widget or window subclass, then it is interactive
    • Note: if the element manually specifies role, and if it natively is NOT a widget or window role, tabindex must also be supplied
  2. If the element is disabled or aria-disabled, then it is NOT interactive
  3. Handle some explicit edge cases like separator

Note that aria-hidden elements may be interactive (even if it’s not best practice) as a part of 2.4.5 Multiple Ways if an alternative is made for screenreaders, etc.

isNameRequired()

For a role, return whether or not an accessible name is required for screenreaders.

import { isNameRequired } from "html-aria";

isNameRequired("link"); // true
isNameRequired("cell"); // false

Note: this does NOT mean aria-label is required! Quite the opposite—if a name is required, it’s always best to have the name visible in content. See ARIA 1.3 Accessible Name Calculation for more info.

isValidAttributeValue()

Some aria-* attributes require specific values. isValidAttributeValue() returns false if, given a specific aria-* attribute, the value is invalid according to the spec.

import { isValidAttributeValue } from "html-aria";

// string attributes
// Note: string attributes will always return `true` except for an empty string
isValidAttributeValue("aria-label", "This is a label"); // true
isValidAttributeValue("aria-label", ""); // false

// boolean attributes
isValidAttributeValue("aria-disabled", true); // true
isValidAttributeValue("aria-disabled", false); // true
isValidAttributeValue("aria-disabled", "true"); // true
isValidAttributeValue("aria-disabled", 1); // false
isValidAttributeValue("aria-disabled", "disabled"); // false

// enum attributes
isValidAttributeValue("aria-checked", "true"); // true
isValidAttributeValue("aria-checked", "mixed"); // true
isValidAttributeValue("aria-checked", "checked"); // false

// number attributes
isValidAttribute("aria-valuenow", "15"); // true
isValidAttribute("aria-valuenow", 15); // true
isValidAttribute("aria-valuenow", 0); // true

⚠️ Be mindful of cases where a valid value may still be valid, but invoke different behavior according to the ARIA role, e.g. mixed behavior for radio/menuitemradio/switch

Reference

ARIA roles from HTML

This outlines the requirements to adhere to the W3C spec when it comes to inferring the correct ARIA roles from HTML. Essentially, there are 3 types of inference:

  1. Tag map: 1 tag → 1 ARIA role.
  2. Tag + attribute map: Tags + attributes are needed to determine the ARIA role (e.g. input[type="radio"]radio)
  3. Tag + DOM tree: Tags + DOM tree structure are needed to determine the ARIA role.

Here are all the HTML elements where either attributes, hierarchy, or both are necessary to determine the correct role. Any HTML elements not listed here follow the simple “tag map” approach (keep in mind that aria-* attributes may not follow the same rules!).

ElementRoleAttribute-basedHierarchy-based
ageneric | link
areageneric | link
footercontentinfo | generic
headerbanner | generic
inputbutton | checkbox | combobox | radio | searchbox | slider | spinbutton | textbox
lilistitem | generic
sectiongeneric | region
selectcombobox | listbox
tdcell| gridcell | —
thcolumnheader | rowheader | —

Note: = no corresponding role

aria-* attributes from HTML

Further, a common mistake many simple accessibility libraries make is mapping aria-* attributes to ARIA roles. While that mostly works, there are a few exceptions where HTML information is needed. That is why getSupportedAttributes() takes an HTML element. Here is a full list:

ElementDefault RoleNotes
audioAccepts application aria-* attributes by default
basegenericNo aria-* attributes allowed
bodygenericDoes NOT allow aria-hidden="true"
brgenericNo aria-* attributes allowed EXCEPT aria-hidden
colNo aria-* attributes allowed
colgroupNo aria-* attributes allowed
datalistlistboxNo aria-* attributes allowed
headNo aria-* attributes allowed
htmlNo aria-* attributes allowed
img (no alt)noneNo aria-* attributes allowed EXCEPT aria-hidden
input[type=checkbox]Forbids aria-checked
input[type=color]Acts as a generic element but allows aria-disabled
input[type=files]Acts as a generic element but allows aria-disabled, aria-invalid, and aria-required
input[type=hidden]No aria-* attributes allowed
input[type=radio]Forbids aria-checked
linkNo aria-* attributes allowed
mapNo aria-* attributes allowed
metaNo aria-* attributes allowed
noscriptNo aria-* attributes allowed
pictureNo aria-* attributes allowed EXCEPT aria-hidden
scriptNo aria-* attributes allowed
slotNo aria-* attributes allowed
sourceNo aria-* attributes allowed
styleNo aria-* attributes allowed
summaryAllows aria-disabled and aria-haspopup regardless of role
templateNo aria-* attributes allowed
titleNo aria-* attributes allowed
trackNo aria-* attributes allowed EXCEPT aria-hidden
videoAccepts application aria-* attributes by default
wbrNo aria-* attributes allowed EXCEPT aria-hidden

Note: = no corresponding role. Also worth pointing out that in other cases, global aria-* attributes are allowed, so this is unique to the element and NOT the ARIA role.

Technical deviations from the spec

Mark

The <mark> tag gets the mark role. Seems logical, right? Well, not according to the spec. It’s not listed in the HTML in ARIA spec, and it’s worth noting that <mark> is a related concept, not a base concept as elements usually are.

But despite the ARIA specs being pretty clear that <mark> and mark aren’t directly equivalent, all modern browsers today seem to think they are, and <mark> always gets a mark role. For that reason, html-aria has sided with practical browser implementation rather than the ARIA spec.

SVG

SVG is tricky. Though the spec says <svg> should get the graphics-document role by default, browsers chose chaos. Firefox 134 displays graphics-document, Chrome 131 defaults to image (previously it returned nothing, or other roles), and Safari defaults to generic (which is one of the worst roles you could probably give it).

Since we have 1 spec and 1 browser agreeing, this library defaults to graphics-document. Though the best answer is SVGs should ALWAYS get an explicit role.

Ancestor-based roles

In regards to ARIA roles in HTML, the spec gives non-semantic roles to <td>, <th>, and <li> UNLESS they are used inside specific containers (table, grid, or gridcell for <td>/<th>; list or menu for <li>). This library assumes they’re being used in their proper containers without requiring the ancestors array. This is done to avoid the footgun of requiring missable configuration to produce accurate results, which is bad software design.

Instead, the non-semantic roles must be “opted in” by passing an explicitly-empty ancestors array:

import { getRole } from "html-aria";

getRole({ tagName: "td" }, { ancestors: [] }); // undefined
getRole({ tagName: "th" }, { ancestors: [] }); // undefined
getRole({ tagName: "li" }, { ancestors: [] }); // "generic"

FAQ

Why the { tagName: string } object syntax?

Most of the time this library will be used in a Node.js environment, likely outside the DOM (e.g. an ESLint plugin traversing an AST). While most methods also allow an HTMLElement as input, the object syntax is universal and works in any context.

What’s the difference between “no corresponding role” and the none role?

From the spec:

No corresponding role

The elements marked with No corresponding role, in the second column of the table do not have any implicit ARIA semantics, but they do have meaning and this meaning may be represented in roles, states and properties not provided by ARIA, and exposed to users of assistive technology via accessibility APIs. It is therefore recommended that authors add a role attribute to a semantically neutral element such as a div or span, rather than overriding the semantics of the listed elements.

none role

An element whose implicit native role semantics will not be mapped to the accessibility API. See synonym presentation.

In other words, none is more of a decisive “this element is presentational and can be ignored” labeling, while “no corresponding role” means “this element doesn’t have predefined behavior that can be automatically determined, and the author should provide additional information such as explicit roles and ARIA states and properties.”

In html-aria, “no corresponding role” is represented as undefined.

What is the difference between “unsupported attributes” and “prohibited attributes?”

In the spec, you’ll find language describing both roles and attributes in 4 categories:

  1. Supported and recommended: valid and recommended to use
  2. Supported but not recommended: valid, but may cause unpredictable behavior
  3. Unsupported, but not prohibited: these are omitted both from supported and prohibited lists
  4. Unsupported and prohibited: explicitly prohibited

As stated in Project Goals, html-aria aims to not conflate non-normative recommendations as normative guidelines. So in the API, getSupportedRoles() and getSupportedAttributes() will return 1 and 2, but not 3 or 4.

While there is a technical distinction between 3 and 4, for the purposees of html-aria they’re treated the same (because 3 specifically is not explicitly allowed, we can make a choice to read it as prohibited).

About

Project Goals

  1. Implement all ARIA spec docs, not just the roles specification
  2. Stick to normative guidelines (i.e. only implement “MUST” language, not “SHOULD”—the latter is the area of linters)
  3. Reduce mistakes with explicit methods and user-friendly API design.

Keywords

FAQs

Package last updated on 27 Jan 2025

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc