diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0f135..632e149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.4.0 +* Added: Custom separated date fields (read [the announcement for more information](https://epiph.yt/en/blog/2024/form-block-1-4-0-release-and-opinions-on-date-pickers/)) +* Added: All supported input types that were previously only part of the Pro version +* Added: Design for Twenty Twenty-Four +* Added: More recognized field names for the form wizard +* Improved: Input type selection is now more descriptive and translatable +* Fixed: `aria-describedby` for error fields is no more added multiple times +* Fixed: Form wizard now returns the proper input fields + ## 1.3.0 * Added: Support block settings like font size, line height and dimensions * Added: By selecting an invalid field, the error message will now be announced to screen readers diff --git a/README.md b/README.md index 6b7715d..85db31d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ The code is open source and hosted on [GitHub](https://github.com/epiphyt/form-b We are [Epiphyt](https://epiph.yt/), your friendly neighborhood WordPress plugin shop from southern Germany. +## Security + +For security related information, please consult the [security policy](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md index 0885776..e08fe13 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,11 +9,6 @@ We usually only support the latest major version. | 1.3.x | :white_check_mark: | | < 1.3 | :x: | -## Reporting a Vulnerability +## How can I report security bugs? -Please report any vulnerability via . You should receive -an answer within 24 hours. You will be informed about if the vulnerability is -accepted or declined. If accepted and fixed, we will thank you in the changelog. - -If desired, we can also mention you in any other channel we use to announce an -update, e.g. in a blog post or via Mastodon/Twitter. +You can report security bugs through the Patchstack Vulnerability Disclosure Program. The Patchstack team help validate, triage and handle any security vulnerabilities. [Report a security vulnerability.](https://patchstack.com/database/vdp/form-block) diff --git a/assets/js/form.js b/assets/js/form.js index 6d515a0..2f794f8 100644 --- a/assets/js/form.js +++ b/assets/js/form.js @@ -204,6 +204,7 @@ document.addEventListener( 'DOMContentLoaded', () => { * @param {Boolean} isHtml Whether the message is raw HTML */ function setSubmitMessage( form, messageType, message, isHtml ) { + const ariaLiveType = messageType === 'error' ? 'assertive' : 'polite'; let messageContainer = form.querySelector( '.form-block__message-container' ); if ( ! messageContainer ) { @@ -220,7 +221,7 @@ document.addEventListener( 'DOMContentLoaded', () => { messageContainer.textContent = message; // then replace all newlines with
messageContainer.innerHTML = nl2br( messageContainer.innerHTML ); - messageContainer.setAttribute( 'aria-live', 'assertive' ); + messageContainer.setAttribute( 'aria-live', ariaLiveType ); if ( isHtml ) { messageContainer.innerHTML = message; diff --git a/assets/js/multi-field.js b/assets/js/multi-field.js new file mode 100644 index 0000000..22d2521 --- /dev/null +++ b/assets/js/multi-field.js @@ -0,0 +1,102 @@ +document.addEventListener( 'DOMContentLoaded', () => { + const multiFieldInputs = document.querySelectorAll( '.form-block__element.is-sub-element input[data-max-length]' ); + + for ( const multiFieldInput of multiFieldInputs ) { + multiFieldInput.addEventListener( 'input', onInput ); + multiFieldInput.addEventListener( 'paste', handlePaste ); + } +} ); + +const onInput = ( event ) => addLeadingZero( event.currentTarget ); + +/** + * Add leading zeros to an element. + * + * @see https://stackoverflow.com/a/72864152/3461955 + * + * @param {HTMLElement} element The element to add zeros to + * @param {string} [attribute='data-max-length'] The attribute to check for + */ +function addLeadingZero( element, attribute = 'data-max-length' ) { + const maxLength = parseInt( element.getAttribute( attribute ) ); + const isNegative = parseInt( element.value ) < 0 + let newValue = ( '0'.repeat( maxLength ) + Math.abs( element.value ).toString() ).slice( -maxLength ); + + if ( isNegative ) { + newValue = '-' + newValue; + } + + element.value = newValue; +} + +/** + * Handle pasting into custom date fields. + * + * @param {Event} event Paste event + */ +function handlePaste( event ) { + const currentTarget = event.currentTarget; + const isFirstInput = !! currentTarget.closest( '.form-block__element.is-sub-element:first-of-type' ); + + if ( ! isFirstInput ) { + return; + } + + const container = currentTarget.closest( '.form-block__element:not(.is-sub-element)' ); + const inputs = container.querySelectorAll( 'input' ); + const format = getFormat( inputs ); + const paste = ( event.clipboardData || event.originalEvent.clipboardData || window.clipboardData ).getData( 'text' ) || ''; + const matches = paste.match( new RegExp( format ) ); + + if ( matches ) { + event.preventDefault(); + + for ( let i = 0; i < inputs.length; i++ ) { + inputs[ i ].value = matches[ 2 * i + 1 ]; + } + } +} + +/** + * Get regular expression format from a pasted string. + * + * @param {HTMLCollection} inputs List of inputs + * @returns {string} Regular expression string + */ +function getFormat( inputs ) { + let isFirst = true; + let format = '^'; + + const escape = ( string, symbol ) => { + let newString; + + for ( let i = 0; i < string.length; i++ ) { + newString = symbol + string.charAt( i ); + } + + return newString; + } + + for ( const input of inputs ) { + if ( ! isFirst ) { + if ( input.previousElementSibling ) { + format += ' ' + input.previousElementSibling.textContent + ' '; + } + } + else { + isFirst = false; + } + + format += '([0-9]{' + input.getAttribute( 'data-validate-length-range' ) + '})'; + format += '('; + + if ( input.nextElementSibling ) { + format += escape( input.nextElementSibling.textContent, '\\' ); + } + } + + format = format.replace( /\($/, '' ); + format += '?)'.repeat( inputs.length ); + + return format.replace( /\)$/, '' ); +} diff --git a/assets/js/validation.js b/assets/js/validation.js index d897bc7..a7ad79a 100644 --- a/assets/js/validation.js +++ b/assets/js/validation.js @@ -96,6 +96,46 @@ FormValidator.prototype.tests.url = function( field, data ) { return this.texts.url; }; +const adjustMultiFieldErrors = ( data ) => { + const parentField = data.field.closest( '.form-block__element' ); + let innerError = document.getElementById( data.field.id + '__inline-error' ); + + if ( innerError ) { + innerError.remove(); + } + + if ( data.valid ) { + data.field.removeAttribute( 'aria-invalid' ); + + return; + } + + const adjacentField = parentField.closest( '.form-block__element:not(.is-sub-element)' ); + innerError = document.createElement( 'div' ); + const labelContent = parentField.querySelector( '.form-block__label-content' ).textContent; + innerError.id = data.field.id + '__inline-error'; + innerError.textContent = labelContent + ': ' + data.error; + innerError.classList.add( 'inline-error' ); + parentField.classList.add( 'form-error' ); + adjacentField.classList.add( 'form-error' ); + adjacentField.appendChild( innerError ); + setAriaDescribedBy( data.field, adjacentField ); + data.field.closest( '.form-block__element' ).querySelector( '.inline-error' ).remove(); + data.field.ariaInvalid = true; + +} + +const setAriaDescribedBy = ( field, parentField ) => { + const fieldToExtend = parentField || field; + const errorId = field.id + '__inline-error'; + const innerError = document.getElementById( errorId ) || fieldToExtend.parentNode.querySelector( '.inline-error:not([id])' ); + innerError.id = errorId; + + if ( ! field.hasAttribute( 'aria-describedby' ) || ! field.getAttribute( 'aria-describedby' ).includes( errorId ) ) { + field.setAttribute( 'aria-describedby', ( ( field.getAttribute( 'aria-describedby' ) || '' ) + ' ' + errorId ).trim() ); + } +} + const validator = new FormValidator( { classes: { alert: 'inline-error', @@ -125,13 +165,6 @@ document.addEventListener( 'DOMContentLoaded', function() { const forms = document.querySelectorAll( '.wp-block-form-block-form' ); let typingTimeout; - const setAriaDescribedBy = ( field ) => { - const innerError = field.parentNode.querySelector( '.inline-error' ); - console.log( innerError ); - innerError.id = field.id + '__inline-error'; - field.setAttribute( 'aria-describedby', ( ( field.getAttribute( 'aria-describedby' ) || '' ) + ' ' + innerError.id ).trim() ); - } - for ( const form of forms ) { form.validator = validator; @@ -155,6 +188,14 @@ document.addEventListener( 'DOMContentLoaded', function() { } } + if ( event.target.closest( '.form-block__input-group' ) ) { + let data = validator.checkField( event.target ); + data.field = event.target; + adjustMultiFieldErrors( data ); + + return; + } + const container = event.target.closest( '[class^="wp-block-form-block-"]' ); if ( container && result.valid ) { @@ -185,8 +226,13 @@ document.addEventListener( 'DOMContentLoaded', function() { let invalidFields = []; const validatorResult = validator.checkAll( this ); - validatorResult.fields.forEach( function( field, index, array ) { - if ( field.field.type !== 'file' ) { + validatorResult.fields.reverse().forEach( function( field, index, array ) { + if ( field.field.closest( '.form-block__input-group' ) ) { + adjustMultiFieldErrors( field ); + + return; + } + else if ( field.field.type !== 'file' ) { if ( ! field.valid ) { setAriaDescribedBy( field.field ); invalidFields.push( field ); @@ -245,7 +291,7 @@ document.addEventListener( 'DOMContentLoaded', function() { 'is-error-notice', 'screen-reader-text', ); - invalidFieldNotice.setAttribute( 'aria-live', 'assertive' ); + invalidFieldNotice.ariaLive = 'assertive'; form.appendChild( invalidFieldNotice ); } @@ -319,6 +365,10 @@ document.addEventListener( 'DOMContentLoaded', function() { if ( formErrors ) { for ( const formError of formErrors ) { + if ( formError.classList.contains( 'has-sub-elements' ) ) { + continue; + } + if ( formError.classList.contains( 'wp-block-form-block-input' ) ) { formError.querySelector( 'input' ).ariaInvalid = true; } diff --git a/assets/style/form.scss b/assets/style/form.scss index e63b714..4a2337c 100644 --- a/assets/style/form.scss +++ b/assets/style/form.scss @@ -12,6 +12,10 @@ flex: 0 0 100%; } + .form-block__element { + margin-bottom: 0; + } + &.is-type-checkbox, &.is-type-radio { align-items: flex-start; @@ -32,6 +36,37 @@ } } +.form-block__input-container { + align-items: center; + column-gap: 8px; + display: flex; + + > .form-block__source { + max-width: 75px; + + .is-sub-type-year & { + max-width: 120px; + } + } +} + +.form-block__input-group { + border: 0; + column-gap: 8px; + display: flex; + flex-wrap: wrap; + padding: 0; + + > legend { + flex: 0 0 100%; + padding: 0; + } + + > .form-block__element:first-of-type .form-block__date-custom--separator.is-before { + display: none; + } +} + .form-block__message-container { &.is-type-loading { align-items: center; diff --git a/assets/style/twenty-twenty-four.scss b/assets/style/twenty-twenty-four.scss new file mode 100644 index 0000000..e90596b --- /dev/null +++ b/assets/style/twenty-twenty-four.scss @@ -0,0 +1,44 @@ +.form-block__element { + input:not([type="reset"]):not([type="submit"]), + textarea { + border: 1px solid #949494; + font-family: inherit; + font-size: 1em; + } + + input:not([type="checkbox"]):not([type="radio"]):not([type="reset"]):not([type="submit"]), + textarea { + box-sizing: border-box; + display: block; + padding: calc(.667em + 2px); + width: 100%; + } + + input[type="reset"], + input[type="submit"] { + align-self: flex-start; + } + + select { + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + background: url("data:image/svg+xml;utf8,") no-repeat right calc(.667em + 2px) top 56%; + border: 1px solid #949494; + border-radius: 0; + font-size: 1em; + line-height: 1.6; + padding: calc(.667em + 2px) calc(1.333em + 12px) calc(.667em + 2px) calc(.667em + 2px); + } + + &.is-type-checkbox, + &.is-type-radio { + input { + margin-top: .35em; + } + + .form-block__label { + margin-left: .3em; + } + } +} diff --git a/form-block.php b/form-block.php index 7f12751..fd56c58 100644 --- a/form-block.php +++ b/form-block.php @@ -18,7 +18,7 @@ Plugin Name: Form Block Plugin URI: https://formblock.pro/en/ Description: An extensive yet user-friendly form block. -Version: 1.3.0 +Version: 1.4.0 Author: Epiphyt Author URI: https://epiph.yt License: GPL2 @@ -43,7 +43,7 @@ // exit if ABSPATH is not defined defined( 'ABSPATH' ) || exit; -define( 'FORM_BLOCK_VERSION', '1.3.0' ); +define( 'FORM_BLOCK_VERSION', '1.4.0' ); if ( ! defined( 'EPI_FORM_BLOCK_BASE' ) ) { define( 'EPI_FORM_BLOCK_BASE', WP_PLUGIN_DIR . '/form-block/' ); diff --git a/inc/blocks/class-form.php b/inc/blocks/class-form.php index dea683b..9943da1 100644 --- a/inc/blocks/class-form.php +++ b/inc/blocks/class-form.php @@ -263,6 +263,11 @@ public static function register_block(): void { public function register_frontend_assets(): void { $is_debug = defined( 'WP_DEBUG' ) && WP_DEBUG; $suffix = ( $is_debug ? '' : '.min' ); + $file_path = \plugin_dir_path( \EPI_FORM_BLOCK_FILE ) . 'assets/js/' . ( $is_debug ? '' : 'build/' ) . 'multi-field' . $suffix . '.js'; + $file_url = \plugin_dir_url( \EPI_FORM_BLOCK_FILE ) . 'assets/js/' . ( $is_debug ? '' : 'build/' ) . 'multi-field' . $suffix . '.js'; + + \wp_register_script( 'form-block-multi-field', $file_url, [ 'form-block-form' ], $is_debug ? \filemtime( $file_path ) : \FORM_BLOCK_VERSION, true ); + $file_path = plugin_dir_path( EPI_FORM_BLOCK_FILE ) . 'assets/js/vendor/validator' . $suffix . '.js'; $file_url = plugin_dir_url( EPI_FORM_BLOCK_FILE ) . 'assets/js/vendor/validator' . $suffix . '.js'; diff --git a/inc/class-admin.php b/inc/class-admin.php index 4282cd9..886c554 100644 --- a/inc/class-admin.php +++ b/inc/class-admin.php @@ -3,11 +3,6 @@ namespace epiphyt\Form_Block; use function add_action; -use function plugin_dir_path; -use function plugin_dir_url; -use function wp_enqueue_script; -use function wp_enqueue_style; -use const EPI_FORM_BLOCK_FILE; /** * Form Block admin class. diff --git a/inc/class-form-block.php b/inc/class-form-block.php index 3acdff7..360f4c3 100644 --- a/inc/class-form-block.php +++ b/inc/class-form-block.php @@ -2,6 +2,7 @@ namespace epiphyt\Form_Block; use DOMDocument; +use epiphyt\Form_Block\modules\Custom_Date; use epiphyt\Form_Block\block_data\Data as Block_Data_Data; use epiphyt\Form_Block\blocks\Form; use epiphyt\Form_Block\blocks\Input; @@ -19,6 +20,13 @@ final class Form_Block { const MAX_INT = 2147483647; + /** + * @var array Registered Modules + */ + public array $modules = [ + Custom_Date::class, + ]; + /** * @var array List of block name attributes */ @@ -46,6 +54,11 @@ public function init(): void { Input::get_instance()->init(); Select::get_instance()->init(); Textarea::get_instance()->init(); + + foreach ( $this->modules as $key => $asset ) { + $this->modules[ $key ] = new $asset(); + $this->modules[ $key ]->init(); + } } /** diff --git a/inc/class-theme-styles.php b/inc/class-theme-styles.php index 16364ee..821d9df 100644 --- a/inc/class-theme-styles.php +++ b/inc/class-theme-styles.php @@ -61,7 +61,10 @@ public function is_theme( $name ) { * @return array Updated block styles */ public function register_block_styles( array $styles ): array { - if ( $this->is_theme( 'Twenty Twenty-Three' ) ) { + if ( $this->is_theme( 'Twenty Twenty-Four' ) ) { + $styles[] = 'form-block-twenty-twenty-four'; + } + else if ( $this->is_theme( 'Twenty Twenty-Three' ) ) { $styles[] = 'form-block-twenty-twenty-three'; } else if ( $this->is_theme( 'Twenty Twenty-Two' ) ) { @@ -78,7 +81,13 @@ public function register_styles(): void { $is_debug = defined( 'WP_DEBUG' ) && WP_DEBUG; $suffix = ( $is_debug ? '' : '.min' ); - if ( $this->is_theme( 'Twenty Twenty-Three' ) ) { + if ( $this->is_theme( 'Twenty Twenty-Four' ) ) { + $file_path = \plugin_dir_path( EPI_FORM_BLOCK_FILE ) . 'assets/style/build/twenty-twenty-four' . $suffix . '.css'; + $file_url = \plugin_dir_url( EPI_FORM_BLOCK_FILE ) . 'assets/style/build/twenty-twenty-four' . $suffix . '.css'; + + \wp_register_style( 'form-block-twenty-twenty-four', $file_url, [ 'form-block' ], $is_debug ? \filemtime( $file_path ) : \FORM_BLOCK_VERSION ); + } + else if ( $this->is_theme( 'Twenty Twenty-Three' ) ) { $file_path = plugin_dir_path( EPI_FORM_BLOCK_FILE ) . 'assets/style/build/twenty-twenty-three' . $suffix . '.css'; $file_url = plugin_dir_url( EPI_FORM_BLOCK_FILE ) . 'assets/style/build/twenty-twenty-three' . $suffix . '.css'; diff --git a/inc/form-data/class-validation.php b/inc/form-data/class-validation.php index 16dc9b4..ada046a 100644 --- a/inc/form-data/class-validation.php +++ b/inc/form-data/class-validation.php @@ -92,7 +92,12 @@ private function by_attributes( $value, array $attributes ): array { $validated = sanitize_textarea_field( $value ); break; default: - $validated = sanitize_text_field( $value ); + if ( \is_array( $value ) ) { + $validated = \array_map( 'sanitize_text_field', $value ); + } + else { + $validated = sanitize_text_field( $value ); + } break; } diff --git a/inc/modules/class-custom-date.php b/inc/modules/class-custom-date.php new file mode 100644 index 0000000..bf63058 --- /dev/null +++ b/inc/modules/class-custom-date.php @@ -0,0 +1,365 @@ + $field ) { + $container = $dom->createElement( 'div' ); + $input_container = $dom->createElement( 'div' ); + $input_node = $dom->createElement( 'input' ); + $label_classes = 'form-block__label is-input-label'; + $label_content_node = $dom->createElement( 'span', $field['label'] ); + $label_node = $dom->createElement( 'label' ); + $separators = []; + + foreach ( $field['separator'] as $position => $value ) { + if ( ! empty( $value ) ) { + $separators[ $position ] = $dom->createElement( 'span' ); + $separators[ $position ]->setAttribute( 'class', 'form-block__date-custom--separator is-' . $position ); + $separators[ $position ]->textContent = $value; + } + } + + $input_node->setAttribute( 'class', $field_data['class'] ); + $input_node->setAttribute( 'data-max-length', $field['validation']['max-length'] ); + $input_node->setAttribute( 'data-type', $type ); + $input_node->setAttribute( 'data-validate-length-range', $field['validation']['min-length'] . ',' . $field['validation']['max-length'] ); + $input_node->setAttribute( 'data-validate-minmax', $field['validation']['min'] . ',' . $field['validation']['max'] ); + $input_node->setAttribute( 'id', $field_data['id'] . '-' . $type ); + $input_node->setAttribute( 'max', $field['validation']['max'] ); + $input_node->setAttribute( 'min', $field['validation']['min'] ); + $input_node->setAttribute( 'name', $field_data['name'] . '[' . $type . ']' ); + $input_node->setAttribute( 'type', 'number' ); + $input_node->setAttribute( 'value', $block_data['value'][ $type ] ?? '' ); + + if ( $field_data['is_required'] ) { + $input_node->setAttribute( 'required', '' ); + } + + if ( $block_data['showPlaceholder'] ) { + $input_node->setAttribute( 'placeholder', $field['placeholder'] ); + } + + if ( empty( $block_data['showLabel'] ) ) { + $label_classes .= ' screen-reader-text'; + } + + $container->setAttribute( 'class', 'form-block__element is-sub-element is-type-text is-sub-type-' . $type ); + $input_container->setAttribute( 'class', 'form-block__input-container' ); + $label_content_node->setAttribute( 'class', 'form-block__label-content' ); + $label_node->setAttribute( 'class', $label_classes ); + $label_node->setAttribute( 'for', $field_data['id'] . '-' . $type ); + $label_node->appendChild( $label_content_node ); + + if ( ! empty( $separators['before'] ) ) { + $input_container->appendChild( $separators['before'] ); + } + + $input_container->appendChild( $input_node ); + + if ( ! empty( $separators['after'] ) ) { + $input_container->appendChild( $separators['after'] ); + } + + $container->appendChild( $input_container ); + $container->appendChild( $label_node ); + $element->appendChild( $container ); + } + } + + /** + * Enqueue editor assets. + */ + public static function enqueue_editor_assets(): void { + \wp_localize_script( 'form-block-input-editor-script', 'formBlockInputCustomDate', self::get_field_data() ); + } + + /** + * Get field data. + * + * @param array $order Field data order + * @return array Field data + */ + public static function get_field_data( array $order = [] ): array { + $fields = [ + 'day' => [ + 'label' => \__( 'Day', 'form-block' ), + 'placeholder' => \_x( 'DD', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => \_x( '/', 'date separator', 'form-block' ), + 'before' => '', + ], + 'validation' => [ + 'max' => 31, + 'max-length' => 2, + 'min' => 1, + 'min-length' => 2, + 'type' => 'numeric', + ], + ], + 'hour' => [ + 'label' => \__( 'Hours', 'form-block' ), + 'placeholder' => \_x( 'HH', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => \_x( ':', 'time separator', 'form-block' ), + 'before' => \_x( 'at', 'date and time separator', 'form-block' ), + ], + 'validation' => [ + 'max' => 24, + 'max-length' => 2, + 'min' => 0, + 'min-length' => 2, + 'type' => 'numeric', + ], + ], + 'minute' => [ + 'label' => \__( 'Minutes', 'form-block' ), + 'placeholder' => \_x( 'MM', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => '', + 'before' => '', + ], + 'validation' => [ + 'max' => 59, + 'max-length' => 2, + 'min' => 0, + 'min-length' => 2, + 'type' => 'numeric', + ], + ], + 'month' => [ + 'label' => \__( 'Month', 'form-block' ), + 'placeholder' => \_x( 'MM', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => \_x( '/', 'date separator', 'form-block' ), + 'before' => '', + ], + 'validation' => [ + 'max' => 12, + 'max-length' => 2, + 'min' => 1, + 'min-length' => 2, + 'type' => 'numeric', + ], + ], + 'week' => [ + 'label' => \__( 'Week', 'form-block' ), + 'placeholder' => \_x( 'WK', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => \_x( '/', 'date separator', 'form-block' ), + 'before' => '', + ], + 'validation' => [ + 'max' => 53, + 'max-length' => 2, + 'min' => 1, + 'min-length' => 2, + 'type' => 'numeric', + ], + ], + 'year' => [ + 'label' => \__( 'Year', 'form-block' ), + 'placeholder' => \_x( 'YYYY', 'date field placeholder', 'form-block' ), + 'separator' => [ + 'after' => '', + 'before' => '', + ], + 'validation' => [ + 'max' => 99999, + 'max-length' => 4, + 'min' => 0, + 'min-length' => 4, + 'type' => 'numeric', + ], + ], + ]; + + if ( empty( $order ) ) { + return $fields; + } + + return \array_merge( \array_flip( $order ), $fields ); + } + + /** + * Get the field order. + * + * @param string $type Field type + * @return array Field order + */ + public static function get_field_order( string $type ): array { + switch ( $type ) { + case 'date-custom': + $order = \explode( ', ', \_x( 'month, day, year', 'date order in lowercase', 'form-block' ) ); + break; + case 'datetime-local-custom': + $order = \explode( ', ', \_x( 'month, day, year, hour, minute', 'date order in lowercase', 'form-block' ) ); + break; + case 'month-custom': + $order = \explode( ', ', \_x( 'month, year', 'date order in lowercase', 'form-block' ) ); + break; + case 'time-custom': + $order = \explode( ', ', \_x( 'hour, minute', 'date order in lowercase', 'form-block' ) ); + break; + case 'week-custom': + $order = \explode( ', ', \_x( 'week, year', 'date order in lowercase', 'form-block' ) ); + break; + default: + $order = []; + break; + } + + return $order; + } + + /** + * Set markup for a custom date field. + * + * @param string $block_content The block content + * @param array $block Block attributes + * @return string Updated block content + */ + public static function set_markup( string $block_content, array $block ): string { + $dom = new DOMDocument(); + $dom->loadHTML( + '' . $block_content . '', + \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD + ); + $xpath = new DOMXPath( $dom ); + /** @var \DOMElement $container_node */ + $container_node = $xpath->query( '//div[contains(@class, "wp-block-form-block-input")]' )->item( 0 ); + /** @var \DOMElement $input_node */ + $input_node = $xpath->query( '//div[contains(@class, "wp-block-form-block-input")]//input' )->item( 0 ); + $label_node = $xpath->query( '//div[contains(@class, "wp-block-form-block-input")]//label' )->item( 0 ); + $label_content_node = $xpath->query( '//div[contains(@class, "wp-block-form-block-input")]//span[contains(@class, "form-block__label-content")]' )->item( 0 ); + $field_data = [ + 'class' => $input_node->getAttribute( 'class' ), + 'id' => $input_node->getAttribute( 'id' ), + 'is_required' => $input_node->hasAttribute( 'required' ) ? true : false, + 'name' => $input_node->getAttribute( 'name' ), + 'type' => $input_node->getAttribute( 'type' ), + ]; + + if ( ! \str_ends_with( $field_data['type'], '-custom' ) ) { + return $block_content; + } + + $fieldset = $dom->createElement( 'fieldset' ); + $legend = $dom->createElement( 'legend' ); + $legend_content = $dom->createElement( 'span', $label_content_node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $legend_content->setAttribute( 'class', 'form-block__label-content' ); + $legend->appendChild( $legend_content ); + + if ( $field_data['is_required'] ) { + $required_symbol = $dom->createElement( 'span' ); + $required_symbol->setAttribute( 'class', 'is-required' ); + $required_symbol->setAttribute( 'aria-hidden', 'true' ); + $required_symbol->textContent = '*'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $legend->appendChild( $required_symbol ); + } + + $fieldset->appendChild( $legend ); + $fieldset->setAttribute( 'class', 'form-block__input-group' ); + $order = self::get_field_order( $field_data['type'] ); + + if ( empty( $order ) ) { + return $block_content; + } + + \wp_enqueue_script( 'form-block-multi-field' ); + + $fields = \array_intersect_key( self::get_field_data( $order ), \array_flip( $order ) ); + $block_data = \wp_parse_args( + $block['attrs']['customDate'] ?? [], + [ + 'showPlaceholder' => true, + 'showLabel' => false, + 'value' => [], + ] + ); + + self::add_date_fields( $fields, $dom, $fieldset, $field_data, $block_data ); + $container_node->setAttribute( 'class', $container_node->getAttribute( 'class' ) . ' has-sub-elements' ); + $input_node->parentNode->appendChild( $fieldset ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $input_node->parentNode->removeChild( $input_node ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $label_node->parentNode->removeChild( $label_node ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + return str_replace( [ '', '' ], '', $dom->saveHTML( $dom->documentElement ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + /** + * Set the output format. + * + * @param mixed $value Post value + * @param string $name Field name + * @param array $field_data Form field data + * @return mixed Output in proper format + */ + public static function set_output_format( mixed $value, string $name, array $field_data ): mixed { + $field = Data::get_instance()->get_field_data_by_name( $name, $field_data['fields'] ); + + if ( ! \in_array( $field['type'], self::$field_types, true ) ) { + return $value; + } + + $field_order = self::get_field_order( $field['type'] ); + $format_data = \array_intersect_key( self::get_field_data( $field_order ), \array_flip( $field_order ) ); + $output = ''; + + foreach ( $format_data as $format_type => $field_format ) { + // don't start with a separator + if ( ! empty( $output ) ) { + $output .= $field_format['separator']['before'] ?? ''; + } + + if ( ! empty( $value[ $format_type ] ) && \is_string( $value[ $format_type ] ) ) { + $output .= $value[ $format_type ]; + $output .= $field_format['separator']['after'] ?? ''; + } + } + + return $output; + } +} diff --git a/package-lock.json b/package-lock.json index 29c8ede..2fbd5a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "form-block", - "version": "1.0.0-dev", "devDependencies": { "@wordpress/block-editor": "^11.1.0", "@wordpress/blocks": "^12.1.0", diff --git a/readme.txt b/readme.txt index 8541fdc..fdadd3c 100644 --- a/readme.txt +++ b/readme.txt @@ -1,8 +1,8 @@ === Form Block === Contributors: epiphyt, kittmedia -Tags: contact, form, contact form, gutenberg, block editor +Tags: contact, form, contact form, gutenberg, block editor, accessibility Requires at least: 6.3 -Stable tag: 1.3.0 +Stable tag: 1.4.0 Tested up to: 6.5 Requires PHP: 7.4 License: GPL2 @@ -19,6 +19,7 @@ WordPress offers several (contact) form plugins, but most of them are not up-to- = Features = * Fully support of the block editor +* Built with accessibility in mind * Create forms with an unlimited number of fields * Select from a wide variety of field types * Use a predefined form or start from scratch @@ -88,9 +89,21 @@ The code is open source and hosted on [GitHub](https://github.com/epiphyt/form-b We are [Epiphyt](https://epiph.yt/), your friendly neighborhood WordPress plugin shop from southern Germany. += How can I report security bugs? = + +You can report security bugs through the Patchstack Vulnerability Disclosure Program. The Patchstack team help validate, triage and handle any security vulnerabilities. [Report a security vulnerability.](https://patchstack.com/database/vdp/form-block) == Changelog == += 1.4.0 = +* Added: Custom separated date fields (read [the announcement for more information](https://epiph.yt/en/blog/2024/form-block-1-4-0-release-and-opinions-on-date-pickers/)) +* Added: All supported input types that were previously only part of the Pro version +* Added: Design for Twenty Twenty-Four +* Added: More recognized field names for the form wizard +* Improved: Input type selection is now more descriptive and translatable +* Fixed: `aria-describedby` for error fields is no more added multiple times +* Fixed: Form wizard now returns the proper input fields + = 1.3.0 = * Added: Support block settings like font size, line height and dimensions * Added: By selecting an invalid field, the error message will now be announced to screen readers diff --git a/src/data/attributes.js b/src/data/attributes.js index 8b54c28..99c6097 100644 --- a/src/data/attributes.js +++ b/src/data/attributes.js @@ -12,6 +12,9 @@ export const attributes = applyFilters( description: __( 'Whether the element is checked by default.', 'form-block' ), label: _x( 'Checked', 'HTML attribute name', 'form-block' ), }, + customDate: { + controlType: 'custom-date', + }, disabled: { controlType: 'toggle', description: __( 'Whether the form element is disabled and will not be submitted by sending the form.', 'form-block' ), diff --git a/src/form/wizard.js b/src/form/wizard.js index 81f623f..3c2fef2 100644 --- a/src/form/wizard.js +++ b/src/form/wizard.js @@ -14,9 +14,19 @@ const fieldMatches = applyFilters( 'formBlock.wizard.fieldMatches', { checkbox: [ - _x( '?', 'potential form field name in lowercase', 'form-block' ), + _x( 'checkbox', 'potential form field name in lowercase', 'form-block' ), _x( 'consent', 'potential form field name in lowercase', 'form-block' ), ], + color: [ + _x( 'color', 'potential form field name in lowercase', 'form-block' ), + ], + 'datetime-local-custom': [ // override 'date' + _x( 'date time', 'potential form field name in lowercase', 'form-block' ), + _x( 'date and time', 'potential form field name in lowercase', 'form-block' ), + ], + 'date-custom': [ + _x( 'date', 'potential form field name in lowercase', 'form-block' ), + ], email: [ _x( 'e-mail', 'potential form field name in lowercase', 'form-block' ), _x( 'email', 'potential form field name in lowercase', 'form-block' ), @@ -26,9 +36,46 @@ const fieldMatches = applyFilters( _x( 'file', 'potential form field name in lowercase', 'form-block' ), _x( 'upload', 'potential form field name in lowercase', 'form-block' ), ], + hidden: [ + _x( 'hidden', 'potential form field name in lowercase', 'form-block' ), + _x( 'invisible', 'potential form field name in lowercase', 'form-block' ), + ], + image: [ + _x( 'image', 'potential form field name in lowercase', 'form-block' ), + _x( 'picture', 'potential form field name in lowercase', 'form-block' ), + ], + 'month-custom': [ + _x( 'month', 'potential form field name in lowercase', 'form-block' ), + ], + 'number': [ + _x( 'amount', 'potential form field name in lowercase', 'form-block' ), + _x( 'count', 'potential form field name in lowercase', 'form-block' ), + _x( 'int', 'potential form field name in lowercase', 'form-block' ), + _x( 'integer', 'potential form field name in lowercase', 'form-block' ), + _x( 'number', 'potential form field name in lowercase', 'form-block' ), + _x( 'numeric', 'potential form field name in lowercase', 'form-block' ), + ], password: [ _x( 'password', 'potential form field name in lowercase', 'form-block' ), ], + radio: [ + _x( 'choice', 'potential form field name in lowercase', 'form-block' ), + ], + range: [ + _x( 'range', 'potential form field name in lowercase', 'form-block' ), + ], + reset: [ + _x( 'cancel', 'potential form field name in lowercase', 'form-block' ), + _x( 'reset', 'potential form field name in lowercase', 'form-block' ), + ], + search: [ + _x( 'find', 'potential form field name in lowercase', 'form-block' ), + _x( 'search', 'potential form field name in lowercase', 'form-block' ), + ], + select: [ + _x( 'select', 'potential form field name in lowercase', 'form-block' ), + _x( 'selection', 'potential form field name in lowercase', 'form-block' ), + ], tel: [ _x( 'tel', 'potential form field name in lowercase', 'form-block' ), _x( 'phone', 'potential form field name in lowercase', 'form-block' ), @@ -42,7 +89,23 @@ const fieldMatches = applyFilters( _x( 'zip', 'potential form field name in lowercase', 'form-block' ), ], textarea: [ + _x( 'area', 'potential form field name in lowercase', 'form-block' ), _x( 'message', 'potential form field name in lowercase', 'form-block' ), + _x( 'multiline', 'potential form field name in lowercase', 'form-block' ), + _x( 'textarea', 'potential form field name in lowercase', 'form-block' ), + ], + 'time-custom': [ + _x( 'clock', 'potential form field name in lowercase', 'form-block' ), + _x( 'time', 'potential form field name in lowercase', 'form-block' ), + ], + url: [ + _x( 'homepage', 'potential form field name in lowercase', 'form-block' ), + _x( 'link', 'potential form field name in lowercase', 'form-block' ), + _x( 'page', 'potential form field name in lowercase', 'form-block' ), + _x( 'url', 'potential form field name in lowercase', 'form-block' ), + ], + 'week-custom': [ + _x( 'week', 'potential form field name in lowercase', 'form-block' ), ], } ); @@ -66,15 +129,37 @@ export default function Wizard( props ) { const preparedFields = fields.split( ',' ).map( ( field ) => field.trim() ); for ( const preparedField of preparedFields ) { + let isAdded = false; + const isRequired = preparedField.includes( '*' ); + const fieldLabel = preparedField.replace( '*', '' ); + checkFieldTypeLoop: for ( const fieldType of Object.keys( fieldMatches ) ) { for ( const potentialMatch of fieldMatches[ fieldType ] ) { - console.log( {preparedField, potentialMatch} ); - const isRequired = preparedField.includes( '*' ); - const fieldLabel = preparedField.replace( '*', '' ); + if ( ! preparedField.toLowerCase().includes( potentialMatch ) ) { + continue; + } - if ( preparedField.toLowerCase().includes( potentialMatch ) ) { - if ( fieldType !== 'textarea' ) { + switch ( fieldType ) { + case 'select': + blocks.push( [ + 'form-block/select', + { + label: fieldLabel, + required: isRequired, + }, + ] ); + break; + case 'textarea': + blocks.push( [ + 'form-block/textarea', + { + label: fieldLabel, + required: isRequired, + }, + ] ); + break; + default: let blockAttributes = { label: fieldLabel, required: isRequired, @@ -93,30 +178,24 @@ export default function Wizard( props ) { 'form-block/input', blockAttributes, ] ); - } - else { - blocks.push( [ - 'form-block/textarea', - { - label: fieldLabel, - required: isRequired, - }, - ] ); - } - } - else { - blocks.push( [ - 'form-block/input', - { - label: fieldLabel, - required: isRequired, - type: 'text', - }, - ] ); + break; } + + isAdded = true; break checkFieldTypeLoop; } } + + if ( ! isAdded ) { + blocks.push( [ + 'form-block/input', + { + label: fieldLabel, + required: isRequired, + type: 'text', + }, + ] ); + } } if ( blocks.length ) { @@ -150,6 +229,12 @@ export default function Wizard( props ) { setIsWizardOpen( false ); }; + const onKeyPress = ( event ) => { + if ( event.key === 'Enter' ) { + onInsert(); + } + } + return ( setFields( fields ) } + onKeyPress={ onKeyPress } /> + ); case 'number': return ( setAttributes( { type } ) } - options={ getTypes().map( ( type ) => ( { label: type, value: type } ) ) } + options={ getTypes().map( ( type ) => ( { label: types[ type ].label, value: type } ) ) } value={ type } /> setAttributes( { value } ) } - { ...elementProps } - /> + { isCustomDate( type ) + ? + : setAttributes( { value } ) } + { ...elementProps } + /> + } } diff --git a/src/input/html-data.js b/src/input/html-data.js index 4d86d05..4365dee 100644 --- a/src/input/html-data.js +++ b/src/input/html-data.js @@ -1,4 +1,5 @@ import { applyFilters } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; export const getAllowedAttributes = ( type ) => { return types[ type ].allowedAttributes; @@ -14,7 +15,7 @@ export const isAllowedAttribute = ( type, attribute ) => { return types[ type ].allowedAttributes.includes( attribute ); } -const types = applyFilters( +export const types = applyFilters( 'formBlock.input.htmlTypes', { checkbox: { @@ -25,6 +26,68 @@ const types = applyFilters( 'required', 'value', ], + label: __( 'Checkbox', 'form-block' ), + }, + color: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + ], + label: __( 'Color selection', 'form-block' ), + }, + date: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'readOnly', + 'required', + 'step', + ], + label: __( 'Datei', 'form-block' ), + }, + 'date-custom': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'customDate', + 'disabled', + 'label', + 'readOnly', + 'required', + ], + label: __( 'Date with separate fields', 'form-block' ), + }, + 'datetime-local': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'readOnly', + 'required', + 'step', + ], + label: __( 'Date and time', 'form-block' ), + }, + 'datetime-local-custom': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'customDate', + 'disabled', + 'label', + 'readOnly', + 'required', + ], + label: __( 'Date and time with separate fields', 'form-block' ), }, email: { allowedAttributes: [ @@ -41,6 +104,7 @@ const types = applyFilters( 'required', 'size', ], + label: __( 'E-mail', 'form-block' ), }, file: { allowedAttributes: [ @@ -53,6 +117,52 @@ const types = applyFilters( 'readOnly', 'required', ], + label: __( 'File', 'form-block' ), + }, + hidden: { + allowedAttributes: [], + label: __( 'Hidden', 'form-block' ), + }, + image: { + allowedAttributes: [ + 'ariaDescription', + 'alt', + 'autoComplete', + 'disabled', + 'height', + 'label', + 'readOnly', + 'required', + 'src', + 'width', + ], + label: __( 'Image', 'form-block' ), + }, + month: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'readOnly', + 'required', + 'step', + ], + label: __( 'Month', 'form-block' ), + }, + 'month-custom': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'customDate', + 'disabled', + 'label', + 'readOnly', + 'required', + ], + label: __( 'Month with separate fields', 'form-block' ), }, number: { allowedAttributes: [ @@ -66,6 +176,23 @@ const types = applyFilters( 'required', 'step', ], + label: __( 'Number', 'form-block' ), + }, + password: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'maxLength', + 'minLength', + 'pattern', + 'placeholder', + 'readOnly', + 'required', + 'size', + ], + label: __( 'Password (input not visible)', 'form-block' ), }, radio: { allowedAttributes: [ @@ -75,18 +202,50 @@ const types = applyFilters( 'required', 'value', ], + label: __( 'Radio button', 'form-block' ), + }, + range: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'step', + ], + label: __( 'Range', 'form-block' ), }, reset: { allowedAttributes: [ 'disabled', 'value', ], + label: __( 'Reset button', 'form-block' ), + }, + search: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'dirname', + 'disabled', + 'label', + 'maxLength', + 'minLength', + 'pattern', + 'placeholder', + 'readOnly', + 'required', + 'size', + ], + label: __( 'Search', 'form-block' ), }, submit: { allowedAttributes: [ 'disabled', 'value', ], + label: __( 'Submit button', 'form-block' ), }, tel: { allowedAttributes: [ @@ -101,6 +260,7 @@ const types = applyFilters( 'required', 'size', ], + label: __( 'Telephone', 'form-block' ), }, text: { allowedAttributes: [ @@ -116,6 +276,75 @@ const types = applyFilters( 'required', 'size', ], + label: __( 'Text', 'form-block' ), + }, + time: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'readOnly', + 'required', + 'step', + ], + label: __( 'Time', 'form-block' ), + }, + 'time-custom': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'customDate', + 'disabled', + 'label', + 'readOnly', + 'required', + ], + label: __( 'Time with separate fields', 'form-block' ), + }, + url: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'maxLength', + 'minLength', + 'pattern', + 'placeholder', + 'readOnly', + 'required', + 'size', + ], + label: __( 'URL', 'form-block' ), + }, + week: { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'disabled', + 'label', + 'max', + 'min', + 'readOnly', + 'required', + 'step', + ], + label: __( 'Week', 'form-block' ), + }, + 'week-custom': { + allowedAttributes: [ + 'ariaDescription', + 'autoComplete', + 'customDate', + 'disabled', + 'label', + 'readOnly', + 'required', + ], + label: __( 'Week with separate fields', 'form-block' ), }, }, ); diff --git a/src/input/modules/custom-date/controls.js b/src/input/modules/custom-date/controls.js new file mode 100644 index 0000000..0817649 --- /dev/null +++ b/src/input/modules/custom-date/controls.js @@ -0,0 +1,28 @@ +import { ToggleControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default function CustomDateControls( { attribute, props, updateValue } ) { + const updateSettings = ( field, newValue ) => { + let updatedValue = structuredClone( props.attributes[ attribute ] ); + updatedValue[ field ] = newValue; + + updateValue( updatedValue, attribute ); + }; + + return ( + <> + updateSettings( 'showPlaceholder', newValue ) } + /> + updateSettings( 'showLabel', newValue ) } + /> + + ); +} diff --git a/src/input/modules/custom-date/editor.scss b/src/input/modules/custom-date/editor.scss new file mode 100644 index 0000000..a5464a7 --- /dev/null +++ b/src/input/modules/custom-date/editor.scss @@ -0,0 +1,17 @@ +@import '~@wordpress/base-styles/variables'; + +.form-block__date-custom { + .form-block__date-custom--separator { + font-size: $default-font-size; + margin-block-end: $grid-unit-10; // match input margin + padding-block: 6px; // match input padding + + &.is-before:first-child { + display: none; + } + } + + .is-type-year { + flex-grow: 2; + } +} diff --git a/src/input/modules/custom-date/index.js b/src/input/modules/custom-date/index.js new file mode 100644 index 0000000..2f750b0 --- /dev/null +++ b/src/input/modules/custom-date/index.js @@ -0,0 +1,131 @@ +import { + Flex, + FlexBlock, + FlexItem, + TextControl, +} from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import { addFilter, applyFilters } from '@wordpress/hooks'; +import { __, _x } from '@wordpress/i18n'; + +import './editor.scss'; + +const getAllowedInputTypes = () => { + let types = [ + 'date-custom', + 'datetime-local-custom', + 'month-custom', + 'time-custom', + 'week-custom', + ]; + + types = applyFilters( + 'formBlock.module.datePicker.allowedInputTypes', + types + ); + + return types; +} + +const addControlTypes = ( controlTypes, props ) => { + const { + attributes: { + type, + }, + } = props; + + if ( ! isCustomDate( type ) ) { + return controlTypes; + } + + controlTypes.push( { + attributeName: 'customDate', + attributes: {}, + } ); + + return controlTypes; +} + +addFilter( 'formBlock.input.controlTypes', 'form-block/custom-date/add-control-types', addControlTypes ); + +export const isCustomDate = ( type ) => getAllowedInputTypes().includes( type ); + +export function CustomDate( { props, elementProps } ) { + const { + attributes: { + customDate, + label, + type, + }, + setAttributes, + } = props; + const { + showLabel, + showPlaceholder, + value, + } = customDate; + let fields; + + const onFieldUpdate = ( field, fieldValue ) => { + let newValue = structuredClone( customDate ); + newValue.value[ field ] = fieldValue; + + setAttributes( { customDate: newValue } ); + } + + switch ( type ) { + case 'date-custom': + fields = _x( 'month, day, year', 'date order in lowercase', 'form-block' ).split( ', ' ); + break; + case 'datetime-local-custom': + fields = _x( 'month, day, year, hour, minute', 'date order in lowercase', 'form-block' ).split( ', ' ); + break; + case 'month-custom': + fields = _x( 'month, year', 'date order in lowercase', 'form-block' ).split( ', ' ); + break; + case 'time-custom': + fields = _x( 'hour, minute', 'date order in lowercase', 'form-block' ).split( ', ' ); + break; + case 'week-custom': + fields = _x( 'week, year', 'date order in lowercase', 'form-block' ).split( ', ' ); + break; + } + + return ( +
+ { label } + + + { fields.map( ( field, index ) => ( + + { formBlockInputCustomDate[ field ].separator.before + ? + { formBlockInputCustomDate[ field ].separator.before } + + : null + } + + + onFieldUpdate( field, value ) } + { ...elementProps } + type="number" + placeholder={ showPlaceholder ? formBlockInputCustomDate[ field ].placeholder : '' } + value={ value ? ( value[ field ] || '' ) : '' } + /> + + + { formBlockInputCustomDate[ field ].separator.after + ? + { formBlockInputCustomDate[ field ].separator.after } + + : null + } + + ) ) } + +
+ ); +} diff --git a/webpack.config.js b/webpack.config.js index 7e99436..e1875bd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,10 +8,12 @@ const mode = isProduction ? 'production' : 'development'; const jsFiles = { 'form': path.resolve( process.cwd(), 'assets/js', 'form.js' ), + 'multi-field': path.resolve( process.cwd(), 'assets/js', 'multi-field.js' ), 'validation': path.resolve( process.cwd(), 'assets/js', 'validation.js' ), }; const scssFiles = { 'form': path.resolve( process.cwd(), 'assets/style', 'form.scss' ), + 'twenty-twenty-four': path.resolve( process.cwd(), 'assets/style', 'twenty-twenty-four.scss' ), 'twenty-twenty-three': path.resolve( process.cwd(), 'assets/style', 'twenty-twenty-three.scss' ), 'twenty-twenty-two': path.resolve( process.cwd(), 'assets/style', 'twenty-twenty-two.scss' ), };