Feat/621 a11y storybook add on (#705)

* chore(ui-toolkit): add aria label to icon for a11y (#621)

* chore(ui-toolkit): add labels for form-groups for a11y (#621)

* fix(ui-toolkit): fix form inputs storybook for a11y (#621)

* feat(ui-toolkit): add strict eslint a11y and components config (#621)

* chore(ui-toolkit): add translate t to form labels
This commit is contained in:
Elmar 2022-07-07 12:01:03 +01:00 committed by GitHub
parent b69c58f59d
commit 6db09974d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 184 additions and 64 deletions

View File

@ -2,9 +2,21 @@
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nrwl/nx", "eslint-plugin-unicorn", "jsx-a11y", "jest"],
"settings": {
"jsx-a11y": {
"components": {
"Button": "button",
"Input": "input",
"Select": "select",
"Radio": "radio",
"TextArea": "textarea"
}
}
},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"extends": ["plugin:jsx-a11y/strict"],
"rules": {
"@nrwl/nx/enforce-module-boundaries": [
"error",

View File

@ -55,14 +55,18 @@ export const Search = () => {
onSubmit={handleSubmit(onSubmit)}
className="flex-1 flex self-center md:ml-16 md:mr-12 md:justify-end"
>
<FormGroup className="relative w-full md:w-2/3 mb-0">
<FormGroup
label={t('Search by block number or transaction hash')}
className="relative w-full md:w-2/3 mb-0"
labelClassName="sr-only"
labelFor="search"
>
<Input
{...register('search')}
id="search"
data-testid="search"
hasError={Boolean(error?.message)}
type="text"
autoFocus={true}
placeholder={t('Enter block number or transaction hash')}
/>
{error?.message && (

View File

@ -6,6 +6,7 @@ import {
FormGroup,
Lozenge,
} from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -174,7 +175,11 @@ export const TokenInput = ({
return (
<>
<FormGroup label="" labelFor={inputName}>
<FormGroup
labelClassName="sr-only"
label={t('Input Amount')}
labelFor={inputName}
>
<AmountInput
amount={amount}
setAmount={setAmount}

View File

@ -224,8 +224,13 @@ export const StakingForm = ({
return (
<>
<h2>{t('Manage your stake')}</h2>
<FormGroup>
<FormGroup
label={t('Select if you want to add or remove stake')}
labelFor="radio-stake-options"
labelClassName="sr-only"
>
<RadioGroup
name="radio-stake-options"
onChange={(value) => {
// @ts-ignore value does exist on target
setAction(value);

View File

@ -8,6 +8,7 @@ export const Navbar = () => {
return (
<nav className="flex items-center">
<Link href="/" passHref={true}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a className="px-[26px]">
<Vega className="fill-black dark:fill-white" />
</a>
@ -43,6 +44,7 @@ const NavLink = ({ name, path, exact, testId = name }: NavLinkProps) => {
);
return (
<Link data-testid={testId} href={path} passHref={true}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a className={linkClasses}>{name}</a>
</Link>
);

View File

@ -72,7 +72,8 @@ export const WithdrawPageContainer = ({
{hasIncompleteWithdrawals ? (
<p className="mb-12">
{t('You have incomplete withdrawals.')}{' '}
<Link href="/portfolio/withdrawals">
<Link href="/portfolio/withdrawals" passHref={true}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
className="underline"
data-testid="complete-withdrawals-prompt"

View File

@ -1,4 +1,5 @@
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { validateSize } from '../utils/validate-size';
import type { DealTicketAmountProps } from './deal-ticket-amount';
@ -15,8 +16,9 @@ export const DealTicketLimitAmount = ({
return (
<div className="flex items-center gap-8">
<div className="flex-1">
<FormGroup label="Amount">
<FormGroup label={t('Amount')} labelFor="input-order-size-limit">
<Input
id="input-order-size-limit"
className="w-full"
type="number"
step={step}
@ -32,8 +34,13 @@ export const DealTicketLimitAmount = ({
</div>
<div>@</div>
<div className="flex-1">
<FormGroup label={`Price (${quoteName})`} labelAlign="right">
<FormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
labelAlign="right"
>
<Input
id="input-price-quote"
className="w-full"
type="number"
step={step}

View File

@ -1,4 +1,5 @@
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { validateSize } from '../utils/validate-size';
import type { DealTicketAmountProps } from './deal-ticket-amount';
@ -16,8 +17,9 @@ export const DealTicketMarketAmount = ({
return (
<div className="flex items-center gap-8">
<div className="flex-1">
<FormGroup label="Amount">
<FormGroup label={t('Amount')} labelFor="input-order-size-market">
<Input
id="input-order-size-market"
className="w-full"
type="number"
step={step}

View File

@ -1,5 +1,6 @@
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
interface ExpirySelectorProps {
value?: Date;
@ -11,7 +12,7 @@ export const ExpirySelector = ({ value, onSelect }: ExpirySelectorProps) => {
const dateFormatted = formatForInput(date);
const minDate = formatForInput(date);
return (
<FormGroup label="Expiry time/date" labelFor="expiration">
<FormGroup label={t('Expiry time/date')} labelFor="expiration">
<Input
data-testid="date-picker-field"
id="expiration"

View File

@ -1,6 +1,7 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { OrderSide } from '@vegaprotocol/wallet';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
interface SideSelectorProps {
value: OrderSide;
@ -14,8 +15,9 @@ export const SideSelector = ({ value, onSelect }: SideSelectorProps) => {
}));
return (
<FormGroup label="Direction">
<FormGroup label={t('Direction')} labelFor="order-side-toggle">
<Toggle
id="order-side-toggle"
name="order-side"
toggles={toggles}
checkedValue={value}

View File

@ -1,5 +1,6 @@
import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
import { t } from '@vegaprotocol/react-helpers';
interface TimeInForceSelectorProps {
value: OrderTimeInForce;
@ -22,8 +23,9 @@ export const TimeInForceSelector = ({
);
return (
<FormGroup label="Time in force">
<FormGroup label={t('Time in force')} labelFor="select-time-in-force">
<Select
id="select-time-in-force"
value={value}
onChange={(e) => onSelect(e.target.value as OrderTimeInForce)}
className="w-full"

View File

@ -1,4 +1,5 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { OrderType } from '@vegaprotocol/wallet';
import { Toggle } from '@vegaprotocol/ui-toolkit';
@ -14,8 +15,9 @@ const toggles = Object.entries(OrderType).map(([label, value]) => ({
export const TypeSelector = ({ value, onSelect }: TypeSelectorProps) => {
return (
<FormGroup label="Order type">
<FormGroup label={t('Order type')} labelFor="order-type">
<Toggle
id="order-type"
name="order-type"
toggles={toggles}
checkedValue={value}

View File

@ -190,9 +190,9 @@ export const DepositForm = ({
)}
</FormGroup>
{selectedAsset && limits && (
<FormGroup>
<div className="mb-20">
<DepositLimits limits={limits} />
</FormGroup>
</div>
)}
<FormGroup label={t('Amount')} labelFor="amount" className="relative">
<Input

View File

@ -18,6 +18,14 @@ export const SelectMarketList = ({
data,
onSelect,
}: SelectMarketListDataProps) => {
const handleKeyPress = (
event: React.KeyboardEvent<HTMLAnchorElement>,
id: string
) => {
if (event.key === 'Enter') {
return onSelect(id);
}
};
const thClassNames = (direction: 'left' | 'right') =>
`px-8 text-${direction} font-sans font-normal text-ui-small leading-9 mb-0 text-dark/80 dark:text-white/80`;
const tdClassNames =
@ -54,8 +62,9 @@ export const SelectMarketList = ({
>
<td className={`${boldUnderlineClassNames} relative`}>
<Link href={`/markets/${id}`} passHref={true}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid,jsx-a11y/no-static-element-interactions */}
<a
onKeyPress={(event) => handleKeyPress(event, id)}
onClick={() => onSelect(id)}
data-testid={`market-link-${id}`}
>

View File

@ -2,6 +2,22 @@ import '../src/styles.scss';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
backgrounds: { disable: true },
a11y: {
config: {
rules: [
{
// Disabled only for storybook because we display both the dark and light variants of the components on the same page without differentiating the ids, so it will always error.
id: 'duplicate-id-aria',
selector: '[data-testid="form-group"] > label',
},
{
// Disabled because we can't control the radix radio group component and it claims to be accessible to begin with, so hopefully no issues.
id: 'button-name',
selector: '[role=radiogroup] > button',
},
],
},
},
/*themes: {
default: 'dark',
list: [

View File

@ -4,9 +4,10 @@ import classnames from 'classnames';
interface FormGroupProps {
children: ReactNode;
label?: string;
labelFor?: string;
label: string; // For accessibility reasons this must always be set for screen readers. If you want it to not show, then add labelClassName="sr-only"
labelFor: string; // Same as above
labelAlign?: 'left' | 'right';
labelClassName?: string;
labelDescription?: string;
className?: string;
hasError?: boolean;
@ -18,6 +19,7 @@ export const FormGroup = ({
labelFor,
labelDescription,
labelAlign = 'left',
labelClassName,
className,
hasError,
}: FormGroupProps) => {
@ -27,7 +29,8 @@ export const FormGroup = ({
className={classnames(className, { 'mb-20': !className?.includes('mb') })}
>
{label && (
<label htmlFor={labelFor}>
<label className={labelClassName} htmlFor={labelFor}>
{
<div
className={classNames(
'mb-4 text-body-large text-black dark:text-white',
@ -44,6 +47,7 @@ export const FormGroup = ({
</div>
)}
</div>
}
</label>
)}
{children}

View File

@ -8,9 +8,10 @@ interface IconProps {
name: IconName;
className?: string;
size?: 16 | 20 | 24 | 32 | 48 | 64;
ariaLabel?: string;
}
export const Icon = ({ size = 16, name, className }: IconProps) => {
export const Icon = ({ size = 16, name, className, ariaLabel }: IconProps) => {
const effectiveClassName = classNames(
'inline-block',
'fill-current',
@ -26,7 +27,13 @@ export const Icon = ({ size = 16, name, className }: IconProps) => {
);
const viewbox = size <= 16 ? '0 0 16 16' : '0 0 20 20';
return (
<svg className={effectiveClassName} viewBox={viewbox}>
// For more information on accessibility for svg see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/img_role#svg_and_roleimg
<svg
role="img"
aria-label={ariaLabel || `${name} icon`}
className={effectiveClassName}
viewBox={viewbox}
>
{(size <= 16 ? IconSvgPaths16 : IconSvgPaths20)[name].map((d, key) => (
<path fillRule="evenodd" clipRule="evenodd" d={d} key={key} />
))}

View File

@ -1,11 +1,16 @@
import type { Story, Meta } from '@storybook/react';
import { Input } from './input';
import { FormGroup } from '../form-group';
export default {
component: Input,
title: 'Input',
} as Meta;
const Template: Story = (args) => <Input {...args} value="I type words" />;
const Template: Story = (args) => (
<FormGroup labelClassName="sr-only" label="Hello" labelFor={args.id}>
<Input value="I type words" {...args} />
</FormGroup>
);
const customElementPlaceholder = (
<span
@ -20,40 +25,57 @@ const customElementPlaceholder = (
);
export const Default = Template.bind({});
Default.args = {};
Default.args = {
id: 'input-default',
};
export const WithError = Template.bind({});
WithError.args = {
hasError: true,
id: 'input-has-error',
};
export const Disabled = Template.bind({});
Disabled.args = {
disabled: true,
id: 'input-disabled',
};
export const TypeDate = Template.bind({});
TypeDate.args = {
type: 'date',
id: 'input-date',
};
export const TypeDateTime = Template.bind({});
TypeDateTime.args = {
type: 'datetime-local',
id: 'input-datetime-local',
};
export const IconPrepend: Story = () => (
<Input value="I type words" prependIconName="search" />
);
export const IconPrepend = Template.bind({});
IconPrepend.args = {
prependIconName: 'search',
id: 'input-icon-prepend',
};
export const IconAppend: Story = () => (
<Input value="I type words and even more words" appendIconName="search" />
);
export const IconAppend = Template.bind({});
IconAppend.args = {
value: 'I type words and even more words',
appendIconName: 'search',
id: 'input-icon-append',
};
export const ElementPrepend: Story = () => (
<Input value="<- custom element" prependElement={customElementPlaceholder} />
);
export const ElementPrepend = Template.bind({});
ElementPrepend.args = {
value: '<- custom element',
prependElement: customElementPlaceholder,
id: 'input-element-prepend',
};
export const ElementAppend: Story = () => (
<Input value="custom element ->" appendElement={customElementPlaceholder} />
);
export const ElementAppend = Template.bind({});
ElementAppend.args = {
value: 'custom element ->',
appendElement: customElementPlaceholder,
id: 'input-element-append',
};

View File

@ -3,15 +3,17 @@ import classNames from 'classnames';
import type { ReactNode } from 'react';
interface RadioGroupProps {
name?: string;
children: ReactNode;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
}
export const RadioGroup = ({ children, onChange }: RadioGroupProps) => {
export const RadioGroup = ({ children, onChange, name }: RadioGroupProps) => {
return (
<RadioGroupPrimitive.Root
name={name}
onValueChange={onChange}
className="flex flex-row gap-24"
>

View File

@ -1,5 +1,6 @@
import type { Story, Meta } from '@storybook/react';
import { Select } from './select';
import { FormGroup } from '../form-group';
export default {
component: Select,
@ -7,22 +8,28 @@ export default {
} as Meta;
const Template: Story = (args) => (
<FormGroup labelClassName="sr-only" label="Hello" labelFor={args.id}>
<Select {...args}>
<option value="Option 1">Option 1</option>
<option value="Option 2">Option 2</option>
<option value="Option 3">Option 3</option>
</Select>
</FormGroup>
);
export const Default = Template.bind({});
Default.args = {};
Default.args = {
id: 'select-default',
};
export const WithError = Template.bind({});
WithError.args = {
id: 'select-has-error',
hasError: true,
};
export const Disabled = Template.bind({});
Disabled.args = {
id: 'select-disabled',
disabled: true,
};

View File

@ -1,4 +1,5 @@
import type { Story, Meta } from '@storybook/react';
import { FormGroup } from '../form-group';
import { TextArea } from './text-area';
export default {
@ -6,21 +7,25 @@ export default {
title: 'TextArea',
} as Meta;
const Template: Story = (args) => (
<TextArea {...args} className="h-48">
I type words
</TextArea>
const Template: Story = (args, context) => (
<FormGroup labelClassName="sr-only" label="Hello" labelFor={args.id}>
<TextArea {...args} className="h-48" defaultValue="I type words" />
</FormGroup>
);
export const Default = Template.bind({});
Default.args = {};
Default.args = {
id: 'text-area-default',
};
export const WithError = Template.bind({});
WithError.args = {
id: 'text-area-error',
hasError: true,
};
export const Disabled = Template.bind({});
Disabled.args = {
id: 'text-area-disabled',
disabled: true,
};

View File

@ -8,6 +8,7 @@ interface ToggleProps {
}
export interface ToggleInputProps {
id?: string;
name: string;
toggles: ToggleProps[];
className?: string;
@ -16,6 +17,7 @@ export interface ToggleInputProps {
}
export const Toggle = ({
id,
name,
toggles,
className,
@ -45,9 +47,10 @@ export const Toggle = ({
{toggles.map(({ label, value }, key) => {
const isSelected = value === checkedValue;
return (
<label key={key} className={labelClasses}>
<label key={key} className={labelClasses} htmlFor={label}>
<input
type="radio"
id={label}
name={name}
value={value}
onChange={onChange}

View File

@ -34,6 +34,7 @@ const TestComponent = () => {
{keypairs?.length ? (
<ul data-testid="keypair-list">
{keypairs.map((kp) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions
<li key={kp.pub} onClick={() => selectPublicKey(kp.pub)}>
{kp.pub}
</li>

View File

@ -76,7 +76,6 @@ export function RestConnectorForm({
{...register('wallet', { required: t('Required') })}
id="wallet"
type="text"
autoFocus={true}
/>
{errors.wallet?.message && (
<InputError intent="danger" className="mt-4">