Skip to content

Commit

Permalink
[fixed] Keyboard accessibility for anchors serving as buttons
Browse files Browse the repository at this point in the history
Bootstrap uses a lot of styling that is specifically targeting anchor
tags that may also serve in the capacity as a button. Unfortunately
since Bootstrap does not style said buttons we have to use an anchor
tag instead. But in order to maintain keyboard functionality for
accessibility concerns even those anchor tags must provide an href.

The solution is to add an internal `SafeAnchor` component which ensures
that something exists for the href attribute, and calls
`event.preventDefault()` when the anchor is clicked. It will then
continue to invoke any additional `onClick` handler provided.
  • Loading branch information
mtscout6 committed Jun 1, 2015
1 parent e54fcd8 commit 9c09e2a
Show file tree
Hide file tree
Showing 11 changed files with 157 additions and 35 deletions.
6 changes: 3 additions & 3 deletions src/ListGroupItem.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { cloneElement } from 'react';
import BootstrapMixin from './BootstrapMixin';
import classNames from 'classnames';

import SafeAnchor from './SafeAnchor';

const ListGroupItem = React.createClass({
mixins: [BootstrapMixin],
Expand Down Expand Up @@ -51,12 +51,12 @@ const ListGroupItem = React.createClass({

renderAnchor(classes) {
return (
<a
<SafeAnchor
{...this.props}
className={classNames(this.props.className, classes)}
>
{this.props.header ? this.renderStructuredContent() : this.props.children}
</a>
</SafeAnchor>
);
},

Expand Down
6 changes: 3 additions & 3 deletions src/MenuItem.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import SafeAnchor from './SafeAnchor';

const MenuItem = React.createClass({
propTypes: {
Expand All @@ -15,7 +16,6 @@ const MenuItem = React.createClass({

getDefaultProps() {
return {
href: '#',
active: false
};
},
Expand All @@ -29,9 +29,9 @@ const MenuItem = React.createClass({

renderAnchor() {
return (
<a onClick={this.handleClick} href={this.props.href} target={this.props.target} title={this.props.title} tabIndex="-1">
<SafeAnchor onClick={this.handleClick} href={this.props.href} target={this.props.target} title={this.props.title} tabIndex="-1">
{this.props.children}
</a>
</SafeAnchor>
);
},

Expand Down
14 changes: 4 additions & 10 deletions src/NavItem.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import BootstrapMixin from './BootstrapMixin';
import SafeAnchor from './SafeAnchor';

const NavItem = React.createClass({
mixins: [BootstrapMixin],
Expand All @@ -15,12 +16,6 @@ const NavItem = React.createClass({
target: React.PropTypes.string
},

getDefaultProps() {
return {
href: '#'
};
},

render() {
let {
disabled,
Expand All @@ -38,8 +33,7 @@ const NavItem = React.createClass({
href,
title,
target,
onClick: this.handleClick,
ref: 'anchor'
onClick: this.handleClick
};

if (href === '#') {
Expand All @@ -48,9 +42,9 @@ const NavItem = React.createClass({

return (
<li {...props} className={classNames(props.className, classes)}>
<a {...linkProps}>
<SafeAnchor {...linkProps}>
{ children }
</a>
</SafeAnchor>
</li>
);
},
Expand Down
14 changes: 4 additions & 10 deletions src/PageItem.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import SafeAnchor from './SafeAnchor';

const PageItem = React.createClass({

Expand All @@ -14,12 +15,6 @@ const PageItem = React.createClass({
eventKey: React.PropTypes.any
},

getDefaultProps() {
return {
href: '#'
};
},

render() {
let classes = {
'disabled': this.props.disabled,
Expand All @@ -31,14 +26,13 @@ const PageItem = React.createClass({
<li
{...this.props}
className={classNames(this.props.className, classes)}>
<a
<SafeAnchor
href={this.props.href}
title={this.props.title}
target={this.props.target}
onClick={this.handleSelect}
ref="anchor">
onClick={this.handleSelect}>
{this.props.children}
</a>
</SafeAnchor>
</li>
);
},
Expand Down
38 changes: 38 additions & 0 deletions src/SafeAnchor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

/**
* Note: This is intended as a stop-gap for accessibility concerns that the
* Bootstrap CSS does not address as they have styled anchors and not buttons
* in many cases.
*/
export default class SafeAnchor extends React.Component {
constructor(props) {
super(props);

this.handleClick = this.handleClick.bind(this);
}

handleClick(event) {
if (this.props.href === undefined) {
event.preventDefault();
}

if (this.props.onClick) {
this.props.onClick(event);
}
}

render() {
return (
<a role={this.props.href ? undefined : 'button'}
{...this.props}
onClick={this.handleClick}
href={this.props.href || ''}/>
);
}
}

SafeAnchor.propTypes = {
href: React.PropTypes.string,
onClick: React.PropTypes.func
};
8 changes: 4 additions & 4 deletions src/SubNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import classNames from 'classnames';
import ValidComponentChildren from './utils/ValidComponentChildren';
import createChainedFunction from './utils/createChainedFunction';
import BootstrapMixin from './BootstrapMixin';
import SafeAnchor from './SafeAnchor';

const SubNav = React.createClass({
mixins: [BootstrapMixin],
Expand Down Expand Up @@ -99,14 +100,13 @@ const SubNav = React.createClass({

return (
<li {...this.props} className={classNames(this.props.className, classes)}>
<a
<SafeAnchor
href={this.props.href}
title={this.props.title}
target={this.props.target}
onClick={this.handleClick}
ref="anchor">
onClick={this.handleClick}>
{this.props.text}
</a>
</SafeAnchor>
<ul className="nav">
{ValidComponentChildren.map(this.props.children, this.renderNavItem)}
</ul>
Expand Down
5 changes: 3 additions & 2 deletions src/Thumbnail.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import classSet from 'classnames';
import BootstrapMixin from './BootstrapMixin';
import SafeAnchor from './SafeAnchor';

const Thumbnail = React.createClass({
mixins: [BootstrapMixin],
Expand All @@ -16,9 +17,9 @@ const Thumbnail = React.createClass({

if(this.props.href) {
return (
<a {...this.props} href={this.props.href} className={classSet(this.props.className, classes)}>
<SafeAnchor {...this.props} href={this.props.href} className={classSet(this.props.className, classes)}>
<img src={this.props.src} alt={this.props.alt} />
</a>
</SafeAnchor>
);
}
else {
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import Pager from './Pager';
import Popover from './Popover';
import ProgressBar from './ProgressBar';
import Row from './Row';
import SafeAnchor from './SafeAnchor';
import SplitButton from './SplitButton';
import SubNav from './SubNav';
import TabbedArea from './TabbedArea';
Expand Down Expand Up @@ -97,6 +98,7 @@ export default {
Popover,
ProgressBar,
Row,
SafeAnchor,
SplitButton,
SubNav,
TabbedArea,
Expand Down
4 changes: 2 additions & 2 deletions test/NavSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ describe('Nav', function () {
</Nav>
);

let items = ReactTestUtils.scryRenderedComponentsWithType(instance, NavItem);
let items = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A');

ReactTestUtils.Simulate.click(items[1].refs.anchor);
ReactTestUtils.Simulate.click(items[1]);
});

it('Should set the correct item active by href', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/PageItemSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('PageItem', function () {
it('Should call "onSelect" when item is clicked', function (done) {
function handleSelect(key, href) {
assert.equal(key, 1);
assert.equal(href, '#');
assert.equal(href, undefined);
done();
}
let instance = ReactTestUtils.renderIntoDocument(
Expand Down
93 changes: 93 additions & 0 deletions test/SafeAnchorSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import ReactTestUtils from 'react/lib/ReactTestUtils';
import SafeAnchor from '../src/SafeAnchor';

describe('SafeAnchor', function() {
it('renders an anchor tag', function() {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor />);
const node = React.findDOMNode(instance);

node.tagName.should.equal('A');
});

it('forwards arbitrary props to the anchor', function() {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor herpa='derpa' />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.herpa.should.equal('derpa');
});

it('forwards provided href', function() {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor href='http://google.com' />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.href.should.equal('http://google.com');
});

it('ensures that an href is provided', function() {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.href.should.equal('');
});

it('forwards onClick handler', function(done) {
const handleClick = (event) => {
done();
};
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor onClick={handleClick} />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

ReactTestUtils.Simulate.click(anchor);
});

it('prevents default when no href is provided', function(done) {
const handleClick = (event) => {
event.defaultPrevented.should.be.true;
done();
};
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor onClick={handleClick} />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

ReactTestUtils.Simulate.click(anchor);
});

it('does not prevent default when href is provided', function(done) {
const handleClick = (event) => {
expect(event.defaultPrevented).to.not.be.ok;
done();
};
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor href='#' onClick={handleClick} />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

ReactTestUtils.Simulate.click(anchor);
});

it('forwards provided role', function () {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor role='test' />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.role.should.equal('test');
});

it('forwards provided role with href', function () {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor role='test' href='http://google.com' />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.role.should.equal('test');
});

it('set role=button with no provided href', function () {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

anchor.props.role.should.equal('button');
});

it('sets no role with provided href', function () {
const instance = ReactTestUtils.renderIntoDocument(<SafeAnchor href='http://google.com' />);
const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A');

expect(anchor.props.role).to.be.undefined;
});
});

0 comments on commit 9c09e2a

Please sign in to comment.