?
+ return (
+
+ {
+ map( this.props.suggestions, ( suggestion, index ) => {
+ const match = this.computeSuggestionMatch( suggestion );
+ const classeName = classnames( 'components-form-token-field__suggestion', {
+ 'is-selected': index === this.props.selectedIndex,
+ } );
+
+ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
+ return (
+ -
+ { match
+ ? (
+
+ { match.suggestionBeforeMatch }
+
+ { match.suggestionMatch }
+
+ { match.suggestionAfterMatch }
+
+ )
+ : this.props.displayTransform( suggestion )
+ }
+
+ );
+ /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
+ } )
+ }
+
+ );
+ }
+}
+
+SuggestionsList.defaultProps = {
+ isExpanded: false,
+ match: '',
+ onHover: () => {},
+ onSelect: () => {},
+ suggestions: Object.freeze( [] ),
+};
+
+export default SuggestionsList;
diff --git a/components/form-token-field/test/index.js b/components/form-token-field/test/index.js
new file mode 100644
index 00000000000000..5a983bf60c1456
--- /dev/null
+++ b/components/form-token-field/test/index.js
@@ -0,0 +1,474 @@
+/**
+ * External dependencies
+ */
+import { expect } from 'chai';
+import { filter, map } from 'lodash';
+import { test } from 'sinon';
+import { mount } from 'enzyme';
+
+/**
+ * Internal dependencies
+ */
+import fixtures from './lib/fixtures';
+import TokenFieldWrapper from './lib/token-field-wrapper';
+
+/**
+ * Module variables
+ */
+const keyCodes = {
+ backspace: 8,
+ tab: 9,
+ enter: 13,
+ leftArrow: 37,
+ upArrow: 38,
+ rightArrow: 39,
+ downArrow: 40,
+ 'delete': 46,
+ comma: 188,
+};
+
+const charCodes = {
+ comma: 44,
+};
+
+describe( 'FormTokenField', function() {
+ let wrapper, tokenFieldNode, textInputNode;
+
+ function setText( text ) {
+ textInputNode.simulate( 'change', { target: { value: text } } );
+ }
+
+ function sendKeyDown( keyCode, shiftKey ) {
+ tokenFieldNode.simulate( 'keyDown', {
+ keyCode: keyCode,
+ shiftKey: ! ! shiftKey,
+ } );
+ }
+
+ function sendKeyPress( charCode ) {
+ tokenFieldNode.simulate( 'keyPress', {
+ charCode: charCode,
+ } );
+ }
+
+ function getNodeInnerHtml( node ) {
+ const div = document.createElement( 'div' );
+ div.innerHTML = node.html();
+ return div.firstChild.innerHTML;
+ }
+
+ function getTokensHTML() {
+ const textNodes = tokenFieldNode.find( '.components-form-token-field__token-text' );
+
+ return textNodes.map( getNodeInnerHtml );
+ }
+
+ function getSuggestionsText( selector ) {
+ const suggestionNodes = tokenFieldNode.find( selector || '.components-form-token-field__suggestion' );
+
+ return suggestionNodes.map( getSuggestionNodeText );
+ }
+
+ function getSuggestionNodeText( node ) {
+ if ( ! node.find( 'span' ).length ) {
+ return getNodeInnerHtml( node );
+ }
+
+ // This suggestion is part of a partial match; return up to three
+ // sections of the suggestion (before match, match, and after
+ // match)
+ const div = document.createElement( 'div' );
+ div.innerHTML = node.find( 'span' ).html();
+
+ return map(
+ filter(
+ div.firstChild.childNodes,
+ childNode => childNode.nodeType !== window.Node.COMMENT_NODE
+ ),
+ childNode => childNode.textContent
+ );
+ }
+
+ function getSelectedSuggestion() {
+ const selectedSuggestions = getSuggestionsText( '.components-form-token-field__suggestion.is-selected' );
+
+ return selectedSuggestions[ 0 ] || null;
+ }
+
+ beforeEach( function() {
+ wrapper = mount(
);
+ tokenFieldNode = wrapper.ref( 'tokenField' );
+ textInputNode = tokenFieldNode.find( '.components-form-token-field__input' );
+ textInputNode.simulate( 'focus' );
+ } );
+
+ describe( 'displaying tokens', function() {
+ it( 'should render default tokens', function() {
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should display tokens with escaped special characters properly', function() {
+ wrapper.setState( {
+ tokens: fixtures.specialTokens.textEscaped,
+ } );
+ expect( getTokensHTML() ).to.deep.equal( fixtures.specialTokens.htmlEscaped );
+ } );
+
+ it( 'should display tokens with special characters properly', function() {
+ // This test is not as realistic as the previous one: if a WP site
+ // contains tag names with special characters, the API will always
+ // return the tag names already escaped. However, this is still
+ // worth testing, so we can be sure that token values with
+ // dangerous characters in them don't have these characters carried
+ // through unescaped to the HTML.
+ wrapper.setState( {
+ tokens: fixtures.specialTokens.textUnescaped,
+ } );
+ expect( getTokensHTML() ).to.deep.equal( fixtures.specialTokens.htmlUnescaped );
+ } );
+ } );
+
+ describe( 'suggestions', function() {
+ it( 'should render default suggestions', function() {
+ // limited by maxSuggestions (default 100 so doesn't matter here)
+ expect( getSuggestionsText() ).to.deep.equal( wrapper.state( 'tokenSuggestions' ) );
+ } );
+
+ it( 'should remove already added tags from suggestions', function() {
+ wrapper.setState( {
+ tokens: Object.freeze( [ 'of', 'and' ] ),
+ } );
+ expect( getSuggestionsText() ).to.not.include.members( getTokensHTML() );
+ } );
+
+ it( 'should suggest partial matches', function() {
+ setText( 't' );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.matchingSuggestions.t );
+ } );
+
+ it( 'suggestions that begin with match are boosted', function() {
+ setText( 's' );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.matchingSuggestions.s );
+ } );
+
+ it( 'should display suggestions with escaped special characters properly', function() {
+ wrapper.setState( {
+ tokenSuggestions: fixtures.specialSuggestions.textEscaped,
+ } );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.specialSuggestions.htmlEscaped );
+ } );
+
+ it( 'should display suggestions with special characters properly', function() {
+ wrapper.setState( {
+ tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
+ } );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.specialSuggestions.htmlUnescaped );
+ } );
+
+ it( 'should match against the unescaped values of suggestions with special characters', function() {
+ setText( '&' );
+ wrapper.setState( {
+ tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
+ } );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.specialSuggestions.matchAmpersandUnescaped );
+ } );
+
+ it( 'should match against the unescaped values of suggestions with special characters (including spaces)', function() {
+ setText( 's &' );
+ wrapper.setState( {
+ tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
+ } );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.specialSuggestions.matchAmpersandSequence );
+ } );
+
+ it( 'should not match against the escaped values of suggestions with special characters', function() {
+ setText( 'amp' );
+ wrapper.setState( {
+ tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
+ } );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.specialSuggestions.matchAmpersandEscaped );
+ } );
+
+ it( 'should match suggestions even with trailing spaces', function() {
+ setText( ' at ' );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.matchingSuggestions.at );
+ } );
+
+ it( 'should manage the selected suggestion based on both keyboard and mouse events', test( function() {
+ // We need a high timeout here to accomodate Travis CI
+ this.timeout( 10000 );
+
+ setText( 't' );
+ expect( getSuggestionsText() ).to.deep.equal( fixtures.matchingSuggestions.t );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'he' ] );
+ sendKeyDown( keyCodes.downArrow ); // 'to'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'o' ] );
+
+ const hoverSuggestion = tokenFieldNode.find( '.components-form-token-field__suggestion' ).at( 5 ); // 'it'
+ expect( getSuggestionNodeText( hoverSuggestion ) ).to.deep.equal( [ 'i', 't' ] );
+
+ // before sending a hover event, we need to wait for
+ // SuggestionList#_scrollingIntoView to become false
+ this.clock.tick( 100 );
+
+ hoverSuggestion.simulate( 'mouseEnter' );
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 'i', 't' ] );
+ sendKeyDown( keyCodes.upArrow );
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 'wi', 't', 'h' ] );
+ sendKeyDown( keyCodes.upArrow );
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'his' ] );
+ hoverSuggestion.simulate( 'click' );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ expect( getTokensHTML() ).to.deep.equal( [ 'foo', 'bar', 'it' ] );
+ } ) );
+ } );
+
+ describe( 'adding tokens', function() {
+ it( 'should add a token when Tab pressed', function() {
+ setText( 'baz' );
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( '' );
+ } );
+
+ it( 'should not allow adding blank tokens with Tab', function() {
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should not allow adding whitespace tokens with Tab', function() {
+ setText( ' ' );
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should add a token when Enter pressed', function() {
+ setText( 'baz' );
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( '' );
+ } );
+
+ it( 'should not allow adding blank tokens with Enter', function() {
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should not allow adding whitespace tokens with Enter', function() {
+ setText( ' ' );
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should not allow adding whitespace tokens with comma', function() {
+ setText( ' ' );
+ sendKeyPress( charCodes.comma );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ } );
+
+ it( 'should add a token when comma pressed', function() {
+ setText( 'baz' );
+ sendKeyPress( charCodes.comma );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ } );
+
+ it( 'should not add a token when < pressed', function() {
+ setText( 'baz' );
+ sendKeyDown( keyCodes.comma, true );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+ // The text input does not register the < keypress when it is sent this way.
+ expect( textInputNode.prop( 'value' ) ).to.equal( 'baz' );
+ } );
+
+ it( 'should trim token values when adding', function() {
+ setText( ' baz ' );
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ } );
+
+ function testOnBlur( initialText, selectSuggestion, expectedSuggestion, expectedTokens ) {
+ setText( initialText );
+ if ( selectSuggestion ) {
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ sendKeyDown( keyCodes.downArrow ); // 'to'
+ }
+ expect( getSelectedSuggestion() ).to.deep.equal( expectedSuggestion );
+
+ function testSavedState( isActive ) {
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( expectedTokens );
+ expect( textInputNode.prop( 'value' ) ).to.equal( '' );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ expect( tokenFieldNode.find( 'div' ).first().hasClass( 'is-active' ) ).to.equal( isActive );
+ }
+
+ document.activeElement.blur();
+ textInputNode.simulate( 'blur' );
+ testSavedState( false );
+ textInputNode.simulate( 'focus' );
+ testSavedState( true );
+ }
+
+ it( 'should add the current text when the input field loses focus', test( function() {
+ testOnBlur(
+ 't', // initialText
+ false, // selectSuggestion
+ null, // expectedSuggestion
+ [ 'foo', 'bar', 't' ] // expectedTokens
+ );
+ } ) );
+
+ it( 'should add the suggested token when the (non-blank) input field loses focus', test( function() {
+ testOnBlur(
+ 't', // initialText
+ true, // selectSuggestion
+ [ 't', 'o' ], // expectedSuggestion
+ [ 'foo', 'bar', 'to' ] // expectedTokens
+ );
+ } ) );
+
+ it( 'should not add the suggested token when the (blank) input field loses focus', test( function() {
+ testOnBlur(
+ '', // initialText
+ true, // selectSuggestion
+ 'of', // expectedSuggestion
+ [ 'foo', 'bar' ], // expectedTokens
+ this.clock
+ );
+ } ) );
+
+ it( 'should not lose focus when a suggestion is clicked', test( function() {
+ // prevents regression of https://github.com/Automattic/wp-calypso/issues/1884
+
+ const firstSuggestion = tokenFieldNode.find( '.components-form-token-field__suggestion' ).at( 0 );
+ firstSuggestion.simulate( 'click' );
+
+ // wait for setState call
+ this.clock.tick( 10 );
+
+ expect( tokenFieldNode.find( 'div' ).first().hasClass( 'is-active' ) ).to.equal( true );
+ } ) );
+
+ it( 'should add tokens in the middle of the current tokens', function() {
+ sendKeyDown( keyCodes.leftArrow );
+ setText( 'baz' );
+ sendKeyDown( keyCodes.tab );
+ setText( 'quux' );
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'baz', 'quux', 'bar' ] );
+ } );
+
+ it( 'should add tokens from the selected matching suggestion using Tab', function() {
+ setText( 't' );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'he' ] );
+ sendKeyDown( keyCodes.downArrow ); // 'to'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'o' ] );
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'to' ] );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ } );
+
+ it( 'should add tokens from the selected matching suggestion using Enter', function() {
+ setText( 't' );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'he' ] );
+ sendKeyDown( keyCodes.downArrow ); // 'to'
+ expect( getSelectedSuggestion() ).to.deep.equal( [ 't', 'o' ] );
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'to' ] );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ } );
+
+ it( 'should add tokens from the selected suggestion using Tab', function() {
+ expect( getSelectedSuggestion() ).to.equal( null );
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ expect( getSelectedSuggestion() ).to.equal( 'the' );
+ sendKeyDown( keyCodes.downArrow ); // 'of'
+ expect( getSelectedSuggestion() ).to.equal( 'of' );
+ sendKeyDown( keyCodes.tab );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'of' ] );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ } );
+
+ it( 'should add tokens from the selected suggestion using Enter', function() {
+ expect( getSelectedSuggestion() ).to.equal( null );
+ sendKeyDown( keyCodes.downArrow ); // 'the'
+ expect( getSelectedSuggestion() ).to.equal( 'the' );
+ sendKeyDown( keyCodes.downArrow ); // 'of'
+ expect( getSelectedSuggestion() ).to.equal( 'of' );
+ sendKeyDown( keyCodes.enter );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'of' ] );
+ expect( getSelectedSuggestion() ).to.equal( null );
+ } );
+ } );
+
+ describe( 'adding multiple tokens when pasting', function() {
+ it( 'should add multiple comma-separated tokens when pasting', function() {
+ setText( 'baz, quux, wut' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz', 'quux' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( ' wut' );
+ setText( 'wut,' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz', 'quux', 'wut' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( '' );
+ } );
+
+ it( 'should add multiple tab-separated tokens when pasting', function() {
+ setText( 'baz\tquux\twut' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz', 'quux' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( 'wut' );
+ } );
+
+ it( 'should not duplicate tokens when pasting', function() {
+ setText( 'baz \tbaz, quux \tquux,quux , wut \twut, wut' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz', 'quux', 'wut' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( ' wut' );
+ } );
+
+ it( 'should skip empty tokens at the beginning of a paste', function() {
+ setText( ', ,\t \t ,,baz, quux' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( ' quux' );
+ } );
+
+ it( 'should skip empty tokens at the beginning of a paste', function() {
+ setText( ', ,\t \t ,,baz, quux' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( ' quux' );
+ } );
+
+ it( 'should skip empty tokens in the middle of a paste', function() {
+ setText( 'baz, ,\t \t ,,quux' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( 'quux' );
+ } );
+
+ it( 'should skip empty tokens at the end of a paste', function() {
+ setText( 'baz, quux, ,\t \t ,, ' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo', 'bar', 'baz', 'quux' ] );
+ expect( textInputNode.prop( 'value' ) ).to.equal( ' ' );
+ } );
+ } );
+
+ describe( 'removing tokens', function() {
+ it( 'should remove tokens when X icon clicked', function() {
+ tokenFieldNode.find( '.components-form-token-field__remove-token' ).first().simulate( 'click' );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'bar' ] );
+ } );
+
+ it( 'should remove the token to the left when backspace pressed', function() {
+ sendKeyDown( keyCodes.backspace );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'foo' ] );
+ } );
+
+ it( 'should remove the token to the right when delete pressed', function() {
+ sendKeyDown( keyCodes.leftArrow );
+ sendKeyDown( keyCodes.leftArrow );
+ sendKeyDown( keyCodes.delete );
+ expect( wrapper.state( 'tokens' ) ).to.deep.equal( [ 'bar' ] );
+ } );
+ } );
+} );
diff --git a/components/form-token-field/test/lib/fixtures.js b/components/form-token-field/test/lib/fixtures.js
new file mode 100644
index 00000000000000..89c64916fe502c
--- /dev/null
+++ b/components/form-token-field/test/lib/fixtures.js
@@ -0,0 +1,49 @@
+export default {
+ specialTokens: {
+ textEscaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
+ htmlEscaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
+ textUnescaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
+ htmlUnescaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
+ },
+ specialSuggestions: {
+ textEscaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ],
+ htmlEscaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ],
+ textUnescaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ],
+ htmlUnescaped: [ '<3', 'Stuff & Things', 'Tags & Stuff', 'Tags & Stuff 2' ],
+ matchAmpersandUnescaped: [
+ [ 'Stuff ', '&', ' Things' ],
+ [ 'Tags ', '&', ' Stuff' ],
+ [ 'Tags ', '&', ' Stuff 2' ],
+ ],
+ matchAmpersandSequence: [
+ [ 'Tag', 's &', ' Stuff' ],
+ [ 'Tag', 's &', ' Stuff 2' ],
+ ],
+ matchAmpersandEscaped: [],
+ },
+ matchingSuggestions: {
+ t: [
+ [ 't', 'he' ],
+ [ 't', 'o' ],
+ [ 't', 'hat' ],
+ [ 't', 'his' ],
+ [ 'wi', 't', 'h' ],
+ [ 'i', 't' ],
+ [ 'no', 't' ],
+ [ 'a', 't' ],
+ ],
+ s: [
+ [ 's', 'nake' ],
+ [ 's', 'ound' ],
+ [ 'i', 's' ],
+ [ 'thi', 's' ],
+ [ 'a', 's' ],
+ [ 'wa', 's' ],
+ [ 'pipe', 's' ],
+ ],
+ at: [
+ [ 'at' ],
+ [ 'th', 'at' ],
+ ],
+ },
+};
diff --git a/components/form-token-field/test/lib/token-field-wrapper.js b/components/form-token-field/test/lib/token-field-wrapper.js
new file mode 100644
index 00000000000000..166c1e8a6eb781
--- /dev/null
+++ b/components/form-token-field/test/lib/token-field-wrapper.js
@@ -0,0 +1,50 @@
+/**
+ * WordPress dependencies
+ */
+import { Component } from 'element';
+import { unescape } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import TokenField from '../../';
+
+const suggestions = [
+ 'the', 'of', 'and', 'to', 'a', 'in', 'for', 'is', 'on', 'that', 'by', 'this', 'with', 'i', 'you', 'it',
+ 'not', 'or', 'be', 'are', 'from', 'at', 'as', 'your', 'all', 'have', 'new', 'more', 'an', 'was', 'we',
+ 'snake', 'pipes', 'sound',
+];
+
+function unescapeAndFormatSpaces( str ) {
+ const nbsp = String.fromCharCode( 160 );
+ return unescape( str ).replace( / /g, nbsp );
+}
+
+class TokenFieldWrapper extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ tokenSuggestions: suggestions,
+ tokens: Object.freeze( [ 'foo', 'bar' ] ),
+ };
+ this.onTokensChange = this.onTokensChange.bind( this );
+ }
+
+ render() {
+ return (
+
+ );
+ }
+
+ onTokensChange( value ) {
+ this.setState( { tokens: value } );
+ }
+}
+
+module.exports = TokenFieldWrapper;
diff --git a/components/form-token-field/token-input.js b/components/form-token-field/token-input.js
new file mode 100644
index 00000000000000..c8b9314ff43583
--- /dev/null
+++ b/components/form-token-field/token-input.js
@@ -0,0 +1,48 @@
+/**
+ * WordPress dependencies
+ */
+import { Component } from 'element';
+
+class TokenInput extends Component {
+ constructor() {
+ super( ...arguments );
+ this.onChange = this.onChange.bind( this );
+ this.bindInput = this.bindInput.bind( this );
+ }
+
+ focus() {
+ this.input.focus();
+ }
+
+ hasFocus() {
+ return this.input === document.activeElement;
+ }
+
+ bindInput( ref ) {
+ this.input = ref;
+ }
+
+ onChange( event ) {
+ this.props.onChange( {
+ value: event.target.value,
+ } );
+ }
+
+ render() {
+ const props = { ...this.props, onChange: this.onChange };
+ const { value, placeholder } = props;
+ const size = ( ( value.length === 0 && placeholder && placeholder.length ) || value.length ) + 1;
+
+ return (
+
+ );
+ }
+}
+
+export default TokenInput;
diff --git a/components/form-token-field/token.js b/components/form-token-field/token.js
new file mode 100644
index 00000000000000..f42d46cb0759d4
--- /dev/null
+++ b/components/form-token-field/token.js
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import IconButton from 'components/icon-button';
+
+function Token( {
+ value,
+ status,
+ title,
+ displayTransform,
+ isBorderless = false,
+ disabled = false,
+ onClickRemove = noop,
+ onMouseEnter,
+ onMouseLeave,
+} ) {
+ const tokenClasses = classnames( 'components-form-token-field__token', {
+ 'is-error': 'error' === status,
+ 'is-success': 'success' === status,
+ 'is-validating': 'validating' === status,
+ 'is-borderless': isBorderless,
+ 'is-disabled': disabled,
+ } );
+
+ const onClick = () => onClickRemove( { value } );
+
+ return (
+
+
+ { displayTransform( value ) }
+
+
+
+ );
+}
+
+export default Token;
diff --git a/editor/assets/stylesheets/_variables.scss b/editor/assets/stylesheets/_variables.scss
index 9734e68ec76326..cc4093fa55d14d 100644
--- a/editor/assets/stylesheets/_variables.scss
+++ b/editor/assets/stylesheets/_variables.scss
@@ -30,6 +30,11 @@ $blue-medium-300: #66C6E4;
$blue-medium-200: #BFE7F3;
$blue-medium-100: #E5F5FA;
+// Alerts
+$alert-yellow: #f0b849;
+$alert-red: #d94f4f;
+$alert-green: #4ab866;
+
/* Other */
$default-font: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
$default-font-size: 13px;
diff --git a/editor/sidebar/post-settings/index.js b/editor/sidebar/post-settings/index.js
index 1192e7e74f235e..e191c3f1169571 100644
--- a/editor/sidebar/post-settings/index.js
+++ b/editor/sidebar/post-settings/index.js
@@ -15,6 +15,7 @@ import { Panel, PanelHeader, IconButton } from 'components';
import './style.scss';
import PostStatus from '../post-status';
import PostExcerpt from '../post-excerpt';
+import PostTaxonomies from '../post-taxonomies';
import FeaturedImage from '../featured-image';
import DiscussionPanel from '../discussion-panel';
import LastRevision from '../last-revision';
@@ -33,6 +34,7 @@ const PostSettings = ( { toggleSidebar } ) => {
+
diff --git a/editor/sidebar/post-taxonomies/index.js b/editor/sidebar/post-taxonomies/index.js
new file mode 100644
index 00000000000000..93c2f059695268
--- /dev/null
+++ b/editor/sidebar/post-taxonomies/index.js
@@ -0,0 +1,22 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from 'i18n';
+import PanelBody from 'components/panel/body';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import TagsSelector from './tags-selector';
+
+function PostTaxonomies() {
+ return (
+
+
+
+ );
+}
+
+export default PostTaxonomies;
+
diff --git a/editor/sidebar/post-taxonomies/style.scss b/editor/sidebar/post-taxonomies/style.scss
new file mode 100644
index 00000000000000..79756bf2ec3a42
--- /dev/null
+++ b/editor/sidebar/post-taxonomies/style.scss
@@ -0,0 +1,3 @@
+.editor-post-taxonomies__tags-selector {
+ margin-top: 10px;
+}
diff --git a/editor/sidebar/post-taxonomies/tags-selector.js b/editor/sidebar/post-taxonomies/tags-selector.js
new file mode 100644
index 00000000000000..f0918e3ce64451
--- /dev/null
+++ b/editor/sidebar/post-taxonomies/tags-selector.js
@@ -0,0 +1,36 @@
+/**
+ * WordPress dependencies
+ */
+import { Component } from 'element';
+import FormTokenField from 'components/form-token-field';
+
+class TagsSelector extends Component {
+ constructor() {
+ super( ...arguments );
+ this.onTokensChange = this.onTokensChange.bind( this );
+ this.state = {
+ tokens: [ 'React', 'Vue' ],
+ };
+ }
+
+ onTokensChange( value ) {
+ this.setState( { tokens: value } );
+ }
+
+ render() {
+ const suggestions = [ 'React', 'Vue', 'Angular', 'Cycle', 'PReact', 'Inferno' ];
+
+ return (
+
+
+
+ );
+ }
+}
+
+export default TagsSelector;
+
diff --git a/package.json b/package.json
index 82bff5f30a01a9..9b3870a4ef07e3 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"sass-loader": "^6.0.3",
"sinon": "^2.1.0",
"sinon-chai": "^2.9.0",
+ "sinon-test": "^1.0.2",
"style-loader": "^0.14.1",
"tinymce": "^4.5.6",
"webpack": "^2.2.1",
@@ -63,6 +64,7 @@
"dependencies": {
"classnames": "^2.2.5",
"dom-react": "^2.2.0",
+ "dom-scroll-into-view": "^1.2.1",
"element-closest": "^2.0.2",
"hpq": "^1.2.0",
"jed": "^1.1.1",