Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better handling of textareas #599

Merged
merged 5 commits into from
May 28, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/generators/dom/visitors/Element/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ export default function visitElement ( generator: DomGenerator, block: Block, st
}

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
});

node.children = [];
}
}

// <select> value attributes are an annoying special case — it must be handled
// *after* its children have been updated
visitAttributesAndAddProps();
Expand Down
2 changes: 1 addition & 1 deletion src/generators/dom/visitors/Element/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const lookup = {
title: {},
type: { appliesTo: [ 'button', 'input', 'command', 'embed', 'object', 'script', 'source', 'style', 'menu' ] },
usemap: { propertyName: 'useMap', appliesTo: [ 'img', 'input', 'object' ] },
value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select' ] },
value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select', 'textarea' ] },
width: { appliesTo: [ 'canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video' ] },
wrap: { appliesTo: [ 'textarea' ] }
};
Expand Down
45 changes: 29 additions & 16 deletions src/generators/server-side-rendering/visitors/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ const meta = {
':Window': visitWindow
};

function stringifyAttributeValue ( block: Block, chunks: Node[] ) {
return chunks.map( ( chunk: Node ) => {
if ( chunk.type === 'Text' ) {
return chunk.data;
}

const { snippet } = block.contextualise( chunk.expression );
return '${' + snippet + '}';
}).join( '' )
}

