+
+ { ( isSelected || isInnerBlockSelected ) && (
+
{ options.map( ( option, index ) => (
);
-};
-
-export default compose(
- withSharedFieldAttributes( [
- 'borderRadius',
- 'borderWidth',
- 'labelFontSize',
- 'fieldFontSize',
- 'lineHeight',
- 'labelLineHeight',
- 'inputColor',
- 'labelColor',
- 'fieldBackgroundColor',
- 'borderColor',
- ] )
-)( JetpackDropdown );
+}
diff --git a/projects/packages/forms/src/blocks/field-select/index.js b/projects/packages/forms/src/blocks/field-select/index.js
new file mode 100644
index 0000000000000..f4a995b226cb8
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-select/index.js
@@ -0,0 +1,48 @@
+import { Path } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { getIconColor } from '../contact-form/util/block-icons';
+import renderMaterialIcon from '../contact-form/util/render-material-icon';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-select';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Dropdown Field', 'jetpack-forms' ),
+ keywords: [
+ __( 'Choose', 'jetpack-forms' ),
+ __( 'Dropdown', 'jetpack-forms' ),
+ __( 'Option', 'jetpack-forms' ),
+ ],
+ description: __(
+ 'Add a compact select box, that when expanded, allows visitors to choose one value from the list.',
+ 'jetpack-forms'
+ ),
+ icon: {
+ foreground: getIconColor(),
+ src: renderMaterialIcon(
+
+ ),
+ },
+ edit,
+ attributes: {
+ ...defaultSettings.attributes,
+ options: {
+ type: 'array',
+ default: [ '' ],
+ role: 'content',
+ },
+ },
+ deprecated,
+ save,
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-select/save.js b/projects/packages/forms/src/blocks/field-select/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-select/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/field-single-choice/deprecated.js b/projects/packages/forms/src/blocks/field-single-choice/deprecated.js
new file mode 100644
index 0000000000000..1c357a914adea
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-single-choice/deprecated.js
@@ -0,0 +1,54 @@
+import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
+import { __ } from '@wordpress/i18n';
+import INNER_BLOCKS_DEPRECATION from '../shared/deprecations/inner-blocks-deprecation';
+import migrateInnerOptionBlocks from '../shared/deprecations/migrate-inner-option-blocks';
+import multiFieldV1 from '../shared/deprecations/multiple-choice-field-deprecation';
+
+const v1 = multiFieldV1( 'radio' );
+const v2 = {
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: {
+ type: 'string',
+ default: __( 'Choose one option', 'jetpack-forms' ),
+ },
+ },
+ supports: INNER_BLOCKS_DEPRECATION.supports,
+ migrate( attributes, innerBlocks ) {
+ return migrateInnerOptionBlocks( attributes, innerBlocks, 'radio' );
+ },
+ save() {
+ return
;
+ },
+};
+const v3 = {
+ ...INNER_BLOCKS_DEPRECATION,
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: {
+ type: 'string',
+ default: 'Choose one option',
+ },
+ },
+ isEligible( _attributes, innerBlocks ) {
+ if ( innerBlocks.length !== 2 ) {
+ return true;
+ }
+
+ return innerBlocks.some(
+ block => block.name !== `jetpack/label` && block.name !== `jetpack/options`
+ );
+ },
+ migrate( attributes, innerBlocks ) {
+ return migrateInnerOptionBlocks( attributes, innerBlocks, 'radio' );
+ },
+ save() {
+ return (
+
+
+
+ );
+ },
+};
+
+export default [ v3, v2, v1 ];
diff --git a/projects/packages/forms/src/blocks/field-single-choice/edit.js b/projects/packages/forms/src/blocks/field-single-choice/edit.js
new file mode 100644
index 0000000000000..0d3a1759414f3
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-single-choice/edit.js
@@ -0,0 +1,63 @@
+import {
+ store as blockEditorStore,
+ useBlockProps,
+ useInnerBlocksProps,
+} from '@wordpress/block-editor';
+import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import clsx from 'clsx';
+import JetpackFieldControls from '../shared/components/jetpack-field-controls';
+import useFormWrapper from '../shared/hooks/use-form-wrapper';
+
+export default function SingleChoiceFieldEdit( props ) {
+ const { className, clientId, instanceId, setAttributes, isSelected, attributes } = props;
+ const { required, id, width } = attributes;
+
+ useFormWrapper( props );
+
+ const innerBlocks = useSelect(
+ select => select( blockEditorStore ).getBlock( clientId ).innerBlocks,
+ [ clientId ]
+ );
+ const options = innerBlocks?.[ 1 ]?.innerBlocks;
+ const classes = clsx( className, 'jetpack-field jetpack-field-multiple', {
+ 'is-selected': isSelected,
+ 'has-placeholder': !! options?.length,
+ } );
+
+ const blockProps = useBlockProps( {
+ id: `jetpack-field-multiple-${ instanceId }`,
+ className: classes,
+ } );
+
+ const innerBlockProps = useInnerBlocksProps( blockProps, {
+ template: [
+ [
+ 'jetpack/label',
+ {
+ label: __( 'Choose one option', 'jetpack-forms' ),
+ defaultLabel: __( 'Add label…', 'jetpack-forms' ),
+ },
+ ],
+ [ 'jetpack/options', { type: 'radio' } ],
+ ],
+ templateLock: 'all',
+ } );
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/projects/packages/forms/src/blocks/field-single-choice/index.js b/projects/packages/forms/src/blocks/field-single-choice/index.js
new file mode 100644
index 0000000000000..d2f85a4ecc384
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-single-choice/index.js
@@ -0,0 +1,61 @@
+import { Path } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { getIconColor } from '../contact-form/util/block-icons';
+import renderMaterialIcon from '../contact-form/util/render-material-icon';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-radio';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Single Choice (Radio)', 'jetpack-forms' ),
+ keywords: [
+ __( 'Choose', 'jetpack-forms' ),
+ __( 'Select', 'jetpack-forms' ),
+ __( 'Option', 'jetpack-forms' ),
+ ],
+ description: __(
+ 'Offer users a list of choices, and allow them to select a single option.',
+ 'jetpack-forms'
+ ),
+ icon: {
+ foreground: getIconColor(),
+ src: renderMaterialIcon(
+
+ ),
+ },
+ edit,
+ allowedBlocks: [ 'jetpack/label', 'jetpack/field-options' ],
+ attributes: {
+ // TODO: Are there still attributes from the form fieldDefaults that need to be brought here?
+ required: {
+ type: 'boolean',
+ default: false,
+ },
+ id: {
+ type: 'string',
+ default: '',
+ },
+ width: {
+ type: 'number',
+ default: 100,
+ },
+ shareFieldAttributes: {
+ type: 'boolean',
+ default: true,
+ },
+ },
+ deprecated,
+ save,
+ styles: [
+ { name: 'list', label: __( 'List', 'jetpack-forms' ), isDefault: true },
+ { name: 'button', label: __( 'Button', 'jetpack-forms' ) },
+ ],
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-single-choice/save.js b/projects/packages/forms/src/blocks/field-single-choice/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-single-choice/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/field-telephone/deprecated.js b/projects/packages/forms/src/blocks/field-telephone/deprecated.js
new file mode 100644
index 0000000000000..acec2be1ce7a5
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-telephone/deprecated.js
@@ -0,0 +1,11 @@
+import INNER_BLOCKS_DEPRECATION from '../shared/deprecations/inner-blocks-deprecation';
+
+export default [
+ {
+ ...INNER_BLOCKS_DEPRECATION,
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: { type: 'string', default: 'Phone' },
+ },
+ },
+];
diff --git a/projects/packages/forms/src/blocks/field-telephone/edit.js b/projects/packages/forms/src/blocks/field-telephone/edit.js
new file mode 100644
index 0000000000000..6e222a0516025
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-telephone/edit.js
@@ -0,0 +1,24 @@
+import JetpackField from '../shared/components/jetpack-field';
+import useFormWrapper from '../shared/hooks/use-form-wrapper';
+
+export default function TelephoneFieldEdit( props ) {
+ useFormWrapper( props );
+
+ return (
+
+ );
+}
diff --git a/projects/packages/forms/src/blocks/field-telephone/index.js b/projects/packages/forms/src/blocks/field-telephone/index.js
new file mode 100644
index 0000000000000..f0b938e16bfd3
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-telephone/index.js
@@ -0,0 +1,40 @@
+import { Icon } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { mobile } from '@wordpress/icons';
+import { getIconColor } from '../contact-form/util/block-icons';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-telephone';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Phone Number Field', 'jetpack-forms' ),
+ keywords: [
+ __( 'Phone', 'jetpack-forms' ),
+ __( 'Cellular phone', 'jetpack-forms' ),
+ __( 'Mobile', 'jetpack-forms' ),
+ ],
+ description: __( 'Collect phone numbers from site visitors.', 'jetpack-forms' ),
+ icon: {
+ foreground: getIconColor(),
+ src:
,
+ },
+ edit,
+ attributes: {
+ ...defaultSettings.attributes,
+ label: {
+ type: 'string',
+ default: 'Phone',
+ role: 'content',
+ },
+ },
+ deprecated,
+ save,
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-telephone/save.js b/projects/packages/forms/src/blocks/field-telephone/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-telephone/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/field-text/deprecated.js b/projects/packages/forms/src/blocks/field-text/deprecated.js
new file mode 100644
index 0000000000000..ca52be41e48e2
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-text/deprecated.js
@@ -0,0 +1,11 @@
+import INNER_BLOCKS_DEPRECATION from '../shared/deprecations/inner-blocks-deprecation';
+
+export default [
+ {
+ ...INNER_BLOCKS_DEPRECATION,
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: { type: 'string', default: 'Text' },
+ },
+ },
+];
diff --git a/projects/packages/forms/src/blocks/field-text/edit.js b/projects/packages/forms/src/blocks/field-text/edit.js
new file mode 100644
index 0000000000000..68e5b8a18fe82
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-text/edit.js
@@ -0,0 +1,24 @@
+import JetpackField from '../shared/components/jetpack-field';
+import useFormWrapper from '../shared/hooks/use-form-wrapper';
+
+export default function TextFieldEdit( props ) {
+ useFormWrapper( props );
+
+ return (
+
+ );
+}
diff --git a/projects/packages/forms/src/blocks/field-text/index.js b/projects/packages/forms/src/blocks/field-text/index.js
new file mode 100644
index 0000000000000..ac5c9a54b0249
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-text/index.js
@@ -0,0 +1,37 @@
+import { Path } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { getIconColor } from '../contact-form/util/block-icons';
+import renderMaterialIcon from '../contact-form/util/render-material-icon';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-text';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Text Input Field', 'jetpack-forms' ),
+ description: __( 'Collect short text responses from site visitors.', 'jetpack-forms' ),
+ icon: {
+ foreground: getIconColor(),
+ src: renderMaterialIcon(
+
+ ),
+ },
+ edit,
+ attributes: {
+ ...defaultSettings.attributes,
+ label: {
+ type: 'string',
+ default: 'Text',
+ role: 'content',
+ },
+ },
+ deprecated,
+ save,
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-text/save.js b/projects/packages/forms/src/blocks/field-text/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-text/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/field-textarea/deprecated.js b/projects/packages/forms/src/blocks/field-textarea/deprecated.js
new file mode 100644
index 0000000000000..368e0965ce542
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-textarea/deprecated.js
@@ -0,0 +1,28 @@
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import INNER_BLOCKS_DEPRECATION from '../shared/deprecations/inner-blocks-deprecation';
+import deprecateFieldStyles from '../shared/util/deprecate-field-styles';
+
+export default [
+ {
+ ...INNER_BLOCKS_DEPRECATION,
+ migrate( attributes ) {
+ const { restAttributes, labelStyles, inputStyles } = deprecateFieldStyles( attributes );
+ const newInnerBlocks = [
+ createBlock( 'jetpack/label', {
+ label: attributes.label,
+ defaultLabel: __( 'Message', 'jetpack-forms' ),
+ requiredText: attributes.requiredText,
+ style: labelStyles,
+ } ),
+ createBlock( 'jetpack/input', {
+ placeholder: attributes.placeholder,
+ style: inputStyles,
+ type: 'textarea',
+ } ),
+ ];
+
+ return [ restAttributes, newInnerBlocks ];
+ },
+ },
+];
diff --git a/projects/packages/forms/src/blocks/field-textarea/edit.js b/projects/packages/forms/src/blocks/field-textarea/edit.js
new file mode 100644
index 0000000000000..a8e9340ea5a56
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-textarea/edit.js
@@ -0,0 +1,71 @@
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { useEffect, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import clsx from 'clsx';
+import JetpackFieldControls from '../shared/components/jetpack-field-controls';
+import useFieldSelected from '../shared/hooks/use-field-selected';
+import useFormWrapper from '../shared/hooks/use-form-wrapper';
+import useJetpackFieldStyles from '../shared/hooks/use-jetpack-field-styles';
+import { ALLOWED_INNER_BLOCKS } from '../shared/util/constants';
+
+export default function TextareaFieldEdit( props ) {
+ const {
+ attributes,
+ clientId,
+ id,
+ isSelected,
+ label,
+ required,
+ requiredText,
+ setAttributes,
+ width,
+ } = props;
+
+ useFormWrapper( props );
+
+ const { blockStyle } = useJetpackFieldStyles( attributes );
+ const { isInnerBlockSelected, hasPlaceholder } = useFieldSelected( clientId );
+ const blockProps = useBlockProps( {
+ className: clsx( 'jetpack-field jetpack-field-textarea', {
+ 'is-selected': isSelected || isInnerBlockSelected,
+ 'has-placeholder': hasPlaceholder,
+ } ),
+ style: blockStyle,
+ } );
+
+ const defaultLabel = __( 'Message', 'jetpack-forms' );
+ const template = useMemo( () => {
+ return [
+ [ 'jetpack/label', { label, required, defaultLabel, requiredText } ],
+ [ 'jetpack/input', { type: 'textarea' } ],
+ ];
+ }, [ label, defaultLabel, required, requiredText ] );
+
+ const innerBlocksProps = useInnerBlocksProps( blockProps, {
+ allowedBlocks: ALLOWED_INNER_BLOCKS,
+ template,
+ templateLock: 'all',
+ type: 'textarea',
+ } );
+
+ useEffect( () => {
+ if ( label === null || label === undefined ) {
+ setAttributes( { label: '' } );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/projects/packages/forms/src/blocks/field-textarea/index.js b/projects/packages/forms/src/blocks/field-textarea/index.js
new file mode 100644
index 0000000000000..7e2269aad57df
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-textarea/index.js
@@ -0,0 +1,36 @@
+import { Path } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { getIconColor } from '../contact-form/util/block-icons';
+import renderMaterialIcon from '../contact-form/util/render-material-icon';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-textarea';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Multi-line Text Field', 'jetpack-forms' ),
+ keywords: [
+ __( 'Textarea', 'jetpack-forms' ),
+ 'textarea',
+ __( 'Multiline text', 'jetpack-forms' ),
+ ],
+ description: __( 'Capture longform text responses from site visitors.', 'jetpack-forms' ),
+ icon: {
+ foreground: getIconColor(),
+ src: renderMaterialIcon(
+
+ ),
+ },
+ edit,
+ // TODO: Revisit all the field attributes so they don't have label, placeholder etc.
+ attributes: defaultSettings.attributes,
+ deprecated,
+ save,
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-textarea/save.js b/projects/packages/forms/src/blocks/field-textarea/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-textarea/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/field-url/deprecated.js b/projects/packages/forms/src/blocks/field-url/deprecated.js
new file mode 100644
index 0000000000000..de7a26a85c52a
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-url/deprecated.js
@@ -0,0 +1,11 @@
+import INNER_BLOCKS_DEPRECATION from '../shared/deprecations/inner-blocks-deprecation';
+
+export default [
+ {
+ ...INNER_BLOCKS_DEPRECATION,
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: { type: 'string', default: 'Website' },
+ },
+ },
+];
diff --git a/projects/packages/forms/src/blocks/field-url/edit.js b/projects/packages/forms/src/blocks/field-url/edit.js
new file mode 100644
index 0000000000000..512b2e6051f54
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-url/edit.js
@@ -0,0 +1,24 @@
+import JetpackField from '../shared/components/jetpack-field';
+import useFormWrapper from '../shared/hooks/use-form-wrapper';
+
+export default function UrlFieldEdit( props ) {
+ useFormWrapper( props );
+
+ return (
+
+ );
+}
diff --git a/projects/packages/forms/src/blocks/field-url/index.js b/projects/packages/forms/src/blocks/field-url/index.js
new file mode 100644
index 0000000000000..f901e80fa104d
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-url/index.js
@@ -0,0 +1,41 @@
+import { Icon } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { globe } from '@wordpress/icons';
+import { getIconColor } from '../contact-form/util/block-icons';
+import defaultSettings from '../shared/settings';
+import deprecated from './deprecated';
+import edit from './edit';
+import save from './save';
+
+const name = 'field-url';
+const settings = {
+ ...defaultSettings,
+ title: __( 'Website Field', 'jetpack-forms' ),
+ keywords: [
+ __( 'url', 'jetpack-forms' ),
+ __( 'internet page', 'jetpack-forms' ),
+ __( 'link', 'jetpack-forms' ),
+ __( 'website', 'jetpack-forms' ),
+ ],
+ description: __( 'Collect a website address from your site visitors.', 'jetpack-forms' ),
+ icon: {
+ foreground: getIconColor(),
+ src:
,
+ },
+ edit,
+ attributes: {
+ ...defaultSettings.attributes,
+ label: {
+ type: 'string',
+ default: 'Website',
+ role: 'content',
+ },
+ },
+ deprecated,
+ save,
+};
+
+export default {
+ name,
+ settings,
+};
diff --git a/projects/packages/forms/src/blocks/field-url/save.js b/projects/packages/forms/src/blocks/field-url/save.js
new file mode 100644
index 0000000000000..5cd085a8c7b21
--- /dev/null
+++ b/projects/packages/forms/src/blocks/field-url/save.js
@@ -0,0 +1,6 @@
+import { useInnerBlocksProps } from '@wordpress/block-editor';
+
+export default () => {
+ const innerBlocksProps = useInnerBlocksProps.save();
+ return
;
+};
diff --git a/projects/packages/forms/src/blocks/shared/components/jetpack-field-controls.js b/projects/packages/forms/src/blocks/shared/components/jetpack-field-controls.js
new file mode 100644
index 0000000000000..1c46ba5cfc5e0
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/components/jetpack-field-controls.js
@@ -0,0 +1,154 @@
+import {
+ InspectorAdvancedControls,
+ InspectorControls,
+ BlockControls,
+} from '@wordpress/block-editor';
+import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
+import { isValidElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import ToolbarRequiredGroup from '../../contact-form/components/block-controls/toolbar-required-group';
+import JetpackFieldWidth from '../../contact-form/components/jetpack-field-width';
+import JetpackManageResponsesSettings from '../../contact-form/components/jetpack-manage-responses-settings';
+import useFormStyle from '../hooks/use-form-style';
+import { FORM_STYLE } from '../util/constants';
+import getBlockStyle from '../util/get-block-style';
+
+const JetpackFieldControls = ( {
+ attributes,
+ blockClassNames,
+ clientId,
+ id,
+ required,
+ setAttributes,
+ type,
+ width,
+ extraFieldSettings = [],
+} ) => {
+ const formStyle = useFormStyle( clientId );
+ const blockStyle = getBlockStyle( blockClassNames );
+ const isChoicesBlock = [ 'radio', 'checkbox' ].includes( type );
+
+ const optionColorLabel =
+ blockStyle === 'button'
+ ? __( 'Button Text', 'jetpack-forms' )
+ : __( 'Option Text', 'jetpack-forms', 0 );
+
+ const inputColorLabel = isChoicesBlock
+ ? optionColorLabel
+ : __( 'Field Text', 'jetpack-forms', 0 );
+
+ const backgroundColorLabel = isChoicesBlock
+ ? __( 'Background', 'jetpack-forms' )
+ : __( 'Field Background', 'jetpack-forms', 0 );
+
+ const colorSettings = [
+ {
+ value: attributes.labelColor,
+ onChange: value => setAttributes( { labelColor: value } ),
+ label: __( 'Label Text', 'jetpack-forms' ),
+ },
+ {
+ value: attributes.inputColor,
+ onChange: value => setAttributes( { inputColor: value } ),
+ label: inputColorLabel,
+ },
+ ];
+
+ if ( isChoicesBlock && blockStyle === 'button' ) {
+ colorSettings.push( {
+ value: attributes.buttonBackgroundColor,
+ onChange: value => setAttributes( { buttonBackgroundColor: value } ),
+ label: __( 'Button Background', 'jetpack-forms' ),
+ } );
+ }
+
+ if ( ! isChoicesBlock || formStyle === FORM_STYLE.OUTLINED ) {
+ colorSettings.push( {
+ value: attributes.fieldBackgroundColor,
+ onChange: value => setAttributes( { fieldBackgroundColor: value } ),
+ label: backgroundColorLabel,
+ } );
+
+ colorSettings.push( {
+ value: attributes.borderColor,
+ onChange: value => setAttributes( { borderColor: value } ),
+ label: __( 'Border', 'jetpack-forms' ),
+ } );
+ }
+
+ const setId = value => {
+ const newValue = value.replace( /[^a-zA-Z0-9_-]/g, '' );
+ setAttributes( { id: newValue } );
+ };
+
+ let fieldSettings = [
+
setAttributes( { required: value } ) }
+ help={ __( 'You can edit the "required" label in the editor', 'jetpack-forms' ) }
+ __nextHasNoMarginBottom={ true }
+ />,
+ ,
+ setAttributes( { shareFieldAttributes: value } ) }
+ help={ __( 'Deactivate for individual styling of this block', 'jetpack-forms' ) }
+ __nextHasNoMarginBottom={ true }
+ />,
+ ];
+
+ extraFieldSettings.forEach( ( { element, index } ) => {
+ if ( ! isValidElement( element ) ) {
+ return;
+ }
+
+ if ( index >= 0 && index < fieldSettings.length ) {
+ fieldSettings = [
+ ...fieldSettings.slice( 0, index ),
+ element,
+ ...fieldSettings.slice( index ),
+ ];
+ } else {
+ fieldSettings.push( element );
+ }
+ } );
+
+ return (
+ <>
+
+ setAttributes( { required: ! required } ) }
+ />
+
+
+
+
+
+
+
+ <>{ fieldSettings }>
+
+
+
+
+
+ >
+ );
+};
+
+export default JetpackFieldControls;
diff --git a/projects/packages/forms/src/blocks/shared/components/jetpack-field.js b/projects/packages/forms/src/blocks/shared/components/jetpack-field.js
new file mode 100644
index 0000000000000..d9744fdd17fdf
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/components/jetpack-field.js
@@ -0,0 +1,81 @@
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { getBlockType } from '@wordpress/blocks';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { useMemo } from '@wordpress/element';
+import { addFilter } from '@wordpress/hooks';
+import clsx from 'clsx';
+import useFieldSelected from '../hooks/use-field-selected';
+import useJetpackFieldStyles from '../hooks/use-jetpack-field-styles';
+import { ALLOWED_INNER_BLOCKS } from '../util/constants';
+import JetpackFieldControls from './jetpack-field-controls';
+
+const JetpackField = props => {
+ const {
+ attributes,
+ clientId,
+ id,
+ isSelected,
+ label,
+ required,
+ requiredText,
+ setAttributes,
+ type,
+ width,
+ } = props;
+ const { isInnerBlockSelected, hasPlaceholder } = useFieldSelected( clientId );
+ const { blockStyle } = useJetpackFieldStyles( attributes );
+ const blockProps = useBlockProps( {
+ className: clsx( 'jetpack-field', {
+ 'is-selected': isSelected || isInnerBlockSelected,
+ 'has-placeholder': hasPlaceholder,
+ } ),
+ style: blockStyle,
+ } );
+
+ const labelBlockType = getBlockType( 'jetpack/label' );
+ const defaultLabel = labelBlockType.attributes.label.default;
+ const template = useMemo( () => {
+ return [
+ [ 'jetpack/label', { label, required, defaultLabel, requiredText } ],
+ [ 'jetpack/input', { type } ],
+ ];
+ }, [ label, defaultLabel, required, requiredText, type ] );
+
+ const innerBlocksProps = useInnerBlocksProps( blockProps, {
+ allowedBlocks: ALLOWED_INNER_BLOCKS,
+ template,
+ templateLock: 'all',
+ } );
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default JetpackField;
+
+const withCustomClassName = createHigherOrderComponent( BlockListBlock => {
+ return props => {
+ if ( props.name.indexOf( 'jetpack/field' ) > -1 ) {
+ const customClassName = props.attributes.width
+ ? 'jetpack-field__width-' + props.attributes.width
+ : '';
+
+ return ;
+ }
+
+ return ;
+ };
+}, 'withCustomClassName' );
+
+addFilter( 'editor.BlockListBlock', 'jetpack/contact-form', withCustomClassName );
diff --git a/projects/packages/forms/src/blocks/shared/deprecations/inner-blocks-deprecation.js b/projects/packages/forms/src/blocks/shared/deprecations/inner-blocks-deprecation.js
new file mode 100644
index 0000000000000..343a6fa2d1ff3
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/deprecations/inner-blocks-deprecation.js
@@ -0,0 +1,109 @@
+import { createBlock } from '@wordpress/blocks';
+import deprecateFieldStyles from '../util/deprecate-field-styles';
+
+const INNER_BLOCKS_DEPRECATION = {
+ attributes: {
+ label: {
+ type: 'string',
+ default: null,
+ },
+ required: {
+ type: 'boolean',
+ default: false,
+ },
+ requiredText: {
+ type: 'string',
+ },
+ options: {
+ type: 'array',
+ default: [],
+ },
+ defaultValue: {
+ type: 'string',
+ default: '',
+ },
+ placeholder: {
+ type: 'string',
+ default: '',
+ },
+ id: {
+ type: 'string',
+ default: '',
+ },
+ width: {
+ type: 'number',
+ default: 100,
+ },
+ borderRadius: {
+ type: 'number',
+ default: '',
+ },
+ borderWidth: {
+ type: 'number',
+ default: '',
+ },
+ labelFontSize: {
+ type: 'string',
+ },
+ fieldFontSize: {
+ type: 'string',
+ },
+ lineHeight: {
+ type: 'number',
+ },
+ labelLineHeight: {
+ type: 'number',
+ },
+ inputColor: {
+ type: 'string',
+ },
+ labelColor: {
+ type: 'string',
+ },
+ fieldBackgroundColor: {
+ type: 'string',
+ },
+ buttonBackgroundColor: {
+ type: 'string',
+ },
+ buttonBorderRadius: {
+ type: 'number',
+ default: '',
+ },
+ buttonBorderWidth: {
+ type: 'number',
+ default: '',
+ },
+ borderColor: {
+ type: 'string',
+ },
+ shareFieldAttributes: {
+ type: 'boolean',
+ default: true,
+ },
+ },
+ supports: {
+ reusable: false,
+ html: false,
+ },
+ migrate: attributes => {
+ const { restAttributes, labelStyles, inputStyles } = deprecateFieldStyles( attributes );
+ const newInnerBlocks = [
+ createBlock( 'jetpack/label', {
+ label: attributes.label,
+ requiredText: attributes.requiredText,
+ style: labelStyles,
+ } ),
+ createBlock( 'jetpack/input', {
+ placeholder: attributes.placeholder,
+ style: inputStyles,
+ } ),
+ ];
+
+ return [ restAttributes, newInnerBlocks ];
+ },
+ isEligible: ( _attributes, innerBlocks ) => ! innerBlocks.length,
+ save: () => null,
+};
+
+export default INNER_BLOCKS_DEPRECATION;
diff --git a/projects/packages/forms/src/blocks/shared/deprecations/migrate-inner-option-blocks.js b/projects/packages/forms/src/blocks/shared/deprecations/migrate-inner-option-blocks.js
new file mode 100644
index 0000000000000..3ecb773b23def
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/deprecations/migrate-inner-option-blocks.js
@@ -0,0 +1,29 @@
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import deprecateFieldStyles from '../util/deprecate-field-styles';
+
+// Legacy choice fields used inner blocks for only individual options.
+// This function migrates that to use inner blocks for label + options,
+// moving the previous individual option blocks under the new options block.
+export default function migrateInnerOptionBlocks( attributes, innerBlocks, fieldType ) {
+ const { restAttributes, labelStyles, optionStyles } = deprecateFieldStyles( attributes );
+
+ const optionBlocks = innerBlocks.map( optionBlock =>
+ createBlock( 'jetpack/option', {
+ label: optionBlock.attributes.label,
+ defaultLabel: __( 'Add option…', 'jetpack-forms' ),
+ style: optionStyles,
+ } )
+ );
+
+ const newInnerBlocks = [
+ createBlock( 'jetpack/label', {
+ label: attributes.label,
+ requiredText: attributes.requiredText,
+ style: labelStyles,
+ } ),
+ createBlock( 'jetpack/options', { type: fieldType }, optionBlocks ),
+ ];
+
+ return [ restAttributes, newInnerBlocks ];
+}
diff --git a/projects/packages/forms/src/blocks/shared/deprecations/multiple-choice-field-deprecation.js b/projects/packages/forms/src/blocks/shared/deprecations/multiple-choice-field-deprecation.js
new file mode 100644
index 0000000000000..5addffb029924
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/deprecations/multiple-choice-field-deprecation.js
@@ -0,0 +1,48 @@
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import deprecateFieldStyles from '../util/deprecate-field-styles';
+import INNER_BLOCKS_DEPRECATION from './inner-blocks-deprecation';
+
+// Storing in variables to avoid JS mangling breaking translation calls
+const severalOptionsDefault = __( 'Choose several options', 'jetpack-forms' );
+const oneOptionDefault = __( 'Choose one option', 'jetpack-forms' );
+
+const isValidOption = value => typeof value === 'string' && value.trim().length > 0;
+
+export default function multiFieldV1( fieldType ) {
+ return {
+ attributes: {
+ ...INNER_BLOCKS_DEPRECATION.attributes,
+ label: {
+ type: 'string',
+ default: fieldType === 'checkbox' ? severalOptionsDefault : oneOptionDefault,
+ },
+ },
+ supports: INNER_BLOCKS_DEPRECATION.supports,
+ migrate: attributes => {
+ const { restAttributes, labelStyles, optionStyles } = deprecateFieldStyles( attributes );
+ const { options, ...migratedAttributes } = restAttributes;
+
+ const nonEmptyOptions = options ? options.filter( option => isValidOption( option ) ) : [];
+ const optionBlocks = nonEmptyOptions.map( option =>
+ createBlock( 'jetpack/option', {
+ label: option,
+ defaultLabel: __( 'Add option…', 'jetpack-forms' ),
+ style: optionStyles,
+ } )
+ );
+
+ const newInnerBlocks = [
+ createBlock( 'jetpack/label', {
+ label: attributes.label,
+ requiredText: attributes.requiredText,
+ style: labelStyles,
+ } ),
+ createBlock( 'jetpack/options', { type: fieldType }, optionBlocks ),
+ ];
+
+ return [ migratedAttributes, newInnerBlocks ];
+ },
+ save: () => null,
+ };
+}
diff --git a/projects/packages/forms/src/blocks/shared/hooks/use-field-selected.js b/projects/packages/forms/src/blocks/shared/hooks/use-field-selected.js
new file mode 100644
index 0000000000000..49348ea3f272f
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/hooks/use-field-selected.js
@@ -0,0 +1,15 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { useSelect } from '@wordpress/data';
+
+export default function useFieldSelected( clientId ) {
+ return useSelect(
+ select => {
+ const { getBlock, hasSelectedInnerBlock } = select( blockEditorStore );
+ return {
+ isInnerBlockSelected: hasSelectedInnerBlock( clientId, true ),
+ hasPlaceholder: !! getBlock( clientId ).innerBlocks[ 1 ]?.attributes?.placeholder,
+ };
+ },
+ [ clientId ]
+ );
+}
diff --git a/projects/packages/forms/src/blocks/shared/hooks/use-form-style.js b/projects/packages/forms/src/blocks/shared/hooks/use-form-style.js
new file mode 100644
index 0000000000000..715179fbd1743
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/hooks/use-form-style.js
@@ -0,0 +1,19 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { useSelect } from '@wordpress/data';
+import { FORM_BLOCK_NAME, FORM_STYLE } from '../util/constants';
+import getBlockStyle from '../util/get-block-style';
+
+const useFormStyle = clientId => {
+ const formBlockAttributes = useSelect( select => {
+ const [ formBlockClientId ] = select( blockEditorStore ).getBlockParentsByBlockName(
+ clientId,
+ FORM_BLOCK_NAME
+ );
+
+ return select( blockEditorStore ).getBlockAttributes( formBlockClientId );
+ } );
+
+ return getBlockStyle( formBlockAttributes?.className ) || FORM_STYLE.DEFAULT;
+};
+
+export default useFormStyle;
diff --git a/projects/packages/forms/src/blocks/shared/hooks/use-form-wrapper.js b/projects/packages/forms/src/blocks/shared/hooks/use-form-wrapper.js
new file mode 100644
index 0000000000000..c16871b738e05
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/hooks/use-form-wrapper.js
@@ -0,0 +1,34 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { createBlock } from '@wordpress/blocks';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { FORM_BLOCK_NAME } from '../util/constants';
+
+export default function useFormWrapper( { attributes, clientId, name } ) {
+ const BUTTON_BLOCK_NAME = 'jetpack/button';
+ const SUBMIT_BUTTON_ATTR = {
+ text: __( 'Submit', 'jetpack-forms' ),
+ element: 'button',
+ lock: { remove: true },
+ };
+
+ const { replaceBlock } = useDispatch( blockEditorStore );
+
+ const parents = useSelect( select => {
+ return select( blockEditorStore ).getBlockParentsByBlockName( clientId, FORM_BLOCK_NAME );
+ } );
+
+ useEffect( () => {
+ if ( ! parents?.length ) {
+ replaceBlock(
+ clientId,
+ createBlock( FORM_BLOCK_NAME, {}, [
+ createBlock( name, attributes ),
+ createBlock( BUTTON_BLOCK_NAME, SUBMIT_BUTTON_ATTR ),
+ ] )
+ );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+}
diff --git a/projects/packages/forms/src/blocks/shared/hooks/use-jetpack-field-styles.js b/projects/packages/forms/src/blocks/shared/hooks/use-jetpack-field-styles.js
new file mode 100644
index 0000000000000..e12f6eabdc281
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/hooks/use-jetpack-field-styles.js
@@ -0,0 +1,61 @@
+import { isNumber } from 'lodash';
+
+// TODO: Is this hook even needed? Do we actually want to keep this CSS var stuff?
+// Do we have to for BC only?
+
+const useJetpackFieldStyles = attributes => {
+ const blockStyle = {
+ '--jetpack--contact-form--border-color': attributes.borderColor,
+ '--jetpack--contact-form--border-radius': isNumber( attributes.borderRadius )
+ ? `${ attributes.borderRadius }px`
+ : null,
+ '--jetpack--contact-form--border-size': isNumber( attributes.borderWidth )
+ ? `${ attributes.borderWidth }px`
+ : null,
+ '--jetpack--contact-form--input-background': attributes.fieldBackgroundColor,
+ '--jetpack--contact-form--font-size': attributes.fieldFontSize,
+ '--jetpack--contact-form--line-height': attributes.lineHeight,
+ '--jetpack--contact-form--text-color': attributes.inputColor,
+ '--jetpack--contact-form--button-outline--text-color': attributes.inputColor,
+ '--jetpack--contact-form--button-outline--background-color': attributes.buttonBackgroundColor,
+ '--jetpack--contact-form--button-outline--border-radius': isNumber(
+ attributes.buttonBorderRadius
+ )
+ ? `${ attributes.buttonBorderRadius }px`
+ : null,
+ '--jetpack--contact-form--button-outline--border-size': isNumber( attributes.buttonBorderWidth )
+ ? `${ attributes.buttonBorderWidth }px`
+ : null,
+ };
+
+ const labelStyle = {
+ color: attributes.labelColor,
+ fontSize: attributes.labelFontSize,
+ lineHeight: attributes.labelLineHeight,
+ };
+
+ const fieldStyle = {
+ backgroundColor: attributes.fieldBackgroundColor,
+ borderColor: attributes.borderColor,
+ borderRadius: isNumber( attributes.borderRadius ) ? attributes.borderRadius : null,
+ borderWidth: isNumber( attributes.borderWidth ) ? attributes.borderWidth : null,
+ color: attributes.inputColor,
+ fontSize: attributes.fieldFontSize,
+ lineHeight: attributes.lineHeight,
+ };
+
+ const optionStyle = {
+ color: fieldStyle.color,
+ fontSize: fieldStyle.fontSize,
+ lineHeight: fieldStyle.lineHeight,
+ };
+
+ return {
+ blockStyle,
+ fieldStyle,
+ labelStyle,
+ optionStyle,
+ };
+};
+
+export default useJetpackFieldStyles;
diff --git a/projects/packages/forms/src/blocks/shared/settings/attributes.js b/projects/packages/forms/src/blocks/shared/settings/attributes.js
new file mode 100644
index 0000000000000..8a7e3c31d532f
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/settings/attributes.js
@@ -0,0 +1,87 @@
+// TODO: Remove all the old deprecated attributes e.g. label, options, and style attributes.
+// TODO: Fix all the defaults that shouldn't have empty strings.
+export default {
+ label: {
+ type: 'string',
+ default: null,
+ role: 'content',
+ },
+ required: {
+ type: 'boolean',
+ default: false,
+ },
+ requiredText: {
+ type: 'string',
+ role: 'content',
+ },
+ options: {
+ type: 'array',
+ default: [],
+ role: 'content',
+ },
+ defaultValue: {
+ type: 'string',
+ default: '',
+ role: 'content',
+ },
+ placeholder: {
+ type: 'string',
+ default: '',
+ role: 'content',
+ },
+ id: {
+ type: 'string',
+ default: '',
+ },
+ width: {
+ type: 'number',
+ default: 100,
+ },
+ borderRadius: {
+ type: 'number',
+ default: '',
+ },
+ borderWidth: {
+ type: 'number',
+ default: '',
+ },
+ labelFontSize: {
+ type: 'string',
+ },
+ fieldFontSize: {
+ type: 'string',
+ },
+ lineHeight: {
+ type: 'number',
+ },
+ labelLineHeight: {
+ type: 'number',
+ },
+ inputColor: {
+ type: 'string',
+ },
+ labelColor: {
+ type: 'string',
+ },
+ fieldBackgroundColor: {
+ type: 'string',
+ },
+ buttonBackgroundColor: {
+ type: 'string',
+ },
+ buttonBorderRadius: {
+ type: 'number',
+ default: '',
+ },
+ buttonBorderWidth: {
+ type: 'number',
+ default: '',
+ },
+ borderColor: {
+ type: 'string',
+ },
+ shareFieldAttributes: {
+ type: 'boolean',
+ default: true,
+ },
+};
diff --git a/projects/packages/forms/src/blocks/shared/settings/index.js b/projects/packages/forms/src/blocks/shared/settings/index.js
new file mode 100644
index 0000000000000..d2320f8afac36
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/settings/index.js
@@ -0,0 +1,17 @@
+import attributes from './attributes';
+import transforms from './transforms';
+
+export default {
+ apiVersion: 3,
+ attributes,
+ category: 'contact-form',
+ example: {},
+ providesContext: { 'jetpack/field-required': 'required' },
+ save: () => null,
+ supports: {
+ reusable: false,
+ html: false,
+ __experimentalExposeControlsToChildren: true,
+ },
+ transforms,
+};
diff --git a/projects/packages/forms/src/blocks/shared/settings/transforms.js b/projects/packages/forms/src/blocks/shared/settings/transforms.js
new file mode 100644
index 0000000000000..c9750361f67ee
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/settings/transforms.js
@@ -0,0 +1,2 @@
+// TODO: Implement transforms when final structure of each field is stable.
+export default {};
diff --git a/projects/packages/forms/src/blocks/shared/util/caret.js b/projects/packages/forms/src/blocks/shared/util/caret.js
new file mode 100644
index 0000000000000..2a7617ace5073
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/util/caret.js
@@ -0,0 +1,44 @@
+/**
+ * Get the caret position in an active contenteditable element
+ * From https://gist.github.com/loilo/f873a88631e660c59a1d5ab757ca9b1e
+ *
+ * @param {HTMLElement} target - Contenteditable element of which to get the caret position
+ * @return {number} The caret position
+ */
+export const getCaretPosition = target => {
+ const sel = target.ownerDocument.defaultView.getSelection();
+
+ if ( sel.rangeCount === 0 ) {
+ return 0;
+ }
+
+ const range = sel.getRangeAt( 0 );
+
+ const preCaretRange = range.cloneRange();
+ preCaretRange.selectNodeContents( target );
+ preCaretRange.setEnd( range.endContainer, range.endOffset );
+
+ return preCaretRange.toString().length;
+};
+
+/**
+ * Move the caret position in an active contenteditable element to the end
+ *
+ * @param {HTMLElement} target - Contenteditable element of which to move the caret
+ */
+export const moveCaretToEnd = target => {
+ const doc = target.ownerDocument;
+ if ( 'undefined' === typeof doc ) {
+ return;
+ }
+
+ // Add the contenteditable element to a new selection and collapse it to the end
+ const range = doc.createRange();
+ range.selectNodeContents( target );
+ range.collapse( false );
+
+ // Clear the window selection object and add the new selection
+ const selection = doc.getSelection();
+ selection.removeAllRanges();
+ selection.addRange( range );
+};
diff --git a/projects/packages/forms/src/blocks/shared/util/clean-empty-object.js b/projects/packages/forms/src/blocks/shared/util/clean-empty-object.js
new file mode 100644
index 0000000000000..511e207b841a7
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/util/clean-empty-object.js
@@ -0,0 +1,18 @@
+/**
+ * Removed falsy values from nested object.
+ *
+ * @param {*} object - Object to be cleaned.
+ * @return {*} Object cleaned from falsy values.
+ */
+const cleanEmptyObject = object => {
+ if ( object === null || typeof object !== 'object' || Array.isArray( object ) ) {
+ return object;
+ }
+
+ const cleanedNestedObjects = Object.entries( object )
+ .map( ( [ key, value ] ) => [ key, cleanEmptyObject( value ) ] )
+ .filter( ( [ , value ] ) => value !== undefined );
+ return ! cleanedNestedObjects.length ? undefined : Object.fromEntries( cleanedNestedObjects );
+};
+
+export default cleanEmptyObject;
diff --git a/projects/packages/forms/src/blocks/shared/util/deprecate-field-styles.js b/projects/packages/forms/src/blocks/shared/util/deprecate-field-styles.js
new file mode 100644
index 0000000000000..42855815c9104
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/util/deprecate-field-styles.js
@@ -0,0 +1,59 @@
+import cleanEmptyObject from './clean-empty-object';
+
+const deprecateFieldStyles = attributes => {
+ const {
+ borderColor,
+ borderRadius,
+ borderWidth,
+ fieldBackgroundColor,
+ fieldFontSize,
+ inputColor,
+ labelColor,
+ labelFontSize,
+ labelLineHeight,
+ lineHeight,
+ placeholder,
+ ...restAttributes
+ } = attributes;
+
+ const labelStyles = cleanEmptyObject( {
+ color: { text: labelColor },
+ typography: {
+ fontSize: labelFontSize,
+ lineHeight: labelLineHeight,
+ },
+ } );
+
+ const inputStyles = cleanEmptyObject( {
+ border: {
+ color: borderColor,
+ radius: borderRadius ? `${ borderRadius }px` : undefined,
+ style: 'solid',
+ width: borderWidth,
+ },
+ color: {
+ text: inputColor,
+ background: fieldBackgroundColor,
+ },
+ typography: {
+ fontSize: fieldFontSize,
+ lineHeight: lineHeight,
+ },
+ } );
+
+ // Note: Legacy field blocks reused the input styles for options rather than
+ // label styles despite the underlying `label` element for options.
+ const optionStyles = cleanEmptyObject( {
+ color: {
+ text: inputColor,
+ },
+ typography: {
+ fontSize: fieldFontSize,
+ lineHeight: lineHeight,
+ },
+ } );
+
+ return { inputStyles, labelStyles, optionStyles, restAttributes };
+};
+
+export default deprecateFieldStyles;
diff --git a/projects/packages/forms/src/blocks/shared/util/set-focus.js b/projects/packages/forms/src/blocks/shared/util/set-focus.js
new file mode 100644
index 0000000000000..2cf9f8263a4b1
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/util/set-focus.js
@@ -0,0 +1,25 @@
+// TODO: Get rid of lodash use here.
+import { tap } from 'lodash';
+
+export default function setFocus( wrapper, selector, index, cursorToEnd ) {
+ setTimeout( () => {
+ tap( wrapper.querySelectorAll( selector )[ index ], input => {
+ if ( ! input ) {
+ return;
+ }
+
+ input.focus();
+
+ // Allows moving the cursor to the end of
+ // 'contenteditable' elements like
+ if ( document.createRange && cursorToEnd ) {
+ const range = document.createRange();
+ range.selectNodeContents( input );
+ range.collapse( false );
+ const selection = document.defaultView.getSelection();
+ selection.removeAllRanges();
+ selection.addRange( range );
+ }
+ } );
+ }, 0 );
+}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-field.php b/projects/packages/forms/src/contact-form/class-contact-form-field.php
index 3a0af3d981ad7..794382078e080 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-field.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-field.php
@@ -51,6 +51,13 @@ class Contact_Form_Field extends Contact_Form_Shortcode {
*/
public $block_styles = '';
+ /**
+ * Classes to be applied to the field
+ *
+ * @var string
+ */
+ public $field_classes = '';
+
/**
* Styles to be applied to the field
*
@@ -58,6 +65,13 @@ class Contact_Form_Field extends Contact_Form_Shortcode {
*/
public $field_styles = '';
+ /**
+ * Classes to be applied to the field option
+ *
+ * @var string
+ */
+ public $option_classes = '';
+
/**
* Styles to be applied to the field option
*
@@ -65,6 +79,13 @@ class Contact_Form_Field extends Contact_Form_Shortcode {
*/
public $option_styles = '';
+ /**
+ * Classes to be applied to the field
+ *
+ * @var string
+ */
+ public $label_classes = '';
+
/**
* Styles to be applied to the field
*
@@ -88,6 +109,7 @@ public function __construct( $attributes, $content = null, $form = null ) {
'required' => false,
'requiredtext' => null,
'options' => array(),
+ 'optionsdata' => array(),
'id' => null,
'style' => null,
'fieldbackgroundcolor' => null,
@@ -113,6 +135,12 @@ public function __construct( $attributes, $content = null, $form = null ) {
'labelcolor' => null,
'labelfontsize' => null,
'fieldfontsize' => null,
+ 'labelclasses' => null,
+ 'labelstyles' => null,
+ 'inputclasses' => null,
+ 'inputstyles' => null,
+ 'optionclasses' => null,
+ 'optionstyles' => null,
'min' => null,
'max' => null,
'maxfiles' => null,
@@ -141,11 +169,17 @@ public function __construct( $attributes, $content = null, $form = null ) {
if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
+ // TODO: Work out where these values are set? Are they actually set anywhere?
if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
}
}
+ // TODO: Is this the right place to decode the options data for inner block based choice fields?
+ if ( ! empty( $attributes['optionsdata'] ) ) {
+ $attributes['optionsdata'] = json_decode( html_entity_decode( $attributes['optionsdata'] ), true );
+ }
+
if ( $form ) {
// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
$form_id = $form->get_attribute( 'id' );
@@ -301,33 +335,94 @@ public function render() {
$field_width = $this->get_attribute( 'width' );
$class = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
- if ( is_numeric( $this->get_attribute( 'borderradius' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--border-radius: ' . esc_attr( $this->get_attribute( 'borderradius' ) ) . 'px;';
- $this->field_styles .= 'border-radius: ' . (int) $this->get_attribute( 'borderradius' ) . 'px;';
- }
- if ( is_numeric( $this->get_attribute( 'borderwidth' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--border-size: ' . esc_attr( $this->get_attribute( 'borderwidth' ) ) . 'px;';
- $this->field_styles .= 'border-width: ' . (int) $this->get_attribute( 'borderwidth' ) . 'px;';
- }
- if ( is_numeric( $this->get_attribute( 'lineheight' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--line-height: ' . esc_attr( $this->get_attribute( 'lineheight' ) ) . ';';
- $this->field_styles .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
- $this->option_styles .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
- }
- if ( ! empty( $this->get_attribute( 'bordercolor' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
- $this->field_styles .= 'border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
- }
- if ( ! empty( $this->get_attribute( 'inputcolor' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
- $this->block_styles .= '--jetpack--contact-form--button-outline--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
- $this->field_styles .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
- $this->option_styles .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
- }
- if ( ! empty( $this->get_attribute( 'fieldbackgroundcolor' ) ) ) {
- $this->block_styles .= '--jetpack--contact-form--input-background: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
- $this->field_styles .= 'background-color: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
+ $label_classes = $this->get_attribute( 'labelclasses' );
+ $label_styles = $this->get_attribute( 'labelstyles' );
+ $input_classes = $this->get_attribute( 'inputclasses' );
+ $input_styles = $this->get_attribute( 'inputstyles' );
+ $option_classes = $this->get_attribute( 'optionclasses' );
+ $option_styles = $this->get_attribute( 'optionstyles' );
+
+ $has_block_support_styles = ! empty( $label_classes ) || ! empty( $label_styles ) || ! empty( $input_classes ) || ! empty( $input_styles ) || ! empty( $option_classes ) || ! empty( $option_styles );
+
+ if ( $has_block_support_styles ) {
+ // Do any of the block support classes need to be applied at the field wrapper level? Do we need to make the classes etc filterable as per the field classes?
+
+ // Classes.
+ if ( ! empty( $label_classes ) ) {
+ $this->label_classes .= esc_attr( $label_classes );
+ }
+ if ( ! empty( $input_classes ) ) {
+ $class .= $class ? ' ' . esc_attr( $input_classes ) : esc_attr( $input_classes );
+ $this->field_classes = $input_classes;
+ }
+ if ( ! empty( $option_classes ) ) {
+ $class .= $class ? ' ' . esc_attr( $option_classes ) : esc_attr( $option_classes );
+ $this->option_classes = $option_classes;
+ }
+
+ // Styles.
+ if ( ! empty( $label_styles ) ) {
+ $this->label_styles .= esc_attr( $label_styles );
+ }
+ if ( ! empty( $input_styles ) ) {
+ $this->field_styles .= esc_attr( $input_styles );
+ }
+ if ( ! empty( $option_styles ) ) {
+ $this->option_styles .= esc_attr( $option_styles );
+ }
+ } else {
+ if ( is_numeric( $this->get_attribute( 'borderradius' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--border-radius: ' . esc_attr( $this->get_attribute( 'borderradius' ) ) . 'px;';
+ $this->field_styles .= 'border-radius: ' . (int) $this->get_attribute( 'borderradius' ) . 'px;';
+ }
+
+ if ( is_numeric( $this->get_attribute( 'borderwidth' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--border-size: ' . esc_attr( $this->get_attribute( 'borderwidth' ) ) . 'px;';
+ $this->field_styles .= 'border-width: ' . (int) $this->get_attribute( 'borderwidth' ) . 'px;';
+ }
+
+ if ( is_numeric( $this->get_attribute( 'lineheight' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--line-height: ' . esc_attr( $this->get_attribute( 'lineheight' ) ) . ';';
+ $this->field_styles .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
+ $this->option_styles .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'bordercolor' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
+ $this->field_styles .= 'border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'inputcolor' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
+ $this->block_styles .= '--jetpack--contact-form--button-outline--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
+ $this->field_styles .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
+ $this->option_styles .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'fieldbackgroundcolor' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--input-background: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
+ $this->field_styles .= 'background-color: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'fieldfontsize' ) ) ) {
+ $this->block_styles .= '--jetpack--contact-form--font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
+ $this->field_styles .= 'font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
+ $this->option_styles .= 'font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'labelcolor' ) ) ) {
+ $this->label_styles .= 'color: ' . esc_attr( $this->get_attribute( 'labelcolor' ) ) . ';';
+ }
+
+ if ( ! empty( $this->get_attribute( 'labelfontsize' ) ) ) {
+ $this->label_styles .= 'font-size: ' . esc_attr( $this->get_attribute( 'labelfontsize' ) ) . ';';
+ }
+
+ if ( is_numeric( $this->get_attribute( 'labellineheight' ) ) ) {
+ $this->label_styles .= 'line-height: ' . (int) $this->get_attribute( 'labellineheight' ) . ';';
+ }
}
+
if ( ! empty( $this->get_attribute( 'buttonbackgroundcolor' ) ) ) {
$this->block_styles .= '--jetpack--contact-form--button-outline--background-color: ' . esc_attr( $this->get_attribute( 'buttonbackgroundcolor' ) ) . ';';
}
@@ -483,6 +578,13 @@ public function render_label( $type, $id, $label, $required, $required_field_tex
$extra_attrs['style'] = $this->label_styles;
}
+ $type_class = $type ? ' ' . $type : '';
+ $extra_attrs['class'] = "grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' );
+
+ if ( ! empty( $this->label_classes ) ) {
+ $extra_attrs['class'] .= ' ' . $this->label_classes;
+ }
+
$extra_attrs_string = '';
if ( is_array( $extra_attrs ) && ! empty( $extra_attrs ) ) {
foreach ( $extra_attrs as $attr => $val ) {
@@ -492,8 +594,7 @@ public function render_label( $type, $id, $label, $required, $required_field_tex
$type_class = $type ? ' ' . $type : '';
return "