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

Accessible screen reader live region and notifications #359

Merged
merged 19 commits into from
Jul 24, 2019
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
598441b
:building_construction: Add initial a11y notifications helper/manager
cee-chen Jul 17, 2019
9ba4b60
:sparkles: Set up SearchDriver with a11y notification state
cee-chen Jul 17, 2019
df72b65
:white_check_mark: Add tests for new SearchDriver logic
cee-chen Jul 18, 2019
dff5b7b
:recycle: Update Facets +More action to use a11yNotify
cee-chen Jul 18, 2019
29d4b83
:recycle: Refactor _updateSearchResults to use a11yNotify
cee-chen Jul 18, 2019
b880a95
:pencil: Add documentation
cee-chen Jul 18, 2019
da835da
:package: 1.1 release - turn off a11yNotifications by default
cee-chen Jul 19, 2019
90ff38c
:ok_hand: Address PR feedback
cee-chen Jul 22, 2019
d66a57e
Merge branch 'master' into a11y-notifications
constancecchen Jul 22, 2019
95927e6
:ok_hand: Address PR feedback re: optionsCount naming
cee-chen Jul 22, 2019
50fbb96
:ok_hand: Address PR feedback re: SearchDriver test helpers
cee-chen Jul 22, 2019
d37ac77
:ok_hand: Address documentation feedback
cee-chen Jul 22, 2019
20df0c7
Merge branch 'a11y-notifications' of github.com:constancecchen/search…
cee-chen Jul 22, 2019
bbb25a6
:ok_hand: Address PR feedback re: console error
cee-chen Jul 23, 2019
b9df4f8
:ok_hand: Address boolean var naming feedback
cee-chen Jul 23, 2019
8a3d913
Merge branch 'master' into a11y-notifications
constancecchen Jul 23, 2019
70d8379
:ok_hand: Address PR feedback re: moveFilters location
cee-chen Jul 23, 2019
6dced84
:ok_hand: Address PR feedback re: obj mutation
cee-chen Jul 24, 2019
6968597
Merge branch 'master' into a11y-notifications
JasonStoltz Jul 24, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions ADVANCED.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion examples/sandbox/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ const config = {
size: 4
}
},
apiConnector: connector
apiConnector: connector,
a11yNotifications: true
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
};

