0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

refactor(experience): add label for input field (#6200)

This commit is contained in:
Xiao Yijun 2024-07-09 15:46:57 +08:00 committed by GitHub
parent addb528652
commit dbda05a598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 383 additions and 96 deletions

View file

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

View file

@ -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 (
<div
className={classNames(
styles.container,
isDanger && styles.danger,
isActive && styles.active,
isFocused && styles.focused,
!label && styles.noLabel
)}
>
<fieldset className={styles.border}>
<legend>
<span>{label}</span>
</legend>
</fieldset>
<fieldset className={styles.outline}>
<legend>
<span>{label}</span>
</legend>
</fieldset>
{Boolean(label) && <label className={styles.label}>{label}</label>}
</div>
);
};
export default NotchedBorder;

View file

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

View file

@ -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<HTMLProps<HTMLInputElement>, '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<HTMLInputElement>
{
className,
inputFieldClassName,
errorMessage,
isDanger,
prefix,
suffix,
isPrefixVisible,
isSuffixFocusVisible,
label,
onFocus,
onBlur,
onChange,
value,
...props
}: Props,
reference: Ref<Nullable<HTMLInputElement>>
) => {
const innerRef = useRef<HTMLInputElement>(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 (
<div className={className}>
<div
className={classNames(
styles.inputField,
styles.container,
isDanger && styles.danger,
isSuffixFocusVisible && styles.isSuffixFocusVisible
isActive && styles.active,
!label && styles.noLabel
)}
>
{prefix}
<input {...props} ref={reference} />
{suffix &&
cloneElement(suffix, {
className: classNames([suffix.props.className, styles.suffix]),
})}
<div
className={classNames(
styles.inputField,
isSuffixFocusVisible && styles.isSuffixFocusVisible,
inputFieldClassName
)}
>
{prefix}
<input
{...props}
ref={innerRef}
value={value}
onFocus={(event) => {
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]),
})}
</div>
<NotchedBorder
label={label ?? ''}
isActive={isActive}
isDanger={Boolean(isDanger)}
isFocused={isFocused}
/>
</div>
{errorMessages && (
<ErrorMessage className={styles.errorMessage}>

View file

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

View file

@ -228,6 +228,7 @@ const CountryCodeDropdown = ({
prefix={<SearchIcon />}
value={searchValue}
className={styles.searchInputField}
inputFieldClassName={styles.innerInputFiled}
placeholder={t('input.search_region_code')}
onChange={onSearchChange}
onKeyDown={onInputKeyDown}

View file

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

View file

@ -40,7 +40,7 @@ const CountryCodeSelector = (
return (
<div
ref={ref}
className={classNames(styles.countryCodeSelector, className)}
className={classNames(styles.countryCodeSelector, isVisible && styles.visible, className)}
role="button"
tabIndex={isVisible ? 0 : -1}
onClick={showDropDown}

View file

@ -70,8 +70,9 @@ const SmartInputField = (
{...rest}
ref={innerRef}
isSuffixFocusVisible={Boolean(inputValue)}
style={{ zIndex: 1, paddingLeft }} // Give <input /> z-index to override country selector
style={{ paddingLeft }}
value={inputValue}
isPrefixVisible={isPrefixVisible}
prefix={
<AnimatedPrefix isVisible={isPrefixVisible}>
<CountryCodeSelector

View file

@ -15,7 +15,7 @@ describe('Smart Input Field Util Methods', () => {
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');
});
});

View file

@ -11,7 +11,7 @@ export const getInputHtmlProps = (
currentType?: IdentifierInputType
): Pick<
HTMLProps<HTMLInputElement>,
'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(' / '),
};
};

View file

@ -58,7 +58,7 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
<PasswordInputField
className={styles.inputField}
autoComplete="new-password"
placeholder={t('input.password')}
label={t('input.password')}
autoFocus={autoFocus}
isDanger={!!errors.newPassword}
errorMessage={errors.newPassword?.message}

View file

@ -73,7 +73,7 @@ const SetPassword = ({
className={styles.inputField}
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.password')}
label={t('input.password')}
autoFocus={autoFocus}
isDanger={!!errors.newPassword}
errorMessage={errors.newPassword?.message}
@ -97,7 +97,7 @@ const SetPassword = ({
className={styles.inputField}
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.confirm_password')}
label={t('input.confirm_password')}
errorMessage={errors.confirmPassword?.message}
aria-invalid={!!errors.confirmPassword}
{...register('confirmPassword', {

View file

@ -49,7 +49,7 @@ const BackupCodeVerification = () => {
<form onSubmit={onSubmitHandler}>
<InputField
autoComplete="off"
placeholder={t('input.backup_code')}
label={t('input.backup_code')}
className={styles.backupCodeInput}
{...register('code')}
/>

View file

@ -138,7 +138,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
<PasswordInputField
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
label={t('input.password')}
isDanger={!!errors.password}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}

View file

@ -102,7 +102,7 @@ const PasswordForm = ({
autoFocus={autoFocus}
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
label={t('input.password')}
isDanger={!!errors.password}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}