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