mirror of
https://github.com/element-hq/element-web.git
synced 2024-12-15 09:01:30 +03:00
Rework composer autocomplete to be smarter and not trap tab
This commit is contained in:
parent
5c1b38a48c
commit
c05eceef7f
3 changed files with 43 additions and 22 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>);
|
||||||
|
|
|
@ -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});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue