From 04f5d5c975adad4ac1a58373fffcee5648290f46 Mon Sep 17 00:00:00 2001 From: Jacob Wright Date: Thu, 15 Mar 2018 22:11:01 -0600 Subject: [PATCH] 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 export default { actions: { b(node, data) { // do something return { update(data) {}, destroy() {} } } } } ``` 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. --- src/generators/Generator.ts | 20 +- src/generators/nodes/Action.ts | 7 + src/generators/nodes/Element.ts | 301 ++++++++++-------- src/generators/nodes/index.ts | 2 + src/parse/read/directives.ts | 12 +- src/validate/html/validateElement.ts | 13 + src/validate/index.ts | 9 +- src/validate/js/index.ts | 2 +- src/validate/js/propValidators/actions.ts | 16 + src/validate/js/propValidators/index.ts | 2 + test/js/samples/action/expected-bundle.js | 246 ++++++++++++++ test/js/samples/action/expected.js | 64 ++++ test/js/samples/action/input.html | 23 ++ test/parser/index.js | 3 +- .../samples/action-with-call/input.html | 1 + .../samples/action-with-call/output.json | 47 +++ .../samples/action-with-identifier/input.html | 1 + .../action-with-identifier/output.json | 33 ++ .../samples/action-with-literal/input.html | 1 + .../samples/action-with-literal/output.json | 34 ++ test/parser/samples/action/input.html | 1 + test/parser/samples/action/output.json | 28 ++ .../samples/action-function/_config.js | 22 ++ .../runtime/samples/action-function/main.html | 46 +++ test/runtime/samples/action-update/_config.js | 29 ++ test/runtime/samples/action-update/main.html | 52 +++ test/runtime/samples/action/_config.js | 22 ++ test/runtime/samples/action/main.html | 34 ++ test/runtime/samples/trait-function/main.html | 46 +++ .../samples/action-invalid/errors.json | 12 + .../samples/action-invalid/input.html | 1 + .../samples/action-on-component/errors.json | 12 + .../samples/action-on-component/input.html | 15 + 33 files changed, 1025 insertions(+), 132 deletions(-) create mode 100644 src/generators/nodes/Action.ts create mode 100644 src/validate/js/propValidators/actions.ts create mode 100644 test/js/samples/action/expected-bundle.js create mode 100644 test/js/samples/action/expected.js create mode 100644 test/js/samples/action/input.html create mode 100644 test/parser/samples/action-with-call/input.html create mode 100644 test/parser/samples/action-with-call/output.json create mode 100644 test/parser/samples/action-with-identifier/input.html create mode 100644 test/parser/samples/action-with-identifier/output.json create mode 100644 test/parser/samples/action-with-literal/input.html create mode 100644 test/parser/samples/action-with-literal/output.json create mode 100644 test/parser/samples/action/input.html create mode 100644 test/parser/samples/action/output.json create mode 100644 test/runtime/samples/action-function/_config.js create mode 100644 test/runtime/samples/action-function/main.html create mode 100644 test/runtime/samples/action-update/_config.js create mode 100644 test/runtime/samples/action-update/main.html create mode 100644 test/runtime/samples/action/_config.js create mode 100644 test/runtime/samples/action/main.html create mode 100644 test/runtime/samples/trait-function/main.html create mode 100644 test/validator/samples/action-invalid/errors.json create mode 100644 test/validator/samples/action-invalid/input.html create mode 100644 test/validator/samples/action-on-component/errors.json create mode 100644 test/validator/samples/action-on-component/input.html diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index b53851cec9ae..72edbe8d0e5c 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -91,6 +91,7 @@ export default class Generator { components: Set; events: Set; transitions: Set; + actions: Set; importedComponents: Map; namespace: string; hasComponents: boolean; @@ -134,6 +135,7 @@ export default class Generator { this.components = new Set(); this.events = new Set(); this.transitions = new Set(); + this.actions = new Set(); this.importedComponents = new Map(); this.slots = new Set(); @@ -452,7 +454,7 @@ export default class Generator { templateProperties[getName(prop.key)] = prop; }); - ['helpers', 'events', 'components', 'transitions'].forEach(key => { + ['helpers', 'events', 'components', 'transitions', 'actions'].forEach(key => { if (templateProperties[key]) { templateProperties[key].value.properties.forEach((prop: Node) => { this[key].add(getName(prop.key)); @@ -636,6 +638,12 @@ export default class Generator { addDeclaration(getName(property.key), property.value, 'transitions'); }); } + + if (templateProperties.actions) { + templateProperties.actions.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, 'actions'); + }); + } } if (indentationLevel) { @@ -824,6 +832,16 @@ export default class Generator { this.skip(); } + if (node.type === 'Action' && node.expression) { + node.metadata = contextualise(node.expression, contextDependencies, indexes, false); + if (node.expression.type === 'CallExpression') { + node.expression.arguments.forEach((arg: Node) => { + arg.metadata = contextualise(arg, contextDependencies, indexes, true); + }); + } + this.skip(); + } + if (node.type === 'Component' && node.name === ':Component') { node.metadata = contextualise(node.expression, contextDependencies, indexes, false); } diff --git a/src/generators/nodes/Action.ts b/src/generators/nodes/Action.ts new file mode 100644 index 000000000000..fa1320fface9 --- /dev/null +++ b/src/generators/nodes/Action.ts @@ -0,0 +1,7 @@ +import Node from './shared/Node'; + +export default class Action extends Node { + name: string; + value: Node[] + expression: Node +} \ No newline at end of file diff --git a/src/generators/nodes/Element.ts b/src/generators/nodes/Element.ts index 42db165f9daf..45b6bad6b41f 100644 --- a/src/generators/nodes/Element.ts +++ b/src/generators/nodes/Element.ts @@ -12,13 +12,14 @@ import Binding from './Binding'; import EventHandler from './EventHandler'; import Ref from './Ref'; import Transition from './Transition'; +import Action from './Action'; import Text from './Text'; import * as namespaces from '../../utils/namespaces'; export default class Element extends Node { type: 'Element'; name: string; - attributes: (Attribute | Binding | EventHandler | Ref | Transition)[]; // TODO split these up sooner + attributes: (Attribute | Binding | EventHandler | Ref | Transition | Action)[]; // TODO split these up sooner children: Node[]; init( @@ -84,6 +85,8 @@ export default class Element extends Node { this.generator.hasOutroTransitions = block.hasOutroMethod = true; block.outros += 1; } + } else if (attribute.type === 'Action' && attribute.expression) { + block.addDependencies(attribute.metadata.dependencies); } } }); @@ -235,131 +238,11 @@ export default class Element extends Node { } this.addBindings(block, allUsedContexts); - - this.attributes.filter((a: Attribute) => a.type === 'Attribute').forEach((attribute: Attribute) => { - attribute.render(block); - }); - - // event handlers - let eventHandlerUsesComponent = false; - - this.attributes.filter((a: Node) => a.type === 'EventHandler').forEach((attribute: Node) => { - const isCustomEvent = generator.events.has(attribute.name); - const shouldHoist = !isCustomEvent && this.hasAncestor('EachBlock'); - - const context = shouldHoist ? null : name; - const usedContexts: string[] = []; - - if (attribute.expression) { - generator.addSourcemapLocations(attribute.expression); - - const flattened = flattenReference(attribute.expression.callee); - if (!validCalleeObjects.has(flattened.name)) { - // allow event.stopPropagation(), this.select() etc - // TODO verify that it's a valid callee (i.e. built-in or declared method) - generator.code.prependRight( - attribute.expression.start, - `${block.alias('component')}.` - ); - if (shouldHoist) eventHandlerUsesComponent = true; // this feels a bit hacky but it works! - } - - attribute.expression.arguments.forEach((arg: Node) => { - const { contexts } = block.contextualise(arg, context, true); - - contexts.forEach(context => { - if (!~usedContexts.indexOf(context)) usedContexts.push(context); - allUsedContexts.add(context); - }); - }); - } - - const ctx = context || 'this'; - const declarations = usedContexts - .map(name => { - if (name === 'state') { - if (shouldHoist) eventHandlerUsesComponent = true; - return `var state = ${block.alias('component')}.get();`; - } - - const contextType = block.contextTypes.get(name); - if (contextType === 'each') { - const listName = block.listNames.get(name); - const indexName = block.indexNames.get(name); - const contextName = block.contexts.get(name); - - return `var ${listName} = ${ctx}._svelte.${listName}, ${indexName} = ${ctx}._svelte.${indexName}, ${contextName} = ${listName}[${indexName}];`; - } - }) - .filter(Boolean); - - // get a name for the event handler that is globally unique - // if hoisted, locally unique otherwise - const handlerName = (shouldHoist ? generator : block).getUniqueName( - `${attribute.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_handler` - ); - - // create the handler body - const handlerBody = deindent` - ${eventHandlerUsesComponent && - `var ${block.alias('component')} = ${ctx}._svelte.component;`} - ${declarations} - ${attribute.expression ? - `[✂${attribute.expression.start}-${attribute.expression.end}✂];` : - `${block.alias('component')}.fire("${attribute.name}", event);`} - `; - - if (isCustomEvent) { - block.addVariable(handlerName); - - block.builders.hydrate.addBlock(deindent` - ${handlerName} = %events-${attribute.name}.call(#component, ${name}, function(event) { - ${handlerBody} - }); - `); - - block.builders.destroy.addLine(deindent` - ${handlerName}.teardown(); - `); - } else { - const handler = deindent` - function ${handlerName}(event) { - ${handlerBody} - } - `; - - if (shouldHoist) { - generator.blocks.push(handler); - } else { - block.builders.init.addBlock(handler); - } - - block.builders.hydrate.addLine( - `@addListener(${name}, "${attribute.name}", ${handlerName});` - ); - - block.builders.destroy.addLine( - `@removeListener(${name}, "${attribute.name}", ${handlerName});` - ); - } - }); - - // refs - this.attributes.filter((a: Node) => a.type === 'Ref').forEach((attribute: Node) => { - const ref = `#component.refs.${attribute.name}`; - - block.builders.mount.addLine( - `${ref} = ${name};` - ); - - block.builders.destroy.addLine( - `if (${ref} === ${name}) ${ref} = null;` - ); - - generator.usesRefs = true; // so component.refs object is created - }); - + this.addAttributes(block); + const eventHandlerUsesComponent = this.addEventHandlers(block, allUsedContexts); + this.addRefs(block); this.addTransitions(block); + this.addActions(block); if (allUsedContexts.size || eventHandlerUsesComponent) { const initialProps: string[] = []; @@ -548,6 +431,135 @@ export default class Element extends Node { this.initialUpdate = mungedBindings.map(binding => binding.initialUpdate).filter(Boolean).join('\n'); } + addAttributes(block: Block) { + this.attributes.filter((a: Attribute) => a.type === 'Attribute').forEach((attribute: Attribute) => { + attribute.render(block); + }); + } + + addEventHandlers(block: Block, allUsedContexts) { + const { generator } = this; + let eventHandlerUsesComponent = false; + + this.attributes.filter((a: EventHandler) => a.type === 'EventHandler').forEach((attribute: EventHandler) => { + const isCustomEvent = generator.events.has(attribute.name); + const shouldHoist = !isCustomEvent && this.hasAncestor('EachBlock'); + + const context = shouldHoist ? null : this.var; + const usedContexts: string[] = []; + + if (attribute.expression) { + generator.addSourcemapLocations(attribute.expression); + + const flattened = flattenReference(attribute.expression.callee); + if (!validCalleeObjects.has(flattened.name)) { + // allow event.stopPropagation(), this.select() etc + // TODO verify that it's a valid callee (i.e. built-in or declared method) + generator.code.prependRight( + attribute.expression.start, + `${block.alias('component')}.` + ); + if (shouldHoist) eventHandlerUsesComponent = true; // this feels a bit hacky but it works! + } + + attribute.expression.arguments.forEach((arg: Node) => { + const { contexts } = block.contextualise(arg, context, true); + + contexts.forEach(context => { + if (!~usedContexts.indexOf(context)) usedContexts.push(context); + allUsedContexts.add(context); + }); + }); + } + + const ctx = context || 'this'; + const declarations = usedContexts + .map(name => { + if (name === 'state') { + if (shouldHoist) eventHandlerUsesComponent = true; + return `var state = ${block.alias('component')}.get();`; + } + + const contextType = block.contextTypes.get(name); + if (contextType === 'each') { + const listName = block.listNames.get(name); + const indexName = block.indexNames.get(name); + const contextName = block.contexts.get(name); + + return `var ${listName} = ${ctx}._svelte.${listName}, ${indexName} = ${ctx}._svelte.${indexName}, ${contextName} = ${listName}[${indexName}];`; + } + }) + .filter(Boolean); + + // get a name for the event handler that is globally unique + // if hoisted, locally unique otherwise + const handlerName = (shouldHoist ? generator : block).getUniqueName( + `${attribute.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_handler` + ); + + // create the handler body + const handlerBody = deindent` + ${eventHandlerUsesComponent && + `var ${block.alias('component')} = ${ctx}._svelte.component;`} + ${declarations} + ${attribute.expression ? + `[✂${attribute.expression.start}-${attribute.expression.end}✂];` : + `${block.alias('component')}.fire("${attribute.name}", event);`} + `; + + if (isCustomEvent) { + block.addVariable(handlerName); + + block.builders.hydrate.addBlock(deindent` + ${handlerName} = %events-${attribute.name}.call(#component, ${this.var}, function(event) { + ${handlerBody} + }); + `); + + block.builders.destroy.addLine(deindent` + ${handlerName}.teardown(); + `); + } else { + const handler = deindent` + function ${handlerName}(event) { + ${handlerBody} + } + `; + + if (shouldHoist) { + generator.blocks.push(handler); + } else { + block.builders.init.addBlock(handler); + } + + block.builders.hydrate.addLine( + `@addListener(${this.var}, "${attribute.name}", ${handlerName});` + ); + + block.builders.destroy.addLine( + `@removeListener(${this.var}, "${attribute.name}", ${handlerName});` + ); + } + }); + return eventHandlerUsesComponent; + } + + addRefs(block: Block) { + this.attributes.filter((a: Ref) => a.type === 'Ref').forEach((attribute: Ref) => { + const ref = `#component.refs.${attribute.name}`; + + block.builders.mount.addLine( + `${ref} = ${this.var};` + ); + + block.builders.destroy.addLine( + `if (${ref} === ${this.var}) ${ref} = null;` + ); + + this.generator.usesRefs = true; // so component.refs object is created + }); + } + addTransitions( block: Block ) { @@ -638,6 +650,45 @@ export default class Element extends Node { } } + addActions(block: Block) { + this.attributes.filter((a: Action) => a.type === 'Action').forEach((attribute:Action) => { + const { expression } = attribute; + let snippet, dependencies; + if (expression) { + this.generator.addSourcemapLocations(expression); + block.contextualise(expression); + snippet = attribute.metadata.snippet; + dependencies = attribute.metadata.dependencies; + } + + const name = block.getUniqueName( + `${attribute.name.replace(/[^a-zA-Z0-9_$]/g, '_')}_action` + ); + + block.addVariable(name); + const fn = `%actions-${attribute.name}`; + + block.builders.hydrate.addLine( + `${name} = ${fn}(${this.var}${snippet ? `, ${snippet}` : ''}) || {};` + ); + + if (dependencies && dependencies.length) { + let conditional = `typeof ${name}.update === 'function' && `; + const deps = dependencies.map(dependency => `changed.${dependency}`).join(' || '); + conditional += dependencies.length > 1 ? `(${deps})` : deps; + + block.builders.update.addConditional( + conditional, + `${name}.update(${snippet});` + ); + } + + block.builders.destroy.addLine( + `if (typeof ${name}.destroy === 'function') ${name}.destroy();` + ); + }); + } + getStaticAttributeValue(name: string) { const attribute = this.attributes.find( (attr: Attribute) => attr.type === 'Attribute' && attr.name.toLowerCase() === name diff --git a/src/generators/nodes/index.ts b/src/generators/nodes/index.ts index c9602455f317..e3c9de874dcc 100644 --- a/src/generators/nodes/index.ts +++ b/src/generators/nodes/index.ts @@ -1,6 +1,7 @@ import Node from './shared/Node'; import Attribute from './Attribute'; import AwaitBlock from './AwaitBlock'; +import Action from './Action'; import Binding from './Binding'; import CatchBlock from './CatchBlock'; import Comment from './Comment'; @@ -26,6 +27,7 @@ import Window from './Window'; const nodes: Record = { Attribute, AwaitBlock, + Action, Binding, CatchBlock, Comment, diff --git a/src/parse/read/directives.ts b/src/parse/read/directives.ts index c9c79a85c328..744da4a2401c 100644 --- a/src/parse/read/directives.ts +++ b/src/parse/read/directives.ts @@ -49,7 +49,17 @@ const DIRECTIVES = { }, allowedExpressionTypes: ['ObjectExpression'], error: 'Transition argument must be an object literal, e.g. `{ duration: 400 }`' - } + }, + + Action: { + names: [ 'use' ], + attribute(start, end, type, name, expression) { + return { start, end, type, name, expression }; + }, + allowedExpressionTypes: [ 'Identifier', 'MemberExpression', 'ObjectExpression', 'Literal', 'CallExpression' ], + error: 'Data passed to actions must be an identifier (e.g. `foo`), a member expression ' + + '(e.g. `foo.bar` or `foo[baz]`), a method call (e.g. `foo()`), or a literal (e.g. `true` or `\'a string\'`' + }, }; diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts index 9172854c7a53..3fcec9784b27 100644 --- a/src/validate/html/validateElement.ts +++ b/src/validate/html/validateElement.ts @@ -217,6 +217,19 @@ export default function validateElement( if (attribute.name === 'slot' && !isComponent) { checkSlotAttribute(validator, node, attribute, stack); } + } else if (attribute.type === 'Action') { + if (isComponent) { + validator.error(`Actions can only be applied to DOM elements, not components`, attribute); + } + + validator.used.actions.add(attribute.name); + + if (!validator.actions.has(attribute.name)) { + validator.error( + `Missing action '${attribute.name}'`, + attribute + ); + } } }); } diff --git a/src/validate/index.ts b/src/validate/index.ts index 1c68a76306fe..fd487ccb0ff2 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -33,6 +33,7 @@ export class Validator { methods: Map; helpers: Map; transitions: Map; + actions: Map; slots: Set; used: { @@ -40,6 +41,7 @@ export class Validator { helpers: Set; events: Set; transitions: Set; + actions: Set; }; constructor(parsed: Parsed, source: string, options: CompileOptions) { @@ -56,13 +58,15 @@ export class Validator { this.methods = new Map(); this.helpers = new Map(); this.transitions = new Map(); + this.actions = new Map(); this.slots = new Set(); this.used = { components: new Set(), helpers: new Set(), events: new Set(), - transitions: new Set() + transitions: new Set(), + actions: new Set(), }; } @@ -139,7 +143,8 @@ export default function validate( // TODO helpers require a bit more work — need to analyse all expressions // helpers: 'helper', events: 'event definition', - transitions: 'transition' + transitions: 'transition', + actions: 'actions', }; Object.keys(categories).forEach(category => { diff --git a/src/validate/js/index.ts b/src/validate/js/index.ts index 927d3bf2f2e7..a0fc3ee017e1 100644 --- a/src/validate/js/index.ts +++ b/src/validate/js/index.ts @@ -85,7 +85,7 @@ export default function validateJs(validator: Validator, js: Node) { } }); - ['components', 'methods', 'helpers', 'transitions'].forEach(key => { + ['components', 'methods', 'helpers', 'transitions', 'actions'].forEach(key => { if (validator.properties.has(key)) { validator.properties.get(key).value.properties.forEach((prop: Node) => { validator[key].set(getName(prop.key), prop.value); diff --git a/src/validate/js/propValidators/actions.ts b/src/validate/js/propValidators/actions.ts new file mode 100644 index 000000000000..8dc3ad920213 --- /dev/null +++ b/src/validate/js/propValidators/actions.ts @@ -0,0 +1,16 @@ +import checkForDupes from '../utils/checkForDupes'; +import checkForComputedKeys from '../utils/checkForComputedKeys'; +import { Validator } from '../../'; +import { Node } from '../../../interfaces'; + +export default function actions(validator: Validator, prop: Node) { + if (prop.value.type !== 'ObjectExpression') { + validator.error( + `The 'actions' property must be an object literal`, + prop + ); + } + + checkForDupes(validator, prop.value.properties); + checkForComputedKeys(validator, prop.value.properties); +} diff --git a/src/validate/js/propValidators/index.ts b/src/validate/js/propValidators/index.ts index 05819b0837ef..8641ed36b9e9 100644 --- a/src/validate/js/propValidators/index.ts +++ b/src/validate/js/propValidators/index.ts @@ -1,4 +1,5 @@ import data from './data'; +import actions from './actions'; import computed from './computed'; import oncreate from './oncreate'; import ondestroy from './ondestroy'; @@ -19,6 +20,7 @@ import immutable from './immutable'; export default { data, + actions, computed, oncreate, ondestroy, diff --git a/test/js/samples/action/expected-bundle.js b/test/js/samples/action/expected-bundle.js new file mode 100644 index 000000000000..fe0b795b7f51 --- /dev/null +++ b/test/js/samples/action/expected-bundle.js @@ -0,0 +1,246 @@ +function noop() {} + +function assign(target) { + var k, + source, + i = 1, + len = arguments.length; + for (; i < len; i++) { + source = arguments[i]; + for (k in source) target[k] = source[k]; + } + + return target; +} + +function insertNode(node, target, anchor) { + target.insertBefore(node, anchor); +} + +function detachNode(node) { + node.parentNode.removeChild(node); +} + +function createElement(name) { + return document.createElement(name); +} + +function blankObject() { + return Object.create(null); +} + +function destroy(detach) { + this.destroy = noop; + this.fire('destroy'); + this.set = this.get = noop; + + if (detach !== false) this._fragment.u(); + this._fragment.d(); + this._fragment = this._state = null; +} + +function _differs(a, b) { + return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function'); +} + +function dispatchObservers(component, group, changed, newState, oldState) { + for (var key in group) { + if (!changed[key]) continue; + + var newValue = newState[key]; + var oldValue = oldState[key]; + + var callbacks = group[key]; + if (!callbacks) continue; + + for (var i = 0; i < callbacks.length; i += 1) { + var callback = callbacks[i]; + if (callback.__calling) continue; + + callback.__calling = true; + callback.call(component, newValue, oldValue); + callback.__calling = false; + } + } +} + +function fire(eventName, data) { + var handlers = + eventName in this._handlers && this._handlers[eventName].slice(); + if (!handlers) return; + + for (var i = 0; i < handlers.length; i += 1) { + handlers[i].call(this, data); + } +} + +function get(key) { + return key ? this._state[key] : this._state; +} + +function init(component, options) { + component._observers = { pre: blankObject(), post: blankObject() }; + component._handlers = blankObject(); + component._bind = options._bind; + + component.options = options; + component.root = options.root || component; + component.store = component.root.store || options.store; +} + +function observe(key, callback, options) { + var group = options && options.defer + ? this._observers.post + : this._observers.pre; + + (group[key] || (group[key] = [])).push(callback); + + if (!options || options.init !== false) { + callback.__calling = true; + callback.call(this, this._state[key]); + callback.__calling = false; + } + + return { + cancel: function() { + var index = group[key].indexOf(callback); + if (~index) group[key].splice(index, 1); + } + }; +} + +function on(eventName, handler) { + if (eventName === 'teardown') return this.on('destroy', handler); + + var handlers = this._handlers[eventName] || (this._handlers[eventName] = []); + handlers.push(handler); + + return { + cancel: function() { + var index = handlers.indexOf(handler); + if (~index) handlers.splice(index, 1); + } + }; +} + +function set(newState) { + this._set(assign({}, newState)); + if (this.root._lock) return; + this.root._lock = true; + callAll(this.root._beforecreate); + callAll(this.root._oncreate); + callAll(this.root._aftercreate); + this.root._lock = false; +} + +function _set(newState) { + var oldState = this._state, + changed = {}, + dirty = false; + + for (var key in newState) { + if (this._differs(newState[key], oldState[key])) changed[key] = dirty = true; + } + if (!dirty) return; + + this._state = assign({}, oldState, newState); + this._recompute(changed, this._state); + if (this._bind) this._bind(changed, this._state); + + if (this._fragment) { + dispatchObservers(this, this._observers.pre, changed, this._state, oldState); + this._fragment.p(changed, this._state); + dispatchObservers(this, this._observers.post, changed, this._state, oldState); + } +} + +function callAll(fns) { + while (fns && fns.length) fns.shift()(); +} + +function _mount(target, anchor) { + this._fragment[this._fragment.i ? 'i' : 'm'](target, anchor || null); +} + +function _unmount() { + if (this._fragment) this._fragment.u(); +} + +var proto = { + destroy: destroy, + get: get, + fire: fire, + observe: observe, + on: on, + set: set, + teardown: destroy, + _recompute: noop, + _set: _set, + _mount: _mount, + _unmount: _unmount, + _differs: _differs +}; + +/* generated by Svelte vX.Y.Z */ + +function link(node) { + + function onClick(event) { + event.preventDefault(); + history.pushState(null, null, event.target.href); + } + + node.addEventListener('click', onClick); + + return { + destroy() { + node.removeEventListener('click', onClick); + } + } +} +function create_main_fragment(component, state) { + var a, link_action; + + return { + c: function create() { + a = createElement("a"); + a.textContent = "Test"; + this.h(); + }, + + h: function hydrate() { + a.href = "#"; + link_action = link(a) || {}; + }, + + m: function mount(target, anchor) { + insertNode(a, target, anchor); + }, + + p: noop, + + u: function unmount() { + detachNode(a); + }, + + d: function destroy$$1() { + if (typeof link_action.destroy === 'function') link_action.destroy(); + } + }; +} + +function SvelteComponent(options) { + init(this, options); + this._state = assign({}, options.data); + + this._fragment = create_main_fragment(this, this._state); + + if (options.target) { + this._fragment.c(); + this._mount(options.target, options.anchor); + } +} + +assign(SvelteComponent.prototype, proto); + +export default SvelteComponent; diff --git a/test/js/samples/action/expected.js b/test/js/samples/action/expected.js new file mode 100644 index 000000000000..0eceeae646f4 --- /dev/null +++ b/test/js/samples/action/expected.js @@ -0,0 +1,64 @@ +/* generated by Svelte vX.Y.Z */ +import { assign, createElement, detachNode, init, insertNode, noop, proto } from "svelte/shared.js"; + +function link(node) { + + function onClick(event) { + event.preventDefault(); + history.pushState(null, null, event.target.href); + } + + node.addEventListener('click', onClick); + + return { + destroy() { + node.removeEventListener('click', onClick); + } + } +}; + +function create_main_fragment(component, state) { + var a, link_action; + + return { + c: function create() { + a = createElement("a"); + a.textContent = "Test"; + this.h(); + }, + + h: function hydrate() { + a.href = "#"; + link_action = link(a) || {}; + }, + + m: function mount(target, anchor) { + insertNode(a, target, anchor); + }, + + p: noop, + + u: function unmount() { + detachNode(a); + }, + + d: function destroy() { + if (typeof link_action.destroy === 'function') link_action.destroy(); + } + }; +} + +function SvelteComponent(options) { + init(this, options); + this._state = assign({}, options.data); + + this._fragment = create_main_fragment(this, this._state); + + if (options.target) { + this._fragment.c(); + this._mount(options.target, options.anchor); + } +} + +assign(SvelteComponent.prototype, proto); +export default SvelteComponent; \ No newline at end of file diff --git a/test/js/samples/action/input.html b/test/js/samples/action/input.html new file mode 100644 index 000000000000..5b42061fe570 --- /dev/null +++ b/test/js/samples/action/input.html @@ -0,0 +1,23 @@ +Test + + diff --git a/test/parser/index.js b/test/parser/index.js index 93060ef1fff9..7467e31b0c99 100644 --- a/test/parser/index.js +++ b/test/parser/index.js @@ -71,8 +71,7 @@ describe('parse', () => { }); it('includes AST in svelte.compile output', () => { - const dir = fs.readdirSync('test/parser/samples')[0]; - const source = fs.readFileSync(`test/parser/samples/${dir}/input.html`, 'utf-8'); + const source = fs.readFileSync(`test/parser/samples/attribute-dynamic/input.html`, 'utf-8'); const { ast } = svelte.compile(source); const parsed = svelte.parse(source); diff --git a/test/parser/samples/action-with-call/input.html b/test/parser/samples/action-with-call/input.html new file mode 100644 index 000000000000..246bf02c59c3 --- /dev/null +++ b/test/parser/samples/action-with-call/input.html @@ -0,0 +1 @@ + diff --git a/test/parser/samples/action-with-call/output.json b/test/parser/samples/action-with-call/output.json new file mode 100644 index 000000000000..f5cc6824f3d9 --- /dev/null +++ b/test/parser/samples/action-with-call/output.json @@ -0,0 +1,47 @@ +{ + "hash": 1937205193, + "html": { + "start": 0, + "end": 38, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 38, + "type": "Element", + "name": "input", + "attributes": [ + { + "start": 7, + "end": 37, + "type": "Action", + "name": "tooltip", + "expression": { + "type": "CallExpression", + "start": 20, + "end": 36, + "callee": { + "type": "Identifier", + "start": 20, + "end": 21, + "name": "t" + }, + "arguments": [ + { + "type": "Literal", + "start": 22, + "end": 35, + "value": "tooltip msg", + "raw": "'tooltip msg'" + } + ] + } + } + ], + "children": [] + } + ] + }, + "css": null, + "js": null +} \ No newline at end of file diff --git a/test/parser/samples/action-with-identifier/input.html b/test/parser/samples/action-with-identifier/input.html new file mode 100644 index 000000000000..14a65e83ed72 --- /dev/null +++ b/test/parser/samples/action-with-identifier/input.html @@ -0,0 +1 @@ + diff --git a/test/parser/samples/action-with-identifier/output.json b/test/parser/samples/action-with-identifier/output.json new file mode 100644 index 000000000000..6c39ed94bc16 --- /dev/null +++ b/test/parser/samples/action-with-identifier/output.json @@ -0,0 +1,33 @@ +{ + "hash": 1937205193, + "html": { + "start": 0, + "end": 29, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 29, + "type": "Element", + "name": "input", + "attributes": [ + { + "start": 7, + "end": 28, + "type": "Action", + "name": "tooltip", + "expression": { + "type": "Identifier", + "start": 20, + "end": 27, + "name": "message" + } + } + ], + "children": [] + } + ] + }, + "css": null, + "js": null +} \ No newline at end of file diff --git a/test/parser/samples/action-with-literal/input.html b/test/parser/samples/action-with-literal/input.html new file mode 100644 index 000000000000..60e16eacfc7c --- /dev/null +++ b/test/parser/samples/action-with-literal/input.html @@ -0,0 +1 @@ + diff --git a/test/parser/samples/action-with-literal/output.json b/test/parser/samples/action-with-literal/output.json new file mode 100644 index 000000000000..0da031888714 --- /dev/null +++ b/test/parser/samples/action-with-literal/output.json @@ -0,0 +1,34 @@ +{ + "hash": 1937205193, + "html": { + "start": 0, + "end": 35, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 35, + "type": "Element", + "name": "input", + "attributes": [ + { + "start": 7, + "end": 34, + "type": "Action", + "name": "tooltip", + "expression": { + "type": "Literal", + "start": 20, + "end": 33, + "value": "tooltip msg", + "raw": "'tooltip msg'" + } + } + ], + "children": [] + } + ] + }, + "css": null, + "js": null +} \ No newline at end of file diff --git a/test/parser/samples/action/input.html b/test/parser/samples/action/input.html new file mode 100644 index 000000000000..64409c2a6507 --- /dev/null +++ b/test/parser/samples/action/input.html @@ -0,0 +1 @@ + diff --git a/test/parser/samples/action/output.json b/test/parser/samples/action/output.json new file mode 100644 index 000000000000..597ae297a533 --- /dev/null +++ b/test/parser/samples/action/output.json @@ -0,0 +1,28 @@ +{ + "hash": 1937205193, + "html": { + "start": 0, + "end": 21, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 21, + "type": "Element", + "name": "input", + "attributes": [ + { + "start": 7, + "end": 20, + "type": "Action", + "name": "autofocus", + "expression": null + } + ], + "children": [] + } + ] + }, + "css": null, + "js": null +} \ No newline at end of file diff --git a/test/runtime/samples/action-function/_config.js b/test/runtime/samples/action-function/_config.js new file mode 100644 index 000000000000..fcbfd2ac82ba --- /dev/null +++ b/test/runtime/samples/action-function/_config.js @@ -0,0 +1,22 @@ +export default { + html: ` + + `, + + test ( assert, component, target, window ) { + const button = target.querySelector( 'button' ); + const eventEnter = new window.MouseEvent( 'mouseenter' ); + const eventLeave = new window.MouseEvent( 'mouseleave' ); + + button.dispatchEvent( eventEnter ); + assert.htmlEqual( target.innerHTML, ` + +
Perform an Action
+ ` ); + + button.dispatchEvent( eventLeave ); + assert.htmlEqual( target.innerHTML, ` + + ` ); + } +}; diff --git a/test/runtime/samples/action-function/main.html b/test/runtime/samples/action-function/main.html new file mode 100644 index 000000000000..cde902caad69 --- /dev/null +++ b/test/runtime/samples/action-function/main.html @@ -0,0 +1,46 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/action-update/_config.js b/test/runtime/samples/action-update/_config.js new file mode 100644 index 000000000000..0d472950cbd8 --- /dev/null +++ b/test/runtime/samples/action-update/_config.js @@ -0,0 +1,29 @@ +export default { + html: ` + + `, + + test ( assert, component, target, window ) { + const button = target.querySelector( 'button' ); + const eventEnter = new window.MouseEvent( 'mouseenter' ); + const eventLeave = new window.MouseEvent( 'mouseleave' ); + const ctrlPress = new window.KeyboardEvent( 'keydown', { ctrlKey: true } ); + + button.dispatchEvent( eventEnter ); + assert.htmlEqual( target.innerHTML, ` + +
Perform an Action
+ ` ); + + window.dispatchEvent( ctrlPress ); + assert.htmlEqual( target.innerHTML, ` + +
Perform an augmented Action
+ ` ); + + button.dispatchEvent( eventLeave ); + assert.htmlEqual( target.innerHTML, ` + + ` ); + } +}; diff --git a/test/runtime/samples/action-update/main.html b/test/runtime/samples/action-update/main.html new file mode 100644 index 000000000000..03bd0d0ef955 --- /dev/null +++ b/test/runtime/samples/action-update/main.html @@ -0,0 +1,52 @@ + +<:Window on:keydown="checkForCtrl(event)" on:keyup="checkForCtrl(event)"/> + + \ No newline at end of file diff --git a/test/runtime/samples/action/_config.js b/test/runtime/samples/action/_config.js new file mode 100644 index 000000000000..fcbfd2ac82ba --- /dev/null +++ b/test/runtime/samples/action/_config.js @@ -0,0 +1,22 @@ +export default { + html: ` + + `, + + test ( assert, component, target, window ) { + const button = target.querySelector( 'button' ); + const eventEnter = new window.MouseEvent( 'mouseenter' ); + const eventLeave = new window.MouseEvent( 'mouseleave' ); + + button.dispatchEvent( eventEnter ); + assert.htmlEqual( target.innerHTML, ` + +
Perform an Action
+ ` ); + + button.dispatchEvent( eventLeave ); + assert.htmlEqual( target.innerHTML, ` + + ` ); + } +}; diff --git a/test/runtime/samples/action/main.html b/test/runtime/samples/action/main.html new file mode 100644 index 000000000000..505d0078b551 --- /dev/null +++ b/test/runtime/samples/action/main.html @@ -0,0 +1,34 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/trait-function/main.html b/test/runtime/samples/trait-function/main.html new file mode 100644 index 000000000000..cde902caad69 --- /dev/null +++ b/test/runtime/samples/trait-function/main.html @@ -0,0 +1,46 @@ + + + \ No newline at end of file diff --git a/test/validator/samples/action-invalid/errors.json b/test/validator/samples/action-invalid/errors.json new file mode 100644 index 000000000000..1b6c17d9fd73 --- /dev/null +++ b/test/validator/samples/action-invalid/errors.json @@ -0,0 +1,12 @@ +[{ + "message": "Missing action 'whatever'", + "pos": 5, + "loc": { + "line": 1, + "column": 5 + }, + "end": { + "line": 1, + "column": 17 + } +}] \ No newline at end of file diff --git a/test/validator/samples/action-invalid/input.html b/test/validator/samples/action-invalid/input.html new file mode 100644 index 000000000000..11cc450aaa62 --- /dev/null +++ b/test/validator/samples/action-invalid/input.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/test/validator/samples/action-on-component/errors.json b/test/validator/samples/action-on-component/errors.json new file mode 100644 index 000000000000..f146d072b09d --- /dev/null +++ b/test/validator/samples/action-on-component/errors.json @@ -0,0 +1,12 @@ +[{ + "message": "Actions can only be applied to DOM elements, not components", + "pos": 8, + "loc": { + "line": 1, + "column": 8 + }, + "end": { + "line": 1, + "column": 15 + } +}] \ No newline at end of file diff --git a/test/validator/samples/action-on-component/input.html b/test/validator/samples/action-on-component/input.html new file mode 100644 index 000000000000..a59dc6ef89ec --- /dev/null +++ b/test/validator/samples/action-on-component/input.html @@ -0,0 +1,15 @@ + + + \ No newline at end of file