Merge pull request #5659 from matrix-org/t3chguy/a11y/composer-list-autocomplete
This commit is contained in:
commit
df282807b1
15 changed files with 133 additions and 142 deletions
|
@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
|
||||||
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
action: AutocompleteAction.CompleteOrNextSelection,
|
action: AutocompleteAction.ForceComplete,
|
||||||
keyCombo: {
|
keyCombo: {
|
||||||
key: Key.TAB,
|
key: Key.TAB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: AutocompleteAction.CompleteOrNextSelection,
|
action: AutocompleteAction.ForceComplete,
|
||||||
keyCombo: {
|
keyCombo: {
|
||||||
key: Key.TAB,
|
key: Key.TAB,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
action: AutocompleteAction.Complete,
|
||||||
keyCombo: {
|
keyCombo: {
|
||||||
key: Key.TAB,
|
key: Key.ENTER,
|
||||||
shiftKey: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
action: AutocompleteAction.Complete,
|
||||||
keyCombo: {
|
keyCombo: {
|
||||||
key: Key.TAB,
|
key: Key.ENTER,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
shiftKey: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -52,13 +52,11 @@ export enum MessageComposerAction {
|
||||||
|
|
||||||
/** Actions for text editing autocompletion */
|
/** Actions for text editing autocompletion */
|
||||||
export enum AutocompleteAction {
|
export enum AutocompleteAction {
|
||||||
/**
|
/** Accepts chosen autocomplete selection */
|
||||||
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
|
Complete = 'Complete',
|
||||||
* selection.
|
/** Accepts chosen autocomplete selection or,
|
||||||
*/
|
* if the autocompletion window is not shown, open the window and select the first selection */
|
||||||
CompleteOrPrevSelection = 'ApplySelection',
|
ForceComplete = 'ForceComplete',
|
||||||
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
|
|
||||||
CompleteOrNextSelection = 'CompleteOrNextSelection',
|
|
||||||
/** Move to the previous autocomplete selection */
|
/** Move to the previous autocomplete selection */
|
||||||
PrevSelection = 'PrevSelection',
|
PrevSelection = 'PrevSelection',
|
||||||
/** Move to the next autocomplete selection */
|
/** Move to the next autocomplete selection */
|
||||||
|
|
|
@ -27,11 +27,11 @@ export interface ICommand {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class AutocompleteProvider {
|
export default abstract class AutocompleteProvider {
|
||||||
commandRegex: RegExp;
|
commandRegex: RegExp;
|
||||||
forcedCommandRegex: RegExp;
|
forcedCommandRegex: RegExp;
|
||||||
|
|
||||||
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||||
if (commandRegex) {
|
if (commandRegex) {
|
||||||
if (!commandRegex.global) {
|
if (!commandRegex.global) {
|
||||||
throw new Error('commandRegex must have global flag set');
|
throw new Error('commandRegex must have global flag set');
|
||||||
|
@ -93,23 +93,16 @@ export default class AutocompleteProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompletions(
|
abstract getCompletions(
|
||||||
query: string,
|
query: string,
|
||||||
selection: ISelectionRange,
|
selection: ISelectionRange,
|
||||||
force = false,
|
force: boolean,
|
||||||
limit = -1,
|
limit: number,
|
||||||
): Promise<ICompletion[]> {
|
): Promise<ICompletion[]>;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): string {
|
abstract getName(): string;
|
||||||
return 'Default Provider';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
|
abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null;
|
||||||
console.error('stub; should be implemented in subclasses');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
||||||
shouldForceComplete(): boolean {
|
shouldForceComplete(): boolean {
|
||||||
|
|
|
@ -96,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_block"
|
className="mx_Autocomplete_Completion_container_block"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("Command Autocomplete")}
|
aria-label={_t("Command Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -116,7 +116,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("Community Autocomplete")}
|
aria-label={_t("Community Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -105,7 +105,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_block"
|
className="mx_Autocomplete_Completion_container_block"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("DuckDuckGo Results")}
|
aria-label={_t("DuckDuckGo Results")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -139,7 +139,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_pill"
|
className="mx_Autocomplete_Completion_container_pill"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("Emoji Autocomplete")}
|
aria-label={_t("Emoji Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -70,7 +70,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("Notification Autocomplete")}
|
aria-label={_t("Notification Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -134,7 +134,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("Room Autocomplete")}
|
aria-label={_t("Room Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -181,7 +181,7 @@ export default class UserProvider extends AutocompleteProvider {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx_Autocomplete_Completion_container_pill"
|
className="mx_Autocomplete_Completion_container_pill"
|
||||||
role="listbox"
|
role="presentation"
|
||||||
aria-label={_t("User Autocomplete")}
|
aria-label={_t("User Autocomplete")}
|
||||||
>
|
>
|
||||||
{ completions }
|
{ completions }
|
||||||
|
|
|
@ -529,24 +529,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||||
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
|
||||||
// The above condition is crafted to _allow_ characters with Shift
|
// The above condition is crafted to _allow_ characters with Shift
|
||||||
// already pressed (but not the Shift key down itself).
|
// already pressed (but not the Shift key down itself).
|
||||||
|
|
||||||
const isClickShortcut = ev.target !== document.body &&
|
const isClickShortcut = ev.target !== document.body &&
|
||||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||||
|
|
||||||
// Do not capture the context menu key to improve keyboard accessibility
|
// We explicitly allow alt to be held due to it being a common accent modifier.
|
||||||
if (ev.key === Key.CONTEXT_MENU) {
|
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
|
||||||
return;
|
// move focus to the composer so the user can re-type the dead key correctly.
|
||||||
}
|
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
|
||||||
|
|
||||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
// If the user is entering a printable character outside of an input field
|
||||||
|
// redirect it to the composer for them.
|
||||||
|
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
|
||||||
// synchronous dispatch so we focus before key generates input
|
// synchronous dispatch so we focus before key generates input
|
||||||
dis.fire(Action.FocusSendMessageComposer, true);
|
dis.fire(Action.FocusSendMessageComposer, true);
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
// we should *not* preventDefault() here as
|
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
|
||||||
// that would prevent typing in the now-focussed composer
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import Autocompleter from '../../../autocomplete/Autocompleter';
|
import Autocompleter from '../../../autocomplete/Autocompleter';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
|
||||||
const COMPOSER_SELECTED = 0;
|
|
||||||
const MAX_PROVIDER_MATCHES = 20;
|
const MAX_PROVIDER_MATCHES = 20;
|
||||||
|
|
||||||
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
|
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
|
||||||
|
@ -34,9 +33,9 @@ interface IProps {
|
||||||
// the query string for which to show autocomplete suggestions
|
// the query string for which to show autocomplete suggestions
|
||||||
query: string;
|
query: string;
|
||||||
// method invoked with range and text content when completion is confirmed
|
// method invoked with range and text content when completion is confirmed
|
||||||
onConfirm: (ICompletion) => void;
|
onConfirm: (completion: ICompletion) => void;
|
||||||
// method invoked when selected (if any) completion changes
|
// method invoked when selected (if any) completion changes
|
||||||
onSelectionChange?: (ICompletion, number) => void;
|
onSelectionChange?: (partIndex: number) => void;
|
||||||
selection: ISelectionRange;
|
selection: ISelectionRange;
|
||||||
// The room in which we're autocompleting
|
// The room in which we're autocompleting
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -71,7 +70,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,
|
||||||
|
@ -86,7 +85,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
this.applyNewProps();
|
this.applyNewProps();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
|
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
|
||||||
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
|
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
|
||||||
this.autocompleter.destroy();
|
this.autocompleter.destroy();
|
||||||
this.autocompleter = new Autocompleter(this.props.room);
|
this.autocompleter = new Autocompleter(this.props.room);
|
||||||
|
@ -104,7 +103,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
this.autocompleter.destroy();
|
this.autocompleter.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(query: string, selection: ISelectionRange) {
|
private complete(query: string, selection: ISelectionRange): Promise<void> {
|
||||||
this.queryRequested = query;
|
this.queryRequested = query;
|
||||||
if (this.debounceCompletionsRequest) {
|
if (this.debounceCompletionsRequest) {
|
||||||
clearTimeout(this.debounceCompletionsRequest);
|
clearTimeout(this.debounceCompletionsRequest);
|
||||||
|
@ -115,7 +114,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,
|
||||||
});
|
});
|
||||||
|
@ -135,7 +134,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processQuery(query: string, selection: ISelectionRange) {
|
private processQuery(query: string, selection: ISelectionRange): Promise<void> {
|
||||||
return this.autocompleter.getCompletions(
|
return this.autocompleter.getCompletions(
|
||||||
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
|
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
|
||||||
).then((completions) => {
|
).then((completions) => {
|
||||||
|
@ -147,30 +146,35 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processCompletions(completions: IProviderCompletions[]) {
|
private processCompletions(completions: IProviderCompletions[]): void {
|
||||||
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(selectionOffset - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
completions,
|
completions,
|
||||||
|
@ -182,25 +186,25 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSelection(): boolean {
|
public hasSelection(): boolean {
|
||||||
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
|
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
countCompletions(): number {
|
public countCompletions(): number {
|
||||||
return this.state.completionList.length;
|
return this.state.completionList.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// called from MessageComposerInput
|
// called from MessageComposerInput
|
||||||
moveSelection(delta: number) {
|
public moveSelection(delta: number): void {
|
||||||
const completionCount = this.countCompletions();
|
const completionCount = this.countCompletions();
|
||||||
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 {
|
public onEscape(e: KeyboardEvent): boolean {
|
||||||
const completionCount = this.countCompletions();
|
const completionCount = this.countCompletions();
|
||||||
if (completionCount === 0) {
|
if (completionCount === 0) {
|
||||||
// autocomplete is already empty, so don't preventDefault
|
// autocomplete is already empty, so don't preventDefault
|
||||||
|
@ -213,16 +217,16 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide = () => {
|
private hide = (): void => {
|
||||||
this.setState({
|
this.setState({
|
||||||
hide: true,
|
hide: true,
|
||||||
selectionOffset: 0,
|
selectionOffset: 1,
|
||||||
completions: [],
|
completions: [],
|
||||||
completionList: [],
|
completionList: [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
forceComplete() {
|
public forceComplete(): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
forceComplete: true,
|
forceComplete: true,
|
||||||
|
@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onCompletionClicked = (selectionOffset: number): boolean => {
|
public onConfirmCompletion = (): void => {
|
||||||
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
|
this.onCompletionClicked(this.state.selectionOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onCompletionClicked = (selectionOffset: number): boolean => {
|
||||||
|
const count = this.countCompletions();
|
||||||
|
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -246,10 +255,10 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
setSelection(selectionOffset: number) {
|
private setSelection(selectionOffset: number): void {
|
||||||
this.setState({ selectionOffset, hide: false });
|
this.setState({ selectionOffset, hide: false });
|
||||||
if (this.props.onSelectionChange) {
|
if (this.props.onSelectionChange) {
|
||||||
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
|
this.props.onSelectionChange(selectionOffset - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -292,7 +301,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
return completions.length > 0 ? (
|
return completions.length > 0 ? (
|
||||||
<div key={i} className="mx_Autocomplete_ProviderSection">
|
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
|
||||||
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
|
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
|
||||||
{ completionResult.provider.renderCompletions(completions) }
|
{ completionResult.provider.renderCompletions(completions) }
|
||||||
</div>
|
</div>
|
||||||
|
@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||||
}).filter((completion) => !!completion);
|
}).filter((completion) => !!completion);
|
||||||
|
|
||||||
return !this.state.hide && renderedCompletions.length > 0 ? (
|
return !this.state.hide && renderedCompletions.length > 0 ? (
|
||||||
<div className="mx_Autocomplete" ref={this.containerRef}>
|
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
|
||||||
{ renderedCompletions }
|
{ renderedCompletions }
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
|
@ -133,6 +133,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.state = {
|
this.state = {
|
||||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||||
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
||||||
|
showVisualBell: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||||
|
@ -215,7 +216,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;
|
||||||
|
@ -435,7 +440,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
const model = this.props.model;
|
const model = this.props.model;
|
||||||
let handled = false;
|
let handled = false;
|
||||||
|
|
||||||
if (this.state.surroundWith && document.getSelection().type != "Caret") {
|
if (this.state.surroundWith && document.getSelection().type !== "Caret") {
|
||||||
// This surrounds the selected text with a character. This is
|
// This surrounds the selected text with a character. This is
|
||||||
// intentionally left out of the keybinding manager as the keybinds
|
// intentionally left out of the keybinding manager as the keybinds
|
||||||
// here shouldn't be changeable
|
// here shouldn't be changeable
|
||||||
|
@ -456,6 +461,44 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
|
||||||
|
if (model.autoComplete?.hasCompletions()) {
|
||||||
|
const autoComplete = model.autoComplete;
|
||||||
|
switch (autocompleteAction) {
|
||||||
|
case AutocompleteAction.ForceComplete:
|
||||||
|
case AutocompleteAction.Complete:
|
||||||
|
autoComplete.confirmCompletion();
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
case AutocompleteAction.PrevSelection:
|
||||||
|
autoComplete.selectPreviousSelection();
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
case AutocompleteAction.NextSelection:
|
||||||
|
autoComplete.selectNextSelection();
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
case AutocompleteAction.Cancel:
|
||||||
|
autoComplete.onEscape(event);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return; // don't preventDefault on anything else
|
||||||
|
}
|
||||||
|
} else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) {
|
||||||
|
// there is no current autocomplete window, try to open it
|
||||||
|
this.tabCompleteName();
|
||||||
|
handled = true;
|
||||||
|
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
||||||
|
this.formatBarRef.current.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MessageComposerAction.FormatBold:
|
case MessageComposerAction.FormatBold:
|
||||||
|
@ -507,42 +550,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
handled = true;
|
handled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (handled) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
|
|
||||||
if (model.autoComplete && model.autoComplete.hasCompletions()) {
|
|
||||||
const autoComplete = model.autoComplete;
|
|
||||||
switch (autocompleteAction) {
|
|
||||||
case AutocompleteAction.CompleteOrPrevSelection:
|
|
||||||
case AutocompleteAction.PrevSelection:
|
|
||||||
autoComplete.selectPreviousSelection();
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
case AutocompleteAction.CompleteOrNextSelection:
|
|
||||||
case AutocompleteAction.NextSelection:
|
|
||||||
autoComplete.selectNextSelection();
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
case AutocompleteAction.Cancel:
|
|
||||||
autoComplete.onEscape(event);
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // don't preventDefault on anything else
|
|
||||||
}
|
|
||||||
} else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection
|
|
||||||
|| autocompleteAction === AutocompleteAction.CompleteOrNextSelection) {
|
|
||||||
// there is no current autocomplete window, try to open it
|
|
||||||
this.tabCompleteName();
|
|
||||||
handled = true;
|
|
||||||
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
|
||||||
this.formatBarRef.current.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -577,6 +584,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);
|
||||||
|
@ -592,9 +601,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.props.model.autoComplete.onComponentConfirm(completion);
|
this.props.model.autoComplete.onComponentConfirm(completion);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
|
private onAutoCompleteSelectionChange = (completionIndex: number): void => {
|
||||||
this.modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
|
||||||
this.setState({ completionIndex });
|
this.setState({ completionIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -718,6 +726,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 }
|
||||||
|
@ -736,10 +749,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"
|
||||||
aria-disabled={this.props.disabled}
|
aria-disabled={this.props.disabled}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -32,7 +32,6 @@ export type GetAutocompleterComponent = () => Autocomplete;
|
||||||
export type UpdateQuery = (test: string) => Promise<void>;
|
export type UpdateQuery = (test: string) => Promise<void>;
|
||||||
|
|
||||||
export default class AutocompleteWrapperModel {
|
export default class AutocompleteWrapperModel {
|
||||||
private queryPart: Part;
|
|
||||||
private partIndex: number;
|
private partIndex: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -45,10 +44,6 @@ export default class AutocompleteWrapperModel {
|
||||||
|
|
||||||
public onEscape(e: KeyboardEvent): void {
|
public onEscape(e: KeyboardEvent): void {
|
||||||
this.getAutocompleterComponent().onEscape(e);
|
this.getAutocompleterComponent().onEscape(e);
|
||||||
this.updateCallback({
|
|
||||||
replaceParts: [this.partCreator.plain(this.queryPart.text)],
|
|
||||||
close: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public close(): void {
|
public close(): void {
|
||||||
|
@ -64,7 +59,8 @@ export default class AutocompleteWrapperModel {
|
||||||
return ac && ac.countCompletions() > 0;
|
return ac && ac.countCompletions() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onEnter(): void {
|
public async confirmCompletion(): Promise<void> {
|
||||||
|
await this.getAutocompleterComponent().onConfirmCompletion();
|
||||||
this.updateCallback({ close: true });
|
this.updateCallback({ close: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,8 +72,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,25 +84,10 @@ export default class AutocompleteWrapperModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
|
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
|
||||||
// cache the typed value and caret here
|
|
||||||
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
|
|
||||||
this.queryPart = part;
|
|
||||||
this.partIndex = pos.index;
|
this.partIndex = pos.index;
|
||||||
return this.updateQuery(part.text);
|
return this.updateQuery(part.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onComponentSelectionChange(completion: ICompletion): void {
|
|
||||||
if (!completion) {
|
|
||||||
this.updateCallback({
|
|
||||||
replaceParts: [this.queryPart],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.updateCallback({
|
|
||||||
replaceParts: this.partForCompletion(completion),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onComponentConfirm(completion: ICompletion): void {
|
public onComponentConfirm(completion: ICompletion): void {
|
||||||
this.updateCallback({
|
this.updateCallback({
|
||||||
replaceParts: this.partForCompletion(completion),
|
replaceParts: this.partForCompletion(completion),
|
||||||
|
|
|
@ -237,7 +237,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// not _autoComplete, only there if active part is autocomplete part
|
// not autoComplete, only there if active part is autocomplete part
|
||||||
if (this.autoComplete) {
|
if (this.autoComplete) {
|
||||||
return this.autoComplete.onPartUpdate(part, pos);
|
return this.autoComplete.onPartUpdate(part, pos);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue