Advertisement

#16 Using the Keyboard to Navigate

In this article we will build on the work that we did in the previous article and start the process of configuring our component to respond to keyboard navigation inputs. We will do this by using our access to the DOM elements that make up the control groups to focus the appropriate elements within them. By the end of the article we will be able to cycle through all of the inputs using the 'Enter', 'Tab' and 'Shift' keys. In a future article we will fine tune this process.

You can view a completed example of the authenticator component here.

What does it mean to focus an input?

  • WebUi
    • Source
      • components
        • authenticator
          • authenticator.component.ts
          • control-group.ts
          • control-pair-collection.ts

Now that we have access to the DOM for each of our control groups I think the first thing we will start working on is navigation between our inputs. Fundamentally the way we preform navigation between them is by shifting focus from one input to another in response to user input. The only slight complication that we have in this regard is with our remember me control. If you remember in order to style the checkbox to look like it fits with our component we actually hide the input and instead are displaying a label and span styled to look like a checkbox (a).

(a) The DOM for our remember me control group where the input is actually hidden and a label is shown with a span styled to look like a checkbox.

If not for this complication we could simply find the input within the DOM for each control group and use that as the focus element. Since we cannot do this we will specify the type of the focus element within each group with the default being 'input'. Once we know the type of the element we can do the same thing we did with the group's DOM and create a lazy getter for the focus element (b). Following our previous pattern we need to allow the specifying of the focus element type within the control pair collection (c), and also within our components typescript class (d).

control-group.ts (1)

export interface IControlGroupConfig {
    ...
    focusElementType?: string;
    ...
}

export class ControlGroup {
    ...
    private readonly _focusElementType: string = null;
    ...
    private _focusElement: HTMLElement = null;
    ...
    private get focusElement() {
        if (this._focusElement !== null) {
            return this._focusElement;
        }
        this._focusElement = this._domReader.findChild<HTMLElement>(this.groupDOM, this._focusElementType);
        return this._focusElement;
    }
    ...
    constructor(config: IControlGroupConfig) {
        ...
        this._focusElementType = typeof config.focusElementType === "undefined" ? "input" : config.focusElementType;
        ...
        console.log(this.focusElement);
    }
}
(b) We need to allow for the specifying of the type of the focus element as well as create a lazy method for getting it.

control-pair-collection.ts (1)

...
export interface ICreatePairConfig {
    focusElementType?: string;
    ...
}
...
export class ControlPairCollection {
    ...
    public create = (left: ICreatePairConfig, right?: ICreatePairConfig) => {
        const leftConfig = {
            ...
            focusElementType: left.focusElementType,
            ...
        } as IControlGroupConfig;
        ...
        const pair = new ControlPair({
            ...
            right: {
                ...
                focusElementType: right.focusElementType,
                ...
            }
        });
        ...
    }
}
(c) In order to specify the type of the focus element we need to be able to pass it in to the collection when a control pair is created.

authenticator.components.ts (1)

...
export class AuthenticatorComponent ... {
    ...
    public ngAfterViewInit(): void {
        ...
        this._inputPairCollection.create({
            focusElementType: "label",
            id: this.controlRememberMeGroupId
        });
        ...
    }
    ...
}
(d) Finally we are able to specify the type of the focus element within our component's typescript file.

Once again when that is completed we should see (e) displayed within the developer console.

Console output showing we have access to the focus element for each of our control groups.
(e) Console output showing we have access to the focus element for each of our control groups.

Now that we are sure that is working we can remove the console.log(this.focusElement); statement in the constructor of our control group class.

Now we need to actual focus an input

  • WebUi
    • Source
      • components
        • authenticator
          • control-group.ts
          • control-pair.ts
          • control-pair-collection.ts

With access to the focus element it is a simple matter to create a method that will allow us to focus it (f). Following up the chain we create a focus method within the control pair (g) where we can specify which input needs to be focused by providing its id. Finally to provide a focus method that we can use on the control pair collection we just need to add the code found in (h).

control-group (2)

...
export class ControlGroup {
    ...
    public get id() {
        return this._id;
    }
    ...
    public focus = () => {
        this.focusElement.focus();
    }
}
(f) Adding a simple method to our control group that will focus the specified focus element.

control-pair.ts (1)

