Skip to content

Commit

Permalink
Merge pull request #799 from opentripplanner/phone-editor-a11y
Browse files Browse the repository at this point in the history
Phone editor a11y
  • Loading branch information
binh-dam-ibigroup authored Mar 3, 2023
2 parents 4932e0c + ad4078b commit 06e8ed4
Show file tree
Hide file tree
Showing 14 changed files with 688 additions and 340 deletions.
86 changes: 43 additions & 43 deletions __tests__/components/viewers/__snapshots__/stop-viewer.js.snap

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions __tests__/util/a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getAriaPhoneNumber } from '../../lib/util/a11y'

describe('util > a11y', () => {
describe('getAriaPhoneNumber', () => {
const testCases = [
{
expected: '8 7 7. 5 5 5. 1 2 3 4.',
input: '(877) 555-1234'
}
]

testCases.forEach((testCase) => {
it('should split US phone numbers for screen readers', () => {
expect(getAriaPhoneNumber(testCase.input)).toEqual(testCase.expected)
})
})
})
})
3 changes: 3 additions & 0 deletions i18n/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ components:
invalidCode: Please enter 6 digits for the validation code.
invalidPhone: Please enter a valid phone number.
pending: Pending
phoneNumberSubmitted: Phone number {phoneNumber} was successfully submitted.
phoneNumberVerified: Phone number {phoneNumber} was successfully verified.
# Note to translator: placeholder is width-constrained.
placeholder: "Enter your phone number"
prompt: "Enter your phone number for SMS notifications:"
Expand Down Expand Up @@ -683,6 +685,7 @@ common:
print: Print
save: Save
startOver: Start Over
submitting: Submitting…
yes: Yes
# Shared itinerary description messages
itineraryDescriptions:
Expand Down
3 changes: 3 additions & 0 deletions i18n/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ components:
invalidCode: Le code de vérification doit comporter 6 chiffres.
invalidPhone: Veuillez entrer un numéro de téléphone valable.
pending: Non vérifié
phoneNumberSubmitted: Le numéro {phoneNumber} a bien été envoyé.
phoneNumberVerified: Le numéro {phoneNumber} a bien été vérifié.
# Note to translator: placeholder is width-constrained.
placeholder: "Entrez votre numéro"
prompt: "Entrez votre numéro de téléphone pour les SMS de notification :"
Expand Down Expand Up @@ -673,6 +675,7 @@ common:
print: Imprimer
save: Enregistrer
startOver: Recommencer
submitting: Envoi en cours…
yes: Oui
itineraryDescriptions:
calories: "{calories, number} kcal" # SI unit
Expand Down
6 changes: 2 additions & 4 deletions lib/components/user/monitored-trip/trip-basics-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { IntlShape, WrappedComponentProps } from 'react-intl'
import * as userActions from '../../../actions/user'
import { getErrorStates } from '../../../util/ui'
import { getFormattedDayOfWeekPlural } from '../../../util/monitored-trip'
import { labelStyle } from '../styled'
import FormattedDayOfWeek from '../../util/formatted-day-of-week'
import FormattedDayOfWeekCompact from '../../util/formatted-day-of-week-compact'
import FormattedValidationError from '../../util/formatted-validation-error'
Expand Down Expand Up @@ -67,10 +68,7 @@ const ALL_DAYS = [
const AvailableDays = styled.fieldset`
/* Format <legend> like labels. */
legend {
border: none;
font-size: inherit;
font-weight: 700;
margin-bottom: 5px;
${labelStyle}
}
& > span {
Expand Down
63 changes: 19 additions & 44 deletions lib/components/user/notification-prefs-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// @ts-expect-error Package yup does not have type declarations.
import * as yup from 'yup'
import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import { Field, Formik, FormikProps } from 'formik'
import { Field, FormikProps } from 'formik'
import { FormattedMessage } from 'react-intl'
import { FormGroup } from 'react-bootstrap'
import React, { Fragment } from 'react'
import styled from 'styled-components'

import ButtonGroup from '../util/button-group'

import PhoneNumberEditor from './phone-number-editor'
import { FakeLabel, InlineStatic } from './styled'
import { PhoneVerificationSubmitHandler } from './phone-verification-form'
import PhoneNumberEditor, {
PhoneCodeRequestHandler
} from './phone-number-editor'

interface Fields {
notificationChannel: string
Expand All @@ -20,8 +22,8 @@ interface Props extends FormikProps<Fields> {
isPhoneNumberVerified?: boolean
phoneNumber?: string
}
onRequestPhoneVerificationCode: (code: string) => void
onSendPhoneVerificationCode: (code: string) => void
onRequestPhoneVerificationCode: PhoneCodeRequestHandler
onSendPhoneVerificationCode: PhoneVerificationSubmitHandler
phoneFormatOptions: {
countryCode: string
}
Expand All @@ -36,16 +38,6 @@ const Details = styled.div`
margin-bottom: 15px;
`

// Because we show the same message for the two validation conditions below,
// there is no need to pass that message here,
// that is done in the corresponding `<HelpBlock>` in PhoneNumberEditor.
const codeValidationSchema = yup.object({
validationCode: yup
.string()
.required()
.matches(/^\d{6}$/) // 6-digit string
})

/**
* User notification preferences pane.
*/
Expand All @@ -58,9 +50,6 @@ const NotificationPrefsPane = ({
}: Props): JSX.Element => {
const { email, isPhoneNumberVerified, phoneNumber } = loggedInUser
const { notificationChannel } = userData
const initialFormikValues = {
validationCode: ''
}

return (
<div>
Expand Down Expand Up @@ -108,34 +97,20 @@ const NotificationPrefsPane = ({
<Details>
{notificationChannel === 'email' && (
<FormGroup>
<ControlLabel>
<FakeLabel>
<FormattedMessage id="components.NotificationPrefsPane.notificationEmailDetail" />
</ControlLabel>
<FormControl.Static>{email}</FormControl.Static>
</FakeLabel>
<InlineStatic>{email}</InlineStatic>
</FormGroup>
)}
{notificationChannel === 'sms' && (
// @ts-expect-error onSubmit is not passed to Formik because PhoneNumberEditor handles code submission on its own.
<Formik
initialValues={initialFormikValues}
validateOnChange
validationSchema={codeValidationSchema}
>
{
// Pass Formik props to the component rendered so Formik can manage its validation.
// (The validation for this component is independent of the validation set in UserAccountScreen.)
(innerProps) => (
<PhoneNumberEditor
{...innerProps}
initialPhoneNumber={phoneNumber}
initialPhoneNumberVerified={isPhoneNumberVerified}
onRequestCode={onRequestPhoneVerificationCode}
onSubmitCode={onSendPhoneVerificationCode}
phoneFormatOptions={phoneFormatOptions}
/>
)
}
</Formik>
<PhoneNumberEditor
initialPhoneNumber={phoneNumber}
initialPhoneNumberVerified={isPhoneNumberVerified}
onRequestCode={onRequestPhoneVerificationCode}
onSubmitCode={onSendPhoneVerificationCode}
phoneFormatOptions={phoneFormatOptions}
/>
)}
</Details>
</div>
Expand Down
195 changes: 195 additions & 0 deletions lib/components/user/phone-change-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// @ts-expect-error Package yup does not have type declarations.
import * as yup from 'yup'
import { Button, ControlLabel, FormGroup, HelpBlock } from 'react-bootstrap'
import { Form, Formik, FormikProps } from 'formik'
import { FormattedMessage, useIntl } from 'react-intl'
import {
isPossiblePhoneNumber
// @ts-expect-error Package does not have type declaration
} from 'react-phone-number-input'
// @ts-expect-error Package does not have type declaration
import Input from 'react-phone-number-input/input'
import React, {
KeyboardEvent,
MouseEvent,
useCallback,
useEffect,
useRef
} from 'react'
import styled from 'styled-components'

import { InlineLoading } from '../narrative/loading'
import InvisibleA11yLabel from '../util/invisible-a11y-label'

import { ControlStrip, phoneFieldStyle } from './styled'

// Styles
const InlinePhoneInput = styled(Input)`
${phoneFieldStyle}
`

// The validation schema for phone numbers - relies on the react-phone-number-input library.
// Supports the following cases:
// - First time entering a phone number/validation code (blank value, not modified)
// => no color, no feedback indication.
// - Typing backspace all the way to erase a number/code (blank value, modified)
// => invalid (red) alert.
// - Typing a phone number that doesn't match the configured phoneNumberRegEx
// => invalid (red) alert.
const phoneValidationSchema = yup.object({
phoneNumber: yup
.string()
.required()
.test(
'phone-number-format',
'invalidPhoneNumber', // not directly shown.
(value?: string) => value && isPossiblePhoneNumber(value)
)
})

interface Fields {
phoneNumber: string
}

export type PhoneChangeSubmitHandler = (
values: Fields | MouseEvent<Button>
) => void

interface Props {
isSubmitting: boolean
onCancel: () => void
onSubmit: PhoneChangeSubmitHandler
phoneFormatOptions: {
countryCode: string
}
showCancel?: boolean
}

type InnerProps = FormikProps<Fields> & Props

const formId = 'phone-change-form'

const InnerPhoneChangeForm = ({
errors, // Formik
handleBlur, // Formik
handleChange, // Formik
isSubmitting,
onCancel,
phoneFormatOptions,
showCancel,
touched, // Formik
values // Formik
}: InnerProps) => {
const intl = useIntl()
const ref = useRef<HTMLInputElement>()
const showPhoneError = errors.phoneNumber && touched.phoneNumber

useEffect(() => {
if (showCancel) ref.current?.focus()
}, [ref, showCancel])

const handleEscapeKey = useCallback(
(e: KeyboardEvent<FormGroup>) => {
if (e.key === 'Escape' && showCancel && typeof onCancel === 'function') {
e.preventDefault()
// Cancel editing when user presses ESC from the phone number field.
onCancel()
}
},
[onCancel, showCancel]
)

const handlePhoneChange = useCallback(
(newNumber) =>
handleChange({
target: {
name: 'phoneNumber',
value: newNumber
}
}),
[handleChange]
)

return (
<FormGroup
// Handle ESC key from anywhere in this element.
onKeyDown={handleEscapeKey}
validationState={showPhoneError ? 'error' : null}
>
{/* Set up an empty Formik Form without inputs, and link inputs using the form id.
(A submit button within will incorrectly submit the entire page instead of just the subform.)
The containing Formik element will watch submission of the form. */}
<Form id={formId} noValidate />
<ControlLabel htmlFor="phone-number">
<FormattedMessage id="components.PhoneNumberEditor.prompt" />
</ControlLabel>
<ControlStrip>
<InlinePhoneInput
aria-invalid={showPhoneError}
aria-required
className="form-control"
country={phoneFormatOptions.countryCode}
form={formId}
id="phone-number"
name="phoneNumber"
onBlur={handleBlur}
onChange={handlePhoneChange}
placeholder={intl.formatMessage({
id: 'components.PhoneNumberEditor.placeholder'
})}
ref={ref}
type="tel"
value={values.phoneNumber}
/>

<Button
bsStyle="primary"
disabled={isSubmitting}
form={formId}
type="submit"
>
{isSubmitting ? (
<InlineLoading />
) : (
<FormattedMessage id="components.PhoneNumberEditor.sendVerificationText" />
)}
<InvisibleA11yLabel role="status">
{isSubmitting && <FormattedMessage id="common.forms.submitting" />}
</InvisibleA11yLabel>
</Button>
{
// Show cancel button only if a phone number is already recorded.
showCancel && (
<Button onClick={onCancel}>
<FormattedMessage id="common.forms.cancel" />
</Button>
)
}
<HelpBlock role="alert">
{showPhoneError && (
<FormattedMessage id="components.PhoneNumberEditor.invalidPhone" />
)}
</HelpBlock>
</ControlStrip>
</FormGroup>
)
}

/**
* Sub-component that handles phone number editing and validation.
*/
const PhoneChangeForm = (props: Props): JSX.Element => (
<Formik
initialValues={{ phoneNumber: '' }}
onSubmit={props.onSubmit}
validateOnBlur
validateOnChange={false}
validationSchema={phoneValidationSchema}
>
{(formikProps: FormikProps<Fields>) => (
<InnerPhoneChangeForm {...formikProps} {...props} />
)}
</Formik>
)

export default PhoneChangeForm
Loading

0 comments on commit 06e8ed4

Please sign in to comment.