#16 Using the Keyboard to Navigate
Thursday, January 18, 2018
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.
Parts
- Part 30: User Database
- Part 29: Canonical Url
- Part 28: Razor Page Handlers
- Part 27: Send Screen Finished
- Part 26: Stroke Dashoffset
- Part 25: Send Screen
- Part 24: Forgot Link Behaviour
- Part 23: Animating Controls (cont.)
- Part 22: Animating Controls
- Part 21: Hiding Control Groups
- Part 20: ModelState Errors (cont.)
- Part 19: ModelState Errors
- Part 18: Animating Info Pages (cont.)
- Part 17: Animating Info Pages
- Part 16: Keyboard Navigation
- Part 15: Accessing the DOM
- Part 14: All About the Username
- Part 13: CSRF Attacks
- Part 12: Http Requests
- Part 11: HMR
- Part 10: Color Inputs And Buttons
- Part 9: Animating Sub-Actions
- Part 8: Form Validation (cont.)
- Part 7: Form Validation
- Part 6: Form Group
- Part 5: Authenticator Validators
- Part 4: Authenticator Inputs
- Part 3: First Angular Component
- Part 2: Webpack
- Part 1: Beginning
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
-
authenticator
-
components
-
Source
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).
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);
}
}
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,
...
}
});
...
}
}
authenticator.components.ts (1)
...
export class AuthenticatorComponent ... {
...
public ngAfterViewInit(): void {
...
this._inputPairCollection.create({
focusElementType: "label",
id: this.controlRememberMeGroupId
});
...
}
...
}
Once again when that is completed we should see (e) displayed within the developer console.
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
-
authenticator
-
components
-
Source
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();
}
}
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.`);
}
}
}
control-pair-collection.ts (2)
...
export class ControlPairCollection {
...
public focus = (id: string) => {
this._pairs[id].focus(id);
}
}
-
WebUi
-
Source
-
components
-
authenticator
- authenticator.component.pug
- authenticator.component.ts
-
authenticator
-
components
-
Source
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)")
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);
}
}
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).
Our buttons need some attention
-
WebUi
-
Source
-
components
-
authenticator
- authenticator.component.pug
- authenticator.component.ts
-
authenticator
-
components
-
Source
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")
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 = () => {
...
}
...
}
Again to test that we have access to the DOM elements would should see (n) in the output of the browser console window.
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
-
authenticator
-
components
-
Source
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)")
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;
}
}