Checkout: Add password strength meter to new field (#49164)
* Add validation message and fix rendering when account is required * Adjust validation so we can change the label in messages with custom callback * Add and style password meter * Add validation feedback and zxcvbn support * changelog * Accessibility fixes * Update lock file * Fix initial state in safari * Announce strength as you type * Use React.ReactElement * update lock file * Update lock * Downgrade local pnpm and recreate lock * Feedback should only be shown when `showError` is true, not `hasError` * Feedback should default to null
This commit is contained in:
parent
320873a4dc
commit
77428b973d
|
@ -11,18 +11,21 @@ import { ContactFormValues, getSetting } from '@woocommerce/settings';
|
|||
import {
|
||||
StoreNoticesContainer,
|
||||
CheckboxControl,
|
||||
ValidatedTextInput,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { CONTACT_FORM_KEYS } from '@woocommerce/block-settings';
|
||||
import { Form } from '@woocommerce/base-components/cart-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CreatePassword from './create-password';
|
||||
|
||||
const CreateAccountUI = (): React.ReactElement | null => {
|
||||
const { customerPassword, shouldCreateAccount } = useSelect( ( select ) => {
|
||||
const { shouldCreateAccount } = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
customerPassword: store.getCustomerPassword(),
|
||||
shouldCreateAccount: store.getShouldCreateAccount(),
|
||||
};
|
||||
} );
|
||||
|
@ -72,32 +75,7 @@ const CreateAccountUI = (): React.ReactElement | null => {
|
|||
} }
|
||||
/>
|
||||
) }
|
||||
{ showCreateAccountPassword && (
|
||||
<ValidatedTextInput
|
||||
type="password"
|
||||
label={ __( 'Create a password', 'woocommerce' ) }
|
||||
className={ `wc-block-components-address-form__password` }
|
||||
value={ customerPassword }
|
||||
required={ true }
|
||||
customValidityMessage={ (
|
||||
validity: ValidityState
|
||||
): string | undefined => {
|
||||
if (
|
||||
validity.valueMissing ||
|
||||
validity.badInput ||
|
||||
validity.typeMismatch
|
||||
) {
|
||||
return __(
|
||||
'Please enter a valid password',
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
} }
|
||||
onChange={ ( value: string ) =>
|
||||
__internalSetCustomerPassword( value )
|
||||
}
|
||||
/>
|
||||
) }
|
||||
{ showCreateAccountPassword && <CreatePassword /> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { ValidatedTextInput } from '@woocommerce/blocks-components';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PasswordStrengthMeter from '../../password-strength-meter';
|
||||
|
||||
const CreatePassword = () => {
|
||||
const [ passwordStrength, setPasswordStrength ] = useState( 0 );
|
||||
const { customerPassword } = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
customerPassword: store.getCustomerPassword(),
|
||||
};
|
||||
} );
|
||||
const { __internalSetCustomerPassword } = useDispatch( CHECKOUT_STORE_KEY );
|
||||
|
||||
return (
|
||||
<ValidatedTextInput
|
||||
type="password"
|
||||
label={ __( 'Create a password', 'woocommerce' ) }
|
||||
className={ `wc-block-components-address-form__password` }
|
||||
value={ customerPassword }
|
||||
required={ true }
|
||||
errorId={ 'account-password' }
|
||||
customValidityMessage={ (
|
||||
validity: ValidityState
|
||||
): string | undefined => {
|
||||
if (
|
||||
validity.valueMissing ||
|
||||
validity.badInput ||
|
||||
validity.typeMismatch
|
||||
) {
|
||||
return __( 'Please enter a valid password', 'woocommerce' );
|
||||
}
|
||||
} }
|
||||
customValidation={ ( inputObject ) => {
|
||||
if ( passwordStrength < 2 ) {
|
||||
inputObject.setCustomValidity(
|
||||
__( 'Please create a stronger password', 'woocommerce' )
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} }
|
||||
onChange={ ( value: string ) =>
|
||||
__internalSetCustomerPassword( value )
|
||||
}
|
||||
feedback={
|
||||
<PasswordStrengthMeter
|
||||
password={ customerPassword }
|
||||
onChange={ ( strength: number ) =>
|
||||
setPasswordStrength( strength )
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePassword;
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { sprintf, __ } from '@wordpress/i18n';
|
||||
import { useInstanceId } from '@wordpress/compose';
|
||||
import { passwordStrength } from 'check-password-strength';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
zxcvbn: ( password: string ) => {
|
||||
score: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const scoreDescriptions = [
|
||||
__( 'Too weak', 'woocommerce' ),
|
||||
__( 'Weak', 'woocommerce' ),
|
||||
__( 'Medium', 'woocommerce' ),
|
||||
__( 'Strong', 'woocommerce' ),
|
||||
__( 'Very strong', 'woocommerce' ),
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a password strength meter.
|
||||
*
|
||||
* Uses zxcvbn to calculate the password strength if available, otherwise falls back to check-password-strength which
|
||||
* does not include dictionaries of common passwords.
|
||||
*/
|
||||
const PasswordStrengthMeter = ( {
|
||||
password = '',
|
||||
onChange,
|
||||
}: {
|
||||
password: string;
|
||||
onChange?: ( strength: number ) => void;
|
||||
} ): React.ReactElement | null => {
|
||||
const instanceId = useInstanceId(
|
||||
PasswordStrengthMeter,
|
||||
'woocommerce-password-strength-meter'
|
||||
) as string;
|
||||
|
||||
let strength = -1;
|
||||
|
||||
if ( password.length > 0 ) {
|
||||
if ( typeof window.zxcvbn === 'undefined' ) {
|
||||
const result = passwordStrength( password );
|
||||
strength = result.id;
|
||||
} else {
|
||||
const result = window.zxcvbn( password );
|
||||
strength = result.score;
|
||||
}
|
||||
}
|
||||
|
||||
const previousStrength = usePrevious( strength );
|
||||
|
||||
useEffect( () => {
|
||||
if ( strength !== previousStrength && onChange ) {
|
||||
onChange( strength );
|
||||
}
|
||||
}, [ strength, previousStrength, onChange ] );
|
||||
|
||||
return (
|
||||
<div
|
||||
id={ instanceId }
|
||||
className={ clsx( 'wc-block-components-password-strength', {
|
||||
hidden: strength === -1,
|
||||
} ) }
|
||||
>
|
||||
<label
|
||||
htmlFor={ instanceId + '-meter' }
|
||||
className="screen-reader-text"
|
||||
>
|
||||
{ __( 'Password strength', 'woocommerce' ) }
|
||||
</label>
|
||||
<meter
|
||||
id={ instanceId + '-meter' }
|
||||
className="wc-block-components-password-strength__meter"
|
||||
min={ 0 }
|
||||
max={ 4 }
|
||||
value={ strength > -1 ? strength : 0 }
|
||||
>
|
||||
{ scoreDescriptions[ strength ] ?? '' }
|
||||
</meter>
|
||||
<div
|
||||
id={ instanceId + '-result' }
|
||||
className="wc-block-components-password-strength__result"
|
||||
>
|
||||
{ !! scoreDescriptions[ strength ] && (
|
||||
<>
|
||||
<span className="screen-reader-text" aria-live="polite">
|
||||
{ sprintf(
|
||||
/* translators: %s: Password strength */
|
||||
__(
|
||||
'Password strength: %1$s (%2$d characters long)',
|
||||
'woocommerce'
|
||||
),
|
||||
scoreDescriptions[ strength ],
|
||||
password.length
|
||||
) }
|
||||
</span>{ ' ' }
|
||||
<span aria-hidden={ true }>
|
||||
{ scoreDescriptions[ strength ] }
|
||||
</span>
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordStrengthMeter;
|
|
@ -0,0 +1,73 @@
|
|||
.wc-block-components-password-strength {
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
.wc-block-components-password-strength__meter {
|
||||
margin: $gap-small 0 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
background-color: $universal-border-light;
|
||||
color: $alert-red;
|
||||
|
||||
&::-webkit-meter-bar,
|
||||
&::-webkit-meter-inner-element {
|
||||
background: none;
|
||||
border: 0;
|
||||
height: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
&::-webkit-meter-optimum-value,
|
||||
&::-webkit-meter-even-less-good-value,
|
||||
&::-webkit-meter-suboptimum-value {
|
||||
background: none;
|
||||
background-color: currentColor;
|
||||
transition: 0.2s ease;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
height: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&:-moz-meter-optimum::-moz-meter-bar,
|
||||
&:-moz-meter-sub-optimum::-moz-meter-bar,
|
||||
&:-moz-meter-sub-sub-optimum::-moz-meter-bar {
|
||||
background: none;
|
||||
background-color: currentColor;
|
||||
transition: 0.2s ease;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
height: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.wc-block-components-password-strength__result {
|
||||
@include font-size(smaller);
|
||||
display: block;
|
||||
text-align: right;
|
||||
margin: $gap-smallest 0 0;
|
||||
color: $alert-red;
|
||||
|
||||
&::after {
|
||||
content: "\00a0 ";
|
||||
}
|
||||
}
|
||||
.wc-block-components-password-strength__meter[value="1"],
|
||||
.wc-block-components-password-strength__meter[value="1"] + .wc-block-components-password-strength__result {
|
||||
color: $alert-red;
|
||||
}
|
||||
.wc-block-components-password-strength__meter[value="2"],
|
||||
.wc-block-components-password-strength__meter[value="2"] + .wc-block-components-password-strength__result {
|
||||
color: #ff6f00;
|
||||
}
|
||||
.wc-block-components-password-strength__meter[value="3"],
|
||||
.wc-block-components-password-strength__meter[value="3"] + .wc-block-components-password-strength__result {
|
||||
color: $alert-yellow;
|
||||
}
|
||||
.wc-block-components-password-strength__meter[value="4"],
|
||||
.wc-block-components-password-strength__meter[value="4"] + .wc-block-components-password-strength__result {
|
||||
color: $alert-green;
|
||||
}
|
||||
}
|
|
@ -275,6 +275,7 @@
|
|||
"@wordpress/url": "3.13.0",
|
||||
"@wordpress/wordcount": "3.47.0",
|
||||
"change-case": "^4.1.2",
|
||||
"check-password-strength": "^2.0.10",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "4.1.3",
|
||||
"config": "3.3.7",
|
||||
|
|
|
@ -34,7 +34,7 @@ const defaultValidityMessage =
|
|||
const getValidityMessageForInput = (
|
||||
label: string | undefined,
|
||||
inputElement: HTMLInputElement,
|
||||
customValidityMessage?: ( validity: ValidityState ) => string
|
||||
customValidityMessage?: ( validity: ValidityState ) => string | undefined
|
||||
): string => {
|
||||
// No errors, or custom error - return early.
|
||||
if ( inputElement.validity.valid || inputElement.validity.customError ) {
|
||||
|
|
|
@ -20,6 +20,8 @@ export interface ValidatedTextInputProps
|
|||
ariaDescribedBy?: string | undefined;
|
||||
// id to use for the error message. If not provided, an id will be generated.
|
||||
errorId?: string;
|
||||
// Feedback to display alongside the input. May be hidden when validation errors are displayed.
|
||||
feedback?: JSX.Element | null;
|
||||
// if true, the input will be focused on mount.
|
||||
focusOnMount?: boolean;
|
||||
// Callback to run on change which is passed the updated value.
|
||||
|
@ -37,9 +39,7 @@ export interface ValidatedTextInputProps
|
|||
| ( ( inputObject: HTMLInputElement ) => boolean )
|
||||
| undefined;
|
||||
// Custom validation message to display when validity is false. Given the input element. Expected to use inputObject.validity.
|
||||
customValidityMessage?:
|
||||
| ( ( validity: ValidityState ) => undefined | string )
|
||||
| undefined;
|
||||
customValidityMessage?: ( validity: ValidityState ) => undefined | string;
|
||||
// Custom formatted to format values as they are typed.
|
||||
customFormatter?: ( value: string ) => string;
|
||||
// Whether validation should run when focused - only has an effect when focusOnMount is also true.
|
||||
|
|
|
@ -50,6 +50,7 @@ const ValidatedTextInput = forwardRef<
|
|||
value = '',
|
||||
customValidation = () => true,
|
||||
customValidityMessage,
|
||||
feedback = null,
|
||||
customFormatter = ( newValue: string ) => newValue,
|
||||
label,
|
||||
validateOnMount = true,
|
||||
|
@ -241,12 +242,14 @@ const ValidatedTextInput = forwardRef<
|
|||
id={ textInputId }
|
||||
type={ type }
|
||||
feedback={
|
||||
showError ? (
|
||||
showError && hasError ? (
|
||||
<ValidationInputError
|
||||
errorMessage={ passedErrorMessage }
|
||||
propertyName={ errorIdString }
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
feedback
|
||||
)
|
||||
}
|
||||
ref={ inputRef }
|
||||
onChange={ ( newValue ) => {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: enhancement
|
||||
|
||||
Add strength meter to block checkout password field.
|
|
@ -94,10 +94,17 @@ class Checkout extends AbstractBlock {
|
|||
* @return array|string
|
||||
*/
|
||||
protected function get_block_type_script( $key = null ) {
|
||||
$dependencies = [];
|
||||
|
||||
// Load password strength meter script asynchronously if needed.
|
||||
if ( ! is_user_logged_in() && 'no' === get_option( 'woocommerce_registration_generate_password' ) ) {
|
||||
$dependencies[] = 'zxcvbn-async';
|
||||
}
|
||||
|
||||
$script = [
|
||||
'handle' => 'wc-' . $this->block_name . '-block-frontend',
|
||||
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
|
||||
'dependencies' => [],
|
||||
'dependencies' => $dependencies,
|
||||
];
|
||||
return $key ? $script[ $key ] : $script;
|
||||
}
|
||||
|
|
|
@ -3928,6 +3928,9 @@ importers:
|
|||
change-case:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
check-password-strength:
|
||||
specifier: ^2.0.10
|
||||
version: 2.0.10
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
|
@ -13254,6 +13257,9 @@ packages:
|
|||
engines: {node: '>=8.3.0'}
|
||||
hasBin: true
|
||||
|
||||
check-password-strength@2.0.10:
|
||||
resolution: {integrity: sha512-HRM5ICPmtnNtLnTv2QrfVkq1IxI9z3bzYpDJ1k5ixwD9HtJGHuv265R6JmHOV6r8wLhQMlULnIUVpkrC2yaiCw==}
|
||||
|
||||
check-types@8.0.3:
|
||||
resolution: {integrity: sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==}
|
||||
|
||||
|
@ -43968,7 +43974,7 @@ snapshots:
|
|||
'@babel/code-frame': 7.23.5
|
||||
'@babel/parser': 7.23.5
|
||||
'@babel/traverse': 7.23.5
|
||||
'@babel/types': 7.24.7
|
||||
'@babel/types': 7.23.5
|
||||
eslint: 7.32.0
|
||||
eslint-visitor-keys: 1.3.0
|
||||
resolve: 1.22.8
|
||||
|
@ -45585,6 +45591,8 @@ snapshots:
|
|||
run-parallel: 1.2.0
|
||||
semver: 6.3.1
|
||||
|
||||
check-password-strength@2.0.10: {}
|
||||
|
||||
check-types@8.0.3: {}
|
||||
|
||||
checkstyle-formatter@1.1.0:
|
||||
|
|
Loading…
Reference in New Issue