0
Fork 0
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:
simeng-li 2022-03-18 15:34:38 +08:00 committed by GitHub
parent 8144cfee79
commit ee85a25d79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 317 additions and 64 deletions

View file

@ -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',

View file

@ -10,6 +10,9 @@ const translation = {
error: '用户名或密码错误。',
username: '用户名',
password: '密码',
terms_of_use: '用户协议',
terms_agreement_prefix: '登录即表明您已经同意',
continue_with: '更多',
},
register: {
create_account: '创建新账户',

View 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

View file

@ -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};
}

View 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);
}
}
}

View 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();
});
});

View 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;

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.error {
font: var(--font-body-small);
color: var(--color-error);
}

View 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;

View 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;

View file

@ -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);
}
}

View file

@ -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;

View 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;
}

View 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);
});
});

View 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;

View file

@ -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);
}
}

View 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();
});
});

View file

@ -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>
);
};

View file

@ -16,8 +16,7 @@
}
.title {
font: var(--font-heading-1);
color: var(--color-heading);
@include _.title;
margin-bottom: _.unit(9);
}

View file

@ -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>

View file

@ -16,8 +16,7 @@
}
.title {
font: var(--font-heading-1);
color: var(--color-heading);
@include _.title;
margin-bottom: _.unit(9);
}

View file

@ -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>
);

View file

@ -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};
}