fix(a11y): settings toggles (#49664)

Co-authored-by: Sboonny <muhammedelruby@gmail.com>
Co-authored-by: Sboonny <muhammed@freecodecamp.org>
This commit is contained in:
Bruce Blaser 2023-04-12 04:40:00 -07:00 committed by GitHub
parent f76a376b20
commit e0088db2b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 331 additions and 162 deletions

View File

@ -1,63 +1,56 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
function ToggleCheck(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
const { t } = useTranslation();
return (
<>
<span className='sr-only'>{t('icons.toggle')}</span>
<svg
className='tick'
height='50'
viewBox='-10 -45 200 200'
width='50'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<g>
<title>{t('icons.toggle')}</title>
<rect
fill='white'
height='60'
strokeDasharray='null'
transform='rotate(-45, 66.75, 123.75)'
width='148.85878'
x='65.57059'
y='75.32089'
/>
<rect
fill='white'
height='60'
strokeDasharray='null'
transform='rotate(45, 66.75, 123.75)'
width='120.66548'
x='-42.41726'
y='99.75'
/>
<rect
fill='black'
height='30'
strokeDasharray='null'
transform='rotate(-45, 66.75, 123.75)'
width='128.85878'
x='65.57059'
y='92.32089'
/>
<rect
fill='black'
height='30'
strokeDasharray='null'
transform='rotate(-135, 66.75, 123.75)'
width='88.85878'
x='68.57059'
y='103.32089'
/>
</g>
</svg>
</>
<svg
className='tick'
height='50'
viewBox='-10 -45 200 200'
width='50'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<g>
<rect
fill='white'
height='60'
strokeDasharray='null'
transform='rotate(-45, 66.75, 123.75)'
width='148.85878'
x='65.57059'
y='75.32089'
/>
<rect
fill='white'
height='60'
strokeDasharray='null'
transform='rotate(45, 66.75, 123.75)'
width='120.66548'
x='-42.41726'
y='99.75'
/>
<rect
fill='black'
height='30'
strokeDasharray='null'
transform='rotate(-45, 66.75, 123.75)'
width='128.85878'
x='65.57059'
y='92.32089'
/>
<rect
fill='black'
height='30'
strokeDasharray='null'
transform='rotate(-135, 66.75, 123.75)'
width='88.85878'
x='68.57059'
y='103.32089'
/>
</g>
</svg>
);
}

View File

@ -22,7 +22,7 @@ import BlockSaveButton from '../helpers/form/block-save-button';
import FullWidthRow from '../helpers/full-width-row';
import Spacer from '../helpers/spacer';
import SectionHeader from './section-header';
import ToggleSetting from './toggle-setting';
import ToggleButtonSetting from './toggle-button-setting';
const mapStateToProps = () => ({});
const mapDispatchToProps = (dispatch: Dispatch) =>
@ -228,7 +228,7 @@ function EmailSettings({
<Spacer size='medium' />
<FullWidthRow>
<form id='form-quincy-email' onSubmit={handleSubmit}>
<ToggleSetting
<ToggleButtonSetting
action={t('settings.email.weekly')}
flag={sendQuincyEmail}
flagName='sendQuincyEmail'

View File

@ -2,7 +2,7 @@ import { Form } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ToggleSetting from './toggle-setting';
import ToggleButtonSetting from './toggle-button-setting';
type KeyboardShortcutsProps = {
keyboardShortcuts: boolean;
@ -21,7 +21,7 @@ export default function KeyboardShortcutsSettings({
onSubmit={(e: React.FormEvent) => e.preventDefault()}
data-testid='fcc-enable-shortcuts-setting'
>
<ToggleSetting
<ToggleButtonSetting
action={t('settings.labels.keyboard-shortcuts')}
flag={keyboardShortcuts}
flagName='keyboard-shortcuts'

View File

@ -13,7 +13,7 @@ import { submitProfileUI } from '../../redux/settings/actions';
import FullWidthRow from '../helpers/full-width-row';
import Spacer from '../helpers/spacer';
import SectionHeader from './section-header';
import ToggleSetting from './toggle-setting';
import ToggleRadioSetting from './toggle-radio-setting';
const mapStateToProps = createSelector(userSelector, user => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -46,6 +46,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
function submitNewProfileSettings(e: React.FormEvent) {
e.preventDefault();
if (!madeChanges) return;
submitProfileUI(privacyValues);
setMadeChanges(false);
}
@ -57,7 +58,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
<p>{t('settings.privacy')}</p>
<Form inline={true} onSubmit={submitNewProfileSettings}>
<div role='group' aria-label={t('settings.headings.privacy')}>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-profile')}
explain={t('settings.disabled')}
flag={privacyValues['isLocked']}
@ -66,7 +67,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('isLocked')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-name')}
explain={t('settings.private-name')}
flag={!privacyValues['showName']}
@ -75,7 +76,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showName')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-location')}
flag={!privacyValues['showLocation']}
flagName='showLocation'
@ -83,7 +84,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showLocation')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-about')}
flag={!privacyValues['showAbout']}
flagName='showAbout'
@ -91,7 +92,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showAbout')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-points')}
flag={!privacyValues['showPoints']}
flagName='showPoints'
@ -99,7 +100,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showPoints')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-heatmap')}
flag={!privacyValues['showHeatMap']}
flagName='showHeatMap'
@ -107,7 +108,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showHeatMap')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-certs')}
explain={t('settings.disabled')}
flag={!privacyValues['showCerts']}
@ -116,7 +117,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showCerts')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-portfolio')}
flag={!privacyValues['showPortfolio']}
flagName='showPortfolio'
@ -124,7 +125,7 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showPortfolio')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-timeline')}
explain={t('settings.disabled')}
flag={!privacyValues['showTimeLine']}
@ -133,10 +134,10 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showTimeLine')}
/>
<ToggleSetting
<ToggleRadioSetting
action={t('settings.labels.my-donations')}
flag={!privacyValues['showDonation']}
flagName='showPortfolio'
flagName='showDonation'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showDonation')}

