diff --git a/bootstrap-test.js b/bootstrap-test.js index b30da747f1a547..181dcbbf338b82 100644 --- a/bootstrap-test.js +++ b/bootstrap-test.js @@ -3,6 +3,12 @@ require( 'chai' ) .use( require( 'dirty-chai' ) ) .use( require( 'sinon-chai' ) ); +// Sinon plugins +const sinon = require( 'sinon' ); +const sinonTest = require( 'sinon-test' ); +sinon.test = sinonTest.configureTest( sinon ); +sinon.testCase = sinonTest.configureTestCase( sinon ); + // Fake DOM const { JSDOM } = require( 'jsdom' ); const dom = new JSDOM( '', { diff --git a/components/form-token-field/README.md b/components/form-token-field/README.md new file mode 100644 index 00000000000000..d979c49b9ed1bf --- /dev/null +++ b/components/form-token-field/README.md @@ -0,0 +1,71 @@ +Token Field +=========== + +A `FormTokenField` is a field similar to the tags and categories fields in the interim editor chrome, or the "to" field in Mail on OS X. Tokens can be entered by typing them or selecting them from a list of suggested tokens. + +Up to one hundred suggestions that match what the user has typed so far will be shown from which the user can pick from (auto-complete). Tokens are separated by the "," character. Suggetions can be selected with the up or down arrows and added with the tab or enter key. + +The `value` property is handled in a manner similar to controlled form components. See [Forms](http://facebook.github.io/react/docs/forms.html) in the React Documentation for more information. + +### Keyboard Accessibility + +- `left arrow` - if input field is empty, move insertion point before previous token +- `right arrow` - if input field is empty, move insertion point after next token +- `up arrow` - select previous suggestion +- `down arrow` - select next suggestion +- `tab` / `enter` - if suggestion selected, insert suggestion as a new token; otherwise, insert value typed into input as new token +- `comma` - insert value typed into input as new token + +### Properties + +- `value` - An array of strings or objects to display as tokens in the field. If objects are present in the array, they **must** have a property of `value`. Here is an example object that could be passed in as a value: + + ```javascript + { + value: '(string) The value of the token.', + status: "(string) One of 'error', 'validating', or 'success'. Applies styles to token." + title: '(string) If not falsey, will add a title to the token.', + onMouserEnter: '(function) Function to call when onMouseEnter event triggered on token.' + onMouseLeave: '(function) Function to call when onMouseLeave is triggered on token.' + } + ``` +- `displayTransform` - Function to call to transform tokens for display. (In + the editor, this is needed to decode HTML entities embedded in tags - + otherwise entities like `&` in tag names are double-encoded like `&`, + once by the REST API and once by React). +- `saveTransform` - Function to call to transform tokens for saving. The + default is to trim the token value. This function is also applied when + matching suggestions against the current value so that matching works + correctly with leading or trailing spaces. (In the editor, this is needed to + remove leading and trailing spaces from tag names, like wp-admin does. + Otherwise the REST API won't save them.) +- `onChange` - Function to call when the tokens have changed. An array of new + tokens is passed to the callback. +- `onFocus` - Function to call when the TokenField has been focused on. The event is passed to the callback. Useful for analytics. +- `suggestions` - An array of strings to present to the user as suggested + tokens. +- `maxSuggestions` - The maximum number of suggestions to display at a time. +- `tokenizeOnSpace` - If true, will add a token when `TokenField` is focused and `space` is pressed. +- `isBorderless` - When true, renders tokens as without a background. +- `maxLength` - If passed, `TokenField` will disable ability to add new tokens once number of tokens is greater than or equal to `maxLength`. +- `disabled` - When true, tokens are not able to be added or removed. +- `placeholder` - If passed, the `TokenField` input will show a placeholder string if no value tokens are present. + +### Example + +```jsx +class extends Component { + render() { + return ( + + ); + } + + onTokensChange( value ) { + this.setState( { tokens: value } ); + } +} +``` diff --git a/components/form-token-field/index.js b/components/form-token-field/index.js new file mode 100644 index 00000000000000..3366e312776373 --- /dev/null +++ b/components/form-token-field/index.js @@ -0,0 +1,508 @@ +/** + * External dependencies + */ +import { last, take, clone, uniq, map, difference, each, identity, some } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from 'element'; + +/** + * Internal dependencies + */ +import './style.scss'; +import Token from './token'; +import TokenInput from './token-input'; +import SuggestionsList from './suggestions-list'; + +const initialState = { + incompleteTokenValue: '', + inputOffsetFromEnd: 0, + isActive: false, + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, +}; + +class FormTokenField extends Component { + constructor() { + super( ...arguments ); + this.state = initialState; + this.onKeyDown = this.onKeyDown.bind( this ); + this.onKeyPress = this.onKeyPress.bind( this ); + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.deleteTokenBeforeInput = this.deleteTokenBeforeInput.bind( this ); + this.deleteTokenAfterInput = this.deleteTokenAfterInput.bind( this ); + this.addCurrentToken = this.addCurrentToken.bind( this ); + this.onContainerTouched = this.onContainerTouched.bind( this ); + this.renderToken = this.renderToken.bind( this ); + this.onTokenClickRemove = this.onTokenClickRemove.bind( this ); + this.onSuggestionHovered = this.onSuggestionHovered.bind( this ); + this.onSuggestionSelected = this.onSuggestionSelected.bind( this ); + this.onInputChange = this.onInputChange.bind( this ); + this.bindInput = this.bindInput.bind( this ); + this.bindTokensAndInput = this.bindTokensAndInput.bind( this ); + } + + componentDidUpdate() { + if ( this.state.isActive && ! this.input.hasFocus() ) { + this.input.focus(); // make sure focus is on input + } + } + + componentWillReceiveProps( nextProps ) { + if ( nextProps.disabled && this.state.isActive ) { + this.setState( { + isActive: false, + incompleteTokenValue: '', + } ); + } + } + + bindInput( ref ) { + this.input = ref; + } + + bindTokensAndInput( ref ) { + this.tokensAndInput = ref; + } + + onFocus( event ) { + this.setState( { isActive: true } ); + if ( 'function' === typeof this.props.onFocus ) { + this.props.onFocus( event ); + } + } + + onBlur() { + if ( this.inputHasValidValue() ) { + this.setState( { isActive: false }, this.addCurrentToken ); + } else { + this.setState( initialState ); + } + } + + onKeyDown( event ) { + let preventDefault = false; + + switch ( event.keyCode ) { + case 8: // backspace (delete to left) + preventDefault = this.handleDeleteKey( this.deleteTokenBeforeInput ); + break; + case 9: // tab + preventDefault = this.addCurrentToken(); + break; + case 13: // enter/return + preventDefault = this.addCurrentToken(); + break; + case 37: // left arrow + preventDefault = this.handleLeftArrowKey(); + break; + case 38: // up arrow + preventDefault = this.handleUpArrowKey(); + break; + case 39: // right arrow + preventDefault = this.handleRightArrowKey(); + break; + case 40: // down arrow + preventDefault = this.handleDownArrowKey(); + break; + case 46: // delete (to right) + preventDefault = this.handleDeleteKey( this.deleteTokenAfterInput ); + break; + case 32: // space + if ( this.props.tokenizeOnSpace ) { + preventDefault = this.addCurrentToken(); + } + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + } + + onKeyPress( event ) { + let preventDefault = false; + switch ( event.charCode ) { + case 44: // comma + preventDefault = this.handleCommaKey(); + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + } + + onContainerTouched( event ) { + // Prevent clicking/touching the tokensAndInput container from blurring + // the input and adding the current token. + if ( event.target === this.tokensAndInput && this.state.isActive ) { + event.preventDefault(); + } + } + + onTokenClickRemove( event ) { + this.deleteToken( event.value ); + } + + onSuggestionHovered( suggestion ) { + const index = this.getMatchingSuggestions().indexOf( suggestion ); + + if ( index >= 0 ) { + this.setState( { + selectedSuggestionIndex: index, + selectedSuggestionScroll: false, + } ); + } + } + + onSuggestionSelected( suggestion ) { + this.addNewToken( suggestion ); + } + + onInputChange( event ) { + const text = event.value; + const separator = this.props.tokenizeOnSpace ? /[ ,\t]+/ : /[,\t]+/; + const items = text.split( separator ); + + if ( items.length > 1 ) { + this.addNewTokens( items.slice( 0, -1 ) ); + } + + this.setState( { + incompleteTokenValue: last( items ) || '', + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, + } ); + } + + handleDeleteKey( deleteToken ) { + let preventDefault = false; + if ( this.input.hasFocus() && this.isInputEmpty() ) { + deleteToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleLeftArrowKey() { + let preventDefault = false; + if ( this.isInputEmpty() ) { + this.moveInputBeforePreviousToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleRightArrowKey() { + let preventDefault = false; + if ( this.isInputEmpty() ) { + this.moveInputAfterNextToken(); + preventDefault = true; + } + + return preventDefault; + } + + handleUpArrowKey() { + this.setState( { + selectedSuggestionIndex: Math.max( ( this.state.selectedSuggestionIndex || 0 ) - 1, 0 ), + selectedSuggestionScroll: true, + } ); + + return true; // preventDefault + } + + handleDownArrowKey() { + this.setState( { + selectedSuggestionIndex: Math.min( + ( this.state.selectedSuggestionIndex + 1 ) || 0, + this.getMatchingSuggestions().length - 1 + ), + selectedSuggestionScroll: true, + } ); + + return true; // preventDefault + } + + handleCommaKey() { + if ( this.inputHasValidValue() ) { + this.addNewToken( this.state.incompleteTokenValue ); + } + + return true; // preventDefault + } + + moveInputToIndex( index ) { + this.setState( { + inputOffsetFromEnd: this.props.value.length - Math.max( index, -1 ) - 1, + } ); + } + + moveInputBeforePreviousToken() { + this.setState( { + inputOffsetFromEnd: Math.min( this.state.inputOffsetFromEnd + 1, this.props.value.length ), + } ); + } + + moveInputAfterNextToken() { + this.setState( { + inputOffsetFromEnd: Math.max( this.state.inputOffsetFromEnd - 1, 0 ), + } ); + } + + deleteTokenBeforeInput() { + const index = this.getIndexOfInput() - 1; + + if ( index > -1 ) { + this.deleteToken( this.props.value[ index ] ); + } + } + + deleteTokenAfterInput() { + const index = this.getIndexOfInput(); + + if ( index < this.props.value.length ) { + this.deleteToken( this.props.value[ index ] ); + // update input offset since it's the offset from the last token + this.moveInputToIndex( index ); + } + } + + addCurrentToken() { + let preventDefault = false; + const selectedSuggestion = this.getSelectedSuggestion(); + + if ( selectedSuggestion ) { + this.addNewToken( selectedSuggestion ); + preventDefault = true; + } else if ( this.inputHasValidValue() ) { + this.addNewToken( this.state.incompleteTokenValue ); + preventDefault = true; + } + + return preventDefault; + } + + addNewTokens( tokens ) { + const tokensToAdd = uniq( + tokens + .map( this.props.saveTransform ) + .filter( Boolean ) + .filter( token => ! this.valueContainsToken( token ) ) + ); + + if ( tokensToAdd.length > 0 ) { + const newValue = clone( this.props.value ); + newValue.splice.apply( + newValue, + [ this.getIndexOfInput(), 0 ].concat( tokensToAdd ) + ); + this.props.onChange( newValue ); + } + } + + addNewToken( token ) { + this.addNewTokens( [ token ] ); + + this.setState( { + incompleteTokenValue: '', + selectedSuggestionIndex: -1, + selectedSuggestionScroll: false, + } ); + + if ( this.state.isActive ) { + this.input.focus(); + } + } + + deleteToken( token ) { + const newTokens = this.props.value.filter( ( item ) => { + return this.getTokenValue( item ) !== this.getTokenValue( token ); + } ); + this.props.onChange( newTokens ); + } + + getTokenValue( token ) { + if ( 'object' === typeof token ) { + return token.value; + } + + return token; + } + + getMatchingSuggestions() { + let suggestions = this.props.suggestions; + let match = this.props.saveTransform( this.state.incompleteTokenValue ); + const startsWithMatch = []; + const containsMatch = []; + + if ( match.length === 0 ) { + suggestions = difference( suggestions, this.props.value ); + } else { + match = match.toLocaleLowerCase(); + + each( suggestions, ( suggestion ) => { + const index = suggestion.toLocaleLowerCase().indexOf( match ); + if ( this.props.value.indexOf( suggestion ) === -1 ) { + if ( index === 0 ) { + startsWithMatch.push( suggestion ); + } else if ( index > 0 ) { + containsMatch.push( suggestion ); + } + } + } ); + + suggestions = startsWithMatch.concat( containsMatch ); + } + + return take( suggestions, this.props.maxSuggestions ); + } + + getSelectedSuggestion() { + if ( this.state.selectedSuggestionIndex !== -1 ) { + return this.getMatchingSuggestions()[ this.state.selectedSuggestionIndex ]; + } + } + + valueContainsToken( token ) { + return some( this.props.value, ( item ) => { + return this.getTokenValue( token ) === this.getTokenValue( item ); + } ); + } + + getIndexOfInput() { + return this.props.value.length - this.state.inputOffsetFromEnd; + } + + isInputEmpty() { + return this.state.incompleteTokenValue.length === 0; + } + + inputHasValidValue() { + return this.props.saveTransform( this.state.incompleteTokenValue ).length > 0; + } + + renderTokensAndInput() { + const components = map( this.props.value, this.renderToken ); + components.splice( this.getIndexOfInput(), 0, this.renderInput() ); + + return components; + } + + renderToken( token ) { + const value = this.getTokenValue( token ); + const status = token.status ? token.status : undefined; + + return ( + + ); + } + + renderInput() { + const { autoCapitalize, autoComplete, maxLength, value, placeholder } = this.props; + + let props = { + autoCapitalize, + autoComplete, + ref: this.bindInput, + key: 'input', + disabled: this.props.disabled, + value: this.state.incompleteTokenValue, + onBlur: this.onBlur, + }; + + if ( value.length === 0 && placeholder ) { + props.placeholder = placeholder; + } + + if ( ! ( maxLength && value.length >= maxLength ) ) { + props = { ...props, onChange: this.onInputChange }; + } + + return ( + + ); + } + + render() { + const { disabled } = this.props; + const classes = classnames( 'components-form-token-field', { + 'is-active': this.state.isActive, + 'is-disabled': disabled, + } ); + + let tokenFieldProps = { + ref: 'main', + className: classes, + tabIndex: '-1', + }; + + if ( ! disabled ) { + tokenFieldProps = Object.assign( {}, tokenFieldProps, { + onKeyDown: this.onKeyDown, + onKeyPress: this.onKeyPress, + onFocus: this.onFocus, + } ); + } + + return ( +
+
+ { this.renderTokensAndInput() } +
+ +
+ ); + } +} + +FormTokenField.defaultProps = { + suggestions: Object.freeze( [] ), + maxSuggestions: 100, + value: Object.freeze( [] ), + placeholder: '', + displayTransform: identity, + saveTransform: ( token ) => token.trim(), + onChange: () => {}, + isBorderless: false, + disabled: false, + tokenizeOnSpace: false, +}; + +export default FormTokenField; diff --git a/components/form-token-field/style.scss b/components/form-token-field/style.scss new file mode 100644 index 00000000000000..21dba507942310 --- /dev/null +++ b/components/form-token-field/style.scss @@ -0,0 +1,191 @@ +.components-form-token-field { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 0; + background-color: $white; + border: 1px solid $light-gray-500; + color: $dark-gray-800; + cursor: text; + transition: all .15s ease-in-out; + + &:hover { + border-color: $light-gray-700; + } + + &.is-disabled { + background: $light-gray-500; + border-color: $light-gray-700; + } + + &.is-active { + border-color: $blue-wordpress; + box-shadow: 0 0 0 2px $blue-medium-200; + } +} + +.components-form-token-field__input-container { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + padding: 5px 14px 5px 0; +} + +// Token input +input[type="text"].components-form-token-field__input { + display: inline-block; + width: auto; + max-width: 100%; + margin: 2px 0 2px 8px; + padding: 0 0 0 6px; + line-height: 24px; + background: inherit; + border: 0; + outline: none; + font-family: inherit; + font-size: 14px; + color: $dark-gray-800; + box-shadow: none; + + &:focus { + box-shadow: none; + } +} + +// Tokens +.components-form-token-field__token { + font-size: 14px; + display: flex; + margin: 2px 0 2px 8px; + color: $white; + overflow: hidden; + + &.is-success { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + background: $alert-green; + } + } + + &.is-error { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + background: $alert-red; + } + } + + &.is-validating { + .components-form-token-field__token-text, + .components-form-token-field__remove-token { + color: $dark-gray-500; + } + } + + &.is-borderless { + position: relative; + padding: 0 16px 0 0; + + .components-form-token-field__token-text { + background: transparent; + color: $blue-wordpress; + } + + .components-form-token-field__remove-token { + background: transparent; + color: $dark-gray-500; + position: absolute; + top: 1px; + right: 0; + } + + &.is-success { + .components-form-token-field__token-text { + color: $alert-green; + } + } + + &.is-error { + .components-form-token-field__token-text { + color: $alert-red; + border-radius: 4px 0 0 4px; + padding: 0 4px 0 6px; + } + } + + &.is-validating { + .components-form-token-field__token-text { + color: $dark-gray-800; + } + } + } + + &.is-disabled { + .components-form-token-field__remove-token { + cursor: default; + } + } +} + +.components-form-token-field__token-text, +.components-form-token-field__remove-token { + display: inline-block; + line-height: 24px; + background: $dark-gray-500; + transition: all .2s cubic-bezier( .4, 1, .4, 1 ); +} + +.components-form-token-field__token-text { + border-radius: 4px 0 0 4px; + padding: 0 4px 0 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.components-form-token-field__remove-token { + cursor: pointer; + border-radius: 0 4px 4px 0; + padding: 0 2px; + font-size: 10px; + color: $light-gray-500; + line-height: 10px; + + &:hover { + color: white; + background: $dark-gray-600; + } +} + +// Suggestion list +.components-form-token-field__suggestions-list { + background: $white; + max-height: 0; + overflow-y: scroll; + transition: all .15s ease-in-out; + list-style: none; + margin: 0; + + &.is-expanded { + background: $white; + border-top: 1px solid $dark-gray-300; + max-height: 9em; + padding-top: 3px; + } +} + +.components-form-token-field__suggestion { + color: $dark-gray-500; + display: block; + font-size: 13px; + padding: 4px 8px; + cursor: pointer; + + &.is-selected { + background: $blue-medium-500; + color: $white; + } +} + +.components-form-token-field__suggestion-match { + color: $dark-gray-800; +} diff --git a/components/form-token-field/suggestions-list.js b/components/form-token-field/suggestions-list.js new file mode 100644 index 00000000000000..a455e9932074f4 --- /dev/null +++ b/components/form-token-field/suggestions-list.js @@ -0,0 +1,130 @@ +/** + * External dependencies + */ +import { map } from 'lodash'; +import scrollIntoView from 'dom-scroll-into-view'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component } from 'element'; + +class SuggestionsList extends Component { + constructor() { + super( ...arguments ); + this.handleMouseDown = this.handleMouseDown.bind( this ); + this.bindList = this.bindList.bind( this ); + } + + componentDidUpdate( prevProps ) { + // only have to worry about scrolling selected suggestion into view + // when already expanded + if ( prevProps.isExpanded && this.props.isExpanded && this.props.selectedIndex > -1 && this.props.scrollIntoView ) { + this.scrollingIntoView = true; + scrollIntoView( this.list.children[ this.props.selectedIndex ], this.list, { + onlyScrollIfNeeded: true, + } ); + + setTimeout( () => { + this.scrollingIntoView = false; + }, 100 ); + } + } + + bindList( ref ) { + this.list = ref; + } + + handleHover( suggestion ) { + return () => { + if ( ! this.scrollingIntoView ) { + this.props.onHover( suggestion ); + } + }; + } + + handleClick( suggestion ) { + return () => { + this.props.onSelect( suggestion ); + }; + } + + handleMouseDown( e ) { + // By preventing default here, we will not lose focus of when clicking a suggestion + e.preventDefault(); + } + + computeSuggestionMatch( suggestion ) { + const match = this.props.displayTransform( this.props.match || '' ).toLocaleLowerCase(); + if ( match.length === 0 ) { + return null; + } + + suggestion = this.props.displayTransform( suggestion ); + const indexOfMatch = suggestion.toLocaleLowerCase().indexOf( match ); + + return { + suggestionBeforeMatch: suggestion.substring( 0, indexOfMatch ), + suggestionMatch: suggestion.substring( indexOfMatch, indexOfMatch + match.length ), + suggestionAfterMatch: suggestion.substring( indexOfMatch + match.length ), + }; + } + + render() { + const classes = classnames( 'components-form-token-field__suggestions-list', { + 'is-expanded': this.props.isExpanded && this.props.suggestions.length > 0, + } ); + + // We set `tabIndex` here because otherwise Firefox sets focus on this + // div when tabbing off of the input in `TokenField` -- not really sure + // why, since usually a div isn't focusable by default + // TODO does this still apply now that it's a
    and not a
    ? + 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",