combine multiple bindings in single handler, implement bind:indeterminate — fixes #910

pull/937/head
Rich Harris 8 years ago committed by GitHub
parent 509fb1a6e3
commit 775866780e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,21 +7,10 @@ import getTailSnippet from '../../../utils/getTailSnippet';
import getObject from '../../../utils/getObject';
import getExpressionPrecedence from '../../../utils/getExpressionPrecedence';
import { stringify } from '../../../utils/stringify';
import stringifyProps from '../../../utils/stringifyProps';
import { Node } from '../../../interfaces';
import { State } from '../interfaces';
function stringifyProps(props: string[]) {
if (!props.length) return '{}';
const joined = props.join(', ');
if (joined.length > 40) {
// make larger data objects readable
return `{\n\t${props.join(',\n\t')}\n}`;
}
return `{ ${joined} }`;
}
interface Attribute {
name: string;
value: any;

@ -1,349 +0,0 @@
import deindent from '../../../../utils/deindent';
import flattenReference from '../../../../utils/flattenReference';
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
import { DomGenerator } from '../../index';
import Block from '../../Block';
import { Node } from '../../../../interfaces';
import { State } from '../../interfaces';
import getObject from '../../../../utils/getObject';
import getTailSnippet from '../../../../utils/getTailSnippet';
const readOnlyMediaAttributes = new Set([
'duration',
'buffered',
'seekable',
'played'
]);
export default function visitBinding(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
attribute: Node
) {
const { name } = getObject(attribute.value);
const { snippet, contexts, dependencies } = block.contextualise(
attribute.value
);
contexts.forEach(context => {
if (!~state.allUsedContexts.indexOf(context))
state.allUsedContexts.push(context);
});
const eventNames = getBindingEventName(node, attribute);
const handler = block.getUniqueName(
`${state.parentNode}_${eventNames.join('_')}_handler`
);
const isMultipleSelect =
node.name === 'select' &&
node.attributes.find(
(attr: Node) => attr.name.toLowerCase() === 'multiple'
); // TODO use getStaticAttributeValue
const type = getStaticAttributeValue(node, 'type');
const bindingGroup = attribute.name === 'group'
? getBindingGroup(generator, attribute.value)
: null;
const isMediaElement = node.name === 'audio' || node.name === 'video';
const isReadOnly = isMediaElement && readOnlyMediaAttributes.has(attribute.name)
const value = getBindingValue(
generator,
block,
state,
node,
attribute,
isMultipleSelect,
isMediaElement,
bindingGroup,
type
);
let setter = getSetter(generator, block, name, snippet, state.parentNode, attribute, dependencies, value);
let updateElement = `${state.parentNode}.${attribute.name} = ${snippet};`;
const needsLock = !isReadOnly && node.name !== 'input' || !/radio|checkbox|range|color/.test(type); // TODO others?
const lock = `#${state.parentNode}_updating`;
let updateConditions = needsLock ? [`!${lock}`] : [];
if (needsLock) block.addVariable(lock, 'false');
// <select> special case
if (node.name === 'select') {
if (!isMultipleSelect) {
setter = `var selectedOption = ${state.parentNode}.querySelector(':checked') || ${state.parentNode}.options[0];\n${setter}`;
}
const value = block.getUniqueName('value');
const option = block.getUniqueName('option');
const ifStatement = isMultipleSelect
? deindent`
${option}.selected = ~${value}.indexOf(${option}.__value);`
: deindent`
if (${option}.__value === ${value}) {
${option}.selected = true;
break;
}`;
const { name } = getObject(attribute.value);
const tailSnippet = getTailSnippet(attribute.value);
updateElement = deindent`
var ${value} = ${snippet};
for (var #i = 0; #i < ${state.parentNode}.options.length; #i += 1) {
var ${option} = ${state.parentNode}.options[#i];
${ifStatement}
}
`;
generator.hasComplexBindings = true;
block.builders.hydrate.addBlock(
`if (!('${name}' in state)) #component._root._beforecreate.push(${handler});`
);
} else if (attribute.name === 'group') {
// <input type='checkbox|radio' bind:group='selected'> special case
if (type === 'radio') {
setter = deindent`
if (!${state.parentNode}.checked) return;
${setter}
`;
}
const condition = type === 'checkbox'
? `~${snippet}.indexOf(${state.parentNode}.__value)`
: `${state.parentNode}.__value === ${snippet}`;
block.builders.hydrate.addLine(
`#component._bindingGroups[${bindingGroup}].push(${state.parentNode});`
);
block.builders.destroy.addBlock(
`#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${state.parentNode}), 1);`
);
updateElement = `${state.parentNode}.checked = ${condition};`;
} else if (isMediaElement) {
generator.hasComplexBindings = true;
block.builders.hydrate.addBlock(`#component._root._beforecreate.push(${handler});`);
if (attribute.name === 'currentTime') {
const frame = block.getUniqueName(`${state.parentNode}_animationframe`);
block.addVariable(frame);
setter = deindent`
cancelAnimationFrame(${frame});
if (!${state.parentNode}.paused) ${frame} = requestAnimationFrame(${handler});
${setter}
`;
updateConditions.push(`!isNaN(${snippet})`);
} else if (attribute.name === 'paused') {
// this is necessary to prevent the audio restarting by itself
const last = block.getUniqueName(`${state.parentNode}_paused_value`);
block.addVariable(last, 'true');
updateConditions = [`${last} !== (${last} = ${snippet})`];
updateElement = `${state.parentNode}[${last} ? "pause" : "play"]();`;
}
}
block.builders.init.addBlock(deindent`
function ${handler}() {
${needsLock && `${lock} = true;`}
${setter}
${needsLock && `${lock} = false;`}
}
`);
if (node.name === 'input' && type === 'range') {
// need to bind to `input` and `change`, for the benefit of IE
block.builders.hydrate.addBlock(deindent`
@addListener(${state.parentNode}, "input", ${handler});
@addListener(${state.parentNode}, "change", ${handler});
`);
block.builders.destroy.addBlock(deindent`
@removeListener(${state.parentNode}, "input", ${handler});
@removeListener(${state.parentNode}, "change", ${handler});
`);
} else {
eventNames.forEach(eventName => {
block.builders.hydrate.addLine(
`@addListener(${state.parentNode}, "${eventName}", ${handler});`
);
block.builders.destroy.addLine(
`@removeListener(${state.parentNode}, "${eventName}", ${handler});`
);
});
}
if (!isMediaElement) {
node.initialUpdate = updateElement;
node.initialUpdateNeedsStateObject = !block.contexts.has(name);
}
if (!isReadOnly) { // audio/video duration is read-only, it never updates
if (updateConditions.length) {
block.builders.update.addBlock(deindent`
if (${updateConditions.join(' && ')}) {
${updateElement}
}
`);
} else {
block.builders.update.addBlock(deindent`
${updateElement}
`);
}
}
if (attribute.name === 'paused') {
block.builders.create.addLine(
`@addListener(${state.parentNode}, "play", ${handler});`
);
block.builders.destroy.addLine(
`@removeListener(${state.parentNode}, "play", ${handler});`
);
}
}
function getBindingEventName(node: Node, attribute: Node) {
if (node.name === 'input') {
const typeAttribute = node.attributes.find(
(attr: Node) => attr.type === 'Attribute' && attr.name === 'type'
);
const type = typeAttribute ? typeAttribute.value[0].data : 'text'; // TODO in validation, should throw if type attribute is not static
return [type === 'checkbox' || type === 'radio' ? 'change' : 'input'];
}
if (node.name === 'textarea') return ['input'];
if (attribute.name === 'currentTime') return ['timeupdate'];
if (attribute.name === 'duration') return ['durationchange'];
if (attribute.name === 'paused') return ['pause'];
if (attribute.name === 'buffered') return ['progress', 'loadedmetadata'];
if (attribute.name === 'seekable') return ['loadedmetadata'];
if (attribute.name === 'played') return ['timeupdate'];
return ['change'];
}
function getBindingValue(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
attribute: Node,
isMultipleSelect: boolean,
isMediaElement: boolean,
bindingGroup: number,
type: string
) {
// <select multiple bind:value='selected>
if (isMultipleSelect) {
return `[].map.call(${state.parentNode}.querySelectorAll(':checked'), function(option) { return option.__value; })`;
}
// <select bind:value='selected>
if (node.name === 'select') {
return 'selectedOption && selectedOption.__value';
}
// <input type='checkbox' bind:group='foo'>
if (attribute.name === 'group') {
if (type === 'checkbox') {
return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`;
}
return `${state.parentNode}.__value`;
}
// <input type='range|number' bind:value>
if (type === 'range' || type === 'number') {
return `@toNumber(${state.parentNode}.${attribute.name})`;
}
if (isMediaElement && (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played')) {
return `@timeRangesToArray(${state.parentNode}.${attribute.name})`
}
// everything else
return `${state.parentNode}.${attribute.name}`;
}
function getBindingGroup(generator: DomGenerator, value: Node) {
const { parts } = flattenReference(value); // TODO handle cases involving computed member expressions
const keypath = parts.join('.');
// TODO handle contextual bindings — `keypath` should include unique ID of
// each block that provides context
let index = generator.bindingGroups.indexOf(keypath);
if (index === -1) {
index = generator.bindingGroups.length;
generator.bindingGroups.push(keypath);
}
return index;
}
function getSetter(
generator: DomGenerator,
block: Block,
name: string,
snippet: string,
_this: string,
attribute: Node,
dependencies: string[],
value: string,
) {
const tail = attribute.value.type === 'MemberExpression'
? getTailSnippet(attribute.value)
: '';
if (block.contexts.has(name)) {
const prop = dependencies[0];
const computed = isComputed(attribute.value);
return deindent`
var list = ${_this}._svelte.${block.listNames.get(name)};
var index = ${_this}._svelte.${block.indexNames.get(name)};
${computed && `var state = #component.get();`}
list[index]${tail} = ${value};
${computed
? `#component.set({${dependencies.map((prop: string) => `${prop}: state.${prop}`).join(', ')} });`
: `#component.set({${dependencies.map((prop: string) => `${prop}: #component.get('${prop}')`).join(', ')} });`}
`;
}
if (attribute.value.type === 'MemberExpression') {
// This is a little confusing, and should probably be tidied up
// at some point. It addresses a tricky bug (#893), wherein
// Svelte tries to `set()` a computed property, which throws an
// error in dev mode. a) it's possible that we should be
// replacing computations with *their* dependencies, and b)
// we should probably populate `generator.readonly` sooner so
// that we don't have to do the `.some()` here
dependencies = dependencies.filter(prop => !generator.computations.some(computation => computation.key === prop));
return deindent`
var state = #component.get();
${snippet} = ${value};
#component.set({ ${dependencies.map((prop: string) => `${prop}: state.${prop}`).join(', ')} });
`;
}
return `#component.set({ ${name}: ${value} });`;
}
function isComputed(node: Node) {
while (node.type === 'MemberExpression') {
if (node.computed) return true;
node = node.object;
}
return false;
}

@ -4,9 +4,9 @@ import visitSlot from '../Slot';
import visitComponent from '../Component';
import visitWindow from './meta/Window';
import visitAttribute from './Attribute';
import visitEventHandler from './EventHandler';
import visitBinding from './Binding';
import visitRef from './Ref';
import addBindings from './addBindings';
import flattenReference from '../../../../utils/flattenReference';
import validCalleeObjects from '../../../../utils/validCalleeObjects';
import * as namespaces from '../../../../utils/namespaces';
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
import isVoidElementName from '../../../../utils/isVoidElementName';
@ -18,24 +18,10 @@ import { State } from '../../interfaces';
import reservedNames from '../../../../utils/reservedNames';
import { stringify } from '../../../../utils/stringify';
const meta = {
const meta: Record<string, any> = {
':Window': visitWindow,
};
const order = {
Attribute: 1,
Binding: 2,
EventHandler: 3,
Ref: 4,
};
const visitors = {
Attribute: visitAttribute,
EventHandler: visitEventHandler,
Binding: visitBinding,
Ref: visitRef,
};
export default function visitElement(
generator: DomGenerator,
block: Block,
@ -69,8 +55,6 @@ export default function visitElement(
`${componentStack[componentStack.length - 1].var}._slotted.${slot.value[0].data}` : // TODO this looks bonkers
state.parentNode;
const isToplevel = !parentNode;
block.addVariable(name);
block.builders.create.addLine(
`${name} = ${getRenderStatement(
@ -93,6 +77,10 @@ export default function visitElement(
);
} else {
block.builders.mount.addLine(`@insertNode(${name}, #target, anchor);`);
// TODO we eventually need to consider what happens to elements
// that belong to the same outgroup as an outroing element...
block.builders.unmount.addLine(`@detachNode(${name});`);
}
// add CSS encapsulation attribute
@ -109,105 +97,191 @@ export default function visitElement(
}
}
function visitAttributesAndAddProps() {
let intro;
let outro;
node.attributes
.sort((a: Node, b: Node) => order[a.type] - order[b.type])
.forEach((attribute: Node) => {
if (attribute.type === 'Transition') {
if (attribute.intro) intro = attribute;
if (attribute.outro) outro = attribute;
return;
}
visitors[attribute.type](generator, block, childState, node, attribute);
if (node.name === 'textarea') {
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
if (node.children.length > 0) {
node.attributes.push({
type: 'Attribute',
name: 'value',
value: node.children,
});
if (intro || outro)
addTransitions(generator, block, childState, node, intro, outro);
node.children = [];
}
}
// insert static children with textContent or innerHTML
if (!childState.namespace && node.canUseInnerHTML && node.children.length > 0) {
if (node.children.length === 1 && node.children[0].type === 'Text') {
block.builders.create.addLine(
`${name}.textContent = ${stringify(node.children[0].data)};`
);
} else {
block.builders.create.addLine(
`${name}.innerHTML = ${stringify(node.children.map(toHTML).join(''))};`
);
}
} else {
node.children.forEach((child: Node) => {
visit(generator, block, childState, child, elementStack.concat(node), componentStack);
});
}
addBindings(generator, block, childState, node);
if (childState.allUsedContexts.length || childState.usesComponent) {
const initialProps: string[] = [];
const updates: string[] = [];
node.attributes.filter((a: Node) => a.type === 'Attribute').forEach((attribute: Node) => {
visitAttribute(generator, block, childState, node, attribute);
});
if (childState.usesComponent) {
initialProps.push(`component: #component`);
}
// event handlers
node.attributes.filter((a: Node) => a.type === 'EventHandler').forEach((attribute: Node) => {
const isCustomEvent = generator.events.has(attribute.name);
const shouldHoist = !isCustomEvent && state.inEachBlock;
childState.allUsedContexts.forEach((contextName: string) => {
if (contextName === 'state') return;
const context = shouldHoist ? null : name;
const usedContexts: string[] = [];
const listName = block.listNames.get(contextName);
const indexName = block.indexNames.get(contextName);
if (attribute.expression) {
generator.addSourcemapLocations(attribute.expression);
initialProps.push(
`${listName}: ${listName},\n${indexName}: ${indexName}`
);
updates.push(
`${name}._svelte.${listName} = ${listName};\n${name}._svelte.${indexName} = ${indexName};`
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) childState.usesComponent = 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);
if (!~childState.allUsedContexts.indexOf(context))
childState.allUsedContexts.push(context);
});
});
}
if (initialProps.length) {
block.builders.hydrate.addBlock(deindent`
${name}._svelte = {
${initialProps.join(',\n')}
};
`);
const _this = context || 'this';
const declarations = usedContexts.map(name => {
if (name === 'state') {
if (shouldHoist) childState.usesComponent = true;
return `var state = ${block.alias('component')}.get();`;
}
if (updates.length) {
block.builders.update.addBlock(updates.join('\n'));
}
}
}
const listName = block.listNames.get(name);
const indexName = block.indexNames.get(name);
const contextName = block.contexts.get(name);
if (isToplevel) {
// TODO we eventually need to consider what happens to elements
// that belong to the same outgroup as an outroing element...
block.builders.unmount.addLine(`@detachNode(${name});`);
}
return `var ${listName} = ${_this}._svelte.${listName}, ${indexName} = ${_this}._svelte.${indexName}, ${contextName} = ${listName}[${indexName}];`;
});
if (node.name !== 'select') {
if (node.name === 'textarea') {
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
if (node.children.length > 0) {
node.attributes.push({
type: 'Attribute',
name: 'value',
value: node.children,
// 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`
${childState.usesComponent &&
`var ${block.alias('component')} = ${_this}._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}
});
`);
node.children = [];
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});`
);
}
});
// <select> value attributes are an annoying special case — it must be handled
// *after* its children have been updated
visitAttributesAndAddProps();
}
// refs
node.attributes.filter((a: Node) => a.type === 'Ref').forEach((attribute: Node) => {
const ref = `#component.refs.${attribute.name}`;
if (!childState.namespace && node.canUseInnerHTML && node.children.length > 0) {
if (node.children.length === 1 && node.children[0].type === 'Text') {
block.builders.create.addLine(
`${name}.textContent = ${stringify(node.children[0].data)};`
block.builders.mount.addLine(
`${ref} = ${name};`
);
block.builders.destroy.addLine(
`if (${ref} === ${name}) ${ref} = null;`
);
generator.usesRefs = true; // so component.refs object is created
});
addTransitions(generator, block, childState, node);
if (childState.allUsedContexts.length || childState.usesComponent) {
const initialProps: string[] = [];
const updates: string[] = [];
if (childState.usesComponent) {
initialProps.push(`component: #component`);
}
childState.allUsedContexts.forEach((contextName: string) => {
if (contextName === 'state') return;
const listName = block.listNames.get(contextName);
const indexName = block.indexNames.get(contextName);
initialProps.push(
`${listName}: ${listName},\n${indexName}: ${indexName}`
);
} else {
block.builders.create.addLine(
`${name}.innerHTML = ${stringify(node.children.map(toHTML).join(''))};`
updates.push(
`${name}._svelte.${listName} = ${listName};\n${name}._svelte.${indexName} = ${indexName};`
);
}
} else {
node.children.forEach((child: Node) => {
visit(generator, block, childState, child, elementStack.concat(node), componentStack);
});
}
if (node.name === 'select') {
visitAttributesAndAddProps();
if (initialProps.length) {
block.builders.hydrate.addBlock(deindent`
${name}._svelte = {
${initialProps.join(',\n')}
};
`);
}
if (updates.length) {
block.builders.update.addBlock(updates.join('\n'));
}
}
if (node.initialUpdate) {

@ -1,111 +0,0 @@
import deindent from '../../../../utils/deindent';
import flattenReference from '../../../../utils/flattenReference';
import validCalleeObjects from '../../../../utils/validCalleeObjects';
import { DomGenerator } from '../../index';
import Block from '../../Block';
import { Node } from '../../../../interfaces';
import { State } from '../../interfaces';
export default function visitEventHandler(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
attribute: Node
) {
const name = attribute.name;
const isCustomEvent = generator.events.has(name);
const shouldHoist = !isCustomEvent && state.inEachBlock;
const context = shouldHoist ? null : state.parentNode;
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) state.usesComponent = 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);
if (!~state.allUsedContexts.indexOf(context))
state.allUsedContexts.push(context);
});
});
}
const _this = context || 'this';
const declarations = usedContexts.map(name => {
if (name === 'state') {
if (shouldHoist) state.usesComponent = true;
return `var state = ${block.alias('component')}.get();`;
}
const listName = block.listNames.get(name);
const indexName = block.indexNames.get(name);
const contextName = block.contexts.get(name);
return `var ${listName} = ${_this}._svelte.${listName}, ${indexName} = ${_this}._svelte.${indexName}, ${contextName} = ${listName}[${indexName}];`;
});
// get a name for the event handler that is globally unique
// if hoisted, locally unique otherwise
const handlerName = (shouldHoist ? generator : block).getUniqueName(
`${name.replace(/[^a-zA-Z0-9_$]/g, '_')}_handler`
);
// create the handler body
const handlerBody = deindent`
${state.usesComponent &&
`var ${block.alias('component')} = ${_this}._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-${name}.call(#component, ${state.parentNode}, 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(${state.parentNode}, "${name}", ${handlerName});`
);
block.builders.destroy.addLine(
`@removeListener(${state.parentNode}, "${name}", ${handlerName});`
);
}
}

@ -1,25 +0,0 @@
import deindent from '../../../../utils/deindent';
import { DomGenerator } from '../../index';
import Block from '../../Block';
import { Node } from '../../../../interfaces';
import { State } from '../../interfaces';
export default function visitRef(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
attribute: Node
) {
const name = attribute.name;
block.builders.mount.addLine(
`#component.refs.${name} = ${state.parentNode};`
);
block.builders.destroy.addLine(deindent`
if (#component.refs.${name} === ${state.parentNode}) #component.refs.${name} = null;
`);
generator.usesRefs = true; // so this component.refs object is created
}

@ -0,0 +1,382 @@
import deindent from '../../../../utils/deindent';
import flattenReference from '../../../../utils/flattenReference';
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
import { DomGenerator } from '../../index';
import Block from '../../Block';
import { Node } from '../../../../interfaces';
import { State } from '../../interfaces';
import getObject from '../../../../utils/getObject';
import getTailSnippet from '../../../../utils/getTailSnippet';
import stringifyProps from '../../../../utils/stringifyProps';
import { generateRule } from '../../../../shared/index';
import flatten from '../../../../utils/flattenReference';
interface Binding {
name: string;
}
const readOnlyMediaAttributes = new Set([
'duration',
'buffered',
'seekable',
'played'
]);
function isMediaNode(name: string) {
return name === 'audio' || name === 'video';
}
const events = [
{
eventNames: ['input'],
filter: (node: Node, binding: Binding) =>
node.name === 'textarea' ||
node.name === 'input' && !/radio|checkbox/.test(getStaticAttributeValue(node, 'type'))
},
{
eventNames: ['change'],
filter: (node: Node, binding: Binding) =>
node.name === 'select' ||
node.name === 'input' && /radio|checkbox|range/.test(getStaticAttributeValue(node, 'type'))
},
// media events
{
eventNames: ['timeupdate'],
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
(binding.name === 'currentTime' || binding.name === 'played')
},
{
eventNames: ['durationchange'],
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'duration'
},
{
eventNames: ['play', 'pause'],
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'paused'
},
{
eventNames: ['progress'],
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
binding.name === 'buffered'
},
{
eventNames: ['loadedmetadata'],
filter: (node: Node, binding: Binding) =>
isMediaNode(node.name) &&
(binding.name === 'buffered' || binding.name === 'seekable')
}
];
export default function addBindings(
generator: DomGenerator,
block: Block,
state: State,
node: Node
) {
const bindings: Node[] = node.attributes.filter((a: Node) => a.type === 'Binding');
if (bindings.length === 0) return;
if (node.name === 'select' || isMediaNode(node.name)) generator.hasComplexBindings = true;
const needsLock = node.name !== 'input' || !/radio|checkbox|range|color/.test(getStaticAttributeValue(node, 'type'));
const mungedBindings = bindings.map(binding => {
const isReadOnly = isMediaNode(node.name) && readOnlyMediaAttributes.has(binding.name);
let updateCondition: string;
const { name } = getObject(binding.value);
const { snippet, contexts, dependencies } = block.contextualise(
binding.value
);
contexts.forEach(context => {
if (!~state.allUsedContexts.indexOf(context))
state.allUsedContexts.push(context);
});
// view to model
const valueFromDom = getValueFromDom(generator, node, binding);
const handler = getEventHandler(generator, block, name, snippet, binding, dependencies, valueFromDom);
// model to view
let updateDom = getDomUpdater(node, binding, snippet);
let initialUpdate = updateDom;
// special cases
if (binding.name === 'group') {
const bindingGroup = getBindingGroup(generator, binding.value);
block.builders.hydrate.addLine(
`#component._bindingGroups[${bindingGroup}].push(${node.var});`
);
block.builders.destroy.addLine(
`#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${node.var}), 1);`
);
}
if (binding.name === 'currentTime') {
updateCondition = `!isNaN(${snippet})`;
initialUpdate = null;
}
if (binding.name === 'paused') {
// this is necessary to prevent audio restarting by itself
const last = block.getUniqueName(`${node.var}_is_paused`);
block.addVariable(last, 'true');
updateCondition = `${last} !== (${last} = ${snippet})`;
updateDom = `${node.var}[${last} ? "pause" : "play"]();`;
initialUpdate = null;
}
return {
name: binding.name,
object: name,
handler,
updateDom,
initialUpdate,
needsLock: !isReadOnly && needsLock,
updateCondition
};
});
const lock = mungedBindings.some(binding => binding.needsLock) ?
block.getUniqueName(`${node.var}_updating`) :
null;
if (lock) block.addVariable(lock, 'false');
const groups = events
.map(event => {
return {
events: event.eventNames,
bindings: mungedBindings.filter(binding => event.filter(node, binding))
};
})
.filter(group => group.bindings.length);
groups.forEach(group => {
const handler = block.getUniqueName(`${node.var}_${group.events.join('_')}_handler`);
const needsLock = group.bindings.some(binding => binding.needsLock);
group.bindings.forEach(binding => {
if (!binding.updateDom) return;
const updateConditions = needsLock ? [`!${lock}`] : [];
if (binding.updateCondition) updateConditions.push(binding.updateCondition);
block.builders.update.addLine(
updateConditions.length ? `if (${updateConditions.join(' && ')}) ${binding.updateDom}` : binding.updateDom
);
});
const usesContext = group.bindings.some(binding => binding.handler.usesContext);
const usesState = group.bindings.some(binding => binding.handler.usesState);
const mutations = group.bindings.map(binding => binding.handler.mutation).filter(Boolean).join('\n');
const props = new Set();
group.bindings.forEach(binding => {
binding.handler.props.forEach(prop => {
props.add(prop);
});
}); // TODO use stringifyProps here, once indenting is fixed
// media bindings — awkward special case. The native timeupdate events
// fire too infrequently, so we need to take matters into our
// own hands
let animation_frame;
if (group.events[0] === 'timeupdate') {
animation_frame = block.getUniqueName(`${node.var}_animationframe`);
block.addVariable(animation_frame);
}
block.builders.init.addBlock(deindent`
function ${handler}() {
${
animation_frame && deindent`
cancelAnimationFrame(${animation_frame});
if (!${node.var}.paused) ${animation_frame} = requestAnimationFrame(${handler});`
}
${usesContext && `var context = ${node.var}._svelte;`}
${usesState && `var state = #component.get();`}
${needsLock && `${lock} = true;`}
${mutations.length > 0 && mutations}
#component.set({ ${Array.from(props).join(', ')} });
${needsLock && `${lock} = false;`}
}
`);
group.events.forEach(name => {
block.builders.hydrate.addLine(
`@addListener(${node.var}, "${name}", ${handler});`
);
block.builders.destroy.addLine(
`@removeListener(${node.var}, "${name}", ${handler});`
);
});
const allInitialStateIsDefined = group.bindings
.map(binding => `'${binding.object}' in state`)
.join(' && ');
if (node.name === 'select' || group.bindings.find(binding => binding.name === 'indeterminate' || readOnlyMediaAttributes.has(binding.name))) {
generator.hasComplexBindings = true;
block.builders.hydrate.addLine(
`if (!(${allInitialStateIsDefined})) #component._root._beforecreate.push(${handler});`
);
}
});
node.initialUpdate = mungedBindings.map(binding => binding.initialUpdate).filter(Boolean).join('\n');
}
function getDomUpdater(
node: Node,
binding: Node,
snippet: string
) {
if (readOnlyMediaAttributes.has(binding.name)) {
return null;
}
if (node.name === 'select') {
return getStaticAttributeValue(node, 'multiple') === true ?
`@selectOptions(${node.var}, ${snippet})` :
`@selectOption(${node.var}, ${snippet})`;
}
if (binding.name === 'group') {
const type = getStaticAttributeValue(node, 'type');
const condition = type === 'checkbox'
? `~${snippet}.indexOf(${node.var}.__value)`
: `${node.var}.__value === ${snippet}`;
return `${node.var}.checked = ${condition};`
}
return `${node.var}.${binding.name} = ${snippet};`;
}
function getBindingGroup(generator: DomGenerator, value: Node) {
const { parts } = flattenReference(value); // TODO handle cases involving computed member expressions
const keypath = parts.join('.');
// TODO handle contextual bindings — `keypath` should include unique ID of
// each block that provides context
let index = generator.bindingGroups.indexOf(keypath);
if (index === -1) {
index = generator.bindingGroups.length;
generator.bindingGroups.push(keypath);
}
return index;
}
function getEventHandler(
generator: DomGenerator,
block: Block,
name: string,
snippet: string,
attribute: Node,
dependencies: string[],
value: string,
) {
if (block.contexts.has(name)) {
const tail = attribute.value.type === 'MemberExpression'
? getTailSnippet(attribute.value)
: '';
const list = `context.${block.listNames.get(name)}`;
const index = `context.${block.indexNames.get(name)}`;
return {
usesContext: true,
usesState: true,
mutation: `${list}[${index}]${tail} = ${value};`,
props: dependencies.map(prop => `${prop}: state.${prop}`)
};
}
if (attribute.value.type === 'MemberExpression') {
// This is a little confusing, and should probably be tidied up
// at some point. It addresses a tricky bug (#893), wherein
// Svelte tries to `set()` a computed property, which throws an
// error in dev mode. a) it's possible that we should be
// replacing computations with *their* dependencies, and b)
// we should probably populate `generator.readonly` sooner so
// that we don't have to do the `.some()` here
dependencies = dependencies.filter(prop => !generator.computations.some(computation => computation.key === prop));
return {
usesContext: false,
usesState: true,
mutation: `${snippet} = ${value}`,
props: dependencies.map((prop: string) => `${prop}: state.${prop}`)
};
}
return {
usesContext: false,
usesState: false,
mutation: null,
props: [`${name}: ${value}`]
};
}
function getValueFromDom(
generator: DomGenerator,
node: Node,
binding: Node
) {
// <select bind:value='selected>
if (node.name === 'select') {
return getStaticAttributeValue(node, 'multiple') === true ?
`@selectMultipleValue(${node.var})` :
`@selectValue(${node.var})`;
}
const type = getStaticAttributeValue(node, 'type');
// <input type='checkbox' bind:group='foo'>
if (binding.name === 'group') {
const bindingGroup = getBindingGroup(generator, binding.value);
if (type === 'checkbox') {
return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`;
}
return `${node.var}.__value`;
}
// <input type='range|number' bind:value>
if (type === 'range' || type === 'number') {
return `@toNumber(${node.var}.${binding.name})`;
}
if ((binding.name === 'buffered' || binding.name === 'seekable' || binding.name === 'played')) {
return `@timeRangesToArray(${node.var}.${binding.name})`
}
// everything else
return `${node.var}.${binding.name}`;
}
function isComputed(node: Node) {
while (node.type === 'MemberExpression') {
if (node.computed) return true;
node = node.object;
}
return false;
}

@ -8,10 +8,13 @@ export default function addTransitions(
generator: DomGenerator,
block: Block,
state: State,
node: Node,
intro,
outro
node: Node
) {
const intro = node.attributes.find((a: Node) => a.type === 'Transition' && a.intro);
const outro = node.attributes.find((a: Node) => a.type === 'Transition' && a.outro);
if (!intro && !outro) return;
if (intro === outro) {
const name = block.getUniqueName(`${node.var}_transition`);
const snippet = intro.expression

@ -112,6 +112,7 @@ const lookup = {
'http-equiv': { propertyName: 'httpEquiv', appliesTo: ['meta'] },
icon: { appliesTo: ['command'] },
id: {},
indeterminate: { appliesTo: ['input'] },
ismap: { propertyName: 'isMap', appliesTo: ['img'] },
itemprop: {},
keytype: { appliesTo: ['keygen'] },

@ -148,4 +148,33 @@ export function setInputType(input, type) {
export function setStyle(node, key, value) {
node.style.setProperty(key, value);
}
export function selectOption(select, value) {
for (var i = 0; i < select.options.length; i += 1) {
var option = select.options[i];
if (option.__value === value) {
option.selected = true;
return;
}
}
}
export function selectOptions(select, value) {
for (var i = 0; i < select.options.length; i += 1) {
var option = select.options[i];
option.selected = ~value.indexOf(option.__value);
}
}
export function selectValue(select) {
var selectedOption = select.querySelector(':checked') || select.options[0];
return selectedOption && selectedOption.__value;
}
export function selectMultipleValue(select) {
return [].map.call(select.querySelectorAll(':checked'), function(option) {
return option.__value;
});
}

@ -7,6 +7,7 @@ export default function getStaticAttributeValue(node: Node, name: string) {
if (!attribute) return null;
if (attribute.value === true) return true;
if (attribute.value.length === 0) return '';
if (attribute.value.length === 1 && attribute.value[0].type === 'Text') {

@ -0,0 +1,11 @@
export default function stringifyProps(props: string[]) {
if (!props.length) return '{}';
const joined = props.join(', ');
if (joined.length > 40) {
// make larger data objects readable
return `{\n\t${props.join(',\n\t')}\n}`;
}
return `{ ${joined} }`;
}

@ -83,17 +83,17 @@ export default function validateElement(
}
checkTypeAttribute(validator, node);
} else if (name === 'checked') {
} else if (name === 'checked' || name === 'indeterminate') {
if (node.name !== 'input') {
validator.error(
`'checked' is not a valid binding on <${node.name}> elements`,
`'${name}' is not a valid binding on <${node.name}> elements`,
attribute.start
);
}
if (checkTypeAttribute(validator, node) !== 'checkbox') {
validator.error(
`'checked' binding can only be used with <input type="checkbox">`,
`'${name}' binding can only be used with <input type="checkbox">`,
attribute.start
);
}

@ -294,8 +294,8 @@ function create_each_block(state, comments, comment, i, component) {
},
h: function hydrate() {
div.className = "comment";
span.className = "meta";
div.className = "comment";
},
m: function mount(target, anchor) {

@ -95,8 +95,8 @@ function create_each_block(state, comments, comment, i, component) {
},
h: function hydrate() {
div.className = "comment";
span.className = "meta";
div.className = "comment";
},
m: function mount(target, anchor) {

@ -202,8 +202,8 @@ function create_main_fragment(state, component) {
},
h: function hydrate() {
input.type = "checkbox";
addListener(input, "change", input_change_handler);
input.type = "checkbox";
},
m: function mount(target, anchor) {

@ -15,8 +15,8 @@ function create_main_fragment(state, component) {
},
h: function hydrate() {
input.type = "checkbox";
addListener(input, "change", input_change_handler);
input.type = "checkbox";
},
m: function mount(target, anchor) {

@ -197,78 +197,51 @@ var proto = {
/* generated by Svelte vX.Y.Z */
function create_main_fragment(state, component) {
var audio, audio_updating = false, audio_animationframe, audio_paused_value = true;
function audio_progress_loadedmetadata_handler() {
audio_updating = true;
component.set({ buffered: timeRangesToArray(audio.buffered) });
audio_updating = false;
}
function audio_loadedmetadata_handler() {
audio_updating = true;
component.set({ seekable: timeRangesToArray(audio.seekable) });
audio_updating = false;
}
var audio, audio_is_paused = true, audio_updating = false, audio_animationframe;
function audio_timeupdate_handler() {
audio_updating = true;
component.set({ played: timeRangesToArray(audio.played) });
audio_updating = false;
}
function audio_timeupdate_handler_1() {
audio_updating = true;
cancelAnimationFrame(audio_animationframe);
if (!audio.paused) audio_animationframe = requestAnimationFrame(audio_timeupdate_handler_1);
component.set({ currentTime: audio.currentTime });
if (!audio.paused) audio_animationframe = requestAnimationFrame(audio_timeupdate_handler);
audio_updating = true;
component.set({ played: timeRangesToArray(audio.played), currentTime: audio.currentTime });
audio_updating = false;
}
function audio_durationchange_handler() {
audio_updating = true;
component.set({ duration: audio.duration });
audio_updating = false;
}
function audio_pause_handler() {
function audio_play_pause_handler() {
audio_updating = true;
component.set({ paused: audio.paused });
audio_updating = false;
}
function audio_progress_handler() {
component.set({ buffered: timeRangesToArray(audio.buffered) });
}
function audio_loadedmetadata_handler() {
component.set({ buffered: timeRangesToArray(audio.buffered), seekable: timeRangesToArray(audio.seekable) });
}
return {
c: function create() {
audio = createElement("audio");
addListener(audio, "play", audio_pause_handler);
this.h();
},
h: function hydrate() {
component._root._beforecreate.push(audio_progress_loadedmetadata_handler);
addListener(audio, "progress", audio_progress_loadedmetadata_handler);
addListener(audio, "loadedmetadata", audio_progress_loadedmetadata_handler);
component._root._beforecreate.push(audio_loadedmetadata_handler);
addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
component._root._beforecreate.push(audio_timeupdate_handler);
addListener(audio, "timeupdate", audio_timeupdate_handler);
component._root._beforecreate.push(audio_timeupdate_handler_1);
addListener(audio, "timeupdate", audio_timeupdate_handler_1);
component._root._beforecreate.push(audio_durationchange_handler);
if (!('played' in state && 'currentTime' in state)) component._root._beforecreate.push(audio_timeupdate_handler);
addListener(audio, "durationchange", audio_durationchange_handler);
component._root._beforecreate.push(audio_pause_handler);
addListener(audio, "pause", audio_pause_handler);
if (!('duration' in state)) component._root._beforecreate.push(audio_durationchange_handler);
addListener(audio, "play", audio_play_pause_handler);
addListener(audio, "pause", audio_play_pause_handler);
addListener(audio, "progress", audio_progress_handler);
if (!('buffered' in state)) component._root._beforecreate.push(audio_progress_handler);
addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
if (!('buffered' in state && 'seekable' in state)) component._root._beforecreate.push(audio_loadedmetadata_handler);
},
m: function mount(target, anchor) {
@ -276,13 +249,8 @@ function create_main_fragment(state, component) {
},
p: function update(changed, state) {
if (!audio_updating && !isNaN(state.currentTime )) {
audio.currentTime = state.currentTime ;
}
if (audio_paused_value !== (audio_paused_value = state.paused)) {
audio[audio_paused_value ? "pause" : "play"]();
}
if (!audio_updating && !isNaN(state.currentTime )) audio.currentTime = state.currentTime ;
if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused)) audio[audio_is_paused ? "pause" : "play"]();
},
u: function unmount() {
@ -290,14 +258,12 @@ function create_main_fragment(state, component) {
},
d: function destroy$$1() {
removeListener(audio, "progress", audio_progress_loadedmetadata_handler);
removeListener(audio, "loadedmetadata", audio_progress_loadedmetadata_handler);
removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
removeListener(audio, "timeupdate", audio_timeupdate_handler);
removeListener(audio, "timeupdate", audio_timeupdate_handler_1);
removeListener(audio, "durationchange", audio_durationchange_handler);
removeListener(audio, "pause", audio_pause_handler);
removeListener(audio, "play", audio_pause_handler);
removeListener(audio, "play", audio_play_pause_handler);
removeListener(audio, "pause", audio_play_pause_handler);
removeListener(audio, "progress", audio_progress_handler);
removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
}
};
}

@ -2,78 +2,51 @@
import { addListener, assign, callAll, createElement, detachNode, init, insertNode, proto, removeListener, timeRangesToArray } from "svelte/shared.js";
function create_main_fragment(state, component) {
var audio, audio_updating = false, audio_animationframe, audio_paused_value = true;
function audio_progress_loadedmetadata_handler() {
audio_updating = true;
component.set({ buffered: timeRangesToArray(audio.buffered) });
audio_updating = false;
}
function audio_loadedmetadata_handler() {
audio_updating = true;
component.set({ seekable: timeRangesToArray(audio.seekable) });
audio_updating = false;
}
var audio, audio_is_paused = true, audio_updating = false, audio_animationframe;
function audio_timeupdate_handler() {
audio_updating = true;
component.set({ played: timeRangesToArray(audio.played) });
audio_updating = false;
}
function audio_timeupdate_handler_1() {
audio_updating = true;
cancelAnimationFrame(audio_animationframe);
if (!audio.paused) audio_animationframe = requestAnimationFrame(audio_timeupdate_handler_1);
component.set({ currentTime: audio.currentTime });
if (!audio.paused) audio_animationframe = requestAnimationFrame(audio_timeupdate_handler);
audio_updating = true;
component.set({ played: timeRangesToArray(audio.played), currentTime: audio.currentTime });
audio_updating = false;
}
function audio_durationchange_handler() {
audio_updating = true;
component.set({ duration: audio.duration });
audio_updating = false;
}
function audio_pause_handler() {
function audio_play_pause_handler() {
audio_updating = true;
component.set({ paused: audio.paused });
audio_updating = false;
}
function audio_progress_handler() {
component.set({ buffered: timeRangesToArray(audio.buffered) });
}
function audio_loadedmetadata_handler() {
component.set({ buffered: timeRangesToArray(audio.buffered), seekable: timeRangesToArray(audio.seekable) });
}
return {
c: function create() {
audio = createElement("audio");
addListener(audio, "play", audio_pause_handler);
this.h();
},
h: function hydrate() {
component._root._beforecreate.push(audio_progress_loadedmetadata_handler);
addListener(audio, "progress", audio_progress_loadedmetadata_handler);
addListener(audio, "loadedmetadata", audio_progress_loadedmetadata_handler);
component._root._beforecreate.push(audio_loadedmetadata_handler);
addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
component._root._beforecreate.push(audio_timeupdate_handler);
addListener(audio, "timeupdate", audio_timeupdate_handler);
component._root._beforecreate.push(audio_timeupdate_handler_1);
addListener(audio, "timeupdate", audio_timeupdate_handler_1);
component._root._beforecreate.push(audio_durationchange_handler);
if (!('played' in state && 'currentTime' in state)) component._root._beforecreate.push(audio_timeupdate_handler);
addListener(audio, "durationchange", audio_durationchange_handler);
component._root._beforecreate.push(audio_pause_handler);
addListener(audio, "pause", audio_pause_handler);
if (!('duration' in state)) component._root._beforecreate.push(audio_durationchange_handler);
addListener(audio, "play", audio_play_pause_handler);
addListener(audio, "pause", audio_play_pause_handler);
addListener(audio, "progress", audio_progress_handler);
if (!('buffered' in state)) component._root._beforecreate.push(audio_progress_handler);
addListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
if (!('buffered' in state && 'seekable' in state)) component._root._beforecreate.push(audio_loadedmetadata_handler);
},
m: function mount(target, anchor) {
@ -81,13 +54,8 @@ function create_main_fragment(state, component) {
},
p: function update(changed, state) {
if (!audio_updating && !isNaN(state.currentTime )) {
audio.currentTime = state.currentTime ;
}
if (audio_paused_value !== (audio_paused_value = state.paused)) {
audio[audio_paused_value ? "pause" : "play"]();
}
if (!audio_updating && !isNaN(state.currentTime )) audio.currentTime = state.currentTime ;
if (!audio_updating && audio_is_paused !== (audio_is_paused = state.paused)) audio[audio_is_paused ? "pause" : "play"]();
},
u: function unmount() {
@ -95,14 +63,12 @@ function create_main_fragment(state, component) {
},
d: function destroy() {
removeListener(audio, "progress", audio_progress_loadedmetadata_handler);
removeListener(audio, "loadedmetadata", audio_progress_loadedmetadata_handler);
removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
removeListener(audio, "timeupdate", audio_timeupdate_handler);
removeListener(audio, "timeupdate", audio_timeupdate_handler_1);
removeListener(audio, "durationchange", audio_durationchange_handler);
removeListener(audio, "pause", audio_pause_handler);
removeListener(audio, "play", audio_pause_handler);
removeListener(audio, "play", audio_play_pause_handler);
removeListener(audio, "pause", audio_play_pause_handler);
removeListener(audio, "progress", audio_progress_handler);
removeListener(audio, "loadedmetadata", audio_loadedmetadata_handler);
}
};
}

@ -0,0 +1,21 @@
export default {
// This is a bit of a funny one — there's no equivalent attribute,
// so it can't be server-rendered
'skip-ssr': true,
data: {
indeterminate: true
},
html: `
<input type='checkbox'>
`,
test(assert, component, target) {
const input = target.querySelector('input');
assert.ok(input.indeterminate);
component.set({ indeterminate: false });
assert.ok(!input.indeterminate);
}
};

@ -0,0 +1 @@
<input type='checkbox' indeterminate='{{indeterminate}}'>

@ -16,15 +16,15 @@ export default {
<label>
<input type="checkbox"> Alpha
</label>
<label>
<input type="checkbox"> Beta
</label>
<label>
<input type="checkbox"> Gamma
</label>
<p>Beta</p>`,
test ( assert, component, target, window ) {
@ -42,15 +42,15 @@ export default {
<label>
<input type="checkbox"> Alpha
</label>
<label>
<input type="checkbox"> Beta
</label>
<label>
<input type="checkbox"> Gamma
</label>
<p>Alpha, Beta</p>
` );
@ -63,15 +63,15 @@ export default {
<label>
<input type="checkbox"> Alpha
</label>
<label>
<input type="checkbox"> Beta
</label>
<label>
<input type="checkbox"> Gamma
</label>
<p>Beta, Gamma</p>
` );
}

@ -0,0 +1,42 @@
export default {
'skip-ssr': true,
data: {
indeterminate: true,
},
html: `
<input type="checkbox">
<p>checked? false</p>
<p>indeterminate? true</p>
`,
test(assert, component, target, window) {
const input = target.querySelector('input');
assert.equal(input.checked, false);
assert.equal(input.indeterminate, true);
const event = new window.Event('change');
input.checked = true;
input.indeterminate = false;
input.dispatchEvent(event);
assert.equal(component.get('indeterminate'), false);
assert.equal(component.get('checked'), true);
assert.htmlEqual(target.innerHTML, `
<input type="checkbox">
<p>checked? true</p>
<p>indeterminate? false</p>
`);
component.set({ indeterminate: true });
assert.equal(input.indeterminate, true);
assert.equal(input.checked, true);
assert.htmlEqual(target.innerHTML, `
<input type="checkbox">
<p>checked? true</p>
<p>indeterminate? true</p>
`);
},
};

@ -0,0 +1,3 @@
<input type='checkbox' bind:checked bind:indeterminate>
<p>checked? {{checked}}</p>
<p>indeterminate? {{indeterminate}}</p>
Loading…
Cancel
Save