diff --git a/components/autocomplete/README.md b/components/autocomplete/README.md index e71da089c79090..102f88d4532501 100644 --- a/components/autocomplete/README.md +++ b/components/autocomplete/README.md @@ -59,7 +59,14 @@ A function that returns the label for a given option. A label may be a string or A function that returns the keywords for the specified option. - Type: `Function` -- Required: Yes +- Required: No + +#### isOptionDisabled + +A function that returns whether or not the specified option should be disabled. Disabled options cannot be selected. + +- Type: `Function` +- Required: No #### getOptionCompletion @@ -120,6 +127,8 @@ const fruitCompleter = { ], // Declares that options should be matched by their name getOptionKeywords: option => [ option.name ], + // Declares that the Grapes option is disabled + isOptionDisabled: option => option.name === 'Grapes', // Declares completions should be inserted as abbreviations getOptionCompletion: option => ( { option.visual } diff --git a/components/autocomplete/index.js b/components/autocomplete/index.js index e4c8e8039dd80b..feda61c9882aeb 100644 --- a/components/autocomplete/index.js +++ b/components/autocomplete/index.js @@ -41,6 +41,13 @@ const { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } = keycodes; * @returns {string[]} list of key words to search. */ +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @returns {string[]} whether or not the given option is disabled. + */ + /** * @callback FnGetOptionLabel * @param {CompleterOption} option a completer option. @@ -92,6 +99,7 @@ const { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } = keycodes; * @property {String} triggerPrefix the prefix that will display the menu. * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. * @property {?FnAllowNode} allowNode filter the allowed text nodes in the autocomplete. * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. @@ -242,6 +250,10 @@ export class Autocomplete extends Component { const { open, range, query } = this.state; const { getOptionCompletion } = open || {}; + if ( option.isDisabled ) { + return; + } + this.reset(); if ( getOptionCompletion ) { @@ -345,6 +357,7 @@ export class Autocomplete extends Component { value: optionData, label: completer.getOptionLabel( optionData ), keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], + isDisabled: completer.isOptionDisabled ? completer.isOptionDisabled( optionData ) : false, } ) ); const filteredOptions = filterOptions( this.state.search, keyedOptions ); @@ -604,6 +617,7 @@ export class Autocomplete extends Component { id={ `components-autocomplete-item-${ instanceId }-${ option.key }` } role="option" aria-selected={ index === selectedIndex } + disabled={ option.isDisabled } className={ classnames( 'components-autocomplete__result', className, { 'is-selected': index === selectedIndex, } ) } diff --git a/components/autocomplete/test/index.js b/components/autocomplete/test/index.js index a693dacfb1dd11..7b5f047d723970 100644 --- a/components/autocomplete/test/index.js +++ b/components/autocomplete/test/index.js @@ -161,6 +161,7 @@ describe( 'Autocomplete', () => { options, getOptionLabel: ( option ) => option.label, getOptionKeywords: ( option ) => option.keywords, + isOptionDisabled: ( option ) => option.isDisabled, }; const slashCompleter = { @@ -435,6 +436,43 @@ describe( 'Autocomplete', () => { } ); } ); + it( 'set the disabled attribute on results', ( done ) => { + const wrapper = makeAutocompleter( [ + { + ...slashCompleter, + options: [ + { + id: 1, + label: 'Bananas', + keywords: [ 'fruit' ], + isDisabled: true, + }, + { + id: 2, + label: 'Apple', + keywords: [ 'fruit' ], + isDisabled: false, + }, + ], + }, + ] ); + expectInitialState( wrapper ); + // simulate typing '/' + simulateInput( wrapper, [ par( tx( '/' ) ) ] ); + // wait for async popover display + process.nextTick( () => { + wrapper.update(); + + const firstItem = wrapper.find( 'button.components-autocomplete__result' ).at( 0 ).getDOMNode(); + expect( firstItem.hasAttribute( 'disabled' ) ).toBe( true ); + + const secondItem = wrapper.find( 'button.components-autocomplete__result' ).at( 1 ).getDOMNode(); + expect( secondItem.hasAttribute( 'disabled' ) ).toBe( false ); + + done(); + } ); + } ); + it( 'navigates options by arrow keys', ( done ) => { const wrapper = makeAutocompleter( [ slashCompleter ] ); // listen to keydown events on the editor to see if it gets them @@ -591,6 +629,58 @@ describe( 'Autocomplete', () => { } ); } ); + it( 'does not select when option is disabled', ( done ) => { + const getOptionCompletion = jest.fn(); + const testOptions = [ + { + id: 1, + label: 'Bananas', + keywords: [ 'fruit' ], + isDisabled: true, + }, + { + id: 2, + label: 'Apple', + keywords: [ 'fruit' ], + isDisabled: false, + }, + ]; + const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion, options: testOptions } ] ); + // listen to keydown events on the editor to see if it gets them + const editorKeydown = jest.fn(); + const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); + fakeEditor.addEventListener( 'keydown', editorKeydown, false ); + expectInitialState( wrapper ); + // the menu is not open so press enter and see if the editor gets it + expect( editorKeydown ).not.toHaveBeenCalled(); + simulateKeydown( wrapper, ENTER ); + expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); + // clear the call count + editorKeydown.mockClear(); + // simulate typing '/' + simulateInput( wrapper, [ par( tx( '/' ) ) ] ); + // wait for async popover display + process.nextTick( () => { + wrapper.update(); + // menu should be open with all options + expect( wrapper.state( 'open' ) ).toBeDefined(); + expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); + expect( wrapper.state( 'query' ) ).toEqual( '' ); + expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); + expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ + { key: '0-0', value: testOptions[ 0 ], label: 'Bananas', keywords: [ 'fruit' ], isDisabled: true }, + { key: '0-1', value: testOptions[ 1 ], label: 'Apple', keywords: [ 'fruit' ], isDisabled: false }, + ] ); + // pressing enter should NOT reset and NOT call getOptionCompletion + simulateKeydown( wrapper, ENTER ); + expect( wrapper.state( 'open' ) ).toBeDefined(); + expect( getOptionCompletion ).not.toHaveBeenCalled(); + // the editor should not have gotten the event + expect( editorKeydown ).not.toHaveBeenCalled(); + done(); + } ); + } ); + it( 'doesn\'t otherwise interfere with keydown behavior', ( done ) => { const wrapper = makeAutocompleter( [ slashCompleter ] ); // listen to keydown events on the editor to see if it gets them diff --git a/editor/components/autocompleters/block.js b/editor/components/autocompleters/block.js index 824e416445809a..171f90a6021661 100644 --- a/editor/components/autocompleters/block.js +++ b/editor/components/autocompleters/block.js @@ -56,6 +56,9 @@ export function createBlockCompleter( { value: createBlock( name, initialAttributes ), }; }, + isOptionDisabled( inserterItem ) { + return inserterItem.isDisabled; + }, }; } diff --git a/editor/components/autocompleters/test/block.js b/editor/components/autocompleters/test/block.js index f8d9f91a4364e5..dad08ead49dd3d 100644 --- a/editor/components/autocompleters/test/block.js +++ b/editor/components/autocompleters/test/block.js @@ -66,4 +66,22 @@ describe( 'block', () => { expect( labelComponents.at( 0 ).prop( 'icon' ) ).toBe( 'expected-icon' ); expect( labelComponents.at( 1 ).text() ).toBe( 'expected-text' ); } ); + + it( 'should derive isOptionDisabled from the item\'s isDisabled', () => { + const disabledInserterItem = { + name: 'core/foo', + title: 'foo', + keywords: [ 'foo-keyword-1', 'foo-keyword-2' ], + isDisabled: true, + }; + const enabledInserterItem = { + name: 'core/bar', + title: 'bar', + keywords: [], + isDisabled: false, + }; + + expect( blockCompleter.isOptionDisabled( disabledInserterItem ) ).toBe( true ); + expect( blockCompleter.isOptionDisabled( enabledInserterItem ) ).toBe( false ); + } ); } );