From 1c7e6063f91242d046b6c340648d3936ea184532 Mon Sep 17 00:00:00 2001 From: boo-code Date: Sat, 5 Sep 2026 23:22:25 +0200 Subject: [PATCH] Stop nesting form elements in the checkout address step The address step wrapped the whole block in a
, and rendered full address elements inside it. The HTML parser resolves that by dropping the inner start tag and letting the inner
close the outer form, so every control after the address form loses its form owner. Measured on 9.2.0: with two saved addresses, a separate invoice address and the delivery address open for editing, the Continue button, both invoice radios and the not-valid-addresses input all report form === null. Attach those controls to the step form through the form attribute instead, and leave the form element itself empty so the address forms stay siblings. Form validation now looks the submit button up in form.elements, which covers both DOM containment and the form attribute. --- src/js/form-validation.test.ts | 54 ++++++ src/js/form-validation.ts | 7 +- .../_partials/address-selector-block.tpl | 3 +- .../_partials/checkout-step-buttons.tpl | 2 +- .../checkout/_partials/steps/addresses.tpl | 160 +++++++++--------- 5 files changed, 147 insertions(+), 79 deletions(-) create mode 100644 src/js/form-validation.test.ts diff --git a/src/js/form-validation.test.ts b/src/js/form-validation.test.ts new file mode 100644 index 000000000..f5bcfa070 --- /dev/null +++ b/src/js/form-validation.test.ts @@ -0,0 +1,54 @@ +/** + * For the full copyright and license information, please view the + * LICENSE.md file that was distributed with this source code. + */ +import selectorsMap from '@constants/selectors-map'; +import initFormValidation from './form-validation'; + +const setUp = (markup: string) => { + document.body.innerHTML = markup; + + window.Theme = { + ...window.Theme, + selectors: selectorsMap, + }; + + initFormValidation(); +}; + +describe('Form validation', () => { + it('binds the submit button nested inside the form', () => { + setUp(` +
+ + +
+ `); + + const form = document.querySelector('#f') as HTMLFormElement; + const button = document.querySelector('button') as HTMLButtonElement; + button.click(); + + expect(form.classList.contains('was-validated')).toBe(true); + }); + + it('binds a submit button attached through the form attribute', () => { + // WHY: the checkout address step keeps its submit button outside the form element, because + // the step also renders address
elements that must not be nested inside it. + setUp(` +
+ + + `); + + const form = document.querySelector('#f') as HTMLFormElement; + const button = document.querySelector('button') as HTMLButtonElement; + button.click(); + + expect(form.classList.contains('was-validated')).toBe(true); + }); + + it('does not throw when the form has no submit button', () => { + expect(() => setUp('
')).not.toThrow(); + }); +}); diff --git a/src/js/form-validation.ts b/src/js/form-validation.ts index 5c590433d..906e33464 100644 --- a/src/js/form-validation.ts +++ b/src/js/form-validation.ts @@ -9,7 +9,12 @@ const initFormValidation = (selector?: string) => { const formValidationList = document.querySelectorAll(selector ?? formValidationMap.default); formValidationList.forEach((formElement: HTMLFormElement) => { - const submitButton = formElement.querySelector(formValidationMap.submitButton); + // WHY: a control can belong to a form through the `form` attribute rather than DOM + // containment, and querySelector only ever sees descendants. form.elements covers both, + // which the checkout address step relies on to keep its submit button out of the + // address
elements rendered inside the same step. + const submitButton = Array.from(formElement.elements) + .find((element): element is HTMLButtonElement => element.matches(formValidationMap.submitButton)) ?? null; if (submitButton) { submitButton.addEventListener('click', (event) => { diff --git a/templates/checkout/_partials/address-selector-block.tpl b/templates/checkout/_partials/address-selector-block.tpl index 75e3bfeb8..9ecaad96d 100644 --- a/templates/checkout/_partials/address-selector-block.tpl +++ b/templates/checkout/_partials/address-selector-block.tpl @@ -15,6 +15,7 @@ class="form-check-input" name="{$name}" value="{$address.id}" + {if isset($form_id)}form="{$form_id}"{/if} {if $address.id == $selected}checked{/if} aria-label="{l s='Select address: %addressAlias%' sprintf=['%addressAlias%' => $address.alias|lower] d='Shop.Theme.Actions'}" aria-describedby="address-{$address.id}" @@ -60,7 +61,7 @@ {if $interactive}
- +
{/if} {/block} diff --git a/templates/checkout/_partials/checkout-step-buttons.tpl b/templates/checkout/_partials/checkout-step-buttons.tpl index 97b591c47..13e8b7b65 100644 --- a/templates/checkout/_partials/checkout-step-buttons.tpl +++ b/templates/checkout/_partials/checkout-step-buttons.tpl @@ -11,7 +11,7 @@ {/if} {if isset($next_step) && (!isset($show_next_button) || $show_next_button)} - diff --git a/templates/checkout/_partials/steps/addresses.tpl b/templates/checkout/_partials/steps/addresses.tpl index 15f151751..362f712fa 100644 --- a/templates/checkout/_partials/steps/addresses.tpl +++ b/templates/checkout/_partials/steps/addresses.tpl @@ -5,110 +5,118 @@ {extends file='checkout/_partials/steps/checkout-step.tpl'} {block name='step_content'} + {$addresses_form_id = 'checkout-addresses-form'}
+ {* WHY: this step also renders full address elements (checkout/_partials/address-form.tpl). + Wrapping those in this form produced nested tags: the HTML parser drops the inner start + tag and lets the inner close THIS form instead, which orphaned the address selector and + the Continue button that follow it. This form therefore stays empty and its controls are + attached explicitly through the form attribute. *}
- {if $use_same_address} -

- {if $cart.is_virtual} - {l s='The selected address will be used as your personal address (for invoice).' d='Shop.Theme.Checkout'} - {else} - {l s='The selected address will be used both as your personal address (for invoice) and as your delivery address.' d='Shop.Theme.Checkout'} - {/if} -

+ >
+ {if $use_same_address} +

+ {if $cart.is_virtual} + {l s='The selected address will be used as your personal address (for invoice).' d='Shop.Theme.Checkout'} + {else} + {l s='The selected address will be used both as your personal address (for invoice) and as your delivery address.' d='Shop.Theme.Checkout'} + {/if} +

+ {else} +

{l s='Shipping Address' d='Shop.Theme.Checkout'}

+ {/if} + + {if $show_delivery_address_form} +
+ {render file = 'checkout/_partials/address-form.tpl' + ui = $address_form + use_same_address = $use_same_address + type = "delivery" + form_has_continue_button = $form_has_continue_button + } +
+ {elseif $customer.addresses|count> 0} +
+ {include file = 'checkout/_partials/address-selector-block.tpl' + addresses = $customer.addresses + name = "id_address_delivery" + selected = $id_address_delivery + type = "delivery" + form_id = $addresses_form_id + interactive = !$show_delivery_address_form and !$show_invoice_address_form + } +
+ + {if isset($delivery_address_error)} +

{$delivery_address_error.exception}

{else} -

{l s='Shipping Address' d='Shop.Theme.Checkout'}

+ {/if} - {if $show_delivery_address_form} -
+ + + {if $use_same_address && !$cart.is_virtual} + + {l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'} + + {/if} + {/if} + + {if !$use_same_address} +

{l s='Your Invoice Address' d='Shop.Theme.Checkout'}

+ + {if $show_invoice_address_form} +
{render file = 'checkout/_partials/address-form.tpl' ui = $address_form use_same_address = $use_same_address - type = "delivery" + type = "invoice" form_has_continue_button = $form_has_continue_button }
- {elseif $customer.addresses|count> 0} -
+ {else} +
{include file = 'checkout/_partials/address-selector-block.tpl' addresses = $customer.addresses - name = "id_address_delivery" - selected = $id_address_delivery - type = "delivery" + name = "id_address_invoice" + selected = $id_address_invoice + type = "invoice" + form_id = $addresses_form_id interactive = !$show_delivery_address_form and !$show_invoice_address_form }
- {if isset($delivery_address_error)} -

{$delivery_address_error.exception}

+ {if isset($invoice_address_error)} +

{$invoice_address_error.exception}

{else} - + {/if} - - - {if $use_same_address && !$cart.is_virtual} - - {l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'} - - {/if} + + + {l s='Add new address' d='Shop.Theme.Actions'} + {/if} + {/if} - {if !$use_same_address} -

{l s='Your Invoice Address' d='Shop.Theme.Checkout'}

- - {if $show_invoice_address_form} -
- {render file = 'checkout/_partials/address-form.tpl' - ui = $address_form - use_same_address = $use_same_address - type = "invoice" - form_has_continue_button = $form_has_continue_button - } -
- {else} -
- {include file = 'checkout/_partials/address-selector-block.tpl' - addresses = $customer.addresses - name = "id_address_invoice" - selected = $id_address_invoice - type = "invoice" - interactive = !$show_delivery_address_form and !$show_invoice_address_form - } -
- - {if isset($invoice_address_error)} -

{$invoice_address_error.exception}

- {else} - - {/if} +
+ {include file='checkout/_partials/checkout-step-buttons.tpl' show_next_button=false} - - - {l s='Add new address' d='Shop.Theme.Actions'} - - {/if} + {if !$form_has_continue_button} + {include file='checkout/_partials/checkout-step-buttons.tpl' show_back_button=false submit_name='confirm-addresses' submit_value='1' form_id=$addresses_form_id} + {/if} - -
- {include file='checkout/_partials/checkout-step-buttons.tpl' show_next_button=false} - - {if !$form_has_continue_button} - {include file='checkout/_partials/checkout-step-buttons.tpl' show_back_button=false submit_name='confirm-addresses' submit_value='1'} - - {/if} -
- +
{capture name="address_selector_bottom"}{hook h='displayAddressSelectorBottom'}{/capture} {if $smarty.capture.address_selector_bottom}