diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e0ce53864..0a4d83558f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,47 +6,12 @@ on: permissions: contents: read # to fetch code (actions/checkout) jobs: - Setup: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 18 - cache: npm - - run: npm install - env: - SKIP_PREPARE: true - - run: npm run build - env: - PUBLISH: true - - name: Upload build assets - id: upload-artifact - uses: actions/upload-artifact@v3 - with: - name: build-assets - path: | - index.* - compiler.* - ssr.* - action/ - animate/ - easing/ - internal/ - motion/ - store/ - transition/ - types/ Tests: - needs: Setup runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: matrix: - node-version: [8, 10, 12, 14, 16, 18] + node-version: [14, 16, 18] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - uses: actions/checkout@v3 @@ -54,24 +19,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm - - name: Download build assets - uses: actions/download-artifact@v3 - id: download-artifact - with: - name: build-assets - - name: Get Node version ${{ runner.os }} - run: echo "NODE_VERSION=`node --version`" >> $GITHUB_ENV - if: runner.os != 'Windows' - - name: Get Node version ${{ runner.os }} - run: | - chcp 65001 - echo ("NODE_VERSION=$(node --version)") >> $env:GITHUB_ENV - if: runner.os == 'Windows' - - run: npm install --save-dev puppeteer@13 - if: ${{ runner.os == 'Linux' && (!startsWith(env.NODE_VERSION, 'v8.') && !startsWith(env.NODE_VERSION, 'v10.')) }} - run: npm install - env: - SKIP_PREPARE: true - run: npm run test:integration env: CI: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a47296f80..44b50c6951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,80 @@ # Svelte changelog -## Unreleased - -* Make `noreferrer` warning less zealous ([#6289](https://github.com/sveltejs/svelte/issues/6289)) -* `trusted-types` CSP compatibility for Web Components ([#8134](https://github.com/sveltejs/svelte/issues/8134)) +## Unreleased (4.0) + +* Minimum supported Node version is now Node 14 + +## Unreleased (3.0) + +* Handle `width`/`height` attributes when spreading ([#6752](https://github.com/sveltejs/svelte/issues/6752)) +* Add support for resize observer bindings (`
`) ([#8022](https://github.com/sveltejs/svelte/pull/8022)) + +## 3.58.0 + +* Add `bind:innerText` for `contenteditable` elements ([#3311](https://github.com/sveltejs/svelte/issues/3311)) +* Add support for CSS `@container` queries ([#6969](https://github.com/sveltejs/svelte/issues/6969)) +* Respect `preserveComments` in DOM output ([#7182](https://github.com/sveltejs/svelte/pull/7182)) +* Allow use of `document` for `target` in typings ([#7554](https://github.com/sveltejs/svelte/pull/7554)) +* Add `a11y-interactive-supports-focus` warning ([#8392](https://github.com/sveltejs/svelte/pull/8392)) +* Fix equality check when updating dynamic text ([#5931](https://github.com/sveltejs/svelte/issues/5931)) +* Relax `a11y-no-noninteractive-element-to-interactive-role` warning ([#8402](https://github.com/sveltejs/svelte/pull/8402)) +* Properly handle microdata attributes ([#8413](https://github.com/sveltejs/svelte/issues/8413)) +* Prevent name collision when using computed destructuring variables ([#8417](https://github.com/sveltejs/svelte/issues/8417)) +* Fix escaping ` @@ -14,4 +13,4 @@ In cases like these, where the names match, we can also use a shorthand form: ``` -This applies to all bindings, not just textareas. \ No newline at end of file +This applies to all bindings, not just textareas. diff --git a/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md b/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md index a73b07c0ba..4d4b52f3b8 100644 --- a/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md +++ b/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md @@ -2,11 +2,16 @@ title: Contenteditable bindings --- -Elements with a `contenteditable="true"` attribute support `textContent` and `innerHTML` bindings: +Elements with the `contenteditable` attribute support the following bindings: +- [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML) +- [`innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText) +- [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) + +There are slight differences between each of these, read more about them [here](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#Differences_from_innerText). ```html
-``` \ No newline at end of file +``` diff --git a/site/content/tutorial/16-special-elements/06-svelte-body/text.md b/site/content/tutorial/16-special-elements/06-svelte-body/text.md deleted file mode 100644 index 95da3ce608..0000000000 --- a/site/content/tutorial/16-special-elements/06-svelte-body/text.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: ---- - -Similar to ``, the `` element allows you to listen for events that fire on `document.body`. This is useful with the `mouseenter` and `mouseleave` events, which don't fire on `window`. - -Add the `mouseenter` and `mouseleave` handlers to the `` tag: - -```html - -``` \ No newline at end of file diff --git a/site/content/tutorial/16-special-elements/06-svelte-document/app-a/App.svelte b/site/content/tutorial/16-special-elements/06-svelte-document/app-a/App.svelte new file mode 100644 index 0000000000..09cc596e8c --- /dev/null +++ b/site/content/tutorial/16-special-elements/06-svelte-document/app-a/App.svelte @@ -0,0 +1,10 @@ + + + + +

Select this text to fire events

+

Selection: {selection}

diff --git a/site/content/tutorial/16-special-elements/06-svelte-document/app-b/App.svelte b/site/content/tutorial/16-special-elements/06-svelte-document/app-b/App.svelte new file mode 100644 index 0000000000..5dd44aa389 --- /dev/null +++ b/site/content/tutorial/16-special-elements/06-svelte-document/app-b/App.svelte @@ -0,0 +1,10 @@ + + + + +

Select this text to fire events

+

Selection: {selection}

diff --git a/site/content/tutorial/16-special-elements/06-svelte-document/text.md b/site/content/tutorial/16-special-elements/06-svelte-document/text.md new file mode 100644 index 0000000000..b40c83a6f2 --- /dev/null +++ b/site/content/tutorial/16-special-elements/06-svelte-document/text.md @@ -0,0 +1,13 @@ +--- +title: +--- + +Similar to ``, the `` element allows you to listen for events that fire on `document`. This is useful with events like `selectionchange`, which doesn't fire on `window`. + +Add the `selectionchange` handler to the `` tag: + +```html + +``` + +> Avoid `mouseenter` and `mouseleave` handlers on this element, these events are not fired on `document` in all browsers. Use `` for this instead. diff --git a/site/content/tutorial/16-special-elements/06-svelte-body/app-a/App.svelte b/site/content/tutorial/16-special-elements/07-svelte-body/app-a/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/06-svelte-body/app-a/App.svelte rename to site/content/tutorial/16-special-elements/07-svelte-body/app-a/App.svelte diff --git a/site/content/tutorial/16-special-elements/06-svelte-body/app-b/App.svelte b/site/content/tutorial/16-special-elements/07-svelte-body/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/06-svelte-body/app-b/App.svelte rename to site/content/tutorial/16-special-elements/07-svelte-body/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/07-svelte-body/text.md b/site/content/tutorial/16-special-elements/07-svelte-body/text.md new file mode 100644 index 0000000000..4755281d61 --- /dev/null +++ b/site/content/tutorial/16-special-elements/07-svelte-body/text.md @@ -0,0 +1,14 @@ +--- +title: +--- + +Similar to `` and ``, the `` element allows you to listen for events that fire on `document.body`. This is useful with the `mouseenter` and `mouseleave` events, which don't fire on `window`. + +Add the `mouseenter` and `mouseleave` handlers to the `` tag: + +```html + +``` \ No newline at end of file diff --git a/site/content/tutorial/16-special-elements/07-svelte-head/app-a/App.svelte b/site/content/tutorial/16-special-elements/08-svelte-head/app-a/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/07-svelte-head/app-a/App.svelte rename to site/content/tutorial/16-special-elements/08-svelte-head/app-a/App.svelte diff --git a/site/content/tutorial/16-special-elements/07-svelte-head/app-b/App.svelte b/site/content/tutorial/16-special-elements/08-svelte-head/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/07-svelte-head/app-b/App.svelte rename to site/content/tutorial/16-special-elements/08-svelte-head/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/07-svelte-head/text.md b/site/content/tutorial/16-special-elements/08-svelte-head/text.md similarity index 100% rename from site/content/tutorial/16-special-elements/07-svelte-head/text.md rename to site/content/tutorial/16-special-elements/08-svelte-head/text.md diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-a/App.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-a/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-a/App.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-a/App.svelte diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-a/Todo.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-a/Todo.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-a/Todo.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-a/Todo.svelte diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-a/flash.js b/site/content/tutorial/16-special-elements/09-svelte-options/app-a/flash.js similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-a/flash.js rename to site/content/tutorial/16-special-elements/09-svelte-options/app-a/flash.js diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/App.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/App.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/Todo.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/Todo.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/Todo.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/Todo.svelte diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/flash.js b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/flash.js similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/flash.js rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/flash.js diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/text.md b/site/content/tutorial/16-special-elements/09-svelte-options/text.md similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/text.md rename to site/content/tutorial/16-special-elements/09-svelte-options/text.md diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/App.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/App.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/App.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/Box.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/Box.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/Box.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/Box.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/App.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/App.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/Box.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/Box.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/Box.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/Box.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/text.md b/site/content/tutorial/16-special-elements/10-svelte-fragment/text.md similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/text.md rename to site/content/tutorial/16-special-elements/10-svelte-fragment/text.md diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index c0d703892b..9fb41b6108 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -1,3 +1,4 @@ +import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping'; import { walk } from 'estree-walker'; import { getLocator } from 'locate-character'; import Stats from '../Stats'; @@ -32,7 +33,6 @@ import { print, b } from 'code-red'; import { is_reserved_keyword } from './utils/reserved_keywords'; import { apply_preprocessor_sourcemap } from '../utils/mapped_code'; import Element from './nodes/Element'; -import { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping/dist/types/types'; import { clone } from '../utils/clone'; import compiler_warnings from './compiler_warnings'; import compiler_errors from './compiler_errors'; @@ -793,20 +793,31 @@ export default class Component { } let deep = false; - let names: string[] | undefined; + let names: string[] = []; if (node.type === 'AssignmentExpression') { - deep = node.left.type === 'MemberExpression'; - names = deep - ? [get_object(node.left).name] - : extract_names(node.left); + if (node.left.type === 'ArrayPattern') { + walk(node.left, { + enter(node: Node, parent: Node) { + if (node.type === 'Identifier' && + parent.type !== 'MemberExpression' && + (parent.type !== 'AssignmentPattern' || parent.right !== node)) { + names.push(node.name); + } + } + }); + } else { + deep = node.left.type === 'MemberExpression'; + names = deep + ? [get_object(node.left).name] + : extract_names(node.left); + } } else if (node.type === 'UpdateExpression') { deep = node.argument.type === 'MemberExpression'; const { name } = get_object(node.argument); - names = [name]; + names.push(name); } - - if (names) { + if (names.length > 0) { names.forEach(name => { let current_scope = scope; let declaration; @@ -939,7 +950,7 @@ export default class Component { }); } - warn_on_undefined_store_value_references(node: Node, parent: Node, prop: string, scope: Scope) { + warn_on_undefined_store_value_references(node: Node, parent: Node, prop: string | number | symbol, scope: Scope) { if ( node.type === 'LabeledStatement' && node.label.name === '$' && diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index b0c836681c..bad3911673 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -66,7 +66,7 @@ export default { }, missing_contenteditable_attribute: { code: 'missing-contenteditable-attribute', - message: '\'contenteditable\' attribute is required for textContent and innerHTML two-way bindings' + message: '\'contenteditable\' attribute is required for textContent, innerHTML and innerText two-way bindings' }, dynamic_contenteditable_attribute: { code: 'dynamic-contenteditable-attribute', @@ -234,6 +234,10 @@ export default { code: 'css-invalid-global-selector', message: ':global(...) must contain a single selector' }, + css_invalid_global_selector_position: { + code: 'css-invalid-global-selector-position', + message: ':global(...) not at the start of a selector sequence should not contain type or universal selectors' + }, css_invalid_selector: (selector: string) => ({ code: 'css-invalid-selector', message: `Invalid selector "${selector}"` diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a10fe6155c..a851bc24c2 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -119,10 +119,25 @@ export default { code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` }), + a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({ + code: 'a11y-no-noninteractive-element-to-interactive-role', + message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'` + }), a11y_role_has_required_aria_props: (role: string, props: string[]) => ({ code: 'a11y-role-has-required-aria-props', message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}` }), + a11y_role_supports_aria_props: (attribute: string, role: string, is_implicit: boolean, name: string) => { + let message = `The attribute '${attribute}' is not supported by the role '${role}'.`; + if (is_implicit) { + message += ` This role is implicit on the element <${name}>.`; + } + + return { + code: 'a11y-role-supports-aria-props', + message: `A11y: ${message}` + }; + }, a11y_accesskey: { code: 'a11y-accesskey', message: 'A11y: Avoid using accesskey' @@ -151,6 +166,10 @@ export default { code: 'a11y-img-redundant-alt', message: 'A11y: Screenreaders already announce elements as an image.' }, + a11y_interactive_supports_focus: (role: string) => ({ + code: 'a11y-interactive-supports-focus', + message: `A11y: Elements with the '${role}' interactive role must have a tabindex value.` + }), a11y_label_has_associated_control: { code: 'a11y-label-has-associated-control', message: 'A11y: A form label must be associated with a control.' @@ -175,10 +194,10 @@ export default { code: 'a11y-mouse-events-have-key-events', message: `A11y: on:${event} must be accompanied by on:${accompanied_by}` }), - a11y_click_events_have_key_events: () => ({ + a11y_click_events_have_key_events: { code: 'a11y-click-events-have-key-events', message: 'A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.' - }), + }, a11y_missing_content: (name: string) => ({ code: 'a11y-missing-content', message: `A11y: <${name}> element should have child content` @@ -202,5 +221,9 @@ export default { invalid_rest_eachblock_binding: (rest_element_name: string) => ({ code: 'invalid-rest-eachblock-binding', message: `...${rest_element_name} operator will create a new object and binding propagation with original object will not work` - }) + }), + avoid_mouse_events_on_document: { + code: 'avoid-mouse-events-on-document', + message: 'Mouse enter/leave events on the document are not supported in all browsers and should be avoided' + } }; diff --git a/src/compiler/compile/css/Selector.ts b/src/compiler/compile/css/Selector.ts index 5feb59ec0c..c2ad450416 100644 --- a/src/compiler/compile/css/Selector.ts +++ b/src/compiler/compile/css/Selector.ts @@ -76,7 +76,7 @@ export default class Selector { this.blocks.forEach((block, i) => { if (i > 0) { if (block.start - c > 1) { - code.overwrite(c, block.start, block.combinator.name || ' '); + code.update(c, block.start, block.combinator.name || ' '); } } @@ -112,7 +112,7 @@ export default class Selector { } if (selector.type === 'TypeSelector' && selector.name === '*') { - code.overwrite(selector.start, selector.end, attr); + code.update(selector.start, selector.end, attr); } else { code.appendLeft(selector.end, attr); } @@ -148,6 +148,7 @@ export default class Selector { } this.validate_global_with_multiple_selectors(component); + this.validate_global_compound_selector(component); this.validate_invalid_combinator_without_selector(component); } @@ -179,6 +180,23 @@ export default class Selector { } } + validate_global_compound_selector(component: Component) { + for (const block of this.blocks) { + for (let index = 0; index < block.selectors.length; index++) { + const selector = block.selectors[index]; + if (selector.type === 'PseudoClassSelector' && + selector.name === 'global' && + index !== 0 && + selector.children && + selector.children.length > 0 && + !/[.:#\s]/.test(selector.children[0].value[0]) + ) { + component.error(selector, compiler_errors.css_invalid_global_selector_position); + } + } + } + } + get_amount_class_specificity_increased() { let count = 0; for (const block of this.blocks) { diff --git a/src/compiler/compile/css/Stylesheet.ts b/src/compiler/compile/css/Stylesheet.ts index 9a3cbe9d13..7cb1af3635 100644 --- a/src/compiler/compile/css/Stylesheet.ts +++ b/src/compiler/compile/css/Stylesheet.ts @@ -35,7 +35,7 @@ function minify_declarations( declarations.forEach((declaration, i) => { const separator = i > 0 ? ';' : ''; if ((declaration.node.start - c) > separator.length) { - code.overwrite(c, declaration.node.start, separator); + code.update(c, declaration.node.start, separator); } declaration.minify(code); c = declaration.node.end; @@ -75,7 +75,7 @@ class Rule { if (selector.used) { const separator = started ? ',' : ''; if ((selector.node.start - c) > separator.length) { - code.overwrite(c, selector.node.start, separator); + code.update(c, selector.node.start, separator); } selector.minify(code); @@ -133,7 +133,7 @@ class Declaration { if (block.type === 'Identifier') { const name = block.name; if (keyframes.has(name)) { - code.overwrite(block.start, block.end, keyframes.get(name)); + code.update(block.start, block.end, keyframes.get(name)); } } }); @@ -156,7 +156,7 @@ class Declaration { while (regex_whitespace.test(code.original[start])) start += 1; if (start - c > 1) { - code.overwrite(c, start, ':'); + code.update(c, start, ':'); } } } @@ -173,7 +173,7 @@ class Atrule { } apply(node: Element) { - if (this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') { + if (this.node.name === 'container' || this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') { this.children.forEach(child => { child.apply(node); }); @@ -204,7 +204,7 @@ class Atrule { code.remove(c, this.node.block.start); } else if (this.node.name === 'supports') { let c = this.node.start + 9; - if (this.node.prelude.start - c > 1) code.overwrite(c, this.node.prelude.start, ' '); + if (this.node.prelude.start - c > 1) code.update(c, this.node.prelude.start, ' '); this.node.prelude.children.forEach((query: CssNode) => { // TODO minify queries c = query.end; @@ -213,7 +213,7 @@ class Atrule { } else { let c = this.node.start + this.node.name.length + 1; if (this.node.prelude) { - if (this.node.prelude.start - c > 1) code.overwrite(c, this.node.prelude.start, ' '); + if (this.node.prelude.start - c > 1) code.update(c, this.node.prelude.start, ' '); c = this.node.prelude.end; } if (this.node.block && this.node.block.start - c > 0) { @@ -255,7 +255,7 @@ class Atrule { }); }); } else { - code.overwrite(start, end, keyframes.get(name)); + code.update(start, end, keyframes.get(name)); } } }); diff --git a/src/compiler/compile/nodes/Binding.ts b/src/compiler/compile/nodes/Binding.ts index 46112a03a3..303506222f 100644 --- a/src/compiler/compile/nodes/Binding.ts +++ b/src/compiler/compile/nodes/Binding.ts @@ -3,7 +3,7 @@ import get_object from '../utils/get_object'; import Expression from './shared/Expression'; import Component from '../Component'; import TemplateScope from './shared/TemplateScope'; -import { regex_dimensions } from '../../utils/patterns'; +import { regex_dimensions, regex_box_size } from '../../utils/patterns'; import { Node as ESTreeNode } from 'estree'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; @@ -22,7 +22,10 @@ const read_only_media_attributes = new Set([ 'seeking', 'ended', 'videoHeight', - 'videoWidth' + 'videoWidth', + 'naturalWidth', + 'naturalHeight', + 'readyState' ]); export default class Binding extends Node { @@ -89,6 +92,7 @@ export default class Binding extends Node { this.is_readonly = regex_dimensions.test(this.name) || + regex_box_size.test(this.name) || (isElement(parent) && ((parent.is_media_node() && read_only_media_attributes.has(this.name)) || (parent.name === 'input' && type === 'file')) /* TODO others? */); diff --git a/src/compiler/compile/nodes/CatchBlock.ts b/src/compiler/compile/nodes/CatchBlock.ts index ba6a4b77a6..d92b4eda56 100644 --- a/src/compiler/compile/nodes/CatchBlock.ts +++ b/src/compiler/compile/nodes/CatchBlock.ts @@ -17,6 +17,7 @@ export default class CatchBlock extends AbstractBlock { this.scope = scope.child(); if (parent.catch_node) { parent.catch_contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; this.scope.add(context.key.name, parent.expression.dependencies, this); }); } diff --git a/src/compiler/compile/nodes/ConstTag.ts b/src/compiler/compile/nodes/ConstTag.ts index 44a50aa005..edcb949c15 100644 --- a/src/compiler/compile/nodes/ConstTag.ts +++ b/src/compiler/compile/nodes/ConstTag.ts @@ -65,6 +65,7 @@ export default class ConstTag extends Node { }); this.expression = new Expression(this.component, this, this.scope, this.node.expression.right); this.contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; const owner = this.scope.get_owner(context.key.name); if (owner && owner.type === 'ConstTag' && owner.parent === this.parent) { this.component.error(this.node, compiler_errors.invalid_const_declaration(context.key.name)); diff --git a/src/compiler/compile/nodes/Document.ts b/src/compiler/compile/nodes/Document.ts new file mode 100644 index 0000000000..653ccb627b --- /dev/null +++ b/src/compiler/compile/nodes/Document.ts @@ -0,0 +1,41 @@ +import Node from './shared/Node'; +import EventHandler from './EventHandler'; +import Action from './Action'; +import Component from '../Component'; +import TemplateScope from './shared/TemplateScope'; +import { Element } from '../../interfaces'; +import compiler_warnings from '../compiler_warnings'; + +export default class Document extends Node { + type: 'Document'; + handlers: EventHandler[] = []; + actions: Action[] = []; + + constructor(component: Component, parent: Node, scope: TemplateScope, info: Element) { + super(component, parent, scope, info); + + info.attributes.forEach((node) => { + if (node.type === 'EventHandler') { + this.handlers.push(new EventHandler(component, this, scope, node)); + } else if (node.type === 'Action') { + this.actions.push(new Action(component, this, scope, node)); + } else { + // TODO there shouldn't be anything else here... + } + }); + + this.validate(); + } + + private validate() { + const handlers_map = new Set(); + + this.handlers.forEach(handler => ( + handlers_map.add(handler.name) + )); + + if (handlers_map.has('mouseenter') || handlers_map.has('mouseleave')) { + this.component.warn(this, compiler_warnings.avoid_mouse_events_on_document); + } + } +} diff --git a/src/compiler/compile/nodes/EachBlock.ts b/src/compiler/compile/nodes/EachBlock.ts index 4a5ea19e37..4659a53d73 100644 --- a/src/compiler/compile/nodes/EachBlock.ts +++ b/src/compiler/compile/nodes/EachBlock.ts @@ -45,6 +45,7 @@ export default class EachBlock extends AbstractBlock { unpack_destructuring({ contexts: this.contexts, node: info.context, scope, component, context_rest_properties: this.context_rest_properties }); this.contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; this.scope.add(context.key.name, this.expression.dependencies, this); }); diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 4d1cb758fe..2410904d63 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -11,7 +11,8 @@ import StyleDirective from './StyleDirective'; import Text from './Text'; import { namespaces } from '../../utils/namespaces'; import map_children from './shared/map_children'; -import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character } from '../../utils/patterns'; +import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable'; +import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns'; import fuzzymatch from '../../utils/fuzzymatch'; import list from '../../utils/list'; import Let from './Let'; @@ -23,15 +24,14 @@ import { string_literal } from '../utils/stringify'; import { Literal } from 'estree'; import compiler_warnings from '../compiler_warnings'; import compiler_errors from '../compiler_errors'; -import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; -import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element } from '../utils/a11y'; +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, is_static_element, has_disabled_attribute } from '../utils/a11y'; 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 aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract)); const a11y_required_attributes = { a: ['href'], @@ -75,6 +75,33 @@ const a11y_labelable = new Set([ 'textarea' ]); +const a11y_interactive_handlers = new Set([ + // Keyboard events + 'keypress', + 'keydown', + 'keyup', + + // Click events + 'click', + 'contextmenu', + 'dblclick', + 'drag', + 'dragend', + 'dragenter', + 'dragexit', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup' +]); + const a11y_nested_implicit_semantics = new Map([ ['header', 'banner'], ['footer', 'contentinfo'] @@ -82,11 +109,15 @@ const a11y_nested_implicit_semantics = new Map([ 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'], @@ -98,10 +129,14 @@ const a11y_implicit_semantics = new Map([ ['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'], @@ -115,11 +150,96 @@ const a11y_implicit_semantics = new Map([ ['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'] +]); + +/** + * Exceptions to the rule which follows common A11y conventions + * TODO make this configurable by the user + */ +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: Map) { + 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) { + 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 | 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); + } +} + const invisible_elements = new Set(['meta', 'html', 'script', 'style']); const valid_modifiers = new Set([ 'preventDefault', 'stopPropagation', + 'stopImmediatePropagation', 'capture', 'once', 'passive', @@ -487,8 +607,8 @@ export default class Element extends Node { } // aria-activedescendant-has-tabindex - if (name === 'aria-activedescendant' && !is_interactive_element(this.name, attribute_map) && !attribute_map.has('tabindex')) { - component.warn(attribute, compiler_warnings.a11y_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); } } @@ -502,8 +622,8 @@ export default class Element extends Node { const value = attribute.get_static_value(); if (typeof value === 'string') { - value.split(regex_any_repeated_whitespaces).forEach((current_role: ARIARoleDefintionKey) => { - if (current_role && aria_role_abstract_set.has(current_role)) { + 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); @@ -511,7 +631,7 @@ export default class Element extends Node { } // no-redundant-roles - const has_redundant_role = current_role === a11y_implicit_semantics.get(this.name); + 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)); @@ -527,7 +647,7 @@ export default class Element extends Node { } // role-has-required-aria-props - if (!is_semantic_role_element(current_role, this.name, attribute_map)) { + 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); @@ -539,12 +659,31 @@ export default class Element extends Node { } } + // interactive-supports-focus + 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)); + } + } + // 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) && !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)); + } + }); } } @@ -559,7 +698,7 @@ export default class Element extends Node { } // scope - if (name === 'scope' && this.name !== 'th') { + if (name === 'scope' && !this.is_dynamic_element && this.name !== 'th') { component.warn(attribute, compiler_warnings.a11y_misplaced_scope); } @@ -576,9 +715,10 @@ export default class Element extends Node { // 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 ARIARoleDefintionKey); + 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) && @@ -592,19 +732,36 @@ export default class Element extends Node { if (!has_key_event) { component.warn( this, - compiler_warnings.a11y_click_events_have_key_events() + compiler_warnings.a11y_click_events_have_key_events ); } } } // no-noninteractive-tabindex - if (!is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefintionKey)) { + 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() { @@ -898,7 +1055,8 @@ export default class Element extends Node { name === 'muted' || name === 'playbackRate' || name === 'seeking' || - name === 'ended' + name === 'ended' || + name === 'readyState' ) { if (this.name !== 'audio' && this.name !== 'video') { return component.error(binding, compiler_errors.invalid_binding_element_with('audio> or