export default function visitElement ( generator: SsrGenerator, block: Block, node: Node ) {
if ( node.name in meta ) {
return meta[ node.name ]( generator, block, node );
Expand All @@ -21,24 +32,22 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no
}

let openingTag = `<${node.name}`;
let textareaContents; // awkward special case

node.attributes.forEach( ( attribute: Node ) => {
if ( attribute.type !== 'Attribute' ) return;

let str = ` ${attribute.name}`;
if ( attribute.name === 'value' && node.name === 'textarea' ) {
textareaContents = stringifyAttributeValue( block, attribute.value );
} else {
let str = ` ${attribute.name}`;

if ( attribute.value !== true ) {
str += `="` + attribute.value.map( ( chunk: Node ) => {
if ( chunk.type === 'Text' ) {
return chunk.data;
}
if ( attribute.value !== true ) {
str += `="${stringifyAttributeValue( block, attribute.value )}"`;
}

const { snippet } = block.contextualise( chunk.expression );
return '${' + snippet + '}';
}).join( '' ) + `"`;
openingTag += str;
}

openingTag += str;
});

if ( generator.cssId && !generator.elementDepth ) {
Expand All @@ -49,13 +58,17 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no

generator.append( openingTag );

generator.elementDepth += 1;
if ( node.name === 'textarea' && textareaContents !== undefined ) {
generator.append( textareaContents );
} else {
generator.elementDepth += 1;

node.children.forEach( ( child: Node ) => {
visit( generator, block, child );
});
node.children.forEach( ( child: Node ) => {
visit( generator, block, child );
});

generator.elementDepth -= 1;
generator.elementDepth -= 1;
}

if ( !isVoidElementName( node.name ) ) {
generator.append( `</${node.name}>` );
Expand Down
91 changes: 52 additions & 39 deletions src/parse/state/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Parser } from '../index';
import { Node } from '../../interfaces';

const validTagName = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
const invalidUnquotedAttributeCharacters = /[\s"'=<>\/`]/;

const SELF = ':Self';

Expand Down Expand Up @@ -181,6 +180,11 @@ export default function tag ( parser: Parser ) {

if ( selfClosing ) {
element.end = parser.index;
} else if ( name === 'textarea' ) {
// special case
element.children = readSequence( parser, () => parser.template.slice( parser.index, parser.index + 11 ) === '</textarea>' );
parser.read( /<\/textarea>/ );
element.end = parser.index;
} else {
// don't push self-closing elements onto the stack
parser.stack.push( element );
Expand Down Expand Up @@ -280,28 +284,66 @@ function readAttribute ( parser: Parser, uniqueNames ) {
}

function readAttributeValue ( parser: Parser ) {
let quoteMark;
const quoteMark = (
parser.eat( `'` ) ? `'` :
parser.eat( `"` ) ? `"` :
null
);

const regex = (
quoteMark === `'` ? /'/ :
quoteMark === `"` ? /"/ :
/[\s"'=<>\/`]/
);

const value = readSequence( parser, () => regex.test( parser.template[ parser.index ] ) );

if ( quoteMark ) parser.index += 1;
return value;
}

if ( parser.eat( `'` ) ) quoteMark = `'`;
if ( parser.eat( `"` ) ) quoteMark = `"`;
function getShorthandValue ( start: number, name: string ) {
const end = start + name.length;

return [{
type: 'AttributeShorthand',
start,
end,
expression: {
type: 'Identifier',
start,
end,
name
}
}];
}

function readSequence ( parser: Parser, done: () => boolean ) {
let currentChunk: Node = {
start: parser.index,
end: null,
type: 'Text',
data: ''
};

const done = quoteMark ?
char => char === quoteMark :
char => invalidUnquotedAttributeCharacters.test( char );

const chunks = [];

while ( parser.index < parser.template.length ) {
const index = parser.index;

if ( parser.eat( '{{' ) ) {
if ( done() ) {
currentChunk.end = parser.index;

if ( currentChunk.data ) chunks.push( currentChunk );

chunks.forEach( chunk => {
if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data );
});

return chunks;
}

else if ( parser.eat( '{{' ) ) {
if ( currentChunk.data ) {
currentChunk.end = index;
chunks.push( currentChunk );
Expand All @@ -328,39 +370,10 @@ function readAttributeValue ( parser: Parser ) {
};
}

else if ( done( parser.template[ parser.index ] ) ) {
currentChunk.end = parser.index;
if ( quoteMark ) parser.index += 1;

if ( currentChunk.data ) chunks.push( currentChunk );

chunks.forEach( chunk => {
if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data );
});

return chunks;
}

else {
currentChunk.data += parser.template[ parser.index++ ];
}
}

parser.error( `Unexpected end of input` );
}

function getShorthandValue ( start: number, name: string ) {
const end = start + name.length;

return [{
type: 'AttributeShorthand',
start,
end,
expression: {
type: 'Identifier',
start,
end,
name
}
}];
}
}
8 changes: 8 additions & 0 deletions src/validate/html/validateElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export default function validateElement ( validator: Validator, node: Node ) {
validator.error( `Missing transition '${attribute.name}'`, attribute.start );
}
}

else if ( attribute.type === 'Attribute' ) {
if ( attribute.name === 'value' && node.name === 'textarea' ) {
if ( node.children.length ) {
validator.error( `A <textarea> can have either a value attribute or (equivalently) child content, but not both`, attribute.start );
}
}
}
});
}

Expand Down
3 changes: 3 additions & 0 deletions test/parser/samples/textarea-children/input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<textarea>
<p>not actually an element. {{foo}}</p>
</textarea>
44 changes: 44 additions & 0 deletions test/parser/samples/textarea-children/output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"hash": 3618147195,
"html": {
"start": 0,
"end": 63,
"type": "Fragment",
"children": [
{
"start": 0,
"end": 63,
"type": "Element",
"name": "textarea",
"attributes": [],
"children": [
{
"start": 10,
"end": 40,
"type": "Text",
"data": "\n\t<p>not actually an element. "
},
{
"start": 40,
"end": 47,
"type": "MustacheTag",
"expression": {
"type": "Identifier",
"start": 42,
"end": 45,
"name": "foo"
}
},
{
"start": 47,
"end": 52,
"type": "Text",
"data": "</p>\n"
}
]
}
]
},
"css": null,
"js": null
}
17 changes: 17 additions & 0 deletions test/runtime/samples/textarea-children/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default {
'skip-ssr': true, // SSR behaviour is awkwardly different

data: {
foo: 42
},

html: `<textarea></textarea>`,

test ( assert, component, target ) {
const textarea = target.querySelector( 'textarea' );
assert.strictEqual( textarea.value, `\n\t<p>not actually an element. 42</p>\n` );

component.set({ foo: 43 });
assert.strictEqual( textarea.value, `\n\t<p>not actually an element. 43</p>\n` );
}
};
3 changes: 3 additions & 0 deletions test/runtime/samples/textarea-children/main.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<textarea>
<p>not actually an element. {{foo}}</p>
</textarea>
17 changes: 17 additions & 0 deletions test/runtime/samples/textarea-value/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default {
'skip-ssr': true, // SSR behaviour is awkwardly different

data: {
foo: 42
},

html: `<textarea></textarea>`,

test ( assert, component, target ) {
const textarea = target.querySelector( 'textarea' );
assert.strictEqual( textarea.value, '42' );

component.set({ foo: 43 });
assert.strictEqual( textarea.value, '43' );
}
};
1 change: 1 addition & 0 deletions test/runtime/samples/textarea-value/main.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<textarea value='{{foo}}'/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<textarea>
<p>not actually an element. 42</p>
</textarea>
11 changes: 11 additions & 0 deletions test/server-side-rendering/samples/textarea-children/main.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<textarea>
<p>not actually an element. {{foo}}</p>
</textarea>

<script>
export default {
data () {
return { foo: 42 };
}
};
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<textarea>42</textarea>
9 changes: 9 additions & 0 deletions test/server-side-rendering/samples/textarea-value/main.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<textarea value='{{foo}}'/>

<script>
export default {
data () {
return { foo: 42 };
}
};
</script>
8 changes: 8 additions & 0 deletions test/validator/samples/textarea-value-children/errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[{
"message": "A <textarea> can have either a value attribute or (equivalently) child content, but not both",
"loc": {
"line": 1,
"column": 10
},
"pos": 10
}]
Loading