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:
Mike Jolley 2024-07-15 16:22:42 +01:00 committed by GitHub
parent 320873a4dc
commit 77428b973d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 298 additions and 37 deletions

View File

@ -11,18 +11,21 @@ import { ContactFormValues, getSetting } from '@woocommerce/settings';
import { import {
StoreNoticesContainer, StoreNoticesContainer,
CheckboxControl, CheckboxControl,
ValidatedTextInput,
} from '@woocommerce/blocks-components'; } from '@woocommerce/blocks-components';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { CONTACT_FORM_KEYS } from '@woocommerce/block-settings'; import { CONTACT_FORM_KEYS } from '@woocommerce/block-settings';
import { Form } from '@woocommerce/base-components/cart-checkout'; import { Form } from '@woocommerce/base-components/cart-checkout';
/**
* Internal dependencies
*/
import CreatePassword from './create-password';
const CreateAccountUI = (): React.ReactElement | null => { const CreateAccountUI = (): React.ReactElement | null => {
const { customerPassword, shouldCreateAccount } = useSelect( ( select ) => { const { shouldCreateAccount } = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY ); const store = select( CHECKOUT_STORE_KEY );
return { return {
customerPassword: store.getCustomerPassword(),
shouldCreateAccount: store.getShouldCreateAccount(), shouldCreateAccount: store.getShouldCreateAccount(),
}; };
} ); } );
@ -72,32 +75,7 @@ const CreateAccountUI = (): React.ReactElement | null => {
} } } }
/> />
) } ) }
{ showCreateAccountPassword && ( { showCreateAccountPassword && <CreatePassword /> }
<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 )
}
/>
) }
</> </>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -275,6 +275,7 @@
"@wordpress/url": "3.13.0", "@wordpress/url": "3.13.0",
"@wordpress/wordcount": "3.47.0", "@wordpress/wordcount": "3.47.0",
"change-case": "^4.1.2", "change-case": "^4.1.2",
"check-password-strength": "^2.0.10",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compare-versions": "4.1.3", "compare-versions": "4.1.3",
"config": "3.3.7", "config": "3.3.7",

View File

@ -34,7 +34,7 @@ const defaultValidityMessage =
const getValidityMessageForInput = ( const getValidityMessageForInput = (
label: string | undefined, label: string | undefined,
inputElement: HTMLInputElement, inputElement: HTMLInputElement,
customValidityMessage?: ( validity: ValidityState ) => string customValidityMessage?: ( validity: ValidityState ) => string | undefined
): string => { ): string => {
// No errors, or custom error - return early. // No errors, or custom error - return early.
if ( inputElement.validity.valid || inputElement.validity.customError ) { if ( inputElement.validity.valid || inputElement.validity.customError ) {

View File

@ -20,6 +20,8 @@ export interface ValidatedTextInputProps
ariaDescribedBy?: string | undefined; ariaDescribedBy?: string | undefined;
// id to use for the error message. If not provided, an id will be generated. // id to use for the error message. If not provided, an id will be generated.
errorId?: string; 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. // if true, the input will be focused on mount.
focusOnMount?: boolean; focusOnMount?: boolean;
// Callback to run on change which is passed the updated value. // Callback to run on change which is passed the updated value.
@ -37,9 +39,7 @@ export interface ValidatedTextInputProps
| ( ( inputObject: HTMLInputElement ) => boolean ) | ( ( inputObject: HTMLInputElement ) => boolean )
| undefined; | undefined;
// Custom validation message to display when validity is false. Given the input element. Expected to use inputObject.validity. // Custom validation message to display when validity is false. Given the input element. Expected to use inputObject.validity.
customValidityMessage?: customValidityMessage?: ( validity: ValidityState ) => undefined | string;
| ( ( validity: ValidityState ) => undefined | string )
| undefined;
// Custom formatted to format values as they are typed. // Custom formatted to format values as they are typed.
customFormatter?: ( value: string ) => string; customFormatter?: ( value: string ) => string;
// Whether validation should run when focused - only has an effect when focusOnMount is also true. // Whether validation should run when focused - only has an effect when focusOnMount is also true.

View File

@ -50,6 +50,7 @@ const ValidatedTextInput = forwardRef<
value = '', value = '',
customValidation = () => true, customValidation = () => true,
customValidityMessage, customValidityMessage,
feedback = null,
customFormatter = ( newValue: string ) => newValue, customFormatter = ( newValue: string ) => newValue,
label, label,
validateOnMount = true, validateOnMount = true,
@ -241,12 +242,14 @@ const ValidatedTextInput = forwardRef<
id={ textInputId } id={ textInputId }
type={ type } type={ type }
feedback={ feedback={
showError ? ( showError && hasError ? (
<ValidationInputError <ValidationInputError
errorMessage={ passedErrorMessage } errorMessage={ passedErrorMessage }
propertyName={ errorIdString } propertyName={ errorIdString }
/> />
) : null ) : (
feedback
)
} }
ref={ inputRef } ref={ inputRef }
onChange={ ( newValue ) => { onChange={ ( newValue ) => {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add strength meter to block checkout password field.

View File

@ -94,10 +94,17 @@ class Checkout extends AbstractBlock {
* @return array|string * @return array|string
*/ */
protected function get_block_type_script( $key = null ) { 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 = [ $script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend', 'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ), 'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [], 'dependencies' => $dependencies,
]; ];
return $key ? $script[ $key ] : $script; return $key ? $script[ $key ] : $script;
} }

View File

@ -3928,6 +3928,9 @@ importers:
change-case: change-case:
specifier: ^4.1.2 specifier: ^4.1.2
version: 4.1.2 version: 4.1.2
check-password-strength:
specifier: ^2.0.10
version: 2.0.10
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -13254,6 +13257,9 @@ packages:
engines: {node: '>=8.3.0'} engines: {node: '>=8.3.0'}
hasBin: true hasBin: true
check-password-strength@2.0.10:
resolution: {integrity: sha512-HRM5ICPmtnNtLnTv2QrfVkq1IxI9z3bzYpDJ1k5ixwD9HtJGHuv265R6JmHOV6r8wLhQMlULnIUVpkrC2yaiCw==}
check-types@8.0.3: check-types@8.0.3:
resolution: {integrity: sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==} resolution: {integrity: sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==}
@ -43968,7 +43974,7 @@ snapshots:
'@babel/code-frame': 7.23.5 '@babel/code-frame': 7.23.5
'@babel/parser': 7.23.5 '@babel/parser': 7.23.5
'@babel/traverse': 7.23.5 '@babel/traverse': 7.23.5
'@babel/types': 7.24.7 '@babel/types': 7.23.5
eslint: 7.32.0 eslint: 7.32.0
eslint-visitor-keys: 1.3.0 eslint-visitor-keys: 1.3.0
resolve: 1.22.8 resolve: 1.22.8
@ -45585,6 +45591,8 @@ snapshots:
run-parallel: 1.2.0 run-parallel: 1.2.0
semver: 6.3.1 semver: 6.3.1
check-password-strength@2.0.10: {}
check-types@8.0.3: {} check-types@8.0.3: {}
checkstyle-formatter@1.1.0: checkstyle-formatter@1.1.0: