Skip to content

Commit

Permalink
[UI Framework] Add KuiKeyboardAccessible component to UI Framework. (e…
Browse files Browse the repository at this point in the history
…lastic#11743)

* Move accessibleClickKeys service into UI Framework.
* Add KuiKeyboardAccessible component to UI Framework.
* Change KuiKeyboardAccessible and kbn-accessible-click to propagate events. This mirrors mouse click behavior.
  • Loading branch information
cjcenizal authored May 23, 2017
1 parent 099178a commit 66c6db0
Show file tree
Hide file tree
Showing 15 changed files with 457 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
>
<div
data-test-subj="field-{{::field.name}}"
ng-click="toggleDetails(field)"
ng-click="onClickToggleDetails($event, field)"
kbn-accessible-click
class="sidebar-item-title discover-sidebar-item"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ app.directive('discoverField', function ($compile) {
}
};

$scope.onClickToggleDetails = function onClickToggleDetails($event, field) {
// Do nothing if the event originated from a child.
if ($event.currentTarget !== $event.target) {
$event.preventDefault();
}

$scope.toggleDetails(field);
};

$scope.toggleDetails = function (field, recompute) {
if (_.isUndefined(field.details) || recompute) {
$scope.onShowDetails(field, recompute);
Expand Down
30 changes: 1 addition & 29 deletions src/ui/public/accessibility/__tests__/kbn_accessible_click.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import '../kbn_accessible_click';
import {
ENTER_KEY,
SPACE_KEY,
} from '../accessible_click_keys';
} from 'ui_framework/services';

describe('kbnAccessibleClick directive', () => {
let $compile;
Expand Down Expand Up @@ -104,32 +104,4 @@ describe('kbnAccessibleClick directive', () => {
sinon.assert.calledOnce(scope.handleClick);
});
});

describe(`doesn't call ng-click when the element being interacted with is a child`, () => {
let scope;
let child;

beforeEach(function () {
scope = $rootScope.$new();
scope.handleClick = sinon.stub();
const html = `<div ng-click="handleClick()" kbn-accessible-click></div>`;
const element = $compile(html)(scope);
child = angular.element(`<button></button>`);
element.append(child);
});

it(`on ENTER keyup`, () => {
const e = angular.element.Event('keyup'); // eslint-disable-line new-cap
e.keyCode = ENTER_KEY;
child.trigger(e);
expect(scope.handleClick.callCount).to.be(0);
});

it(`on SPACE keyup`, () => {
const e = angular.element.Event('keyup'); // eslint-disable-line new-cap
e.keyCode = SPACE_KEY;
child.trigger(e);
expect(scope.handleClick.callCount).to.be(0);
});
});
});
12 changes: 1 addition & 11 deletions src/ui/public/accessibility/kbn_accessible_click.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import {
accessibleClickKeys,
SPACE_KEY,
} from './accessible_click_keys';
} from 'ui_framework/services';
import { uiModules } from 'ui/modules';

uiModules.get('kibana')
Expand All @@ -30,11 +30,6 @@ uiModules.get('kibana')
restrict: 'A',
controller: $element => {
$element.on('keydown', e => {
// If the user is interacting with a different element, then we don't need to do anything.
if (e.currentTarget !== e.target) {
return;
}

// Prevent a scroll from occurring if the user has hit space.
if (e.keyCode === SPACE_KEY) {
e.preventDefault();
Expand Down Expand Up @@ -69,11 +64,6 @@ uiModules.get('kibana')
}

element.on('keyup', e => {
// If the user is interacting with a different element, then we don't need to do anything.
if (e.currentTarget !== e.target) {
return;
}

// Support keyboard accessibility by emulating mouse click on ENTER or SPACE keypress.
if (accessibleClickKeys[e.keyCode]) {
// Delegate to the click handler on the element (assumed to be ng-click).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`KuiKeyboardAccessible adds accessibility attributes tabindex and role 1`] = `
<div
role="button"
tabindex="0"
/>
`;

exports[`KuiKeyboardAccessible doesn't override pre-existing accessibility attributes role 1`] = `
<div
role="submit"
tabindex="0"
/>
`;

exports[`KuiKeyboardAccessible doesn't override pre-existing accessibility attributes tabindex 1`] = `
<div
role="button"
tabindex="1"
/>
`;
1 change: 1 addition & 0 deletions ui_framework/components/accessibility/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { KuiKeyboardAccessible } from './keyboard_accessible';
102 changes: 102 additions & 0 deletions ui_framework/components/accessibility/keyboard_accessible.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Interactive elements must be able to receive focus.
*
* Ideally, this means using elements that are natively keyboard accessible (<a href="">,
* <input type="button">, or <button>). Note that links should be used when navigating and buttons
* should be used when performing an action on the page.
*
* If you need to use a <div>, <p>, or <a> without the href attribute, then you need to allow
* them to receive focus and to respond to keyboard input. The workaround is to:
*
* - Give the element tabindex="0" so that it can receive keyboard focus.
* - Add a JavaScript onkeyup event handler that triggers element functionality if the Enter key
* is pressed while the element is focused. This is necessary because some browsers do not trigger
* onclick events for such elements when activated via the keyboard.
* - If the item is meant to function as a button, the onkeyup event handler should also detect the
* Spacebar in addition to the Enter key, and the element should be given role="button".
*
* Wrap any of these elements in this component to automatically do the above.
*/

import {
Component,
cloneElement,
} from 'react';

import {
ENTER_KEY,
SPACE_KEY,
} from '../../services';

export class KuiKeyboardAccessible extends Component {
onKeyDown = e => {
// Prevent a scroll from occurring if the user has hit space.
if (e.keyCode === SPACE_KEY) {
e.preventDefault();
}
}

onKeyUp = e => {
// Support keyboard accessibility by emulating mouse click on ENTER or SPACE keypress.
if (e.keyCode === ENTER_KEY || e.keyCode === SPACE_KEY) {
// Delegate to the click handler on the element.
this.props.children.props.onClick(e);
}
}

applyKeyboardAccessibility(child) {
// Add attributes required for accessibility unless they are already specified.
const props = {
tabIndex: '0',
role: 'button',
...child.props,
onKeyDown: this.onKeyDown,
onKeyUp: this.onKeyUp,
};

return cloneElement(child, props);
}

render() {
return this.applyKeyboardAccessibility(this.props.children);
}
}

const keyboardInaccessibleElement = (props, propName, componentName) => {
const child = props.children;

if (!child) {
throw new Error(`${componentName} needs to wrap an element with which the user interacts.`);
}

// The whole point of this component is to hack in functionality that native buttons provide
// by default.
if (child.type === 'button') {
throw new Error(`${componentName} doesn't need to be used on a button.`);
}

if (child.type === 'a' && child.props.href !== undefined) {
throw new Error(`${componentName} doesn't need to be used on a link if it has a href attribute.`);
}

// We're emulating a click action, so we should already have a regular click handler defined.
if (!child.props.onClick) {
throw new Error(`${componentName} needs to wrap an element which has an onClick prop assigned.`);
}

if (typeof child.props.onClick !== 'function') {
throw new Error(`${componentName}'s child's onClick prop needs to be a function.`);
}

if (child.props.onKeyDown) {
throw new Error(`${componentName}'s child can't have an onKeyDown prop because the implementation will override it.`);
}

if (child.props.onKeyUp) {
throw new Error(`${componentName}'s child can't have an onKeyUp prop because the implementation will override it.`);
}
};

KuiKeyboardAccessible.propTypes = {
children: keyboardInaccessibleElement,
};
Loading

0 comments on commit 66c6db0

Please sign in to comment.