diff --git a/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx b/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx new file mode 100644 index 000000000..5fe6ca9d6 --- /dev/null +++ b/app/soapbox/components/ui/datepicker/__tests__/datepicker.test.tsx @@ -0,0 +1,83 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { queryAllByRole, render, screen } from '../../../../jest/test-helpers'; +import Datepicker from '../datepicker'; + +describe('', () => { + it('defaults to the current date', () => { + const handler = jest.fn(); + render(); + const today = new Date(); + + expect(screen.getByTestId('datepicker-month')).toHaveValue(String(today.getMonth())); + expect(screen.getByTestId('datepicker-day')).toHaveValue(String(today.getDate())); + expect(screen.getByTestId('datepicker-year')).toHaveValue(String(today.getFullYear())); + }); + + it('changes number of days based on selected month and year', async() => { + const handler = jest.fn(); + render(); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-month'), + screen.getByRole('option', { name: 'February' }), + ); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2020' }), + ); + + let daySelect: HTMLElement; + daySelect = document.querySelector('[data-testid="datepicker-day"]'); + expect(queryAllByRole(daySelect, 'option')).toHaveLength(29); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2021' }), + ); + + daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement; + expect(queryAllByRole(daySelect, 'option')).toHaveLength(28); + }); + + it('ranges from the current year to 120 years ago', () => { + const handler = jest.fn(); + render(); + const today = new Date(); + + const yearSelect = document.querySelector('[data-testid="datepicker-year"]') as HTMLElement; + expect(queryAllByRole(yearSelect, 'option')).toHaveLength(121); + expect(queryAllByRole(yearSelect, 'option')[0]).toHaveValue(String(today.getFullYear())); + expect(queryAllByRole(yearSelect, 'option')[120]).toHaveValue(String(today.getFullYear() - 120)); + }); + + it('calls the onChange function when the inputs change', async() => { + const handler = jest.fn(); + render(); + + expect(handler.mock.calls.length).toEqual(1); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-month'), + screen.getByRole('option', { name: 'February' }), + ); + + expect(handler.mock.calls.length).toEqual(2); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2020' }), + ); + + expect(handler.mock.calls.length).toEqual(3); + + await userEvent.selectOptions( + screen.getByTestId('datepicker-day'), + screen.getByRole('option', { name: '5' }), + ); + + expect(handler.mock.calls.length).toEqual(4); + }); +}); diff --git a/app/soapbox/components/ui/datepicker/datepicker.tsx b/app/soapbox/components/ui/datepicker/datepicker.tsx new file mode 100644 index 000000000..3c3c9b8e2 --- /dev/null +++ b/app/soapbox/components/ui/datepicker/datepicker.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import Select from '../select/select'; +import Stack from '../stack/stack'; +import Text from '../text/text'; + +const getDaysInMonth = (month: number, year: number) => new Date(year, month + 1, 0).getDate(); +const currentYear = new Date().getFullYear(); + +interface IDatepicker { + onChange(date: Date): void +} + +/** + * Datepicker that allows a user to select month, day, and year. + */ +const Datepicker = ({ onChange }: IDatepicker) => { + const intl = useIntl(); + + const [month, setMonth] = useState(new Date().getMonth()); + const [day, setDay] = useState(new Date().getDate()); + const [year, setYear] = useState(2022); + + const numberOfDays = useMemo(() => { + return getDaysInMonth(month, year); + }, [month, year]); + + useEffect(() => { + onChange(new Date(year, month, day)); + }, [month, day, year]); + + return ( + + + + + + + + setMonth(Number(event.target.value))} + data-testid='datepicker-month' + > + {[...Array(12)].map((_, idx) => ( + + {intl.formatDate(new Date(year, idx, 1), { month: 'long' })} + + ))} + + + + + + + + + + + setDay(Number(event.target.value))} + data-testid='datepicker-day' + > + {[...Array(numberOfDays)].map((_, idx) => ( + {idx + 1} + ))} + + + + + + + + + + + setYear(Number(event.target.value))} + data-testid='datepicker-year' + > + {[...Array(121)].map((_, idx) => ( + {currentYear - idx} + ))} + + + + + ); +}; + +export default Datepicker; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 24324ee02..27acc184b 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -4,6 +4,7 @@ export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Checkbox } from './checkbox/checkbox'; export { default as Column } from './column/column'; export { default as Counter } from './counter/counter'; +export { default as Datepicker } from './datepicker/datepicker'; export { default as Emoji } from './emoji/emoji'; export { default as EmojiSelector } from './emoji-selector/emoji-selector'; export { default as FileInput } from './file-input/file-input'; diff --git a/app/soapbox/components/ui/select/select.tsx b/app/soapbox/components/ui/select/select.tsx index 485b3238c..f79d71ddd 100644 --- a/app/soapbox/components/ui/select/select.tsx +++ b/app/soapbox/components/ui/select/select.tsx @@ -1,13 +1,17 @@ import * as React from 'react'; +interface ISelect extends React.SelectHTMLAttributes { + children: Iterable, +} + /** Multiple-select dropdown. */ -const Select = React.forwardRef((props, ref) => { +const Select = React.forwardRef((props, ref) => { const { children, ...filteredProps } = props; return ( {children} diff --git a/app/soapbox/features/verification/steps/__tests__/age-verification.test.js b/app/soapbox/features/verification/steps/__tests__/age-verification.test.js index 122df77bd..7dd835313 100644 --- a/app/soapbox/features/verification/steps/__tests__/age-verification.test.js +++ b/app/soapbox/features/verification/steps/__tests__/age-verification.test.js @@ -39,7 +39,10 @@ describe('', () => { store, ); - await userEvent.type(screen.getByLabelText('Birth Date'), '{enter}'); + await userEvent.selectOptions( + screen.getByTestId('datepicker-year'), + screen.getByRole('option', { name: '2020' }), + ); fireEvent.submit( screen.getByRole('button'), { diff --git a/app/soapbox/features/verification/steps/age-verification.js b/app/soapbox/features/verification/steps/age-verification.js index caeed7704..113d37388 100644 --- a/app/soapbox/features/verification/steps/age-verification.js +++ b/app/soapbox/features/verification/steps/age-verification.js @@ -1,12 +1,11 @@ import PropTypes from 'prop-types'; import * as React from 'react'; -import DatePicker from 'react-datepicker'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; import snackbar from 'soapbox/actions/snackbar'; import { verifyAge } from 'soapbox/actions/verification'; -import { Button, Form, FormGroup, Text } from 'soapbox/components/ui'; +import { Button, Datepicker, Form, Text } from 'soapbox/components/ui'; const messages = defineMessages({ fail: { @@ -23,13 +22,6 @@ function meetsAgeMinimum(birthday, ageMinimum) { return new Date(year + ageMinimum, month, day) <= new Date(); } -function getMaximumDate(ageMinimum) { - const date = new Date(); - date.setUTCFullYear(date.getUTCFullYear() - ageMinimum); - - return date; -} - const AgeVerification = () => { const intl = useIntl(); const dispatch = useDispatch(); @@ -67,21 +59,9 @@ const AgeVerification = () => { - + - - - + {siteTitle} requires users to be at least {ageMinimum} years old to diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index e6267ac94..74957a487 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -344,6 +344,9 @@ "crypto_donate_panel.actions.view": "Click to see {count} {count, plural, one {wallet} other {wallets}}", "crypto_donate_panel.heading": "Donate Cryptocurrency", "crypto_donate_panel.intro.message": "{siteTitle} accepts cryptocurrency donations to fund our service. Thank you for your support!", + "datepicker.month": "Month", + "datepicker.day": "Day", + "datepicker.year": "Year", "datepicker.hint": "Scheduled to post at…", "datepicker.next_month": "Next month", "datepicker.next_year": "Next year", diff --git a/app/styles/forms.scss b/app/styles/forms.scss index df6888ad5..637f53589 100644 --- a/app/styles/forms.scss +++ b/app/styles/forms.scss @@ -1,5 +1,7 @@ select { @apply pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md; + + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); } .form-error::before,