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 000000000..9379270c0
--- /dev/null
+++ b/packages/experience/src/components/InputFields/InputField/NotchedBorder/index.module.scss
@@ -0,0 +1,127 @@
+@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;
+ border: 3px outset var(--color-overlay-brand-focused);
+ }
+
+ .label {
+ position: absolute;
+ left: _.unit(4);
+ font: var(--font-body-1);
+ color: var(--color-type-secondary);
+ pointer-events: none;
+ top: 50%;
+ transform: translateY(-46%);
+ transition: 0.2s ease-out;
+ transition-property: position, font, top;
+ background-color: transparent;
+ z-index: 1;
+ }
+
+ &.active {
+ .border,
+ .outline {
+ legend {
+ visibility: visible;
+ width: auto;
+ }
+ }
+
+ .label {
+ top: 0;
+ left: _.unit(3);
+ font: var(--font-body-3);
+ color: var(--color-type-secondary);
+ padding: 0 _.unit(1);
+ }
+ }
+
+ &.focused {
+ .border {
+ border-color: var(--color-brand-default);
+ }
+
+ .label {
+ color: var(--color-brand-default);
+ }
+ }
+}
+
+
+.container.danger {
+ .border {
+ border-color: var(--color-danger-default);
+ }
+
+ .outline {
+ border-color: var(--color-overlay-danger-focused);
+ }
+
+ &.active {
+ .label {
+ color: var(--color-danger-default);
+ }
+ }
+}
+
+.container.noLabel {
+ .border,
+ .outline {
+ legend {
+ width: 0;
+ }
+ }
+}
+
+:global(body.desktop) {
+ .container {
+ &:not(.active) {
+ .label {
+ font: var(--font-body-2);
+ }
+ }
+
+ &.focused {
+ .outline {
+ display: block;
+ }
+ }
+ }
+}
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 000000000..c4137b309
--- /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 (
+
+
+
+ {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 c73bb074e..3ccf92a02 100644
--- a/packages/experience/src/components/InputFields/InputField/index.module.scss
+++ b/packages/experience/src/components/InputFields/InputField/index.module.scss
@@ -1,69 +1,90 @@
@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;
+ .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;
- 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);
+ // 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;
+ }
+
+ &.isSuffixFocusVisible:focus-within {
+ input {
+ padding-right: _.unit(10);
+ }
+
+ .suffix {
+ display: flex;
+ }
}
}
- .suffix {
- position: absolute;
- right: _.unit(2);
- top: 50%;
- transform: translateY(-50%);
- width: _.unit(8);
- height: _.unit(8);
- display: none;
- z-index: 1;
+ &.active {
+ .inputField {
+ input::placeholder {
+ opacity: 100%;
+ }
+ }
}
-
- &:focus-within {
- border: _.border(var(--color-brand-default));
-
- input {
- outline: none;
+ &.noLabel {
+ .inputField {
+ input::placeholder {
+ opacity: 100%;
+ }
}
}
&.danger {
- border: _.border(var(--color-danger-default));
-
- input {
- caret-color: var(--color-danger-default);
+ .inputField {
+ /* stylelint-disable-next-line no-descending-specificity */
+ input {
+ caret-color: var(--color-danger-default);
+ }
}
}
- &.isSuffixFocusVisible:focus-within {
- input {
- padding-right: _.unit(10);
- }
-
- .suffix {
- display: flex;
- }
+ // override for firefox & safari focus outline since we are using custom notchedOutline
+ &:focus-visible {
+ outline: none;
}
}
@@ -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);
+ .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 d871b7756..bdf5faf3c 100644
--- a/packages/experience/src/components/InputFields/InputField/index.tsx
+++ b/packages/experience/src/components/InputFields/InputField/index.tsx
@@ -1,41 +1,126 @@
+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 (
{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 fa258ab8d..5bc8098d7 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 2fc4f6842..73944705b 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 878d6b9df..1cac34a3d 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 721b63529..1a039f833 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 6c2c254a5..4190a29b1 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 ec8d65f0d..aa1abd3b8 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
{