diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1af755a1b3..90a0092c80 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -211,7 +211,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: yarn diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index e8fd3ed396..aef65d8ffa 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -81,7 +81,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'yarn' diff --git a/docs/technical-design/howto-update-state-programs.md b/docs/technical-design/howto-update-state-programs.md index 24a33b1b3b..5eb222c6b5 100644 --- a/docs/technical-design/howto-update-state-programs.md +++ b/docs/technical-design/howto-update-state-programs.md @@ -11,6 +11,7 @@ The source of truth for that file comes from a CSV maintained by product and des 1. Download the latest version of csv from google docs when prompted by product/design. 2. Run the script following the command listed in the `import-programs.ts`. -3. Overwrite existing state programs JSON with the new output. Your usage of the script will likely look something like this: `cd scripts && yarn tsc && node import-programs.js ~/Desktop/State\ programs,\ population,\ and\ nicknames.csv > ../services/app-web/src/common-code/data/statePrograms.json` +3. Overwrite existing state programs JSON with the new output. Your usage of the script will likely look something like this: `cd scripts && yarn tsc && node import-programs.js path/to/data.csv > ../services/app-web/src/common-code/data/statePrograms.json` 4. Double check the diff. It's important not to delete any programs that have already been used for a submission because although programs are not in the database, we still store references to the program ID in postgres as if they are stable. Also, we want to be sure we are only changing programs expected to change. -5. Make a PR to update the statePrograms file in the codebase +5. For any newly created programs, manually populate the `id` field usings a UUID generator +6. Make a PR to update the statePrograms file in the codebase diff --git a/scripts/import-programs.ts b/scripts/import-programs.ts index 6b76d8755d..519ae35c2e 100644 --- a/scripts/import-programs.ts +++ b/scripts/import-programs.ts @@ -2,13 +2,16 @@ This script is used to generate a list of MC-Review state programs. To read more about this script and why it's used see "How to update state programs" technical design docs. To run: - yarn tsc && node ./import-programs.js path/to/data.csv + yarn tsc && node ./import-programs.js path/to/data.csv > ../services/app-web/src/common-code/data/statePrograms.json The input file is expected to be a valid CSV with at least the following columns: 1 State (two-character state code, uppercase) 2 Program (full program name) 3 Nickname (acronym or abbreviation e.g. "CME") +Documentation for this script can be found here: +https://github.com/Enterprise-CMCS/managed-care-review/blob/main/docs/technical-design/howto-update-state-programs.md + Additional columns aren't used and should be ignored. */ diff --git a/services/app-api/src/resolvers/attributeHelper.ts b/services/app-api/src/resolvers/attributeHelper.ts index 1d49eeac5a..79dd7214c1 100644 --- a/services/app-api/src/resolvers/attributeHelper.ts +++ b/services/app-api/src/resolvers/attributeHelper.ts @@ -8,7 +8,10 @@ export function setResolverDetailsOnActiveSpan( user: Context['user'], span: Context['span'] ): void { - if (!span) return + if (!span) { + console.info(`No span set on ${name} call`) + return + } span.setAttributes({ [SemanticAttributes.ENDUSER_ID]: user.email, [SemanticAttributes.ENDUSER_ROLE]: user.role, diff --git a/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.ts b/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.ts index fc2dbec38e..fe220799ef 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.ts @@ -53,7 +53,8 @@ export function indexHealthPlanPackagesResolver( ): QueryResolvers['indexHealthPlanPackages'] { return async (_parent, _args, context) => { const { user, span } = context - setResolverDetailsOnActiveSpan('fetchHealthPlanPackage', user, span) + + setResolverDetailsOnActiveSpan('indexHealthPlanPackages', user, span) const ratesDatabaseRefactor = await launchDarkly.getFeatureFlag( context, @@ -70,7 +71,7 @@ export function indexHealthPlanPackagesResolver( if (contractsWithHistory instanceof Error) { const errMessage = `Issue finding contracts with history by stateCode: ${user.stateCode}. Message: ${contractsWithHistory.message}` - logError('fetchHealthPlanPackage', errMessage) + logError('indexHealthPlanPackages', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) if (contractsWithHistory instanceof NotFoundError) { @@ -111,7 +112,7 @@ export function indexHealthPlanPackagesResolver( if (contractsWithHistory instanceof Error) { const errMessage = `Issue finding contracts with history by submit info. Message: ${contractsWithHistory.message}` - logError('fetchHealthPlanPackage', errMessage) + logError('indexHealthPlanPackages', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) if (contractsWithHistory instanceof NotFoundError) { diff --git a/services/app-api/src/resolvers/user/fetchCurrentUser.test.ts b/services/app-api/src/resolvers/user/fetchCurrentUser.test.ts index f93bc1ec7e..373c4d0791 100644 --- a/services/app-api/src/resolvers/user/fetchCurrentUser.test.ts +++ b/services/app-api/src/resolvers/user/fetchCurrentUser.test.ts @@ -16,7 +16,7 @@ describe('currentUser', () => { expect(res.data?.fetchCurrentUser.email).toBe('james@example.com') expect(res.data?.fetchCurrentUser.state.code).toBe('FL') - expect(res.data?.fetchCurrentUser.state.programs).toHaveLength(6) + expect(res.data?.fetchCurrentUser.state.programs).toHaveLength(5) }) it('returns programs for MI', async () => { diff --git a/services/app-proto/src/health_plan_form_data.proto b/services/app-proto/src/health_plan_form_data.proto index 5149a18194..dbd9883393 100644 --- a/services/app-proto/src/health_plan_form_data.proto +++ b/services/app-proto/src/health_plan_form_data.proto @@ -315,4 +315,5 @@ enum StateCode { STATE_CODE_WI = 50; STATE_CODE_WV = 51; STATE_CODE_WY= 52; + STATE_CODE_KY = 53; } diff --git a/services/app-web/src/assets/icons/ky-icon.svg b/services/app-web/src/assets/icons/ky-icon.svg new file mode 100644 index 0000000000..537793a515 --- /dev/null +++ b/services/app-web/src/assets/icons/ky-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/app-web/src/common-code/data/statePrograms.json b/services/app-web/src/common-code/data/statePrograms.json index bab5224255..21341c8ad0 100644 --- a/services/app-web/src/common-code/data/statePrograms.json +++ b/services/app-web/src/common-code/data/statePrograms.json @@ -137,16 +137,6 @@ "fullName": "AIDS Healthcare Foundation ", "name": "AHF" }, - { - "id": "53a6a9ce-e2f9-47af-9795-12901e13a986", - "fullName": "Whole Child Model", - "name": "WCM" - }, - { - "id": "7cec9b0c-3833-4933-bc05-a9287a76d3ec", - "fullName": "Health Homes", - "name": "HHP" - }, { "id": "40ca8541-3f42-45e7-8baa-835337c299c6", "fullName": "Specialty Mental Health Services", @@ -237,14 +227,9 @@ "name": "Florida", "programs": [ { - "id": "c23b4688-b33c-4be8-b7cc-2760efb4932d", - "fullName": "Non-Emergency Medical Transportation - Medical Transportation Management", - "name": "NEMT MTM" - }, - { - "id": "79041bc2-a3c7-43bf-b88a-1151cdedbcb1", - "fullName": "Non-Emergency Medical Transportation - LogistiCare", - "name": "NEMT LC" + "id": "712277fb-f43f-4eb5-98c5-6c6a97509201", + "fullName": "Non-Emergency Medical Transportation", + "name": "NEMT" }, { "id": "037af66b-81eb-4472-8b80-01edf17d12d9", @@ -306,7 +291,7 @@ { "id": "47995323-dd69-4ea5-afeb-eef15d6da27f", "fullName": "Hawaii QUEST Integration", - "name": "Quest" + "name": "QI" }, { "id": "2bf8cc98-2330-4e58-9e2c-96f25daa7b28", @@ -353,7 +338,7 @@ { "id": "9cc424d8-b833-41be-994d-0f7d689b7c82", "fullName": "Behavioral Health Plan", - "name": "IBHP" + "name": "BHP" }, { "id": "e53f0506-84a5-47de-8400-e71f15c8d590", @@ -364,11 +349,6 @@ "id": "d0c0f2cb-c295-4c23-a301-24214aa5fa3d", "fullName": "Healthy Connections", "name": "Healthy Connections" - }, - { - "id": "4412b5cc-885f-4fa4-9f99-0c0f558dec18", - "fullName": "Non-Emergency Medical Transportation", - "name": "NEMT" } ], "code": "ID" @@ -431,7 +411,7 @@ "programs": [ { "id": "acb14791-7d88-4c10-a127-68f0a694d62f", - "fullName": "KY Non-Emergency Transportation Program", + "fullName": "Non-Emergency Medical Transportation Program", "name": "NEMT " }, { @@ -468,7 +448,7 @@ "programs": [ { "id": "1076bd88-69c7-470f-839c-481c0ad13b97", - "fullName": "MassHealth Managed Care (MassHealth MCO Program)", + "fullName": "MassHealth Managed Care Organization Program", "name": "MCO" }, { @@ -481,14 +461,9 @@ "fullName": "Primary Care Accountable Care Organizations", "name": "PCACO" }, - { - "id": "40391715-cde1-4248-b683-e106584ac66c", - "fullName": "Senior Care Options", - "name": "SCO" - }, { "id": "4e76c974-5741-4272-8c8a-9005d70259b6", - "fullName": "MassHealth BH/SUD PIHP", + "fullName": "Managed Behavioral Health Program", "name": "MBHP" } ], @@ -501,11 +476,6 @@ "id": "d64e707e-0f42-4c57-88f9-09bc065f3f22", "fullName": "HealthChoice", "name": "HealthChoice" - }, - { - "id": "663546f5-a8de-4f33-93ae-c506ec48353e", - "fullName": "Hopkins Elder Plus", - "name": "Hopkins Elder Plus" } ], "code": "MD" @@ -636,13 +606,13 @@ "programs": [ { "id": "75358ece-92f0-4c89-a45b-817f80d8be37", - "fullName": "1915(b)/(c) Medicaid Waiver for MH/DD/SA Services", + "fullName": "Behavioral Health Medicaid Managed Care Program", "name": "LME/MCO" }, { "id": "b4e8d1e4-0520-4fa6-b472-d3b655d3efdc", "fullName": "Managed Care PrePaid Health Plan", - "name": "PHP" + "name": "MC PHP" }, { "id": "fae74329-159c-42c1-9f7a-eb1e82afd2c5", @@ -703,19 +673,19 @@ "name": "New Hampshire", "programs": [ { - "id": "8469324c-39a3-46f2-88b4-ed5bbb7dbe46", - "fullName": "New Hampshire Medicaid Care Management", - "name": "MCM " + "id": "fa2a5d94-ff57-4a4b-9f94-26f4a8de7668", + "fullName": "Dental", + "name": "Dental" }, { - "id": "df14e72a-91e2-4c61-a833-12d38eae3e1b", - "fullName": "New Hampshire Health Protection Program Premium Assistance Demonstration for QHP", - "name": "NHHPPP" + "id": "8469324c-39a3-46f2-88b4-ed5bbb7dbe46", + "fullName": "Medicaid Care Management", + "name": "MCM " }, { - "id": "e5097a9c-704e-4b20-a5f5-63262559d055", - "fullName": "Heritage Health Adult ", - "name": "HHA" + "id": "f9a8ace4-1de4-443f-bf82-67fbb9558ff6", + "fullName": "Granite Advantage Health Plan Program", + "name": "GAHCP" } ], "code": "NH" @@ -812,15 +782,25 @@ { "name": "Ohio", "programs": [ + { + "id": "889f525e-08c7-4e62-9df9-b67186a87975", + "fullName": "Ohio Resilience through Integrated Systems and Excellence", + "name": "OhioRISE" + }, { "id": "c4ebe21a-5dd1-4308-a851-c31983b3b974", "fullName": "Ohio Medicaid Managed Care Program", - "name": "MyCare" + "name": "MMC" }, { "id": "d9e3bbf5-f3a3-404c-a510-998d603a3673", "fullName": "MyCare Ohio Opt-Out Program", - "name": "MMC" + "name": "MyCare" + }, + { + "id": "f8fb337f-6f69-4b3d-9cdd-0b257f3ba9da", + "fullName": "Single Pharmacy Benefit Manager", + "name": "SPBM" } ], "code": "OH" @@ -832,6 +812,21 @@ "id": "6d1f77e3-1e97-4f57-9da0-e49358ab97e2", "fullName": "SoonerCare Choice", "name": "SoonerCare Choice" + }, + { + "id": "58b4e72f-219d-4648-84ae-deea4a703965", + "fullName": "SoonerSelect Dental", + "name": "SoonerSelect Dental" + }, + { + "id": "70239e21-dd55-47ae-9156-ef910eb69e49", + "fullName": "SoonerSelect Children's Specialty", + "name": "SoonerSelect Children's Specialty" + }, + { + "id": "4e274fe6-3645-432b-8629-b4b13bc0ee77", + "fullName": "SoonerSelect Medical", + "name": "SoonerSelect Medical" } ], "code": "OK" @@ -841,8 +836,8 @@ "programs": [ { "id": "7ae073dd-dbff-4bc9-86b8-d27954dd5776", - "fullName": "Oregon Health Plan", - "name": "Oregon Health Plan" + "fullName": "Healthier Oregon Program", + "name": "HOP" }, { "id": "46246196-3b63-40be-8db1-c09cec281dae", @@ -860,14 +855,9 @@ { "name": "Pennsylvania", "programs": [ - { - "id": "867b91fa-f470-4553-8e99-5e15afd891b5", - "fullName": "Living Independence for the Elderly", - "name": "LIFE" - }, { "id": "ef45f326-8b3a-44a9-aac6-8fa73400680a", - "fullName": "HealthChoices - Physical Health", + "fullName": "HealthChoices Physical Health", "name": "PH" }, { @@ -877,7 +867,7 @@ }, { "id": "65f965cd-7823-4777-bfb1-688b36bb92b2", - "fullName": "Behavioral Health HealthChoices", + "fullName": "HealthChoices Behavioral Health", "name": "BH" } ], @@ -948,16 +938,6 @@ "id": "3fe3d26b-175f-4550-92a2-9f86b273c0f0", "fullName": "TennCare II", "name": "TennCare" - }, - { - "id": "288f4a6c-1772-46bb-bf88-e57d375a91a6", - "fullName": "TennCare CHOICES", - "name": "CHOICES" - }, - { - "id": "1bf0f2f9-849d-48e6-a233-601af51fd477", - "fullName": "Non-CHOICES  ", - "name": "Non-CHOICES" } ], "code": "TN" @@ -1040,11 +1020,6 @@ "id": "2f4bfd6c-c25a-44b1-9010-7367d13e7325", "fullName": "CHIP Dental", "name": "CHIP Dental" - }, - { - "id": "c20d27e3-ce47-4f6c-911c-e09bcb38e6ff", - "fullName": "Expansion Prepaid Mental Health Planas", - "name": "ExPMHP" } ], "code": "UT" @@ -1071,12 +1046,7 @@ { "id": "2dacc6c3-ed0b-4c95-a17c-47f8d3340511", "fullName": "Global Commitment to Health", - "name": "GCH" - }, - { - "id": "e5027571-9a8b-4022-a3a9-5d6f8a0f83b5", - "fullName": "Vermont Medicaid Next Generation Accountable Care Organization (VMNG)", - "name": "ACO" + "name": "GCTH" } ], "code": "VT" @@ -1087,17 +1057,12 @@ { "id": "7877a1e5-bdda-4668-b379-e35bde146536", "fullName": "Apple Health Integrated Managed Care (FIMC)", - "name": "AHIMC" - }, - { - "id": "64249023-8194-4ed5-8268-0e3099b83093", - "fullName": "Behavioral Health Services Only", - "name": "BHSO" + "name": "Apple Health" }, { "id": "9f5c066a-9e62-4f1a-8f77-090718b11c1a", "fullName": "Apple Health Integrated Foster Care", - "name": "AHIFC" + "name": "IFC" }, { "id": "5601ebf2-5f94-49cb-9994-bc5d4b874bc0", @@ -1113,7 +1078,7 @@ { "id": "03152a0a-0888-4b24-87a8-1a7371e9b8e4", "fullName": "BadgerCare Plus", - "name": "BCP" + "name": "BadgerCare" }, { "id": "9843c1dc-981c-4d2f-a04f-83b2a8ff254d", @@ -1123,18 +1088,13 @@ { "id": "8d289aae-c5f5-4af8-a7a2-8b347273dfee", "fullName": "Family Care Partnership Program", - "name": "Partnership" + "name": "Family Care Partnership" }, { "id": "e00f0ae0-7282-4087-8e1f-232ce967d5c3", "fullName": "Family Care", "name": "Family Care" }, - { - "id": "0f55782c-3bd8-4e6b-952e-7b3facfa0d4e", - "fullName": "Children Come First", - "name": "CCF" - }, { "id": "dc7d4bf5-c2bc-4b36-871b-f0761d040ecd", "fullName": "WrapAround Milwaukee", @@ -1158,7 +1118,7 @@ }, { "id": "76fd2c76-decd-41c4-924b-82820d8fe3f0", - "fullName": "Specialized Managed Care Plan for Children and Youth Program (Mountain Health Promise )", + "fullName": "Mountain Health Promise", "name": "MHP" }, { diff --git a/services/app-web/src/common-code/dateHelpers/dayjs.ts b/services/app-web/src/common-code/dateHelpers/dayjs.ts index c51a0fe180..1cdc3fefae 100644 --- a/services/app-web/src/common-code/dateHelpers/dayjs.ts +++ b/services/app-web/src/common-code/dateHelpers/dayjs.ts @@ -4,11 +4,13 @@ import isLeapYear from 'dayjs/plugin/isLeapYear' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import duration from 'dayjs/plugin/duration' +import customParseFormat from 'dayjs/plugin/customParseFormat' dayjs.extend(utc) dayjs.extend(advancedFormat) dayjs.extend(timezone) dayjs.extend(isLeapYear) dayjs.extend(duration) +dayjs.extend(customParseFormat) export { dayjs } diff --git a/services/app-web/src/common-code/healthPlanFormDataType/StateCodeType.ts b/services/app-web/src/common-code/healthPlanFormDataType/StateCodeType.ts index 5b4071018f..d450396caf 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/StateCodeType.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/StateCodeType.ts @@ -17,6 +17,7 @@ const StateCodes = [ 'IL', 'IN', 'KS', + 'KY', 'LA', 'MA', 'MD', @@ -53,7 +54,7 @@ const StateCodes = [ 'WY', ] as const -type StateCodeType = typeof StateCodes[number] // iterable union type +type StateCodeType = (typeof StateCodes)[number] // iterable union type function isValidStateCode( maybeStateCode: string diff --git a/services/app-web/src/components/FilterAccordion/FilterAccordion.module.scss b/services/app-web/src/components/FilterAccordion/FilterAccordion.module.scss index f304ea33cd..52a81d5a39 100644 --- a/services/app-web/src/components/FilterAccordion/FilterAccordion.module.scss +++ b/services/app-web/src/components/FilterAccordion/FilterAccordion.module.scss @@ -1,13 +1,6 @@ @import '../../styles/uswdsImports.scss'; @import '../../styles/custom.scss'; -.filters { - display: grid; - grid-template-columns: repeat(2, 1fr); - column-gap: 24px; - row-gap: 24px; -} - .filterTitle { font-weight: bold; } @@ -22,5 +15,6 @@ .filterAccordion { [class*='usa-accordion__content'] { background: $cms-color-gray-lightest; + overflow: visible; } } diff --git a/services/app-web/src/components/FilterAccordion/FilterAccordion.tsx b/services/app-web/src/components/FilterAccordion/FilterAccordion.tsx index 1440435fd6..da93e046c0 100644 --- a/services/app-web/src/components/FilterAccordion/FilterAccordion.tsx +++ b/services/app-web/src/components/FilterAccordion/FilterAccordion.tsx @@ -29,7 +29,7 @@ export const FilterAccordion = ({ headingLevel: 'h4', content: ( <> -
{childFilters}
+
{childFilters}
+ +
+ +
+
+ + +
+
+ +
+
+ +
+ + + + + {dayOfWeekShortLabels.map((d, i) => ( + + ))} + + + {listToTable(days, 7)} +
+ {d} +
+ + ) +} diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.stories.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000000..e07a080adc --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.stories.tsx @@ -0,0 +1,146 @@ +import React from 'react' + +import { DatePicker } from './DatePicker' +import { sampleLocalization } from './i18n' +import { ValidationStatus } from './DatePicker' +import { Form, FormGroup, Label, TextInput } from '@trussworks/react-uswds' + +export default { + title: 'Components/Date picker', + component: DatePicker, + argTypes: { + onSubmit: { action: 'submitted' }, + disabled: { control: { type: 'boolean' } }, + validationStatus: { + control: { + type: 'select', + options: [undefined, 'success', 'error'], + }, + defaultValue: undefined, + }, + }, + parameters: { + docs: { + description: { + component: ` +### USWDS 3.0 DatePicker component + +Source: https://designsystem.digital.gov/components/date-picker + +**Note:** There is one small difference in functionality between this component and the USWDS implementation, related to validating the input. The USWDS implementation validates when: +- setting the initial value based on the default value passed in +- clicking on a date in the calendar UI +- typing the Enter key in the external text input +- on focusout (blur) of the external text input + +Because this component uses the useEffect hook to trigger validation whenever the date value changes (regardless of how), the React DatePicker will validate when: +- setting the initial value based on the default value passed in (same as above) +- clicking on a date in the calendar UI (same as above) +- on input (change) of the external text input + +It's also worth mentioning that validation in this case is just calling [setCustomValidity](https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity) on the external text input, and library users should be able to determine how & when they want invalid UI to display by inspecting the [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) of the external input. + +We may find that we want to expose props for custom event handlers or even a ref to the component for better integration with 3rd party form libraries. If you are running into this, please [file an issue](https://github.com/trussworks/react-uswds/issues/new/choose) describing your use case. +`, + }, + }, + }, +} + +type StorybookArguments = { + onSubmit: React.FormEventHandler + disabled?: boolean + validationStatus?: ValidationStatus +} + +export const completeDatePicker = ( + argTypes: StorybookArguments +): React.ReactElement => ( +
+ + +
+ mm/dd/yyyy +
+ +
+ + + +) + +export const defaultDatePicker = (): React.ReactElement => ( + +) + +export const disabled = (): React.ReactElement => ( + +) + +export const withDefaultValue = (): React.ReactElement => ( + +) + +const withDefaultInvalidValue = (): React.ReactElement => ( + +) +withDefaultValue.parameters = { + happo: { + waitForContent: '05/16/1988', + }, +} +export { withDefaultInvalidValue } + +export const withMinMaxInSameMonth = (): React.ReactElement => ( + +) + +export const withMinMax = (): React.ReactElement => ( + +) + +const withRangeDate = (): React.ReactElement => ( + +) +withRangeDate.parameters = { + happo: { + waitForContent: '01/20/2021', + }, +} +export { withRangeDate } + +export const withLocalizations = (): React.ReactElement => ( + +) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.test.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.test.tsx new file mode 100644 index 0000000000..92f77a0344 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.test.tsx @@ -0,0 +1,725 @@ +import React, { ComponentProps } from 'react' +import { + render, + fireEvent, + createEvent, + waitFor, + screen, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { DatePicker } from './DatePicker' +import { sampleLocalization } from './i18n' +import { today } from './utils' +import { + DAY_OF_WEEK_LABELS, + MONTH_LABELS, + VALIDATION_MESSAGE, +} from './constants' + +describe('_DatePicker component', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const testProps = { + id: 'birthdate', + name: 'birthdate', + } + + const renderDatePicker = ( + props?: Omit, 'id' | 'name'> + ) => { + const allProps = { + ...testProps, + ...props, + } + const rendered = render() + const queryForDatePicker = () => screen.queryByTestId('date-picker') + return { + ...rendered, + queryForDatePicker, + } + } + + it('renders without errors', () => { + const { queryForDatePicker } = renderDatePicker() + const datePicker = queryForDatePicker() + expect(datePicker).toBeInTheDocument() + expect(datePicker).toHaveClass('usa-date-picker') + }) + + it('renders a hidden "internal" input with the name prop', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-internal-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'type', + 'text' + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'name', + testProps.name + ) + }) + + it('renders a visible "external" input with the id prop', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-external-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-external-input')).toHaveAttribute( + 'type', + 'text' + ) + expect(getByTestId('date-picker-external-input')).toBeVisible() + expect(getByTestId('date-picker-external-input')).toHaveAttribute( + 'id', + testProps.id + ) + }) + + it('renders a toggle button', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-button')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('date-picker-button')).toHaveAttribute( + 'aria-label', + 'Toggle calendar' + ) + }) + + it('renders a hidden calendar dialog element', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-calendar')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('date-picker-calendar')).toHaveAttribute( + 'role', + 'dialog' + ) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + }) + + it('renders a screen reader status element', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-status')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('date-picker-status')).toHaveAttribute('role', 'status') + expect(getByTestId('date-picker-status')).toHaveTextContent('') + }) + + // https://github.com/uswds/uswds/blob/develop/spec/unit/date-picker/date-picker.spec.js#L933 + it('prevents default action if keyup doesn’t originate within the calendar', async () => { + const { getByTestId } = renderDatePicker({ defaultValue: '2021-01-20' }) + + const calendarEl = getByTestId('date-picker-calendar') + await userEvent.click(getByTestId('date-picker-button')) + expect(calendarEl).toBeVisible() + const keyUpEvent = createEvent.keyUp(calendarEl, { + key: 'Enter', + bubbles: true, + code: 13, + }) + const preventDefaultSpy = jest.spyOn(keyUpEvent, 'preventDefault') + fireEvent(calendarEl, keyUpEvent) + expect(preventDefaultSpy).toHaveBeenCalled() + }) + + describe('toggling the calendar', () => { + it('the calendar is hidden on mount', () => { + const { getByTestId } = renderDatePicker() + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + }) + + it('shows the calendar when the toggle button is clicked and focuses on the selected date', async () => { + const { getByTestId, getByText } = renderDatePicker({ + defaultValue: '2021-01-20', + }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + expect(getByText('20')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + + await waitFor(() => { + expect(getByText('20')).toHaveFocus() + }) + }) + + it('hides the calendar when the escape key is pressed', async () => { + const { getByTestId } = renderDatePicker() + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + fireEvent.keyDown(getByTestId('date-picker'), { key: 'Escape' }) + + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + + // TODO + // This broke but only seems to be in JSDom (works as expected in Chrome) + // expect(getByTestId('date-picker-external-input')).toHaveFocus() + }) + + it('hides the calendar when the toggle button is clicked a second time', async () => { + const { getByTestId } = renderDatePicker() + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent('') + }) + + it('focus defaults to today if there is no value', async () => { + const todayDate = today() + const todayLabel = `${todayDate.getDate()} ${ + MONTH_LABELS[todayDate.getMonth()] + } ${todayDate.getFullYear()} ${DAY_OF_WEEK_LABELS[todayDate.getDay()]}` + + const { getByTestId, getByLabelText } = renderDatePicker() + await userEvent.click(getByTestId('date-picker-button')) + await waitFor(() => { + expect(getByLabelText(todayLabel)).toHaveFocus() + }) + }) + + it('adds Selected date to the status text if the selected date and the focused date are the same', async () => { + const { getByTestId, getByText } = renderDatePicker({ + defaultValue: '2021-01-20', + }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + await waitFor(() => { + expect(getByText('20')).toHaveFocus() + }) + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + }) + + it('coerces the display date to a valid value', async () => { + const { getByTestId, getByLabelText } = renderDatePicker({ + defaultValue: '2021-01-06', + minDate: '2021-01-10', + maxDate: '2021-01-20', + }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByLabelText('6 January 2021 Wednesday')).not.toHaveFocus() + + await waitFor(() => { + expect(getByLabelText('10 January 2021 Sunday')).toHaveFocus() + }) + }) + + it('hides the calendar if focus moves to another element', async () => { + const mockOnBlur = jest.fn() + const { getByTestId } = render( + <> + + + + ) + + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + await userEvent.click(getByTestId('test-external-element')) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(mockOnBlur).toHaveBeenCalled() + }) + }) + + describe('status text', () => { + it('shows instructions in the status text when the calendar is opened', async () => { + const { getByTestId } = renderDatePicker({ defaultValue: '2021-01-20' }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + }) + + it('removes instructions from the status text when the calendar is already open and the displayed date changes', async () => { + const { getByTestId, getByLabelText } = renderDatePicker({ + defaultValue: '2021-01-20', + }) + + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'January 2021' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + + await waitFor(() => { + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + }) + + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'January 2021' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + }) + + it('does not add Selected date to the status text if the selected date and the focused date are not the same', async () => { + const { getByTestId, getByLabelText } = renderDatePicker({ + defaultValue: '2021-01-20', + }) + + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + await waitFor(() => { + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + }) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Selected date' + ) + + fireEvent.mouseMove(getByLabelText(/^20 January 2021/)) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + }) + }) + + describe('with the required prop', () => { + it('the external input is required, and the internal input is not required', () => { + const { getByTestId } = renderDatePicker({ required: true }) + expect(getByTestId('date-picker-external-input')).toBeRequired() + expect(getByTestId('date-picker-internal-input')).not.toBeRequired() + }) + }) + + describe('with the disabled prop', () => { + it('the toggle button and external inputs are disabled, and the internal input is not disabled', () => { + const { getByTestId } = renderDatePicker({ disabled: true }) + expect(getByTestId('date-picker-button')).toBeDisabled() + expect(getByTestId('date-picker-external-input')).toBeDisabled() + expect(getByTestId('date-picker-internal-input')).not.toBeDisabled() + }) + + it('does not show the calendar when the toggle button is clicked', async () => { + const { getByTestId } = renderDatePicker({ disabled: true }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + }) + }) + + describe('with a default value prop', () => { + it('the internal input value is the date string, and the external input value is the formatted date', () => { + const { getByTestId } = renderDatePicker({ defaultValue: '1988-05-16' }) + expect(getByTestId('date-picker-external-input')).toHaveValue( + '05/16/1988' + ) + expect(getByTestId('date-picker-internal-input')).toHaveValue( + '1988-05-16' + ) + }) + + it('validates a valid default value', () => { + const { getByTestId } = renderDatePicker({ defaultValue: '1988-05-16' }) + expect(getByTestId('date-picker-external-input')).toBeValid() + }) + + it('validates an invalid default value', () => { + const { getByTestId } = renderDatePicker({ + defaultValue: '1990-01-01', + minDate: '2020-01-01', + }) + + expect(getByTestId('date-picker-external-input')).toBeInvalid() + }) + }) + + describe('with localization props', () => { + it('displays abbreviated translations for days of the week', async () => { + const { getByText, getByTestId } = renderDatePicker({ + i18n: sampleLocalization, + }) + await userEvent.click(getByTestId('date-picker-button')) + sampleLocalization.daysOfWeekShort.forEach((translation) => { + expect(getByText(translation)).toBeInTheDocument() + }) + }) + it('displays translation for month', async () => { + const { getByText, getByTestId } = renderDatePicker({ + defaultValue: '2020-02-01', + i18n: sampleLocalization, + }) + await 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', async () => { + const mockOnChange = jest.fn() + const { getByText, getByTestId } = renderDatePicker({ + defaultValue: '2021-01-20', + onChange: mockOnChange, + }) + await userEvent.click(getByTestId('date-picker-button')) + const dateButton = getByText('15') + expect(dateButton).toHaveClass('usa-date-picker__calendar__date') + await userEvent.click(dateButton) + expect(getByTestId('date-picker-external-input')).toHaveValue( + '01/15/2021' + ) + expect(getByTestId('date-picker-internal-input')).toHaveValue( + '2021-01-15' + ) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker-external-input')).toHaveFocus() + expect(mockOnChange).toHaveBeenCalledWith('01/15/2021') + }) + + it('selecting a date and opening the calendar focuses on the selected date', async () => { + const { getByTestId, getByText } = renderDatePicker() + + // open calendar + await userEvent.click(getByTestId('date-picker-button')) + + // select date + const dateButton = getByText('12') + await userEvent.click(dateButton) + + // open calendar again + await userEvent.click(getByTestId('date-picker-button')) + + await waitFor(() => { + expect(getByText('12')).toHaveFocus() + }) + expect(getByText('12')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + }) + }) + + describe('typing in a date', () => { + it('typing a date in the external input updates the selected date', async () => { + const mockOnChange = jest.fn() + const { getByTestId, getByText } = renderDatePicker({ + onChange: mockOnChange, + }) + await userEvent.type( + getByTestId('date-picker-external-input'), + '05/16/1988' + ) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('May') + expect(getByTestId('select-year')).toHaveTextContent('1988') + await waitFor(() => { + expect(getByText('16')).toHaveFocus() + }) + expect(getByText('16')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + expect(mockOnChange).toHaveBeenCalledWith('05/16/1988') + }) + + it('typing a date with a 2-digit year in the external input focuses that year in the current century', async () => { + const { getByTestId, getByLabelText } = renderDatePicker() + await userEvent.type(getByTestId('date-picker-external-input'), '2/29/20') + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('February') + expect(getByTestId('select-year')).toHaveTextContent('2020') + + await waitFor(() => { + expect(getByLabelText(/^29 February 2020/)).toHaveFocus() + }) + }) + + it('typing a date with the calendar open updates the calendar to the entered date', async () => { + const { getByTestId, getByText } = renderDatePicker({ + defaultValue: '2021-01-20', + }) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2021') + await userEvent.clear(getByTestId('date-picker-external-input')) + await userEvent.type( + getByTestId('date-picker-external-input'), + '05/16/1988' + ) + expect(getByTestId('select-month')).toHaveTextContent('May') + expect(getByTestId('select-year')).toHaveTextContent('1988') + expect(getByText('16')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + }) + + it('implements a custom onBlur handler', async () => { + const mockOnBlur = jest.fn() + const { getByTestId } = renderDatePicker({ onBlur: mockOnBlur }) + + await userEvent.type( + getByTestId('date-picker-external-input'), + '05/16/1988' + ) + getByTestId('date-picker-external-input').blur() + expect(mockOnBlur).toHaveBeenCalled() + }) + + // TODO - this is an outstanding difference in behavior from USWDS. Fails because validation happens onChange. + it.skip('typing in the external input does not validate until blurring', async () => { + const { getByTestId } = renderDatePicker({ + minDate: '2021-01-20', + maxDate: '2021-02-14', + }) + + const externalInput = getByTestId('date-picker-external-input') + expect(externalInput).toBeValid() + await userEvent.type(externalInput, '05/16/1988') + expect(externalInput).toBeValid() + externalInput.blur() + expect(externalInput).toBeInvalid() + }) + + // TODO - this can be implemented if the above test case is implemented + it.skip('pressing the Enter key in the external input validates the date', async () => { + const { getByTestId } = renderDatePicker({ + minDate: '2021-01-20', + maxDate: '2021-02-14', + }) + + const externalInput = getByTestId('date-picker-external-input') + expect(externalInput).toBeValid() + await userEvent.type(externalInput, '05/16/1988') + expect(externalInput).toBeValid() + await userEvent.type(externalInput, '{enter}') + expect(externalInput).toBeInvalid() + }) + }) + + describe('validation', () => { + it('entering an empty value is valid', async () => { + const { getByTestId } = renderDatePicker() + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + await userEvent.type(externalInput, '{space}{backspace}') + externalInput.blur() + expect(externalInput).toHaveTextContent('') + expect(externalInput).toBeValid() + expect(externalInput.validationMessage).toBe('') + }) + + it('entering a non-date value sets a validation message', async () => { + const mockOnChange = jest.fn() + const { getByTestId } = renderDatePicker({ onChange: mockOnChange }) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + await userEvent.type( + externalInput, + 'abcdefg... That means the convo is done' + ) + expect(mockOnChange).toHaveBeenCalledWith( + 'abcdefg... That means the convo is done' + ) + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + + await userEvent.clear( + externalInput + ) + + await userEvent.type(externalInput, 'ab/cd/efg') + expect(mockOnChange).toHaveBeenCalledWith('ab/cd/efg') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + }) + + it('entering an invalid date sets a validation message and becomes valid when selecting a date in the calendar', async () => { + const mockOnChange = jest.fn() + const { getByTestId, getByLabelText } = renderDatePicker({ + onChange: mockOnChange, + }) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + await userEvent.type(externalInput, '2/31/2019') + expect(mockOnChange).toHaveBeenCalledWith('2/31/2019') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + await userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + await userEvent.click(getByLabelText(/^10 February 2019/)) + expect(mockOnChange).toHaveBeenCalledWith('02/10/2019') + + await waitFor(() => { + expect(externalInput).toBeValid() + }) + expect(externalInput.validationMessage).toBe('') + }) + + it('entering a valid date outside of the min/max date sets a validation message', async () => { + const mockOnChange = jest.fn() + const { getByTestId } = renderDatePicker({ + minDate: '2021-01-20', + maxDate: '2021-02-14', + onChange: mockOnChange, + }) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + await userEvent.type(externalInput, '05/16/1988') + expect(mockOnChange).toHaveBeenCalledWith('05/16/1988') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + }) + }) + + describe('month selection', () => { + it('clicking the selected month updates the status text', async () => { + const { getByTestId } = renderDatePicker() + await userEvent.click(getByTestId('date-picker-button')) + await userEvent.click(getByTestId('select-month')) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Select a month' + ) + }) + }) + + describe('year selection', () => { + it('clicking the selected year updates the status text', async () => { + const { getByTestId } = renderDatePicker({ defaultValue: '2021-01-20' }) + await userEvent.click(getByTestId('date-picker-button')) + await userEvent.click(getByTestId('select-year')) + + await waitFor(() => { + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2016 to 2027. Select a year.' + ) + }) + }) + + it('clicking previous year chunk updates the status text', async () => { + const { getByTestId } = renderDatePicker({ defaultValue: '2021-01-20' }) + await userEvent.click(getByTestId('date-picker-button')) + await userEvent.click(getByTestId('select-year')) + await userEvent.click(getByTestId('previous-year-chunk')) + + await waitFor(() => { + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2004 to 2015. Select a year.' + ) + }) + }) + + it('clicking next year chunk navigates the year picker forward one chunk', async () => { + const { getByTestId } = renderDatePicker({ defaultValue: '2021-01-20' }) + await userEvent.click(getByTestId('date-picker-button')) + await userEvent.click(getByTestId('select-year')) + await userEvent.click(getByTestId('next-year-chunk')) + + await waitFor(() => { + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2028 to 2039. Select a year.' + ) + }) + }) + }) + + describe('validationStatus', () => { + it('renders with error styling', () => { + const { getByTestId } = renderDatePicker({ validationStatus: 'error' }) + expect(getByTestId('date-picker-external-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-external-input')).toHaveClass( + 'usa-input--error' + ) + }) + + it('renders with success styling', () => { + const { getByTestId } = renderDatePicker({ validationStatus: 'success' }) + expect(getByTestId('date-picker-external-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-external-input')).toHaveClass( + 'usa-input--success' + ) + }) + }) +}) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.tsx new file mode 100644 index 0000000000..529016624f --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/DatePicker.tsx @@ -0,0 +1,403 @@ +import React, { + useState, + useEffect, + FocusEvent, + FormEvent, + KeyboardEvent, + Ref, +} from 'react' +import classnames from 'classnames' + +import { + DEFAULT_EXTERNAL_DATE_FORMAT, + VALIDATION_MESSAGE, + DEFAULT_MIN_DATE, +} from './constants' +import { DatePickerLocalization, EN_US } from './i18n' +import { + formatDate, + parseDateString, + isDateInvalid, + today, + keepDateBetweenMinAndMax, + isSameDay, + addDays, +} from './utils' +import { Calendar } from './Calendar' + +export type ValidationStatus = 'error' | 'success' + +export type DatePickerRef = { + clearInput: () => void +} & HTMLInputElement + +type BaseDatePickerProps = { + id: string + name: string + className?: string + validationStatus?: ValidationStatus + disabled?: boolean + required?: boolean + defaultValue?: string + minDate?: string + maxDate?: string + rangeDate?: string + onChange?: (val?: string) => void + onBlur?: ( + event: + | React.FocusEvent + | React.FocusEvent + ) => void + i18n?: DatePickerLocalization + inputRef?: Ref +} + +export type DatePickerProps = BaseDatePickerProps & + Omit + +export enum FocusMode { + None, + Input, +} + +export const DatePicker = ({ + id, + name, + className, + validationStatus, + defaultValue, + disabled, + required, + minDate = DEFAULT_MIN_DATE, + maxDate, + rangeDate, + onChange, + onBlur, + i18n = EN_US, + inputRef, + ...inputProps +}: DatePickerProps): React.ReactElement => { + const datePickerEl = React.useRef(null) + const externalInputEl = React.useRef(null) + + React.useImperativeHandle(inputRef, () => { + if (externalInputEl.current) { + return { + clearInput: () => { + handleClearInput() + }, + ...externalInputEl.current, + } + } + return undefined + }) + + const isError = validationStatus === 'error' + const isSuccess = validationStatus === 'success' + + const [internalValue, setInternalValue] = useState('') + const [externalValue, setExternalValue] = useState('') + const [showCalendar, setShowCalendar] = useState(false) + const [calendarDisplayValue, setCalendarDisplayValue] = useState< + Date | undefined + >(undefined) + const [calendarPosY, setCalendarPosY] = useState(0) + const [statuses, setStatuses] = useState([]) + const [focusMode, setFocusMode] = useState(FocusMode.None) + const [keydownKeyCode, setKeydownKeyCode] = useState( + undefined + ) + + const parsedMinDate = parseDateString(minDate) as Date + const parsedMaxDate = maxDate ? parseDateString(maxDate) : undefined + const parsedRangeDate = rangeDate ? parseDateString(rangeDate) : undefined + + const validateInput = (): void => { + const isInvalid = isDateInvalid( + externalValue, + parsedMinDate, + parsedMaxDate + ) + + if (isInvalid && !externalInputEl?.current?.validationMessage) { + externalInputEl?.current?.setCustomValidity(VALIDATION_MESSAGE) + } + + if ( + !isInvalid && + externalInputEl?.current?.validationMessage === VALIDATION_MESSAGE + ) { + externalInputEl?.current?.setCustomValidity('') + } + } + + const handleSelectDate = ( + dateString: string, + closeCalendar = true + ): void => { + const parsedValue = parseDateString(dateString) + const formattedValue = + parsedValue && formatDate(parsedValue, DEFAULT_EXTERNAL_DATE_FORMAT) + + if (parsedValue) setInternalValue(dateString) + if (formattedValue) setExternalValue(formattedValue) + if (onChange) onChange(formattedValue) + + if (closeCalendar) { + setShowCalendar(false) + setStatuses([]) + externalInputEl?.current?.focus() + } + } + + const handleClearInput = () => { + setInternalValue('') + setExternalValue('') + if (onChange) onChange('') + } + + const handleExternalInput = (event: FormEvent): void => { + // Keep external & internal input values in sync + const value = (event.target as HTMLInputElement).value + setExternalValue(value) + if (onChange) onChange(value) + + const inputDate = parseDateString( + value, + DEFAULT_EXTERNAL_DATE_FORMAT, + true + ) + let newValue = '' + if (inputDate && !isDateInvalid(value, parsedMinDate, parsedMaxDate)) { + newValue = formatDate(inputDate) + } + + if (internalValue !== newValue) { + setInternalValue(newValue) + } + + if (inputDate && showCalendar) { + const newCalendarDate = keepDateBetweenMinAndMax( + inputDate, + parsedMinDate, + parsedMaxDate + ) + setCalendarDisplayValue(newCalendarDate) + } + } + + useEffect(() => { + if (defaultValue) { + handleSelectDate(defaultValue, false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + // focus on selected date when open + if (showCalendar) { + const focusedDate = + datePickerEl.current && + datePickerEl.current.querySelector( + '.usa-date-picker__calendar__date--focused' + ) + + if (focusedDate) { + focusedDate.focus() + } + } + }, [showCalendar]) + + useEffect(() => { + validateInput() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externalValue, minDate, maxDate]) + + const handleToggleClick = (): void => { + if (showCalendar) { + // calendar is open, hide it + setStatuses([]) + } else { + // calendar is closed, show it + const inputDate = parseDateString( + externalValue, + DEFAULT_EXTERNAL_DATE_FORMAT, + true + ) + + const displayDate = keepDateBetweenMinAndMax( + inputDate || + (defaultValue && parseDateString(defaultValue)) || + today(), + parsedMinDate, + parsedMaxDate + ) + + setCalendarDisplayValue(displayDate) + setCalendarPosY(datePickerEl?.current?.offsetHeight) + + const statuses = i18n.statuses + + const selectedDate = parseDateString(internalValue) + if ( + selectedDate && + isSameDay(selectedDate, addDays(displayDate, 0)) + ) { + const selectedDateText = i18n.selectedDate + statuses.unshift(selectedDateText) + } + + setStatuses(statuses) + } + + setShowCalendar(!showCalendar) + } + + // This is why the _DatePicker requires React 17 + const handleFocusOut = (event: FocusEvent): void => { + if (!datePickerEl.current?.contains(event?.relatedTarget as Element)) { + if (showCalendar) { + setShowCalendar(false) + setStatuses([]) + } + + if (onBlur) onBlur(event) + } + } + + const handleEscapeKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + if (showCalendar) { + setShowCalendar(false) + setStatuses([]) + externalInputEl?.current?.focus() + } + event.preventDefault() + } + } + + const handleCalendarKeydown = (event: KeyboardEvent): void => { + setKeydownKeyCode(event.keyCode) + } + + const handleCalendarKeyup = (event: KeyboardEvent): void => { + if (event.keyCode !== keydownKeyCode) event.preventDefault() + } + + const datePickerClasses = classnames( + 'usa-date-picker', + 'usa-date-picker--initialized', + { + 'usa-date-picker--active': showCalendar, + }, + className + ) + const datePickerInputClasses = classnames( + 'usa-input', + 'usa-date-picker__external-input', + { + 'usa-input--error': isError, + 'usa-input--success': isSuccess, + } + ) + + 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) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
+ { + setFocusMode(FocusMode.Input) + }} + onBlur={(e): void => { + setFocusMode(FocusMode.None) + onBlur && onBlur(e) + }} + /> + + {/* Ignoring error: "Non-interactive elements should not be assigned mouse or keyboard event listeners." */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + +
+ {statuses.join('. ')} +
+
+
+ ) +} + +DatePicker.defaultProps = { + minDate: DEFAULT_MIN_DATE, +} diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.stories.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.stories.tsx new file mode 100644 index 0000000000..4adbd1ef82 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.stories.tsx @@ -0,0 +1,151 @@ +import React from 'react' + +import { Day } from './Day' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY +export default { + title: 'Components/Date picker/Day', + component: Day, + argTypes: { + onClick: { action: 'on click' }, + onKeyDown: { action: 'on keydown' }, + onMouseMove: { action: 'on mouse move' }, + }, +} +*/ + +type StorybookArguments = { + onClick: (value: string) => void + onKeyDown: (event: React.KeyboardEvent) => void + onMouseMove: (hoverDate: Date) => void +} + +const testDate = new Date('January 20 2021') + +const defaultProps = { + date: testDate, +} + +export const defaultDay = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const disabled = (argTypes: StorybookArguments): React.ReactElement => ( + +) +export const selected = (argTypes: StorybookArguments): React.ReactElement => ( + +) +export const focused = (argTypes: StorybookArguments): React.ReactElement => ( + +) +export const previousMonth = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) +export const sameMonth = (argTypes: StorybookArguments): React.ReactElement => ( + +) +export const nextMonth = (argTypes: StorybookArguments): React.ReactElement => ( + +) +export const today = (argTypes: StorybookArguments): React.ReactElement => ( + +) + +export const isRangeDate = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const isRangeStart = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) +export const isRangeEnd = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) +export const isWithinRange = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.test.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.test.tsx new file mode 100644 index 0000000000..3f82c6f9ed --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.test.tsx @@ -0,0 +1,176 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Day } from './Day' + +describe('Day', () => { + const testProps = { + date: new Date('January 20 2021'), + onClick: jest.fn(), + onKeyDown: jest.fn(), + onMouseMove: jest.fn(), + } + + it('renders a date selection button', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveClass('usa-date-picker__calendar__date') + expect(button).toHaveAttribute('data-day', '20') + expect(button).toHaveAttribute('data-month', '1') + expect(button).toHaveAttribute('data-year', '2021') + expect(button).toHaveAttribute('data-value', '2021-01-20') + expect(button).toHaveAttribute('aria-label', '20 January 2021 Wednesday') + expect(button).toHaveTextContent('20') + }) + + it('defaults to not disabled', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).not.toHaveAttribute('disabled') + }) + + it('defaults to not focused', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('tabIndex', '-1') + }) + + it('defaults to not selected', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('aria-selected', 'false') + }) + + it('can be clicked to select the date', async () => { + const mockSelectDate = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + await userEvent.click(button) + expect(mockSelectDate).toHaveBeenCalledWith('2021-01-20') + }) + + it('implements the onKeyDown handler', async () => { + const mockKeyDown = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + await userEvent.click(button) + fireEvent.keyDown(button) + expect(mockKeyDown).toHaveBeenCalled() + }) + + describe('when isFocused is true', () => { + it('is focused', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('tabIndex', '0') + expect(button).toHaveClass('usa-date-picker__calendar__date--focused') + }) + }) + + describe('when isSelected is true', () => { + it('is selected', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('aria-selected', 'true') + expect(button).toHaveClass('usa-date-picker__calendar__date--selected') + }) + }) + + describe('when isDisabled is true', () => { + it('is disabled', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('disabled') + }) + + it('cannot be clicked to select the date', async () => { + const mockSelectDate = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + await userEvent.click(button) + expect(mockSelectDate).not.toHaveBeenCalled() + }) + }) + + describe('when in the previous month', () => { + it('has the previous month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--previous-month' + ) + }) + }) + + describe('when in the next month', () => { + it('has the next month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--next-month') + }) + }) + + describe('when in the current month', () => { + it('has the current month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--current-month' + ) + }) + }) + + describe('when is today’s date', () => { + it('has the today class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--today') + }) + }) + + describe('when is the range date', () => { + it('has the range class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--range-date') + }) + }) + + describe('when is the range start date', () => { + it('has the range start class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--range-date-start' + ) + }) + }) + + describe('when is the range end date', () => { + it('has the range end class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--range-date-end' + ) + }) + }) + + describe('when is within the range', () => { + it('has the within range class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--within-range' + ) + }) + }) +}) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.tsx new file mode 100644 index 0000000000..5b6fb377de --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/Day.tsx @@ -0,0 +1,113 @@ +import React, { forwardRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { formatDate, isIosDevice } from './utils' + +import { DatePickerLocalization, EN_US } from './i18n' + +interface DayProps { + date: Date + onClick: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onMouseMove: (hoverDate: Date) => void + isDisabled?: boolean + isSelected?: boolean + isFocused?: boolean + isPrevMonth?: boolean + isFocusedMonth?: boolean + isNextMonth?: boolean + isToday?: boolean + isRangeDate?: boolean + isRangeStart?: boolean + isRangeEnd?: boolean + isWithinRange?: boolean + i18n?: DatePickerLocalization +} + +const DayForwardRef: React.ForwardRefRenderFunction< + HTMLButtonElement, + DayProps +> = ( + { + date, + onClick, + onKeyDown, + onMouseMove, + isDisabled = false, + isSelected = false, + isFocused = false, + isPrevMonth = false, + isFocusedMonth = false, + isNextMonth = false, + isToday = false, + isRangeDate = false, + isRangeStart = false, + isRangeEnd = false, + isWithinRange = false, + i18n = EN_US, + }, + ref +): React.ReactElement => { + const day = date.getDate() + const month = date.getMonth() + const year = date.getFullYear() + const dayOfWeek = date.getDay() + + const formattedDate = formatDate(date) + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__date', { + 'usa-date-picker__calendar__date--previous-month': isPrevMonth, + 'usa-date-picker__calendar__date--current-month': isFocusedMonth, + 'usa-date-picker__calendar__date--next-month': isNextMonth, + 'usa-date-picker__calendar__date--selected': isSelected, + 'usa-date-picker__calendar__date--today': isToday, + 'usa-date-picker__calendar__date--focused': isFocused, + 'usa-date-picker__calendar__date--range-date': isRangeDate, + 'usa-date-picker__calendar__date--range-date-start': isRangeStart, + 'usa-date-picker__calendar__date--range-date-end': isRangeEnd, + 'usa-date-picker__calendar__date--within-range': isWithinRange, + }) + + const monthStr = i18n.months[parseInt(`${month}`)] + const dayStr = i18n.daysOfWeek[parseInt(`${dayOfWeek}`)] + + const handleClick = (): void => { + onClick(formattedDate) + } + + const handleKeyDown = (e: KeyboardEvent): void => { + onKeyDown(e) + } + + const handleMouseMove = (): void => { + if (isDisabled || isIosDevice()) return + onMouseMove(date) + } + + return ( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1017) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) +} + +export const Day = forwardRef(DayForwardRef) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.stories.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.stories.tsx new file mode 100644 index 0000000000..50b2ae3e47 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.stories.tsx @@ -0,0 +1,40 @@ +import React from 'react' + +import { MonthPicker } from './MonthPicker' +import { parseDateString } from './utils' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY + +export default { + title: 'Components/Date picker/Month picker', + component: MonthPicker, + argTypes: { handleSelectMonth: { action: 'handle select month' } }, +} +*/ + +type StorybookArguments = { + handleSelectMonth: (value: number) => void +} + +const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, +} + +export const monthPicker = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const withMinAndMax = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.test.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.test.tsx new file mode 100644 index 0000000000..edb4a1765b --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.test.tsx @@ -0,0 +1,235 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +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 = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, + handleSelectMonth: jest.fn(), + } + + it('renders a button for each month', () => { + const { getByText } = render() + + MONTH_LABELS.forEach((month, index) => { + const button = getByText(month) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${index}`) + expect(button).toHaveAttribute('data-label', month) + }) + }) + + it('each button implements an onClick handler to select the month', async () => { + const mockSelectMonth = jest.fn() + const { getByText } = render( + + ) + + await MONTH_LABELS.reduce(async (previous, month, index) => { + await previous + + const button = getByText(month) + expect(button).toBeInstanceOf(HTMLButtonElement) + await userEvent.click(button) + expect(mockSelectMonth).toHaveBeenCalledWith(index) + }, Promise.resolve()) + }) + + it('the currently displayed month has the selected class', () => { + const { getByText } = render() + const button = getByText('January') + expect(button).toHaveClass('usa-date-picker__calendar__month--selected') + expect(button).toHaveAttribute('aria-selected', 'true') + }) + + it('focus defaults to the currently displayed month', () => { + const { getByText } = render() + const button = getByText('January') + expect(button).toHaveClass('usa-date-picker__calendar__month--focused') + expect(button).toHaveFocus() + expect(button).toHaveAttribute('tabIndex', '0') + }) + + it('disables month buttons that are outside the min and max dates', () => { + const { getByText } = render( + + ) + + MONTH_LABELS.forEach((month, index) => { + if (index < 3 || index > 7) { + // eslint-disable-next-line jest/no-conditional-expect + expect(getByText(month)).toBeDisabled() + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(getByText(month)).not.toBeDisabled() + } + }) + }) + + describe('focusing on hover', () => { + it('focuses on a month when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + fireEvent.mouseMove(getByText('March')) + expect(getByText('March')).toHaveFocus() + }) + + it('does not focus on a disabled month when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + expect(getByText('May')).toBeDisabled() + fireEvent.mouseMove(getByText('May')) + expect(getByText('May')).not.toHaveFocus() + }) + + it('does not focus on a month when hovered over if on an iOS device', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'iPhone') + + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + fireEvent.mouseMove(getByText('March')) + expect(getByText('March')).not.toHaveFocus() + jest.restoreAllMocks() + }) + }) + + describe('keyboard navigation', () => { + it('pressing the up arrow key from a month navigates to 3 months before', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowUp', + }) + expect(getByText('February')).toHaveFocus() + }) + + it('pressing the down arrow key from a month navigates to 3 months later', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowDown', + }) + expect(getByText('August')).toHaveFocus() + }) + + it('pressing the left arrow key from a month navigates to the previous month', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowLeft', + }) + expect(getByText('April')).toHaveFocus() + }) + + it('pressing the right arrow key from a month navigates to the next month', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowRight', + }) + expect(getByText('June')).toHaveFocus() + }) + + it('pressing the home key from a month navigates to the first month of the selected row', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'Home', + }) + expect(getByText('April')).toHaveFocus() + }) + + it('pressing the end key from a month navigates to the last month of the selected row', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'End', + }) + expect(getByText('June')).toHaveFocus() + }) + + it('pressing the page down key from a month navigates to December', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'PageDown', + }) + expect(getByText('December')).toHaveFocus() + }) + + it('pressing the page up key from a month navigates to January', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'PageUp', + }) + expect(getByText('January')).toHaveFocus() + }) + + it('pressing tab cycles through the focusable elements within the month picker', async () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + await userEvent.tab() + 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/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.tsx new file mode 100644 index 0000000000..637dfcf1bf --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/MonthPicker.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect, useRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { + isDatesMonthOutsideMinOrMax, + isSameMonth, + keepDateBetweenMinAndMax, + listToTable, + setMonth, + handleTabKey, + 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) + const monthPickerEl = useRef(null) + const focusedMonthEl = useRef(null) + + useEffect(() => { + const monthToFocus = + monthPickerEl.current && + monthPickerEl.current.querySelector( + `[data-value="${monthToDisplay}"]` + ) + if (monthToFocus) monthToFocus.focus() + }, [monthToDisplay]) + + const handleMonthPickerTab = (event: KeyboardEvent): void => { + handleTabKey(event, [focusedMonthEl?.current]) + } + + const handleKeyDownFromMonth = (event: KeyboardEvent): void => { + let newDisplayMonth + const target = event.target as HTMLButtonElement + const selectedMonth = parseInt(target.dataset?.value || '', 10) + const currentDate = setMonth(date, selectedMonth) + + switch (event.key) { + case 'ArrowUp': + case 'Up': + newDisplayMonth = selectedMonth - 3 + break + case 'ArrowDown': + case 'Down': + newDisplayMonth = selectedMonth + 3 + break + case 'ArrowLeft': + case 'Left': + newDisplayMonth = selectedMonth - 1 + break + case 'ArrowRight': + case 'Right': + newDisplayMonth = selectedMonth + 1 + break + case 'Home': + newDisplayMonth = selectedMonth - (selectedMonth % 3) + break + case 'End': + newDisplayMonth = selectedMonth + 2 - (selectedMonth % 3) + break + case 'PageDown': + newDisplayMonth = 11 + break + case 'PageUp': + newDisplayMonth = 0 + break + default: + return + } + + if (newDisplayMonth !== undefined) { + newDisplayMonth = Math.max(0, Math.min(11, newDisplayMonth)) + const newDate = setMonth(date, newDisplayMonth) + const cappedDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + if (!isSameMonth(currentDate, cappedDate)) { + setMonthToDisplay(cappedDate.getMonth()) + } + } + + event.preventDefault() + } + + const monthNames = i18n.months + + const months = monthNames.map((month, index) => { + const monthToCheck = setMonth(date, index) + const isDisabled = isDatesMonthOutsideMinOrMax( + monthToCheck, + minDate, + maxDate + ) + const isSelected = index === selectedMonth + const isFocused = index === monthToDisplay + + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__month', { + 'usa-date-picker__calendar__month--selected': isSelected, + 'usa-date-picker__calendar__month--focused': isFocused, + }) + + const onClick = (): void => { + handleSelectMonth(index) + } + + const handleMouseMoveFromMonth = (): void => { + if (isDisabled || isIosDevice()) return + if (index === monthToDisplay) return + setMonthToDisplay(index) + } + + return ( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1340) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) + }) + + 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#L1345) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + {listToTable(months, 3)} +
+
+ ) +} diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.stories.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.stories.tsx new file mode 100644 index 0000000000..b0c348c16d --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.stories.tsx @@ -0,0 +1,72 @@ +import React from 'react' + +import { YearPicker } from './YearPicker' +import { parseDateString } from './utils' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY + +export default { + title: 'Components/Date picker/Year picker', + component: YearPicker, + argTypes: { + handleSelectYear: { action: 'handle select year' }, + setStatuses: { action: 'set statuses' }, + }, +} +*/ + +type StorybookArguments = { + handleSelectYear: (year: number) => void + setStatuses: (statuses: string[]) => void +} + +const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, +} + +export const yearPicker = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const withMinAndMaxInCurrentChunk = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const withMinInCurrentChunk = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const withMaxInCurrentChunk = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.test.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.test.tsx new file mode 100644 index 0000000000..e228d24823 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.test.tsx @@ -0,0 +1,353 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { YearPicker } from './YearPicker' +import { parseDateString } from './utils' + +describe('YearPicker', () => { + const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, + handleSelectYear: jest.fn(), + setStatuses: jest.fn(), + } + + it('renders a button for each year in the current chunk', () => { + const { getByText } = render() + + const years = [ + 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + }) + + it('each button implements an onClick handler to select the year', async () => { + const mockSelectYear = jest.fn() + const { getByText } = render( + + ) + + const years = [ + 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, + ] + + await years.reduce(async (previous, year) => { + await previous + + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + await userEvent.click(button) + expect(mockSelectYear).toHaveBeenCalledWith(year) + }, Promise.resolve()) + }) + + it('renders a button to navigate to the previous and next chunks of years', () => { + const { getByTestId } = render() + expect(getByTestId('previous-year-chunk')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('next-year-chunk')).toBeInstanceOf(HTMLButtonElement) + }) + + it('disables the previous button if the min date is in the displayed year chunk', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('previous-year-chunk')).toBeDisabled() + expect(getByTestId('next-year-chunk')).not.toBeDisabled() + }) + + it('disables the next button if the max date is in the displayed year chunk', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('next-year-chunk')).toBeDisabled() + expect(getByTestId('previous-year-chunk')).not.toBeDisabled() + }) + + it('the currently displayed year has the selected class', () => { + const { getByText } = render() + const button = getByText('2021') + expect(button).toHaveClass('usa-date-picker__calendar__year--selected') + expect(button).toHaveAttribute('aria-selected', 'true') + }) + + it('focus defaults to the currently displayed year', () => { + const { getByText } = render() + const button = getByText('2021') + expect(button).toHaveClass('usa-date-picker__calendar__year--focused') + expect(button).toHaveFocus() + expect(button).toHaveAttribute('tabIndex', '0') + }) + + it('disables year buttons that are outside the min and max dates', () => { + const { getByText } = render( + + ) + const years = [ + 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, + ] + + years.forEach((year, index) => { + if (index < 5 || index > 9) { + // eslint-disable-next-line jest/no-conditional-expect + expect(getByText(year)).toBeDisabled() + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(getByText(year)).not.toBeDisabled() + } + }) + }) + + describe('navigation', () => { + it('clicking previous year chunk navigates the year picker back one chunk', async () => { + const { getByTestId, getByText } = render() + await userEvent.click(getByTestId('previous-year-chunk')) + + const years = [ + 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('previous-year-chunk')).toHaveFocus() + }) + + it('clicking previous year chunk focuses on the year picker if the previous year chunk becomes disabled', async () => { + const { getByTestId, getByText } = render( + + ) + await userEvent.click(getByTestId('previous-year-chunk')) + + const years = [ + 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('previous-year-chunk')).toBeDisabled() + + await waitFor(() => { + expect(getByTestId('calendar-year-picker')).toHaveFocus() + }) + }) + + it('clicking next year chunk navigates the year picker forward one chunk', async () => { + const { getByTestId, getByText } = render() + await userEvent.click(getByTestId('next-year-chunk')) + + const years = [ + 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('next-year-chunk')).toHaveFocus() + }) + + it('clicking next year chunk focuses on the year picker if the next year chunk becomes disabled', async () => { + const { getByTestId, getByText } = render( + + ) + await userEvent.click(getByTestId('next-year-chunk')) + + const years = [ + 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('next-year-chunk')).toBeDisabled() + + await waitFor(() => { + expect(getByTestId('calendar-year-picker')).toHaveFocus() + }) + }) + }) + + describe('focusing on hover', () => { + it('focuses on a year when hovered over', () => { + const { getByText } = render() + + expect(getByText('2021')).toHaveFocus() + fireEvent.mouseMove(getByText('2017')) + expect(getByText('2017')).toHaveFocus() + }) + + it('does not focus on a disabled year when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('2021')).toHaveFocus() + expect(getByText('2024')).toBeDisabled() + fireEvent.mouseMove(getByText('2024')) + expect(getByText('2024')).not.toHaveFocus() + }) + + it('does not focus on a year when hovered over if on an iOS device', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'iPhone') + + const { getByText } = render() + + expect(getByText('2021')).toHaveFocus() + fireEvent.mouseMove(getByText('2017')) + expect(getByText('2017')).not.toHaveFocus() + jest.restoreAllMocks() + }) + }) + + describe('keyboard navigation', () => { + it('pressing the up arrow key from a year navigates to 3 years before', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowUp', + }) + expect(getByText('2018')).toHaveFocus() + }) + + it('pressing the down arrow key from a year navigates to 3 years later', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowDown', + }) + expect(getByText('2024')).toHaveFocus() + }) + + it('pressing the left arrow key from a year navigates to the previous year', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowLeft', + }) + expect(getByText('2020')).toHaveFocus() + }) + + it('pressing the right arrow key from a year navigates to the next year', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowRight', + }) + expect(getByText('2022')).toHaveFocus() + }) + + it('pressing the home key from a year navigates to the first year of the selected row', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'Home', + }) + expect(getByText('2019')).toHaveFocus() + }) + + it('pressing the end key from a year navigates to the last year of the selected row', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'End', + }) + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing the page down key from a year navigates forward a year chunk', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'PageDown', + }) + expect(getByText('2033')).toHaveFocus() + }) + + it('pressing the page up key from a year navigates back a year chunk', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'PageUp', + }) + expect(getByText('2009')).toHaveFocus() + }) + + it('pressing tab cycles through the focusable elements within the year picker', async () => { + const { getByText, getByTestId } = render() + + expect(getByText('2021')).toHaveFocus() + await userEvent.tab() + expect(getByTestId('next-year-chunk')).toHaveFocus() + await userEvent.tab() + expect(getByTestId('previous-year-chunk')).toHaveFocus() + await userEvent.tab() + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing tab+shift cycles backwards through the focusable elements within the year picker', async () => { + const { getByText, getByTestId } = render() + + expect(getByText('2021')).toHaveFocus() + await userEvent.tab({ shift: true }) + expect(getByTestId('previous-year-chunk')).toHaveFocus() + await userEvent.tab({ shift: true }) + expect(getByTestId('next-year-chunk')).toHaveFocus() + await userEvent.tab({ shift: true }) + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing tab only cycles through elements that are not disabled', async () => { + const { getByText, getByTestId } = render( + + ) + + expect(getByText('2021')).toHaveFocus() + expect(getByTestId('next-year-chunk')).toBeDisabled() + await userEvent.tab() + expect(getByTestId('previous-year-chunk')).toHaveFocus() + await userEvent.tab() + expect(getByText('2021')).toHaveFocus() + }) + }) +}) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.tsx new file mode 100644 index 0000000000..2828782a25 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/YearPicker.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useState, useRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { YEAR_CHUNK } from './constants' +import { + isDatesYearOutsideMinOrMax, + keepDateBetweenMinAndMax, + listToTable, + setYear, + isSameYear, + handleTabKey, + isIosDevice, +} from './utils' + +interface YearPickerProps { + date: Date + minDate: Date + maxDate?: Date + handleSelectYear: (year: number) => void + setStatuses: (statuses: string[]) => void +} + +export const YearPicker = ({ + date, + minDate, + maxDate, + handleSelectYear, + setStatuses, +}: YearPickerProps): React.ReactElement => { + const prevYearChunkEl = useRef(null) + const nextYearChunkEl = useRef(null) + const focusedYearEl = useRef(null) + const yearPickerEl = useRef(null) + + const selectedYear = date.getFullYear() + + const [yearToDisplay, setYearToDisplay] = useState(selectedYear) + const [nextToFocus, setNextToFocus] = useState< + [HTMLButtonElement | null, HTMLDivElement | null] + >([null, null]) + + let yearToChunk = yearToDisplay + yearToChunk -= yearToChunk % YEAR_CHUNK + yearToChunk = Math.max(0, yearToChunk) + + const prevYearChunkDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearToChunk - 1), + minDate, + maxDate + ) + const nextYearChunkDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearToChunk + YEAR_CHUNK), + minDate, + maxDate + ) + + useEffect(() => { + // update status text when year chunk changes + const statusStr = `Showing years ${yearToChunk} to ${ + yearToChunk + YEAR_CHUNK - 1 + }. Select a year.` + setStatuses([statusStr]) + + // also focus on next element + const [focusEl, fallbackFocusEl] = nextToFocus + + if (focusEl && fallbackFocusEl) { + if (focusEl.disabled) { + fallbackFocusEl.focus() + } else { + focusEl.focus() + } + setNextToFocus([null, null]) + } else { + // Focus on the new year when it changes + const focusedYear = + yearPickerEl.current && + yearPickerEl.current.querySelector( + '.usa-date-picker__calendar__year--focused' + ) + if (focusedYear) { + focusedYear.focus() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [yearToDisplay]) + + useEffect(() => { + // focus on year button on mount + const yearToFocus = + yearPickerEl.current && + yearPickerEl.current.querySelector( + `[data-value="${yearToDisplay}"]` + ) + if (yearToFocus) yearToFocus.focus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleYearPickerTab = (event: KeyboardEvent): void => { + handleTabKey(event, [ + prevYearChunkEl?.current, + focusedYearEl?.current, + nextYearChunkEl?.current, + ]) + } + + const handleKeyDownFromYear = (event: KeyboardEvent): void => { + let newDisplayYear + const target = event.target as HTMLButtonElement + const focusedYear = parseInt(target.dataset?.value || '', 10) + const currentDate = setYear(date, focusedYear) + + switch (event.key) { + case 'ArrowUp': + case 'Up': + newDisplayYear = focusedYear - 3 + break + case 'ArrowDown': + case 'Down': + newDisplayYear = focusedYear + 3 + break + case 'ArrowLeft': + case 'Left': + newDisplayYear = focusedYear - 1 + break + case 'ArrowRight': + case 'Right': + newDisplayYear = focusedYear + 1 + break + case 'Home': + newDisplayYear = focusedYear - (focusedYear % 3) + break + case 'End': + newDisplayYear = focusedYear + 2 - (focusedYear % 3) + break + case 'PageDown': + newDisplayYear = focusedYear + YEAR_CHUNK + break + case 'PageUp': + newDisplayYear = focusedYear - YEAR_CHUNK + break + default: + return + } + + if (newDisplayYear !== undefined) { + newDisplayYear = Math.max(0, newDisplayYear) + const newDate = setYear(date, newDisplayYear) + const cappedDate = keepDateBetweenMinAndMax( + newDate, + minDate, + maxDate + ) + if (!isSameYear(currentDate, cappedDate)) { + setYearToDisplay(cappedDate.getFullYear()) + } + } + + event.preventDefault() + } + + const years = [] + let yearIndex = yearToChunk + while (years.length < YEAR_CHUNK) { + const yearIterator = yearIndex + const isDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearIterator), + minDate, + maxDate + ) + + const isSelected = yearIterator === selectedYear + const isFocused = yearIterator === yearToDisplay + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__year', { + 'usa-date-picker__calendar__year--selected': isSelected, + 'usa-date-picker__calendar__year--focused': isFocused, + }) + + const onClick = (): void => { + handleSelectYear(yearIterator) + } + + const handleMouseMoveFromYear = (): void => { + if (isDisabled || isIosDevice()) return + if (yearIterator === yearToDisplay) return + setYearToDisplay(yearIterator) + } + + years.push( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1447) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) + + yearIndex += 1 + } + + const handlePreviousYearChunkClick = (): void => { + let adjustedYear = yearToDisplay - YEAR_CHUNK + adjustedYear = Math.max(0, adjustedYear) + + let newDate = setYear(date, adjustedYear) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setNextToFocus([prevYearChunkEl.current, yearPickerEl.current]) + setYearToDisplay(newDate.getFullYear()) + } + + const handleNextYearChunkClick = (): void => { + let adjustedYear = yearToDisplay + YEAR_CHUNK + adjustedYear = Math.max(0, adjustedYear) + + let newDate = setYear(date, adjustedYear) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setNextToFocus([nextYearChunkEl.current, yearPickerEl.current]) + setYearToDisplay(newDate.getFullYear()) + } + + 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#L1457) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + + + + + + + +
+ + + + {listToTable(years, 3)} +
+
+ +
+
+ ) +} diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/constants.ts b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/constants.ts new file mode 100644 index 0000000000..3dd7211a54 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/constants.ts @@ -0,0 +1,36 @@ +export const VALIDATION_MESSAGE = 'Please enter a valid date' + +export const MONTH_LABELS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] + +export const DAY_OF_WEEK_LABELS = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] + +export const DAY_OF_WEEK_SHORT_LABELS = ['S', 'M', 'T', 'W', 'Th', 'F', 'S'] + +export const ENTER_KEYCODE = 13 + +export const YEAR_CHUNK = 12 + +export const DEFAULT_MIN_DATE = '0000-01-01' +export const DEFAULT_EXTERNAL_DATE_FORMAT = 'MM/DD/YYYY' +export const INTERNAL_DATE_FORMAT = 'YYYY-MM-DD' diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/i18n.ts b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/i18n.ts new file mode 100644 index 0000000000..e6e723d497 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_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/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.test.ts b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.test.ts new file mode 100644 index 0000000000..bd34ebe0bd --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.test.ts @@ -0,0 +1,143 @@ +import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants' +import { + keepDateWithinMonth, + setDate, + today, + parseDateString, + formatDate, + isDateInvalid, + isDateWithinMinAndMax, +} from './utils' + +describe('keepDateWithinMonth', () => { + it('returns the original date if the month matches', () => { + const testDate = new Date('January 20, 2021') + expect(keepDateWithinMonth(testDate, 0)).toEqual(testDate) + }) + + it('returns the last day of the previous month if the month does not match', () => { + const testDate = new Date('January 20, 2021') + expect(keepDateWithinMonth(testDate, 1)).toEqual( + new Date('December 31, 2020') + ) + }) +}) + +describe('setDate', () => { + it('returns a Date object with the given year, month, and date', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2020, 0, 20) + expect(setDate(2020, 0, 20)).toEqual(expectedDate) + }) +}) + +describe('today', () => { + it('returns a Date object with today’s date', () => { + const todaysDate = new Date() + const expectedDate = new Date(0) + expectedDate.setFullYear( + todaysDate.getFullYear(), + todaysDate.getMonth(), + todaysDate.getDate() + ) + expect(today()).toEqual(expectedDate) + }) +}) + +describe('parseDateString', () => { + it('parses a date string using - syntax and returns a Date object', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 0, 20) + expect(parseDateString('2021-01-20')).toEqual(expectedDate) + }) + + it('parses a date string using / syntax and returns a Date object', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 0, 20) + expect(parseDateString('1/20/2021', DEFAULT_EXTERNAL_DATE_FORMAT)).toEqual( + expectedDate + ) + }) + + it('coerces the date if the string is invalid', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 11, 31) + expect(parseDateString('2021-14-38', INTERNAL_DATE_FORMAT, true)).toEqual( + expectedDate + ) + }) +}) + +describe('formatDate', () => { + it('formats a date object to a string using - syntax', () => { + expect(formatDate(new Date('May 16, 1988'))).toBe('1988-05-16') + }) + + it('formats a date object to a string using / syntax', () => { + expect( + formatDate(new Date('May 16, 1988'), DEFAULT_EXTERNAL_DATE_FORMAT) + ).toBe('05/16/1988') + }) +}) + +describe('isDateInvalid', () => { + it('returns false if the date is within the min & max', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('05/16/1988', testMin, testMax)).toBe(false) + }) + + it('returns true if the date is not within the min & max', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('08/16/1988', testMin, testMax)).toBe(true) + }) + + it('returns true if the date is not valid', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('not a date', testMin, testMax)).toBe(true) + }) + + describe('with no max date', () => { + it('returns false if the date is after the min', () => { + const testMin = new Date('May 1, 1988') + expect(isDateInvalid('05/16/1988', testMin)).toBe(false) + }) + + it('returns true if the date is not after the min', () => { + const testMin = new Date('May 1, 1988') + expect(isDateInvalid('02/16/1988', testMin)).toBe(true) + }) + }) +}) + +describe('isDateWithinMinAndMax', () => { + it('returns true if the date is within the min & max', () => { + const testDate = new Date('January 12, 2021') + const testMin = new Date('January 10, 2021') + const testMax = new Date('January 30, 2021') + expect(isDateWithinMinAndMax(testDate, testMin, testMax)).toBe(true) + }) + + it('returns false if the date is not within the min & max', () => { + const testDate = new Date('August 16, 1988') + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin, testMax)).toBe(false) + }) + + describe('with no max date', () => { + it('returns true if the date is after the min', () => { + const testDate = new Date('May 16, 1988') + const testMin = new Date('May 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin)).toBe(true) + }) + + it('returns false if the date is before the min', () => { + const testDate = new Date('February 16, 1988') + const testMin = new Date('May 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin)).toBe(false) + }) + }) +}) diff --git a/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.tsx b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.tsx new file mode 100644 index 0000000000..409be66d95 --- /dev/null +++ b/services/app-web/src/components/FilterAccordion/FilterDateRange/_DatePicker/utils.tsx @@ -0,0 +1,571 @@ +import React, { KeyboardEvent } from 'react' + +import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants' + +/** + * This file contains the USWDS _DatePicker date manipulation functions converted to TypeScript + */ + +/** + * Keep date within month. Month would only be over by 1 to 3 days + * + * @param {Date} dateToCheck the date object to check + * @param {number} month the correct month + * @returns {Date} the date, corrected if needed + */ +export const keepDateWithinMonth = (dateToCheck: Date, month: number): Date => { + if (month !== dateToCheck.getMonth()) { + dateToCheck.setDate(0) + } + + return dateToCheck +} + +/** + * Set date from month day year + * + * @param {number} year the year to set + * @param {number} month the month to set (zero-indexed) + * @param {number} date the date to set + * @returns {Date} the set date + */ +export const setDate = (year: number, month: number, date: number): Date => { + const newDate = new Date(0) + newDate.setFullYear(year, month, date) + return newDate +} + +/** + * todays date + * + * @returns {Date} todays date + */ +export const today = (): Date => { + const newDate = new Date() + const day = newDate.getDate() + const month = newDate.getMonth() + const year = newDate.getFullYear() + return setDate(year, month, day) +} + +/** + * Set date to first day of the month + * + * @param {Date} date the date to adjust + * @returns {Date} the adjusted date + */ +export const startOfMonth = (date: Date): Date => { + const newDate = new Date(0) + newDate.setFullYear(date.getFullYear(), date.getMonth(), 1) + return newDate +} + +/** + * Set date to last day of the month + * + * @param {number} date the date to adjust + * @returns {Date} the adjusted date + */ +export const lastDayOfMonth = (date: Date): Date => { + const newDate = new Date(0) + newDate.setFullYear(date.getFullYear(), date.getMonth() + 1, 0) + return newDate +} + +/** + * Add days to date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +export const addDays = (date: Date, numDays: number): Date => { + const newDate = new Date(date.getTime()) + newDate.setDate(newDate.getDate() + numDays) + return newDate +} + +/** + * Subtract days from date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +export const subDays = (date: Date, numDays: number): Date => + addDays(date, -numDays) + +/** + * Add weeks to date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const addWeeks = (date: Date, numWeeks: number): Date => + addDays(date, numWeeks * 7) + +/** + * Subtract weeks from date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const subWeeks = (date: Date, numWeeks: number): Date => + addWeeks(date, -numWeeks) + +/** + * Set date to the start of the week (Sunday) + * + * @param {Date} _date the date to adjust + * @returns {Date} the adjusted date + */ +export const startOfWeek = (date: Date): Date => { + const dayOfWeek = date.getDay() + return subDays(date, dayOfWeek) +} + +/** + * Set date to the end of the week (Saturday) + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const endOfWeek = (date: Date): Date => { + const dayOfWeek = date.getDay() + return addDays(date, 6 - dayOfWeek) +} + +/** + * Add months to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +export const addMonths = (date: Date, numMonths: number): Date => { + const newDate = new Date(date.getTime()) + const dateMonth = (newDate.getMonth() + 12 + numMonths) % 12 + newDate.setMonth(newDate.getMonth() + numMonths) + keepDateWithinMonth(newDate, dateMonth) + return newDate +} + +/** + * Subtract months from date + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +export const subMonths = (date: Date, numMonths: number): Date => + addMonths(date, -numMonths) + +/** + * Add years to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +export const addYears = (date: Date, numYears: number): Date => + addMonths(date, numYears * 12) + +/** + * Subtract years from date + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +export const subYears = (date: Date, numYears: number): Date => + addYears(date, -numYears) + +/** + * Set months of date + * + * @param {Date} _date the date to adjust + * @param {number} month zero-indexed month to set + * @returns {Date} the adjusted date + */ +export const setMonth = (date: Date, month: number): Date => { + const newDate = new Date(date.getTime()) + newDate.setMonth(month) + keepDateWithinMonth(newDate, month) + return newDate +} + +/** + * Set year of date + * + * @param {Date} _date the date to adjust + * @param {number} year the year to set + * @returns {Date} the adjusted date + */ +export const setYear = (date: Date, year: number): Date => { + const newDate = new Date(date.getTime()) + const month = newDate.getMonth() + newDate.setFullYear(year) + keepDateWithinMonth(newDate, month) + return newDate +} + +/** + * Return the earliest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the earliest date + */ +export const min = (dateA: Date, dateB: Date): Date => { + let newDate = dateA + if (dateB < dateA) { + newDate = dateB + } + return new Date(newDate.getTime()) +} + +/** + * Return the latest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the latest date + */ +export const max = (dateA: Date, dateB: Date): Date => { + let newDate = dateA + if (dateB > dateA) { + newDate = dateB + } + return new Date(newDate.getTime()) +} + +/** + * Check if dates are the in the same year + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same year + */ +export const isSameYear = (dateA: Date, dateB: Date): boolean => { + return dateA && dateB && dateA.getFullYear() === dateB.getFullYear() +} + +/** + * Check if dates are the in the same month + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same month + */ +export const isSameMonth = (dateA: Date, dateB: Date): boolean => { + return isSameYear(dateA, dateB) && dateA.getMonth() === dateB.getMonth() +} + +/** + * Check if dates are the same date + * + * @param {Date} dateA the date to compare + * @param {Date} dateA the date to compare + * @returns {boolean} are dates the same date + */ +export const isSameDay = (dateA: Date, dateB: Date): boolean => { + return isSameMonth(dateA, dateB) && dateA.getDate() === dateB.getDate() +} + +/** + * return a new date within minimum and maximum date + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @returns {Date} the date between min and max + */ +export const keepDateBetweenMinAndMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): Date => { + let newDate = date + + if (date < minDate) { + newDate = minDate + } else if (maxDate && date > maxDate) { + newDate = maxDate + } + + return new Date(newDate.getTime()) +} + +/** + * Check if dates is valid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is there a day within the month within min and max dates + */ +export const isDateWithinMinAndMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => date >= minDate && (!maxDate || date <= maxDate) + +/** + * Check if dates month is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +export const isDatesMonthOutsideMinOrMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => { + return ( + lastDayOfMonth(date) < minDate || + (!!maxDate && startOfMonth(date) > maxDate) + ) +} + +/** + * Check if dates year is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +export const isDatesYearOutsideMinOrMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => { + return ( + lastDayOfMonth(setMonth(date, 11)) < minDate || + (!!maxDate && startOfMonth(setMonth(date, 0)) > maxDate) + ) +} + +/** + * Parse a date with format M-D-YY + * + * @param {string} dateString the date string to parse + * @param {string} dateFormat the format of the date string + * @param {boolean} adjustDate should the date be adjusted + * @returns {Date} the parsed date + */ +export const parseDateString = ( + dateString: string, + dateFormat: string = INTERNAL_DATE_FORMAT, + adjustDate = false +): Date | undefined => { + let date + let month + let day + let year + let parsed + + if (dateString) { + let monthStr, dayStr, yearStr + + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + ;[monthStr, dayStr, yearStr] = dateString.split('/') + } else { + ;[yearStr, monthStr, dayStr] = dateString.split('-') + } + + if (yearStr) { + parsed = parseInt(yearStr, 10) + if (!Number.isNaN(parsed)) { + year = parsed + if (adjustDate) { + year = Math.max(0, year) + if (yearStr.length < 3) { + const currentYear = today().getFullYear() + const currentYearStub = + currentYear - (currentYear % 10 ** yearStr.length) + year = currentYearStub + parsed + } + } + } + } + + if (monthStr) { + parsed = parseInt(monthStr, 10) + if (!Number.isNaN(parsed)) { + month = parsed + if (adjustDate) { + month = Math.max(1, month) + month = Math.min(12, month) + } + } + } + + if (month && dayStr && year != null) { + parsed = parseInt(dayStr, 10) + if (!Number.isNaN(parsed)) { + day = parsed + if (adjustDate) { + const lastDayOfMonth = setDate(year, month, 0).getDate() + day = Math.max(1, day) + day = Math.min(lastDayOfMonth, day) + } + } + } + + if (month && day && year != null) { + date = setDate(year, month - 1, day) + } + } + + return date +} + +/** + * Format a date to format YYYY-MM-DD + * + * @param {Date} date the date to format + * @param {string} dateFormat the format of the date string + * @returns {string} the formatted date string + */ +export const formatDate = ( + date: Date, + dateFormat: string = INTERNAL_DATE_FORMAT +): string => { + const padZeros = (value: number, length: number): string => { + return `0000${value}`.slice(-length) + } + + const month = date.getMonth() + 1 + const day = date.getDate() + const year = date.getFullYear() + + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + return [padZeros(month, 2), padZeros(day, 2), padZeros(year, 4)].join('/') + } + + return [padZeros(year, 4), padZeros(month, 2), padZeros(day, 2)].join('-') +} + +// VALIDATION + +export const isDateInvalid = ( + dateString: string, + minDate: Date, + maxDate?: Date +): boolean => { + let isInvalid = false + + if (dateString) { + isInvalid = true + + const dateStringParts = dateString.split('/') + const [month, day, year] = dateStringParts.map((str) => { + let value + const parsed = parseInt(str, 10) + if (!Number.isNaN(parsed)) value = parsed + return value + }) + + if (month && day && year != null) { + const checkDate = setDate(year, month - 1, day) + + if ( + checkDate.getMonth() === month - 1 && + checkDate.getDate() === day && + checkDate.getFullYear() === year && + dateStringParts[2].length === 4 && + isDateWithinMinAndMax(checkDate, minDate, maxDate) + ) { + isInvalid = false + } + } + } + + return isInvalid +} + +// RENDERING TABLES + +export const listToTable = ( + list: React.ReactNode[], + rowSize: number +): React.ReactElement => { + const rows = [] + let i = 0 + + while (i < list.length) { + const row = [] + while (i < list.length && row.length < rowSize) { + row.push(list[parseInt(`${i}`)]) + i += 1 + } + rows.push(row) + } + + return ( + <> + {rows.map((r, rIndex) => ( + + {r.map((cell, cIndex) => ( + {cell} + ))} + + ))} + + ) +} + +export const handleTabKey = ( + event: KeyboardEvent, + focusableEl: Array +): void => { + if (event.key === 'Tab') { + const focusable = focusableEl.filter((el) => el && !el.disabled) + const activeElement = document?.activeElement + + const firstTabIndex = 0 + const lastTabIndex = focusable.length - 1 + const firstTabStop = focusable[parseInt(`${firstTabIndex}`)] + const lastTabStop = focusable[parseInt(`${lastTabIndex}`)] + const focusIndex = + activeElement instanceof HTMLButtonElement + ? focusable.indexOf(activeElement) + : -1 + + const isLastTab = focusIndex === lastTabIndex + const isFirstTab = focusIndex === firstTabIndex + const isNotFound = focusIndex === -1 + + if (event.shiftKey) { + // Tab backwards + if (isFirstTab || isNotFound) { + event.preventDefault() + lastTabStop?.focus() + } + } else { + // Tab forwards + if (isLastTab || isNotFound) { + event.preventDefault() + firstTabStop?.focus() + } + } + } +} + +export const isIosDevice = (): boolean => + [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.userAgent) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document) diff --git a/services/app-web/src/components/FilterAccordion/FilterSelect/FilterSelect.tsx b/services/app-web/src/components/FilterAccordion/FilterSelect/FilterSelect.tsx index c6d53b857b..0d21815cb5 100644 --- a/services/app-web/src/components/FilterAccordion/FilterSelect/FilterSelect.tsx +++ b/services/app-web/src/components/FilterAccordion/FilterSelect/FilterSelect.tsx @@ -25,7 +25,6 @@ export type FilterSelectedOptionsType = MultiValue export const FilterSelect = ({ name, - value, filterOptions, label, toggleClearFilter, @@ -57,7 +56,6 @@ export const FilterSelect = ({ )}