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
+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"
}
}
}