|
|
|
@ -49,286 +49,6 @@ import fuzzymatch from '../../../../1-parse/utils/fuzzymatch.js';
|
|
|
|
|
import { is_content_editable_binding } from '../../../../../../utils.js';
|
|
|
|
|
import * as w from '../../../../../warnings.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_presentation_role(role) {
|
|
|
|
|
return presentation_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function is_hidden_from_screen_reader(tag_name, attribute_map) {
|
|
|
|
|
if (tag_name === 'input') {
|
|
|
|
|
const type = get_static_value(attribute_map.get('type'));
|
|
|
|
|
if (type === 'hidden') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aria_hidden = attribute_map.get('aria-hidden');
|
|
|
|
|
if (!aria_hidden) return false;
|
|
|
|
|
const aria_hidden_value = get_static_value(aria_hidden);
|
|
|
|
|
if (aria_hidden_value === null) return true;
|
|
|
|
|
return aria_hidden_value === true || aria_hidden_value === 'true';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function has_disabled_attribute(attribute_map) {
|
|
|
|
|
const disabled_attr_value = get_static_value(attribute_map.get('disabled'));
|
|
|
|
|
if (disabled_attr_value) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aria_disabled_attr = attribute_map.get('aria-disabled');
|
|
|
|
|
if (aria_disabled_attr) {
|
|
|
|
|
const aria_disabled_attr_value = get_static_value(aria_disabled_attr);
|
|
|
|
|
if (aria_disabled_attr_value === 'true') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {ElementInteractivity[keyof ElementInteractivity]}
|
|
|
|
|
*/
|
|
|
|
|
function element_interactivity(tag_name, attribute_map) {
|
|
|
|
|
if (
|
|
|
|
|
interactive_element_role_schemas.some((schema) => match_schema(schema, tag_name, attribute_map))
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
tag_name !== 'header' &&
|
|
|
|
|
non_interactive_element_role_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
interactive_element_ax_object_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
non_interactive_element_ax_object_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
return ElementInteractivity.Static;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_interactive_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_non_interactive_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_static_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function is_semantic_role_element(role, tag_name, attribute_map) {
|
|
|
|
|
for (const [schema, ax_object] of elementAXObjects.entries()) {
|
|
|
|
|
if (
|
|
|
|
|
schema.name === tag_name &&
|
|
|
|
|
(!schema.attributes ||
|
|
|
|
|
schema.attributes.every(
|
|
|
|
|
/** @param {any} attr */
|
|
|
|
|
(attr) =>
|
|
|
|
|
attribute_map.has(attr.name) &&
|
|
|
|
|
get_static_value(attribute_map.get(attr.name)) === attr.value
|
|
|
|
|
))
|
|
|
|
|
) {
|
|
|
|
|
for (const name of ax_object) {
|
|
|
|
|
const roles = AXObjectRoles.get(name);
|
|
|
|
|
if (roles) {
|
|
|
|
|
for (const { name } of roles) {
|
|
|
|
|
if (name === role) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {null | true | string} autocomplete
|
|
|
|
|
*/
|
|
|
|
|
function is_valid_autocomplete(autocomplete) {
|
|
|
|
|
if (autocomplete === true) {
|
|
|
|
|
return false;
|
|
|
|
|
} else if (!autocomplete) {
|
|
|
|
|
return true; // dynamic value
|
|
|
|
|
}
|
|
|
|
|
const tokens = autocomplete.trim().toLowerCase().split(regex_whitespaces);
|
|
|
|
|
if (typeof tokens[0] === 'string' && tokens[0].startsWith('section-')) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
if (address_type_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
if (autofill_field_name_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
} else {
|
|
|
|
|
if (contact_type_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
if (autofill_contact_field_name_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
} else {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (tokens[0] === 'webauthn') {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
return tokens.length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {Map<string, AST.Attribute>} attribute_map */
|
|
|
|
|
function input_implicit_role(attribute_map) {
|
|
|
|
|
const type_attribute = attribute_map.get('type');
|
|
|
|
|
if (!type_attribute) return;
|
|
|
|
|
const type = get_static_text_value(type_attribute);
|
|
|
|
|
if (!type) return;
|
|
|
|
|
const list_attribute_exists = attribute_map.has('list');
|
|
|
|
|
if (list_attribute_exists && combobox_if_list.includes(type)) {
|
|
|
|
|
return 'combobox';
|
|
|
|
|
}
|
|
|
|
|
return input_type_to_implicit_role.get(type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {Map<string, AST.Attribute>} attribute_map */
|
|
|
|
|
function menuitem_implicit_role(attribute_map) {
|
|
|
|
|
const type_attribute = attribute_map.get('type');
|
|
|
|
|
if (!type_attribute) return;
|
|
|
|
|
const type = get_static_text_value(type_attribute);
|
|
|
|
|
if (!type) return;
|
|
|
|
|
return menuitem_type_to_implicit_role.get(type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function get_implicit_role(name, attribute_map) {
|
|
|
|
|
if (name === 'menuitem') {
|
|
|
|
|
return menuitem_implicit_role(attribute_map);
|
|
|
|
|
} else if (name === 'input') {
|
|
|
|
|
return input_implicit_role(attribute_map);
|
|
|
|
|
} else {
|
|
|
|
|
return a11y_implicit_semantics.get(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_non_interactive_roles(role) {
|
|
|
|
|
return non_interactive_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_interactive_roles(role) {
|
|
|
|
|
return interactive_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_abstract_role(role) {
|
|
|
|
|
return abstract_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.Attribute | undefined} attribute
|
|
|
|
|
*/
|
|
|
|
|
function get_static_text_value(attribute) {
|
|
|
|
|
const value = get_static_value(attribute);
|
|
|
|
|
if (value === true) return null;
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.Attribute | undefined} attribute
|
|
|
|
|
*/
|
|
|
|
|
function get_static_value(attribute) {
|
|
|
|
|
if (!attribute) return null;
|
|
|
|
|
if (attribute.value === true) return true;
|
|
|
|
|
if (is_text_attribute(attribute)) return attribute.value[0].data;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.RegularElement | AST.SvelteElement} element
|
|
|
|
|
*/
|
|
|
|
|
function has_content(element) {
|
|
|
|
|
for (const node of element.fragment.nodes) {
|
|
|
|
|
if (node.type === 'Text') {
|
|
|
|
|
if (node.data.trim() === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
|
|
|
|
|
if (
|
|
|
|
|
node.name === 'img' &&
|
|
|
|
|
node.attributes.some((node) => node.type === 'Attribute' && node.name === 'alt')
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!has_content(node)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// assume everything else has content — this will result in false positives
|
|
|
|
|
// (e.g. an empty `{#if ...}{/if}`) but that's probably fine
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.RegularElement | AST.SvelteElement} node
|
|
|
|
|
* @param {Context} context
|
|
|
|
@ -682,149 +402,429 @@ export function check_element(node, context) {
|
|
|
|
|
w.a11y_invalid_attribute(href, href_value, href.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (!has_spread) {
|
|
|
|
|
const id_attribute = get_static_value(attribute_map.get('id'));
|
|
|
|
|
const name_attribute = get_static_value(attribute_map.get('name'));
|
|
|
|
|
const aria_disabled_attribute = get_static_value(attribute_map.get('aria-disabled'));
|
|
|
|
|
if (!id_attribute && !name_attribute && aria_disabled_attribute !== 'true') {
|
|
|
|
|
warn_missing_attribute(node, ['href']);
|
|
|
|
|
} else if (!has_spread) {
|
|
|
|
|
const id_attribute = get_static_value(attribute_map.get('id'));
|
|
|
|
|
const name_attribute = get_static_value(attribute_map.get('name'));
|
|
|
|
|
const aria_disabled_attribute = get_static_value(attribute_map.get('aria-disabled'));
|
|
|
|
|
if (!id_attribute && !name_attribute && aria_disabled_attribute !== 'true') {
|
|
|
|
|
warn_missing_attribute(node, ['href']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'input': {
|
|
|
|
|
const type = attribute_map.get('type');
|
|
|
|
|
const type_value = get_static_text_value(type);
|
|
|
|
|
if (type_value === 'image' && !has_spread) {
|
|
|
|
|
const required_attributes = ['alt', 'aria-label', 'aria-labelledby'];
|
|
|
|
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
|
|
|
|
|
if (!has_attribute) {
|
|
|
|
|
warn_missing_attribute(node, required_attributes, 'input type="image"');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// autocomplete-valid
|
|
|
|
|
const autocomplete = attribute_map.get('autocomplete');
|
|
|
|
|
if (type && autocomplete) {
|
|
|
|
|
const autocomplete_value = get_static_value(autocomplete);
|
|
|
|
|
if (!is_valid_autocomplete(autocomplete_value)) {
|
|
|
|
|
w.a11y_autocomplete_valid(
|
|
|
|
|
autocomplete,
|
|
|
|
|
/** @type {string} */ (autocomplete_value),
|
|
|
|
|
type_value ?? '...'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'img': {
|
|
|
|
|
const alt_attribute = get_static_text_value(attribute_map.get('alt'));
|
|
|
|
|
const aria_hidden = get_static_value(attribute_map.get('aria-hidden'));
|
|
|
|
|
if (alt_attribute && !aria_hidden && !has_spread) {
|
|
|
|
|
if (regex_redundant_img_alt.test(alt_attribute)) {
|
|
|
|
|
w.a11y_img_redundant_alt(node);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'label': {
|
|
|
|
|
/** @param {AST.TemplateNode} node */
|
|
|
|
|
const has_input_child = (node) => {
|
|
|
|
|
let has = false;
|
|
|
|
|
walk(
|
|
|
|
|
node,
|
|
|
|
|
{},
|
|
|
|
|
{
|
|
|
|
|
_(node, { next }) {
|
|
|
|
|
if (
|
|
|
|
|
node.type === 'SvelteElement' ||
|
|
|
|
|
node.type === 'SlotElement' ||
|
|
|
|
|
node.type === 'Component' ||
|
|
|
|
|
node.type === 'RenderTag' ||
|
|
|
|
|
(node.type === 'RegularElement' &&
|
|
|
|
|
(a11y_labelable.includes(node.name) || node.name === 'slot'))
|
|
|
|
|
) {
|
|
|
|
|
has = true;
|
|
|
|
|
} else {
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return has;
|
|
|
|
|
};
|
|
|
|
|
if (!has_spread && !attribute_map.has('for') && !has_input_child(node)) {
|
|
|
|
|
w.a11y_label_has_associated_control(node);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'video': {
|
|
|
|
|
const aria_hidden_attribute = attribute_map.get('aria-hidden');
|
|
|
|
|
const aria_hidden_exist = aria_hidden_attribute && get_static_value(aria_hidden_attribute);
|
|
|
|
|
if (attribute_map.has('muted') || aria_hidden_exist === 'true' || has_spread) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let has_caption = false;
|
|
|
|
|
const track = /** @type {AST.RegularElement | undefined} */ (
|
|
|
|
|
node.fragment.nodes.find((i) => i.type === 'RegularElement' && i.name === 'track')
|
|
|
|
|
);
|
|
|
|
|
if (track) {
|
|
|
|
|
has_caption = track.attributes.some(
|
|
|
|
|
(a) =>
|
|
|
|
|
a.type === 'SpreadAttribute' ||
|
|
|
|
|
(a.type === 'Attribute' && a.name === 'kind' && get_static_value(a) === 'captions')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!has_caption) {
|
|
|
|
|
w.a11y_media_has_caption(node);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'figcaption': {
|
|
|
|
|
if (!is_parent(context.path, ['figure'])) {
|
|
|
|
|
w.a11y_figcaption_parent(node);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'figure': {
|
|
|
|
|
const children = node.fragment.nodes.filter((node) => {
|
|
|
|
|
if (node.type === 'Comment') return false;
|
|
|
|
|
if (node.type === 'Text') return regex_not_whitespace.test(node.data);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
const index = children.findIndex(
|
|
|
|
|
(child) => child.type === 'RegularElement' && child.name === 'figcaption'
|
|
|
|
|
);
|
|
|
|
|
if (index !== -1 && index !== 0 && index !== children.length - 1) {
|
|
|
|
|
w.a11y_figcaption_index(children[index]);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!has_spread && node.name !== 'a') {
|
|
|
|
|
const required_attributes = a11y_required_attributes[node.name];
|
|
|
|
|
if (required_attributes) {
|
|
|
|
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
|
|
|
|
|
if (!has_attribute) {
|
|
|
|
|
warn_missing_attribute(node, required_attributes);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (a11y_distracting_elements.includes(node.name)) {
|
|
|
|
|
// no-distracting-elements
|
|
|
|
|
w.a11y_distracting_elements(node, node.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check content
|
|
|
|
|
if (
|
|
|
|
|
!has_spread &&
|
|
|
|
|
!is_labelled &&
|
|
|
|
|
!has_contenteditable_binding &&
|
|
|
|
|
a11y_required_content.includes(node.name) &&
|
|
|
|
|
!has_content(node)
|
|
|
|
|
) {
|
|
|
|
|
w.a11y_missing_content(node, node.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_presentation_role(role) {
|
|
|
|
|
return presentation_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function is_hidden_from_screen_reader(tag_name, attribute_map) {
|
|
|
|
|
if (tag_name === 'input') {
|
|
|
|
|
const type = get_static_value(attribute_map.get('type'));
|
|
|
|
|
if (type === 'hidden') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aria_hidden = attribute_map.get('aria-hidden');
|
|
|
|
|
if (!aria_hidden) return false;
|
|
|
|
|
const aria_hidden_value = get_static_value(aria_hidden);
|
|
|
|
|
if (aria_hidden_value === null) return true;
|
|
|
|
|
return aria_hidden_value === true || aria_hidden_value === 'true';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function has_disabled_attribute(attribute_map) {
|
|
|
|
|
const disabled_attr_value = get_static_value(attribute_map.get('disabled'));
|
|
|
|
|
if (disabled_attr_value) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aria_disabled_attr = attribute_map.get('aria-disabled');
|
|
|
|
|
if (aria_disabled_attr) {
|
|
|
|
|
const aria_disabled_attr_value = get_static_value(aria_disabled_attr);
|
|
|
|
|
if (aria_disabled_attr_value === 'true') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {ElementInteractivity[keyof ElementInteractivity]}
|
|
|
|
|
*/
|
|
|
|
|
function element_interactivity(tag_name, attribute_map) {
|
|
|
|
|
if (
|
|
|
|
|
interactive_element_role_schemas.some((schema) => match_schema(schema, tag_name, attribute_map))
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
tag_name !== 'header' &&
|
|
|
|
|
non_interactive_element_role_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
interactive_element_ax_object_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
non_interactive_element_ax_object_schemas.some((schema) =>
|
|
|
|
|
match_schema(schema, tag_name, attribute_map)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
return ElementInteractivity.Static;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_interactive_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_non_interactive_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
function is_static_element(tag_name, attribute_map) {
|
|
|
|
|
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
* @param {string} tag_name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function is_semantic_role_element(role, tag_name, attribute_map) {
|
|
|
|
|
for (const [schema, ax_object] of elementAXObjects.entries()) {
|
|
|
|
|
if (
|
|
|
|
|
schema.name === tag_name &&
|
|
|
|
|
(!schema.attributes ||
|
|
|
|
|
schema.attributes.every(
|
|
|
|
|
/** @param {any} attr */
|
|
|
|
|
(attr) =>
|
|
|
|
|
attribute_map.has(attr.name) &&
|
|
|
|
|
get_static_value(attribute_map.get(attr.name)) === attr.value
|
|
|
|
|
))
|
|
|
|
|
) {
|
|
|
|
|
for (const name of ax_object) {
|
|
|
|
|
const roles = AXObjectRoles.get(name);
|
|
|
|
|
if (roles) {
|
|
|
|
|
for (const { name } of roles) {
|
|
|
|
|
if (name === role) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'input': {
|
|
|
|
|
const type = attribute_map.get('type');
|
|
|
|
|
const type_value = get_static_text_value(type);
|
|
|
|
|
if (type_value === 'image' && !has_spread) {
|
|
|
|
|
const required_attributes = ['alt', 'aria-label', 'aria-labelledby'];
|
|
|
|
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
|
|
|
|
|
if (!has_attribute) {
|
|
|
|
|
warn_missing_attribute(node, required_attributes, 'input type="image"');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// autocomplete-valid
|
|
|
|
|
const autocomplete = attribute_map.get('autocomplete');
|
|
|
|
|
if (type && autocomplete) {
|
|
|
|
|
const autocomplete_value = get_static_value(autocomplete);
|
|
|
|
|
if (!is_valid_autocomplete(autocomplete_value)) {
|
|
|
|
|
w.a11y_autocomplete_valid(
|
|
|
|
|
autocomplete,
|
|
|
|
|
/** @type {string} */ (autocomplete_value),
|
|
|
|
|
type_value ?? '...'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {null | true | string} autocomplete
|
|
|
|
|
*/
|
|
|
|
|
function is_valid_autocomplete(autocomplete) {
|
|
|
|
|
if (autocomplete === true) {
|
|
|
|
|
return false;
|
|
|
|
|
} else if (!autocomplete) {
|
|
|
|
|
return true; // dynamic value
|
|
|
|
|
}
|
|
|
|
|
case 'img': {
|
|
|
|
|
const alt_attribute = get_static_text_value(attribute_map.get('alt'));
|
|
|
|
|
const aria_hidden = get_static_value(attribute_map.get('aria-hidden'));
|
|
|
|
|
if (alt_attribute && !aria_hidden && !has_spread) {
|
|
|
|
|
if (regex_redundant_img_alt.test(alt_attribute)) {
|
|
|
|
|
w.a11y_img_redundant_alt(node);
|
|
|
|
|
const tokens = autocomplete.trim().toLowerCase().split(regex_whitespaces);
|
|
|
|
|
if (typeof tokens[0] === 'string' && tokens[0].startsWith('section-')) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
if (address_type_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
if (autofill_field_name_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
} else {
|
|
|
|
|
if (contact_type_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
case 'label': {
|
|
|
|
|
/** @param {AST.TemplateNode} node */
|
|
|
|
|
const has_input_child = (node) => {
|
|
|
|
|
let has = false;
|
|
|
|
|
walk(
|
|
|
|
|
node,
|
|
|
|
|
{},
|
|
|
|
|
{
|
|
|
|
|
_(node, { next }) {
|
|
|
|
|
if (
|
|
|
|
|
node.type === 'SvelteElement' ||
|
|
|
|
|
node.type === 'SlotElement' ||
|
|
|
|
|
node.type === 'Component' ||
|
|
|
|
|
node.type === 'RenderTag' ||
|
|
|
|
|
(node.type === 'RegularElement' &&
|
|
|
|
|
(a11y_labelable.includes(node.name) || node.name === 'slot'))
|
|
|
|
|
) {
|
|
|
|
|
has = true;
|
|
|
|
|
if (autofill_contact_field_name_tokens.includes(tokens[0])) {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
} else {
|
|
|
|
|
next();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (tokens[0] === 'webauthn') {
|
|
|
|
|
tokens.shift();
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return has;
|
|
|
|
|
};
|
|
|
|
|
if (!has_spread && !attribute_map.has('for') && !has_input_child(node)) {
|
|
|
|
|
w.a11y_label_has_associated_control(node);
|
|
|
|
|
return tokens.length === 0;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
/** @param {Map<string, AST.Attribute>} attribute_map */
|
|
|
|
|
function input_implicit_role(attribute_map) {
|
|
|
|
|
const type_attribute = attribute_map.get('type');
|
|
|
|
|
if (!type_attribute) return;
|
|
|
|
|
const type = get_static_text_value(type_attribute);
|
|
|
|
|
if (!type) return;
|
|
|
|
|
const list_attribute_exists = attribute_map.has('list');
|
|
|
|
|
if (list_attribute_exists && combobox_if_list.includes(type)) {
|
|
|
|
|
return 'combobox';
|
|
|
|
|
}
|
|
|
|
|
case 'video': {
|
|
|
|
|
const aria_hidden_attribute = attribute_map.get('aria-hidden');
|
|
|
|
|
const aria_hidden_exist = aria_hidden_attribute && get_static_value(aria_hidden_attribute);
|
|
|
|
|
if (attribute_map.has('muted') || aria_hidden_exist === 'true' || has_spread) {
|
|
|
|
|
return;
|
|
|
|
|
return input_type_to_implicit_role.get(type);
|
|
|
|
|
}
|
|
|
|
|
let has_caption = false;
|
|
|
|
|
const track = /** @type {AST.RegularElement | undefined} */ (
|
|
|
|
|
node.fragment.nodes.find((i) => i.type === 'RegularElement' && i.name === 'track')
|
|
|
|
|
);
|
|
|
|
|
if (track) {
|
|
|
|
|
has_caption = track.attributes.some(
|
|
|
|
|
(a) =>
|
|
|
|
|
a.type === 'SpreadAttribute' ||
|
|
|
|
|
(a.type === 'Attribute' && a.name === 'kind' && get_static_value(a) === 'captions')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/** @param {Map<string, AST.Attribute>} attribute_map */
|
|
|
|
|
function menuitem_implicit_role(attribute_map) {
|
|
|
|
|
const type_attribute = attribute_map.get('type');
|
|
|
|
|
if (!type_attribute) return;
|
|
|
|
|
const type = get_static_text_value(type_attribute);
|
|
|
|
|
if (!type) return;
|
|
|
|
|
return menuitem_type_to_implicit_role.get(type);
|
|
|
|
|
}
|
|
|
|
|
if (!has_caption) {
|
|
|
|
|
w.a11y_media_has_caption(node);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} name
|
|
|
|
|
* @param {Map<string, AST.Attribute>} attribute_map
|
|
|
|
|
*/
|
|
|
|
|
function get_implicit_role(name, attribute_map) {
|
|
|
|
|
if (name === 'menuitem') {
|
|
|
|
|
return menuitem_implicit_role(attribute_map);
|
|
|
|
|
} else if (name === 'input') {
|
|
|
|
|
return input_implicit_role(attribute_map);
|
|
|
|
|
} else {
|
|
|
|
|
return a11y_implicit_semantics.get(name);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'figcaption': {
|
|
|
|
|
if (!is_parent(context.path, ['figure'])) {
|
|
|
|
|
w.a11y_figcaption_parent(node);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_non_interactive_roles(role) {
|
|
|
|
|
return non_interactive_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_interactive_roles(role) {
|
|
|
|
|
return interactive_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
case 'figure': {
|
|
|
|
|
const children = node.fragment.nodes.filter((node) => {
|
|
|
|
|
if (node.type === 'Comment') return false;
|
|
|
|
|
if (node.type === 'Text') return regex_not_whitespace.test(node.data);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
const index = children.findIndex(
|
|
|
|
|
(child) => child.type === 'RegularElement' && child.name === 'figcaption'
|
|
|
|
|
);
|
|
|
|
|
if (index !== -1 && index !== 0 && index !== children.length - 1) {
|
|
|
|
|
w.a11y_figcaption_index(children[index]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {ARIARoleDefinitionKey} role
|
|
|
|
|
*/
|
|
|
|
|
function is_abstract_role(role) {
|
|
|
|
|
return abstract_roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.Attribute | undefined} attribute
|
|
|
|
|
*/
|
|
|
|
|
function get_static_text_value(attribute) {
|
|
|
|
|
const value = get_static_value(attribute);
|
|
|
|
|
if (value === true) return null;
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.Attribute | undefined} attribute
|
|
|
|
|
*/
|
|
|
|
|
function get_static_value(attribute) {
|
|
|
|
|
if (!attribute) return null;
|
|
|
|
|
if (attribute.value === true) return true;
|
|
|
|
|
if (is_text_attribute(attribute)) return attribute.value[0].data;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!has_spread && node.name !== 'a') {
|
|
|
|
|
const required_attributes = a11y_required_attributes[node.name];
|
|
|
|
|
if (required_attributes) {
|
|
|
|
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
|
|
|
|
|
if (!has_attribute) {
|
|
|
|
|
warn_missing_attribute(node, required_attributes);
|
|
|
|
|
/**
|
|
|
|
|
* @param {AST.RegularElement | AST.SvelteElement} element
|
|
|
|
|
*/
|
|
|
|
|
function has_content(element) {
|
|
|
|
|
for (const node of element.fragment.nodes) {
|
|
|
|
|
if (node.type === 'Text') {
|
|
|
|
|
if (node.data.trim() === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
|
|
|
|
|
if (
|
|
|
|
|
node.name === 'img' &&
|
|
|
|
|
node.attributes.some((node) => node.type === 'Attribute' && node.name === 'alt')
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (a11y_distracting_elements.includes(node.name)) {
|
|
|
|
|
// no-distracting-elements
|
|
|
|
|
w.a11y_distracting_elements(node, node.name);
|
|
|
|
|
if (!has_content(node)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check content
|
|
|
|
|
if (
|
|
|
|
|
!has_spread &&
|
|
|
|
|
!is_labelled &&
|
|
|
|
|
!has_contenteditable_binding &&
|
|
|
|
|
a11y_required_content.includes(node.name) &&
|
|
|
|
|
!has_content(node)
|
|
|
|
|
) {
|
|
|
|
|
w.a11y_missing_content(node, node.name);
|
|
|
|
|
// assume everything else has content — this will result in false positives
|
|
|
|
|
// (e.g. an empty `{#if ...}{/if}`) but that's probably fine
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|