diff --git a/src/generators/dom/preprocess.ts b/src/generators/dom/preprocess.ts index e602af60aee1..b945c902b999 100644 --- a/src/generators/dom/preprocess.ts +++ b/src/generators/dom/preprocess.ts @@ -1,7 +1,7 @@ import Block from './Block'; import { trimStart, trimEnd } from '../../utils/trim'; import { assign } from '../../shared/index.js'; -import getStaticAttributeValue from '../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../utils/getStaticAttributeValue'; import { DomGenerator } from './index'; import { Node } from '../../interfaces'; import { State } from './interfaces'; diff --git a/src/generators/dom/visitors/Element/Attribute.ts b/src/generators/dom/visitors/Element/Attribute.ts index a994c6b58b04..837e0945dbfa 100644 --- a/src/generators/dom/visitors/Element/Attribute.ts +++ b/src/generators/dom/visitors/Element/Attribute.ts @@ -3,7 +3,7 @@ import deindent from '../../../../utils/deindent'; import visitStyleAttribute, { optimizeStyle } from './StyleAttribute'; import { stringify } from '../../../../utils/stringify'; import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence'; -import getStaticAttributeValue from '../../../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; import { DomGenerator } from '../../index'; import Block from '../../Block'; import { Node } from '../../../../interfaces'; diff --git a/src/generators/dom/visitors/Element/Binding.ts b/src/generators/dom/visitors/Element/Binding.ts index 8878af17d09c..492cebd5b44f 100644 --- a/src/generators/dom/visitors/Element/Binding.ts +++ b/src/generators/dom/visitors/Element/Binding.ts @@ -1,6 +1,6 @@ import deindent from '../../../../utils/deindent'; import flattenReference from '../../../../utils/flattenReference'; -import getStaticAttributeValue from '../../../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; import { DomGenerator } from '../../index'; import Block from '../../Block'; import { Node } from '../../../../interfaces'; diff --git a/src/generators/dom/visitors/Element/Element.ts b/src/generators/dom/visitors/Element/Element.ts index f26c3e341bce..49eeab2bef52 100644 --- a/src/generators/dom/visitors/Element/Element.ts +++ b/src/generators/dom/visitors/Element/Element.ts @@ -8,7 +8,7 @@ import visitEventHandler from './EventHandler'; import visitBinding from './Binding'; import visitRef from './Ref'; import * as namespaces from '../../../../utils/namespaces'; -import getStaticAttributeValue from '../../../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; import addTransitions from './addTransitions'; import { DomGenerator } from '../../index'; import Block from '../../Block'; @@ -102,7 +102,7 @@ export default function visitElement( if (node._cssRefAttribute) { block.builders.hydrate.addLine( - `@setAttribute(${name}, "svelte-ref-${node._cssRefAttribute}", ");` + `@setAttribute(${name}, "svelte-ref-${node._cssRefAttribute}", "");` ) } } diff --git a/src/generators/dom/visitors/Element/StyleAttribute.ts b/src/generators/dom/visitors/Element/StyleAttribute.ts index 4cf577614524..ab661bfe06ed 100644 --- a/src/generators/dom/visitors/Element/StyleAttribute.ts +++ b/src/generators/dom/visitors/Element/StyleAttribute.ts @@ -2,7 +2,7 @@ import attributeLookup from './lookup'; import deindent from '../../../../utils/deindent'; import { stringify } from '../../../../utils/stringify'; import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence'; -import getStaticAttributeValue from '../../../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; import { DomGenerator } from '../../index'; import Block from '../../Block'; import { Node } from '../../../../interfaces'; diff --git a/src/generators/dom/visitors/Slot.ts b/src/generators/dom/visitors/Slot.ts index 5249879f40e3..ef3651e1bf7f 100644 --- a/src/generators/dom/visitors/Slot.ts +++ b/src/generators/dom/visitors/Slot.ts @@ -2,7 +2,7 @@ import { DomGenerator } from '../index'; import deindent from '../../../utils/deindent'; import visit from '../visit'; import Block from '../Block'; -import getStaticAttributeValue from '../../shared/getStaticAttributeValue'; +import getStaticAttributeValue from '../../../utils/getStaticAttributeValue'; import { Node } from '../../../interfaces'; import { State } from '../interfaces'; diff --git a/src/generators/shared/getStaticAttributeValue.ts b/src/generators/shared/getStaticAttributeValue.ts deleted file mode 100644 index 02d38343d3b7..000000000000 --- a/src/generators/shared/getStaticAttributeValue.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Node } from '../../interfaces'; - -export default function getStaticAttributeValue(node: Node, name: string) { - const attribute = node.attributes.find( - (attr: Node) => attr.name.toLowerCase() === name - ); - if (!attribute) return null; - - if (attribute.value.length !== 1 || attribute.value[0].type !== 'Text') { - // TODO catch this in validation phase, give a more useful error (with location etc) - throw new Error(`'${name}' must be a static attribute`); - } - - return attribute.value[0].data; -} diff --git a/src/interfaces.ts b/src/interfaces.ts index 07bca42ca0bd..acb967149982 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -54,7 +54,7 @@ export interface CompileOptions { cascade?: boolean; hydratable?: boolean; legacy?: boolean; - customElement: CustomElementOptions | true; + customElement?: CustomElementOptions | true; onerror?: (error: Error) => void; onwarn?: (warning: Warning) => void; diff --git a/src/utils/getStaticAttributeValue.ts b/src/utils/getStaticAttributeValue.ts new file mode 100644 index 000000000000..67bea122593a --- /dev/null +++ b/src/utils/getStaticAttributeValue.ts @@ -0,0 +1,17 @@ +import { Node } from '../interfaces'; + +export default function getStaticAttributeValue(node: Node, name: string) { + const attribute = node.attributes.find( + (attr: Node) => attr.name.toLowerCase() === name + ); + + if (!attribute) return null; + + if (attribute.value.length === 0) return ''; + + if (attribute.value.length === 1 && attribute.value[0].type === 'Text') { + return attribute.value[0].data; + } + + return null; +} diff --git a/src/validate/html/a11y.ts b/src/validate/html/a11y.ts new file mode 100644 index 000000000000..205a6388f550 --- /dev/null +++ b/src/validate/html/a11y.ts @@ -0,0 +1,171 @@ +import * as namespaces from '../../utils/namespaces'; +import getStaticAttributeValue from '../../utils/getStaticAttributeValue'; +import fuzzymatch from '../utils/fuzzymatch'; +import validateEventHandler from './validateEventHandler'; +import { Validator } from '../index'; +import { Node } from '../../interfaces'; + +const ariaAttributes = 'activedescendant atomic autocomplete busy checked controls describedby disabled dropeffect expanded flowto grabbed haspopup hidden invalid label labelledby level live multiline multiselectable orientation owns posinset pressed readonly relevant required selected setsize sort valuemax valuemin valuenow valuetext'.split(' '); +const ariaAttributeSet = new Set(ariaAttributes); + +const ariaRoles = 'alert alertdialog application article banner button checkbox columnheader combobox command complementary composite contentinfo definition dialog directory document form grid gridcell group heading img input landmark link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation note option presentation progressbar radio radiogroup range region roletype row rowgroup rowheader scrollbar search section sectionhead select separator slider spinbutton status structure tab tablist tabpanel textbox timer toolbar tooltip tree treegrid treeitem widget window'.split(' '); +const ariaRoleSet = new Set(ariaRoles); + +const invisibleElements = new Set(['meta', 'html', 'script', 'style']); + +export default function a11y( + validator: Validator, + node: Node, + elementStack: Node[] +) { + if (node.type === 'Text') { + // accessible-emoji + return; + } + + if (node.type !== 'Element') return; + + const attributeMap = new Map(); + node.attributes.forEach((attribute: Node) => { + const name = attribute.name.toLowerCase(); + + // aria-props + if (name.startsWith('aria-')) { + if (invisibleElements.has(node.name)) { + // aria-unsupported-elements + validator.warn(`A11y: <${node.name}> should not have aria-* attributes`, attribute.start); + } + + const type = name.slice(5); + if (!ariaAttributeSet.has(type)) { + const match = fuzzymatch(type, ariaAttributes); + let message = `A11y: Unknown aria attribute 'aria-${type}'`; + if (match) message += ` (did you mean '${match}'?)`; + + validator.warn(message, attribute.start); + } + } + + // aria-role + if (name === 'role') { + if (invisibleElements.has(node.name)) { + // aria-unsupported-elements + validator.warn(`A11y: <${node.name}> should not have role attribute`, attribute.start); + } + + const value = getStaticAttributeValue(node, 'role'); + if (value && !ariaRoleSet.has(value)) { + const match = fuzzymatch(value, ariaRoles); + let message = `A11y: Unknown role '${value}'`; + if (match) message += ` (did you mean '${match}'?)`; + + validator.warn(message, attribute.start); + } + } + + // no-access-key + if (name === 'accesskey') { + validator.warn(`A11y: Avoid using accesskey`, attribute.start); + } + + // no-autofocus + if (name === 'autofocus') { + validator.warn(`A11y: Avoid using autofocus`, attribute.start); + } + + // scope + if (name === 'scope' && node.name !== 'th') { + validator.warn(`A11y: The scope attribute should only be used with