docs/components/page-footer/Survey.tsx

217 строки
7.1 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import cx from 'classnames'
import { useRouter } from 'next/router'
import { ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react'
import { useTranslation } from 'components/hooks/useTranslation'
import { Link } from 'components/Link'
import { sendEvent, EventType } from 'components/lib/events'
import styles from './Survey.module.scss'
enum ViewState {
START = 'START',
YES = 'YES',
NO = 'NO',
END = 'END',
}
export const Survey = () => {
const { asPath } = useRouter()
const { t } = useTranslation('survey')
const [state, setState] = useState<ViewState>(ViewState.START)
const [isEmailError, setIsEmailError] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
// Always reset the form if navigating to a new page because what
// you might have said or started to say belongs exclusively to
// to the page you started on.
setState(ViewState.START)
}, [asPath])
useEffect(() => {
// After the form is submitted we need to manually set the focus since we
// remove the form inputs after submit. The privacy policy link is the
// next focusable element in the footer so we focus that.
if (state === ViewState.END) {
document
.querySelector<HTMLAnchorElement>(
'footer a[href="/github/site-policy/github-privacy-statement"]'
)
?.focus()
}
}, [state])
function vote(state: ViewState) {
return () => {
trackEvent(getFormData())
setState(state)
}
}
// Though we set `type="email"` on the email address input which gives us browser
// validation of the field, that has accessibility issues (e.g. some screen
// readers won't read the error message) so we need to do manual validation
// ourselves.
function handleEmailInputChange() {
const emailRegex = /[^@\s.][^@\s]*@\[?[a-z0-9.-]+\]?/i
const surveyEmail = getFormData()?.get('survey-email')?.toString()
if (surveyEmail?.length === 0 || surveyEmail?.match(emailRegex)) {
setIsEmailError(false)
} else {
setIsEmailError(true)
}
}
function submit(evt: React.FormEvent) {
evt.preventDefault()
trackEvent(getFormData())
if (!isEmailError) {
setState(ViewState.END)
setIsEmailError(false)
}
}
function getFormData() {
if (!formRef.current) return
return new FormData(formRef.current)
}
return (
<form className="f5" onSubmit={submit} ref={formRef} data-testid="survey-form">
<h2 className="f4 mb-3">{t`able_to_find`}</h2>
{/* Honeypot: token isn't a real field */}
<input type="text" className="d-none" name="survey-token" aria-hidden="true" />
{state !== ViewState.END && (
<div className="radio-group mb-2">
<input
className={cx(styles.visuallyHidden, styles.customRadio)}
id="survey-yes"
type="radio"
name="survey-vote"
value="Y"
aria-label={t`yes`}
onChange={vote(ViewState.YES)}
checked={state === ViewState.YES}
/>
<label
className={cx(
'btn mr-1 color-border-accent-emphasis',
state === ViewState.YES && 'color-bg-accent-emphasis'
)}
htmlFor="survey-yes"
>
<ThumbsupIcon size={16} className={state === ViewState.YES ? '' : 'color-fg-muted'} />
</label>
<input
className={cx(styles.visuallyHidden, styles.customRadio)}
id="survey-no"
type="radio"
name="survey-vote"
value="N"
aria-label={t`no`}
onChange={vote(ViewState.NO)}
checked={state === ViewState.NO}
/>
<label
className={cx(
'btn color-border-accent-emphasis',
state === ViewState.NO && 'color-bg-danger-emphasis'
)}
htmlFor="survey-no"
>
<ThumbsdownIcon size={16} className={state === ViewState.NO ? '' : 'color-fg-muted'} />
</label>
</div>
)}
{[ViewState.YES, ViewState.NO].includes(state) && (
<>
<p className="mb-3">
<label className="d-block mb-1 f6" htmlFor="survey-comment">
<span>
{state === ViewState.YES && t`comment_yes_label`}
{state === ViewState.NO && t`comment_no_label`}
</span>
<span className="text-normal color-fg-muted float-right ml-1">{t`optional`}</span>
</label>
<textarea
className="form-control input-sm width-full"
name="survey-comment"
id="survey-comment"
></textarea>
</p>
<div className={cx('form-group', isEmailError ? 'warn' : '')}>
<label className="d-block mb-1 f6" htmlFor="survey-email">
{t`email_label`}
<span className="text-normal color-fg-muted float-right ml-1">{t`optional`}</span>
</label>
<input
type="email"
className="form-control input-sm width-full"
name="survey-email"
id="survey-email"
placeholder={t`email_placeholder`}
onChange={handleEmailInputChange}
aria-invalid={isEmailError}
{...(isEmailError ? { 'aria-describedby': 'email-input-validation' } : {})}
/>
{isEmailError && (
<p className="note warning" id="email-input-validation">
{t`email_validation`}
</p>
)}
</div>
<span className="f6 color-fg-muted">{t`not_support`}</span>
<div className="d-flex flex-justify-end flex-items-center mt-3">
<button
type="button"
className="btn btn-sm btn-invisible mr-3"
onClick={() => {
setState(ViewState.START)
setIsEmailError(false)
}}
>
Cancel
</button>
<button
disabled={isEmailError}
type="submit"
className="btn btn-sm color-border-accent-emphasis"
>
{t`send`}
</button>
</div>
</>
)}
{state === ViewState.END && (
<p role="status" className="color-fg-muted f6" data-testid="survey-end">{t`feedback`}</p>
)}
<Link
className="f6 text-normal color-fg-accent"
href="/github/site-policy/github-privacy-statement"
target="_blank"
>
{t`privacy_policy`}
</Link>
</form>
)
}
function trackEvent(formData: FormData | undefined) {
if (!formData) return
// Nota bene: convert empty strings to undefined
return sendEvent({
type: EventType.survey,
survey_token: (formData.get('survey-token') as string) || undefined, // Honeypot
survey_vote: formData.get('survey-vote') === 'Y',
survey_comment: (formData.get('survey-comment') as string) || undefined,
survey_email: (formData.get('survey-email') as string) || undefined,
})
}