Skip to content

Commit

Permalink
feat(floating-menu): add close-on-blur behavior (#219)
Browse files Browse the repository at this point in the history
* feat(floating-menu): add close-on-blur behavior

This change introduces track-blur mix-in which calls `handleBlur` method when component's root element loses focus.

With this change, floating menu closes when it loses focus, and sets the focus back to the trigger button.

* chore(floating-menu): add -1 tabindex to floating menu
  • Loading branch information
asudoh authored and chrisdhanaraj committed Jul 20, 2017
1 parent c15b441 commit eafa055
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 27 deletions.
2 changes: 1 addition & 1 deletion demo/views/demo-all.dust
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
</nav>
{/links}

<div class="demo--container">
<div class="demo--container" data-floating-menu-container>
{?links}
{#links}
{?items}
Expand Down
17 changes: 5 additions & 12 deletions src/components/dropdown/dropdown.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import mixin from '../../globals/js/misc/mixin';
import createComponent from '../../globals/js/mixins/create-component';
import initComponentBySearch from '../../globals/js/mixins/init-component-by-search';
import trackBlur from '../../globals/js/mixins/track-blur';
import eventMatches from '../../globals/js/misc/event-matches';
import on from '../../globals/js/misc/on';

class Dropdown extends mixin(createComponent, initComponentBySearch) {
class Dropdown extends mixin(createComponent, initComponentBySearch, trackBlur) {
/**
* A selector with drop downs.
* @extends CreateComponent
Expand All @@ -28,8 +29,6 @@ class Dropdown extends mixin(createComponent, initComponentBySearch) {
*/
this.hDocumentClick = on(this.element.ownerDocument, 'click', (event) => { this._toggle(event); });

this._setCloseOnBlur();

this.element.addEventListener('keydown', (event) => { this._handleKeyDown(event); });
this.element.addEventListener('click', (event) => {
const item = eventMatches(event, this.options.selectorItem);
Expand Down Expand Up @@ -162,16 +161,10 @@ class Dropdown extends mixin(createComponent, initComponentBySearch) {
}

/**
* Sets an event handler to document for "close on blur" behavior.
* Closes the dropdown menu if this component loses focus.
*/
_setCloseOnBlur() {
const hasFocusin = 'onfocusin' in window;
const focusinEventName = hasFocusin ? 'focusin' : 'focus';
this.hFocusIn = on(this.element.ownerDocument, focusinEventName, (event) => {
if (!this.element.contains(event.target)) {
this.element.classList.remove('bx--dropdown--open');
}
}, !hasFocusin);
handleBlur() {
this.element.classList.remove('bx--dropdown--open');
}

/**
Expand Down
18 changes: 17 additions & 1 deletion src/components/floating-menu/floating-menu.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import mixin from '../../globals/js/misc/mixin';
import createComponent from '../../globals/js/mixins/create-component';
import eventedShowHideState from '../../globals/js/mixins/evented-show-hide-state';
import trackBlur from '../../globals/js/mixins/track-blur';
import getLaunchingDetails from '../../globals/js/misc/get-launching-details';
import optimizedResize from '../../globals/js/misc/resize';

class FloatingMenu extends mixin(createComponent, eventedShowHideState) {
class FloatingMenu extends mixin(createComponent, eventedShowHideState, trackBlur) {
/**
* Floating menu.
* @extends CreateComponent
Expand Down Expand Up @@ -41,6 +43,18 @@ class FloatingMenu extends mixin(createComponent, eventedShowHideState) {
}
}

/**
* Focuses back on the trigger button if this component loses focus.
*/
handleBlur(event) {
if (this.element.classList.contains(this.options.classShown)) {
this.changeState('hidden', getLaunchingDetails(event));
if (this.element.contains(event.relatedTarget) && event.target !== this.options.refNode) {
this.options.refNode.focus();
}
}
}

/**
* @private
* @returns {Element} The element that this menu should be placed to.
Expand Down Expand Up @@ -162,6 +176,7 @@ class FloatingMenu extends mixin(createComponent, eventedShowHideState) {
}
this._getContainer().appendChild(this.element);
this._place();
(this.element.querySelector(this.options.selectorPrimaryFocus) || this.element).focus();
}
if (state === 'hidden' && this.hResize) {
this.hResize.release();
Expand All @@ -180,6 +195,7 @@ class FloatingMenu extends mixin(createComponent, eventedShowHideState) {

static options = {
selectorContainer: '[data-floating-menu-container]',
selectorPrimaryFocus: '[data-floating-menu-primary-focus]',
attribDirection: 'data-floating-menu-direction',
classShown: '', // Should be provided from options arg in constructor
classRefShown: '', // Should be provided from options arg in constructor
Expand Down
8 changes: 4 additions & 4 deletions src/components/overflow-menu/overflow-menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<circle cx="2" cy="10" r="2"></circle>
<circle cx="2" cy="18" r="2"></circle>
</svg>
<ul class="bx--overflow-menu-options">
<ul class="bx--overflow-menu-options" tabindex="-1">
<li class="bx--overflow-menu-options__option">
<button class="bx--overflow-menu-options__btn">Stop app</button>
<button class="bx--overflow-menu-options__btn" data-floating-menu-primary-focus>Stop app</button>
</li>
<li class="bx--overflow-menu-options__option">
<button class="bx--overflow-menu-options__btn">Restart app</button>
Expand All @@ -29,9 +29,9 @@
<circle cx="2" cy="10" r="2"></circle>
<circle cx="2" cy="18" r="2"></circle>
</svg>
<ul class="bx--overflow-menu-options bx--overflow-menu--flip">
<ul class="bx--overflow-menu-options bx--overflow-menu--flip" tabindex="-1">
<li class="bx--overflow-menu-options__option">
<button class="bx--overflow-menu-options__btn">Stop app</button>
<button class="bx--overflow-menu-options__btn" data-floating-menu-primary-focus>Stop app</button>
</li>
<li class="bx--overflow-menu-options__option">
<button class="bx--overflow-menu-options__btn">Restart app</button>
Expand Down
38 changes: 38 additions & 0 deletions src/globals/js/mixins/track-blur.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import on from '../misc/on';

export default function (ToMix) {
class TrackBlur extends ToMix {
/**
* Mix-in class to add an handler for losing focus.
* @param {HTMLElement} element The element working as this component.
* @param {Object} [options] The component options.
*/
constructor(element, options) {
super(element, options);
const hasFocusin = 'onfocusin' in window;
const focusinEventName = hasFocusin ? 'focusin' : 'focus';
this.hFocusIn = on(this.element.ownerDocument, focusinEventName, (event) => {
if (!this.element.contains(event.target)) {
this.handleBlur(event);
}
}, !hasFocusin);
}

/**
* The method called when this component loses focus.
* @abstract
*/
handleBlur() {
throw new Error('Components inheriting TrackBlur mix-in must implement handleBlur() method.');
}

release() {
if (this.hFocusIn) {
this.hFocusIn = this.hFocusIn.release();
}
super.release();
}
}

return TrackBlur;
}
95 changes: 92 additions & 3 deletions tests/spec/floating-menu_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,28 @@ describe('Test floating menu', function () {
});

describe('Setting menu direction', function () {
let menu;

it('Should use bottom by default', function () {
expect(new FloatingMenu(document.createElement('div')).options.direction).to.equal('bottom');
expect((menu = new FloatingMenu(document.createElement('div'))).options.direction).to.equal('bottom');
});

it('Should read the direction from data-floating-menu-direction', function () {
const element = document.createElement('div');
element.dataset.floatingMenuDirection = 'left';
expect(new FloatingMenu(element).options.direction).to.equal('left');
expect((menu = new FloatingMenu(element)).options.direction).to.equal('left');
});

it('Should use options.direction over data-floating-menu-direction', function () {
const element = document.createElement('div');
element.dataset.floatingMenuDirection = 'left';
expect(new FloatingMenu(element, { direction: 'right' }).options.direction).to.equal('right');
expect((menu = new FloatingMenu(element, { direction: 'right' })).options.direction).to.equal('right');
});

afterEach(function () {
if (menu) {
menu = menu.release();
}
});
});

Expand Down Expand Up @@ -388,4 +396,85 @@ describe('Test floating menu', function () {
}
});
});

describe('Managing focus', function () {
let menu;
let element;
let primaryFocusNode;
let refNode;
let input;
let spyFocusRefNode;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = HTML;

before(function () {
element = tempDiv.querySelector('ul.bx--overflow-menu-options');
document.body.appendChild(element);
primaryFocusNode = element.querySelector('[data-floating-menu-primary-focus]');
refNode = document.createElement('div');
document.body.appendChild(refNode);
input = document.createElement('input');
document.body.appendChild(input);
menu = new FloatingMenu(element, {
refNode,
classShown: 'my-floating-menu-open',
classRefShown: 'my-floating-menu-trigger-open',
});
spyFocusRefNode = sinon.spy(refNode, 'focus');
});

it('Should close menu when both the trigger button and the menu lose focus', function () {
primaryFocusNode.focus();
menu.changeState('shown', {});
input.focus();
expect(element.classList.contains('bx--overflow-menu-options--open')).to.be.false;
});

it('Should focus back on the trigger button when floating menu loses focus', function () {
const hasFocusin = 'onfocusin' in window;
const focusinEventName = hasFocusin ? 'focusin' : 'focus';
primaryFocusNode.focus();
menu.changeState('shown', {});
// Firefox does not fire `onfocus` event with `input.focus()` call, presumably when the window does not have focus
input.dispatchEvent(Object.assign(new CustomEvent(focusinEventName, { bubbles: true }), {
relatedTarget: primaryFocusNode,
}));
expect(spyFocusRefNode).to.have.been.calledOnce;
});

afterEach(function () {
element.classList.remove('bx--overflow-menu-options--open');
if (spyFocusRefNode) {
spyFocusRefNode.reset();
}
});

after(function () {
if (spyFocusRefNode) {
spyFocusRefNode.restore();
spyFocusRefNode = null;
}
if (menu) {
menu = menu.release();
}
if (input) {
if (input.parentNode) {
input.parentNode.removeChild(input);
}
input = null;
}
if (refNode) {
if (refNode.parentNode) {
refNode.parentNode.removeChild(refNode);
}
refNode = null;
}
if (element) {
if (element.parentNode) {
element.parentNode.removeChild(element);
}
element = null;
}
});
});
});
49 changes: 43 additions & 6 deletions tests/spec/overflow-menu_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,17 @@ describe('Test Overflow menu', function () {

it('Should open one menu on a single click event', function () {
element1.dispatchEvent(new CustomEvent('click', { bubbles: true }));
expect(element1.classList.contains('bx--overflow-menu--open')).to.be.true;
expect(element2.classList.contains('bx--overflow-menu--open')).to.be.false;
expect(element3.classList.contains('bx--overflow-menu--open')).to.be.false;
expect(element1.classList.contains('bx--overflow-menu--open'), '1st overflow menu').to.be.true;
expect(element2.classList.contains('bx--overflow-menu--open'), '2nd overflow menu').to.be.false;
expect(element3.classList.contains('bx--overflow-menu--open'), '3rd overflow menu').to.be.false;
});

it('Should open one menu on multiple click events', function () {
element1.dispatchEvent(new CustomEvent('click', { bubbles: true }));
element2.dispatchEvent(new CustomEvent('click', { bubbles: true }));
expect(element1.classList.contains('bx--overflow-menu--open')).to.be.false;
expect(element2.classList.contains('bx--overflow-menu--open')).to.be.true;
expect(element3.classList.contains('bx--overflow-menu--open')).to.be.false;
expect(element1.classList.contains('bx--overflow-menu--open'), '1st overflow menu').to.be.false;
expect(element2.classList.contains('bx--overflow-menu--open'), '2nd overflow menu').to.be.true;
expect(element3.classList.contains('bx--overflow-menu--open'), '3rd overflow menu').to.be.false;
});

afterEach(function () {
Expand All @@ -158,6 +158,43 @@ describe('Test Overflow menu', function () {
});
});

describe('Managing focus', function () {
let menu;
let element;
let firstItemNode;
let spyFocusFirstItemNode;
const container = document.createElement('div');
container.innerHTML = HTML;

before(function () {
document.body.appendChild(container);
element = document.querySelector('.bx--overflow-menu');
firstItemNode = element.querySelector('[data-floating-menu-primary-focus]');
spyFocusFirstItemNode = sinon.spy(firstItemNode, 'focus');
menu = new OverflowMenu(element);
});

it('Should focus on the floating menu when the menu is open', function () {
element.dispatchEvent(new CustomEvent('click', { bubbles: true }));
expect(spyFocusFirstItemNode).to.have.been.calledOnce;
});

afterEach(function () {
if (spyFocusFirstItemNode) {
spyFocusFirstItemNode.reset();
}
});

after(function () {
if (spyFocusFirstItemNode) {
spyFocusFirstItemNode.restore();
spyFocusFirstItemNode = null;
}
menu.release();
document.body.removeChild(container);
});
});

describe('Managing instances', function () {
let menu;
let element;
Expand Down

0 comments on commit eafa055

Please sign in to comment.