mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(ui): textual ui components (#404)
* feat(ui): error message & text link error message & text link * feat(ui): add tos, divider and text link components tos, divider and text link components * fix(ui): update the usage of error message update the usage of error message
This commit is contained in:
parent
8144cfee79
commit
ee85a25d79
23 changed files with 317 additions and 64 deletions
|
@ -8,6 +8,9 @@ const translation = {
|
|||
error: 'Username or password is invalid.',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
terms_of_use: 'Terms of Use',
|
||||
terms_agreement_prefix: 'I agree with ',
|
||||
continue_with: 'Continue With',
|
||||
},
|
||||
register: {
|
||||
create_account: 'Create an Account',
|
||||
|
|
|
@ -10,6 +10,9 @@ const translation = {
|
|||
error: '用户名或密码错误。',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
terms_of_use: '用户协议',
|
||||
terms_agreement_prefix: '登录即表明您已经同意',
|
||||
continue_with: '更多',
|
||||
},
|
||||
register: {
|
||||
create_account: '创建新账户',
|
||||
|
|
9
packages/ui/src/assets/icons/radio-button.svg
Normal file
9
packages/ui/src/assets/icons/radio-button.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol width="18" height="18" viewBox="0 0 18 18" id="unchecked">
|
||||
<circle cx="9" cy="9" r="8" stroke="#D8D8D8" stroke-width="2" fill="none"/>
|
||||
</symbol>
|
||||
<symbol width="18" height="18" viewBox="0 0 18 18" id="checked">
|
||||
<circle cx="9" cy="9" r="9" fill="#6139F6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75691 7.99955C4.36639 8.39008 4.36639 9.02324 4.75691 9.41377L7.58534 12.2422C7.97586 12.6327 8.60903 12.6327 8.99955 12.2422L13.2422 7.99955C13.6327 7.60903 13.6327 6.97586 13.2422 6.58534C12.8517 6.19481 12.2185 6.19481 11.828 6.58534L8.29245 10.1209L6.17112 7.99955C5.7806 7.60903 5.14744 7.60903 4.75691 7.99955Z" fill="white"/>
|
||||
</symbol>
|
||||
</svg>
|
After Width: | Height: | Size: 783 B |
|
@ -74,15 +74,20 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
--color-control-focus: #{$color-primary-tint-60};
|
||||
--color-control-action: #{$color-neutral-70};
|
||||
--color-control-action-focus: #{$color-primary-tint-70};
|
||||
--color-checkbox-border: #{$color-neutral-30};
|
||||
--color-divider: #dbdbdb;
|
||||
|
||||
/* Font Color */
|
||||
--color-font-primary: #{$color-neutral-100};
|
||||
--color-font-secondary: #444;
|
||||
--color-font-placeholder: #aaa;
|
||||
--color-font-divider: #bbb;
|
||||
--color-font-button-text: #{$color-neutral-0};
|
||||
--color-font-button-text-active: #{rgba($color-neutral-0, 0.4)};
|
||||
--color-font-secondary-active: #{$color-neutral-70};
|
||||
--color-font-secondary-disabled: #{rgba($color-neutral-100, 0.4)};
|
||||
--color-font-link: #{$color-primary};
|
||||
--color-font-link-secondary: #{$color-neutral-70};
|
||||
|
||||
/* ===== Legacy Styling ===== */
|
||||
--color-heading: #333;
|
||||
|
@ -98,15 +103,15 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
|
||||
.mobile {
|
||||
--font-title: 600 32px/40px #{$font-family};
|
||||
--font-heading-2: 400 18px/22px #{$font-family};
|
||||
--font-heading-2: 500 18px/22px #{$font-family};
|
||||
--font-heading-2-bold: 600 18px/22px #{$font-family};
|
||||
--font-control: 500 18px/20px #{$font-family};
|
||||
--font-button-text: 600 20px/24px #{$font-family};
|
||||
|
||||
--font-body: 400 16px/20px #{$font-family};
|
||||
--font-body-bold: 600 16px/20px #{$font-family};
|
||||
--font-body-small: 400 14px/18px #{$font-family};
|
||||
/* ===== Legacy Styling ===== */
|
||||
--font-headline: 600 40px/56px #{$font-family};
|
||||
--font-heading-1: 600 28px/39px #{$font-family};
|
||||
--font-heading-3: 600 16px/22.4px #{$font-family};
|
||||
--font-body: 400 12px/16px #{$font-family};
|
||||
--font-body-bold: 500 12px/16px #{$font-family};
|
||||
}
|
||||
|
|
24
packages/ui/src/components/Divider/index.module.scss
Normal file
24
packages/ui/src/components/Divider/index.module.scss
Normal file
|
@ -0,0 +1,24 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.divider {
|
||||
@include _.flex-row;
|
||||
font: var(--font-body);
|
||||
color: var(--color-font-divider);
|
||||
margin: _.unit(4) 0;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-divider);
|
||||
|
||||
&:first-child {
|
||||
margin-right: _.unit(4);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: _.unit(4);
|
||||
}
|
||||
}
|
||||
}
|
14
packages/ui/src/components/Divider/index.test.tsx
Normal file
14
packages/ui/src/components/Divider/index.test.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Divider from '.';
|
||||
|
||||
describe('Divider', () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
it('render with content', () => {
|
||||
const { queryByText } = render(<Divider label="sign_in.continue_with" />);
|
||||
expect(queryByText(t('sign_in.continue_with'))).not.toBeNull();
|
||||
});
|
||||
});
|
25
packages/ui/src/components/Divider/index.tsx
Normal file
25
packages/ui/src/components/Divider/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { I18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
label?: I18nKey;
|
||||
};
|
||||
|
||||
const Divider = ({ className, label }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.divider, className)}>
|
||||
<i className={styles.line} />
|
||||
{label && t(label)}
|
||||
<i className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Divider;
|
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.error {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-error);
|
||||
}
|
24
packages/ui/src/components/ErrorMessage/index.tsx
Normal file
24
packages/ui/src/components/ErrorMessage/index.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { LogtoErrorCode, LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
errorCode?: LogtoErrorCode;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ errorCode, className, children }: Props) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.error, className)}>
|
||||
{children ?? (errorCode ? i18n.t<string, LogtoErrorI18nKey>(`errors:${errorCode}`) : ``)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
16
packages/ui/src/components/Icons/RadioButtonIcon.tsx
Normal file
16
packages/ui/src/components/Icons/RadioButtonIcon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React, { SVGProps } from 'react';
|
||||
|
||||
import RadioButton from '@/assets/icons/radio-button.svg';
|
||||
|
||||
const RadioButtonIcon = ({
|
||||
checked,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { checked?: boolean }) => {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<use href={`${RadioButton}#${checked ? 'checked' : 'unchecked'}`} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioButtonIcon;
|
|
@ -1,13 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.messageBox {
|
||||
font: var(--font-body-bold);
|
||||
padding: _.unit(2) _.unit(5);
|
||||
border-radius: _.unit();
|
||||
|
||||
&.error {
|
||||
color: var(--color-error);
|
||||
background: var(--color-error-background);
|
||||
border: 1px solid var(--color-error-border);
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const MessageBox = ({ className, children }: Props) => {
|
||||
return <div className={classNames(styles.messageBox, styles.error, className)}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MessageBox;
|
22
packages/ui/src/components/TermsOfUse/index.module.scss
Normal file
22
packages/ui/src/components/TermsOfUse/index.module.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.terms {
|
||||
@include _.flex-row;
|
||||
|
||||
input[type='checkbox'] {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radioButton {
|
||||
margin-right: _.unit(2);
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.content {
|
||||
@include _.text-hint;
|
||||
}
|
47
packages/ui/src/components/TermsOfUse/index.test.tsx
Normal file
47
packages/ui/src/components/TermsOfUse/index.test.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import TermsOfUse from '.';
|
||||
|
||||
describe('Terms of Use', () => {
|
||||
const onChange = jest.fn();
|
||||
const termsOfUse: TermsOfUseType = {
|
||||
enabled: true,
|
||||
contentUrl: 'http://logto.dev/',
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const prefix = t('sign_in.terms_agreement_prefix');
|
||||
|
||||
beforeEach(() => {
|
||||
onChange.mockClear();
|
||||
});
|
||||
|
||||
it('render Terms of User checkbox', () => {
|
||||
const { getByText, container } = render(
|
||||
<TermsOfUse name="terms" termsOfUse={termsOfUse} onChange={onChange} />
|
||||
);
|
||||
|
||||
const element = getByText(prefix);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onChange).toBeCalledWith(true);
|
||||
|
||||
const linkElement = container.querySelector('a');
|
||||
expect(linkElement).not.toBeNull();
|
||||
|
||||
if (linkElement) {
|
||||
expect(linkElement.href).toEqual(termsOfUse.contentUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it('render null with disabled terms', () => {
|
||||
const { container } = render(
|
||||
<TermsOfUse name="terms" termsOfUse={{ ...termsOfUse, enabled: false }} onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
});
|
52
packages/ui/src/components/TermsOfUse/index.tsx
Normal file
52
packages/ui/src/components/TermsOfUse/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RadioButtonIcon from '@/components/Icons/RadioButtonIcon';
|
||||
import TextLink from '@/components/TextLink';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
className?: string;
|
||||
termsOfUse: TermsOfUseType;
|
||||
isChecked?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const TermsOfUse = ({ name, className, termsOfUse, isChecked, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!termsOfUse.enabled || !termsOfUse.contentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = t('sign_in.terms_agreement_prefix');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.terms, className)}
|
||||
onClick={() => {
|
||||
onChange(!isChecked);
|
||||
}}
|
||||
>
|
||||
<input disabled readOnly name={name} type="checkbox" checked={isChecked} />
|
||||
<RadioButtonIcon checked={isChecked} className={styles.radioButton} />
|
||||
<div className={styles.content}>
|
||||
{prefix}
|
||||
<TextLink
|
||||
text="sign_in.terms_of_use"
|
||||
href={termsOfUse.contentUrl}
|
||||
type="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsOfUse;
|
|
@ -1,12 +1,18 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.link {
|
||||
color: var(--color-button-background);
|
||||
font: var(--font-body-bold);
|
||||
transition: var(--transition-default-control);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-button-background-hover);
|
||||
&.primary {
|
||||
color: var(--color-font-link);
|
||||
font: var(--font-body-bold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
color: var(--color-font-link-secondary);
|
||||
font: var(--font-body-small);
|
||||
}
|
||||
}
|
||||
|
|
18
packages/ui/src/components/TextLink/index.test.tsx
Normal file
18
packages/ui/src/components/TextLink/index.test.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import TextLink from '.';
|
||||
|
||||
describe('TextLink', () => {
|
||||
it('render with children', () => {
|
||||
const { queryByText } = render(<TextLink href="#">foo</TextLink>);
|
||||
expect(queryByText('foo')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render with i18nKey', () => {
|
||||
const { queryByText } = render(<TextLink href="#" text="sign_in.action" />);
|
||||
const { t } = useTranslation();
|
||||
expect(queryByText(t('sign_in.action'))).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,18 +1,25 @@
|
|||
import { I18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { ReactChild } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
children: ReactChild;
|
||||
children?: ReactNode;
|
||||
text?: I18nKey;
|
||||
href: string;
|
||||
type?: 'primary' | 'secondary';
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
const TextLink = ({ className, children, href }: Props) => {
|
||||
const TextLink = ({ className, children, text, href, type = 'primary', onClick }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a className={classNames(styles.link, className)} href={href}>
|
||||
{children}
|
||||
<a className={classNames(styles.link, styles[type], className)} href={href} onClick={onClick}>
|
||||
{children ?? (text ? t(text) : '')}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-heading-1);
|
||||
color: var(--color-heading);
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordInput from '@/components/Input/PasswordInput';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const Register: FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
|
@ -52,11 +51,7 @@ const Register: FC = () => {
|
|||
value={password}
|
||||
onChange={setPassword} // TODO: password validation
|
||||
/>
|
||||
{error && (
|
||||
<MessageBox className={styles.box}>
|
||||
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||
</MessageBox>
|
||||
)}
|
||||
{error && <ErrorMessage className={styles.box} errorCode={error.code} />}
|
||||
<Button isDisabled={loading} onClick={signUp}>
|
||||
{loading ? t('register.loading') : t('register.action')}
|
||||
</Button>
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-heading-1);
|
||||
color: var(--color-heading);
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordInput from '@/components/Input/PasswordInput';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
|
@ -15,7 +14,7 @@ import * as styles from './index.module.scss';
|
|||
|
||||
const SignIn: FC = () => {
|
||||
// TODO: Consider creating cross page data modal
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
|
@ -57,17 +56,15 @@ const SignIn: FC = () => {
|
|||
className={styles.inputField}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
{error && (
|
||||
<MessageBox className={styles.box}>
|
||||
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||
</MessageBox>
|
||||
)}
|
||||
{error && <ErrorMessage className={styles.box} errorCode={error.code} />}
|
||||
<Button isDisabled={loading} type="primary" onClick={signInHandler}>
|
||||
{loading ? t('sign_in.loading') : t('sign_in.action')}
|
||||
</Button>
|
||||
<TextLink className={styles.createAccount} href="/register">
|
||||
{t('register.create_account')}
|
||||
</TextLink>
|
||||
<TextLink
|
||||
className={styles.createAccount}
|
||||
href="/register"
|
||||
text="register.create_account"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -20,6 +20,16 @@
|
|||
object-position: center;
|
||||
}
|
||||
|
||||
@mixin text-hint {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-font-link-secondary);
|
||||
}
|
||||
|
||||
@mixin title {
|
||||
font: var(--font-title);
|
||||
color: var(--color-font-primary);
|
||||
}
|
||||
|
||||
@function border($color: transparent, $width: 1) {
|
||||
@return #{$width}px solid #{$color};
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue