Creating An Input-Driven AutoFocus Directive In Angular 5.0.2
HTML form elements already provide for an "autofocus" attribute that will pull focus to an input field after it is rendered on the page. This is great for static pages; but, in my experience, using the autofocus attribute in an Angular 5 application can be a bit hit-and-miss. It will often work the first time that an input is rendered; but, it will then stop working even if that input is hidden and re-rendered. As such, I usually end-up creating an autofocus attribute directive that wraps the focus workflow in a timer. Encapsulating the autofocus in an attribute directive has the added benefit of being able to (somewhat) programmatically control which input receives focus.
Run this demo in my JavaScript Demos project on GitHub.
Most of the time, I just want to improve the behavior of the native "autofocus" attribute. So, I use "[autofocus]" as my directive's selector. But, at the same time, I like to provide some sort of input-driven behavior for certain use-cases. So, I usually provide an alternate selector like [appAutofocus] that can accept a property-value. This way, the full selector of my Angular 5 attribute directive looks like this:
selector: "[autofocus], [appAutofocus]"
... with inputs that look like this:
inputs: [ "appAutofocus" ]
This way, the "autofocus" is still treated like a stand-alone attribute. And, if I want to provide a input property value, I have switch over to the custom attribute. I think this branching strategy leads to less surprise in the code.
Now, to be honest, I don't actually understand why the native autofocus attribute stops working consistently in a dynamic single-page application (SPA). All I know is that wrapping the focus workflow in a small timer seems to help smooth out the wrinkles. In fact, the bulk of the autofocus attribute directive is just managing the timer workflow. Ultimately, we're just calling HTMLElement.focus() under the hood.
// Import the core angular services.
import { AfterContentInit } from "@angular/core";
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { OnChanges } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { SimpleChanges } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var BASE_TIMER_DELAY = 10;
@Directive({
selector: "[autofocus], [appAutofocus]",
inputs: [
"shouldFocusElement: appAutofocus",
"timerDelay: autofocusDelay"
]
})
export class AutofocusDirective implements AfterContentInit, OnChanges, OnDestroy {
public shouldFocusElement: any;
public timerDelay: number | string;
private elementRef: ElementRef;
private timer: number;
// I initialize the autofocus directive.
constructor( elementRef: ElementRef ) {
this.elementRef = elementRef;
this.shouldFocusElement = "";
this.timer = null;
this.timerDelay = BASE_TIMER_DELAY;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once after the contents have been fully initialized.
public ngAfterContentInit() : void {
// Because this directive can act on the stand-only "autofocus" attribute or
// the more specialized "appAutofocus" property, we need to check to see if the
// "shouldFocusElement" input property is the empty string. This will signify
// that the focus it not being data-driven and should be performed automatically.
if ( this.shouldFocusElement === "" ) {
this.startFocusWorkflow();
}
}
// I get called every time the input bindings are updated.
public ngOnChanges( changes: SimpleChanges ) : void {
// If the timer delay is being passed-in as a string (ie, someone is using
// attribute-input syntax, not property-input syntax), let's coalesce the
// attribute to a numeric value so that our type-annotations are consistent.
if ( changes.timerDelay && ( typeof( this.timerDelay ) !== "number" ) ) {
// If the coalesce fails, just fall-back to a sane value.
if ( isNaN( this.timerDelay = +this.timerDelay ) ) {
this.timerDelay = BASE_TIMER_DELAY;
}
}
// If the focus input is being data-driven, then we either need to start the
// focus workflow if focus is required; or, clear any existing workflow if focus
// is no longer required (so that we don't steal focus from another element).
if ( changes.shouldFocusElement ) {
( this.shouldFocusElement )
? this.startFocusWorkflow()
: this.stopFocusWorkflow()
;
}
}
// I get called once when the directive is being unmounted.
public ngOnDestroy() : void {
this.stopFocusWorkflow();
}
// ---
// PRIVATE METHODS.
// ---
// I start the timer-based workflow that will focus the current element.
private startFocusWorkflow() : void {
// If there is already a timer running for this element, just let it play out -
// resetting it at this point will only push-out the time at which the focus is
// applied to the element.
if ( this.timer ) {
return;
}
this.timer = setTimeout(
() : void => {
this.timer = null;
this.elementRef.nativeElement.focus();
},
this.timerDelay
);
}
// I stop the timer-based workflow, preventing focus from taking place.
private stopFocusWorkflow() : void {
clearTimeout( this.timer );
this.timer = null;
}
}
As you can see, after the content associated with the attribute directive's Element is initialized, we kick off a setTimeout() timer to call focus. And, of course, to be safe, we're clearing that timer when the attribute directive is destroyed (which is something you should always be doing).
Because our attribute directive has two different modes of input consumption, we're using both the ngOnChanges() and the ngAfterContentInit() life-cycle hook methods. This way, we can use the ngAfterContentInit() for the stand-alone attribute; and, we can use the ngOnChanges() for the data-driven input bindings.
Now, to see this attribute directive in action, I've created a demo in which you can toggle the display of two different sets of inputs. The first set uses the normal [autofocus] attribute. And, the second set uses the [appAutofocus] attribute with a view-model driven input binding. This way, when we toggle the second set of inputs, we can control which input receives focus:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<p>
<a (click)="toggleFirst()">Toggle First</a>
</p>
<div *ngIf="isShowingFirst">
<input type="text" autofocus placeholder="This should autofocus..." />
</div>
<p>
<a (click)="toggleSecond( 'one' )">Toggle Second ( one )</a> —
<a (click)="toggleSecond( 'two' )">Toggle Second ( two )</a> —
<a (click)="toggleSecond( 'three' )">Toggle Second ( three )</a>
</p>
<div *ngIf="isShowingSecond">
<input type="text" [appAutofocus]="( focus === 'one' )" placeholder="Field one..." />
<input type="text" [appAutofocus]="( focus === 'two' )" placeholder="Field two..." />
<input type="text" [appAutofocus]="( focus === 'three' )" placeholder="Field three..." />
Set Focus:
<a (click)="setFocus( 'one' )">one</a>,
<a (click)="setFocus( 'two' )">two</a>,
<a (click)="setFocus( 'three' )">three</a>
</div>
`
})
export class AppComponent {
public focus: string;
public isShowingFirst: boolean;
public isShowingSecond: boolean;
// I initialize the app component.
constructor() {
this.focus = "";
this.isShowingFirst = false;
this.isShowingSecond = false;
}
// ---
// PUBLIC METHODS.
// ---
// I define which field in should be focused.
public setFocus( fieldToFocus: string ) : void {
this.focus = fieldToFocus;
}
// I toggle the first set of inputs.
public toggleFirst() : void {
this.isShowingFirst = ! this.isShowingFirst;
}
// I toggle the second set of inputs.
public toggleSecond( fieldToFocus: string ) : void {
this.isShowingSecond = ! this.isShowingSecond;
this.setFocus( fieldToFocus );
}
}
As you can see, the second set of inputs all use a property-binding like:
[appAutofocus]="( focus === 'one' )"
When using this special kind of syntax, we can programmatically control which input in a group receives the focus when the parent context is rendered. And, if we run this app in the browser and toggle the "two" input, we get the following output:
As you can see, we can use the view-model to declaratively define which input will receive focus.
For the most part, I use this Angular 5 attribute directive to make sure that the [autofocus] behavior performs more consistently as I hide and show elements in a single-page application (SPA). But, the data-driven aspects of this directive also help to focus different elements based on the state of the page (though, admittedly, this second mode is a bit more theoretical for me at the moment).
Want to use code from this post? Check out the license.
Reader Comments
@All,
One other thing that I've found helpful is adding a "select" action for inputs that have "readOnly" enabled. So, for example, inside the setTimeout() above, I might add this line at the end:
if ( this.elementRef.nativeElement.readOnly ) {
. . . this.elementRef.nativeElement.select();
}
This way, if the [autofocus] attribute appears on a read-only input, it will attempt to select the text. This feels like an appropriate gesture since a read-only element would likely only have an auto-focus intent if the gesture was to allow the user to select/copy the read-only content.
Hi Ben, as usual, very good post... I implemented the directive and happily fixed my program.
Alas, I always like to test (so I've been taught by my elders...)
Sadly, my test has never been successful:
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { AutofocusDirective } from './autofocus.directive';
@Component({
template: `<input type="text" [appAutofocus]="(focus ==='one')"> `
})
class TestFocusComponent {
focus: string;
}
describe('Directive: AutoFocus', () => {
let component: TestFocusComponent;
let fixture: ComponentFixture<TestFocusComponent>;
let inputEl: DebugElement;
let focusEl: DebugElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestFocusComponent, AutofocusDirective]
});
fixture = TestBed.createComponent(TestFocusComponent);
component = fixture.componentInstance;
inputEl = fixture.debugElement.query(By.css('input'));
focusEl = fixture.debugElement.query(By.css(':focus'));
});
it('calling for focus on input', () => {
expect(fixture.debugElement.query(By.css(':focus'))).toBe(null);
component.focus = 'one';
fixture.detectChanges();
expect(inputEl).toBe(fixture.debugElement.query(By.css(':focus')));
});
});
I've tried may variants of the above and the focus element is always null, the directive works in the app and is called by the test...
Me thinks it's a timing thing.
I hope you can give me a pointer or a hint.
Thanks in advance...
Miguel Delgado
@Miguel,
I am not too familiar with testing (to be honest). I wonder if the setTimeout() is somehow causing an issue? There is, by default, a 10ms delay between the initiation of focus and the actual call to .focus(). Perhaps the test doesn't know to wait for that?
Sorry I can't be more help.
@Ben,
Thanks for the hint... will look for a way to make it for the wait...
Thanks for this and many other posts...
Great post. How does this change if you want to make this a more generic... Set focus to a modal or the like... I've toyed with it a bit, but no luck when not focusing on non-input elements.
Just to follow-up, you can set focus on non-input elements, but they must contain the attribute tabindex if they are not anchors or form elements.
Wow that was a very nice solution !!
@Jay,
Ah, good to know about the
tabindex
issue.@Dionisis,
My pleasure-I'm glad you found it helpful.