svelte/src/compiler/compile/nodes/Element.ts

1168 lines
37 KiB

import { is_html, is_svg, is_void } from '../../../shared/utils/names';
7 years ago
import Node from './shared/Node';
import Attribute from './Attribute';
import Binding from './Binding';
import EventHandler from './EventHandler';
import Transition from './Transition';
import Animation from './Animation';
Adds actions to components Actions add additional functionality to elements within your component's template that may be difficult to add with other mechanisms. Examples of functionality which actions makes trivial to attach are: * tooltips * image lazy loaders * drag and drop functionality Actions can be added to an element with the `use` directive. ```html <img use:lazyload data-src="giant-photo.jpg> ``` Data may be passed to the action as an object literal (e.g. `use:b="{ setting: true }"`, a literal value (e.g. `use:b="'a string'"`), or a value or function from your component's state (e.g. `add:b="foo"` or `add:b="foo()"`). Actions are defined in a "actions" property on your component definition. ```html <script> export default { actions: { b(node, data) { // do something return { update(data) {}, destroy() {} } } } } </script> ``` A action is a function which receives a reference to an element and optionally the data if it is added in the HTML. This function can then attach listeners or alter the element as needed. The action can optionally return an object with the methods `update(data)` and `destroy()`. When data is added in the HTML and comes from state, the action's `update(data)` will be called if defined whenever the state is changed. When the element is removed from the DOM `destroy()` will be called if provided, allowing for cleanup of event listeners, etc. See https://github.com/sveltejs/svelte/issues/469 for discussion around this feature and more examples of how it could be used.
7 years ago
import Action from './Action';
import Class from './Class';
import StyleDirective from './StyleDirective';
7 years ago
import Text from './Text';
import { namespaces } from '../../utils/namespaces';
6 years ago
import map_children from './shared/map_children';
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character } from '../../utils/patterns';
import fuzzymatch from '../../utils/fuzzymatch';
import list from '../../utils/list';
import Let from './Let';
import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces';
import Component from '../Component';
import Expression from './shared/Expression';
import { string_literal } from '../utils/stringify';
import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } 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 } from '../utils/a11y';
7 years ago
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(' ');
6 years ago
const aria_attribute_set = new Set(aria_attributes);
const aria_roles = roles.keys();
6 years ago
const aria_role_set = new Set(aria_roles);
6 years ago
const a11y_required_attributes = {
a: ['href'],
area: ['alt', 'aria-label', 'aria-labelledby'],
// html-has-lang
html: ['lang'],
// iframe-has-title
iframe: ['title'],
img: ['alt'],
object: ['title', 'aria-label', 'aria-labelledby']
};
6 years ago
const a11y_distracting_elements = new Set([
'blink',
'marquee'
]);
6 years ago
const a11y_required_content = new Set([
// anchor-has-content
'a',
// heading-has-content
'h1',
'h2',
'h3',
'h4',
'h5',
'h6'
]);
const a11y_labelable = new Set([
'button',
'input',
'keygen',
'meter',
'output',
'progress',
'select',
'textarea'
]);
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'],
['form', 'form'],
['h1', 'heading'],
['h2', 'heading'],
['h3', 'heading'],
['h4', 'heading'],
['h5', 'heading'],
['h6', 'heading'],
['hr', 'separator'],
['img', 'img'],
['li', 'listitem'],
['link', 'link'],
['menu', 'list'],
['meter', 'progressbar'],
['nav', 'navigation'],
['ol', 'list'],
['option', 'option'],
['optgroup', 'group'],
['output', 'status'],
['progress', 'progressbar'],
['section', 'region'],
['summary', 'button'],
['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 combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']);
function input_implicit_role(attribute_map: Map<string, Attribute>) {
const type_attribute = attribute_map.get('type');
if (!type_attribute || !type_attribute.is_static) return;
const type = type_attribute.get_static_value() as string;
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: Map<string, Attribute>) {
const type_attribute = attribute_map.get('type');
if (!type_attribute || !type_attribute.is_static) return;
const type = type_attribute.get_static_value() as string;
return menuitem_type_to_implicit_role.get(type);
}
function get_implicit_role(name: string, attribute_map: Map<string, Attribute>) : (string | undefined) {
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);
}
}
6 years ago
const invisible_elements = new Set(['meta', 'html', 'script', 'style']);
6 years ago
const valid_modifiers = new Set([
6 years ago
'preventDefault',
'stopPropagation',
'stopImmediatePropagation',
6 years ago
'capture',
'once',
'passive',
'nonpassive',
'self',
'trusted'
6 years ago
]);
6 years ago
const passive_events = new Set([
6 years ago
'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: INode, elements: string[]) {
let check = false;
while (parent) {
const parent_name = (parent as Element).name;
if (elements.includes(parent_name)) {
check = true;
break;
}
if (parent.type === 'Element') {
break;
}
parent = parent.parent;
}
return check;
}
function get_namespace(parent: Element, element: Element, explicit_namespace: string) {
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: ARIAPropertyDefinition, value: string | boolean): boolean {
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': // single token
return (schema.values || [])
.indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1;
case 'idlist': // if list of ids, split each
return typeof value === 'string'
&& value.split(regex_any_repeated_whitespaces).every((id) => typeof id === 'string');
case 'tokenlist': // if list of tokens, split each
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-.])|[\^$@%&#?!|()[\]{}^*+~;]/;
7 years ago
export default class Element extends Node {
type: 'Element';
name: string;
scope: TemplateScope;
attributes: Attribute[] = [];
actions: Action[] = [];
bindings: Binding[] = [];
classes: Class[] = [];
styles: StyleDirective[] = [];
handlers: EventHandler[] = [];
lets: Let[] = [];
intro?: Transition = null;
outro?: Transition = null;
animation?: Animation = null;
children: INode[];
7 years ago
namespace: string;
needs_manual_style_scoping: boolean;
tag_expr: Expression;
contains_a11y_label: boolean;
get is_dynamic_element() {
return this.name === 'svelte:element';
}
7 years ago
constructor(component: Component, parent: Node, scope: TemplateScope, info: any) {
super(component, parent, scope, info);
this.name = info.name;
7 years ago
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) as Literal);
this.name = info.tag;
}
} else {
this.tag_expr = new Expression(component, this, scope, string_literal(this.name) as Literal);
}
this.namespace = get_namespace(parent as Element, 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') {
// The leading newline character needs to be stripped because of a quirk,
// it is ignored by browsers if the tag and its contents are set through
// innerHTML (NOT if set through the innerHTML of the tag or dynamically).
// Therefore strip it here but add it back in the appropriate
// places if there's another newline afterwards.
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
first.data = first.data.replace(regex_starts_with_newline, '');
}
}
if (this.name === 'textarea') {
if (info.children.length > 0) {
const value_attribute = info.attributes.find(node => node.name === 'value');
if (value_attribute) {
component.error(value_attribute, compiler_errors.textarea_duplicate_value);
return;
}
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
info.attributes.push({
type: 'Attribute',
name: 'value',
value: info.children
});
info.children = [];
}
}
if (this.name === 'option') {
// Special case — treat these the same way:
// <option>{foo}</option>
// <option value={foo}>{foo}</option>
const value_attribute = info.attributes.find(attribute => attribute.name === 'value');
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();
}
// Binding relies on Attribute, defer its evaluation
5 years ago
const order = ['Binding']; // everything else is -1
info.attributes.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
info.attributes.forEach(node => {
switch (node.type) {
7 years ago
case 'Action':
this.actions.push(new Action(component, this, scope, node));
7 years ago
break;
case 'Attribute':
7 years ago
case 'Spread':
// special case
7 years ago
if (node.name === 'xmlns') this.namespace = node.value[0].data;
this.attributes.push(new Attribute(component, this, scope, node));
break;
7 years ago
case 'Binding':
this.bindings.push(new Binding(component, this, scope, node));
7 years ago
break;
case 'Class':
this.classes.push(new Class(component, this, scope, node));
break;
case 'StyleDirective':
this.styles.push(new StyleDirective(component, this, scope, node));
[feat] Style directives (#5923) * add Style node, interfaces * style-directives: add parser and runtime test * style-directives: push styles in to styles array on Element * style-directives: minimal ssr work * style-directives: ssr add_styles * style-directive: tests * style-directives: work in progress * obviously incorrect hard-coded color * tweak * style directive interface * style-directives: get text from info in Style node * style-directives: add_styles func in ElementWrapper * style-directives: ssr rendering * handle text-only style directive in tag.ts * style-directives: handle spread styles in ssr * style-directives: more parser tests * style-directives: more inline tests * style-directives: remove solo tests * style-directives: cleanup * style-directives: tweak spread ssr internal * style-directives: push updater into update chunks; add dynamic test; * remove .solo * check for dynamic dependencies before adding update chunk * add test of multiple styles; remove null styles; * style-directives: docs; more tests of multiple styles * style-directives: use camelcase * style-directives: cleanup * style-directives: fix mustache template case with template literal * style-directives: use ternary * style-directives: linting * style-directives: remove "text" from interface * style-directives: actually, remove StyleDirective interface entriely * add more test, fix test for ssr * fix lint and tidy up * add test for css variables * fix linting errors Co-authored-by: pmurray73 <pmurray73@bloomberg.net> Co-authored-by: Tan Li Hau <lhtan93@gmail.com>
3 years ago
break;
7 years ago
case 'EventHandler':
this.handlers.push(new EventHandler(component, this, scope, node));
7 years ago
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;
}
7 years ago
case 'Transition':
{
const transition = new Transition(component, this, scope, node);
7 years ago
if (node.intro) this.intro = transition;
if (node.outro) this.outro = transition;
break;
}
7 years ago
case 'Animation':
this.animation = new Animation(component, this, scope, node);
break;
default:
throw new Error(`Not implemented: ${node.type}`);
}
});
this.scope = scope;
6 years ago
this.children = map_children(component, this, this.scope, info.children);
this.validate();
this.optimise();
component.apply_stylesheet(this);
}
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();
// Errors
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));
// this code was unreachable. Still needed?
// component.slot_outlets.add(name);
}
if (!(parent.type === 'SlotTemplate' || within_custom_element(parent))) {
return component.error(attribute, compiler_errors.invalid_slotted_content);
}
}
// Warnings
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<string, Attribute>();
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 => {
6 years ago
if (attribute.is_spread) return;
const name = attribute.name.toLowerCase();
// aria-props
if (name.startsWith('aria-')) {
6 years ago
if (invisible_elements.has(this.name)) {
// aria-unsupported-elements
component.warn(attribute, compiler_warnings.a11y_aria_attributes(this.name));
}
const type = name.slice(5);
6 years ago
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));
}
// aria-proptypes
let value = attribute.get_static_value();
if (value === 'true') value = true;
if (value === 'false') value = false;
if (value !== null && value !== undefined && aria.has(name as ARIAProperty)) {
const schema = aria.get(name as ARIAProperty);
if (!is_valid_aria_attribute_value(schema, value)) {
component.warn(attribute, compiler_warnings.a11y_incorrect_attribute_type(schema, name));
}
}
// aria-activedescendant-has-tabindex
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);
}
}
// aria-role
if (name === 'role') {
6 years ago
if (invisible_elements.has(this.name)) {
// aria-unsupported-elements
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: ARIARoleDefinitionKey) => {
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));
}
// no-redundant-roles
const has_redundant_role = current_role === get_implicit_role(this.name, attribute_map);
if (this.name === current_role || has_redundant_role) {
component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role));
}
// Footers and headers are special cases, and should not have redundant roles unless they are the children of sections or articles.
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));
}
}
// role-has-required-aria-props
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));
}
}
}
// no-interactive-element-to-noninteractive-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));
}
// no-noninteractive-element-to-interactive-role
if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role)) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name));
}
});
}
}
// no-access-key
if (name === 'accesskey') {
component.warn(attribute, compiler_warnings.a11y_accesskey);
}
// no-autofocus
if (name === 'autofocus') {
component.warn(attribute, compiler_warnings.a11y_autofocus);
}
// scope
if (name === 'scope' && !this.is_dynamic_element && this.name !== 'th') {
component.warn(attribute, compiler_warnings.a11y_misplaced_scope);
}
// tabindex-no-positive
if (name === 'tabindex') {
6 years ago
const value = attribute.get_static_value();
// @ts-ignore todo is tabindex=true correct case?
if (!isNaN(value) && +value > 0) {
component.warn(attribute, compiler_warnings.a11y_positive_tabindex);
}
}
});
// click-events-have-key-events
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() as ARIARoleDefinitionKey);
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
);
}
}
}
// no-noninteractive-tabindex
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) {
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);
}
}
// role-supports-aria-props
const role = attribute_map.get('role');
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
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 as ARIAProperty)) {
component.warn(prop, compiler_warnings.a11y_role_supports_aria_props(prop.name, role_value, is_implicit, this.name));
}
});
}
}
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');
// links with target="_blank" should have noopener or noreferrer: https://developer.chrome.com/docs/lighthouse/best-practices/external-anchors-use-rel-noopener/
// modern browsers add noopener by default, so we only need to check legacy browsers
// legacy browsers don't support noopener so we only check for noreferrer there
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 {
6 years ago
const required_attributes = a11y_required_attributes[this.name];
if (required_attributes) {
const has_attribute = required_attributes.some(name => attribute_map.has(name));
6 years ago
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"');
}
}
}
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: INode[]) => {
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: Element) => 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)) {
// no-distracting-elements
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 as Element).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 as Element).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));
}
});
}
6 years ago
validate_bindings() {
const { component } = this;
6 years ago
const check_type_attribute = () => {
const attribute = this.attributes.find(
(attribute: Attribute) => attribute.name === 'type'
);
if (!attribute) return null;
6 years ago
if (!attribute.is_static) {
return component.error(attribute, compiler_errors.invalid_type);
}
6 years ago
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) => attribute.name === 'multiple'
);
6 years ago
if (attribute && !attribute.is_static) {
return component.error(attribute, compiler_errors.dynamic_multiple_attribute);
}
} else {
6 years ago
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'));
}
6 years ago
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'));
}
6 years ago
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'));
6 years ago
} 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') {
return component.error(binding, compiler_errors.invalid_binding(binding.name));
}
});
}
6 years ago
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));
}
}
6 years ago
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 => {
6 years ago
if (!valid_modifiers.has(modifier)) {
return component.error(handler, compiler_errors.invalid_event_modifier(list(Array.from(valid_modifiers))));
}
if (modifier === 'passive') {
6 years ago
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);
}
}
6 years ago
if (component.compile_options.legacy && (modifier === 'once' || modifier === 'passive')) {
// TODO this could be supported, but it would need a few changes to
// how event listeners work
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')) {
// touch/wheel events should be passive by default
handler.modifiers.add('passive');
}
});
}
6 years ago
is_media_node() {
7 years ago
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;
6 years ago
const class_attribute = this.attributes.find(a => a.name === 'class');
6 years ago
if (class_attribute && !class_attribute.is_true) {
if (class_attribute.chunks.length === 1 && class_attribute.chunks[0].type === 'Text') {
(class_attribute.chunks[0] as Text).data += ` ${id}`;
} else {
(class_attribute.chunks as Node[]).push(
new Text(this.component, this, this.scope, {
type: 'Text',
data: ` ${id}`,
synthetic: true
} as any)
);
}
} else {
this.attributes.push(
new Attribute(this.component, this, this.scope, {
type: 'Attribute',
name: 'class',
value: [{ type: 'Text', data: id, synthetic: true }]
} as any)
);
}
}
get slot_template_name() {
return this.attributes.find(attribute => attribute.name === 'slot').get_static_value() as string;
}
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;
}
});
}
});
}
7 years ago
}
const regex_starts_with_vowel = /^[aeiou]/;
6 years ago
function should_have_attribute(
node,
attributes: string[],
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: INode) {
while (parent) {
if (parent.type === 'InlineComponent') return false;
if (parent.type === 'Element' && regex_minus_sign.test(parent.name)) return true;
parent = parent.parent;
}
return false;
}