diff --git a/ADVANCED.md b/ADVANCED.md
index 1f0558ac..acf5f4e3 100644
--- a/ADVANCED.md
+++ b/ADVANCED.md
@@ -182,18 +182,19 @@ ex.
### Actions
-| method | params | return | description |
-| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------- |
-| `addFilter` | `name` String - field name to filter on
`value` String - field value to filter on
`filterType` String - type of filter to apply: "all", "any", or "none" | | Add a filter in addition to current filters values. |
-| `setFilter` | `name` String - field name to filter on
`value` String - field value to filter on
`filterType` String - type of filter to apply: "all", "any", or "none" | | Set a filter value, replacing current filter values. |
-| `removeFilter` | `name` String - field to remove filters from
`value` String - (Optional) Specify which filter value to remove
`filterType` String - (Optional) Specify which filter type to remove: "all", "any", or "none" | | Removes filters or filter values. |
-| `reset` | | | Reset state to initial search state. |
-| `clearFilters` | `except` Array[String] - List of field names that should NOT be cleared | | Clear all filters. |
-| `setCurrent` | Integer | | Update the current page number. Used for paging. |
-| `setResultsPerPage` | Integer | | |
-| `setSearchTerm` | `searchTerm` String
`options` Object
`options.refresh` Boolean - Refresh search results on update. Default: `true`.
`options.debounce` Number - Length to debounce any resulting queries
`options.autocompleteSuggestions` Boolean - Fetch query suggestions for autocomplete on update, stored in `autocompletedSuggestions` state
`options.autocompleteResults` Boolean - Fetch results on update, stored in `autocompletedResults` state | | |
-| `setSort` | `sortField` String - field to sort on
`sortDirection` String - "asc" or "desc" | | |
-| `trackClickThrough` | `documentId` String - The document ID associated with the result that was clicked
`tag` - Array[String] Optional tags which can be used to categorize this click event | | Report a clickthrough event, which is when a user clicks on a result link. |
+| method | params | return | description |
+| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `addFilter` | `name` String - field name to filter on
`value` String - field value to filter on
`filterType` String - type of filter to apply: "all", "any", or "none" | | Add a filter in addition to current filters values. |
+| `setFilter` | `name` String - field name to filter on
`value` String - field value to filter on
`filterType` String - type of filter to apply: "all", "any", or "none" | | Set a filter value, replacing current filter values. |
+| `removeFilter` | `name` String - field to remove filters from
`value` String - (Optional) Specify which filter value to remove
`filterType` String - (Optional) Specify which filter type to remove: "all", "any", or "none" | | Removes filters or filter values. |
+| `reset` | | | Reset state to initial search state. |
+| `clearFilters` | `except` Array[String] - List of field names that should NOT be cleared | | Clear all filters. |
+| `setCurrent` | Integer | | Update the current page number. Used for paging. |
+| `setResultsPerPage` | Integer | | |
+| `setSearchTerm` | `searchTerm` String
`options` Object
`options.refresh` Boolean - Refresh search results on update. Default: `true`.
`options.debounce` Number - Length to debounce any resulting queries
`options.autocompleteSuggestions` Boolean - Fetch query suggestions for autocomplete on update, stored in `autocompletedSuggestions` state
`options.autocompleteResults` Boolean - Fetch results on update, stored in `autocompletedResults` state | | |
+| `setSort` | `sortField` String - field to sort on
`sortDirection` String - "asc" or "desc" | | |
+| `trackClickThrough` | `documentId` String - The document ID associated with the result that was clicked
`tag` - Array[String] Optional tags which can be used to categorize this click event | | Report a clickthrough event, which is when a user clicks on a result link. |
+| `a11yNotify` | `messageFunc` String - object key to run as function
`messageArgs` Object - Arguments to pass to form your screen reader message string | | Reads out a screen reader accessible notification. See `a11yNotificationMessages` under [Advanced Configuration](#advanced-configuration) |
### State
@@ -1076,6 +1077,11 @@ const configurationOptions = {
]
}
}
+ },
+ hasA11yNotifications: true,
+ a11yNotificationMessages: {
+ searchResults: ({ start, end, totalResults, searchTerm }) =>
+ `Searching for "${searchTerm}". Showing ${start} to ${end} results out of ${totalResults}.`
}
};
@@ -1110,6 +1116,8 @@ return (
| `searchQuery` | [Query Config](#query-config) | optional | {} | Configuration options for the main search query. |
| `trackUrlState` | Boolean | optional | true | By default, [Request State](#request-state) will be synced with the browser url. To turn this off, pass `false`. |
| `urlPushDebounceLength` | Integer | optional | 500 | The amount of time in milliseconds to debounce/delay updating the browser url after the UI update. This, for example, prevents excessive history entries while a user is still typing in a live search box. |
+| `hasA11yNotifications` | Boolean | optional | false | Search UI will create a visually hidden live region to announce search results & other actions to screen reader users. This accessibility feature will be turned on by default in our 2.0 release. |
+| `a11yNotificationMessages` | Object | optional | {} | You can override our default screen reader [messages](packages/search-ui/src/A11yNotifications.js#L49) (e.g. for localization), or create your own custom notification, by passing in your own key and message function(s). |
## Query Config
diff --git a/examples/elasticsearch/src/App.js b/examples/elasticsearch/src/App.js
index 7e5fe7dc..3f640197 100644
--- a/examples/elasticsearch/src/App.js
+++ b/examples/elasticsearch/src/App.js
@@ -24,6 +24,7 @@ import buildState from "./buildState";
const config = {
debug: true,
+ hasA11yNotifications: true,
onResultClick: () => {
/* Not implemented */
},
diff --git a/examples/sandbox/src/App.js b/examples/sandbox/src/App.js
index 35637ce1..d69f2e54 100755
--- a/examples/sandbox/src/App.js
+++ b/examples/sandbox/src/App.js
@@ -167,7 +167,8 @@ const config = {
size: 4
}
},
- apiConnector: connector
+ apiConnector: connector,
+ hasA11yNotifications: true
};
export default function App() {
diff --git a/packages/react-search-ui/src/A11yNotifications.js b/packages/react-search-ui/src/A11yNotifications.js
new file mode 100644
index 00000000..b8559b37
--- /dev/null
+++ b/packages/react-search-ui/src/A11yNotifications.js
@@ -0,0 +1,14 @@
+/**
+ * Accessibility notifications
+ * @see packages/search-ui/src/A11yNotifications.js
+ */
+
+const defaultMessages = {
+ moreFilters: ({ visibleOptionsCount, showingAll }) => {
+ let message = showingAll ? "All " : "";
+ message += `${visibleOptionsCount} options shown.`;
+ return message;
+ }
+};
+
+export default defaultMessages;
diff --git a/packages/react-search-ui/src/SearchProvider.js b/packages/react-search-ui/src/SearchProvider.js
index ff98c3f3..13dc4133 100644
--- a/packages/react-search-ui/src/SearchProvider.js
+++ b/packages/react-search-ui/src/SearchProvider.js
@@ -4,6 +4,8 @@ import React, { Component } from "react";
import { SearchDriver } from "@elastic/search-ui";
import SearchContext from "./SearchContext";
+import defaultA11yMessages from "./A11yNotifications";
+
/**
* The SearchProvider primarily holds a reference to the SearchDriver and
* exposes it to the rest of the application in a Context.
@@ -28,7 +30,13 @@ class SearchProvider extends Component {
// This initialization is done inside of componentDidMount, because initializing the SearchDriver server side
// will error out, since the driver depends on window. Placing the initialization inside of componentDidMount
// assures that it won't attempt to initialize server side.
- const driver = new SearchDriver(config);
+ const driver = new SearchDriver({
+ ...config,
+ a11yNotificationMessages: {
+ ...defaultA11yMessages,
+ ...config.a11yNotificationMessages
+ }
+ });
this.setState({
driver
});
diff --git a/packages/react-search-ui/src/__tests__/A11yNotifications.test.js b/packages/react-search-ui/src/__tests__/A11yNotifications.test.js
new file mode 100644
index 00000000..96c7296f
--- /dev/null
+++ b/packages/react-search-ui/src/__tests__/A11yNotifications.test.js
@@ -0,0 +1,17 @@
+import defaultMessages from "../A11yNotifications";
+
+it("outputs moreFilters correctly", () => {
+ expect(
+ defaultMessages.moreFilters({
+ visibleOptionsCount: 15,
+ showingAll: false
+ })
+ ).toEqual("15 options shown.");
+
+ expect(
+ defaultMessages.moreFilters({
+ visibleOptionsCount: 28,
+ showingAll: true
+ })
+ ).toEqual("All 28 options shown.");
+});
diff --git a/packages/react-search-ui/src/__tests__/SearchProvider.test.js b/packages/react-search-ui/src/__tests__/SearchProvider.test.js
index 3bfbd3ad..c1fa6ec3 100644
--- a/packages/react-search-ui/src/__tests__/SearchProvider.test.js
+++ b/packages/react-search-ui/src/__tests__/SearchProvider.test.js
@@ -35,4 +35,39 @@ describe("SearchProvider", () => {
);
expect(wrapper.text()).toEqual("testfunction");
});
+
+ describe("merges default and custom a11yNotificationMessages", () => {
+ const getA11yNotificationMessages = a11yNotificationMessages => {
+ const wrapper = mount(
+
+ Test
+
+ );
+ return wrapper.state("driver").a11yNotificationMessages;
+ };
+
+ it("default messages", () => {
+ const messages = getA11yNotificationMessages({});
+
+ expect(messages.moreFilters({ visibleOptionsCount: 7 })).toEqual(
+ "7 options shown."
+ );
+ });
+
+ it("override messages", () => {
+ const messages = getA11yNotificationMessages({
+ moreFilters: () => "Example override"
+ });
+
+ expect(messages.moreFilters()).toEqual("Example override");
+ });
+
+ it("new messages", () => {
+ const messages = getA11yNotificationMessages({
+ customMessage: () => "Hello world"
+ });
+
+ expect(messages.customMessage()).toEqual("Hello world");
+ });
+ });
});
diff --git a/packages/react-search-ui/src/containers/Facet.js b/packages/react-search-ui/src/containers/Facet.js
index 586516b3..01a6b3c7 100644
--- a/packages/react-search-ui/src/containers/Facet.js
+++ b/packages/react-search-ui/src/containers/Facet.js
@@ -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 = {
@@ -45,10 +46,16 @@ export class FacetContainer extends Component {
};
}
- handleClickMore = () => {
- this.setState(({ more }) => ({
- more: more + 10
- }));
+ handleClickMore = totalOptions => {
+ this.setState(({ more }) => {
+ let visibleOptionsCount = more + 10;
+ const showingAll = visibleOptionsCount >= totalOptions;
+ if (showingAll) visibleOptionsCount = totalOptions;
+
+ this.props.a11yNotify("moreFilters", { visibleOptionsCount, showingAll });
+
+ return { more: visibleOptionsCount };
+ });
};
handleFacetSearch = searchTerm => {
@@ -93,7 +100,7 @@ export class FacetContainer extends Component {
return View({
className,
label: label,
- onMoreClick: this.handleClickMore,
+ onMoreClick: this.handleClickMore.bind(this, options.length),
onRemove: value => {
removeFilter(field, value, filterType);
},
@@ -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);
diff --git a/packages/react-search-ui/src/containers/PagingInfo.js b/packages/react-search-ui/src/containers/PagingInfo.js
index 9db654cd..3ed3b7bf 100644
--- a/packages/react-search-ui/src/containers/PagingInfo.js
+++ b/packages/react-search-ui/src/containers/PagingInfo.js
@@ -9,9 +9,8 @@ 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
};
@@ -19,35 +18,29 @@ export class PagingInfoContainer extends Component {
render() {
const {
className,
- current,
- resultsPerPage,
+ pagingStart,
+ pagingEnd,
resultSearchTerm,
totalResults,
view
} = this.props;
- const start = totalResults === 0 ? 0 : (current - 1) * resultsPerPage + 1;
- 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
})
diff --git a/packages/react-search-ui/src/containers/__tests__/Facet.test.js b/packages/react-search-ui/src/containers/__tests__/Facet.test.js
index 34c08cd3..222728c1 100644
--- a/packages/react-search-ui/src/containers/__tests__/Facet.test.js
+++ b/packages/react-search-ui/src/containers/__tests__/Facet.test.js
@@ -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();
+ jest.clearAllMocks();
});
it("renders correctly", () => {
@@ -187,6 +185,11 @@ describe("show more", () => {
describe("after a show more click", () => {
beforeAll(() => {
wrapper.find(View).prop("onMoreClick")();
+
+ expect(params.a11yNotify).toHaveBeenCalledWith("moreFilters", {
+ visibleOptionsCount: 15,
+ showingAll: false
+ });
});
it("should have 10 more options", () => {
@@ -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", {
+ visibleOptionsCount: 17,
+ showingAll: true
+ });
});
it("should be showing all options", () => {
diff --git a/packages/react-search-ui/src/containers/__tests__/PagingInfo.test.js b/packages/react-search-ui/src/containers/__tests__/PagingInfo.test.js
index e10d7140..6d683986 100644
--- a/packages/react-search-ui/src/containers/__tests__/PagingInfo.test.js
+++ b/packages/react-search-ui/src/containers/__tests__/PagingInfo.test.js
@@ -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
};
@@ -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();
- 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";
diff --git a/packages/search-ui/src/A11yNotifications.js b/packages/search-ui/src/A11yNotifications.js
new file mode 100644
index 00000000..b19f89b5
--- /dev/null
+++ b/packages/search-ui/src/A11yNotifications.js
@@ -0,0 +1,59 @@
+/**
+ * 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
+
+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;
+ }
+};
+
+export { getLiveRegion, announceToScreenReader, defaultMessages };
diff --git a/packages/search-ui/src/SearchDriver.js b/packages/search-ui/src/SearchDriver.js
index d8b447c0..9c31e8f5 100644
--- a/packages/search-ui/src/SearchDriver.js
+++ b/packages/search-ui/src/SearchDriver.js
@@ -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,
@@ -46,6 +48,8 @@ export const DEFAULT_STATE = {
resultSearchTerm: "",
totalPages: 0,
totalResults: 0,
+ pagingStart: 0,
+ pagingEnd: 0,
wasSearched: false
};
@@ -86,7 +90,9 @@ export default class SearchDriver {
onAutocompleteResultClick,
searchQuery = {},
trackUrlState = true,
- urlPushDebounceLength = 500
+ urlPushDebounceLength = 500,
+ hasA11yNotifications = false,
+ a11yNotificationMessages = {}
}) {
this.actions = Object.entries(actions).reduce(
(acc, [actionName, action]) => {
@@ -130,6 +136,15 @@ export default class SearchDriver {
urlState = {};
}
+ // Manage screen reader accessible notifications
+ this.hasA11yNotifications = hasA11yNotifications;
+ if (this.hasA11yNotifications) a11y.getLiveRegion();
+
+ this.a11yNotificationMessages = {
+ ...a11y.defaultMessages,
+ ...a11yNotificationMessages
+ };
+
// Remember the state this application is initialized into, so that we can
// reset to it later.
this.startingState = {
@@ -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;
+ const start =
+ 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.hasA11yNotifications) {
+ 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
diff --git a/packages/search-ui/src/__tests__/A11yNotifications.ssr.test.js b/packages/search-ui/src/__tests__/A11yNotifications.ssr.test.js
new file mode 100644
index 00000000..ab9897fb
--- /dev/null
+++ b/packages/search-ui/src/__tests__/A11yNotifications.ssr.test.js
@@ -0,0 +1,9 @@
+/**
+ * @jest-environment node
+ */
+import { getLiveRegion, announceToScreenReader } from "../A11yNotifications";
+
+it("does not crash or create errors in server-side rendered apps", () => {
+ expect(getLiveRegion()).toBeUndefined();
+ expect(announceToScreenReader()).toBeUndefined();
+});
diff --git a/packages/search-ui/src/__tests__/A11yNotifications.test.js b/packages/search-ui/src/__tests__/A11yNotifications.test.js
new file mode 100644
index 00000000..6e4995be
--- /dev/null
+++ b/packages/search-ui/src/__tests__/A11yNotifications.test.js
@@ -0,0 +1,52 @@
+/**
+ * @jest-environment jsdom
+ */
+import {
+ getLiveRegion,
+ announceToScreenReader,
+ defaultMessages
+} from "../A11yNotifications";
+
+it("creates a live screen reader region", () => {
+ // Before init
+ let region = document.getElementById("search-ui-screen-reader-notifications");
+ expect(region).toBeNull();
+
+ // After init
+ region = getLiveRegion();
+ expect(region).not.toBeNull();
+ expect(region.getAttribute("role")).toEqual("status");
+ expect(region.getAttribute("aria-live")).toEqual("polite");
+ expect(region.style._values.overflow).toEqual("hidden");
+});
+
+it("updates the live region correctly via announceToScreenReader", () => {
+ announceToScreenReader("Hello world!");
+
+ const region = document.getElementById(
+ "search-ui-screen-reader-notifications"
+ );
+ expect(region.textContent).toEqual("Hello world!");
+});
+
+describe("defaultMessages", () => {
+ it("outputs searchResults correctly", () => {
+ expect(
+ defaultMessages.searchResults({
+ start: 1,
+ end: 20,
+ totalResults: 50,
+ searchTerm: "foo"
+ })
+ ).toEqual('Showing 1 to 20 results out of 50, searching for "foo".');
+
+ expect(
+ defaultMessages.searchResults({
+ start: 0,
+ end: 0,
+ totalResults: 0,
+ searchTerm: ""
+ })
+ ).toEqual("Showing 0 to 0 results out of 0");
+ });
+});
diff --git a/packages/search-ui/src/__tests__/SearchDriver.test.js b/packages/search-ui/src/__tests__/SearchDriver.test.js
index 5739dbb7..27439505 100644
--- a/packages/search-ui/src/__tests__/SearchDriver.test.js
+++ b/packages/search-ui/src/__tests__/SearchDriver.test.js
@@ -58,6 +58,22 @@ it("will use initial state if provided", () => {
});
});
+it("will merge default and custom a11yNotificationMessages", () => {
+ const { driver } = setupDriver({
+ a11yNotificationMessages: {
+ customMessage: () => "Hello world",
+ moreFilter: () => "Example override"
+ }
+ });
+ const messages = driver.a11yNotificationMessages;
+
+ expect(messages.customMessage()).toEqual("Hello world");
+ expect(messages.moreFilter()).toEqual("Example override");
+ expect(messages.searchResults({ start: 0, end: 0, totalResults: 0 })).toEqual(
+ "Showing 0 to 0 results out of 0"
+ );
+});
+
it("will default facets to {} in state if facets is missing from the response", () => {
const initialState = {
searchTerm: "test"
@@ -345,7 +361,7 @@ describe("#getActions", () => {
it("returns the current state", () => {
const driver = new SearchDriver(params);
const actions = driver.getActions();
- expect(Object.keys(actions).length).toBe(11);
+ expect(Object.keys(actions).length).toBe(12);
expect(actions.addFilter).toBeInstanceOf(Function);
expect(actions.clearFilters).toBeInstanceOf(Function);
expect(actions.removeFilter).toBeInstanceOf(Function);
@@ -357,5 +373,67 @@ describe("#getActions", () => {
expect(actions.setCurrent).toBeInstanceOf(Function);
expect(actions.trackClickThrough).toBeInstanceOf(Function);
expect(actions.trackAutocompleteClickThrough).toBeInstanceOf(Function);
+ expect(actions.a11yNotify).toBeInstanceOf(Function);
+ });
+});
+
+describe("_updateSearchResults", () => {
+ const initialState = {
+ searchTerm: "test",
+ resultsPerPage: 20,
+ current: 2
+ };
+
+ it("calculates pagingStart and pagingEnd correctly", () => {
+ const { stateAfterCreation } = setupDriver({ initialState });
+
+ expect(stateAfterCreation.totalResults).toEqual(1000);
+ expect(stateAfterCreation.pagingStart).toEqual(21);
+ expect(stateAfterCreation.pagingEnd).toEqual(40);
+ });
+
+ it("does not set pagingEnd to more than the total # of results", () => {
+ const mockSearchResponse = { totalResults: 30, totalPages: 2 };
+
+ const { stateAfterCreation } = setupDriver({
+ initialState,
+ mockSearchResponse
+ });
+
+ expect(stateAfterCreation.totalResults).toEqual(30);
+ expect(stateAfterCreation.pagingStart).toEqual(21);
+ expect(stateAfterCreation.pagingEnd).toEqual(30);
+ });
+
+ it("zeroes out pagingStart and pagingEnd correctly", () => {
+ const mockSearchResponse = { totalResults: 0 };
+
+ const { stateAfterCreation } = setupDriver({
+ initialState,
+ mockSearchResponse
+ });
+
+ expect(stateAfterCreation.totalResults).toEqual(0);
+ expect(stateAfterCreation.pagingStart).toEqual(0);
+ expect(stateAfterCreation.pagingEnd).toEqual(0);
+ });
+
+ it("calls a11yNotify when search results update", () => {
+ const searchResultsNotification = jest.fn();
+
+ setupDriver({
+ initialState,
+ hasA11yNotifications: true,
+ a11yNotificationMessages: {
+ searchResults: searchResultsNotification
+ }
+ });
+
+ expect(searchResultsNotification).toHaveBeenCalledWith({
+ start: 21,
+ end: 40,
+ totalResults: 1000,
+ searchTerm: "test"
+ });
});
});
diff --git a/packages/search-ui/src/__tests__/actions/a11yNotify.test.js b/packages/search-ui/src/__tests__/actions/a11yNotify.test.js
new file mode 100644
index 00000000..703b87ce
--- /dev/null
+++ b/packages/search-ui/src/__tests__/actions/a11yNotify.test.js
@@ -0,0 +1,61 @@
+import { setupDriver } from "../../test/helpers";
+
+// Mock announceToScreenReader so that we can spy on it
+jest.mock("../../A11yNotifications.js");
+import { announceToScreenReader } from "../../A11yNotifications";
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe("#a11yNotify", () => {
+ const config = {
+ hasA11yNotifications: true,
+ a11yNotificationMessages: {
+ customMessage: () => "Hello world"
+ }
+ };
+
+ it("runs", () => {
+ const { driver } = setupDriver(config);
+
+ driver.a11yNotify("customMessage");
+ expect(announceToScreenReader).toHaveBeenCalledWith("Hello world");
+ });
+
+ it("does not run if hasA11yNotifications is false", () => {
+ const { driver } = setupDriver({ ...config, hasA11yNotifications: false });
+
+ driver.a11yNotify("customMessage");
+ expect(announceToScreenReader).not.toHaveBeenCalled();
+ });
+
+ it("does not run if a valid a11yNotificationMessage function is not found", () => {
+ const { driver } = setupDriver(config);
+
+ driver.a11yNotify("invalid");
+ expect(announceToScreenReader).not.toHaveBeenCalled();
+ });
+
+ it("logs expected console messages", () => {
+ // Spy on and silence expected console messages
+ jest.spyOn(global.console, "log").mockImplementation();
+ jest.spyOn(global.console, "warn").mockImplementation();
+
+ const { driver } = setupDriver({ ...config, debug: true });
+
+ driver.a11yNotify("customMessage", { foo: "bar" });
+ expect(global.console.log).toHaveBeenCalledWith("Action", "a11yNotify", {
+ messageFunc: "customMessage",
+ messageArgs: { foo: "bar" },
+ message: "Hello world"
+ });
+
+ driver.a11yNotify("invalid");
+ expect(global.console.warn).toHaveBeenCalledWith(
+ "Action",
+ "a11yNotify",
+ 'Could not find corresponding message function in a11yNotificationMessages: "invalid"'
+ );
+ });
+});
diff --git a/packages/search-ui/src/actions/a11yNotify.js b/packages/search-ui/src/actions/a11yNotify.js
new file mode 100644
index 00000000..b376477a
--- /dev/null
+++ b/packages/search-ui/src/actions/a11yNotify.js
@@ -0,0 +1,27 @@
+import { announceToScreenReader } from "../A11yNotifications";
+
+/**
+ * Announces a specific message in `a11yNotificationMessages`
+ * to Search UI's screen reader live region.
+ *
+ * @param {string} messageFunc - key of a message function in `a11yNotificationMessages`
+ * @param {object} [messageArgs] - arguments to pass to the message function, if any
+ */
+export default function a11yNotify(messageFunc, messageArgs) {
+ if (!this.hasA11yNotifications) return;
+
+ const getMessage = this.a11yNotificationMessages[messageFunc];
+
+ if (!getMessage) {
+ const errorMessage = `Could not find corresponding message function in a11yNotificationMessages: "${messageFunc}"`;
+ console.warn("Action", "a11yNotify", errorMessage);
+ return;
+ }
+
+ const message = getMessage(messageArgs);
+ announceToScreenReader(message);
+
+ if (this.debug) {
+ console.log("Action", "a11yNotify", { messageFunc, messageArgs, message }); // eslint-disable-line no-console
+ }
+}
diff --git a/packages/search-ui/src/actions/index.js b/packages/search-ui/src/actions/index.js
index 045757d5..091462ec 100644
--- a/packages/search-ui/src/actions/index.js
+++ b/packages/search-ui/src/actions/index.js
@@ -11,3 +11,4 @@ export { default as setResultsPerPage } from "./setResultsPerPage";
export { default as setSearchTerm } from "./setSearchTerm";
export { default as setSort } from "./setSort";
export { default as trackClickThrough } from "./trackClickThrough";
+export { default as a11yNotify } from "./a11yNotify";
diff --git a/packages/search-ui/src/test/helpers.js b/packages/search-ui/src/test/helpers.js
index 3905ad6f..4870f027 100644
--- a/packages/search-ui/src/test/helpers.js
+++ b/packages/search-ui/src/test/helpers.js
@@ -43,11 +43,7 @@ export function getMockApiConnector() {
};
}
-export function setupDriver({
- initialState,
- mockSearchResponse,
- trackUrlState
-} = {}) {
+export function setupDriver({ mockSearchResponse, ...rest } = {}) {
const mockApiConnector = getMockApiConnector();
if (mockSearchResponse) {
@@ -56,13 +52,10 @@ export function setupDriver({
});
}
- trackUrlState =
- trackUrlState === false || trackUrlState === true ? trackUrlState : true;
-
const driver = new SearchDriver({
apiConnector: mockApiConnector,
- trackUrlState,
- initialState,
+ // Pass, e.g., initialState and all other configs
+ ...rest,
// We don't want to deal with async in our tests, so pass 0 so URL state
// pushes happen synchronously
urlPushDebounceLength: 0