...
export class ControlPair {
    ...
    public focus = (id: string) => {
        switch(id) {
            case this._left.id:
                this._left.focus();
                return;
            case this._right.id:
                this._right.focus();
                return;
            default:
                throw new Error(`The provided id: ${id} does not match either left: ${this._left.id} or right: ${this._right.id} input group's id.`);
        }
    }
}
(g) Method added to our control pair class that will focus either the left or right focus element based on a provided id.

control-pair-collection.ts (2)

...
export class ControlPairCollection {
    ...
    public focus = (id: string) => {
        this._pairs[id].focus(id);
    }
}
(h) Method added to our control pair collection class to focus an element with a provided id.
Advertisement
  • WebUi
    • Source
      • components
        • authenticator
          • authenticator.component.pug
          • authenticator.component.ts

Next in our template we need to make a couple changes to the label of the remember me control group (i). First we need to add a tab index attribute so that we are able to focus the label and secondly we need to bind the keyup event just as we have previously done with the other control groups. With that done we will turn to our component's typescript file and modify the onKeyUp method. We created the default case for this method in a previous article and now it's time to specify a particular case for the enter key (j). In this case we will also create a helper method that we will use for both the enter and tab keys.

authenticator.component.pug (1)

...
div.authenticator
    div.inputs-container#inputs-container
        ...
        form(...)
            ...
            div.form-control-group.control-group-remember-me(...)
                ...
                label(..., tabindex="-1", (keyup)="onKeyUp($event, controlRememberMeKey)")
(i) Couple of changes to the label of the remember me control group. The inclusion of the tabindex allows us to focus the label and we need the keyup binding for keyboard navigation.

authenticator.component.ts (2)

...
export class AuthenticatorComponent ... {
    ...
    public onKeyUp = (...) => {
        switch(event.key) {
            case "Enter":
                this.tabOrEnter();
                break;
            default:
                ...
                break;
        }
    }
    ...
    private tabOrEnter = () => {
        this._controlPairCollection.focus(this.controlRememberMeGroupId);
    }
}
(j) Beginning code to respond to the release of the enter key. The helper method will also be used to respond to the user pressing the tab key.

Once those changes are save and we return to the browser if we focus any of the inputs and press the enter key the result should be that we are focusing the remember me label (k).

Image showing the remember me label being focused after focusing one of the inputs and
            pressing enter.
(k) Image showing the remember me label being focused after focusing one of the inputs and pressing enter.

Our buttons need some attention

  • WebUi
    • Source
      • components
        • authenticator
          • authenticator.component.pug
          • authenticator.component.ts

Next we will turn to updating our buttons. We will start by creating a mixin that we can use to insert the buttons (l). In order to use this mixin though we will need to add a few getters for the ids and keys to our component typescript file (m). In addition to this we will also need access to the DOM for the buttons we will also add a couple private fields and getters to handle this.

authenticator.component.pug (2)

...
mixin button(key, text)
    button([attr.id]=`${key}Id`, (click)=`onClickButton('${key}')`) #{text}
...
div.authenticator
    div.inputs-container#inputs-container
        ...
        form(...)
            ...
            div.input-buttons
                +button("buttonReset", "Reset")
                +button("buttonSend", "Send")
(l) Simplifying our lives by creating a mixin for our buttons.

authenticator.component.ts (3)

...
export class AuthenticatorComponent ... {
    ...
    private _buttonResetDOM: HTMLButtonElement = null;
    private _buttonSendDOM: HTMLButtonElement = null;
    ...
    private get buttonResetDOM() {
        if (this._buttonResetDOM !== null) {
            return this._buttonResetDOM;
        }
        this._buttonResetDOM = this._domReader.findChildById<HTMLButtonElement>(this._elementRef.nativeElement, this.buttonResetId);
        return this._buttonResetDOM;
    }

    private get buttonSendDOM() {
        if (this._buttonSendDOM !== null) {
            return this._buttonSendDOM;
        }
        this._buttonSendDOM = this._domReader.findChildById<HTMLButtonElement>(this._elementRef.nativeElement, this.buttonSendId);
        return this._buttonSendDOM;
    }
    ...
    public get buttonResetId() { return "button-reset"; }
    public get buttonSendId() { return "button-send"; }
    ...
    public get buttonResetKey() { return "buttonReset"; }
    public get buttonSendKey() { return "buttonSend"; }
    ...
    public ngAfterViewInit(): void {
        ...
        console.log(this.buttonResetDOM);
        console.log(this.buttonSendDOM);
    }
    ...
    public onClickButton = (key: string) => {
        switch (key) {
            case this.buttonResetKey:
                this.onClickReset();
                break;
            default:
                break;
        }
    }
    ...
    
    private onClickReset = () => {
        ...
    }
    ...
}
(m) In order to use the buttons after creating the mixin we need to specify the ids and keys for the buttons as well as a common click handler. We also provide access to their DOM elements through private getters.

Again to test that we have access to the DOM elements would should see (n) in the output of the browser console window.

Console output showing that we do have access to the DOM elements of the buttons.
(n) Console output showing that we do have access to the DOM elements of the buttons.

Once we have checked that they are working we can remove the console.log(this.buttonResetDOM); and console.log(this.buttonSendDOM); statements from the ngAfterViewInit method.

Starting Navigation

  • WebUi
    • Source
      • components
        • authenticator
          • authenticator.component.pug
          • authenticator.component.ts

In order to handle navigation with the keyboard properly we need to respond to key down events as well as the key up events. To do this we once again return to our template file and bind an event handler to the keydown event just as we have done previously with the keyup event (o). Once we have defined the onKeyDown and modified the onKeyUp method we just need to modify the tabOrEnter method to correct cycle through the controls depending on whether the user is also pressing the shift key or not (p). This will just be a starting point as we will need to return to the navigation method to handle whether or not the current form control is valid or not. We will not want to go from the password to confirm password input if the password form control is invalid for instance.

authenticator.component.pug (3)

...            
mixin button(key, text)
    button(..., (keydown)=`onKeyDown($event, '${key}')`) #{text}
...
mixin formControlGroup(...)
    div.form-control-group(...)
        ...
        div.control-group(...)
            input(..., (keydown)=`onKeyDown($event, '${key}')`)
...
div.authenticator
    div.inputs-container#inputs-container
        ...
        form(...)
            ...
            div.form-control-group.control-group-remember-me(...)
                ...
                label(..., (keydown)="onKeyDown($event, controlRememberMeKey)")
(o) In order to correctly handle the user pressing the tab key we need to bind an event handler to the key down event.

authenticator.component.ts (4)

...
export class AuthenticatorComponent ... {
    ...
    public onKeyDown = (event: KeyboardEvent, key: string) => {
        switch (event.key) {
            case "Tab":
                const preventDefault = this.tabOrEnter(event, key);
                if (preventDefault) {
                    event.preventDefault();
                }
                break;
            default:
                break;
            }
    }

    public onKeyUp = (...) => {
        switch(event.key) {
            case "Enter":
                this.tabOrEnter(event, key);
                break;
            default:
                ...
                break;
        }
    }
    ...
    private tabOrEnter = (event: KeyboardEvent, key: string) => {
        let preventDefault = true;
        switch (key) {
            case this.controlUsernameKey:
                if (event.shiftKey) {
                    preventDefault = false;
                } else {
                    this._controlPairCollection.focus(this.controlPasswordGroupId);
                }
                break;
            case this.controlPasswordKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlUsernameGroupId);
                } else {
                    this._controlPairCollection.focus(this.controlPasswordConfirmGroupId);
                }
                break;
            case this.controlPasswordConfirmKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlPasswordGroupId);
                } else {
                    this._controlPairCollection.focus(this.controlEmailGroupId);
                }
                break;
            case this.controlEmailKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlPasswordConfirmGroupId);
                } else {
                    this._controlPairCollection.focus(this.controlEmailConfirmGroupId);
                }
                break;
            case this.controlEmailConfirmKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlEmailGroupId);
                } else {
                    this._controlPairCollection.focus(this.controlRememberMeGroupId);
                }
                break;
            case this.controlRememberMeKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlEmailConfirmGroupId);
                } else {
                    if (this.authForm.valid) {
                        this.buttonSendDOM.focus();
                    } else {
                        this.buttonResetDOM.focus();
                    }
                }
                break;
            case this.buttonResetKey:
                if (event.shiftKey) {
                    this._controlPairCollection.focus(this.controlRememberMeGroupId);
                } else {
                    preventDefault = false;
                }
                break;
            case this.buttonSendKey:
                if (event.shiftKey) {
                    this.buttonResetDOM.focus();
                } else {
                    preventDefault = false;
                }
                break;
        }
        return preventDefault;
    }
}
(p) Starting code to handle keyboard navigation between the control groups of our component.
Exciton Interactive LLC
Advertisement