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:
parent
b69c58f59d
commit
6db09974d6
@ -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",
|
||||
|
@ -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 && (
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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}`}
|
||||
>
|
||||
|
@ -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: [
|
||||
|
@ -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}
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
Loading…
Reference in New Issue
Block a user