0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(ui): implement phonenumber input field (#372)

* feat(ui): implement phonenumber input field

implement phonenumber input field

* fix(ui): phone input ui fix

phone input ui fix

* fix(ui): should not show error if not interacted

should not show error if not interacted

* fix(ui): fix styling

fix styling

* feat(ui): add typeMode for phone input

add typeMode for phone input

* chore(ui): update pnpm-lock

update pnpm-lock
This commit is contained in:
simeng-li 2022-03-15 17:31:13 +08:00 committed by GitHub
parent 9f3fc5a5cc
commit 86030ab97c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 649 additions and 82 deletions

View file

@ -6,6 +6,7 @@ const config: Config.InitialOptions = {
transform: {
// Enable JS/JSX transformation
'\\.(ts|js)x?$': 'ts-jest',
'\\.(svg)$': 'jest-transform-stub',
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\]((?!ky[/\\\\]).)+\\.(js|jsx|mjs|cjs|ts|tsx)$',

View file

@ -21,9 +21,11 @@
"i18next": "^21.6.11",
"i18next-browser-languagedetector": "^6.1.3",
"ky": "^0.29.0",
"libphonenumber-js": "^1.9.49",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.4",
"react-phone-number-input": "^3.1.46",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
@ -42,6 +44,7 @@
"eslint": "^8.10.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"jest-transform-stub": "^2.0.0",
"lint-staged": "^11.1.1",
"parcel": "^2.3.2",
"postcss": "^8.4.6",

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<symbol width="24" height="24" viewBox="0 0 24 24" id="down">
<path d="M14.2941 9.29409L12.0041 11.5941L9.71409 9.29409C9.62085 9.20085 9.51016 9.12689 9.38834 9.07643C9.26652 9.02597 9.13595 9 9.00409 9C8.87223 9 8.74166 9.02597 8.61984 9.07643C8.49802 9.12689 8.38733 9.20085 8.29409 9.29409C8.20085 9.38733 8.12689 9.49802 8.07643 9.61984C8.02597 9.74166 8 9.87223 8 10.0041C8 10.136 8.02597 10.2665 8.07643 10.3883C8.12689 10.5102 8.20085 10.6209 8.29409 10.7141L11.2941 13.7141C11.3871 13.8078 11.4977 13.8822 11.6195 13.933C11.7414 13.9838 11.8721 14.0099 12.0041 14.0099C12.1361 14.0099 12.2668 13.9838 12.3887 13.933C12.5105 13.8822 12.6211 13.8078 12.7141 13.7141L15.7141 10.7141C15.9024 10.5258 16.0082 10.2704 16.0082 10.0041C16.0082 9.73779 15.9024 9.48239 15.7141 9.29409C15.5258 9.10579 15.2704 9 15.0041 9C14.7378 9 14.4824 9.10579 14.2941 9.29409Z" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 977 B

View file

@ -0,0 +1,3 @@
<svg id="close-icon" width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<path d="M14.7098 7.29008C14.6169 7.19635 14.5063 7.12196 14.3844 7.07119C14.2626 7.02042 14.1318 6.99428 13.9998 6.99428C13.8678 6.99428 13.7371 7.02042 13.6153 7.07119C13.4934 7.12196 13.3828 7.19635 13.2898 7.29008L10.9998 9.59008L8.70984 7.29008C8.52153 7.10178 8.26614 6.99599 7.99984 6.99599C7.73353 6.99599 7.47814 7.10178 7.28983 7.29008C7.10153 7.47838 6.99574 7.73378 6.99574 8.00008C6.99574 8.26638 7.10153 8.52178 7.28983 8.71008L9.58984 11.0001L7.28983 13.2901C7.19611 13.383 7.12171 13.4936 7.07094 13.6155C7.02017 13.7374 6.99404 13.8681 6.99404 14.0001C6.99404 14.1321 7.02017 14.2628 7.07094 14.3847C7.12171 14.5065 7.19611 14.6171 7.28983 14.7101C7.3828 14.8038 7.4934 14.8782 7.61526 14.929C7.73712 14.9797 7.86782 15.0059 7.99984 15.0059C8.13185 15.0059 8.26255 14.9797 8.38441 14.929C8.50627 14.8782 8.61687 14.8038 8.70984 14.7101L10.9998 12.4101L13.2898 14.7101C13.3828 14.8038 13.4934 14.8782 13.6153 14.929C13.7371 14.9797 13.8678 15.0059 13.9998 15.0059C14.1318 15.0059 14.2626 14.9797 14.3844 14.929C14.5063 14.8782 14.6169 14.8038 14.7098 14.7101C14.8036 14.6171 14.878 14.5065 14.9287 14.3847C14.9795 14.2628 15.0056 14.1321 15.0056 14.0001C15.0056 13.8681 14.9795 13.7374 14.9287 13.6155C14.878 13.4936 14.8036 13.383 14.7098 13.2901L12.4098 11.0001L14.7098 8.71008C14.8036 8.61712 14.878 8.50652 14.9287 8.38466C14.9795 8.2628 15.0056 8.13209 15.0056 8.00008C15.0056 7.86807 14.9795 7.73736 14.9287 7.6155C14.878 7.49364 14.8036 7.38304 14.7098 7.29008ZM18.0698 3.93008C17.1474 2.97498 16.0439 2.21316 14.8239 1.68907C13.6038 1.16498 12.2916 0.889113 10.9638 0.877575C9.63605 0.866037 8.31926 1.11905 7.09029 1.62186C5.86133 2.12467 4.74481 2.8672 3.80589 3.80613C2.86696 4.74506 2.12443 5.86158 1.62162 7.09054C1.11881 8.3195 0.865793 9.6363 0.877331 10.9641C0.888869 12.2919 1.16473 13.6041 1.68882 14.8241C2.21291 16.0442 2.97473 17.1476 3.92984 18.0701C4.8523 19.0252 5.95575 19.787 7.17579 20.3111C8.39583 20.8352 9.70803 21.111 11.0358 21.1226C12.3636 21.1341 13.6804 20.8811 14.9094 20.3783C16.1383 19.8755 17.2549 19.133 18.1938 18.194C19.1327 17.2551 19.8752 16.1386 20.3781 14.9096C20.8809 13.6807 21.1339 12.3639 21.1223 11.0361C21.1108 9.70827 20.8349 8.39607 20.3109 7.17603C19.7868 5.95599 19.0249 4.85255 18.0698 3.93008ZM16.6598 16.6601C15.3519 17.9695 13.6304 18.7849 11.7886 18.9674C9.94688 19.1499 8.09884 18.6881 6.55936 17.6609C5.01987 16.6336 3.88419 15.1043 3.34581 13.3336C2.80742 11.5628 2.89964 9.66022 3.60675 7.94986C4.31386 6.23951 5.59211 4.82723 7.22373 3.95364C8.85534 3.08006 10.7394 2.79921 12.5548 3.15895C14.3703 3.5187 16.0049 4.49677 17.1801 5.92654C18.3553 7.35631 18.9984 9.14932 18.9998 11.0001C19.0034 12.0514 18.7984 13.0929 18.3968 14.0645C17.9951 15.036 17.4047 15.9182 16.6598 16.6601Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" >
<symbol id="hide" width="24" height="24" viewBox="0 0 24 24">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.4215 6.7492C4.07018 8.00321 2.9206 9.64733 2.07958 11.6C2.02452 11.7262 1.99609 11.8623 1.99609 12C1.99609 12.1377 2.02452 12.2738 2.07958 12.4C4.09958 17.09 7.89958 20 11.9996 20C13.796 20 15.5347 19.4414 17.0915 18.4192L15.6372 16.9649C14.498 17.6367 13.2623 18 11.9996 18C8.82958 18 5.82958 15.71 4.09958 12C4.81964 10.4558 5.75972 9.15764 6.8405 8.16819L5.4215 6.7492ZM8.58647 9.91417C8.4785 10.0908 8.38397 10.2764 8.30406 10.4693C8.00131 11.2002 7.9221 12.0044 8.07644 12.7804C8.23078 13.5563 8.61174 14.269 9.17115 14.8284C9.73056 15.3878 10.4433 15.7688 11.2192 15.9231C11.9951 16.0775 12.7994 15.9983 13.5303 15.6955C13.7232 15.6156 13.9087 15.5211 14.0854 15.4131L12.5848 13.9125C12.3968 13.97 12.1997 14 11.9996 14C11.604 14 11.2173 13.8827 10.8884 13.6629C10.5595 13.4432 10.3032 13.1308 10.1518 12.7654C10.0004 12.3999 9.96084 11.9978 10.038 11.6098C10.0511 11.5438 10.0675 11.4787 10.0871 11.4148L8.58647 9.91417ZM15.9847 11.6556L12.344 8.01485C13.2787 8.09559 14.1595 8.50304 14.828 9.17157C15.4965 9.84011 15.904 10.7209 15.9847 11.6556ZM18.5754 14.2463C19.0711 13.5734 19.5165 12.8215 19.8996 12C18.1696 8.29 15.1696 6 11.9996 6C11.494 6 10.9928 6.05824 10.5004 6.1712L8.89645 4.56729C9.89345 4.19567 10.9364 4 11.9996 4C16.0996 4 19.8996 6.91 21.9196 11.6C21.9746 11.7262 22.0031 11.8623 22.0031 12C22.0031 12.1377 21.9746 12.2738 21.9196 12.4C21.3941 13.62 20.7482 14.7195 20.0079 15.6788L18.5754 14.2463Z"
/>
<rect
x="3.30762"
y="3.22266"
width="2"
height="24"
rx="1"
transform="rotate(-45 3.30762 3.22266)"
/>
</symbol>
<symbol id="show" width="24" height="24" viewBox="0 0 24 24" >
<path d="M21.9196 11.6C19.8996 6.91 16.0996 4 11.9996 4C7.89958 4 4.09958 6.91 2.07958 11.6C2.02452 11.7262 1.99609 11.8623 1.99609 12C1.99609 12.1377 2.02452 12.2738 2.07958 12.4C4.09958 17.09 7.89958 20 11.9996 20C16.0996 20 19.8996 17.09 21.9196 12.4C21.9746 12.2738 22.0031 12.1377 22.0031 12C22.0031 11.8623 21.9746 11.7262 21.9196 11.6ZM11.9996 18C8.82958 18 5.82958 15.71 4.09958 12C5.82958 8.29 8.82958 6 11.9996 6C15.1696 6 18.1696 8.29 19.8996 12C18.1696 15.71 15.1696 18 11.9996 18ZM11.9996 8C11.2085 8 10.4351 8.2346 9.7773 8.67412C9.1195 9.11365 8.60681 9.73836 8.30406 10.4693C8.00131 11.2002 7.9221 12.0044 8.07644 12.7804C8.23078 13.5563 8.61174 14.269 9.17115 14.8284C9.73056 15.3878 10.4433 15.7688 11.2192 15.9231C11.9951 16.0775 12.7994 15.9983 13.5303 15.6955C14.2612 15.3928 14.8859 14.8801 15.3255 14.2223C15.765 13.5645 15.9996 12.7911 15.9996 12C15.9996 10.9391 15.5782 9.92172 14.828 9.17157C14.0779 8.42143 13.0604 8 11.9996 8ZM11.9996 14C11.604 14 11.2173 13.8827 10.8884 13.6629C10.5595 13.4432 10.3032 13.1308 10.1518 12.7654C10.0004 12.3999 9.96084 11.9978 10.038 11.6098C10.1152 11.2219 10.3057 10.8655 10.5854 10.5858C10.8651 10.3061 11.2214 10.1156 11.6094 10.0384C11.9974 9.96126 12.3995 10.0009 12.7649 10.1522C13.1304 10.3036 13.4428 10.56 13.6625 10.8889C13.8823 11.2178 13.9996 11.6044 13.9996 12C13.9996 12.5304 13.7889 13.0391 13.4138 13.4142C13.0387 13.7893 12.53 14 11.9996 14Z" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -52,7 +52,7 @@
--color-gradient: linear-gradient(69.73deg, #492ef3 5.52%, #cf69ff 94.22%);
--color-background: #fdfdff;
--color-control-background: #f4f4f4;
--color-control-focus: #a48dfa;
--color-control-focus: #{rgba(#a48dfa, 0.8)};
--color-neutral-100: #111;
--color-neutral-90: #666;

View file

@ -1,8 +1,10 @@
import React, { SVGProps } from 'react';
import CloseIcon from '@/assets/icons/close-icon.svg';
const ClearIcon = (props: SVGProps<SVGSVGElement>) => (
<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M14.7098 7.29008C14.6169 7.19635 14.5063 7.12196 14.3844 7.07119C14.2626 7.02042 14.1318 6.99428 13.9998 6.99428C13.8678 6.99428 13.7371 7.02042 13.6153 7.07119C13.4934 7.12196 13.3828 7.19635 13.2898 7.29008L10.9998 9.59008L8.70984 7.29008C8.52153 7.10178 8.26614 6.99599 7.99984 6.99599C7.73353 6.99599 7.47814 7.10178 7.28983 7.29008C7.10153 7.47838 6.99574 7.73378 6.99574 8.00008C6.99574 8.26638 7.10153 8.52178 7.28983 8.71008L9.58984 11.0001L7.28983 13.2901C7.19611 13.383 7.12171 13.4936 7.07094 13.6155C7.02017 13.7374 6.99404 13.8681 6.99404 14.0001C6.99404 14.1321 7.02017 14.2628 7.07094 14.3847C7.12171 14.5065 7.19611 14.6171 7.28983 14.7101C7.3828 14.8038 7.4934 14.8782 7.61526 14.929C7.73712 14.9797 7.86782 15.0059 7.99984 15.0059C8.13185 15.0059 8.26255 14.9797 8.38441 14.929C8.50627 14.8782 8.61687 14.8038 8.70984 14.7101L10.9998 12.4101L13.2898 14.7101C13.3828 14.8038 13.4934 14.8782 13.6153 14.929C13.7371 14.9797 13.8678 15.0059 13.9998 15.0059C14.1318 15.0059 14.2626 14.9797 14.3844 14.929C14.5063 14.8782 14.6169 14.8038 14.7098 14.7101C14.8036 14.6171 14.878 14.5065 14.9287 14.3847C14.9795 14.2628 15.0056 14.1321 15.0056 14.0001C15.0056 13.8681 14.9795 13.7374 14.9287 13.6155C14.878 13.4936 14.8036 13.383 14.7098 13.2901L12.4098 11.0001L14.7098 8.71008C14.8036 8.61712 14.878 8.50652 14.9287 8.38466C14.9795 8.2628 15.0056 8.13209 15.0056 8.00008C15.0056 7.86807 14.9795 7.73736 14.9287 7.6155C14.878 7.49364 14.8036 7.38304 14.7098 7.29008ZM18.0698 3.93008C17.1474 2.97498 16.0439 2.21316 14.8239 1.68907C13.6038 1.16498 12.2916 0.889113 10.9638 0.877575C9.63605 0.866037 8.31926 1.11905 7.09029 1.62186C5.86133 2.12467 4.74481 2.8672 3.80589 3.80613C2.86696 4.74506 2.12443 5.86158 1.62162 7.09054C1.11881 8.3195 0.865793 9.6363 0.877331 10.9641C0.888869 12.2919 1.16473 13.6041 1.68882 14.8241C2.21291 16.0442 2.97473 17.1476 3.92984 18.0701C4.8523 19.0252 5.95575 19.787 7.17579 20.3111C8.39583 20.8352 9.70803 21.111 11.0358 21.1226C12.3636 21.1341 13.6804 20.8811 14.9094 20.3783C16.1383 19.8755 17.2549 19.133 18.1938 18.194C19.1327 17.2551 19.8752 16.1386 20.3781 14.9096C20.8809 13.6807 21.1339 12.3639 21.1223 11.0361C21.1108 9.70827 20.8349 8.39607 20.3109 7.17603C19.7868 5.95599 19.0249 4.85255 18.0698 3.93008ZM16.6598 16.6601C15.3519 17.9695 13.6304 18.7849 11.7886 18.9674C9.94688 19.1499 8.09884 18.6881 6.55936 17.6609C5.01987 16.6336 3.88419 15.1043 3.34581 13.3336C2.80742 11.5628 2.89964 9.66022 3.60675 7.94986C4.31386 6.23951 5.59211 4.82723 7.22373 3.95364C8.85534 3.08006 10.7394 2.79921 12.5548 3.15895C14.3703 3.5187 16.0049 4.49677 17.1801 5.92654C18.3553 7.35631 18.9984 9.14932 18.9998 11.0001C19.0034 12.0514 18.7984 13.0929 18.3968 14.0645C17.9951 15.036 17.4047 15.9182 16.6598 16.6601Z" />
<use href={`${CloseIcon}#close-icon`} />
</svg>
);

View file

@ -0,0 +1,13 @@
import React, { SVGProps } from 'react';
import Arrow from '@/assets/icons/arrow.svg';
const DownArrowIcon = (props: SVGProps<SVGSVGElement>) => {
return (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<use href={`${Arrow}#down`} transform="translate(0, 1)" />
</svg>
);
};
export default DownArrowIcon;

View file

@ -1,33 +1,15 @@
import React, { SVGProps } from 'react';
import Icon from '@/assets/icons/privacy-icon.svg';
type Props = {
type?: 'show' | 'hide';
} & SVGProps<SVGSVGElement>;
const PrivacyIcon = ({ type = 'show', ...rest }: Props) => {
if (type === 'hide') {
return (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.4215 6.7492C4.07018 8.00321 2.9206 9.64733 2.07958 11.6C2.02452 11.7262 1.99609 11.8623 1.99609 12C1.99609 12.1377 2.02452 12.2738 2.07958 12.4C4.09958 17.09 7.89958 20 11.9996 20C13.796 20 15.5347 19.4414 17.0915 18.4192L15.6372 16.9649C14.498 17.6367 13.2623 18 11.9996 18C8.82958 18 5.82958 15.71 4.09958 12C4.81964 10.4558 5.75972 9.15764 6.8405 8.16819L5.4215 6.7492ZM8.58647 9.91417C8.4785 10.0908 8.38397 10.2764 8.30406 10.4693C8.00131 11.2002 7.9221 12.0044 8.07644 12.7804C8.23078 13.5563 8.61174 14.269 9.17115 14.8284C9.73056 15.3878 10.4433 15.7688 11.2192 15.9231C11.9951 16.0775 12.7994 15.9983 13.5303 15.6955C13.7232 15.6156 13.9087 15.5211 14.0854 15.4131L12.5848 13.9125C12.3968 13.97 12.1997 14 11.9996 14C11.604 14 11.2173 13.8827 10.8884 13.6629C10.5595 13.4432 10.3032 13.1308 10.1518 12.7654C10.0004 12.3999 9.96084 11.9978 10.038 11.6098C10.0511 11.5438 10.0675 11.4787 10.0871 11.4148L8.58647 9.91417ZM15.9847 11.6556L12.344 8.01485C13.2787 8.09559 14.1595 8.50304 14.828 9.17157C15.4965 9.84011 15.904 10.7209 15.9847 11.6556ZM18.5754 14.2463C19.0711 13.5734 19.5165 12.8215 19.8996 12C18.1696 8.29 15.1696 6 11.9996 6C11.494 6 10.9928 6.05824 10.5004 6.1712L8.89645 4.56729C9.89345 4.19567 10.9364 4 11.9996 4C16.0996 4 19.8996 6.91 21.9196 11.6C21.9746 11.7262 22.0031 11.8623 22.0031 12C22.0031 12.1377 21.9746 12.2738 21.9196 12.4C21.3941 13.62 20.7482 14.7195 20.0079 15.6788L18.5754 14.2463Z"
/>
<rect
x="3.30762"
y="3.22266"
width="2"
height="24"
rx="1"
transform="rotate(-45 3.30762 3.22266)"
/>
</svg>
);
}
return (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
<path d="M21.9196 11.6C19.8996 6.91 16.0996 4 11.9996 4C7.89958 4 4.09958 6.91 2.07958 11.6C2.02452 11.7262 1.99609 11.8623 1.99609 12C1.99609 12.1377 2.02452 12.2738 2.07958 12.4C4.09958 17.09 7.89958 20 11.9996 20C16.0996 20 19.8996 17.09 21.9196 12.4C21.9746 12.2738 22.0031 12.1377 22.0031 12C22.0031 11.8623 21.9746 11.7262 21.9196 11.6ZM11.9996 18C8.82958 18 5.82958 15.71 4.09958 12C5.82958 8.29 8.82958 6 11.9996 6C15.1696 6 18.1696 8.29 19.8996 12C18.1696 15.71 15.1696 18 11.9996 18ZM11.9996 8C11.2085 8 10.4351 8.2346 9.7773 8.67412C9.1195 9.11365 8.60681 9.73836 8.30406 10.4693C8.00131 11.2002 7.9221 12.0044 8.07644 12.7804C8.23078 13.5563 8.61174 14.269 9.17115 14.8284C9.73056 15.3878 10.4433 15.7688 11.2192 15.9231C11.9951 16.0775 12.7994 15.9983 13.5303 15.6955C14.2612 15.3928 14.8859 14.8801 15.3255 14.2223C15.765 13.5645 15.9996 12.7911 15.9996 12C15.9996 10.9391 15.5782 9.92172 14.828 9.17157C14.0779 8.42143 13.0604 8 11.9996 8ZM11.9996 14C11.604 14 11.2173 13.8827 10.8884 13.6629C10.5595 13.4432 10.3032 13.1308 10.1518 12.7654C10.0004 12.3999 9.96084 11.9978 10.038 11.6098C10.1152 11.2219 10.3057 10.8655 10.5854 10.5858C10.8651 10.3061 11.2214 10.1156 11.6094 10.0384C11.9974 9.96126 12.3995 10.0009 12.7649 10.1522C13.1304 10.3036 13.4428 10.56 13.6625 10.8889C13.8823 11.2178 13.9996 11.6044 13.9996 12C13.9996 12.5304 13.7889 13.0391 13.4138 13.4142C13.0387 13.7893 12.53 14 11.9996 14Z" />
<use href={`${Icon}#${type}`} />
</svg>
);
};

View file

@ -1,2 +1,3 @@
export { default as ClearIcon } from './ClearIcon';
export { default as PrivacyIcon } from './PrivacyIcon';
export { default as DownArrowIcon } from './DownArrowIcon';

View file

@ -24,12 +24,19 @@ describe('Input Field UI Component', () => {
const { container } = render(<PasswordInput name="foo" value={text} onChange={onChange} />);
const inputEle = container.querySelector('input');
if (!inputEle) {
return;
}
fireEvent.focus(inputEle);
const visibilityButton = container.querySelector('svg');
expect(visibilityButton).not.toBeNull();
if (visibilityButton) {
fireEvent.click(visibilityButton);
expect(inputEle?.type).toEqual('text');
fireEvent.mouseDown(visibilityButton);
expect(inputEle.type).toEqual('text');
}
});
});

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { useState, useRef } from 'react';
import React, { useState } from 'react';
import { PrivacyIcon } from '../Icons';
import * as styles from './index.module.scss';
@ -25,20 +25,23 @@ const PasswordInput = ({
hasError = false,
onChange,
}: Props) => {
const inputReference = useRef<HTMLInputElement>(null);
// Used to toggle the password visibility
// Toggle the password visibility
const [type, setType] = useState('password');
const [onFocus, setOnFocus] = useState(false);
const iconType = type === 'password' ? 'hide' : 'show';
return (
<div className={classNames(styles.wrapper, className)}>
<div
className={classNames(
styles.wrapper,
onFocus && styles.focus,
hasError && styles.error,
className
)}
>
<input
ref={inputReference}
name={name}
disabled={isDisabled}
className={classNames(styles.input, hasError && styles.error)}
placeholder={placeholder}
type={type}
value={value}
@ -58,7 +61,6 @@ const PasswordInput = ({
className={classNames(styles.actionButton, iconType === 'hide' && styles.highlight)}
type={iconType}
onMouseDown={(event) => {
// Should execute before onFocus
event.preventDefault();
setType(type === 'password' ? 'text' : 'password');
}}

View file

@ -0,0 +1,80 @@
import { render, fireEvent } from '@testing-library/react';
import React from 'react';
import { defaultCountryCallingCode, countryList } from '@/hooks/use-phone-number';
import PhoneInput from './PhoneInput';
describe('Phone Input Field UI Component', () => {
const onChange = jest.fn();
beforeEach(() => {
onChange.mockClear();
});
it('render empty PhoneInput', () => {
const { queryByText, container } = render(
<PhoneInput name="PhoneInput" nationalNumber="" onChange={onChange} />
);
expect(queryByText(`+${defaultCountryCallingCode}`)).toBeNull();
expect(container.querySelector('input')?.value).toBe('');
});
it('render with country list', () => {
const { queryByText, container } = render(
<PhoneInput
name="PhoneInput"
nationalNumber=""
countryList={countryList}
countryCallingCode={defaultCountryCallingCode}
onChange={onChange}
/>
);
const countryCode = queryByText(`+${defaultCountryCallingCode}`);
expect(countryCode).not.toBeNull();
const selector = container.querySelector('select');
expect(selector).not.toBeNull();
if (selector) {
fireEvent.change(selector, { target: { value: '1' } });
expect(onChange).toBeCalledWith({ countryCallingCode: '1' });
}
});
it('render input update', () => {
const { container } = render(
<PhoneInput name="PhoneInput" nationalNumber="911" onChange={onChange} />
);
const inputField = container.querySelector('input');
expect(inputField?.value).toBe('911');
if (inputField) {
fireEvent.change(inputField, { target: { value: '110' } });
expect(onChange).toBeCalledWith({ nationalNumber: '110' });
fireEvent.focus(inputField);
}
});
it('render input clear', () => {
const { container } = render(
<PhoneInput name="PhoneInput" nationalNumber="911" onChange={onChange} />
);
const inputField = container.querySelector('input');
if (inputField) {
fireEvent.focus(inputField);
}
const clearButton = container.querySelector('svg');
expect(clearButton).not.toBeNull();
if (clearButton) {
fireEvent.mouseDown(clearButton);
expect(onChange).toBeCalledWith({ nationalNumber: '' });
}
});
});

View file

@ -0,0 +1,111 @@
import classNames from 'classnames';
import React, { useState, useMemo, useRef } from 'react';
import { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number';
import { ClearIcon, DownArrowIcon } from '../Icons';
import * as styles from './index.module.scss';
import * as phoneInputStyles from './phoneInput.module.scss';
export type Props = {
name: string;
autoComplete?: AutoCompleteType;
isDisabled?: boolean;
className?: string;
placeholder?: string;
countryCallingCode?: CountryCallingCode;
nationalNumber: string;
countryList?: CountryMetaData[];
hasError?: boolean;
onChange: (value: { countryCallingCode?: CountryCallingCode; nationalNumber?: string }) => void;
};
const PhoneInput = ({
name,
autoComplete,
isDisabled,
className,
placeholder,
countryCallingCode,
nationalNumber,
countryList,
hasError = false,
onChange,
}: Props) => {
const [onFocus, setOnFocus] = useState(false);
const inputReference = useRef<HTMLInputElement>(null);
const countrySelector = useMemo(() => {
if (!countryCallingCode || !countryList) {
return null;
}
return (
<div className={phoneInputStyles.countryCodeSelector}>
<span>{`+${countryCallingCode}`}</span>
<DownArrowIcon />
<select
onChange={({ target: { value } }) => {
onChange({ countryCallingCode: value });
// Auto Focus to the input
if (inputReference.current) {
inputReference.current.focus();
const { length } = inputReference.current.value;
inputReference.current.setSelectionRange(length, length);
}
}}
>
{countryList.map(({ countryCode, countryCallingCode, countryName }) => (
<option key={countryCode} value={countryCallingCode}>
{`${countryName ?? countryCode}: +${countryCallingCode}`}
</option>
))}
</select>
</div>
);
}, [countryCallingCode, countryList, onChange]);
return (
<div
className={classNames(
styles.wrapper,
onFocus && styles.focus,
hasError && styles.error,
className
)}
>
{countrySelector}
<input
ref={inputReference}
name={name}
disabled={isDisabled}
placeholder={placeholder}
value={nationalNumber}
type="tel"
inputMode="numeric"
autoComplete={autoComplete}
onFocus={() => {
setOnFocus(true);
}}
onBlur={() => {
setOnFocus(false);
}}
onChange={({ target: { value } }) => {
onChange({ nationalNumber: value });
}}
/>
{nationalNumber && onFocus && (
<ClearIcon
className={styles.actionButton}
onMouseDown={(event) => {
event.preventDefault();
onChange({ nationalNumber: '' });
}}
/>
)}
</div>
);
};
export default PhoneInput;

View file

@ -2,38 +2,46 @@
.wrapper {
position: relative;
}
.input {
width: 100%;
padding: _.unit(3) _.unit(12) _.unit(3) _.unit(5);
@include _.flex-row;
padding: 0 _.unit(4);
border-radius: _.unit(2);
border: _.border();
background: var(--color-control-background);
color: var(--color-font-primary);
caret-color: var(--color-primary);
font: var(--font-control);
transition: var(--transition-default-control);
&::placeholder {
color: var(--color-font-tertiary-3);
> *:not(:first-child) {
margin-left: _.unit(1);
}
&:focus {
&.focus {
border: _.border(var(--color-control-focus));
}
}
.error,
.error:focus {
border: _.border(var(--color-error));
&.error {
border: _.border(var(--color-error));
}
input {
flex: 1;
border: none;
background: none;
padding: _.unit(3) 0;
caret-color: var(--color-primary);
font: var(--font-control);
transition: var(--transition-default-control);
&::placeholder {
color: var(--color-font-tertiary-3);
}
&:-webkit-autofill {
box-shadow: 0 0 0 30px var(--color-control-background) inset;
transition: background-color 5000s ease-in-out 0s;
}
}
}
.actionButton {
position: absolute;
right: _.unit(5);
bottom: 50%;
transform: translateY(50%);
fill: var(--color-neutral-70);
&.highlight {

View file

@ -12,7 +12,6 @@ describe('Input Field UI Component', () => {
const inputEle = container.querySelector('input');
expect(inputEle).not.toBeNull();
expect(inputEle?.value).toEqual(text);
expect(container.querySelector('svg')).not.toBeNull();
if (inputEle) {
fireEvent.change(inputEle, { target: { value: 'update' } });
@ -22,11 +21,19 @@ describe('Input Field UI Component', () => {
test('click on clear button', () => {
const { container } = render(<Input name="foo" value={text} onChange={onChange} />);
const inputField = container.querySelector('input');
expect(container.querySelector('svg')).toBeNull();
if (inputField) {
fireEvent.focus(inputField);
}
const clearIcon = container.querySelector('svg');
expect(clearIcon).not.toBeNull();
if (clearIcon) {
fireEvent.click(clearIcon);
fireEvent.mouseDown(clearIcon);
expect(onChange).toBeCalledWith('');
}
});

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { useState, useRef } from 'react';
import React, { useState } from 'react';
import { ClearIcon } from '../Icons';
import * as styles from './index.module.scss';
@ -30,15 +30,19 @@ const Input = ({
onChange,
}: Props) => {
const [onFocus, setOnFocus] = useState(false);
const inputReference = useRef<HTMLInputElement>(null);
return (
<div className={classNames(styles.wrapper, className)}>
<div
className={classNames(
styles.wrapper,
onFocus && styles.focus,
hasError && styles.error,
className
)}
>
<input
ref={inputReference}
name={name}
disabled={isDisabled}
className={classNames(styles.input, hasError && styles.error)}
placeholder={placeholder}
type={type}
value={value}
@ -57,7 +61,6 @@ const Input = ({
<ClearIcon
className={styles.actionButton}
onMouseDown={(event) => {
// Should execute before onFocus
event.preventDefault();
onChange('');
}}

View file

@ -0,0 +1,26 @@
@use '@/scss/underscore' as _;
.countryCodeSelector {
color: var(--color-font-primary);
font: var(--font-control);
border: none;
background: none;
width: auto;
@include _.flex-row;
position: relative;
> select {
appearance: none;
border: none;
outline: none;
background: none;
position: absolute;
width: 100%;
height: 100%;
font-size: 0;
}
> svg {
fill: var(--color-primary);
}
}

View file

@ -0,0 +1,79 @@
import { render, fireEvent } from '@testing-library/react';
import React from 'react';
import { defaultCountryCallingCode } from '@/hooks/use-phone-number';
import PhoneInputProvider from '.';
describe('Phone Input Provider', () => {
const onChange = jest.fn();
beforeEach(() => {
onChange.mockClear();
});
it('render with empty input', () => {
const { queryByText } = render(
<PhoneInputProvider name="phone" value="" onChange={onChange} />
);
expect(queryByText(`+${defaultCountryCallingCode}`)).not.toBeNull();
});
it('render with input', () => {
const { queryByText, container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
expect(queryByText('+1')).not.toBeNull();
expect(container.querySelector('input')?.value).toEqual('911');
});
it('update country code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
const selector = container.querySelector('select');
if (selector) {
fireEvent.change(selector, { target: { value: '86' } });
expect(onChange).toBeCalledWith('+86911');
}
});
it('update national code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '119' } });
expect(onChange).toBeCalledWith('+1119');
}
});
it('clear national code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
const input = container.querySelector('input');
if (!input) {
return;
}
fireEvent.focus(input);
const clearButton = container.querySelectorAll('svg');
expect(clearButton).toHaveLength(2);
if (clearButton[1]) {
fireEvent.mouseDown(clearButton[1]);
expect(onChange).toBeCalledWith('+1');
}
});
});

View file

@ -0,0 +1,38 @@
import React from 'react';
import PhoneInput from '@/components/Input/PhoneInput';
import usePhoneNumber, { countryList } from '@/hooks/use-phone-number';
export type Props = {
name: string;
autoComplete?: AutoCompleteType;
isDisabled?: boolean;
className?: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
};
const PhoneInputProvider = ({ value, onChange, ...inputProps }: Props) => {
// TODO: error message
const {
error,
phoneNumber: { countryCallingCode, nationalNumber, interacted },
setPhoneNumber,
} = usePhoneNumber(value, onChange);
return (
<PhoneInput
{...inputProps}
countryCallingCode={countryCallingCode}
nationalNumber={nationalNumber}
countryList={countryList}
hasError={Boolean(error && interacted)}
onChange={(data) => {
setPhoneNumber((phoneNumber) => ({ ...phoneNumber, ...data, interacted: true }));
}}
/>
);
};
export default PhoneInputProvider;

View file

@ -0,0 +1,128 @@
/**
* Provide PhoneNumber Format support
* Reference [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js)
*/
import {
parsePhoneNumber as _parsePhoneNumber,
getCountries,
getCountryCallingCode,
CountryCallingCode,
CountryCode,
E164Number,
ParseError,
} from 'libphonenumber-js';
import { useState, useEffect } from 'react';
// Should not need the react-phone-number-input package, but we use its locale country name for now
import en from 'react-phone-number-input/locale/en.json';
export type { CountryCallingCode } from 'libphonenumber-js';
/**
* TODO: Get Default Country Code
*/
const defaultCountryCode: CountryCode = 'CN';
export const defaultCountryCallingCode: CountryCallingCode =
getCountryCallingCode(defaultCountryCode);
/**
* Provide Country Code Options
* TODO: Country Name i18n
*/
export type CountryMetaData = {
countryCode: CountryCode;
countryCallingCode: CountryCallingCode;
countryName?: string;
};
export const countryList: CountryMetaData[] = getCountries().map((code) => {
const callingCode = getCountryCallingCode(code);
const countryName = en[code];
return {
countryCode: code,
countryCallingCode: callingCode,
countryName,
};
});
type PhoneNumberData = {
countryCallingCode: string;
nationalNumber: string;
};
// Add interact status to prevent the initial onUpdate useEffect call
type PhoneNumberState = PhoneNumberData & { interacted: boolean };
const parseE164Number = (value: string): E164Number | '' => {
if (!value || value.startsWith('+')) {
return value;
}
return `+${value}`;
};
export const parsePhoneNumber = (value: string): [ParseError?, PhoneNumberData?] => {
try {
const phoneNumber = _parsePhoneNumber(parseE164Number(value));
const { countryCallingCode, nationalNumber } = phoneNumber;
return [undefined, { countryCallingCode, nationalNumber }];
} catch (error: unknown) {
if (error instanceof ParseError) {
return [error];
}
throw error;
}
};
const usePhoneNumber = (value: string, onChangeCallback: (value: string) => void) => {
// TODO: phoneNumber format based on country
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberState>({
countryCallingCode: defaultCountryCallingCode,
nationalNumber: '',
interacted: false,
});
const [error, setError] = useState<ParseError>();
useEffect(() => {
// Only run on data initialization
if (phoneNumber.interacted) {
return;
}
const [parseError, result] = parsePhoneNumber(value);
setError(parseError);
if (result) {
const { countryCallingCode, nationalNumber } = result;
setPhoneNumber((previous) => ({
...previous,
countryCallingCode,
nationalNumber,
}));
}
}, [phoneNumber.interacted, value]);
useEffect(() => {
// Only run after data initialization
if (!phoneNumber.interacted) {
return;
}
const { countryCallingCode, nationalNumber } = phoneNumber;
const [parseError] = parsePhoneNumber(`${countryCallingCode}${nationalNumber}`);
setError(parseError);
onChangeCallback(`+${countryCallingCode}${nationalNumber}`);
}, [onChangeCallback, phoneNumber]);
return {
error,
phoneNumber,
setPhoneNumber,
};
};
export default usePhoneNumber;

View file

@ -68,7 +68,8 @@ type AutoCompleteType =
| 'bday-year'
| 'sex'
| 'url'
| 'photo';
| 'photo'
| 'mobile';
// TO-DO: remove me
interface Body {

View file

@ -9,6 +9,12 @@
justify-content: center;
}
@mixin flex-row {
display: flex;
align-items: center;
justify-content: center;
}
@mixin image-align-center {
object-fit: contain;
object-position: center;

View file

@ -288,7 +288,9 @@ importers:
i18next-browser-languagedetector: ^6.1.3
identity-obj-proxy: ^3.0.0
jest: ^27.5.1
jest-transform-stub: ^2.0.0
ky: ^0.29.0
libphonenumber-js: ^1.9.49
lint-staged: ^11.1.1
parcel: ^2.3.2
postcss: ^8.4.6
@ -297,6 +299,7 @@ importers:
react: ^17.0.2
react-dom: ^17.0.2
react-i18next: ^11.15.4
react-phone-number-input: ^3.1.46
react-router-dom: ^5.2.0
stylelint: ^13.13.1
ts-jest: ^27.0.5
@ -308,9 +311,11 @@ importers:
i18next: 21.6.11
i18next-browser-languagedetector: 6.1.3
ky: 0.29.0
libphonenumber-js: 1.9.49
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-i18next: 11.15.4_3fb644aa30122a07f960d67fa51d6dc1
react-phone-number-input: 3.1.46_react-dom@17.0.2+react@17.0.2
react-router-dom: 5.3.0_react@17.0.2
devDependencies:
'@jest/types': 27.5.1
@ -328,6 +333,7 @@ importers:
eslint: 8.10.0
identity-obj-proxy: 3.0.0
jest: 27.5.1
jest-transform-stub: 2.0.0
lint-staged: 11.2.6
parcel: 2.3.2_postcss@8.4.6
postcss: 8.4.6
@ -2216,27 +2222,6 @@ packages:
write-file-atomic: 3.0.3
dev: true
/@monaco-editor/loader/1.2.0_monaco-editor@0.32.1:
resolution: {integrity: sha512-cJVCG/T/KxXgzYnjKqyAgsKDbH9mGLjcXxN6AmwumBwa2rVFkwvGcUj1RJtD0ko4XqLqJxwqsN/Z/KURB5f1OQ==}
peerDependencies:
monaco-editor: '>= 0.21.0 < 1'
dependencies:
monaco-editor: 0.32.1
state-local: 1.0.7
dev: false
/@monaco-editor/react/4.3.1_e62f1489d5efe674a41c3f8d6971effe:
resolution: {integrity: sha512-f+0BK1PP/W5I50hHHmwf11+Ea92E5H1VZXs+wvKplWUWOfyMa1VVwqkJrXjRvbcqHL+XdIGYWhWNdi4McEvnZg==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
dependencies:
'@monaco-editor/loader': 1.2.0_monaco-editor@0.32.1
monaco-editor: 0.32.1
prop-types: 15.8.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
/@logto/browser/0.1.2:
resolution: {integrity: sha512-sTJjnx00BXYEChCbbO/LPs0x0wE1bDSHniFi+u93cynyEHgoT5yjMnH4N39NhrpmRdkXxOxaIkXmyAT1nSmYzQ==}
requiresBuild: true
@ -2271,6 +2256,29 @@ packages:
react: 17.0.2
dev: false
/@monaco-editor/loader/1.2.0_monaco-editor@0.32.1:
resolution: {integrity: sha512-cJVCG/T/KxXgzYnjKqyAgsKDbH9mGLjcXxN6AmwumBwa2rVFkwvGcUj1RJtD0ko4XqLqJxwqsN/Z/KURB5f1OQ==}
peerDependencies:
monaco-editor: '>= 0.21.0 < 1'
dependencies:
monaco-editor: 0.32.1
state-local: 1.0.7
dev: false
/@monaco-editor/react/4.3.1_e62f1489d5efe674a41c3f8d6971effe:
resolution: {integrity: sha512-f+0BK1PP/W5I50hHHmwf11+Ea92E5H1VZXs+wvKplWUWOfyMa1VVwqkJrXjRvbcqHL+XdIGYWhWNdi4McEvnZg==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
dependencies:
'@monaco-editor/loader': 1.2.0_monaco-editor@0.32.1
monaco-editor: 0.32.1
prop-types: 15.8.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
dev: false
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -5033,6 +5041,10 @@ packages:
yaml: 1.10.2
dev: true
/country-flag-icons/1.4.21:
resolution: {integrity: sha512-bA9jDr+T5li7EsKdDx0xVnO0bdMdoT8IA3BNbeT2XSWUygR1okhiZ2+eYiC1EKLrFZhI4aEHni2w03lUlOjogg==}
dev: false
/crack-json/1.3.0:
resolution: {integrity: sha512-JfZ9NPLsU9ejTYgZ7fM+5TIMfTwROTxpi2Twh597GxmiVDwIGZSjaor+zsQBKZ0mmCKOFb9EZZLVeKNf/5UaGg==}
engines: {node: '>=8.0'}
@ -7243,6 +7255,12 @@ packages:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
dev: false
/input-format/0.3.7:
resolution: {integrity: sha512-hgwiCjV7MnhFvX4Hwrvk7hB2a2rcB2CQb7Ex7GlK1ISbEXuLtflwBUnadFSA1rVNDPFh9yWBaJJ4/o1XkzhPIg==}
dependencies:
prop-types: 15.8.1
dev: false
/inquirer/7.3.3:
resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
engines: {node: '>=8.0.0'}
@ -8477,6 +8495,10 @@ packages:
- supports-color
dev: true
/jest-transform-stub/2.0.0:
resolution: {integrity: sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg==}
dev: true
/jest-util/27.4.2:
resolution: {integrity: sha512-YuxxpXU6nlMan9qyLuxHaMMOzXAl5aGZWCSzben5DhLHemYQxCc4YK+4L3ZrCutT8GPQ+ui9k5D8rUJoDioMnA==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@ -9016,6 +9038,10 @@ packages:
- supports-color
dev: true
/libphonenumber-js/1.9.49:
resolution: {integrity: sha512-/wEOIONcVboFky+lWlCaF7glm1FhBz11M5PHeCApA+xDdVfmhKjHktHS8KjyGxouV5CSXIr4f3GvLSpJa4qMSg==}
dev: false
/lilconfig/2.0.4:
resolution: {integrity: sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==}
engines: {node: '>=10'}
@ -11923,6 +11949,21 @@ packages:
warning: 4.0.3
dev: false
/react-phone-number-input/3.1.46_react-dom@17.0.2+react@17.0.2:
resolution: {integrity: sha512-afYl7BMy/0vMqWtzsZBmOgiPdqQAGyPO/Z3auorFs4K/zgFSBq3YoaASleodBkeRO/PygJ4ML8Wnb4Ce+3dlVQ==}
peerDependencies:
react: '>=0.16.8'
react-dom: '>=0.16.8'
dependencies:
classnames: 2.3.1
country-flag-icons: 1.4.21
input-format: 0.3.7
libphonenumber-js: 1.9.49
prop-types: 15.8.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
dev: false
/react-refresh/0.9.0:
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
engines: {node: '>=0.10.0'}