mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(ui): add a11y support (#2076)
This commit is contained in:
parent
afa2ac47ee
commit
2249d717a8
23 changed files with 126 additions and 63 deletions
|
@ -24,7 +24,7 @@
|
|||
"@parcel/transformer-svg-react": "2.7.0",
|
||||
"@peculiar/webcrypto": "^1.3.3",
|
||||
"@silverhand/eslint-config": "1.0.0",
|
||||
"@silverhand/eslint-config-react": "1.0.0",
|
||||
"@silverhand/eslint-config-react": "1.1.0",
|
||||
"@silverhand/essentials": "^1.2.1",
|
||||
"@silverhand/jest-config": "1.0.0",
|
||||
"@silverhand/ts-config": "1.0.0",
|
||||
|
|
|
@ -35,6 +35,8 @@ const IframeConfirmModal = ({
|
|||
<iframe
|
||||
sandbox={undefined}
|
||||
className={isLoading ? styles.hidden : undefined}
|
||||
// For styling use
|
||||
// eslint-disable-next-line jsx-a11y/aria-role
|
||||
role="iframe"
|
||||
src={url}
|
||||
title="terms"
|
||||
|
|
|
@ -19,6 +19,8 @@ const Drawer = ({ className, isOpen = false, children, onClose }: Props) => {
|
|||
return (
|
||||
<ReactModal
|
||||
shouldCloseOnOverlayClick
|
||||
// For styling use
|
||||
// eslint-disable-next-line jsx-a11y/aria-role
|
||||
role="popup"
|
||||
isOpen={isOpen}
|
||||
className={classNames(styles.drawer, className)}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './DropdownItem.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -9,7 +11,13 @@ type Props = {
|
|||
};
|
||||
|
||||
const DropdownItem = ({ onClick, className, children }: Props) => (
|
||||
<li className={classNames(styles.item, className)} onClick={onClick}>
|
||||
<li
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
className={classNames(styles.item, className)}
|
||||
onKeyDown={onKeyDownHandler(onClick)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import classNames from 'classnames';
|
||||
import ReactModal, { Props as ModalProps } from 'react-modal';
|
||||
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export { default as DropdownItem } from './DropdownItem';
|
||||
|
@ -19,7 +21,17 @@ const Dropdown = ({ onClose, children, className, ...rest }: Props) => {
|
|||
onRequestClose={onClose}
|
||||
{...rest}
|
||||
>
|
||||
<ul className={styles.list} onClick={onClose}>
|
||||
<ul
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
className={styles.list}
|
||||
onKeyDown={onKeyDownHandler({
|
||||
Esc: onClose,
|
||||
Enter: onClose,
|
||||
' ': onClose,
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</ReactModal>
|
||||
|
|
|
@ -79,6 +79,7 @@ const PhoneInput = ({
|
|||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel-national"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ArrowPrev from '@/assets/icons/arrow-prev.svg';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -16,7 +17,12 @@ const NavBar = ({ title }: Props) => {
|
|||
return (
|
||||
<div className={styles.navBar}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.backButton}
|
||||
onKeyDown={onKeyDownHandler(() => {
|
||||
navigate(-1);
|
||||
})}
|
||||
onClick={() => {
|
||||
navigate(-1);
|
||||
}}
|
||||
|
|
|
@ -2,6 +2,7 @@ import classNames from 'classnames';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import InfoIcon from '@/assets/icons/info-icon.svg';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -18,7 +19,18 @@ const Notification = ({ className, message, onClose }: Props) => {
|
|||
<div className={classNames(styles.notification, className)}>
|
||||
<InfoIcon className={styles.icon} />
|
||||
<div className={styles.message}>{message}</div>
|
||||
<a className={styles.link} onClick={onClose}>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.link}
|
||||
onClick={onClose}
|
||||
onKeyDown={onKeyDownHandler({
|
||||
Esc: onClose,
|
||||
Enter: onClose,
|
||||
' ': onClose,
|
||||
})}
|
||||
>
|
||||
{t('action.got_it')}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import Checkbox from '@/components/Checkbox';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -20,12 +21,24 @@ const TermsOfUse = ({ name, className, termsUrl, isChecked, onChange, onTermsCli
|
|||
|
||||
const prefix = t('description.agree_with_terms');
|
||||
|
||||
const toggle = () => {
|
||||
onChange(!isChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isChecked}
|
||||
tabIndex={0}
|
||||
className={classNames(styles.terms, className)}
|
||||
onClick={() => {
|
||||
onChange(!isChecked);
|
||||
}}
|
||||
onClick={toggle}
|
||||
onKeyDown={onKeyDownHandler({
|
||||
Esc: () => {
|
||||
onChange(false);
|
||||
},
|
||||
Enter: toggle,
|
||||
' ': toggle,
|
||||
})}
|
||||
>
|
||||
<Checkbox name={name} checked={isChecked} className={styles.checkBox} />
|
||||
<div className={styles.content}>
|
||||
|
|
|
@ -30,6 +30,8 @@ const Toast = ({ message, duration = 3000, callback }: Props) => {
|
|||
|
||||
return (
|
||||
<ReactModal
|
||||
// For styling use
|
||||
// eslint-disable-next-line jsx-a11y/aria-role
|
||||
role="toast"
|
||||
isOpen={Boolean(message)}
|
||||
overlayClassName={styles.toastContainer}
|
||||
|
|
|
@ -87,6 +87,7 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
|
|||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="new-username"
|
||||
|
|
|
@ -121,6 +121,7 @@ const EmailPasswordless = ({
|
|||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder={t('input.email')}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
{...register('email', emailValidation)}
|
||||
|
|
|
@ -136,6 +136,7 @@ const PhonePasswordless = ({
|
|||
className={styles.inputField}
|
||||
countryCallingCode={phoneNumber.countryCallingCode}
|
||||
nationalNumber={phoneNumber.nationalNumber}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
countryList={countryList}
|
||||
{...register('phone', phoneNumberValidation)}
|
||||
|
|
|
@ -94,6 +94,7 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
|
|||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
{...register('password', passwordValidation)}
|
||||
onClear={() => {
|
||||
|
|
|
@ -20,7 +20,10 @@ const SocialLanding = ({ className, connectorId, isLoading = false }: Props) =>
|
|||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.connector}>
|
||||
{connector ? (
|
||||
<img src={theme === 'dark' ? connector.logoDark ?? connector.logo : connector.logo} />
|
||||
<img
|
||||
src={theme === 'dark' ? connector.logoDark ?? connector.logo : connector.logo}
|
||||
alt="logo"
|
||||
/>
|
||||
) : (
|
||||
connectorId
|
||||
)}
|
||||
|
|
|
@ -93,6 +93,7 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
|
|||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="username"
|
||||
|
|
|
@ -24,6 +24,7 @@ const Consent = () => {
|
|||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<img
|
||||
alt="logo"
|
||||
src={
|
||||
(theme === 'dark' && experienceSettings?.branding.darkLogoUrl) ||
|
||||
experienceSettings?.branding.logoUrl
|
||||
|
|
|
@ -18,10 +18,12 @@ const ForgotPassword = () => {
|
|||
|
||||
const forgotPasswordForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <PhonePasswordless autoFocus hasSwitch type="forgot-password" hasTerms={false} />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <EmailPasswordless autoFocus hasSwitch type="forgot-password" hasTerms={false} />;
|
||||
}
|
||||
}, [method]);
|
||||
|
|
|
@ -19,13 +19,16 @@ const Register = () => {
|
|||
|
||||
const registerForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <PhonePasswordless autoFocus type="register" />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <EmailPasswordless autoFocus type="register" />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <CreateAccount autoFocus />;
|
||||
}, [method]);
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ const ResetPassword = () => {
|
|||
<NavBar />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>{t('description.new_password')}</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<ResetPasswordForm autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,13 +19,16 @@ const SecondarySignIn = () => {
|
|||
|
||||
const signInForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <PhonePasswordless autoFocus type="sign-in" />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <EmailPasswordless autoFocus type="sign-in" />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
return <UsernameSignIn autoFocus />;
|
||||
}, [method]);
|
||||
|
||||
|
|
24
packages/ui/src/utils/a11y.ts
Normal file
24
packages/ui/src/utils/a11y.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/* istanbul ignore file */
|
||||
// FIXME: @simeng-li
|
||||
|
||||
import { KeyboardEventHandler, KeyboardEvent } from 'react';
|
||||
|
||||
type callbackHandler<T> = ((event: KeyboardEvent<T>) => void) | undefined;
|
||||
|
||||
type callbackHandlerMap<T> = Record<string, callbackHandler<T>>;
|
||||
|
||||
export const onKeyDownHandler =
|
||||
<T = Element>(callback?: callbackHandler<T> | callbackHandlerMap<T>): KeyboardEventHandler<T> =>
|
||||
(event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (typeof callback === 'function' && [' ', 'Enter'].includes(key)) {
|
||||
callback(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (typeof callback === 'object') {
|
||||
callback[key]?.(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
|
@ -600,7 +600,7 @@ importers:
|
|||
'@parcel/transformer-svg-react': 2.7.0
|
||||
'@peculiar/webcrypto': ^1.3.3
|
||||
'@silverhand/eslint-config': 1.0.0
|
||||
'@silverhand/eslint-config-react': 1.0.0
|
||||
'@silverhand/eslint-config-react': 1.1.0
|
||||
'@silverhand/essentials': ^1.2.1
|
||||
'@silverhand/jest-config': 1.0.0
|
||||
'@silverhand/ts-config': 1.0.0
|
||||
|
@ -654,7 +654,7 @@ importers:
|
|||
'@parcel/transformer-svg-react': 2.7.0_@parcel+core@2.7.0
|
||||
'@peculiar/webcrypto': 1.3.3
|
||||
'@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni
|
||||
'@silverhand/eslint-config-react': 1.0.0_3jdvf2aalbcoibv3m53iflhmym
|
||||
'@silverhand/eslint-config-react': 1.1.0_3jdvf2aalbcoibv3m53iflhmym
|
||||
'@silverhand/essentials': 1.2.1
|
||||
'@silverhand/jest-config': 1.0.0_bi2kohzqnxavgozw3csgny5hju
|
||||
'@silverhand/ts-config': 1.0.0_typescript@4.7.4
|
||||
|
@ -3853,27 +3853,6 @@ packages:
|
|||
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
|
||||
dev: true
|
||||
|
||||
/@silverhand/eslint-config-react/1.0.0_3jdvf2aalbcoibv3m53iflhmym:
|
||||
resolution: {integrity: sha512-t+DOdP51k0iP84nkOhnUOTqgXFhy3k6MKckZIzX8h7Poz4o0Xr6Nmeslksce49Mticxw4KE/gLk4X6cidpjNaA==}
|
||||
engines: {node: ^16.0.0 || ^18.0.0}
|
||||
peerDependencies:
|
||||
stylelint: ^14.9.1
|
||||
dependencies:
|
||||
'@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni
|
||||
eslint-config-xo-react: 0.27.0_5dhhqh7zhie7fohiouyxntxzve
|
||||
eslint-plugin-react: 7.29.3_eslint@8.21.0
|
||||
eslint-plugin-react-hooks: 4.3.0_eslint@8.21.0
|
||||
stylelint: 14.9.1
|
||||
stylelint-config-xo-scss: 0.15.0_eqpuutlgonckfyjzwkrpusdvaa
|
||||
transitivePeerDependencies:
|
||||
- eslint
|
||||
- eslint-import-resolver-webpack
|
||||
- postcss
|
||||
- prettier
|
||||
- supports-color
|
||||
- typescript
|
||||
dev: true
|
||||
|
||||
/@silverhand/eslint-config-react/1.0.0_qoomm4vc6ijs52fnjlal4yoenm:
|
||||
resolution: {integrity: sha512-t+DOdP51k0iP84nkOhnUOTqgXFhy3k6MKckZIzX8h7Poz4o0Xr6Nmeslksce49Mticxw4KE/gLk4X6cidpjNaA==}
|
||||
engines: {node: ^16.0.0 || ^18.0.0}
|
||||
|
@ -5174,7 +5153,7 @@ packages:
|
|||
call-bind: 1.0.2
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
get-intrinsic: 1.1.1
|
||||
get-intrinsic: 1.1.3
|
||||
is-string: 1.0.7
|
||||
dev: true
|
||||
|
||||
|
@ -5197,8 +5176,8 @@ packages:
|
|||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.1.3
|
||||
es-abstract: 1.19.1
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
dev: true
|
||||
|
||||
/arrify/1.0.1:
|
||||
|
@ -7198,12 +7177,12 @@ packages:
|
|||
peerDependencies:
|
||||
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
|
||||
dependencies:
|
||||
array-includes: 3.1.4
|
||||
array-includes: 3.1.5
|
||||
array.prototype.flatmap: 1.2.5
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.21.0
|
||||
estraverse: 5.3.0
|
||||
jsx-ast-utils: 3.2.1
|
||||
jsx-ast-utils: 3.3.3
|
||||
minimatch: 3.1.2
|
||||
object.entries: 1.1.5
|
||||
object.fromentries: 2.0.5
|
||||
|
@ -8192,7 +8171,7 @@ packages:
|
|||
/has-property-descriptors/1.0.0:
|
||||
resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
|
||||
dependencies:
|
||||
get-intrinsic: 1.1.1
|
||||
get-intrinsic: 1.1.3
|
||||
dev: true
|
||||
|
||||
/has-symbols/1.0.3:
|
||||
|
@ -10139,14 +10118,6 @@ packages:
|
|||
engines: {'0': node >= 0.2.0}
|
||||
dev: true
|
||||
|
||||
/jsx-ast-utils/3.2.1:
|
||||
resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==}
|
||||
engines: {node: '>=4.0'}
|
||||
dependencies:
|
||||
array-includes: 3.1.4
|
||||
object.assign: 4.1.2
|
||||
dev: true
|
||||
|
||||
/jsx-ast-utils/3.3.3:
|
||||
resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
@ -11978,8 +11949,8 @@ packages:
|
|||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.1.3
|
||||
es-abstract: 1.19.1
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
dev: true
|
||||
|
||||
/object.fromentries/2.0.5:
|
||||
|
@ -11987,15 +11958,15 @@ packages:
|
|||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.1.3
|
||||
es-abstract: 1.19.1
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
dev: true
|
||||
|
||||
/object.hasown/1.1.0:
|
||||
resolution: {integrity: sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==}
|
||||
dependencies:
|
||||
define-properties: 1.1.3
|
||||
es-abstract: 1.19.1
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
dev: true
|
||||
|
||||
/object.values/1.1.5:
|
||||
|
@ -13688,14 +13659,6 @@ packages:
|
|||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/regexp.prototype.flags/1.4.1:
|
||||
resolution: {integrity: sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.1.3
|
||||
dev: true
|
||||
|
||||
/regexp.prototype.flags/1.4.3:
|
||||
resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
@ -14545,12 +14508,12 @@ packages:
|
|||
resolution: {integrity: sha512-6WgDX8HmQqvEd7J+G6VtAahhsQIssiZ8zl7zKh1VDMFyL3hRTJP4FTNA3RbIp2TOQ9AYNDcc7e3fH0Qbup+DBg==}
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
define-properties: 1.1.3
|
||||
es-abstract: 1.19.1
|
||||
get-intrinsic: 1.1.1
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.4
|
||||
get-intrinsic: 1.1.3
|
||||
has-symbols: 1.0.3
|
||||
internal-slot: 1.0.3
|
||||
regexp.prototype.flags: 1.4.1
|
||||
regexp.prototype.flags: 1.4.3
|
||||
side-channel: 1.0.4
|
||||
dev: true
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue