mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
components: add new component to fix a11y issues
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
This folder contains generic components.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"helpButton": {
|
||||
"label": "Help",
|
||||
"description": "Click to show help for the {helpForLabel} checkbox."
|
||||
},
|
||||
"helpDialog": {
|
||||
"title": "Help",
|
||||
"closeButton": {
|
||||
"label": "Close"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user