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

update/3329: Changed autocomplete menu "ul > li > button" to "div > button" #3343

Merged
merged 15 commits into from
Nov 13, 2017
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
829dfea
update/3329: Changed autocomplete menu "ul > li > button" to "div > b…
tiny-james Nov 6, 2017
3c6b8f9
update/3329: Updated selector to be more specific to fix tests
tiny-james Nov 6, 2017
570fbee
update/3329: Fix styling - images don't shrink, text has max-width
tiny-james Nov 7, 2017
73a6231
Merge branch 'master' into update/3329-autocomplete-aria-markup-impro…
tiny-james Nov 7, 2017
c229639
update/3329: specify profile picture size to avoid resizing after load
tiny-james Nov 7, 2017
fa85051
update/3329: ESC suppresses popup, CTRL+SPACE reshows; List count spo…
tiny-james Nov 7, 2017
7d8aedb
Merge branch 'master' into update/3329-autocomplete-aria-markup-impro…
tiny-james Nov 7, 2017
49413dc
Merge branch 'master' into update/3329-autocomplete-aria-markup-impro…
tiny-james Nov 9, 2017
334ed13
update/3329: Autocompleter matches after word boundaries or space
tiny-james Nov 9, 2017
5c2570c
Merge branch 'master' into update/3329-autocomplete-aria-markup-impro…
tiny-james Nov 10, 2017
45c3a55
update/3329: Fix the list staying suppressed when the cursor is moved
tiny-james Nov 10, 2017
0d0e8f2
Merge branch 'master' into update/3329-autocomplete-aria-markup-impro…
tiny-james Nov 13, 2017
b3325c1
update/3329: fixed withFocusOutside position in call order
tiny-james Nov 13, 2017
5976604
update/3329: Renamed classes to follow BEM style guide
tiny-james Nov 13, 2017
bdeff5a
update/3329: Bracketed test of turnary statement to improve readability
tiny-james Nov 13, 2017
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
39 changes: 31 additions & 8 deletions blocks/autocompleters/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,34 @@
margin-right: 8px;
}

.blocks-user-autocomplete img {
margin-right: 8px;
}

.blocks-user-autocomplete .slug {
margin-left: 8px;
color: $dark-gray-100;
}
.blocks-user-autocomplete {
.components-autocomplete__result img {
margin-right: 8px;
flex-grow: 0;
flex-shrink: 0;
max-width: none; // we must override the gutenberg default of 100%
width: 24px; // avoid jarring resize by seting the size upfront
height: 24px;
}
.name {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although they weren't introduced in this PR, these classnames don't adhere to Gutenberg's BEM-like conventions. We should have something along the lines of components-autocomplete__result-name.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, reading that document I'd come up with blocks-autocompleters__user-name because the actual CSS is not in the components/autocomplete folder but the blocks/autocompleters folder. @mcsf Does that sound right to you?
I would also have blocks-autocompleters__user-slug and blocks-autocompleters__user-avatar.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have renamed the classes. I also tweaked where the completers' className is applied in the Autocompleter structure so their css won't care about the Autocompleter's class names.

white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 200px;
flex-shrink: 0;
flex-grow: 1;
}
.slug {
margin-left: 8px;
color: $dark-gray-100;
white-space: nowrap;
text-overflow: ellipsis;
overflow: none;
max-width: 100px;
flex-grow: 0;
flex-shrink: 0;
}
.components-autocomplete__result:hover .slug {
color: $blue-medium-300;
}
}
156 changes: 93 additions & 63 deletions components/autocomplete/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { escapeRegExp, find, filter, map } from 'lodash';
*/
import { Component, findDOMNode, renderToString } from '@wordpress/element';
import { keycodes } from '@wordpress/utils';
import { __, _n, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
Expand All @@ -17,8 +18,9 @@ import './style.scss';
import Button from '../button';
import Popover from '../popover';
import withInstanceId from '../higher-order/with-instance-id';
import withSpokenMessages from '../higher-order/with-spoken-messages';

const { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } = keycodes;
const { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB, SPACE } = keycodes;

/**
* Recursively select the firstChild until hitting a leaf node.
Expand Down Expand Up @@ -78,14 +80,43 @@ function lastIndexOfSpace( text ) {
return -1;
}

function filterOptions( search, options = [], maxResults = 10 ) {
const filtered = [];
for ( let i = 0; i < options.length; i++ ) {
const option = options[ i ];

// Merge label into keywords
let { keywords = [] } = option;
if ( 'string' === typeof option.label ) {
keywords = [ ...keywords, option.label ];
}

const isMatch = keywords.some( ( keyword ) => search.test( keyword ) );
if ( ! isMatch ) {
continue;
}

filtered.push( option );

// Abort early if max reached
if ( filtered.length === maxResults ) {
break;
}
}

return filtered;
}

export class Autocomplete extends Component {
static getInitialState() {
return {
search: /./,
selectedIndex: 0,
suppress: undefined,
open: undefined,
query: undefined,
range: undefined,
filteredOptions: [],
};
}

Expand Down Expand Up @@ -161,12 +192,33 @@ export class Autocomplete extends Component {
return range;
}

announce( filteredOptions ) {
const { debouncedSpeak } = this.props;
if ( ! debouncedSpeak ) {
return;
}
if ( !! filteredOptions.length ) {
debouncedSpeak( sprintf( _n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
filteredOptions.length
), filteredOptions.length ), 'assertive' );
} else {
debouncedSpeak( __( 'No results.' ), 'assertive' );
}
}

loadOptions( index ) {
this.props.completers[ index ].getOptions().then( ( options ) => {
const keyedOptions = map( options, ( option, i ) => ( { ...option, key: index + '-' + i } ) );
const filteredOptions = filterOptions( this.state.search, keyedOptions );
const selectedIndex = filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0;
this.setState( {
[ 'options_' + index ]: map( options,
( option, i ) => ( { ...option, key: index + '_' + i } ) ),
[ 'options_' + index ]: keyedOptions,
filteredOptions,
selectedIndex,
} );
this.announce( filteredOptions );
} );
}

Expand Down Expand Up @@ -247,88 +299,67 @@ export class Autocomplete extends Component {
}

search( event ) {
const { open: wasOpen } = this.state;
const { open: wasOpen, suppress: wasSuppress } = this.state;
const { completers } = this.props;
// ensure that the cursor location is unambiguous
const container = event.target;
const cursor = this.getCursor( container );
// look for the trigger prefix and search query just before the cursor location
const match = this.findMatch( container, cursor, completers, wasOpen );
const { open, query, range } = match || {};
// create a regular expression to filter the options
const search = open ? new RegExp( escapeRegExp( query ), 'i' ) : /./;
// asynchronously load the options for the open completer
if ( open && ( ! wasOpen || open.idx !== wasOpen.idx ) ) {
this.loadOptions( open.idx );
}
// create a regular expression to filter the options
const search = open ? new RegExp( escapeRegExp( query ), 'i' ) : /./;
// filter the options we already have
const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : [];
// check if we should still suppress the popover
const suppress = open && wasSuppress === open.idx ? wasSuppress : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest adding parentheses to make eval order really obvious, instead of relying on precedence rules. One level is enough: ( a && b === c ) ? d : e

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// update the state
if ( wasOpen || open ) {
this.setState( { selectedIndex: 0, search, open, query, range } );
this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query, range } );
}
}

getFilteredOptions() {
const { maxResults = 10 } = this.props;
const { search, open } = this.state;
if ( ! open ) {
return [];
// announce the count of filtered options but only if they have loaded
if ( open && this.state[ 'options_' + open.idx ] ) {
this.announce( filteredOptions );
}
const options = this.state[ 'options_' + open.idx ] || [];

const filtered = [];
for ( let i = 0; i < options.length; i++ ) {
const option = options[ i ];

// Merge label into keywords
let { keywords = [] } = option;
if ( 'string' === typeof option.label ) {
keywords = [ ...keywords, option.label ];
}

const isMatch = keywords.some( ( keyword ) => search.test( keyword ) );
if ( ! isMatch ) {
continue;
}

filtered.push( option );

// Abort early if max reached
if ( filtered.length === maxResults ) {
break;
}
}

return filtered;
}

setSelectedIndex( event ) {
const { open, selectedIndex } = this.state;
if ( ! open ) {
const { open, suppress, selectedIndex, filteredOptions } = this.state;
if ( ! open || filteredOptions.length === 0 ) {
return;
}
const options = this.getFilteredOptions();
if ( options.length === 0 ) {
if ( suppress === open.idx ) {
const { keyCode, ctrlKey, shiftKey, altKey, metaKey } = event;
if ( keyCode === SPACE && ctrlKey && ! ( shiftKey || altKey || metaKey ) ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the SPACE check here; if the user moves focus out of the block after dismissing suggestions and then returns later, shouldn't we expect the dismissal to no longer take effect?

focus-out

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a future refactor, to simplify the handling of modifier keys, we might consider adding support to <KeyboardShortcuts /> to monitor key events of children, mentioned originally in implementation notes #1944 . Made more difficult here by the fact that we manually create the DOM event handlers.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that still hidden after move thing is a bug.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bug should be fixed.

this.setState( { suppress: undefined } );
event.preventDefault();
event.stopPropagation();
}
return;
}

let nextSelectedIndex;
switch ( event.keyCode ) {
case UP:
nextSelectedIndex = ( selectedIndex === 0 ? options.length : selectedIndex ) - 1;
nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1;
this.setState( { selectedIndex: nextSelectedIndex } );
break;

case DOWN:
nextSelectedIndex = ( selectedIndex + 1 ) % options.length;
nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length;
this.setState( { selectedIndex: nextSelectedIndex } );
break;

case ESCAPE:
this.reset();
this.setState( { suppress: open.idx } );
break;

case ENTER:
this.select( options[ selectedIndex ] );
this.select( filteredOptions[ selectedIndex ] );
break;

case LEFT:
Expand Down Expand Up @@ -389,13 +420,13 @@ export class Autocomplete extends Component {

render() {
const { children, instanceId } = this.props;
const { open, selectedIndex } = this.state;
const { className } = open || {};
const { open, suppress, selectedIndex, filteredOptions } = this.state;
const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {};
const { className, idx } = open || {};
const classes = classnames( 'components-autocomplete__popover', className );
const filteredOptions = this.getFilteredOptions();
const isExpanded = filteredOptions.length > 0;
const isExpanded = suppress !== idx && filteredOptions.length > 0;
const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null;
const activeId = isExpanded ? `components-autocomplete-item-${ instanceId }-${ selectedIndex }` : null;
const activeId = isExpanded ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` : null;

return (
<div
Expand All @@ -412,31 +443,30 @@ export class Autocomplete extends Component {
className={ classes }
getAnchorRect={ this.getWordRect }
>
<ul
<div
id={ listBoxId }
role="listbox"
className="components-autocomplete__results"
>
{ map( filteredOptions, ( option, index ) => (
<li
{ isExpanded && map( filteredOptions, ( option, index ) => (
<Button
key={ option.key }
id={ `components-autocomplete-item-${ instanceId }-${ index }` }
id={ `components-autocomplete-item-${ instanceId }-${ option.key }` }
role="option"
aria-selected={ index === selectedIndex }
className={ classnames( 'components-autocomplete__result', {
'is-selected': index === selectedIndex,
} ) }
onClick={ () => this.select( option ) }
>
<Button onClick={ () => this.select( option ) }>
{ option.label }
</Button>
</li>
{ option.label }
</Button>
) ) }
</ul>
</div>
</Popover>
</div>
);
}
}

export default withInstanceId( Autocomplete );
export default withSpokenMessages( withInstanceId( Autocomplete ) );
29 changes: 14 additions & 15 deletions components/autocomplete/style.scss
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
.components-autocomplete__popover .components-popover__content {
width: 200px;
min-width: 200px;
}

.components-autocomplete__popover .components-autocomplete__results {
list-style-type: none;
padding: 3px;
display: flex;
flex-direction: column;
align-items: stretch;

&:empty {
display: none;
}
}

.components-autocomplete__result {
.components-autocomplete__result.components-button {
margin-bottom: 0;
border: 1px solid transparent;
font-family: $default-font;
font-size: $default-font-size;
color: $dark-gray-500;
display: flex;
flex-direction: row;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
padding: 6px;
text-align: left;

&.is-selected {
box-shadow: none;
Expand All @@ -24,17 +34,6 @@
}

&:hover {
.components-button {
color: $blue-medium-500;
}
}

.components-button {
color: $dark-gray-500;
display: flex;
align-items: center;
width: 100%;
padding: 6px;
text-align: left;
color: $blue-medium-500;
}
}
Loading