Rework composer autocomplete to be smarter and not trap tab

This commit is contained in:
Michael Telatynski 2021-02-16 17:56:09 +00:00
parent 5c1b38a48c
commit c05eceef7f
3 changed files with 43 additions and 22 deletions

View file

@ -24,8 +24,6 @@ import {Room} from 'matrix-js-sdk/src/models/room';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`; export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
interface IProps { interface IProps {
@ -68,7 +66,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completionList: [], completionList: [],
// how far down the completion list we are (THIS IS 1-INDEXED!) // how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: COMPOSER_SELECTED, selectionOffset: 1,
// whether we should show completions if they're available // whether we should show completions if they're available
shouldShowCompletions: true, shouldShowCompletions: true,
@ -112,7 +110,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completions: [], completions: [],
completionList: [], completionList: [],
// Reset selected completion // Reset selected completion
selectionOffset: COMPOSER_SELECTED, selectionOffset: 1,
// Hide the autocomplete box // Hide the autocomplete box
hide: true, hide: true,
}); });
@ -148,26 +146,31 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
const completionList = flatMap(completions, (provider) => provider.completions); const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty. // Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED; let selectionOffset = 1;
if (completionList.length > 0) { if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list, /* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer. try to find it and jump to it. If not, select composer.
*/ */
const currentSelection = this.state.selectionOffset === 0 ? null : const currentSelection = this.state.selectionOffset <= 1 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion; this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex( selectionOffset = completionList.findIndex(
(completion) => completion.completion === currentSelection); (completion) => completion.completion === currentSelection);
if (selectionOffset === -1) { if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED; selectionOffset = 1;
} else { } else {
selectionOffset++; // selectionOffset is 1-indexed! selectionOffset++; // selectionOffset is 1-indexed!
} }
} }
let hide = this.state.hide; let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query // If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command); const anyMatches = completions.some((completion) => !!completion.command.command);
hide = !anyMatches; if (anyMatches) {
hide = false;
if (this.props.onSelectionChange) {
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
}
}
this.setState({ this.setState({
completions, completions,
@ -193,8 +196,8 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
if (completionCount === 0) return; // there are no items to move the selection through if (completionCount === 0) return; // there are no items to move the selection through
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected // Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1); const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
this.setSelection(index); this.setSelection(1 + index);
} }
onEscape(e: KeyboardEvent): boolean { onEscape(e: KeyboardEvent): boolean {
@ -213,7 +216,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
hide = () => { hide = () => {
this.setState({ this.setState({
hide: true, hide: true,
selectionOffset: 0, selectionOffset: 1,
completions: [], completions: [],
completionList: [], completionList: [],
}); });
@ -232,8 +235,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
}); });
} }
onConfirmCompletion = () => {
this.onCompletionClicked(this.state.selectionOffset);
}
onCompletionClicked = (selectionOffset: number): boolean => { onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) { const count = this.countCompletions();
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false; return false;
} }

View file

@ -126,6 +126,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
super(props); super(props);
this.state = { this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
showVisualBell: false,
}; };
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -201,7 +202,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (isEmpty) { if (isEmpty) {
this.formatBarRef.current.hide(); this.formatBarRef.current.hide();
} }
this.setState({autoComplete: this.props.model.autoComplete}); this.setState({
autoComplete: this.props.model.autoComplete,
// if a change is happening then clear the showVisualBell
showVisualBell: diff ? false : this.state.showVisualBell,
});
this.historyManager.tryPush(this.props.model, selection, inputType, diff); this.historyManager.tryPush(this.props.model, selection, inputType, diff);
let isTyping = !this.props.model.isEmpty; let isTyping = !this.props.model.isEmpty;
@ -490,6 +495,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
break; break;
case Key.TAB: case Key.TAB:
case Key.ENTER:
if (!metaOrAltPressed) { if (!metaOrAltPressed) {
autoComplete.onTab(event); autoComplete.onTab(event);
handled = true; handled = true;
@ -504,7 +510,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
default: default:
return; // don't preventDefault on anything else return; // don't preventDefault on anything else
} }
} else if (event.key === Key.TAB) { } else if (!this.props.model.isEmpty && !this.state.showVisualBell && event.key === Key.TAB) {
this.tabCompleteName(event); this.tabCompleteName(event);
handled = true; handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
@ -545,6 +551,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({showVisualBell: true}); this.setState({showVisualBell: true});
model.autoComplete.close(); model.autoComplete.close();
} }
} else {
this.setState({showVisualBell: true});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -562,7 +570,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => {
this.modifiedFlag = true; this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion); // this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({completionIndex}); this.setState({completionIndex});
}; };
@ -679,6 +687,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}; };
const {completionIndex} = this.state; const {completionIndex} = this.state;
const hasAutocomplete = Boolean(this.state.autoComplete);
let activeDescendant;
if (hasAutocomplete && completionIndex >= 0) {
activeDescendant = generateCompletionDomId(completionIndex);
}
return (<div className={wrapperClasses}> return (<div className={wrapperClasses}>
{ autoComplete } { autoComplete }
@ -697,10 +710,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
aria-label={this.props.label} aria-label={this.props.label}
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-autocomplete="both" aria-autocomplete="list"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={Boolean(this.state.autoComplete)} aria-expanded={hasAutocomplete}
aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined} aria-owns="mx_Autocomplete"
aria-activedescendant={activeDescendant}
dir="auto" dir="auto"
/> />
</div>); </div>);

View file

@ -74,10 +74,9 @@ export default class AutocompleteWrapperModel {
if (acComponent.countCompletions() === 0) { if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered // Force completions to show for the text currently entered
await acComponent.forceComplete(); await acComponent.forceComplete();
// Select the first item by moving "down"
await acComponent.moveSelection(+1);
} else { } else {
await acComponent.moveSelection(e.shiftKey ? -1 : +1); await acComponent.onConfirmCompletion();
this.updateCallback({close: true});
} }
} }