View File

@ -6,7 +6,7 @@ import { Spacer } from '../helpers';
import './sound.css';
import { playTone } from '../../utils/tone';
import ToggleSetting from './toggle-setting';
import ToggleButtonSetting from './toggle-button-setting';
type SoundProps = {
sound: boolean;
@ -41,7 +41,7 @@ export default function SoundSettings({
return (
<Form inline={true} onSubmit={(e: React.FormEvent) => e.preventDefault()}>
<ToggleSetting
<ToggleButtonSetting
action={t('settings.labels.sound-mode')}
explain={t('settings.sound-mode')}
flag={sound}

View File

@ -2,7 +2,7 @@ import { Form } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ToggleSetting from './toggle-setting';
import ToggleButtonSetting from './toggle-button-setting';
export enum Themes {
Night = 'night',
@ -25,7 +25,7 @@ export default function ThemeSettings({
inline={true}
onSubmit={(e: React.FormEvent): void => e.preventDefault()}
>
<ToggleSetting
<ToggleButtonSetting
action={t('settings.labels.night-mode')}
flag={currentTheme === Themes.Night}
flagName='currentTheme'

View File

@ -0,0 +1,66 @@
import React from 'react';
import ToggleCheck from '../../assets/icons/toggle-check';
import type { ToggleSettingProps } from './toggle-radio-setting';
import '../helpers/toggle-button.css';
import './toggle-setting.css';
const checkIconStyle = {
height: '1rem',
width: '1.25rem'
};
export default function ToggleButtonSetting({
action,
explain,
flag,
flagName,
toggleFlag,
...restProps
}: ToggleSettingProps): JSX.Element {
return (
<div className='toggle-setting-container'>
<fieldset
{...(explain && {
'aria-labelledby': `legend${flagName} desc${flagName}`
})}
>
<legend
className='sr-only'
{...(explain && { id: `legend${flagName}` })}
>
{action}
</legend>
<div className='toggle-description'>
<p aria-hidden={true}>{action}</p>
{explain ? <p id={`desc${flagName}`}>{explain}</p> : null}
</div>
<div className='toggle-button-group'>
<button
aria-pressed={flag}
{...(!flag && { onClick: toggleFlag })}
value='1'
className='toggle-button-right'
>
<span>
{restProps.onLabel}
{flag ? <ToggleCheck style={checkIconStyle} /> : null}
</span>
</button>
<button
aria-pressed={!flag}
{...(flag && { onClick: toggleFlag })}
value='2'
className='toggle-button-left'
>
<span>
{restProps.offLabel}
{!flag ? <ToggleCheck style={checkIconStyle} /> : null}
</span>
</button>
</div>
</fieldset>
</div>
);
}
ToggleButtonSetting.displayName = 'ToggleButtonSetting';

View File

@ -0,0 +1,72 @@
import React from 'react';
import '../helpers/toggle-button.css';
import './toggle-setting.css';
export type ToggleSettingProps = {
action: string;
explain?: string;
flag: boolean;
flagName: string;
toggleFlag: () => void;
offLabel: string;
onLabel: string;
};
export default function ToggleRadioSetting({
action,
explain,
flag,
flagName,
toggleFlag,
...restProps
}: ToggleSettingProps): JSX.Element {
const firstRadioId = `radioA${flagName}`;
const secondRadioId = `radioB${flagName}`;
return (
<div className='toggle-setting-container'>
<fieldset
{...(explain && {
'aria-labelledby': `legend${flagName} desc${flagName}`
})}
>
<legend
className='sr-only'
{...(explain && { id: `legend${flagName}` })}
>
{action}
</legend>
<div className='toggle-description'>
<p aria-hidden={true}>{action}</p>
{explain ? <p id={`desc${flagName}`}>{explain}</p> : null}
</div>
<div className='toggle-radio-group'>
<label htmlFor={firstRadioId} data-checked={flag}>
<input
id={firstRadioId}
type='radio'
{...(flag && { defaultChecked: true })}
{...(!flag && { onChange: toggleFlag })}
name={flagName}
value='1'
/>
<span>{restProps.onLabel}</span>
</label>
<label htmlFor={secondRadioId} data-checked={!flag}>
<input
id={secondRadioId}
type='radio'
{...(!flag && { defaultChecked: true })}
{...(flag && { onChange: toggleFlag })}
name={flagName}
value='2'
/>
<span>{restProps.offLabel}</span>
</label>
</div>
</fieldset>
</div>
);
}
ToggleRadioSetting.displayName = 'ToggleRadioSetting';

View File

@ -1,49 +1,142 @@
.toggle-setting-container {
margin-bottom: 15px;
}
.toggle-setting-container .form-group {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.toggle-setting-container .toggle-label {
display: flex;
flex-direction: column;
justify-content: center;
}
.toggle-setting-container .btn-group {
padding-inline: 5px;
}
.toggle-setting-container > .form-group > * {
flex: 1 1 0;
}
.toggle-setting-container .btn-group {
.toggle-description {
max-width: 50%;
}
.toggle-description > p:first-child {
font-weight: 700;
margin-bottom: 0.5rem;
}
.toggle-description > p:not(:first-child) {
font-style: italic;
font-size: 0.8rem;
}
.toggle-setting-container fieldset {
display: flex;
justify-content: flex-end;
align-items: flex-start;
justify-content: space-between;
}
@media (max-width: 440px) {
.toggle-setting-container .form-group {
.toggle-button-group {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content;
gap: 3px;
}
.toggle-button-group button {
padding: 5px 1.5rem;
border: 3px solid var(--secondary-color);
white-space: nowrap;
display: grid;
align-items: baseline;
justify-content: center;
min-width: 6rem;
}
.toggle-button-group button[aria-pressed='true'] {
background-color: var(--secondary-color);
color: var(--secondary-background);
}
.toggle-button-group button > span {
position: relative;
}
.toggle-button-group button > span > svg {
position: absolute;
right: -1.5rem;
margin-top: 0.1rem;
}
.toggle-button-group button:first-of-type > span > svg,
[dir='rtl'] .toggle-button-group button > span > svg {
right: unset;
left: -1.25rem;
}
[dir='rtl'] .toggle-button-group button:first-of-type > span > svg {
left: unset;
right: -1.5rem;
}
.toggle-button-group button[aria-pressed='true']:hover {
cursor: default;
}
.toggle-radio-group {
display: flex;
align-items: start;
}
.toggle-radio-group label {
font-weight: normal;
display: flex;
align-items: center;
}
.toggle-radio-group input {
-webkit-appearance: none;
appearance: none;
height: 0.8rem;
width: 0.8rem;
border-radius: 50%;
background: transparent;
border: 2px solid var(--secondary-color);
margin: 0;
}
.toggle-radio-group [data-checked='true'] input {
background: var(--secondary-color);
}
.toggle-radio-group [data-checked='true'] span {
font-weight: 700;
}
.toggle-radio-group input:focus-visible {
outline-offset: 1px;
}
.toggle-radio-group label[data-checked='false']:hover,
.toggle-radio-group label[data-checked='false'] input:hover {
cursor: pointer;
}
.toggle-radio-group label + label {
margin-inline-start: 2rem;
}
.toggle-radio-group input {
margin-inline-end: 0.35rem;
accent-color: var(--secondary-color);
}
@media (max-width: 35rem) {
.toggle-setting-container fieldset {
flex-direction: column;
margin-bottom: 1rem;
}
.toggle-setting-container > .form-group > * {
flex: 0 1 auto;
.toggle-description {
max-width: none;
}
.toggle-setting-container .toggle-label {
justify-content: flex-start;
}
.toggle-setting-container .help-block {
margin-bottom: 0px;
}
.toggle-setting-container .btn-group {
justify-content: flex-start;
margin-top: 5px;
margin-bottom: 5px;
.toggle-description > p:not(:first-child) {
margin-bottom: 0.5rem;
}
}

View File

@ -1,56 +0,0 @@
import {
FormGroup,
ControlLabel,
HelpBlock
} from '@freecodecamp/react-bootstrap';
import React from 'react';
import { Spacer } from '../helpers';
import TB from '../helpers/toggle-button';
import './toggle-setting.css';
type ToggleSettingProps = {
action: string;
explain?: string;
flag: boolean;
flagName: string;
toggleFlag: () => void;
offLabel: string;
onLabel: string;
};
export default function ToggleSetting({
action,
explain,
flag,
flagName,
toggleFlag,
...restProps
}: ToggleSettingProps): JSX.Element {
return (
<>
<div className='toggle-setting-container'>
<FormGroup>
<ControlLabel className='toggle-label' htmlFor={flagName}>
<strong>{action}</strong>
{explain ? (
<HelpBlock>
<em>{explain}</em>
</HelpBlock>
) : null}
</ControlLabel>
<TB
name={flagName}
onChange={toggleFlag}
value={flag}
{...restProps}
/>
</FormGroup>
</div>
<Spacer size='small' />
</>
);
}
ToggleSetting.displayName = 'ToggleSetting';

View File

@ -15,9 +15,9 @@ const preserveSession = () => {
const setPrivacyTogglesToPublic = () => {
cy.get('#privacy-settings')
.find('.toggle-not-active')
.find('[type=radio][value=2]')
.each(element => {
cy.wrap(element).click().should('have.class', 'toggle-active');
cy.wrap(element).click().should('be.checked');
});
cy.get('[data-cy=save-privacy-settings]').click();
cy.get('#honesty-policy').find('button').click();