Merge pull request #5659 from matrix-org/t3chguy/a11y/composer-list-autocomplete

This commit is contained in:
Michael Telatynski 2021-08-13 16:55:46 +01:00 committed by GitHub
commit df282807b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 133 additions and 142 deletions

View file

@ -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,
}, },
}, },
{ {

View file

@ -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 */

View file

@ -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 {

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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
} }
} }
}; };

View file

@ -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;

View file

@ -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}
/> />

View file

@ -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),

View file

@ -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);
} }