Skip to content
This repository has been archived by the owner on Feb 29, 2020. It is now read-only.

Commit

Permalink
fix(a11y): Add basic a11y support to section-info-option (#3069)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dan Mosedale authored and Mardak committed Aug 2, 2017
1 parent 8d800d6 commit 9ea0f8f
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 13 deletions.
56 changes: 48 additions & 8 deletions system-addon/content-src/components/Sections/Sections.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,62 @@
const React = require("react");
const {connect} = require("react-redux");
const {FormattedMessage} = require("react-intl");
const {injectIntl, FormattedMessage} = require("react-intl");
const Card = require("content-src/components/Card/Card");
const Topics = require("content-src/components/Topics/Topics");

class Section extends React.Component {
constructor(props) {
super(props);
this.onInfoEnter = this.onInfoEnter.bind(this);
this.onInfoLeave = this.onInfoLeave.bind(this);
this.state = {infoActive: false};
}

onInfoEnter() {
this.setState({infoActive: true});
}

onInfoLeave(event) {
// If we have a related target, check to see if it is within the current
// target (section-info-option) to keep infoActive true. False otherwise.
this.setState({
infoActive: event && event.relatedTarget && (
event.relatedTarget.compareDocumentPosition(event.currentTarget) &
Node.DOCUMENT_POSITION_CONTAINS)
});
}

render() {
const {id, eventSource, title, icon, rows, infoOption, emptyState, dispatch, maxCards, contextMenuOptions} = this.props;
const {id, eventSource, title, icon, rows, infoOption, emptyState, dispatch, maxCards, contextMenuOptions, intl} = this.props;
const initialized = rows && rows.length > 0;
const shouldShowTopics = (id === "TopStories" && this.props.topics && this.props.read_more_endpoint);

const infoOptionIconA11yAttrs = {
"aria-haspopup": "true",
"aria-controls": "info-option",
"aria-expanded": this.state.infoActive ? "true" : "false",
"role": "note",
"tabIndex": 0
};

const sectionInfoTitle = intl.formatMessage({id: "section_info_option"});

// <Section> <-- React component
// <section> <-- HTML5 element
return (<section>
<div className="section-top-bar">
<h3 className="section-title"><span className={`icon icon-small-spacer icon-${icon}`} /><FormattedMessage {...title} /></h3>
{infoOption && <span className="section-info-option">
<span className="sr-only"><FormattedMessage id="section_info_option" /></span>
<img className="info-option-icon" />
{infoOption &&
<span className="section-info-option"
onBlur={this.onInfoLeave}
onFocus={this.onInfoEnter}
onMouseOut={this.onInfoLeave}
onMouseOver={this.onInfoEnter}>
<img className="info-option-icon" title={sectionInfoTitle}
{...infoOptionIconA11yAttrs} />
<div className="info-option">
{infoOption.header &&
<div className="info-option-header">
<div className="info-option-header" role="heading">
<FormattedMessage {...infoOption.header} />
</div>}
{infoOption.body &&
Expand Down Expand Up @@ -51,17 +88,20 @@ class Section extends React.Component {
}
}

const SectionIntl = injectIntl(Section);

class Sections extends React.Component {
render() {
const sections = this.props.Sections;
return (
<div className="sections-list">
{sections.map(section => <Section key={section.id} {...section} dispatch={this.props.dispatch} />)}
{sections.map(section => <SectionIntl key={section.id} {...section} dispatch={this.props.dispatch} />)}
</div>
);
}
}

module.exports = connect(state => ({Sections: state.Sections}))(Sections);
module.exports._unconnected = Sections;
module.exports.Section = Section;
module.exports.SectionIntl = SectionIntl;
module.exports._unconnectedSection = Section;
4 changes: 2 additions & 2 deletions system-addon/content-src/components/Sections/_Sections.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
display: inline-block;
}

.section-info-option div {
.section-info-option .info-option {
visibility: hidden;
opacity: 0;
transition: visibility 0.2s, opacity 0.2s ease-out;
transition-delay: 0.5s;
}

.section-info-option:hover div {
.info-option-icon[aria-expanded="true"] + .info-option {
visibility: visible;
opacity: 1;
transition: visibility 0.2s, opacity 0.2s ease-out;
Expand Down
76 changes: 73 additions & 3 deletions system-addon/test/unit/content-src/components/Sections.test.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const React = require("react");
const {shallow} = require("enzyme");
const {shallowWithIntl} = require("test/unit/utils");
const {_unconnected: Sections, Section} = require("content-src/components/Sections/Sections");
const {_unconnected: Sections, _unconnectedSection: Section, SectionIntl} =
require("content-src/components/Sections/Sections");

describe("<Sections>", () => {
let wrapper;
Expand All @@ -12,14 +14,82 @@ describe("<Sections>", () => {
initialized: false,
rows: []
}));
wrapper = shallowWithIntl(<Sections Sections={FAKE_SECTIONS} />);
wrapper = shallow(<Sections Sections={FAKE_SECTIONS} />);
});
it("should render a Sections element", () => {
assert.ok(wrapper.exists());
});
it("should render a Section for each one passed in props.Sections", () => {
const sectionElems = wrapper.find(Section);
const sectionElems = wrapper.find(SectionIntl);
assert.lengthOf(sectionElems, 5);
sectionElems.forEach((section, i) => assert.equal(section.props().id, FAKE_SECTIONS[i].id));
});
});

describe("<Section>", () => {
const FAKE_SECTION = {
id: `foo_bar_1`,
title: `Foo Bar 1`,
rows: [{link: "http://localhost", index: 0}],
infoOption: {}
};

it("should render info-option-icon with a tabindex", () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

// Because this is a shallow render, we need to use the casing
// that react understands (tabIndex), rather than the one used by
// the browser itself (tabindex).
assert.lengthOf(wrapper.find(".info-option-icon[tabIndex]"), 1);
});

it("should render info-option-icon with a role of 'note'", () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

assert.lengthOf(wrapper.find('.info-option-icon[role="note"]'), 1);
});

it("should render info-option-icon with a title attribute", () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

assert.lengthOf(wrapper.find(".info-option-icon[title]"), 1);
});

it("should render info-option-icon with aria-haspopup", () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

assert.lengthOf(wrapper.find('.info-option-icon[aria-haspopup="true"]'),
1);
});

it('should render info-option-icon with aria-controls="info-option"', () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

assert.lengthOf(
wrapper.find('.info-option-icon[aria-controls="info-option"]'), 1);
});

it('should render info-option-icon aria-expanded["false"] by default', () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

assert.lengthOf(wrapper.find('.info-option-icon[aria-expanded="false"]'),
1);
});

it("should render info-option-icon w/aria-expanded when moused over", () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);

wrapper.find(".section-info-option").simulate("mouseover");

assert.lengthOf(wrapper.find('.info-option-icon[aria-expanded="true"]'), 1);
});

it('should render info-option-icon w/aria-expanded["false"] when moused out', () => {
const wrapper = shallowWithIntl(<Section {...FAKE_SECTION} />);
wrapper.find(".section-info-option").simulate("mouseover");

wrapper.find(".section-info-option").simulate("mouseout");

assert.lengthOf(wrapper.find('.info-option-icon[aria-expanded="false"]'), 1);
});
});

0 comments on commit 9ea0f8f

Please sign in to comment.