components: add new component to fix a11y issues

This commit is contained in:
David Lechner
2022-05-15 23:27:55 -05:00
parent b729d90907
commit 8ba4d49e3b
16 changed files with 1825 additions and 97 deletions
+15 -1
View File
@@ -2,7 +2,7 @@
// Copyright (c) 2021-2022 The Pybricks Authors
import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import { call, delay, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
@@ -121,8 +121,22 @@ function* monitorBeforeInstallPrompt(): Generator {
}
}
// istabul ignore next: for dev envionemnt only
function* fakeCheckForUpdate(): Generator {
console.warn('checking for updates is not supported in developemnt mode');
// let this "busy" indication spin for a it
yield* delay(3000);
// then indicate we are already up-to-date
yield* put(appDidCheckForUpdate(false));
}
export default function* app(): Generator {
yield* fork(monitorServiceWorkerRegistration);
yield* fork(monitorAppInstalled);
yield* fork(monitorBeforeInstallPrompt);
// istabul ignore if: for dev envionemnt only
if (process.env.NODE_ENV === 'development') {
yield* takeEvery(appCheckForUpdate, fakeCheckForUpdate);
}
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Classes, Icon, IconName, IconSize, Spinner } from '@blueprintjs/core';
import { mergeRefs, useId } from '@react-aria/utils';
import classNames from 'classnames';
import React, { useRef } from 'react';
import { FocusRing, useButton } from 'react-aria';
type ButtonProps = {
/** The label for the button. */
label: string;
/** If true, the label will not be visible (will use aria-label instead). */
hideLabel?: boolean;
/** A description of what the button does (not displayed - read by screen reader). */
description?: string;
/** When true, the contents of the button will be replaced with a {@link Spinner}. */
loading?: boolean;
/** When true, the button will use the {@link Classes.MINIMAL} style. */
minimal?: boolean;
/** Icon that will be displayed to the left of the button content. */
icon: IconName;
/** A refernece to the underlying <button> HTML element. */
elementRef?: React.ForwardedRef<HTMLButtonElement>;
/** Called when the button is pressed. */
onPress?: () => void;
};
/** Similar to Blueprint.js button with better accessibility. */
export const Button: React.VoidFunctionComponent<ButtonProps> = ({
label,
hideLabel,
description,
loading,
minimal,
icon,
elementRef,
onPress,
}) => {
const isLabelVisible = !hideLabel && !loading;
const labelId = useId();
const descriptionId = useId();
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(
{
onPress,
'aria-label': isLabelVisible ? undefined : label,
'aria-labelledby': isLabelVisible ? labelId : undefined,
// REVISIT: aria-description is not widley supported yet
'aria-describedby': description ? descriptionId : undefined,
},
ref,
);
return (
<>
{/* The blueprint focus manage doesn't always get it right, so we ignore it. */}
<FocusRing focusRingClass={Classes.FOCUS_STYLE_MANAGER_IGNORE}>
<button
className={classNames(Classes.BUTTON, {
[Classes.ACTIVE]: isPressed,
[Classes.LOADING]: loading,
[Classes.MINIMAL]: minimal,
})}
ref={mergeRefs(ref, elementRef ?? null)}
{...buttonProps}
>
{loading ? (
<Spinner
className={Classes.BUTTON_SPINNER}
size={IconSize.LARGE}
/>
) : (
<>
<Icon icon={icon} aria-hidden />
{isLabelVisible && (
<span id={labelId} className={Classes.BUTTON_TEXT}>
{label}
</span>
)}
</>
)}
</button>
</FocusRing>
{/* This can't be inside the buttton element, otherwise it messes up the styling */}
{description && (
<div id={descriptionId} hidden>
{description}
</div>
)}
</>
);
};
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useEffect, useState } from 'react';
import { OverlayContainer } from 'react-aria';
import { useBoolean } from 'usehooks-ts';
import { Button } from './Button';
import HelpDialog from './HelpDialog';
import { I18nId } from './i18n';
type HelpButtonProps = {
/** The label of the control this button provides help for. */
helpForLabel: string;
/** The help dialog content. */
content: React.ReactNode;
};
const HelpButton: React.VoidFunctionComponent<HelpButtonProps> = ({
helpForLabel,
content,
}) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
const {
value: isDialogOpen,
setTrue: setIsDialogOpenTrue,
setFalse: setIsDialogOpenFalse,
} = useBoolean(false);
const [isDialogMounted, setIsDialogMounted] = useState(false);
// isDialogMounted=true is triggered on isDialogOpen==true rising edge
useEffect(() => {
if (isDialogOpen) {
setIsDialogMounted(true);
}
}, [isDialogOpen, setIsDialogMounted]);
// isDialogMounted=false is triggered when isDialogOpen==false and the animation ends
const handleAnimationEnd = useCallback(() => {
if (!isDialogOpen) {
setIsDialogMounted(false);
}
}, [isDialogOpen, setIsDialogMounted]);
const [openButton, setOpenButton] = useState<HTMLButtonElement | null>(null);
return (
<>
<Button
label={i18n.translate(I18nId.HelpButtonLabel)}
hideLabel
description={i18n.translate(I18nId.HelpButtonDescription, {
helpForLabel,
})}
minimal
icon="help"
elementRef={setOpenButton}
onPress={setIsDialogOpenTrue}
/>
{isDialogMounted && (
<OverlayContainer className={Classes.PORTAL}>
<HelpDialog
title={i18n.translate(I18nId.HelpDialogTitle)}
isOpen={isDialogOpen}
openButton={openButton}
onClose={setIsDialogOpenFalse}
onAnimationEnd={handleAnimationEnd}
>
{content}
</HelpDialog>
</OverlayContainer>
)}
</>
);
};
export default HelpButton;
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
@use '@blueprintjs/core/lib/scss/variables' as bp;
.#{bp.$ns}-popover2-open.#{bp.$ns}-popover2-placement-right {
animation: open-right 0.25s;
}
.#{bp.$ns}-popover2-close.#{bp.$ns}-popover2-placement-right {
animation: close-right 0.25s;
}
@mixin start-right {
// starts with 0 size aligned to the left edge
transform: translateX(-50%) scale(0);
}
@mixin end {
transform: translateX(0%) scale(1);
}
@keyframes open-right {
from {
@include start-right;
}
to {
@include end;
}
}
@keyframes close-right {
from {
@include end;
}
to {
@include start-right;
}
}
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import './HelpDialog.scss';
import { Classes } from '@blueprintjs/core';
import { Classes as Classes2 } from '@blueprintjs/popover2';
import {
POPOVER_ARROW_SVG_SIZE,
Popover2Arrow,
} from '@blueprintjs/popover2/lib/cjs/popover2Arrow';
import { useI18n } from '@shopify/react-i18n';
import classNames from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
DismissButton,
FocusScope,
mergeProps,
useDialog,
useId,
useModal,
useOverlay,
} from 'react-aria';
import { usePopper } from 'react-popper';
import { I18nId } from './i18n';
type HelpDialogProps = {
/** The title of the dialog. */
title: string;
/** Controls the dialog open state. */
isOpen: boolean;
/** The button that triggered the dialog to open. */
openButton: HTMLButtonElement | null;
/** Called when the dialog has been requested to close. */
onClose: () => void;
/** Called if/when CSS animation ends on the `.pb-help-dialog` element. */
onAnimationEnd?: () => void;
};
/** React component for showing help as a dialog. */
const HelpDialog: React.FunctionComponent<HelpDialogProps> = ({
title,
isOpen,
openButton,
onClose,
onAnimationEnd,
children,
}) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
// this is the dialog element and the popper element
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{ isOpen, onClose, isDismissable: true },
ref,
);
const descId = useId();
const { modalProps } = useModal();
const { dialogProps } = useDialog(
{ 'aria-label': title, 'aria-describedby': descId },
ref,
);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [arrow, setArrow] = useState<HTMLDivElement | null>(null);
const { styles, attributes, state } = usePopper(
openButton,
popperElement,
useMemo(
() => ({
placement: 'right',
modifiers: [
{
name: 'arrow',
options: {
element: arrow,
},
},
{
name: 'offset',
options: {
offset: [0, POPOVER_ARROW_SVG_SIZE / 2],
},
},
],
}),
[arrow],
),
);
// HACK: workaround https://github.com/palantir/blueprint/pull/5302
useEffect(() => {
arrow?.setAttribute('aria-hidden', 'true');
}, [arrow]);
// HACK: since there are not keyboard focusable elements for autoFocus
// we have to manually focus the only focusable element in the dialog,
// otherwise the FocusScope doesn't work.
const dismissId = useId();
useEffect(() => {
document.getElementById(dismissId)?.focus();
}, [dismissId]);
return (
<div
className={classNames(Classes.OVERLAY, { [Classes.OVERLAY_OPEN]: isOpen })}
>
<div className={Classes.OVERLAY_BACKDROP} {...underlayProps}>
<div
className={classNames(
Classes2.POPOVER2_TRANSITION_CONTAINER,
Classes.OVERLAY_CONTENT,
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div
className={classNames(
Classes2.POPOVER2,
`${Classes2.POPOVER2}-${isOpen ? 'open' : 'close'}`,
`${Classes2.POPOVER2_CONTENT_PLACEMENT}-right`,
Classes2.POPOVER2_CONTENT_SIZING,
)}
onAnimationEnd={onAnimationEnd}
>
<Popover2Arrow
arrowProps={{ ref: setArrow, style: styles.arrow }}
placement={state?.placement ?? 'auto'}
/>
<FocusScope contain restoreFocus autoFocus>
<div
aria-modal
className="pb-help-dialog"
ref={ref}
{...mergeProps(overlayProps, dialogProps, modalProps)}
// dialog itself should not be focusable
tabIndex={undefined}
// clicking anywhere in the dialog closes it
onClick={onClose}
>
<div id={descId} className={Classes2.POPOVER2_CONTENT}>
{children}
</div>
<DismissButton
id={dismissId}
aria-label={i18n.translate(
I18nId.HelpDialogCloseButtonLabel,
)}
onDismiss={onClose}
/>
</div>
</FocusScope>
</div>
</div>
</div>
</div>
);
};
export default HelpDialog;
+1
View File
@@ -0,0 +1 @@
This folder contains generic components.
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import { lookup } from '../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
export enum I18nId {
HelpButtonLabel = 'helpButton.label',
HelpButtonDescription = 'helpButton.description',
HelpDialogTitle = 'helpDialog.title',
HelpDialogCloseButtonLabel = 'helpDialog.closeButton.label',
}
+12
View File
@@ -0,0 +1,12 @@
{
"helpButton": {
"label": "Help",
"description": "Click to show help for the {helpForLabel} checkbox."
},
"helpDialog": {
"title": "Help",
"closeButton": {
"label": "Close"
}
}
}
+5
View File
@@ -112,6 +112,11 @@ a.#{bp.$ns}-button {
min-width: math.div(bp.$pt-grid-size, 2);
}
.#{bp.$ns}-focus-disabled :focus.#{bp.$ns}-focus-style-manager-ignore {
outline: bp.$pt-outline-color auto 2px !important;
outline-offset: 2px !important;
}
::-webkit-scrollbar {
width: 16px;
+4 -1
View File
@@ -4,6 +4,7 @@
import { FocusStyleManager } from '@blueprintjs/core';
import { I18nContext } from '@shopify/react-i18n';
import React from 'react';
import { OverlayProvider } from 'react-aria';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore } from 'redux';
@@ -53,7 +54,9 @@ ReactDOM.render(
<Provider store={store}>
<I18nContext.Provider value={i18nManager}>
<ViewHeightSensor />
<App />
<OverlayProvider>
<App />
</OverlayProvider>
</I18nContext.Provider>
</Provider>
</React.StrictMode>,
+34 -70
View File
@@ -3,20 +3,17 @@
import {
AnchorButton,
Button,
ButtonGroup,
ControlGroup,
FormGroup,
IRef,
Icon,
InputGroup,
Intent,
Label,
Switch,
} from '@blueprintjs/core';
import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { useTernaryDarkMode } from 'usehooks-ts';
import AboutDialog from '../about/AboutDialog';
@@ -28,6 +25,8 @@ import {
pybricksProjectsUrl,
pybricksSupportUrl,
} from '../app/constants';
import { Button } from '../components/Button';
import HelpButton from '../components/HelpButton';
import { pseudolocalize } from '../i18n';
import { useSelector } from '../reducers';
import ExternalLinkIcon from '../utils/ExternalLinkIcon';
@@ -40,43 +39,6 @@ import {
import { I18nId } from './i18n';
import './settings.scss';
type HelpButtonProps = {
label: string;
content: string | JSX.Element;
};
const HelpButton: React.VoidFunctionComponent<HelpButtonProps> = ({
label,
content,
}) => {
const handleOpening = useCallback((node: HTMLElement) => {
// role must match aria-haspopup
node.setAttribute('role', 'dialog');
}, []);
return (
<Popover2
onOpening={handleOpening}
placement="right"
shouldReturnFocusOnClose
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
content={content}
renderTarget={({ isOpen, ref, ...targetProps }) => (
<Button
aria-label={label}
aria-expanded={isOpen}
minimal
icon="help"
elementRef={ref as IRef<HTMLButtonElement>}
{...targetProps}
// override targetProps
aria-haspopup="dialog"
/>
)}
/>
);
};
const Settings: React.VoidFunctionComponent = () => {
const { isSettingShowDocsEnabled, setIsSettingShowDocsEnabled } =
useSettingIsShowDocsEnabled();
@@ -103,7 +65,7 @@ const Settings: React.VoidFunctionComponent = () => {
const [i18n] = useI18n();
return (
<div className="pb-settings" aria-label={i18n.translate(I18nId.Title)}>
<div className="pb-settings">
<FormGroup
label={i18n.translate(I18nId.AppearanceTitle)}
helperText={i18n.translate(I18nId.AppearanceZoomHelp, {
@@ -122,10 +84,10 @@ const Settings: React.VoidFunctionComponent = () => {
}
/>
<HelpButton
label={i18n.translate(I18nId.AppearanceDocumentationHelpLabel)}
content={i18n.translate(
I18nId.AppearanceDocumentationHelpContent,
helpForLabel={i18n.translate(
I18nId.AppearanceDocumentationLabel,
)}
content={i18n.translate(I18nId.AppearanceDocumentationHelp)}
/>
</ControlGroup>
<ControlGroup>
@@ -141,8 +103,8 @@ const Settings: React.VoidFunctionComponent = () => {
}
/>
<HelpButton
label={i18n.translate(I18nId.AppearanceDarkModeHelpLabel)}
content={i18n.translate(I18nId.AppearanceDarkModeHelpContent)}
helpForLabel={i18n.translate(I18nId.AppearanceDarkModeLabel)}
content={i18n.translate(I18nId.AppearanceDarkModeHelp)}
/>
</ControlGroup>
</FormGroup>
@@ -158,11 +120,12 @@ const Settings: React.VoidFunctionComponent = () => {
}
/>
<HelpButton
label={i18n.translate(I18nId.FirmwareCurrentProgramHelpLabel)}
content={i18n.translate(
I18nId.FirmwareCurrentProgramHelpContent,
{ appName },
helpForLabel={i18n.translate(
I18nId.FirmwareCurrentProgramLabel,
)}
content={i18n.translate(I18nId.FirmwareCurrentProgramHelp, {
appName,
})}
/>
</ControlGroup>
<Label htmlFor="hub-name-input">
@@ -187,8 +150,8 @@ const Settings: React.VoidFunctionComponent = () => {
}
/>
<HelpButton
label={i18n.translate(I18nId.FirmwareHubNameHelpLabel)}
content={i18n.translate(I18nId.FirmwareHubNameHelpContent)}
helpForLabel={i18n.translate(I18nId.FirmwareHubNameLabel)}
content={i18n.translate(I18nId.FirmwareHubNameHelp)}
/>
</ControlGroup>
</FormGroup>
@@ -233,36 +196,37 @@ const Settings: React.VoidFunctionComponent = () => {
<ButtonGroup minimal={true} vertical={true} alignText="left">
{hasUnresolvedInstallPrompt && (
<Button
label={i18n.translate(I18nId.AppInstallLabel)}
icon="add"
onClick={() => dispatch(appShowInstallPrompt())}
onPress={() => dispatch(appShowInstallPrompt())}
loading={promptingInstall}
>
{i18n.translate(I18nId.AppInstallLabel)}
</Button>
/>
)}
{isServiceWorkerRegistered && !updateAvailable && (
{(process.env.NODE_ENV === 'development' ||
(isServiceWorkerRegistered && !updateAvailable)) && (
<Button
label={i18n.translate(I18nId.AppCheckForUpdateLabel)}
icon="refresh"
onClick={() => dispatch(appCheckForUpdate())}
onPress={() => dispatch(appCheckForUpdate())}
loading={checkingForUpdate}
>
{i18n.translate(I18nId.AppCheckForUpdateLabel)}
</Button>
/>
)}
{isServiceWorkerRegistered && updateAvailable && (
<Button icon="refresh" onClick={() => dispatch(appReload())}>
{i18n.translate(I18nId.AppRestartLabel)}
</Button>
{(process.env.NODE_ENV === 'development' ||
(isServiceWorkerRegistered && updateAvailable)) && (
<Button
label={i18n.translate(I18nId.AppRestartLabel)}
icon="refresh"
onPress={() => dispatch(appReload())}
/>
)}
<Button
label={i18n.translate(I18nId.AppAboutLabel)}
icon="info-sign"
onClick={() => {
onPress={() => {
setIsAboutDialogOpen(true);
return true;
}}
>
{i18n.translate(I18nId.AppAboutLabel)}
</Button>
/>
</ButtonGroup>
</FormGroup>
{process.env.NODE_ENV === 'development' && (
+4 -8
View File
@@ -7,19 +7,15 @@ export enum I18nId {
Title = 'title',
AppearanceTitle = 'appearance.title',
AppearanceDocumentationLabel = 'appearance.documentation.label',
AppearanceDocumentationHelpLabel = 'appearance.documentation.help.label',
AppearanceDocumentationHelpContent = 'appearance.documentation.help.content',
AppearanceDocumentationHelp = 'appearance.documentation.help',
AppearanceDarkModeLabel = 'appearance.darkMode.label',
AppearanceDarkModeHelpLabel = 'appearance.darkMode.help.label',
AppearanceDarkModeHelpContent = 'appearance.darkMode.help.content',
AppearanceDarkModeHelp = 'appearance.darkMode.help',
AppearanceZoomHelp = 'appearance.zoom.help',
FirmwareTitle = 'firmware.title',
FirmwareCurrentProgramLabel = 'firmware.flashCurrentProgram.label',
FirmwareCurrentProgramHelpLabel = 'firmware.flashCurrentProgram.help.label',
FirmwareCurrentProgramHelpContent = 'firmware.flashCurrentProgram.help.content',
FirmwareCurrentProgramHelp = 'firmware.flashCurrentProgram.help',
FirmwareHubNameLabel = 'firmware.hubName.label',
FirmwareHubNameHelpLabel = 'firmware.hubName.help.label',
FirmwareHubNameHelpContent = 'firmware.hubName.help.content',
FirmwareHubNameHelp = 'firmware.hubName.help',
FirmwareHubNameError = 'firmware.hubName.error',
HelpTitle = 'help.title',
HelpProjectsLabel = 'help.projects.label',
+4 -16
View File
@@ -4,17 +4,11 @@
"title": "Appearance",
"documentation": {
"label": "Documentation",
"help": {
"label": "Show help for documentation toggle.",
"content": "Enable to show the documentation and disable to hide the documentation."
}
"help": "Enable to show the documentation and disable to hide the documentation."
},
"darkMode": {
"label": "Dark mode",
"help": {
"label": "Show help for dark mode toggle.",
"content": "Enable to set theme to dark mode and disable to set theme to light mode."
}
"help": "Enable to set theme to dark mode and disable to set theme to light mode."
},
"zoom": {
"help": "Use {in} and {out} to zoom."
@@ -24,17 +18,11 @@
"title": "Firmware",
"flashCurrentProgram": {
"label": "Include current program",
"help": {
"label": "Show help for include current program toggle.",
"content": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}"
}
"help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}"
},
"hubName": {
"label": "Hub name",
"help": {
"label": "Show help on hub name input",
"content": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting."
},
"help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.",
"error": "The name is too long."
}
},