Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Switch from Select to Combobox for Country and State Inputs (#4369)
Browse files Browse the repository at this point in the history
* Add combobox control

* Implement in country and state

* mobile styling

* styling across themes

* Remove validated select component

* Use focus-within

* Update tests

* Use @wordpress/compose

* Move field clearing to effect hook

* Patch combobox component

PR WordPress/gutenberg#33928

* patch package after install

* update package

* Prevent autofill handling impacting manual input

* Add todo

* combo requires option to be selected
  • Loading branch information
mikejolley committed Aug 12, 2021
1 parent ec54e9b commit 56476e1
Show file tree
Hide file tree
Showing 14 changed files with 787 additions and 357 deletions.
Expand Up @@ -112,6 +112,18 @@ const AddressForm = ( {
);
}, [ currentFields, fieldConfig, values.country ] );

// Clear values for hidden fields.
useEffect( () => {
addressFormFields.forEach( ( field ) => {
if ( field.hidden && values[ field.key ] ) {
onChange( {
...values,
[ field.key ]: '',
} );
}
} );
}, [ addressFormFields, onChange, values ] );

useEffect( () => {
if ( type === 'shipping' ) {
validateShippingCountry(
Expand Down Expand Up @@ -161,8 +173,6 @@ const AddressForm = ( {
...values,
country: newValue,
state: '',
city: '',
postcode: '',
} )
}
errorId={
Expand Down
19 changes: 5 additions & 14 deletions assets/js/base/components/cart-checkout/address-form/test/index.js
Expand Up @@ -21,7 +21,7 @@ const renderInCheckoutProvider = ( ui, options = {} ) => {
// Countries used in testing addresses must be in the wcSettings global.
// See: tests/js/setup-globals.js
const primaryAddress = {
country: 'United Kingdom (UK)',
country: 'United Kingdom',
countryKey: 'GB',
city: 'London',
state: 'Greater London',
Expand All @@ -46,25 +46,22 @@ const cityRegExp = /city/i;
const stateRegExp = /county|province|state/i;
const postalCodeRegExp = /postal code|postcode|zip/i;

const inputAddress = ( {
const inputAddress = async ( {
country = null,
city = null,
state = null,
postcode = null,
} ) => {
if ( country ) {
const countryButton = screen.getByRole( 'button', {
name: countryRegExp,
} );
userEvent.click( countryButton );
userEvent.click( screen.getByRole( 'option', { name: country } ) );
const countryInput = screen.getByLabelText( countryRegExp );
userEvent.type( countryInput, country + '{arrowdown}{enter}' );
}
if ( city ) {
const cityInput = screen.getByLabelText( cityRegExp );
userEvent.type( cityInput, city );
}
if ( state ) {
const stateButton = screen.queryByRole( 'button', {
const stateButton = screen.queryByRole( 'combobox', {
name: stateRegExp,
} );
// State input might be a select or a text input.
Expand Down Expand Up @@ -162,17 +159,11 @@ describe( 'AddressForm Component', () => {
inputAddress( secondaryAddress );
// Only update `country` to verify other values are reset.
inputAddress( { country: primaryAddress.country } );

expect( screen.getByLabelText( cityRegExp ).value ).toBe( '' );
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
expect( screen.getByLabelText( postalCodeRegExp ).value ).toBe( '' );

// Repeat the test with an address which has a select for the state.
inputAddress( tertiaryAddress );
inputAddress( { country: primaryAddress.country } );

expect( screen.getByLabelText( cityRegExp ).value ).toBe( '' );
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
expect( screen.getByLabelText( postalCodeRegExp ).value ).toBe( '' );
} );
} );
150 changes: 150 additions & 0 deletions assets/js/base/components/combobox/index.tsx
@@ -0,0 +1,150 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose';
import { ComboboxControl } from 'wordpress-components';
import {
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { isObject } from '@woocommerce/types';

/**
* Internal dependencies
*/
import './style.scss';

export interface ComboboxControlOption {
label: string;
value: string;
}

/**
* Wrapper for the WordPress ComboboxControl which supports validation.
*/
const Combobox = ( {
id,
className,
label,
onChange,
options,
value,
required = false,
errorMessage = __(
'Please select a value.',
'woo-gutenberg-products-block'
),
errorId: incomingErrorId,
instanceId = '0',
autoComplete = 'off',
}: {
id: string;
className: string;
label: string;
onChange: ( filterValue: string ) => void;
options: ComboboxControlOption[];
value: string;
required: boolean;
errorMessage: string;
errorId: string;
instanceId: string;
autoComplete: string;
} ): JSX.Element => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();

const controlRef = useRef< HTMLDivElement >( null );
const controlId = id || 'control-' + instanceId;
const errorId = incomingErrorId || controlId;
const error = ( getValidationError( errorId ) || {
message: '',
hidden: false,
} ) as {
message: string;
hidden: boolean;
};

useEffect( () => {
if ( ! required || value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
return () => {
clearValidationError( errorId );
};
}, [
clearValidationError,
value,
errorId,
errorMessage,
required,
setValidationErrors,
] );

// @todo Remove patch for ComboboxControl once https://github.com/WordPress/gutenberg/pull/33928 is released
return (
<div
id={ controlId }
className={ classnames( 'wc-block-components-combobox', className, {
'is-active': value,
'has-error': error.message && ! error.hidden,
} ) }
ref={ controlRef }
>
<ComboboxControl
className={ 'wc-block-components-combobox-control' }
label={ label }
onChange={ onChange }
onFilterValueChange={ ( filterValue: string ) => {
if ( filterValue.length ) {
// If we have a value and the combobox is not focussed, this could be from browser autofill.
const activeElement = isObject( controlRef.current )
? controlRef.current.ownerDocument.activeElement
: undefined;

if (
activeElement &&
isObject( controlRef.current ) &&
controlRef.current.contains( activeElement )
) {
return;
}

// Try to match.
const normalizedFilterValue = filterValue.toLocaleUpperCase();
const foundOption = options.find(
( option ) =>
option.label
.toLocaleUpperCase()
.startsWith( normalizedFilterValue ) ||
option.value.toLocaleUpperCase() ===
normalizedFilterValue
);
if ( foundOption ) {
onChange( foundOption.value );
}
}
} }
options={ options }
value={ value || '' }
allowReset={ false }
autoComplete={ autoComplete }
/>
<ValidationInputError propertyName={ errorId } />
</div>
);
};

export default withInstanceId( Combobox );
156 changes: 156 additions & 0 deletions assets/js/base/components/combobox/style.scss
@@ -0,0 +1,156 @@
.wc-block-components-form .wc-block-components-combobox,
.wc-block-components-combobox {
.wc-block-components-combobox-control {
@include reset-typography();
@include reset-box();

.components-base-control__field {
@include reset-box();
}
.components-combobox-control__suggestions-container {
@include reset-typography();
@include reset-box();
position: relative;
}
input.components-combobox-control__input {
@include reset-typography();
@include font-size(regular);

box-sizing: border-box;
outline: inherit;
border: 1px solid $input-border-gray;
background: #fff;
box-shadow: none;
color: $input-text-active;
font-family: inherit;
font-weight: normal;
height: 3em;
letter-spacing: inherit;
line-height: 1;
padding: em($gap-large) $gap em($gap-smallest);
text-align: left;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
width: 100%;
opacity: initial;
border-radius: 4px;

&[aria-expanded="true"],
&:focus {
background-color: #fff;
color: $input-text-active;
}

&[aria-expanded="true"] {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}

.has-dark-controls & {
background-color: $input-background-dark;
border-color: $input-border-dark;
color: $input-text-dark;

&:focus {
background-color: $input-background-dark;
color: $input-text-dark;
}
}
}
.components-form-token-field__suggestions-list {
position: absolute;
z-index: 10;
background-color: $select-dropdown-light;
border: 1px solid $input-border-gray;
border-top: 0;
margin: 3em 0 0 0;
padding: 0;
max-height: 300px;
min-width: 100%;
overflow: auto;
color: $input-text-active;

.has-dark-controls & {
background-color: $select-dropdown-dark;
color: $input-text-dark;
}

.components-form-token-field__suggestion {
@include font-size(regular);
color: $gray-700;
cursor: default;
list-style: none;
margin: 0;
padding: em($gap-smallest) $gap;

&.is-selected {
background-color: $gray-300;
.has-dark-controls & {
background-color: $select-item-dark;
}
}

&:hover,
&:focus,
&.is-highlighted,
&:active {
background-color: #00669e;
color: #fff;

}
}
}

label.components-base-control__label {
@include reset-typography();
@include font-size(regular);
line-height: 1.375; // =22px when font-size is 16px.
position: absolute;
transform: translateY(0.75em);
transform-origin: top left;
transition: all 200ms ease;
color: $gray-700;
z-index: 1;
margin: 0 0 0 #{$gap + 1px};
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - #{2 * $gap});
white-space: nowrap;

.has-dark-controls & {
color: $input-placeholder-dark;
}
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
}

&.is-active,
&:focus-within {
.wc-block-components-combobox-control label.components-base-control__label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
}

&.has-error {
.wc-block-components-combobox-control {
label.components-base-control__label {
color: $alert-red;
}
input.components-combobox-control__input {
&,
&:hover,
&:focus,
&:active {
border-color: $alert-red;
}
&:focus {
outline: 1px dotted $alert-red;
outline-offset: 2px;
}
}
}
}
}

0 comments on commit 56476e1

Please sign in to comment.