From bf57729c21a46849bf40708e0ab753276d3f67cd Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Sat, 6 Jul 2024 10:04:40 +0800 Subject: [PATCH 1/2] refactor(experience): add label for input field --- .../NotchedBorder/index.module.scss | 156 ++++++++++++++++++ .../InputField/NotchedBorder/index.tsx | 54 ++++++ .../InputFields/InputField/index.module.scss | 139 +++++++++------- .../InputFields/InputField/index.tsx | 110 ++++++++++-- .../CountryCodeDropdown/index.module.scss | 4 +- .../CountryCodeDropdown/index.tsx | 1 + .../CountryCodeSelector/index.module.scss | 8 + .../CountryCodeSelector/index.tsx | 2 +- .../InputFields/SmartInputField/index.tsx | 3 +- .../InputFields/SmartInputField/utils.test.ts | 10 +- .../InputFields/SmartInputField/utils.ts | 10 +- .../src/containers/SetPassword/Lite.tsx | 2 +- .../containers/SetPassword/SetPassword.tsx | 4 +- .../BackupCodeVerification/index.tsx | 2 +- .../pages/SignIn/PasswordSignInForm/index.tsx | 2 +- .../SignInPassword/PasswordForm/index.tsx | 2 +- 16 files changed, 413 insertions(+), 96 deletions(-) create mode 100644 packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss create mode 100644 packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx diff --git a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss new file mode 100644 index 00000000000..1a1673927e7 --- /dev/null +++ b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss @@ -0,0 +1,156 @@ +@use '@/scss/underscore' as _; + +.container { + position: absolute; + inset: 0; + pointer-events: none; + + .border, + .outline { + text-align: left; + position: absolute; + inset: -8px 0 0; + border: _.border(var(--color-line-border)); + border-radius: var(--radius); + pointer-events: none; + display: block; + + legend { + display: inline-block; + visibility: hidden; + padding: 0; + // fix to the label font height to keep space for the input container + height: 20px; + // Set to 0 to avoid the empty space in the top border + width: 0; + + span { + padding: 0 _.unit(1); + opacity: 0%; + visibility: hidden; + display: inline-block; + font: var(--font-body-3); + } + } + } + + .outline { + display: none; + inset: -9px -2px -2px; + border-radius: 10px; + + legend { + // Adjust to let the top border notched to fit the floating label + margin-left: -0.5px; + } + } + + .label { + position: absolute; + left: _.unit(4); + font: var(--font-body-1); + color: var(--color-type-secondary); + pointer-events: none; + top: 50%; + transform: translateY(-50%); + transition: 0.2s ease-out; + transition-property: position, font, top; + background-color: transparent; + z-index: 1; + } + + &.active { + .border { + border: _.border(var(--color-brand-default)); + + legend { + visibility: visible; + width: auto; + } + } + + .label { + top: 0; + left: _.unit(3); + font: var(--font-body-3); + color: var(--color-brand-default); + padding: 0 _.unit(1); + } + } + + + &.danger { + .border { + border: _.border(var(--color-danger-default)); + } + + .label { + color: var(--color-danger-default); + } + } + + &.danger:not(.active) { + .border legend { + width: 0; + } + } + + &.active.noLabel { + .border legend { + width: 0; + } + } +} + +:global(body.desktop) { + .container { + .label { + font: var(--font-body-2); + } + + &.active { + .label { + font: var(--font-body-3); + } + } + + &.focused { + .outline { + display: block; + border: 3px outset var(--color-overlay-brand-focused); + + legend { + visibility: visible; + width: auto; + } + } + + + &.danger { + .outline { + border-color: var(--color-overlay-danger-focused); + } + } + + &.danger:not(.active) { + .border legend { + width: 0; + } + + .outline { + display: none; + + legend { + width: 0; + } + } + } + + &.active.noLabel { + .outline legend { + width: 0; + } + } + } + } +} diff --git a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx new file mode 100644 index 00000000000..c7d666c11eb --- /dev/null +++ b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; + +import * as styles from './index.module.scss'; + +type Props = { + readonly label: string; + readonly isActive: boolean; + readonly isDanger: boolean; + readonly isFocused: boolean; +}; + +/** + * NotchedBorder Component + * + * This component creates a customizable border with a notch for labels in form inputs. + * It enhances the visual appearance and accessibility of input fields by providing + * a floating label effect and customizable border/outline styles. + * + * Key implementation details: + * 1. Uses two fieldset elements: + * - The first creates the main border effect. + * - The second creates a separate outline effect, avoiding gaps between the built-in outline and border. + * 2. Utilizes the fieldset's legend for the label, allowing it to overlap the border: + * - This creates a "floating" label effect. + * - The legend's background becomes transparent, preventing it from blocking the background. + * + */ +const NotchedBorder = ({ label, isActive, isDanger, isFocused }: Props) => { + return ( +
+
+ + {label} + +
+
+ + {label} + +
+ {Boolean(label) && } +
+ ); +}; + +export default NotchedBorder; diff --git a/packages/experience/src/components/InputFields/InputField/index.module.scss b/packages/experience/src/components/InputFields/InputField/index.module.scss index c73bb074ef1..3ccf92a02ae 100644 --- a/packages/experience/src/components/InputFields/InputField/index.module.scss +++ b/packages/experience/src/components/InputFields/InputField/index.module.scss @@ -1,70 +1,91 @@ @use '@/scss/underscore' as _; -.inputField { +.container { position: relative; - @include _.flex-row; - border: _.border(var(--color-line-border)); - border-radius: var(--radius); - overflow: hidden; - transition-property: outline, border; - transition-timing-function: ease-in-out; - transition-duration: 0.2s; - align-items: stretch; - // fix in safari input field line-height issue - height: 44px; - - input { - transition: width 0.3s ease-in; - padding: 0 _.unit(4); - flex: 1; - background: var(--color-bg-body); - caret-color: var(--color-brand-default); - font: var(--font-body-1); - color: var(--color-type-primary); + .inputField { + position: relative; + @include _.flex-row; + overflow: hidden; + transition-property: border; + transition-timing-function: ease-in-out; + transition-duration: 0.2s; + background: inherit; + align-items: stretch; + + // fix in safari input field line-height issue + height: 44px; - &::placeholder { - color: var(--color-type-secondary); + input { + transition: width 0.3s ease-in; + padding: 0 _.unit(4); + flex: 1; + background: inherit; + caret-color: var(--color-brand-default); + font: var(--font-body-1); + color: var(--color-type-primary); + outline: none; + border-radius: var(--radius); + margin: -1px 1px 0; + + &::placeholder { + color: var(--color-type-secondary); + transition: opacity 0.2s ease-out; + opacity: 0%; + } } - } - - .suffix { - position: absolute; - right: _.unit(2); - top: 50%; - transform: translateY(-50%); - width: _.unit(8); - height: _.unit(8); - display: none; - z-index: 1; - } + .suffix { + position: absolute; + right: _.unit(2); + top: 50%; + transform: translateY(-50%); + width: _.unit(8); + height: _.unit(8); + display: none; + z-index: 1; + } - &:focus-within { - border: _.border(var(--color-brand-default)); + &.isSuffixFocusVisible:focus-within { + input { + padding-right: _.unit(10); + } - input { - outline: none; + .suffix { + display: flex; + } } } - &.danger { - border: _.border(var(--color-danger-default)); - - input { - caret-color: var(--color-danger-default); + &.active { + .inputField { + input::placeholder { + opacity: 100%; + } } } - &.isSuffixFocusVisible:focus-within { - input { - padding-right: _.unit(10); + &.noLabel { + .inputField { + input::placeholder { + opacity: 100%; + } } + } - .suffix { - display: flex; + &.danger { + .inputField { + /* stylelint-disable-next-line no-descending-specificity */ + input { + caret-color: var(--color-danger-default); + } } } + + // override for firefox & safari focus outline since we are using custom notchedOutline + &:focus-visible { + outline: none; + } } .errorMessage { @@ -72,22 +93,14 @@ margin-left: _.unit(0.5); } -:global(body.desktop) { - .inputField { - outline: 3px solid transparent; - - /* stylelint-disable-next-line no-descending-specificity */ - input { - font: var(--font-body-2); - background: var(--color-bg-float); - } - &:focus-within { - outline-color: var(--color-overlay-brand-focused); - } - - &.danger:focus-within { - outline-color: var(--color-overlay-danger-focused); +:global(body.desktop) { + .container { + .inputField { + /* stylelint-disable-next-line no-descending-specificity */ + input { + font: var(--font-body-2); + } } } } diff --git a/packages/experience/src/components/InputFields/InputField/index.tsx b/packages/experience/src/components/InputFields/InputField/index.tsx index d871b77560e..ef00720bf34 100644 --- a/packages/experience/src/components/InputFields/InputField/index.tsx +++ b/packages/experience/src/components/InputFields/InputField/index.tsx @@ -1,41 +1,127 @@ +import { type Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; -import type { ForwardedRef, HTMLProps, ReactElement } from 'react'; -import { forwardRef, cloneElement } from 'react'; +import type { HTMLProps, ReactElement, Ref } from 'react'; +import { forwardRef, cloneElement, useState, useImperativeHandle, useRef, useEffect } from 'react'; import ErrorMessage from '@/components/ErrorMessage'; +import NotchedBorder from './NotchedBorder'; import * as styles from './index.module.scss'; export type Props = Omit, 'prefix'> & { readonly className?: string; + readonly inputFieldClassName?: string; readonly errorMessage?: string; readonly isDanger?: boolean; readonly prefix?: ReactElement; + readonly isPrefixVisible?: boolean; readonly suffix?: ReactElement; readonly isSuffixFocusVisible?: boolean; + readonly label?: string; }; const InputField = ( - { className, errorMessage, isDanger, prefix, suffix, isSuffixFocusVisible, ...props }: Props, - reference: ForwardedRef + { + className, + inputFieldClassName, + errorMessage, + isDanger, + prefix, + suffix, + isPrefixVisible, + isSuffixFocusVisible, + label, + onFocus, + onBlur, + onChange, + value, + ...props + }: Props, + reference: Ref> ) => { + const innerRef = useRef(null); + + useImperativeHandle(reference, () => innerRef.current); + const errorMessages = errorMessage?.split('\n'); + const [isFocused, setIsFocused] = useState(false); + const [hasValue, setHasValue] = useState(false); + const hasContent = + Boolean(isPrefixVisible) || + hasValue || + // Handle the case when this filed have a default value + Boolean(value); + + const isActive = hasContent || isFocused; + + useEffect(() => { + const inputDom = innerRef.current; + if (!inputDom) { + return; + } + + /** + * Use a timeout to check if the input field has autofill value. + * Fix the issue that the input field doesn't have the active style when the autofill value is set. + * see https://github.com/facebook/react/issues/1159#issuecomment-1025423604 + */ + const checkAutoFillTimeout = setTimeout(() => { + setHasValue(inputDom.matches('*:-webkit-autofill')); + }, 200); + + return () => { + clearTimeout(checkAutoFillTimeout); + }; + }, [innerRef]); + return (
- {prefix} - - {suffix && - cloneElement(suffix, { - className: classNames([suffix.props.className, styles.suffix]), - })} +
+ {prefix} + { + setIsFocused(true); + return onFocus?.(event); + }} + onBlur={(event) => { + setIsFocused(false); + return onBlur?.(event); + }} + onChange={(event) => { + setHasValue(Boolean(event.target.value)); + return onChange?.(event); + }} + /> + {suffix && + cloneElement(suffix, { + className: classNames([suffix.props.className, styles.suffix]), + })} +
+
{errorMessages && ( diff --git a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.module.scss b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.module.scss index fa258ab8d4b..5bc8098d761 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.module.scss +++ b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.module.scss @@ -77,7 +77,7 @@ border-radius: _.unit(2); } - .searchInputField > div { + .innerInputFiled { padding: _.unit(1.5) _.unit(3); height: auto; } @@ -113,7 +113,7 @@ margin-top: _.unit(2); } - > div { + .innerInputFiled { padding-left: _.unit(4); } } diff --git a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.tsx b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.tsx index 2fc4f6842e4..73944705b96 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.tsx +++ b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.tsx @@ -228,6 +228,7 @@ const CountryCodeDropdown = ({ prefix={} value={searchValue} className={styles.searchInputField} + inputFieldClassName={styles.innerInputFiled} placeholder={t('input.search_region_code')} onChange={onSearchChange} onKeyDown={onInputKeyDown} diff --git a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.module.scss b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.module.scss index 878d6b9df88..1cac34a3dcd 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.module.scss +++ b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.module.scss @@ -14,6 +14,14 @@ @include _.flex-row; overflow: hidden; white-space: nowrap; + opacity: 0%; + // Disable pointer events to avoid the focus on the country code selector when clicking on the input + pointer-events: none; + + &.visible { + opacity: 100%; + pointer-events: auto; + } &:focus-visible { border: _.border(var(--color-brand-default)); diff --git a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.tsx b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.tsx index 721b6352992..1a039f8337c 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.tsx +++ b/packages/experience/src/components/InputFields/SmartInputField/CountryCodeSelector/index.tsx @@ -40,7 +40,7 @@ const CountryCodeSelector = ( return (
z-index to override country selector + style={{ paddingLeft }} value={inputValue} + isPrefixVisible={isPrefixVisible} prefix={ { expect(props.type).toBe('tel'); expect(props.pattern).toBe('[0-9]*'); expect(props.inputMode).toBe('numeric'); - expect(props.placeholder).toBe('input.phone_number'); + expect(props.label).toBe('input.phone_number'); expect(props.autoComplete).toBe('tel'); }); @@ -23,21 +23,21 @@ describe('Smart Input Field Util Methods', () => { const props = getInputHtmlProps([SignInIdentifier.Email], SignInIdentifier.Email); expect(props.type).toBe('email'); expect(props.inputMode).toBe('email'); - expect(props.placeholder).toBe('input.email'); + expect(props.label).toBe('input.email'); expect(props.autoComplete).toBe('email'); }); it('Should return correct html props for username', () => { const props = getInputHtmlProps([SignInIdentifier.Username], SignInIdentifier.Username); expect(props.type).toBe('text'); - expect(props.placeholder).toBe('input.username'); + expect(props.label).toBe('input.username'); expect(props.autoComplete).toBe('username'); }); it('Should return correct html props for username email or phone', () => { const props = getInputHtmlProps(enabledTypes, SignInIdentifier.Username); expect(props.type).toBe('text'); - expect(props.placeholder).toBe('input.username / input.email / input.phone_number'); + expect(props.label).toBe('input.username / input.email / input.phone_number'); expect(props.autoComplete).toBe('username email tel'); }); @@ -47,7 +47,7 @@ describe('Smart Input Field Util Methods', () => { SignInIdentifier.Email ); expect(props.type).toBe('text'); - expect(props.placeholder).toBe('input.email / input.phone_number'); + expect(props.label).toBe('input.email / input.phone_number'); expect(props.autoComplete).toBe('email tel'); }); }); diff --git a/packages/experience/src/components/InputFields/SmartInputField/utils.ts b/packages/experience/src/components/InputFields/SmartInputField/utils.ts index 6c2c254a51f..4190a29b17b 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/utils.ts +++ b/packages/experience/src/components/InputFields/SmartInputField/utils.ts @@ -11,7 +11,7 @@ export const getInputHtmlProps = ( currentType?: IdentifierInputType ): Pick< HTMLProps, - 'type' | 'pattern' | 'inputMode' | 'placeholder' | 'autoComplete' + 'type' | 'pattern' | 'inputMode' | 'placeholder' | 'autoComplete' | 'label' > => { if (currentType === SignInIdentifier.Phone && enabledTypes.length === 1) { return { @@ -19,7 +19,7 @@ export const getInputHtmlProps = ( pattern: '[0-9]*', inputMode: 'numeric', autoComplete: 'tel', - placeholder: i18next.t('input.phone_number'), + label: i18next.t('input.phone_number'), }; } @@ -28,7 +28,7 @@ export const getInputHtmlProps = ( type: 'email', inputMode: 'email', autoComplete: 'email', - placeholder: i18next.t('input.email'), + label: i18next.t('input.email'), }; } @@ -37,8 +37,6 @@ export const getInputHtmlProps = ( autoComplete: enabledTypes .map((type) => (type === SignInIdentifier.Phone ? 'tel' : type)) .join(' '), - placeholder: enabledTypes - .map((type) => i18next.t(identifierInputPlaceholderMap[type])) - .join(' / '), + label: enabledTypes.map((type) => i18next.t(identifierInputPlaceholderMap[type])).join(' / '), }; }; diff --git a/packages/experience/src/containers/SetPassword/Lite.tsx b/packages/experience/src/containers/SetPassword/Lite.tsx index ec8d65f0d48..aa1abd3b8a0 100644 --- a/packages/experience/src/containers/SetPassword/Lite.tsx +++ b/packages/experience/src/containers/SetPassword/Lite.tsx @@ -58,7 +58,7 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage {
diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx index 53f599d5e16..3ce300d5e5f 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx @@ -138,7 +138,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { Date: Tue, 9 Jul 2024 15:12:04 +0800 Subject: [PATCH 2/2] refactor(experience): reorg notched border styles --- .../NotchedBorder/index.module.scss | 87 +++++++------------ .../InputField/NotchedBorder/index.tsx | 2 +- .../InputFields/InputField/index.tsx | 1 - 3 files changed, 30 insertions(+), 60 deletions(-) diff --git a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss index 1a1673927e7..9379270c02b 100644 --- a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss +++ b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss @@ -38,11 +38,7 @@ display: none; inset: -9px -2px -2px; border-radius: 10px; - - legend { - // Adjust to let the top border notched to fit the floating label - margin-left: -0.5px; - } + border: 3px outset var(--color-overlay-brand-focused); } .label { @@ -52,7 +48,7 @@ color: var(--color-type-secondary); pointer-events: none; top: 50%; - transform: translateY(-50%); + transform: translateY(-46%); transition: 0.2s ease-out; transition-property: position, font, top; background-color: transparent; @@ -60,9 +56,8 @@ } &.active { - .border { - border: _.border(var(--color-brand-default)); - + .border, + .outline { legend { visibility: visible; width: auto; @@ -73,30 +68,43 @@ top: 0; left: _.unit(3); font: var(--font-body-3); - color: var(--color-brand-default); + color: var(--color-type-secondary); padding: 0 _.unit(1); } } - - &.danger { + &.focused { .border { - border: _.border(var(--color-danger-default)); + border-color: var(--color-brand-default); } .label { - color: var(--color-danger-default); + color: var(--color-brand-default); } } +} - &.danger:not(.active) { - .border legend { - width: 0; + +.container.danger { + .border { + border-color: var(--color-danger-default); + } + + .outline { + border-color: var(--color-overlay-danger-focused); + } + + &.active { + .label { + color: var(--color-danger-default); } } +} - &.active.noLabel { - .border legend { +.container.noLabel { + .border, + .outline { + legend { width: 0; } } @@ -104,52 +112,15 @@ :global(body.desktop) { .container { - .label { - font: var(--font-body-2); - } - - &.active { + &:not(.active) { .label { - font: var(--font-body-3); + font: var(--font-body-2); } } &.focused { .outline { display: block; - border: 3px outset var(--color-overlay-brand-focused); - - legend { - visibility: visible; - width: auto; - } - } - - - &.danger { - .outline { - border-color: var(--color-overlay-danger-focused); - } - } - - &.danger:not(.active) { - .border legend { - width: 0; - } - - .outline { - display: none; - - legend { - width: 0; - } - } - } - - &.active.noLabel { - .outline legend { - width: 0; - } } } } diff --git a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx index c7d666c11eb..c4137b3093b 100644 --- a/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx +++ b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.tsx @@ -30,8 +30,8 @@ const NotchedBorder = ({ label, isActive, isDanger, isFocused }: Props) => {