Skip to content

Commit a82a129

Browse files
authored
Add validated form controls (#100771)
* Add form validation prototypes * Add more components * Change "required" indicator * Refactor * Add password example * Fix styling on longer errors * Split ToggleGroup story * ToggleGroupControl: Try custom handling * Better screen reader support? * Explicitly read "(Required)" label suffix This may be redundant sometimes due to the `required` attribute on the element itself, but some components like ToggleGroupControl cannot be marked as `required` via attribute. * ToggleGroupControl: Prevent React warning * RadioControl: Add custom handling * Add support for NumberControl, CheckboxControl, ToggleControl * Add support for SelectControl and CustomSelectControl * Add support for TextControl, fix state bugs * Add support for TextareaControl * Add support for ComboboxControl, RangeControl * Improve TODO comments * Check InputControl will suffix button (needs fix) * Expand support for error styles * Add more TODOs * Fix bug in password example * Stricter types for ValidityTarget * Add prop descriptions * Remove unused rest prop passing in ControlWithError * Move `required` prop to wrapper component * Encapsulate into components * Rename CSS class * Extract delegate styles * Add clarification for custom validity * Remove unneeded optional chaining * Add Overview doc * Split out CheckboxControl story * Simplify SR announcements * Improve UX on blur when tooltip visible * Split out InputControl stories * Work around types * Fix story arg passing * Extract more stories * Improve UX on forms with multiple errors * Remove __next props from docs * Add "mark when optional" functionality * Tweak and simplify docs * Rename * Fix bug in CustomSelectControl * When errored on submit, start incremental validation * Use error icon instead * Tighten vertical margins on error message As suggested by @elizaan36 * Add supplemental examples * Handle direct submissions with no blurring * Document default values on props * Improve custom validator docs (based on feedback) * Rename prop to `customValidator` * Update Storybook config to reflect current setup * Support ReactNode in label * Resolve React display name errors * Rename to `children` prop * Move `children` prop down * Move to JSX children * Add prop description * Add changelog
1 parent 06fb0b2 commit a82a129

34 files changed

+1759
-2
lines changed

packages/components/.storybook/main.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ const storybookDefaultConfig = require( '@automattic/calypso-storybook' );
22

33
module.exports = {
44
...storybookDefaultConfig( {
5-
stories: [ '../{src,styles}/**/*.stories.{js,jsx,ts,tsx}', './**/*.mdx' ],
5+
stories: [
6+
'../{src,styles}/**/*.stories.{js,jsx,ts,tsx}',
7+
'../{src,styles,.storybook}/**/*.mdx',
8+
],
69
} ),
710
docs: { autodocs: true },
811
};

packages/components/.storybook/preview.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const parameters = {
1212
},
1313
options: {
1414
storySort: ( a, b ) => {
15-
const sectionOrder = [ 'WP Overrides', 'Deprecated', 'Unaudited' ];
15+
const sectionOrder = [ 'Validated Form Controls', 'WP Overrides', 'Deprecated', 'Unaudited' ];
1616
const aIndex = sectionOrder.findIndex( ( prefix ) => a.title.startsWith( prefix ) );
1717
const bIndex = sectionOrder.findIndex( ( prefix ) => b.title.startsWith( prefix ) );
1818

packages/components/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### Enhancements
88

99
- Add `BigSkyLogo.Mark` component ([#103612](https://github.com/Automattic/wp-calypso/pull/103612)).
10+
- Add `ValidatedFormControls` components, still in beta ([#100771](https://github.com/Automattic/wp-calypso/pull/100771)).
1011

1112
## 2.3.0
1213

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copied from @wordpress/components
2+
3+
/**
4+
* A higher-order function that wraps a keydown event handler to ensure it is not an IME event.
5+
*
6+
* In CJK languages, an IME (Input Method Editor) is used to input complex characters.
7+
* During an IME composition, keydown events (e.g. Enter or Escape) can be fired
8+
* which are intended to control the IME and not the application.
9+
* These events should be ignored by any application logic.
10+
* @param keydownHandler The keydown event handler to execute after ensuring it was not an IME event.
11+
* @returns A wrapped version of the given event handler that ignores IME events.
12+
*/
13+
export function withIgnoreIMEEvents< E extends React.KeyboardEvent | KeyboardEvent >(
14+
keydownHandler: ( event: E ) => void
15+
) {
16+
return ( event: E ) => {
17+
const { isComposing } = 'nativeEvent' in event ? event.nativeEvent : event;
18+
19+
if (
20+
isComposing ||
21+
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
22+
// is `isComposing=false`, even though it's technically still part of the composition.
23+
// These can only be detected by keyCode.
24+
event.keyCode === 229
25+
) {
26+
return;
27+
}
28+
29+
keydownHandler( event );
30+
};
31+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { fn } from '@storybook/test';
2+
import { useState } from 'react';
3+
import { ValidatedCheckboxControl } from './checkbox-control';
4+
import { formDecorator } from './story-utils';
5+
import type { StoryObj, Meta } from '@storybook/react';
6+
7+
const meta: Meta< typeof ValidatedCheckboxControl > = {
8+
title: 'Validated Form Controls/ValidatedCheckboxControl',
9+
component: ValidatedCheckboxControl,
10+
decorators: formDecorator,
11+
args: { onChange: fn() },
12+
argTypes: {
13+
checked: { control: false },
14+
// TODO: Figure out why this deprecated prop is still showing up here and not in the WP Storybook.
15+
heading: { table: { disable: true } },
16+
},
17+
};
18+
export default meta;
19+
20+
export const Default: StoryObj< typeof ValidatedCheckboxControl > = {
21+
render: function Template( { onChange, ...args } ) {
22+
const [ checked, setChecked ] = useState( false );
23+
24+
return (
25+
<ValidatedCheckboxControl
26+
{ ...args }
27+
checked={ checked }
28+
onChange={ ( value ) => {
29+
setChecked( value );
30+
onChange?.( value );
31+
} }
32+
/>
33+
);
34+
},
35+
};
36+
Default.args = {
37+
required: true,
38+
label: 'Checkbox',
39+
help: 'This checkbox may neither be checked nor unchecked.',
40+
customValidator: ( value ) => {
41+
if ( value ) {
42+
return 'This checkbox may not be checked.';
43+
}
44+
},
45+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { CheckboxControl } from '@wordpress/components';
2+
import { useMergeRefs } from '@wordpress/compose';
3+
import { forwardRef, useRef } from 'react';
4+
import { ControlWithError } from '../control-with-error';
5+
import type { CheckboxControlProps, ValidatedControlProps } from './types';
6+
7+
type Value = CheckboxControlProps[ 'checked' ];
8+
9+
const UnforwardedValidatedCheckboxControl = (
10+
{
11+
required,
12+
customValidator,
13+
onChange,
14+
markWhenOptional,
15+
...restProps
16+
}: Omit< CheckboxControlProps, '__nextHasNoMarginBottom' > & ValidatedControlProps< Value >,
17+
forwardedRef: React.ForwardedRef< HTMLInputElement >
18+
) => {
19+
const validityTargetRef = useRef< HTMLDivElement >( null );
20+
const mergedRefs = useMergeRefs( [ forwardedRef, validityTargetRef ] );
21+
const valueRef = useRef< Value >( restProps.checked );
22+
23+
return (
24+
<ControlWithError
25+
required={ required }
26+
markWhenOptional={ markWhenOptional }
27+
ref={ mergedRefs }
28+
customValidator={ () => {
29+
return customValidator?.( valueRef.current );
30+
} }
31+
getValidityTarget={ () =>
32+
validityTargetRef.current?.querySelector< HTMLInputElement >( 'input[type="checkbox"]' )
33+
}
34+
>
35+
<CheckboxControl
36+
__nextHasNoMarginBottom
37+
onChange={ ( value ) => {
38+
valueRef.current = value;
39+
onChange?.( value );
40+
} }
41+
// TODO: Upstream limitation - CheckboxControl doesn't support uncontrolled mode, visually.
42+
{ ...restProps }
43+
/>
44+
</ControlWithError>
45+
);
46+
};
47+
48+
export const ValidatedCheckboxControl = forwardRef( UnforwardedValidatedCheckboxControl );
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { fn } from '@storybook/test';
2+
import { useState } from 'react';
3+
import { ValidatedComboboxControl } from './combobox-control';
4+
import { formDecorator } from './story-utils';
5+
import type { Meta, StoryObj } from '@storybook/react';
6+
7+
const meta: Meta< typeof ValidatedComboboxControl > = {
8+
title: 'Validated Form Controls/ValidatedComboboxControl',
9+
component: ValidatedComboboxControl,
10+
decorators: formDecorator,
11+
args: { onChange: fn() },
12+
argTypes: {
13+
value: { control: false },
14+
},
15+
};
16+
export default meta;
17+
18+
export const Default: StoryObj< typeof ValidatedComboboxControl > = {
19+
render: function Template( { onChange, ...args } ) {
20+
const [ value, setValue ] =
21+
useState< React.ComponentProps< typeof ValidatedComboboxControl >[ 'value' ] >();
22+
23+
return (
24+
<ValidatedComboboxControl
25+
{ ...args }
26+
value={ value }
27+
onChange={ ( newValue ) => {
28+
setValue( newValue );
29+
onChange?.( newValue );
30+
} }
31+
/>
32+
);
33+
},
34+
};
35+
Default.args = {
36+
required: true,
37+
label: 'Combobox',
38+
help: 'Option A is not allowed.',
39+
options: [
40+
{ value: 'a', label: 'Option A (not allowed)' },
41+
{ value: 'b', label: 'Option B' },
42+
],
43+
customValidator: ( value ) => {
44+
if ( value === 'a' ) {
45+
return 'Option A is not allowed.';
46+
}
47+
},
48+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ComboboxControl } from '@wordpress/components';
2+
import { useMergeRefs } from '@wordpress/compose';
3+
import { forwardRef, useEffect, useRef } from 'react';
4+
import { ControlWithError } from '../control-with-error';
5+
import type { ComboboxControlProps, ValidatedControlProps } from './types';
6+
7+
type Value = ComboboxControlProps[ 'value' ];
8+
9+
const UnforwardedValidatedComboboxControl = (
10+
{
11+
required,
12+
customValidator,
13+
onChange,
14+
markWhenOptional,
15+
...restProps
16+
}: Omit< ComboboxControlProps, '__next40pxDefaultSize' | '__nextHasNoMarginBottom' > &
17+
ValidatedControlProps< Value >,
18+
forwardedRef: React.ForwardedRef< HTMLInputElement >
19+
) => {
20+
const validityTargetRef = useRef< HTMLInputElement >( null );
21+
const mergedRefs = useMergeRefs( [ forwardedRef, validityTargetRef ] );
22+
const valueRef = useRef< Value >( restProps.value );
23+
24+
// TODO: Upstream limitation - The `required` attribute is not passed down to the input,
25+
// so we need to set it manually.
26+
useEffect( () => {
27+
const input =
28+
validityTargetRef.current?.querySelector< HTMLInputElement >( 'input[role="combobox"]' );
29+
if ( input ) {
30+
input.required = required ?? false;
31+
}
32+
}, [ required ] );
33+
34+
return (
35+
// TODO: Bug - Missing value error is not cleared immediately on change, waits for blur.
36+
<ControlWithError
37+
required={ required }
38+
markWhenOptional={ markWhenOptional }
39+
ref={ mergedRefs }
40+
customValidator={ () => {
41+
return customValidator?.( valueRef.current );
42+
} }
43+
getValidityTarget={ () =>
44+
validityTargetRef.current?.querySelector< HTMLInputElement >( 'input[role="combobox"]' )
45+
}
46+
>
47+
<ComboboxControl
48+
__nextHasNoMarginBottom
49+
__next40pxDefaultSize
50+
{ ...restProps }
51+
onChange={ ( value ) => {
52+
valueRef.current = value;
53+
onChange?.( value );
54+
} }
55+
/>
56+
</ControlWithError>
57+
);
58+
};
59+
60+
export const ValidatedComboboxControl = forwardRef( UnforwardedValidatedComboboxControl );
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { fn } from '@storybook/test';
2+
import { useState } from 'react';
3+
import { ValidatedCustomSelectControl } from './custom-select-control';
4+
import { formDecorator } from './story-utils';
5+
import type { Meta, StoryObj } from '@storybook/react';
6+
7+
const meta: Meta< typeof ValidatedCustomSelectControl > = {
8+
title: 'Validated Form Controls/ValidatedCustomSelectControl',
9+
component: ValidatedCustomSelectControl,
10+
decorators: formDecorator,
11+
args: { onChange: fn() },
12+
argTypes: {
13+
value: { control: false },
14+
},
15+
};
16+
export default meta;
17+
18+
export const Default: StoryObj< typeof ValidatedCustomSelectControl > = {
19+
render: function Template( { onChange, ...args } ) {
20+
const [ value, setValue ] =
21+
useState< React.ComponentProps< typeof ValidatedCustomSelectControl >[ 'value' ] >();
22+
23+
return (
24+
<ValidatedCustomSelectControl
25+
{ ...args }
26+
value={ value }
27+
onChange={ ( newValue ) => {
28+
setValue( newValue.selectedItem );
29+
onChange?.( newValue );
30+
} }
31+
/>
32+
);
33+
},
34+
};
35+
Default.args = {
36+
required: true,
37+
label: 'Custom Select',
38+
options: [
39+
{ key: '', name: 'Select an option' },
40+
{ key: 'a', name: 'Option A (not allowed)' },
41+
{ key: 'b', name: 'Option B' },
42+
],
43+
customValidator: ( value ) => {
44+
if ( value?.key === 'a' ) {
45+
return 'Option A is not allowed.';
46+
}
47+
},
48+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { CustomSelectControl } from '@wordpress/components';
2+
import { forwardRef, useRef } from 'react';
3+
import { ControlWithError } from '../control-with-error';
4+
import type { CustomSelectControlProps, ValidatedControlProps } from './types';
5+
6+
type Value = CustomSelectControlProps[ 'value' ];
7+
8+
const UnforwardedValidatedCustomSelectControl = (
9+
{
10+
required,
11+
customValidator,
12+
onChange,
13+
markWhenOptional,
14+
...restProps
15+
}: Omit< CustomSelectControlProps, '__next40pxDefaultSize' > & ValidatedControlProps< Value >,
16+
forwardedRef: React.ForwardedRef< HTMLDivElement >
17+
) => {
18+
const validityTargetRef = useRef< HTMLSelectElement >( null );
19+
const valueRef = useRef< Value >( restProps.value );
20+
21+
return (
22+
<div className="a8c-validated-control__wrapper-with-error-delegate" ref={ forwardedRef }>
23+
<ControlWithError
24+
required={ required }
25+
markWhenOptional={ markWhenOptional }
26+
customValidator={ () => {
27+
return customValidator?.( valueRef.current );
28+
} }
29+
getValidityTarget={ () => validityTargetRef.current }
30+
>
31+
<CustomSelectControl
32+
// TODO: Upstream limitation - Required isn't passed down correctly,
33+
// so it needs to be set on a delegate element.
34+
__next40pxDefaultSize
35+
onChange={ ( value ) => {
36+
valueRef.current = value.selectedItem;
37+
onChange?.( value );
38+
} }
39+
{ ...restProps }
40+
/>
41+
</ControlWithError>
42+
<select
43+
className="a8c-validated-control__error-delegate"
44+
ref={ validityTargetRef }
45+
required={ required }
46+
tabIndex={ -1 }
47+
value={ restProps.value?.key ? 'hasvalue' : '' }
48+
onChange={ () => {} }
49+
onFocus={ ( e ) => {
50+
e.target.previousElementSibling
51+
?.querySelector< HTMLButtonElement >( '[role="combobox"]' )
52+
?.focus();
53+
} }
54+
>
55+
<option value="">No selection</option>
56+
<option value="hasvalue">Has selection</option>
57+
</select>
58+
</div>
59+
);
60+
};
61+
62+
export const ValidatedCustomSelectControl = forwardRef( UnforwardedValidatedCustomSelectControl );

0 commit comments

Comments
 (0)