|
import { is_html, is_svg, is_void } from '../../../shared/utils/names.js'; |
|
import Node from './shared/Node.js'; |
|
import { walk } from 'estree-walker'; |
|
import Attribute from './Attribute.js'; |
|
import Binding from './Binding.js'; |
|
import EventHandler from './EventHandler.js'; |
|
import Transition from './Transition.js'; |
|
import Animation from './Animation.js'; |
|
import Action from './Action.js'; |
|
import Class from './Class.js'; |
|
import StyleDirective from './StyleDirective.js'; |
|
import Text from './Text.js'; |
|
import { namespaces } from '../../utils/namespaces.js'; |
|
import map_children from './shared/map_children.js'; |
|
import { |
|
is_name_contenteditable, |
|
get_contenteditable_attr, |
|
has_contenteditable_attr |
|
} from '../utils/contenteditable.js'; |
|
import { |
|
regex_dimensions, |
|
regex_starts_with_newline, |
|
regex_non_whitespace_character, |
|
regex_box_size |
|
} from '../../utils/patterns.js'; |
|
import fuzzymatch from '../../utils/fuzzymatch.js'; |
|
import list from '../../utils/list.js'; |
|
import hash from '../utils/hash.js'; |
|
import Let from './Let.js'; |
|
import Expression from './shared/Expression.js'; |
|
import { string_literal } from '../utils/stringify.js'; |
|
import compiler_warnings from '../compiler_warnings.js'; |
|
import compiler_errors from '../compiler_errors.js'; |
|
import { roles, aria } from 'aria-query'; |
|
import { |
|
is_interactive_element, |
|
is_non_interactive_element, |
|
is_non_interactive_roles, |
|
is_presentation_role, |
|
is_interactive_roles, |
|
is_hidden_from_screen_reader, |
|
is_semantic_role_element, |
|
is_abstract_role, |
|
is_static_element, |
|
has_disabled_attribute, |
|
is_valid_autocomplete |
|
} from '../utils/a11y.js'; |
|
|
|
const aria_attributes = |
|
'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split( |
|
' ' |
|
); |
|
const aria_attribute_set = new Set(aria_attributes); |
|
const aria_roles = roles.keys(); |
|
const aria_role_set = new Set(aria_roles); |
|
const a11y_required_attributes = { |
|
a: ['href'], |
|
area: ['alt', 'aria-label', 'aria-labelledby'], |
|
|
|
html: ['lang'], |
|
|
|
iframe: ['title'], |
|
img: ['alt'], |
|
object: ['title', 'aria-label', 'aria-labelledby'] |
|
}; |
|
const a11y_distracting_elements = new Set(['blink', 'marquee']); |
|
const a11y_required_content = new Set([ |
|
|
|
'a', |
|
|
|
'h1', |
|
'h2', |
|
'h3', |
|
'h4', |
|
'h5', |
|
'h6' |
|
]); |
|
const a11y_labelable = new Set([ |
|
'button', |
|
'input', |
|
'keygen', |
|
'meter', |
|
'output', |
|
'progress', |
|
'select', |
|
'textarea' |
|
]); |
|
const a11y_interactive_handlers = new Set([ |
|
|
|
'keypress', |
|
'keydown', |
|
'keyup', |
|
|
|
'click', |
|
'contextmenu', |
|
'dblclick', |
|
'drag', |
|
'dragend', |
|
'dragenter', |
|
'dragexit', |
|
'dragleave', |
|
'dragover', |
|
'dragstart', |
|
'drop', |
|
'mousedown', |
|
'mouseenter', |
|
'mouseleave', |
|
'mousemove', |
|
'mouseout', |
|
'mouseover', |
|
'mouseup' |
|
]); |
|
const a11y_recommended_interactive_handlers = new Set([ |
|
'click', |
|
'mousedown', |
|
'mouseup', |
|
'keypress', |
|
'keydown', |
|
'keyup' |
|
]); |
|
const a11y_nested_implicit_semantics = new Map([ |
|
['header', 'banner'], |
|
['footer', 'contentinfo'] |
|
]); |
|
const a11y_implicit_semantics = new Map([ |
|
['a', 'link'], |
|
['area', 'link'], |
|
['article', 'article'], |
|
['aside', 'complementary'], |
|
['body', 'document'], |
|
['button', 'button'], |
|
['datalist', 'listbox'], |
|
['dd', 'definition'], |
|
['dfn', 'term'], |
|
['dialog', 'dialog'], |
|
['details', 'group'], |
|
['dt', 'term'], |
|
['fieldset', 'group'], |
|
['figure', 'figure'], |
|
['form', 'form'], |
|
['h1', 'heading'], |
|
['h2', 'heading'], |
|
['h3', 'heading'], |
|
['h4', 'heading'], |
|
['h5', 'heading'], |
|
['h6', 'heading'], |
|
['hr', 'separator'], |
|
['img', 'img'], |
|
['li', 'listitem'], |
|
['link', 'link'], |
|
['main', 'main'], |
|
['menu', 'list'], |
|
['meter', 'progressbar'], |
|
['nav', 'navigation'], |
|
['ol', 'list'], |
|
['option', 'option'], |
|
['optgroup', 'group'], |
|
['output', 'status'], |
|
['progress', 'progressbar'], |
|
['section', 'region'], |
|
['summary', 'button'], |
|
['table', 'table'], |
|
['tbody', 'rowgroup'], |
|
['textarea', 'textbox'], |
|
['tfoot', 'rowgroup'], |
|
['thead', 'rowgroup'], |
|
['tr', 'row'], |
|
['ul', 'list'] |
|
]); |
|
const menuitem_type_to_implicit_role = new Map([ |
|
['command', 'menuitem'], |
|
['checkbox', 'menuitemcheckbox'], |
|
['radio', 'menuitemradio'] |
|
]); |
|
const input_type_to_implicit_role = new Map([ |
|
['button', 'button'], |
|
['image', 'button'], |
|
['reset', 'button'], |
|
['submit', 'button'], |
|
['checkbox', 'checkbox'], |
|
['radio', 'radio'], |
|
['range', 'slider'], |
|
['number', 'spinbutton'], |
|
['email', 'textbox'], |
|
['search', 'searchbox'], |
|
['tel', 'textbox'], |
|
['text', 'textbox'], |
|
['url', 'textbox'] |
|
]); |
|
|
|
|
|
|
|
|
|
const a11y_non_interactive_element_to_interactive_role_exceptions = { |
|
ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], |
|
ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], |
|
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], |
|
table: ['grid'], |
|
td: ['gridcell'], |
|
fieldset: ['radiogroup', 'presentation'] |
|
}; |
|
const combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']); |
|
|
|
|
|
function input_implicit_role(attribute_map) { |
|
const type_attribute = attribute_map.get('type'); |
|
if (!type_attribute || !type_attribute.is_static) return; |
|
const type = (type_attribute.get_static_value()); |
|
const list_attribute_exists = attribute_map.has('list'); |
|
if (list_attribute_exists && combobox_if_list.has(type)) { |
|
return 'combobox'; |
|
} |
|
return input_type_to_implicit_role.get(type); |
|
} |
|
|
|
|
|
function menuitem_implicit_role(attribute_map) { |
|
const type_attribute = attribute_map.get('type'); |
|
if (!type_attribute || !type_attribute.is_static) return; |
|
const type = (type_attribute.get_static_value()); |
|
return menuitem_type_to_implicit_role.get(type); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
} |
|
} |
|
const invisible_elements = new Set(['meta', 'html', 'script', 'style']); |
|
const valid_modifiers = new Set([ |
|
'preventDefault', |
|
'stopPropagation', |
|
'stopImmediatePropagation', |
|
'capture', |
|
'once', |
|
'passive', |
|
'nonpassive', |
|
'self', |
|
'trusted' |
|
]); |
|
const passive_events = new Set(['wheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel']); |
|
const react_attributes = new Map([ |
|
['className', 'class'], |
|
['htmlFor', 'for'] |
|
]); |
|
const attributes_to_compact_whitespace = ['class', 'style']; |
|
|
|
|
|
|
|
|
|
|
|
function is_parent(parent, elements) { |
|
let check = false; |
|
while (parent) { |
|
const parent_name = (parent).name; |
|
if (elements.includes(parent_name)) { |
|
check = true; |
|
break; |
|
} |
|
if (parent.type === 'Element') { |
|
break; |
|
} |
|
parent = parent.parent; |
|
} |
|
return check; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function get_namespace(parent, element, explicit_namespace) { |
|
const parent_element = parent.find_nearest(/^Element/); |
|
if (!parent_element) { |
|
return explicit_namespace || (is_svg(element.name) ? namespaces.svg : null); |
|
} |
|
if (parent_element.namespace !== namespaces.foreign) { |
|
if (is_svg(element.name.toLowerCase())) return namespaces.svg; |
|
if (parent_element.name.toLowerCase() === 'foreignobject') return null; |
|
} |
|
return parent_element.namespace; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function is_valid_aria_attribute_value(schema, value) { |
|
switch (schema.type) { |
|
case 'boolean': |
|
return typeof value === 'boolean'; |
|
case 'string': |
|
case 'id': |
|
return typeof value === 'string'; |
|
case 'tristate': |
|
return typeof value === 'boolean' || value === 'mixed'; |
|
case 'integer': |
|
case 'number': |
|
return typeof value !== 'boolean' && isNaN(Number(value)) === false; |
|
case 'token': |
|
return ( |
|
(schema.values || []).indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1 |
|
); |
|
case 'idlist': |
|
return ( |
|
typeof value === 'string' && |
|
value.split(regex_any_repeated_whitespaces).every((id) => typeof id === 'string') |
|
); |
|
case 'tokenlist': |
|
return ( |
|
typeof value === 'string' && |
|
value |
|
.split(regex_any_repeated_whitespaces) |
|
.every((token) => (schema.values || []).indexOf(token.toLowerCase()) > -1) |
|
); |
|
default: |
|
return false; |
|
} |
|
} |
|
const regex_any_repeated_whitespaces = /[\s]+/g; |
|
const regex_heading_tags = /^h[1-6]$/; |
|
const regex_illegal_attribute_character = /(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/; |
|
|
|
|
|
export default class Element extends Node { |
|
|
|
name; |
|
|
|
|
|
scope; |
|
|
|
|
|
actions = []; |
|
|
|
|
|
bindings = []; |
|
|
|
|
|
classes = []; |
|
|
|
|
|
styles = []; |
|
|
|
|
|
handlers = []; |
|
|
|
|
|
lets = []; |
|
|
|
|
|
intro = null; |
|
|
|
|
|
outro = null; |
|
|
|
|
|
animation = null; |
|
|
|
|
|
children; |
|
|
|
|
|
namespace; |
|
|
|
|
|
needs_manual_style_scoping; |
|
|
|
|
|
tag_expr; |
|
|
|
|
|
contains_a11y_label; |
|
get is_dynamic_element() { |
|
return this.name === 'svelte:element'; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor(component, parent, scope, info) { |
|
super(component, parent, scope, info); |
|
this.name = info.name; |
|
if (info.name === 'svelte:element') { |
|
if (typeof info.tag !== 'string') { |
|
this.tag_expr = new Expression(component, this, scope, info.tag); |
|
} else { |
|
this.tag_expr = new Expression( |
|
component, |
|
this, |
|
scope, |
|
(string_literal(info.tag)) |
|
); |
|
this.name = info.tag; |
|
} |
|
} else { |
|
this.tag_expr = new Expression( |
|
component, |
|
this, |
|
scope, |
|
(string_literal(this.name)) |
|
); |
|
} |
|
this.namespace = get_namespace( (parent), this, component.namespace); |
|
if (this.namespace !== namespaces.foreign) { |
|
if (this.name === 'pre' || this.name === 'textarea') { |
|
const first = info.children[0]; |
|
if (first && first.type === 'Text') { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
first.data = first.data.replace(regex_starts_with_newline, ''); |
|
} |
|
} |
|
if (this.name === 'textarea') { |
|
if (info.children.length > 0) { |
|
const value_attribute = get_value_attribute(info.attributes); |
|
if (value_attribute) { |
|
component.error(value_attribute, compiler_errors.textarea_duplicate_value); |
|
return; |
|
} |
|
|
|
|
|
info.attributes.push({ |
|
type: 'Attribute', |
|
name: 'value', |
|
value: info.children |
|
}); |
|
info.children = []; |
|
} |
|
} |
|
if (this.name === 'option') { |
|
|
|
|
|
|
|
const value_attribute = get_value_attribute(info.attributes); |
|
if (!value_attribute) { |
|
info.attributes.push({ |
|
type: 'Attribute', |
|
name: 'value', |
|
value: info.children, |
|
synthetic: true |
|
}); |
|
} |
|
} |
|
} |
|
const has_let = info.attributes.some((node) => node.type === 'Let'); |
|
if (has_let) { |
|
scope = scope.child(); |
|
} |
|
|
|
const order = ['Binding']; |
|
info.attributes.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type)); |
|
info.attributes.forEach((node) => { |
|
switch (node.type) { |
|
case 'Action': |
|
this.actions.push(new Action(component, this, scope, node)); |
|
break; |
|
case 'Attribute': |
|
case 'Spread': |
|
|
|
if (node.name === 'xmlns') this.namespace = node.value[0].data; |
|
this.attributes.push(new Attribute(component, this, scope, node)); |
|
break; |
|
case 'Binding': |
|
this.bindings.push(new Binding(component, this, scope, node)); |
|
break; |
|
case 'Class': |
|
this.classes.push(new Class(component, this, scope, node)); |
|
break; |
|
case 'StyleDirective': |
|
this.styles.push(new StyleDirective(component, this, scope, node)); |
|
break; |
|
case 'EventHandler': |
|
this.handlers.push(new EventHandler(component, this, scope, node)); |
|
break; |
|
case 'Let': { |
|
const l = new Let(component, this, scope, node); |
|
this.lets.push(l); |
|
const dependencies = new Set([l.name.name]); |
|
l.names.forEach((name) => { |
|
scope.add(name, dependencies, this); |
|
}); |
|
break; |
|
} |
|
case 'Transition': { |
|
const transition = new Transition(component, this, scope, node); |
|
if (node.intro) this.intro = transition; |
|
if (node.outro) this.outro = transition; |
|
break; |
|
} |
|
case 'Animation': |
|
this.animation = new Animation(component, this, scope, node); |
|
break; |
|
default: |
|
throw new Error(`Not implemented: ${node.type}`); |
|
} |
|
}); |
|
this.scope = scope; |
|
this.children = map_children(component, this, this.scope, info.children); |
|
this.validate(); |
|
this.optimise(); |
|
component.apply_stylesheet(this); |
|
if (this.parent) { |
|
if ( |
|
this.actions.length > 0 || |
|
this.animation || |
|
this.bindings.length > 0 || |
|
this.classes.length > 0 || |
|
this.intro || |
|
this.outro || |
|
this.handlers.length > 0 || |
|
this.styles.length > 0 || |
|
this.name === 'option' || |
|
this.is_dynamic_element || |
|
this.tag_expr.dynamic_dependencies().length || |
|
component.compile_options.dev |
|
) { |
|
this.parent.cannot_use_innerhtml(); |
|
this.parent.not_static_content(); |
|
} |
|
} |
|
} |
|
validate() { |
|
if ( |
|
this.component.var_lookup.has(this.name) && |
|
this.component.var_lookup.get(this.name).imported && |
|
!is_svg(this.name) && |
|
!is_html(this.name) |
|
) { |
|
this.component.warn(this, compiler_warnings.component_name_lowercase(this.name)); |
|
} |
|
this.validate_attributes(); |
|
this.validate_event_handlers(); |
|
if (this.namespace === namespaces.foreign) { |
|
this.validate_bindings_foreign(); |
|
} else { |
|
this.validate_attributes_a11y(); |
|
this.validate_special_cases(); |
|
this.validate_bindings(); |
|
this.validate_content(); |
|
} |
|
} |
|
validate_attributes() { |
|
const { component, parent } = this; |
|
this.attributes.forEach((attribute) => { |
|
if (attribute.is_spread) return; |
|
const name = attribute.name.toLowerCase(); |
|
|
|
if (regex_illegal_attribute_character.test(name)) { |
|
return component.error(attribute, compiler_errors.illegal_attribute(name)); |
|
} |
|
if (name === 'slot') { |
|
if (!attribute.is_static) { |
|
return component.error(attribute, compiler_errors.invalid_slot_attribute); |
|
} |
|
if (component.slot_outlets.has(name)) { |
|
return component.error(attribute, compiler_errors.duplicate_slot_attribute(name)); |
|
|
|
|
|
} |
|
if (!(parent.type === 'SlotTemplate' || within_custom_element(parent))) { |
|
return component.error(attribute, compiler_errors.invalid_slotted_content); |
|
} |
|
} |
|
|
|
if (this.namespace !== namespaces.foreign) { |
|
if (name === 'is') { |
|
component.warn(attribute, compiler_warnings.avoid_is); |
|
} |
|
if (react_attributes.has(attribute.name)) { |
|
component.warn( |
|
attribute, |
|
compiler_warnings.invalid_html_attribute( |
|
attribute.name, |
|
react_attributes.get(attribute.name) |
|
) |
|
); |
|
} |
|
} |
|
}); |
|
} |
|
validate_attributes_a11y() { |
|
const { component, attributes, handlers } = this; |
|
const attribute_map = new Map(); |
|
const handlers_map = new Map(); |
|
attributes.forEach((attribute) => attribute_map.set(attribute.name, attribute)); |
|
handlers.forEach((handler) => handlers_map.set(handler.name, handler)); |
|
attributes.forEach((attribute) => { |
|
if (attribute.is_spread) return; |
|
const name = attribute.name.toLowerCase(); |
|
|
|
if (name.startsWith('aria-')) { |
|
if (invisible_elements.has(this.name)) { |
|
|
|
component.warn(attribute, compiler_warnings.a11y_aria_attributes(this.name)); |
|
} |
|
const type = name.slice(5); |
|
if (!aria_attribute_set.has(type)) { |
|
const match = fuzzymatch(type, aria_attributes); |
|
component.warn(attribute, compiler_warnings.a11y_unknown_aria_attribute(type, match)); |
|
} |
|
if (name === 'aria-hidden' && regex_heading_tags.test(this.name)) { |
|
component.warn(attribute, compiler_warnings.a11y_hidden(this.name)); |
|
} |
|
|
|
let value = attribute.get_static_value(); |
|
if (value === 'true') value = true; |
|
if (value === 'false') value = false; |
|
if ( |
|
value !== null && |
|
value !== undefined && |
|
aria.has( (name)) |
|
) { |
|
const schema = aria.get( (name)); |
|
if (!is_valid_aria_attribute_value(schema, value)) { |
|
component.warn( |
|
attribute, |
|
compiler_warnings.a11y_incorrect_attribute_type(schema, name) |
|
); |
|
} |
|
} |
|
|
|
if ( |
|
name === 'aria-activedescendant' && |
|
!this.is_dynamic_element && |
|
!is_interactive_element(this.name, attribute_map) && |
|
!attribute_map.has('tabindex') |
|
) { |
|
component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex); |
|
} |
|
} |
|
|
|
if (name === 'role') { |
|
if (invisible_elements.has(this.name)) { |
|
|
|
component.warn(attribute, compiler_warnings.a11y_misplaced_role(this.name)); |
|
} |
|
const value = attribute.get_static_value(); |
|
if (typeof value === 'string') { |
|
value.split(regex_any_repeated_whitespaces).forEach( |
|
( |
|
current_role |
|
) => { |
|
if (current_role && is_abstract_role(current_role)) { |
|
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(current_role)); |
|
} else if (current_role && !aria_role_set.has(current_role)) { |
|
const match = fuzzymatch(current_role, aria_roles); |
|
component.warn(attribute, compiler_warnings.a11y_unknown_role(current_role, match)); |
|
} |
|
|
|
if ( |
|
current_role === get_implicit_role(this.name, attribute_map) && |
|
|
|
!['ul', 'ol', 'li'].includes(this.name) |
|
) { |
|
component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role)); |
|
} |
|
|
|
const is_parent_section_or_article = is_parent(this.parent, ['section', 'article']); |
|
if (!is_parent_section_or_article) { |
|
const has_nested_redundant_role = |
|
current_role === a11y_nested_implicit_semantics.get(this.name); |
|
if (has_nested_redundant_role) { |
|
component.warn( |
|
attribute, |
|
compiler_warnings.a11y_no_redundant_roles(current_role) |
|
); |
|
} |
|
} |
|
|
|
if ( |
|
!this.is_dynamic_element && |
|
!is_semantic_role_element(current_role, this.name, attribute_map) |
|
) { |
|
const role = roles.get(current_role); |
|
if (role) { |
|
const required_role_props = Object.keys(role.requiredProps); |
|
const has_missing_props = required_role_props.some( |
|
(prop) => !attributes.find((a) => a.name === prop) |
|
); |
|
if (has_missing_props) { |
|
component.warn( |
|
attribute, |
|
compiler_warnings.a11y_role_has_required_aria_props( |
|
current_role, |
|
required_role_props |
|
) |
|
); |
|
} |
|
} |
|
} |
|
|
|
if ( |
|
!has_disabled_attribute(attribute_map) && |
|
!is_hidden_from_screen_reader(this.name, attribute_map) && |
|
!is_presentation_role(current_role) && |
|
is_interactive_roles(current_role) && |
|
is_static_element(this.name, attribute_map) && |
|
!attribute_map.get('tabindex') |
|
) { |
|
const has_interactive_handlers = handlers.some((handler) => |
|
a11y_interactive_handlers.has(handler.name) |
|
); |
|
if (has_interactive_handlers) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_interactive_supports_focus(current_role) |
|
); |
|
} |
|
} |
|
|
|
if ( |
|
is_interactive_element(this.name, attribute_map) && |
|
(is_non_interactive_roles(current_role) || is_presentation_role(current_role)) |
|
) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_no_interactive_element_to_noninteractive_role( |
|
current_role, |
|
this.name |
|
) |
|
); |
|
} |
|
|
|
if ( |
|
is_non_interactive_element(this.name, attribute_map) && |
|
is_interactive_roles(current_role) && |
|
!a11y_non_interactive_element_to_interactive_role_exceptions[this.name]?.includes( |
|
current_role |
|
) |
|
) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_no_noninteractive_element_to_interactive_role( |
|
current_role, |
|
this.name |
|
) |
|
); |
|
} |
|
} |
|
); |
|
} |
|
} |
|
|
|
if (name === 'accesskey') { |
|
component.warn(attribute, compiler_warnings.a11y_accesskey); |
|
} |
|
|
|
if (name === 'autofocus') { |
|
component.warn(attribute, compiler_warnings.a11y_autofocus); |
|
} |
|
|
|
if (name === 'scope' && !this.is_dynamic_element && this.name !== 'th') { |
|
component.warn(attribute, compiler_warnings.a11y_misplaced_scope); |
|
} |
|
|
|
if (name === 'tabindex') { |
|
const value = attribute.get_static_value(); |
|
|
|
if (!isNaN(value) && +value > 0) { |
|
component.warn(attribute, compiler_warnings.a11y_positive_tabindex); |
|
} |
|
} |
|
}); |
|
|
|
if (handlers_map.has('click')) { |
|
const role = attribute_map.get('role'); |
|
const is_non_presentation_role = |
|
role?.is_static && |
|
!is_presentation_role( |
|
(role.get_static_value()) |
|
); |
|
if ( |
|
!this.is_dynamic_element && |
|
!is_hidden_from_screen_reader(this.name, attribute_map) && |
|
(!role || is_non_presentation_role) && |
|
!is_interactive_element(this.name, attribute_map) && |
|
!this.attributes.find((attr) => attr.is_spread) |
|
) { |
|
const has_key_event = |
|
handlers_map.has('keydown') || handlers_map.has('keyup') || handlers_map.has('keypress'); |
|
if (!has_key_event) { |
|
component.warn(this, compiler_warnings.a11y_click_events_have_key_events); |
|
} |
|
} |
|
} |
|
const role = attribute_map.get('role'); |
|
const role_static_value = ( |
|
role?.get_static_value() |
|
); |
|
const role_value = ( |
|
role ? role_static_value : get_implicit_role(this.name, attribute_map) |
|
); |
|
|
|
if ( |
|
!this.is_dynamic_element && |
|
!is_interactive_element(this.name, attribute_map) && |
|
!is_interactive_roles(role_static_value) |
|
) { |
|
const tab_index = attribute_map.get('tabindex'); |
|
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { |
|
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); |
|
} |
|
} |
|
|
|
if (typeof role_value === 'string' && roles.has(role_value)) { |
|
const { props } = roles.get(role_value); |
|
const invalid_aria_props = new Set(aria.keys().filter((attribute) => !(attribute in props))); |
|
const is_implicit = role_value && role === undefined; |
|
attributes |
|
.filter((prop) => prop.type !== 'Spread') |
|
.forEach((prop) => { |
|
if ( |
|
invalid_aria_props.has( (prop.name)) |
|
) { |
|
component.warn( |
|
prop, |
|
compiler_warnings.a11y_role_supports_aria_props( |
|
prop.name, |
|
role_value, |
|
is_implicit, |
|
this.name |
|
) |
|
); |
|
} |
|
}); |
|
} |
|
|
|
if ( |
|
!has_contenteditable_attr(this) && |
|
!is_hidden_from_screen_reader(this.name, attribute_map) && |
|
!is_presentation_role(role_static_value) && |
|
((!is_interactive_element(this.name, attribute_map) && |
|
is_non_interactive_roles(role_static_value)) || |
|
(is_non_interactive_element(this.name, attribute_map) && !role)) |
|
) { |
|
const has_interactive_handlers = handlers.some((handler) => |
|
a11y_recommended_interactive_handlers.has(handler.name) |
|
); |
|
if (has_interactive_handlers) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_no_noninteractive_element_interactions(this.name) |
|
); |
|
} |
|
} |
|
const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static; |
|
|
|
if ( |
|
!has_dynamic_role && |
|
!is_hidden_from_screen_reader(this.name, attribute_map) && |
|
!is_presentation_role(role_static_value) && |
|
!is_interactive_element(this.name, attribute_map) && |
|
!is_interactive_roles(role_static_value) && |
|
!is_non_interactive_element(this.name, attribute_map) && |
|
!is_non_interactive_roles(role_static_value) && |
|
!is_abstract_role(role_static_value) |
|
) { |
|
const interactive_handlers = handlers |
|
.map((handler) => handler.name) |
|
.filter((handler_name) => a11y_interactive_handlers.has(handler_name)); |
|
if (interactive_handlers.length > 0) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers) |
|
); |
|
} |
|
} |
|
} |
|
validate_special_cases() { |
|
const { component, attributes, handlers } = this; |
|
const attribute_map = new Map(); |
|
const handlers_map = new Map(); |
|
attributes.forEach((attribute) => attribute_map.set(attribute.name, attribute)); |
|
handlers.forEach((handler) => handlers_map.set(handler.name, handler)); |
|
if (this.name === 'a') { |
|
const href_attribute = attribute_map.get('href') || attribute_map.get('xlink:href'); |
|
const id_attribute = attribute_map.get('id'); |
|
const name_attribute = attribute_map.get('name'); |
|
const target_attribute = attribute_map.get('target'); |
|
const aria_label_attribute = attribute_map.get('aria-label'); |
|
|
|
|
|
|
|
if ( |
|
component.compile_options.legacy && |
|
target_attribute && |
|
target_attribute.get_static_value() === '_blank' && |
|
href_attribute |
|
) { |
|
const href_static_value = href_attribute.get_static_value() |
|
? href_attribute.get_static_value().toLowerCase() |
|
: null; |
|
if (href_static_value === null || href_static_value.match(/^(https?:)?\/\//i)) { |
|
const rel = attribute_map.get('rel'); |
|
if (rel == null || rel.is_static) { |
|
const rel_values = rel |
|
? rel.get_static_value().split(regex_any_repeated_whitespaces) |
|
: []; |
|
if (!rel || !rel_values.includes('noreferrer')) { |
|
component.warn(this, { |
|
code: 'security-anchor-rel-noreferrer', |
|
message: |
|
'Security: Anchor with "target=_blank" should have rel attribute containing the value "noreferrer"' |
|
}); |
|
} |
|
} |
|
} |
|
} |
|
if (aria_label_attribute) { |
|
const aria_value = aria_label_attribute.get_static_value(); |
|
if (aria_value != '') { |
|
this.contains_a11y_label = true; |
|
} |
|
} |
|
if (href_attribute) { |
|
const href_value = href_attribute.get_static_value(); |
|
if (href_value === '' || href_value === '#' || /^\W*javascript:/i.test(href_value)) { |
|
component.warn( |
|
href_attribute, |
|
compiler_warnings.a11y_invalid_attribute(href_attribute.name, href_value) |
|
); |
|
} |
|
} else { |
|
const id_attribute_valid = id_attribute && id_attribute.get_static_value() !== ''; |
|
const name_attribute_valid = name_attribute && name_attribute.get_static_value() !== ''; |
|
if (!id_attribute_valid && !name_attribute_valid) { |
|
component.warn(this, compiler_warnings.a11y_missing_attribute('a', 'an', 'href')); |
|
} |
|
} |
|
} else { |
|
const required_attributes = a11y_required_attributes[this.name]; |
|
if (required_attributes) { |
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name)); |
|
if (!has_attribute) { |
|
should_have_attribute(this, required_attributes); |
|
} |
|
} |
|
} |
|
if (this.name === 'input') { |
|
const type = attribute_map.get('type'); |
|
if (type && type.get_static_value() === 'image') { |
|
const required_attributes = ['alt', 'aria-label', 'aria-labelledby']; |
|
const has_attribute = required_attributes.some((name) => attribute_map.has(name)); |
|
if (!has_attribute) { |
|
should_have_attribute(this, required_attributes, 'input type="image"'); |
|
} |
|
} |
|
|
|
const autocomplete = attribute_map.get('autocomplete'); |
|
if (type && autocomplete) { |
|
const type_value = type.get_static_value(); |
|
const autocomplete_value = autocomplete.get_static_value(); |
|
if (!is_valid_autocomplete(autocomplete_value)) { |
|
component.warn( |
|
autocomplete, |
|
compiler_warnings.a11y_autocomplete_valid(type_value, autocomplete_value) |
|
); |
|
} |
|
} |
|
} |
|
if (this.name === 'img') { |
|
const alt_attribute = attribute_map.get('alt'); |
|
const aria_hidden_attribute = attribute_map.get('aria-hidden'); |
|
const aria_hidden_exist = aria_hidden_attribute && aria_hidden_attribute.get_static_value(); |
|
if (alt_attribute && !aria_hidden_exist) { |
|
const alt_value = alt_attribute.get_static_value(); |
|
if (/\b(image|picture|photo)\b/i.test(alt_value)) { |
|
component.warn(this, compiler_warnings.a11y_img_redundant_alt); |
|
} |
|
} |
|
} |
|
if (this.name === 'label') { |
|
|
|
const has_input_child = (children) => { |
|
if ( |
|
children.some( |
|
(child) => |
|
child instanceof Element && (a11y_labelable.has(child.name) || child.name === 'slot') |
|
) |
|
) { |
|
return true; |
|
} |
|
for (const child of children) { |
|
if (!('children' in child) || child.children.length === 0) { |
|
continue; |
|
} |
|
if (has_input_child(child.children)) { |
|
return true; |
|
} |
|
} |
|
return false; |
|
}; |
|
if (!attribute_map.has('for') && !has_input_child(this.children)) { |
|
component.warn(this, compiler_warnings.a11y_label_has_associated_control); |
|
} |
|
} |
|
if (this.name === 'video') { |
|
const aria_hidden_attribute = attribute_map.get('aria-hidden'); |
|
const aria_hidden_exist = aria_hidden_attribute && aria_hidden_attribute.get_static_value(); |
|
if (attribute_map.has('muted') || aria_hidden_exist === 'true') { |
|
return; |
|
} |
|
let has_caption; |
|
const track = this.children.find( (i) => i.name === 'track'); |
|
if (track) { |
|
has_caption = track.attributes.find( |
|
(a) => a.name === 'kind' && a.get_static_value() === 'captions' |
|
); |
|
} |
|
if (!has_caption) { |
|
component.warn(this, compiler_warnings.a11y_media_has_caption); |
|
} |
|
} |
|
if (a11y_distracting_elements.has(this.name)) { |
|
|
|
component.warn(this, compiler_warnings.a11y_distracting_elements(this.name)); |
|
} |
|
if (this.name === 'figcaption') { |
|
let { parent } = this; |
|
let is_figure_parent = false; |
|
while (parent) { |
|
if ( (parent).name === 'figure') { |
|
is_figure_parent = true; |
|
break; |
|
} |
|
if (parent.type === 'Element') { |
|
break; |
|
} |
|
parent = parent.parent; |
|
} |
|
if (!is_figure_parent) { |
|
component.warn(this, compiler_warnings.a11y_structure_immediate); |
|
} |
|
} |
|
if (this.name === 'figure') { |
|
const children = this.children.filter((node) => { |
|
if (node.type === 'Comment') return false; |
|
if (node.type === 'Text') return regex_non_whitespace_character.test(node.data); |
|
return true; |
|
}); |
|
const index = children.findIndex( |
|
(child) => (child).name === 'figcaption' |
|
); |
|
if (index !== -1 && index !== 0 && index !== children.length - 1) { |
|
component.warn(children[index], compiler_warnings.a11y_structure_first_or_last); |
|
} |
|
} |
|
if (handlers_map.has('mouseover') && !handlers_map.has('focus')) { |
|
component.warn( |
|
this, |
|
compiler_warnings.a11y_mouse_events_have_key_events('mouseover', 'focus') |
|
); |
|
} |
|
if (handlers_map.has('mouseout') && !handlers_map.has('blur')) { |
|
component.warn(this, compiler_warnings.a11y_mouse_events_have_key_events('mouseout', 'blur')); |
|
} |
|
} |
|
validate_bindings_foreign() { |
|
this.bindings.forEach((binding) => { |
|
if (binding.name !== 'this') { |
|
return this.component.error(binding, compiler_errors.invalid_binding_foreign(binding.name)); |
|
} |
|
}); |
|
} |
|
validate_bindings() { |
|
const { component } = this; |
|
const check_type_attribute = () => { |
|
const attribute = this.attributes.find( |
|
(attribute) => |
|
attribute.name === 'type' |
|
); |
|
if (!attribute) return null; |
|
if (!attribute.is_static) { |
|
return component.error(attribute, compiler_errors.invalid_type); |
|
} |
|
const value = attribute.get_static_value(); |
|
if (value === true) { |
|
return component.error(attribute, compiler_errors.missing_type); |
|
} |
|
return value; |
|
}; |
|
this.bindings.forEach((binding) => { |
|
const { name } = binding; |
|
if (name === 'value') { |
|
if (this.name !== 'input' && this.name !== 'textarea' && this.name !== 'select') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_elements(this.name, 'value') |
|
); |
|
} |
|
if (this.name === 'select') { |
|
const attribute = this.attributes.find( |
|
|
|
(attribute) => attribute.name === 'multiple' |
|
); |
|
if (attribute && !attribute.is_static) { |
|
return component.error(attribute, compiler_errors.dynamic_multiple_attribute); |
|
} |
|
} else { |
|
check_type_attribute(); |
|
} |
|
} else if (name === 'checked' || name === 'indeterminate') { |
|
if (this.name !== 'input') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_elements(this.name, name) |
|
); |
|
} |
|
const type = check_type_attribute(); |
|
if (type !== 'checkbox') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_no_checkbox(name, type === 'radio') |
|
); |
|
} |
|
} else if (name === 'group') { |
|
if (this.name !== 'input') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_elements(this.name, 'group') |
|
); |
|
} |
|
const type = check_type_attribute(); |
|
if (type !== 'checkbox' && type !== 'radio') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with( |
|
'<input type="checkbox"> or <input type="radio">', |
|
'group' |
|
) |
|
); |
|
} |
|
} else if (name === 'files') { |
|
if (this.name !== 'input') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_elements(this.name, 'files') |
|
); |
|
} |
|
const type = check_type_attribute(); |
|
if (type !== 'file') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with('<input type="file">', 'files') |
|
); |
|
} |
|
} else if (name === 'open') { |
|
if (this.name !== 'details') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with('<details>', name) |
|
); |
|
} |
|
} else if ( |
|
name === 'currentTime' || |
|
name === 'duration' || |
|
name === 'paused' || |
|
name === 'buffered' || |
|
name === 'seekable' || |
|
name === 'played' || |
|
name === 'volume' || |
|
name === 'muted' || |
|
name === 'playbackRate' || |
|
name === 'seeking' || |
|
name === 'ended' || |
|
name === 'readyState' |
|
) { |
|
if (this.name !== 'audio' && this.name !== 'video') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with('audio> or <video>', name) |
|
); |
|
} |
|
} else if (name === 'videoHeight' || name === 'videoWidth') { |
|
if (this.name !== 'video') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with('<video>', name) |
|
); |
|
} |
|
} else if (regex_dimensions.test(name)) { |
|
if (this.name === 'svg' && (name === 'offsetWidth' || name === 'offsetHeight')) { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_on( |
|
binding.name, |
|
`<svg>. Use '${name.replace('offset', 'client')}' instead` |
|
) |
|
); |
|
} else if (is_svg(this.name)) { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_on(binding.name, 'SVG elements') |
|
); |
|
} else if (is_void(this.name)) { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_on( |
|
binding.name, |
|
`void elements like <${this.name}>. Use a wrapper element instead` |
|
) |
|
); |
|
} |
|
} else if (name === 'naturalWidth' || name === 'naturalHeight') { |
|
if (this.name !== 'img') { |
|
return component.error( |
|
binding, |
|
compiler_errors.invalid_binding_element_with('<img>', name) |
|
); |
|
} |
|
} else if (is_name_contenteditable(name)) { |
|
const contenteditable = get_contenteditable_attr(this); |
|
if (!contenteditable) { |
|
return component.error(binding, compiler_errors.missing_contenteditable_attribute); |
|
} else if (contenteditable && !contenteditable.is_static) { |
|
return component.error( |
|
contenteditable, |
|
compiler_errors.dynamic_contenteditable_attribute |
|
); |
|
} |
|
} else if (name !== 'this' && !regex_box_size.test(name)) { |
|
return component.error(binding, compiler_errors.invalid_binding(binding.name)); |
|
} |
|
}); |
|
} |
|
validate_content() { |
|
if (!a11y_required_content.has(this.name)) return; |
|
if (this.contains_a11y_label) return; |
|
if (this.bindings.some((binding) => ['textContent', 'innerHTML'].includes(binding.name))) |
|
return; |
|
if (this.children.length === 0) { |
|
this.component.warn(this, compiler_warnings.a11y_missing_content(this.name)); |
|
} |
|
} |
|
validate_event_handlers() { |
|
const { component } = this; |
|
this.handlers.forEach((handler) => { |
|
if (handler.modifiers.has('passive') && handler.modifiers.has('preventDefault')) { |
|
return component.error( |
|
handler, |
|
compiler_errors.invalid_event_modifier_combination('passive', 'preventDefault') |
|
); |
|
} |
|
if (handler.modifiers.has('passive') && handler.modifiers.has('nonpassive')) { |
|
return component.error( |
|
handler, |
|
compiler_errors.invalid_event_modifier_combination('passive', 'nonpassive') |
|
); |
|
} |
|
handler.modifiers.forEach((modifier) => { |
|
if (!valid_modifiers.has(modifier)) { |
|
return component.error( |
|
handler, |
|
compiler_errors.invalid_event_modifier(list(Array.from(valid_modifiers))) |
|
); |
|
} |
|
if (modifier === 'passive') { |
|
if (passive_events.has(handler.name)) { |
|
if (handler.can_make_passive) { |
|
component.warn(handler, compiler_warnings.redundant_event_modifier_for_touch); |
|
} |
|
} else { |
|
component.warn(handler, compiler_warnings.redundant_event_modifier_passive); |
|
} |
|
} |
|
if (component.compile_options.legacy && (modifier === 'once' || modifier === 'passive')) { |
|
|
|
|
|
return component.error(handler, compiler_errors.invalid_event_modifier_legacy(modifier)); |
|
} |
|
}); |
|
if ( |
|
passive_events.has(handler.name) && |
|
handler.can_make_passive && |
|
!handler.modifiers.has('preventDefault') && |
|
!handler.modifiers.has('nonpassive') |
|
) { |
|
|
|
handler.modifiers.add('passive'); |
|
} |
|
}); |
|
} |
|
is_media_node() { |
|
return this.name === 'audio' || this.name === 'video'; |
|
} |
|
add_css_class() { |
|
if (this.attributes.some((attr) => attr.is_spread)) { |
|
this.needs_manual_style_scoping = true; |
|
return; |
|
} |
|
const { id } = this.component.stylesheet; |
|
const class_attribute = this.attributes.find((a) => a.name === 'class'); |
|
if (class_attribute && !class_attribute.is_true) { |
|
if (class_attribute.chunks.length === 1 && class_attribute.chunks[0].type === 'Text') { |
|
(class_attribute.chunks[0]).data += ` ${id}`; |
|
} else { |
|
(class_attribute.chunks).push( |
|
new Text( |
|
this.component, |
|
this, |
|
this.scope, |
|
({ |
|
type: 'Text', |
|
data: ` ${id}`, |
|
synthetic: true |
|
}) |
|
) |
|
); |
|
} |
|
} else { |
|
this.attributes.push( |
|
new Attribute( |
|
this.component, |
|
this, |
|
this.scope, |
|
({ |
|
type: 'Attribute', |
|
name: 'class', |
|
value: [{ type: 'Text', data: id, synthetic: true }] |
|
}) |
|
) |
|
); |
|
} |
|
} |
|
get slot_template_name() { |
|
return ( |
|
this.attributes.find((attribute) => attribute.name === 'slot').get_static_value() |
|
); |
|
} |
|
optimise() { |
|
attributes_to_compact_whitespace.forEach((attribute_name) => { |
|
const attribute = this.attributes.find((a) => a.name === attribute_name); |
|
if (attribute && !attribute.is_true) { |
|
attribute.chunks.forEach((chunk, index) => { |
|
if (chunk.type === 'Text') { |
|
let data = chunk.data.replace(regex_any_repeated_whitespaces, ' '); |
|
if (index === 0) { |
|
data = data.trimLeft(); |
|
} else if (index === attribute.chunks.length - 1) { |
|
data = data.trimRight(); |
|
} |
|
chunk.data = data; |
|
} |
|
}); |
|
} |
|
}); |
|
} |
|
get can_use_textcontent() { |
|
return ( |
|
this.is_static_content && |
|
this.children.every((node) => node.type === 'Text' || node.type === 'MustacheTag') |
|
); |
|
} |
|
get can_optimise_to_html_string() { |
|
const can_use_textcontent = this.can_use_textcontent; |
|
const is_template_with_text_content = this.name === 'template' && can_use_textcontent; |
|
return ( |
|
!is_template_with_text_content && |
|
!this.namespace && |
|
(this.can_use_innerhtml || can_use_textcontent) && |
|
this.children.length > 0 |
|
); |
|
} |
|
get can_optimise_hydration() { |
|
|
|
|
|
|
|
|
|
const is_static_text_content = |
|
this.is_static_content && this.children.every((node) => node.type === 'Text'); |
|
return this.can_optimise_to_html_string && (this.can_use_innerhtml || is_static_text_content); |
|
} |
|
hash() { |
|
return `svelte-${hash(this.component.source.slice(this.start, this.end))}`; |
|
} |
|
} |
|
const regex_starts_with_vowel = /^[aeiou]/; |
|
|
|
|
|
|
|
|
|
|
|
|
|
function should_have_attribute(node, attributes, name = node.name) { |
|
const article = regex_starts_with_vowel.test(attributes[0]) ? 'an' : 'a'; |
|
const sequence = |
|
attributes.length > 1 |
|
? attributes.slice(0, -1).join(', ') + ` or ${attributes[attributes.length - 1]}` |
|
: attributes[0]; |
|
node.component.warn(node, compiler_warnings.a11y_missing_attribute(name, article, sequence)); |
|
} |
|
const regex_minus_sign = /-/; |
|
|
|
|
|
function within_custom_element(parent) { |
|
while (parent) { |
|
if (parent.type === 'InlineComponent') return false; |
|
if (parent.type === 'Element') { |
|
if (regex_minus_sign.test(parent.name) || parent.is_dynamic_element) return true; |
|
} |
|
parent = parent.parent; |
|
} |
|
return false; |
|
} |
|
|
|
|
|
|
|
|
|
function get_value_attribute(attributes) { |
|
let node_value; |
|
attributes.forEach((node) => { |
|
if (node.type !== 'Spread' && node.name.toLowerCase() === 'value') { |
|
node_value = node; |
|
} |
|
if (node.type === 'Spread') { |
|
walk( (node.expression), { |
|
enter( node) { |
|
if (node_value) { |
|
this.skip(); |
|
} |
|
if (node.type === 'Identifier') { |
|
if ( (node).name.toLowerCase() === 'value') { |
|
node_value = node; |
|
} |
|
} |
|
} |
|
}); |
|
} |
|
}); |
|
return node_value; |
|
} |
|
|