diff --git a/README.md b/README.md index 6827fe8866..a57f565e39 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![npm version](https://img.shields.io/npm/v/@trussworks/react-uswds)](https://www.npmjs.com/package/@trussworks/react-uswds) -[![CircleCI](https://img.shields.io/circleci/build/github/trussworks/react-uswds/develop)](https://circleci.com/gh/trussworks/react-uswds) +[![uswds version](https://img.shields.io/github/package-json/dependency-version/trussworks/react-uswds/dev/uswds)](https://www.npmjs.com/package/uswds) + +[![CircleCI](https://img.shields.io/circleci/build/github/trussworks/react-uswds/main)](https://circleci.com/gh/trussworks/react-uswds) [![npm downloads](https://img.shields.io/npm/dm/@trussworks/react-uswds)](https://www.npmjs.com/package/@trussworks/react-uswds) **ReactUSWDS Component Library** @@ -20,25 +22,14 @@ An example application, built with React-USWDS, can be found in the `/example` f **Table of Contents** -- [@trussworks/react-uswds](#trussworksreact-uswds) - - [Background](#background) - - [Non-Goals](#non-goals) - - [Install](#install) - - [Pre-Release](#pre-release) - - [Usage](#usage) - - [Maintainers](#maintainers) - - [Contributing](#contributing) - - [License](#license) - -## Background - -The primary deliverable is a published npm package that can be included as a dependency in other projects that use USWDS with React. In order for these components to be useful, they should follow best practices for accessible, semantic, markup; be well-tested across browsers and devices; and allow for an appropriate level of customization. We adhere to a set of [development guidelines](./docs/contributing.md#guidelines) as much as possible and use automation to enforce tests, linting, and other standards. - -### Non-Goals - -This is not meant to be a one-size-fits-all front end solution, We are starting off with the opinionated decision to cater towards projects that use the U.S. Design System 2.0, and encapsulate these specific styles and markup in React components. - -In the process, we expect to gain learnings around how to best abstract out UI code from implementation; how to better standardize and document front end code practices; and how to develop, maintain, and distribute a shared JS library in alignment with our [company values at Truss](https://truss.works/values). +- [Install](#install) + - [Pre-Release](#pre-release) +- [Usage](#usage) +- [Background](#background) + - [Non-Goals](#non-goals) +- [Maintainers](#maintainers) +- [Contributing](#contributing) +- [License](#license) ## Install @@ -56,7 +47,7 @@ npm i @trussworks/react-uswds ### Pre-Release -Pre-release packages are published to GitHub Packages. To use, you +Pre-release packages are published to GitHub Packages every time code is pushed to the `main` branch. To use, you will need a [GitHub access token](https://docs.github.com/en/packages/publishing-and-managing-packages/about-github-packages#about-tokens) with the `read:packages` scope. @@ -84,13 +75,15 @@ for more detailed information. ## Usage +It is strongly suggested applications use the same version of USWDS that was used to build the version of ReactUSWDS they're using. A version mismatch may result in unexpected markup & CSS combinations. + You can import ReactUSWDS components using ES6 syntax: ``` import { Alert } from '@trussworks/react-uswds' ``` -> **Warning:** Do _not_ include the full USWDS JS in your project alongside this library, as that will result in some components that use JS (such as the ComboBox) to initialize twice. +> **Warning:** Do _not_ include USWDS JS in your project alongside this library (i.e., using `import 'uswds'`), as that will result in some components that use JS (such as the ComboBox) to initialize twice. Also make sure to include the following in order to import the compiled CSS from this project: @@ -102,6 +95,16 @@ If you aren't already using USWDS as a dependency, you also need to import USWDS Having issues? See [FAQs](./docs/faqs.md). +## Background + +The primary deliverable is a published npm package that can be included as a dependency in other projects that use USWDS with React. In order for these components to be useful, they should follow best practices for accessible, semantic, markup; be well-tested across browsers and devices; and allow for an appropriate level of customization. We adhere to a set of [development guidelines](./docs/contributing.md#guidelines) as much as possible and use automation to enforce tests, linting, and other standards. + +### Non-Goals + +This is not meant to be a one-size-fits-all front end solution, We are starting off with the opinionated decision to cater towards projects that use the U.S. Design System 2.0, and encapsulate these specific styles and markup in React components. + +In the process, we expect to gain learnings around how to best abstract out UI code from implementation; how to better standardize and document front end code practices; and how to develop, maintain, and distribute a shared JS library in alignment with our [company values at Truss](https://truss.works/values). + ## Maintainers - [@suzubara](https://github.com/suzubara) diff --git a/package.json b/package.json index 9647e3063e..1a5d434fd6 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,9 @@ "peerDependencies": { "react": "^16.x || ^17.x", "react-dom": "^16.x || ^17.x", - "uswds": "2.10.0" + + "uswds": "2.10.3" + }, "devDependencies": { "@babel/core": "^7.10.5", @@ -126,7 +128,8 @@ "ts-jest": "^26.1.2", "typescript": "^3.8.0", "url-loader": "^4.0.0", - "uswds": "2.10.0", + "uswds": "2.10.3", + "webpack": "^4.41.0", "webpack-cli": "^4.0.0" }, diff --git a/src/components/Footer/Footer/Footer.stories.tsx b/src/components/Footer/Footer/Footer.stories.tsx index af652fa66f..f607aa1393 100644 --- a/src/components/Footer/Footer/Footer.stories.tsx +++ b/src/components/Footer/Footer/Footer.stories.tsx @@ -96,7 +96,7 @@ export const SlimFooter = (): React.ReactElement => ( src={logoImg} /> } - heading={

Name of Agency

} + heading={

Name of Agency

} /> } /> @@ -127,7 +127,7 @@ export const MediumFooter = (): React.ReactElement => ( src={logoImg} /> } - heading={

Name of Agency

} + heading={

Name of Agency

} />
( href="#"> YouTube , + + Instagram + , ( src={logoImg} /> } - heading={

Name of Agency

} + heading={

Name of Agency

} />
( href="#"> YouTube , + + Instagram + , ( image={ Mock logo } - heading={

Name of Agency

} + heading={

Name of Agency

} />
) diff --git a/src/components/Footer/Logo/Logo.test.tsx b/src/components/Footer/Logo/Logo.test.tsx index 87720fad79..2cf3c07c1f 100644 --- a/src/components/Footer/Logo/Logo.test.tsx +++ b/src/components/Footer/Logo/Logo.test.tsx @@ -5,7 +5,7 @@ jest.mock('../../../deprecation') import { deprecationWarning } from '../../../deprecation' import { Logo } from './Logo' -const heading =

Swoosh Branding

+const heading =

Swoosh Branding

const logoImage = ( YouTube
, + + Instagram + , RSS , diff --git a/src/components/GovBanner/GovBanner.tsx b/src/components/GovBanner/GovBanner.tsx index d8be425f46..5545e96f70 100644 --- a/src/components/GovBanner/GovBanner.tsx +++ b/src/components/GovBanner/GovBanner.tsx @@ -23,7 +23,14 @@ interface GovBannerCopy { const getCopy = (language: Language, tld: TLD): GovBannerCopy => { const lock = ( - lock + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} + lock ) @@ -154,7 +161,9 @@ export const GovBanner = ({

@@ -168,7 +177,9 @@ export const GovBanner = ({

diff --git a/src/components/GovBanner/__snapshots__/GovBanner.test.tsx.snap b/src/components/GovBanner/__snapshots__/GovBanner.test.tsx.snap index 71e6548566..88db8748b1 100644 --- a/src/components/GovBanner/__snapshots__/GovBanner.test.tsx.snap +++ b/src/components/GovBanner/__snapshots__/GovBanner.test.tsx.snap @@ -65,8 +65,10 @@ exports[`GovBanner component static content renders consistently in English for className="usa-banner__guidance tablet:grid-col-6" >

) @@ -196,8 +202,10 @@ exports[`GovBanner component static content renders consistently in English for className="usa-banner__guidance tablet:grid-col-6" >
) @@ -327,8 +339,10 @@ exports[`GovBanner component static content renders consistently in Spanish for className="usa-banner__guidance tablet:grid-col-6" >
) @@ -459,8 +477,10 @@ exports[`GovBanner component static content renders consistently in Spanish for className="usa-banner__guidance tablet:grid-col-6" >
) @@ -591,8 +615,10 @@ exports[`GovBanner component static content renders consistently with default pr className="usa-banner__guidance tablet:grid-col-6" >
) diff --git a/src/components/forms/ComboBox/ComboBox.stories.tsx b/src/components/forms/ComboBox/ComboBox.stories.tsx index 3318003b4c..774bb38af7 100644 --- a/src/components/forms/ComboBox/ComboBox.stories.tsx +++ b/src/components/forms/ComboBox/ComboBox.stories.tsx @@ -71,8 +71,16 @@ export const withLabel = (): React.ReactElement => { return (
- - + + ) } diff --git a/src/components/forms/ComboBox/ComboBox.test.tsx b/src/components/forms/ComboBox/ComboBox.test.tsx index 97fdb0096e..0696046929 100644 --- a/src/components/forms/ComboBox/ComboBox.test.tsx +++ b/src/components/forms/ComboBox/ComboBox.test.tsx @@ -40,12 +40,10 @@ describe('ComboBox component', () => { defaultValue="apple" /> ) - expect(getByTestId('combo-box-select')).toBeInstanceOf(HTMLSelectElement) - expect(getByTestId('combo-box-select')).toHaveAttribute( - 'aria-hidden', - 'true' - ) - expect(getByTestId('combo-box-select')).toHaveClass('usa-sr-only') + const comboBoxSelect = getByTestId('combo-box-select') + expect(comboBoxSelect).toBeInstanceOf(HTMLSelectElement) + expect(comboBoxSelect).toHaveAttribute('aria-hidden', 'true') + expect(comboBoxSelect).toHaveClass('usa-sr-only') }) it('renders input element', () => { @@ -57,8 +55,10 @@ describe('ComboBox component', () => { onChange={jest.fn()} /> ) - expect(getByRole('combobox')).toBeInTheDocument() - expect(getByRole('combobox')).toBeInstanceOf(HTMLInputElement) + + const comboBox = getByRole('combobox') + expect(comboBox).toBeInTheDocument() + expect(comboBox).toBeInstanceOf(HTMLInputElement) }) it('renders hidden options list on load', () => { @@ -216,9 +216,9 @@ describe('ComboBox component', () => { selectProps={{ required: true, role: 'testing' }} /> ) - - expect(getByTestId('combo-box-select')).toHaveAttribute('required') - expect(getByTestId('combo-box-select')).toHaveAttribute('role', 'testing') + const comboBoxSelect = getByTestId('combo-box-select') + expect(comboBoxSelect).toHaveAttribute('required') + expect(comboBoxSelect).toHaveAttribute('role', 'testing') }) it('renders input with custom props if passed in', () => { @@ -232,8 +232,27 @@ describe('ComboBox component', () => { /> ) - expect(getByTestId('combo-box-input')).toHaveAttribute('required') - expect(getByTestId('combo-box-input')).toHaveAttribute('role', 'testing') + const comboBoxInput = getByTestId('combo-box-input') + expect(comboBoxInput).toHaveAttribute('required') + expect(comboBoxInput).toHaveAttribute('role', 'testing') + }) + + it('renders input with custom props if passed in', () => { + const { getByTestId } = render( + + ) + + const comboBoxOptionList = getByTestId('combo-box-option-list') + expect(comboBoxOptionList).toHaveAttribute( + 'aria-labelledby', + 'test-label-id' + ) }) // TODO: ❓ Don't know how to test this @@ -460,10 +479,9 @@ describe('ComboBox component', () => { expect(getByTestId('combo-box-clear-button')).not.toBeVisible() expect(getByTestId('combo-box-input')).toHaveValue('') expect(onChange).toHaveBeenNthCalledWith(2, undefined) - expect(getByTestId('combo-box-option-list')).not.toBeVisible() - expect(getByTestId('combo-box-option-list').children.length).toBe( - fruitOptions.length - ) + const comboBoxOptionList = getByTestId('combo-box-option-list') + expect(comboBoxOptionList).not.toBeVisible() + expect(comboBoxOptionList.children.length).toBe(fruitOptions.length) }) it('remains focused on the input after click', () => { @@ -533,9 +551,10 @@ describe('ComboBox component', () => { userEvent.type(getByTestId('combo-box-input'), 'zzz{enter}') + const comboBoxInput = getByTestId('combo-box-input') expect(getByTestId('combo-box-option-list')).not.toBeVisible() - expect(getByTestId('combo-box-input')).toHaveValue('') - expect(getByTestId('combo-box-input')).toHaveFocus() + expect(comboBoxInput).toHaveValue('') + expect(comboBoxInput).toHaveFocus() }) it('clears filter when there is no match and enter is pressed', () => { @@ -549,11 +568,9 @@ describe('ComboBox component', () => { ) userEvent.type(getByTestId('combo-box-input'), 'zzz{enter}') - - expect(getByTestId('combo-box-option-list')).not.toBeVisible() - expect(getByTestId('combo-box-option-list').children.length).toBe( - fruitOptions.length - ) + const comboBoxOptionList = getByTestId('combo-box-option-list') + expect(comboBoxOptionList).not.toBeVisible() + expect(comboBoxOptionList.children.length).toBe(fruitOptions.length) }) it('focuses the first filtered option with tab', () => { @@ -587,11 +604,9 @@ describe('ComboBox component', () => { userEvent.click(getByTestId('combo-box-input')) // open menu userEvent.tab() - expect(getByTestId('combo-box-option-apple')).toHaveFocus() - expect(getByTestId('combo-box-option-apple')).toHaveAttribute( - 'tabindex', - '0' - ) + const appleOption = getByTestId('combo-box-option-apple') + expect(appleOption).toHaveFocus() + expect(appleOption).toHaveAttribute('tabindex', '0') }) it('selects the focused option with tab', () => { @@ -643,11 +658,12 @@ describe('ComboBox component', () => { /> ) - userEvent.type(getByTestId('combo-box-input'), 'apri') + const comboBoxInput = getByTestId('combo-box-input') + userEvent.type(comboBoxInput, 'apri') userEvent.tab() userEvent.type(getByTestId('combo-box-option-apricot'), '{enter}') - expect(getByTestId('combo-box-input')).toHaveValue('Apricot') + expect(comboBoxInput).toHaveValue('Apricot') expect(onChange).toHaveBeenLastCalledWith('apricot') }) @@ -705,8 +721,9 @@ describe('ComboBox component', () => { /> ) - userEvent.click(getByTestId('combo-box-input')) - fireEvent.keyDown(getByTestId('combo-box-input'), { + const comboBoxInput = getByTestId('combo-box-input') + userEvent.click(comboBoxInput) + fireEvent.keyDown(comboBoxInput, { key: 'ArrowDown', }) @@ -724,8 +741,9 @@ describe('ComboBox component', () => { /> ) - userEvent.click(getByTestId('combo-box-input')) - fireEvent.keyDown(getByTestId('combo-box-input'), { + const comboBoxInput = getByTestId('combo-box-input') + userEvent.click(comboBoxInput) + fireEvent.keyDown(comboBoxInput, { key: 'ArrowDown', }) fireEvent.keyDown(getByTestId('combo-box-option-apple'), { @@ -733,7 +751,7 @@ describe('ComboBox component', () => { }) expect(getByTestId('combo-box-option-list')).not.toBeVisible() - expect(getByTestId('combo-box-input')).toHaveFocus() + expect(comboBoxInput).toHaveFocus() }) it('does not change focus when last option is focused and down arrow is pressed', () => { @@ -746,13 +764,14 @@ describe('ComboBox component', () => { /> ) + const yuzuOption = getByTestId('combo-box-option-yuzu') fireEvent.click(getByTestId('combo-box-input')) - userEvent.hover(getByTestId('combo-box-option-yuzu')) - fireEvent.keyDown(getByTestId('combo-box-option-yuzu'), { + userEvent.hover(yuzuOption) + fireEvent.keyDown(yuzuOption, { key: 'ArrowDown', }) - expect(getByTestId('combo-box-option-yuzu')).toHaveFocus() + expect(yuzuOption).toHaveFocus() }) it('pressing tab once in the input with a selected option focuses the clear button', () => { @@ -824,12 +843,13 @@ describe('ComboBox component', () => { ) // Apple is the item at top of list - userEvent.hover(getByTestId('combo-box-option-apple')) - fireEvent.keyDown(getByTestId('combo-box-option-apple'), { + const appleOption = getByTestId('combo-box-option-apple') + userEvent.hover(appleOption) + fireEvent.keyDown(appleOption, { key: 'ArrowUp', }) - expect(getByTestId('combo-box-option-apple')).toHaveFocus() + expect(appleOption).toHaveFocus() expect(getByTestId('combo-box-input')).toHaveAttribute( 'aria-expanded', 'true' @@ -927,10 +947,11 @@ describe('ComboBox component', () => { /> ) const input = getByTestId('combo-box-input') + const apricotOption = getByTestId('combo-box-option-apricot') userEvent.tab() - userEvent.hover(getByTestId('combo-box-option-apricot')) - fireEvent.keyDown(getByTestId('combo-box-option-apricot'), { + userEvent.hover(apricotOption) + fireEvent.keyDown(apricotOption, { key: 'tab', keyCode: 9, shiftKey: true, @@ -950,17 +971,14 @@ describe('ComboBox component', () => { onChange={jest.fn()} /> ) + const input = getByTestId('combo-box-input') + const optionList = getByTestId('combo-box-option-list') - fireEvent.click(getByTestId('combo-box-input')) + fireEvent.click(input) - expect(getByTestId('combo-box-input')).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(getByTestId('combo-box-option-list')).toBeVisible() - expect(getByTestId('combo-box-option-list').childElementCount).toEqual( - fruitOptions.length - ) + expect(input).toHaveAttribute('aria-expanded', 'true') + expect(optionList).toBeVisible() + expect(optionList.childElementCount).toEqual(fruitOptions.length) }) it('displays options list when input is clicked twice', () => { @@ -992,13 +1010,11 @@ describe('ComboBox component', () => { /> ) - fireEvent.click(getByTestId('combo-box-input')) - fireEvent.blur(getByTestId('combo-box-input')) + const input = getByTestId('combo-box-input') + fireEvent.click(input) + fireEvent.blur(input) - expect(getByTestId('combo-box-input')).toHaveAttribute( - 'aria-expanded', - 'false' - ) + expect(input).toHaveAttribute('aria-expanded', 'false') expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) @@ -1012,15 +1028,15 @@ describe('ComboBox component', () => { /> ) - fireEvent.click(getByTestId('combo-box-input')) - userEvent.hover(getByTestId('combo-box-option-blackberry')) + const input = getByTestId('combo-box-input') + const blackberryOption = getByTestId('combo-box-option-blackberry') - fireEvent.blur(getByTestId('combo-box-option-blackberry')) + fireEvent.click(input) - expect(getByTestId('combo-box-input')).toHaveAttribute( - 'aria-expanded', - 'false' - ) + userEvent.hover(blackberryOption) + fireEvent.blur(blackberryOption) + + expect(input).toHaveAttribute('aria-expanded', 'false') expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) @@ -1034,21 +1050,17 @@ describe('ComboBox component', () => { /> ) + const input = getByTestId('combo-box-input') + const optionList = getByTestId('combo-box-option-list') fireEvent.click(getByTestId('combo-box-toggle')) - expect(getByTestId('combo-box-input')).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(getByTestId('combo-box-option-list')).toBeVisible() + expect(input).toHaveAttribute('aria-expanded', 'true') + expect(optionList).toBeVisible() fireEvent.click(getByTestId('combo-box-toggle')) - expect(getByTestId('combo-box-input')).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(getByTestId('combo-box-option-list')).not.toBeVisible() + expect(input).toHaveAttribute('aria-expanded', 'false') + expect(optionList).not.toBeVisible() }) it('selects an item by clicking on an option', () => { @@ -1062,12 +1074,13 @@ describe('ComboBox component', () => { /> ) + const input = getByTestId('combo-box-input') fireEvent.click(getByTestId('combo-box-toggle')) fireEvent.click(getByTestId('combo-box-option-apple')) expect(onChange).toHaveBeenLastCalledWith('apple') - expect(getByTestId('combo-box-input')).toHaveDisplayValue('Apple') - expect(getByTestId('combo-box-input')).toHaveValue('Apple') + expect(input).toHaveDisplayValue('Apple') + expect(input).toHaveValue('Apple') }) it('persists input text when items list is blurred', () => { @@ -1084,13 +1097,14 @@ describe('ComboBox component', () => { ) + const input = getByTestId('combo-box-input') userEvent.click(getByTestId('combo-box-toggle')) userEvent.click(getByTestId('combo-box-option-apple')) - fireEvent.blur(getByTestId('combo-box-input')) + fireEvent.blur(input) expect(onChange).toHaveBeenLastCalledWith('apple') - expect(getByTestId('combo-box-input')).toHaveDisplayValue('Apple') - expect(getByTestId('combo-box-input')).toHaveValue('Apple') + expect(input).toHaveDisplayValue('Apple') + expect(input).toHaveValue('Apple') }) it('persists input text if dropdown is closed and open without selection', () => { @@ -1104,12 +1118,13 @@ describe('ComboBox component', () => { ) const input = getByTestId('combo-box-input') + const toggle = getByTestId('combo-box-toggle') userEvent.type(input, 'yu') - userEvent.click(getByTestId('combo-box-toggle')) + userEvent.click(toggle) expect(input).toHaveValue('yu') - userEvent.click(getByTestId('combo-box-toggle')) + userEvent.click(toggle) expect(input).toHaveValue('yu') }) @@ -1142,20 +1157,21 @@ describe('ComboBox component', () => { /> ) + const yuzuOption = getByTestId('combo-box-option-yuzu') + const blackberryOption = getByTestId('combo-box-option-blackberry') + userEvent.click(getByTestId('combo-box-toggle')) - userEvent.hover(getByTestId('combo-box-option-blackberry')) + userEvent.hover(blackberryOption) - expect(getByTestId('combo-box-option-blackberry')).toHaveClass( + expect(blackberryOption).toHaveClass( 'usa-combo-box__list-option--focused' ) - userEvent.hover(getByTestId('combo-box-option-yuzu')) - expect(getByTestId('combo-box-option-blackberry')).not.toHaveClass( - 'usa-combo-box__list-option--focused' - ) - expect(getByTestId('combo-box-option-yuzu')).toHaveClass( + userEvent.hover(yuzuOption) + expect(blackberryOption).not.toHaveClass( 'usa-combo-box__list-option--focused' ) + expect(yuzuOption).toHaveClass('usa-combo-box__list-option--focused') }) it('clears focus when clicking outside of the component', () => { diff --git a/src/components/forms/ComboBox/ComboBox.tsx b/src/components/forms/ComboBox/ComboBox.tsx index 14ebf5e193..16b83c8df2 100644 --- a/src/components/forms/ComboBox/ComboBox.tsx +++ b/src/components/forms/ComboBox/ComboBox.tsx @@ -37,6 +37,7 @@ interface ComboBoxProps { noResults?: string inputProps?: JSX.IntrinsicElements['input'] selectProps?: JSX.IntrinsicElements['select'] + ulProps?: JSX.IntrinsicElements['ul'] } interface InputProps { @@ -79,6 +80,7 @@ export const ComboBox = ({ noResults, selectProps, inputProps, + ulProps, ...customProps }: ComboBoxProps): React.ReactElement => { const isDisabled = !!disabled @@ -341,7 +343,8 @@ export const ComboBox = ({ id={listID} className="usa-combo-box__list" role="listbox" - hidden={!state.isOpen}> + hidden={!state.isOpen} + {...ulProps}> {state.filteredOptions.map((option, index) => { const focused = option === state.focusedOption const selected = option === state.selectedOption diff --git a/src/components/forms/DatePicker/Calendar.tsx b/src/components/forms/DatePicker/Calendar.tsx index 9faaa1723f..9a2b9d4095 100644 --- a/src/components/forms/DatePicker/Calendar.tsx +++ b/src/components/forms/DatePicker/Calendar.tsx @@ -1,10 +1,5 @@ import React, { useState, useRef, useEffect, KeyboardEvent } from 'react' -import { - DAY_OF_WEEK_LABELS, - DAY_OF_WEEK_SHORT_LABELS, - MONTH_LABELS, -} from './constants' import { today, addDays, @@ -43,6 +38,8 @@ import { MonthPicker } from './MonthPicker' import { YearPicker } from './YearPicker' import { FocusMode } from './DatePicker' +import { DatePickerLocalization, EN_US } from './i18n' + export const Calendar = ({ date, selectedDate, @@ -52,6 +49,7 @@ export const Calendar = ({ rangeDate, setStatuses, focusMode, + i18n = EN_US, }: { date?: Date selectedDate?: Date @@ -61,6 +59,7 @@ export const Calendar = ({ rangeDate?: Date setStatuses: (statuses: string[]) => void focusMode: FocusMode + i18n?: DatePickerLocalization }): React.ReactElement => { const prevYearEl = useRef(null) const prevMonthEl = useRef(null) @@ -97,7 +96,15 @@ export const Calendar = ({ const focusedMonth = dateToDisplay.getMonth() const focusedYear = dateToDisplay.getFullYear() - const monthLabel = MONTH_LABELS[parseInt(`${focusedMonth}`)] + const monthLabel = i18n.months[parseInt(`${focusedMonth}`)] + const dayOfWeekShortLabels = i18n.daysOfWeekShort + const dayOfWeekLabels = i18n.daysOfWeek + const backOneYear = i18n.backOneYear + const backOneMonth = i18n.backOneMonth + const clickToSelectMonth = `${monthLabel}. ${i18n.clickToSelectMonth}` + const clickToSelectYear = `${focusedYear}. ${i18n.clickToSelectYear}` + const forwardOneMonth = i18n.forwardOneMonth + const forwardOneYear = i18n.forwardOneYear useEffect(() => { calendarWasHidden = false @@ -137,8 +144,10 @@ export const Calendar = ({ if (calendarWasHidden) { const newStatuses = [`${monthLabel} ${focusedYear}`] - if (selectedDate && isSameDay(focusedDate, selectedDate)) - newStatuses.unshift('Selected date') + if (selectedDate && isSameDay(focusedDate, selectedDate)) { + const selectedDateText = i18n.selectedDate + newStatuses.unshift(selectedDateText) + } setStatuses(newStatuses) } }, [dateToDisplay]) @@ -150,6 +159,7 @@ export const Calendar = ({ minDate={minDate} maxDate={maxDate} handleSelectMonth={handleSelectMonth} + i18n={i18n} /> ) } else if (mode === CalendarModes.YEAR_PICKER) { @@ -282,7 +292,8 @@ export const Calendar = ({ const handleToggleMonthSelection = (): void => { setMode(CalendarModes.MONTH_PICKER) - setStatuses(['Select a month.']) + const selectAMonth = i18n.selectAMonth + setStatuses([selectAMonth]) } const handleToggleYearSelection = (): void => { @@ -325,6 +336,7 @@ export const Calendar = ({ withinRangeEndDate ) } + i18n={i18n} /> ) dateIterator = addDays(dateIterator, 1) @@ -348,7 +360,7 @@ export const Calendar = ({ onClick={handlePreviousYearClick} ref={prevYearEl} className="usa-date-picker__calendar__previous-year" - aria-label="Navigate back one year" + aria-label={backOneYear} disabled={prevButtonsDisabled}>   @@ -360,7 +372,7 @@ export const Calendar = ({ onClick={handlePreviousMonthClick} ref={prevMonthEl} className="usa-date-picker__calendar__previous-month" - aria-label="Navigate back one month" + aria-label={backOneMonth} disabled={prevButtonsDisabled}>   @@ -372,7 +384,7 @@ export const Calendar = ({ onClick={handleToggleMonthSelection} ref={selectMonthEl} className="usa-date-picker__calendar__month-selection" - aria-label={`${monthLabel}. Click to select month`}> + aria-label={clickToSelectMonth}> {monthLabel}
@@ -392,7 +404,7 @@ export const Calendar = ({ onClick={handleNextMonthClick} ref={nextMonthEl} className="usa-date-picker__calendar__next-month" - aria-label="Navigate forward one month" + aria-label={forwardOneMonth} disabled={nextButtonsDisabled}>   @@ -404,7 +416,7 @@ export const Calendar = ({ onClick={handleNextYearClick} ref={nextYearEl} className="usa-date-picker__calendar__next-year" - aria-label="Navigate forward one year" + aria-label={forwardOneYear} disabled={nextButtonsDisabled}>   @@ -413,11 +425,11 @@ export const Calendar = ({ - {DAY_OF_WEEK_SHORT_LABELS.map((d, i) => ( + {dayOfWeekShortLabels.map((d, i) => ( diff --git a/src/components/forms/DatePicker/DatePicker.stories.tsx b/src/components/forms/DatePicker/DatePicker.stories.tsx index 477b3655ab..36538f2cc3 100644 --- a/src/components/forms/DatePicker/DatePicker.stories.tsx +++ b/src/components/forms/DatePicker/DatePicker.stories.tsx @@ -1,6 +1,7 @@ import React from 'react' import { DatePicker } from './DatePicker' +import { sampleLocalization } from './i18n' import { Form } from '../Form/Form' import { FormGroup } from '../FormGroup/FormGroup' import { Label } from '../Label/Label' @@ -109,3 +110,7 @@ export const withRangeDate = (): React.ReactElement => ( rangeDate="2021-01-08" /> ) + +export const withLocalizations = (): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/DatePicker.test.tsx b/src/components/forms/DatePicker/DatePicker.test.tsx index e8143f6f4e..7a5a5f7557 100644 --- a/src/components/forms/DatePicker/DatePicker.test.tsx +++ b/src/components/forms/DatePicker/DatePicker.test.tsx @@ -3,6 +3,7 @@ import { render, fireEvent, createEvent } from '@testing-library/react' import userEvent, { specialChars } from '@testing-library/user-event' import { DatePicker } from './DatePicker' +import { sampleLocalization } from './i18n' import { today } from './utils' import { DAY_OF_WEEK_LABELS, @@ -376,6 +377,29 @@ describe('DatePicker component', () => { }) }) + describe('with localization props', () => { + it('displays abbreviated translations for days of the week', () => { + const { getByText, getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + sampleLocalization.daysOfWeekShort.forEach((translation) => { + expect(getByText(translation)).toBeInTheDocument() + }) + }) + it('displays translation for month', () => { + const { getByText, getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByText('febrero')).toBeInTheDocument() + }) + }) + describe('selecting a date', () => { it('clicking a date button selects that date and closes the calendar and focuses the external input', () => { const mockOnChange = jest.fn() diff --git a/src/components/forms/DatePicker/DatePicker.tsx b/src/components/forms/DatePicker/DatePicker.tsx index 566f1c7f7f..3a88b06771 100644 --- a/src/components/forms/DatePicker/DatePicker.tsx +++ b/src/components/forms/DatePicker/DatePicker.tsx @@ -13,6 +13,7 @@ import { VALIDATION_MESSAGE, DEFAULT_MIN_DATE, } from './constants' +import { DatePickerLocalization, EN_US } from './i18n' import { formatDate, parseDateString, @@ -38,6 +39,7 @@ interface BaseDatePickerProps { onBlur?: ( event: React.FocusEvent | React.FocusEvent ) => void + i18n?: DatePickerLocalization } export type DatePickerProps = BaseDatePickerProps & @@ -60,6 +62,7 @@ export const DatePicker = ({ rangeDate, onChange, onBlur, + i18n = EN_US, ...inputProps }: DatePickerProps): React.ReactElement => { const datePickerEl = useRef(null) @@ -185,17 +188,12 @@ export const DatePicker = ({ setCalendarDisplayValue(displayDate) setCalendarPosY(datePickerEl?.current?.offsetHeight) - const statuses = [ - 'You can navigate by day using left and right arrows', - 'Weeks by using up and down arrows', - 'Months by using page up and page down keys', - 'Years by using shift plus page up and shift plus page down', - 'Home and end keys navigate to the beginning and end of a week', - ] + const statuses = i18n.statuses const selectedDate = parseDateString(internalValue) if (selectedDate && isSameDay(selectedDate, addDays(displayDate, 0))) { - statuses.unshift('Selected date') + const selectedDateText = i18n.selectedDate + statuses.unshift(selectedDateText) } setStatuses(statuses) @@ -244,6 +242,8 @@ export const DatePicker = ({ className ) + const toggleCalendar = i18n.toggleCalendar + return ( // Ignoring error: "Static HTML elements with event handlers require a role." // Ignoring because this element does not have a role in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L828) @@ -292,7 +292,7 @@ export const DatePicker = ({ type="button" className="usa-date-picker__button" aria-haspopup={true} - aria-label="Toggle calendar" + aria-label={toggleCalendar} disabled={disabled} onClick={handleToggleClick}>   @@ -319,6 +319,7 @@ export const DatePicker = ({ selectedDate={parseDateString(internalValue)} setStatuses={setStatuses} focusMode={focusMode} + i18n={i18n} /> )} diff --git a/src/components/forms/DatePicker/Day.tsx b/src/components/forms/DatePicker/Day.tsx index 50bb7f3e29..bbacdef28f 100644 --- a/src/components/forms/DatePicker/Day.tsx +++ b/src/components/forms/DatePicker/Day.tsx @@ -1,9 +1,10 @@ import React, { forwardRef, KeyboardEvent } from 'react' import classnames from 'classnames' -import { DAY_OF_WEEK_LABELS, MONTH_LABELS } from './constants' import { formatDate, isIosDevice } from './utils' +import { DatePickerLocalization, EN_US } from './i18n' + export const Day = forwardRef( ( { @@ -22,6 +23,7 @@ export const Day = forwardRef( isRangeStart = false, isRangeEnd = false, isWithinRange = false, + i18n = EN_US, }: { date: Date onClick: (value: string) => void @@ -38,6 +40,7 @@ export const Day = forwardRef( isRangeStart?: boolean isRangeEnd?: boolean isWithinRange?: boolean + i18n?: DatePickerLocalization }, ref: React.ForwardedRef ): React.ReactElement => { @@ -62,8 +65,8 @@ export const Day = forwardRef( 'usa-date-picker__calendar__date--within-range': isWithinRange, }) - const monthStr = MONTH_LABELS[parseInt(`${month}`)] - const dayStr = DAY_OF_WEEK_LABELS[parseInt(`${dayOfWeek}`)] + const monthStr = i18n.months[parseInt(`${month}`)] + const dayStr = i18n.daysOfWeek[parseInt(`${dayOfWeek}`)] const handleClick = (): void => { onClick(formattedDate) diff --git a/src/components/forms/DatePicker/MonthPicker.test.tsx b/src/components/forms/DatePicker/MonthPicker.test.tsx index 44d1b1e3df..243b94e66a 100644 --- a/src/components/forms/DatePicker/MonthPicker.test.tsx +++ b/src/components/forms/DatePicker/MonthPicker.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { MonthPicker } from './MonthPicker' import { MONTH_LABELS } from './constants' import { parseDateString } from './utils' +import { sampleLocalization } from './i18n' describe('MonthPicker', () => { const testProps = { @@ -212,4 +213,19 @@ describe('MonthPicker', () => { expect(getByText('January')).toHaveFocus() }) }) + + describe('with localization props', () => { + it('displays month translations', () => { + const { getByText } = render( + + ) + sampleLocalization.months.forEach((translation) => { + expect(getByText(translation)).toBeInTheDocument() + }) + }) + }) }) diff --git a/src/components/forms/DatePicker/MonthPicker.tsx b/src/components/forms/DatePicker/MonthPicker.tsx index 5a066867c0..f920e5494c 100644 --- a/src/components/forms/DatePicker/MonthPicker.tsx +++ b/src/components/forms/DatePicker/MonthPicker.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef, KeyboardEvent } from 'react' import classnames from 'classnames' -import { MONTH_LABELS } from './constants' import { isDatesMonthOutsideMinOrMax, isSameMonth, @@ -12,16 +11,20 @@ import { isIosDevice, } from './utils' +import { DatePickerLocalization, EN_US } from './i18n' + export const MonthPicker = ({ date, minDate, maxDate, handleSelectMonth, + i18n = EN_US, }: { date: Date minDate: Date maxDate?: Date handleSelectMonth: (value: number) => void + i18n?: DatePickerLocalization }): React.ReactElement => { const selectedMonth = date.getMonth() const [monthToDisplay, setMonthToDisplay] = useState(selectedMonth) @@ -92,7 +95,9 @@ export const MonthPicker = ({ event.preventDefault() } - const months = MONTH_LABELS.map((month, index) => { + const monthNames = i18n.months + + const months = monthNames.map((month, index) => { const monthToCheck = setMonth(date, index) const isDisabled = isDatesMonthOutsideMinOrMax( monthToCheck, diff --git a/src/components/forms/DatePicker/i18n.ts b/src/components/forms/DatePicker/i18n.ts new file mode 100644 index 0000000000..e6e723d497 --- /dev/null +++ b/src/components/forms/DatePicker/i18n.ts @@ -0,0 +1,86 @@ +import { + MONTH_LABELS, + DAY_OF_WEEK_LABELS, + DAY_OF_WEEK_SHORT_LABELS, +} from './constants' + +export interface DatePickerLocalization { + months: string[] + daysOfWeek: string[] + daysOfWeekShort: string[] + statuses: string[] + selectedDate: string + selectAMonth: string + toggleCalendar: string + backOneYear: string + backOneMonth: string + clickToSelectMonth: string + clickToSelectYear: string + forwardOneYear: string + forwardOneMonth: string +} + +export const EN_US = { + months: MONTH_LABELS, + daysOfWeek: DAY_OF_WEEK_LABELS, + daysOfWeekShort: DAY_OF_WEEK_SHORT_LABELS, + statuses: [ + 'You can navigate by day using left and right arrows', + 'Weeks by using up and down arrows', + 'Months by using page up and page down keys', + 'Years by using shift plus page up and shift plus page down', + 'Home and end keys navigate to the beginning and end of a week', + ], + selectedDate: 'Selected date', + selectAMonth: 'Select a month.', + toggleCalendar: 'Toggle calendar', + backOneYear: 'Navigate back one year', + backOneMonth: 'Navigate back one month', + forwardOneYear: 'Navigate forward one year', + forwardOneMonth: 'Navigate forward one month', + clickToSelectMonth: 'Click to select month', + clickToSelectYear: 'Click to select year', +} + +export const sampleLocalization = { + months: [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', + ], + daysOfWeek: [ + 'domingo', + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + ], + daysOfWeekShort: ['Do', 'Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa'], + statuses: [ + 'Puede navegar por día usando las flechas izquierda y derecha', + 'Semanas usando flechas hacia arriba y hacia abajo', + 'Meses usando las teclas de avance y retroceso de página', + 'Años usando shift plus page up y shift plus page down', + 'Las teclas de inicio y finalización navegan hasta el principio y el final de una semana', + ], + selectedDate: 'Fecha seleccionada', + selectAMonth: 'Selecciona un mes.', + toggleCalendar: 'Alternar calendario', + backOneYear: 'Navegar hacia atrás un año', + backOneMonth: 'Navegar hacia atrás un mes', + forwardOneYear: 'Navegar hacia adelante un año', + forwardOneMonth: 'Navegar hacia adelante un mes', + clickToSelectMonth: 'Haga clic para seleccionar el mes', + clickToSelectYear: 'Haga clic para seleccionar el año', +} diff --git a/src/stories/templates/documentation.stories.tsx b/src/stories/templates/documentation.stories.tsx index 59830e9a71..f429c8e3ae 100644 --- a/src/stories/templates/documentation.stories.tsx +++ b/src/stories/templates/documentation.stories.tsx @@ -155,7 +155,7 @@ export const DocumentationPage = (): React.ReactElement => { } - heading={

Name of Agency

} + heading={

Name of Agency

} /> { href="javascript:void(0);"> YouTube , + + Instagram + , { } - heading={

Name of Agency

} + heading={

Name of Agency

} /> { href="javascript:void(0);"> YouTube
, + + Instagram + , =2.0.0 <4.0.0", chokidar@^3.4.1: +"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== @@ -12280,11 +12280,11 @@ sass-resources-loader@^2.0.1: loader-utils "^2.0.0" sass@^1.26.0: - version "1.32.8" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.8.tgz#f16a9abd8dc530add8834e506878a2808c037bdc" - integrity sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ== + version "1.32.10" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.10.tgz#d40da4e20031b450359ee1c7e69bc8cc89569241" + integrity sha512-Nx0pcWoonAkn7CRp0aE/hket1UP97GiR1IFw3kcjV3pnenhWgZEWUf0ZcfPOV2fK52fnOcK3JdC/YYZ9E47DTQ== dependencies: - chokidar ">=2.0.0 <4.0.0" + chokidar ">=3.0.0 <4.0.0" saxes@^5.0.0: version "5.0.1" @@ -13944,10 +13944,11 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -uswds@2.10.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.10.0.tgz#c7ecb779c82302fa189b36ca3eefe59dfde7f782" - integrity sha512-A6HjL42ryf9pdfbm6JrGxZywKzCZraHO4v3Ve21uFDqoA3ijoNjiSYME+3SG86CIgC6zRasrbQVuI3bvd3Xv2w== +uswds@2.10.3: + version "2.10.3" + resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.10.3.tgz#16d34cee81897762d6d69d3ac83aa55129826fa6" + integrity sha512-krNRzx1jRzOJpuH/qtmQhd5zxnXTaDVqrPNYT99sJbxzWUqjb1zZHh3jFNo+xKDpNuiO0XMPwZwlaSp2YdZ3Ag== + dependencies: classlist-polyfill "^1.0.3" del "^6.0.0"
{d}