Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: focustrap standalone service #2322

Merged
merged 1 commit into from
Apr 19, 2018

Conversation

benouat
Copy link
Member

@benouat benouat commented Apr 17, 2018

PR introduces an internal service to setup a focus-trap feature to be added to any element dynamically created by a widget.

Usage context: any datepicker popup, popover, modals...
Usage example:

  • provide the service in the widget module providers: [NgbFocusTrapFactory]
  • inject it in the widget constructor: private _focusTrapFactory: NgbFocusTrapFactory
  • call the create method specifying an element, it returns the newly created NgbFocusTrap instance
this._focusTrap = this._focusTrapFactory.create(this.elementRef.nativeElement)
  • later on, when the dynamically created element is not needed, properly destroy the focustrap to release event listeners set on document: this._focusTrap.destory()

You can also decorate a node contained inside element with NgbAutofocus attribute, and specify a second boolean argument to the create() method:

<div>
  <span></span>
  <input type="text" ngbAutofocus />
  <div></div>
</div>
this._focusTrap = this._focusTrapFactory.create(this.elementRef.nativeElement, true)

this will automatically set the focus on the input when the focus gets initialised. On top it will remenber any previously focused element, and restore it when destroyed (feature needed to properly implement accessible modals)

Copy link
Member

@maxokorokov maxokorokov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, two use cases need to be addressed:

  • the case when focustrap is around something that is appended to body
  • it might be better to call this._focusInitial() when the zone is stable


/** Detect if incoming focus event should be prevented or not */
private _enforceFocus(event) {
const {shiftKey, target} = event;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shiftKey is not used

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed !

return element.querySelector(selector) as HTMLElement;
};

function dispatchEvent(element, eventName: string, props: {[key: string]: any}) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatchEvent is not used

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed !

set autofocus(value: boolean) {
if (value === true && this._previouslyFocused === null) {
this._previouslyFocused = document.activeElement as HTMLElement;
this._focusInitial();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be called synchronously, I'm not sure it will work for dynamically instantiated components.
I guess it would try to focus before the change detection is run, so should be checked

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure it might be needed. Live tests seem to show that it is working in both datepicker and modal without that change.

Nonetheless, if we would ever need such a thing, I would rather prefer creating the focusTrap inside of an ngAfterViewInit to make sure that View is created and hence available to focus.

@benouat
Copy link
Member Author

benouat commented Apr 18, 2018

@maxokorokov I pushed a new commit, and added a small change to make sure it would work properly in modal.

The issue with modal is simple. Modal are appended to the body, so when you hit tab on the last focusable element of the modal, you basically have reached the end of the document. So we never go inside the focusin in focustrap.

Fix was simple, when focustrap is created I create a focusable anchor and append it to the body.

I added a test for that also.

@benouat benouat force-pushed the focus-trap branch 2 times, most recently from d637e20 to b704777 Compare April 18, 2018 14:29
Copy link
Member

@maxokorokov maxokorokov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, @benouat, thanks!

LGTM, just a couple of questions about NgbFocusTrap API after I did another pass. No big deal, just food for thought :)

Will merge tomorrow after you decide if it's worth fixing.

Feel free to open other PRs for Datepicker/Modal if you have time for it.

* Guess the next focusable element.
* Computation is based on specific CSS selector and [tab] navigation direction
*/
get focusableElement(): HTMLElement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is neither used publicly nor in tests. Why have a public getter for it instead of a private function?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used to have tests in one of the previous version. I will make it private

*/
create(element: HTMLElement, autofocus = false): NgbFocusTrap {
const trap = new NgbFocusTrap(element, this._document, this._ngZone);
trap.autofocus = autofocus;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question for autofocus setter. Do you see anybody calling it after constructing the service?

I mean doing service = factory.create(el) and then doing service.autofocus = true later, as opposed to service = factory.create(el, true) right away ?

Copy link
Member Author

@benouat benouat Apr 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, it seems there is no added value to do that.
I will move the logic to constructor right away.

@maxokorokov
Copy link
Member

Saucelabs failing as usual, will merge when green

@benouat
Copy link
Member Author

benouat commented Apr 19, 2018

Awesome ! Thanks

@maxokorokov maxokorokov merged commit 6961cb3 into ng-bootstrap:master Apr 19, 2018
@ymeine
Copy link
Contributor

ymeine commented Apr 19, 2018

You got a bit fast on merging this one - btw my PRs are forever pending - since it has some drawbacks.

First it doesn't handle the use case of coming from the outside of the viewport. Then it doesn't handle screen readers, where aria-hidden should be used. Finally, it re-implements a whole logic upon each tab/shift+tab keystroke while some other equivalent techniques don't. For people who tend to do remarks about micro-optimizations I find this one a bit borderline.

Also, since usually the Angular way is pushed, I thought that it would have been a good use case for a directive.

Not to mention some other use cases not handled such as elements hidden because of a parent which is not displayed.

So please don't overlook team work, I've worked on this task for quite a while now (doing researches to know what would be best for this product) so I have some knowledge about it, and also have an implementation already done before which handles those mentioned use cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants