Help with handling keypress events

I’ve written a small autocomplete custom element. It’s made up of an md-input and an md-collection.

What I would like to achieve is that when the user presses the ArrowDown key the focus moves to to the collection, and the user can use the arrow keys to move up and down the list, finally pressing Enter to commit the selection.

I’ve never worked with keypress events before. I tried to add a keydown.delegate="keydown(event)" to the md-input but that just eats anything typed into the input. I’m also not sure how to move the focus to the collection.

The other issue is how to ensure that the collection covers the content below, i.e. setting the z-index.

countries.html

<template>
    <style>
        .autocomplete .input-field,
        .autocomplete .input-field input {
            margin-bottom: 0;
        }

        .autocomplete div.collection {
            margin-top: 0;
        }
    </style>

    <div class="autocomplete">
        <md-input type="text" label="Country" value.bind="searchText"></md-input>

        <md-collection if.bind="showCollection" style="z-index: 1000">
            <md-collection-item repeat.for="item of filteredCountries" click.delegate="autocomplete(item)">${item.name}
            </md-collection-item>
        </md-collection>
    </div>
</template>

countries.ts

@autoinject
export class CountriesCustomElement {
    @bindable({defaultBindingMode: bindingMode.twoWay}) public value: string;
    @observable public state: IState;
    public countries: IListItem[] = [];
    public filteredCountries: IListItem[] = [];
    public showCollection = false;

    @observable protected searchText = "";
    private selectedCountry: IListItem;
    private subscriptions: Subscription[] = [];

    constructor(
        private store: Store<IState>,
        private countriesService: CountriesService
    ) { }

    public async attached() {
        this.subscriptions.push(this.store.state.subscribe(state => this.state = state));
        await this.countriesService.loadCountries();
    }

    public detached() {
        this.subscriptions.forEach(c => c.unsubscribe());
    }

    protected stateChanged(state: IState) {
        this.countries = state.countries.list;
        if (this.value) {
            this.selectedCountry = this.countries.find(c => c.id === this.value);
            this.searchText = this.selectedCountry.name;
            this.showCollection = false;
        }
    }

    protected autocomplete(item: IListItem) {
        this.selectedCountry = item;
        this.searchText = item.name;
        this.showCollection = false;
        this.value = item.id;
    }


    protected searchTextChanged(search: string) {
        const value = search.toLowerCase();
        if (value.length === 0) {
            this.filteredCountries = [];
            this.showCollection = false;
        } else {
            this.filteredCountries = this.countries.filter(c => c.id.toLocaleLowerCase().includes(value) || c.name.toLowerCase().includes(value));
            this.showCollection = this.filteredCountries.length > 0;
        }
    }
}
2 Likes

The keydown function should return true otherwise the native behavior is surpressed which results in nothing written

1 Like

Could you perhaps point me in the direction of some example code and I’ll take it from there?

1 Like

There are two different keypress.

  1. when your input in in focus, input will capture the arrow keys. You need special event handle on the input.
  2. when there is no focused input, global keypress event is needed for arrow keys.

I am not sure about your md-input (whether it allows to attach event trigger), but if you have a native input, you can do following.

<input value.bind="searchText" keydown.trigger="keyDownInSearch($event)">
<collection-list></collection-list>

Use aurelia-combo to easily handle global keypress.
This is literally my source code, you need to change filteredUsers to your filteredCountries.

  keyDownInSearch(e) {
    if (e.keyCode === 38) { // up key
      $(e.target).blur();
      this.selectPrevious();
      return false; // preventDefault
    } else if (e.keyCode === 40) { // down key
      $(e.target).blur();
      this.selectNext();
      return false; // preventDefault
    }

    return true; // normal input, do not preventDefault
  }

  @combo('up')
  selectPrevious() {
    if (!this.selected) return;

    const idx = _.indexOf(this.filteredUsers, this.selected);
    if (idx > 0) {
      this.selected = this.filteredUsers[idx - 1];
      this.scrollIfNeeded();
    }
  }

  @combo('down')
  selectNext() {
    if (!this.selected) {
      if (this.filteredUsers.length) {
        this.selected = this.filteredUsers[0];
        this.scrollIfNeeded();
      }
      return;
    }

    const idx = _.indexOf(this.filteredUsers, this.selected);
    if (idx >= 0 && idx < this.filteredUsers.length - 1) {
      this.selected = this.filteredUsers[idx + 1];
     this.scrollIfNeeded();
    }
  }

  @combo('enter')
  submitIfSelected() {
    if (this.selected) {
      // ...
    }
  }

  // You need to modify this dom code to deal with your view.
  // You might not need this feature at all on small list.
  scrollIfNeeded() {
    this.taskQueue.queueMicroTask(() => {
      const dom = $(this.list).find('li.active').get(0);
      if (dom) {
        if (dom.scrollIntoViewIfNeeded) {
          dom.scrollIntoViewIfNeeded();
        } else if (dom.scrollIntoView) {
          dom.scrollIntoView();
        }
      }
    });
  }
3 Likes