export default function App() {
Expand Down
24 changes: 16 additions & 8 deletions packages/react-search-ui/src/containers/Facet.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export class FacetContainer extends Component {
// Actions
addFilter: PropTypes.func.isRequired,
removeFilter: PropTypes.func.isRequired,
setFilter: PropTypes.func.isRequired
setFilter: PropTypes.func.isRequired,
a11yNotify: PropTypes.func.isRequired
};

static defaultProps = {
Expand All @@ -45,10 +46,16 @@ export class FacetContainer extends Component {
};
}

handleClickMore = () => {
this.setState(({ more }) => ({
more: more + 10
}));
handleClickMore = totalOptions => {
this.setState(({ more }) => {
let optionsCount = more + 10;
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
const showingAll = optionsCount >= totalOptions;
if (showingAll) optionsCount = totalOptions;

this.props.a11yNotify("moreFilters", { optionsCount, showingAll });

return { more: optionsCount };
});
};

handleFacetSearch = searchTerm => {
Expand Down Expand Up @@ -93,7 +100,7 @@ export class FacetContainer extends Component {
return View({
className,
label: label,
onMoreClick: this.handleClickMore,
onMoreClick: this.handleClickMore.bind(this, options.length),
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
onRemove: value => {
removeFilter(field, value, filterType);
},
Expand All @@ -116,11 +123,12 @@ export class FacetContainer extends Component {
}

export default withSearch(
({ filters, facets, addFilter, removeFilter, setFilter }) => ({
({ filters, facets, addFilter, removeFilter, setFilter, a11yNotify }) => ({
filters,
facets,
addFilter,
removeFilter,
setFilter
setFilter,
a11yNotify
})
)(FacetContainer);
25 changes: 9 additions & 16 deletions packages/react-search-ui/src/containers/PagingInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,38 @@ export class PagingInfoContainer extends Component {
className: PropTypes.string,
view: PropTypes.func,
// State
current: PropTypes.number.isRequired,
results: PropTypes.arrayOf(PropTypes.object).isRequired,
resultsPerPage: PropTypes.number.isRequired,
pagingStart: PropTypes.number.isRequired,
pagingEnd: PropTypes.number.isRequired,
resultSearchTerm: PropTypes.string.isRequired,
totalResults: PropTypes.number.isRequired
};

render() {
const {
className,
current,
resultsPerPage,
pagingStart,
pagingEnd,
resultSearchTerm,
totalResults,
view
} = this.props;
const start = totalResults === 0 ? 0 : (current - 1) * resultsPerPage + 1;
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
const end =
totalResults <= start + resultsPerPage
? totalResults
: start + resultsPerPage - 1;

const View = view || PagingInfo;

return View({
className,
end: end,
searchTerm: resultSearchTerm,
start: start,
start: pagingStart,
end: pagingEnd,
totalResults: totalResults
});
}
}

export default withSearch(
({ current, results, resultsPerPage, resultSearchTerm, totalResults }) => ({
current,
results,
resultsPerPage,
({ pagingStart, pagingEnd, resultSearchTerm, totalResults }) => ({
pagingStart,
pagingEnd,
resultSearchTerm,
totalResults
})
Expand Down
18 changes: 13 additions & 5 deletions packages/react-search-ui/src/containers/__tests__/Facet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,12 @@ const params = {
},
addFilter: jest.fn(),
removeFilter: jest.fn(),
setFilter: jest.fn()
setFilter: jest.fn(),
a11yNotify: jest.fn()
};

beforeEach(() => {
params.view.mockClear();
params.addFilter.mockClear();
params.removeFilter.mockClear();
params.setFilter.mockClear();
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
jest.clearAllMocks();
});

it("renders correctly", () => {
Expand Down Expand Up @@ -187,6 +185,11 @@ describe("show more", () => {
describe("after a show more click", () => {
beforeAll(() => {
wrapper.find(View).prop("onMoreClick")();

expect(params.a11yNotify).toHaveBeenCalledWith("moreFilters", {
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
optionsCount: 15,
showingAll: false
});
});

it("should have 10 more options", () => {
Expand All @@ -201,6 +204,11 @@ describe("show more", () => {
describe("after more more show more click", () => {
beforeAll(() => {
wrapper.find(View).prop("onMoreClick")();

expect(params.a11yNotify).toHaveBeenCalledWith("moreFilters", {
optionsCount: 17,
showingAll: true
});
});

it("should be showing all options", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { shallow } from "enzyme";
import { PagingInfoContainer } from "../PagingInfo";

const params = {
current: 1,
results: [{}, {}],
resultsPerPage: 20,
pagingStart: 1,
pagingEnd: 20,
resultSearchTerm: "test",
totalResults: 100
};
Expand Down Expand Up @@ -37,20 +36,6 @@ it("renders when it doesn't have any results or a result search term", () => {
expect(wrapper).toMatchSnapshot();
});

it("does not render more than the total # of results", () => {
const wrapper = shallow(<PagingInfoContainer {...params} totalResults={5} />);
expect(wrapper.text()).toEqual("Showing 1 - 5 out of 5 for: test");

wrapper.setProps({ current: 3, resultsPerPage: 5, totalResults: 12 });
expect(wrapper.text()).toEqual("Showing 11 - 12 out of 12 for: test");

wrapper.setProps({ totalResults: 15 });
expect(wrapper.text()).toEqual("Showing 11 - 15 out of 15 for: test");

wrapper.setProps({ totalResults: 0 });
expect(wrapper.text()).toEqual("Showing 0 - 0 out of 0 for: test");
});

it("passes className through to the view", () => {
let viewProps;
const className = "test-class";
Expand Down
64 changes: 64 additions & 0 deletions packages/search-ui/src/A11yNotifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* This helper creates a live region that announces the results of certain
* actions (e.g. searching, paging, etc.), that are otherwise invisible
* to screen reader users.
*
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
*/
const regionId = "search-ui-screen-reader-notifications";
const hasDOM = typeof document !== "undefined"; // Prevent errors in SSR apps
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved

const getLiveRegion = () => {
if (!hasDOM) return;

let region = document.getElementById(regionId);
if (region) return region;

region = document.createElement("div");
region.id = regionId;
region.setAttribute("role", "status");
region.setAttribute("aria-live", "polite");

/**
* Visually-hidden CSS that's still available to screen readers.
* We're avoiding putting this in a stylesheet to ensure that this
* still works for users that opt for custom views & CSS. We're
* also opting to use CSSOM instead of inline styles to avoid
* Content Security Policy warnings.
*
* @see https://accessibility.18f.gov/hidden-content/
*/
region.style.position = "absolute";
region.style.width = "1px";
region.style.height = "1px";
region.style.margin = "-1px";
region.style.padding = "0";
region.style.border = "0";
region.style.overflow = "hidden";
region.style.clip = "rect(0 0 0 0)";

document.body.appendChild(region);
return region;
};

const announceToScreenReader = announcement => {
if (hasDOM) {
const region = getLiveRegion();
region.textContent = announcement;
}
};

const defaultMessages = {
searchResults: ({ start, end, totalResults, searchTerm }) => {
let message = `Showing ${start} to ${end} results out of ${totalResults}`;
if (searchTerm) message += `, searching for "${searchTerm}".`;
return message;
},
moreFilters: ({ optionsCount, showingAll }) => {
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
let message = showingAll ? "All " : "";
message += `${optionsCount} options shown.`;
return message;
}
};

export { getLiveRegion, announceToScreenReader, defaultMessages };
33 changes: 32 additions & 1 deletion packages/search-ui/src/SearchDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import DebounceManager from "./DebounceManager";
import * as actions from "./actions";
import Events from "./Events";

import * as a11y from "./A11yNotifications";

function filterSearchParameters({
current,
filters,
Expand Down Expand Up @@ -46,6 +48,8 @@ export const DEFAULT_STATE = {
resultSearchTerm: "",
totalPages: 0,
totalResults: 0,
pagingStart: 0,
pagingEnd: 0,
wasSearched: false
};

Expand Down Expand Up @@ -86,7 +90,9 @@ export default class SearchDriver {
onAutocompleteResultClick,
searchQuery = {},
trackUrlState = true,
urlPushDebounceLength = 500
urlPushDebounceLength = 500,
a11yNotifications = false,
a11yNotificationMessages = {}
}) {
this.actions = Object.entries(actions).reduce(
(acc, [actionName, action]) => {
Expand Down Expand Up @@ -130,6 +136,15 @@ export default class SearchDriver {
urlState = {};
}

// Manage screen reader accessible notifications
this.a11yNotifications = a11yNotifications;
if (this.a11yNotifications) a11y.getLiveRegion();
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved

this.a11yNotificationMessages = {
...a11y.defaultMessages,
...a11yNotificationMessages
};

// Remember the state this application is initialized into, so that we can
// reset to it later.
this.startingState = {
Expand Down Expand Up @@ -235,13 +250,29 @@ export default class SearchDriver {
if (this.requestSequencer.isOldRequest(requestId)) return;
this.requestSequencer.completed(requestId);

// Results paging start & end
const { totalResults } = resultState;
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
const start =
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
totalResults === 0 ? 0 : (current - 1) * resultsPerPage + 1;
const end =
totalResults <= start + resultsPerPage
? totalResults
: start + resultsPerPage - 1;

this._setState({
isLoading: false,
resultSearchTerm: searchTerm,
pagingStart: start,
pagingEnd: end,
...resultState,
wasSearched: true
});

if (this.a11yNotifications) {
const messageArgs = { start, end, totalResults, searchTerm };
this.actions.a11yNotify("searchResults", messageArgs);
}

if (!skipPushToUrl && this.trackUrlState) {
// We debounce here so that we don't get a lot of intermediary
// URL state if someone is updating a UI really fast, like typing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @jest-environment node
yakhinvadim marked this conversation as resolved.
Show resolved Hide resolved
*/
import { getLiveRegion, announceToScreenReader } from "../A11yNotifications";

it("does not crash or create errors in server-side rendered apps", () => {
expect(getLiveRegion()).toBeUndefined();
expect(announceToScreenReader()).toBeUndefined();
});
Loading