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:
parent
addb528652
commit
dbda05a598
16 changed files with 383 additions and 96 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -1,14 +1,16 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
|
||||
.inputField {
|
||||
position: relative;
|
||||
@include _.flex-row;
|
||||
border: _.border(var(--color-line-border));
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition-property: outline, border;
|
||||
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
|
||||
|
@ -18,13 +20,18 @@
|
|||
transition: width 0.3s ease-in;
|
||||
padding: 0 _.unit(4);
|
||||
flex: 1;
|
||||
background: var(--color-bg-body);
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,23 +46,6 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
&:focus-within {
|
||||
border: _.border(var(--color-brand-default));
|
||||
|
||||
input {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border: _.border(var(--color-danger-default));
|
||||
|
||||
input {
|
||||
caret-color: var(--color-danger-default);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSuffixFocusVisible:focus-within {
|
||||
input {
|
||||
padding-right: _.unit(10);
|
||||
|
@ -67,27 +57,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.inputField {
|
||||
input::placeholder {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.noLabel {
|
||||
.inputField {
|
||||
input::placeholder {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.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 {
|
||||
margin-top: _.unit(1);
|
||||
margin-left: _.unit(0.5);
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.inputField {
|
||||
outline: 3px solid transparent;
|
||||
|
||||
:global(body.desktop) {
|
||||
.container {
|
||||
.inputField {
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,42 +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<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
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.inputField,
|
||||
isSuffixFocusVisible && styles.isSuffixFocusVisible,
|
||||
inputFieldClassName
|
||||
)}
|
||||
>
|
||||
{prefix}
|
||||
<input {...props} ref={reference} />
|
||||
<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}>
|
||||
{errorMessages.length > 1 ? (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(' / '),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
@ -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') })}
|
||||
|
|
|
@ -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') })}
|
||||
|
|
Loading…
Add table
Reference in a new issue