diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md
index 200ee29a03..76f0779eea 100644
--- a/src/Symfony/Bridge/Twig/CHANGELOG.md
+++ b/src/Symfony/Bridge/Twig/CHANGELOG.md
@@ -2,10 +2,11 @@ CHANGELOG
=========
5.3
------
+---
* Add a new `markAsPublic` method on `NotificationEmail` to change the `importance` context option to null after creation
* Add a new `fragment_uri()` helper to generate the URI of a fragment
+* Add support of Bootstrap 5 for form theming
5.3.0
-----
diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig
index 9c8a0cc14f..7316abf0dd 100644
--- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig
+++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig
@@ -54,6 +54,11 @@
{%- endif -%}
{%- endblock radio_widget %}
+{% block choice_widget_collapsed -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
+ {{- parent() -}}
+{%- endblock choice_widget_collapsed %}
+
{# Labels #}
{% block form_label -%}
diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig
index c990d81370..3fbaa052c7 100644
--- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig
+++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig
@@ -202,6 +202,11 @@
{%- endif -%}
{%- endblock radio_widget %}
+{% block choice_widget_collapsed -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
+ {{- parent() -}}
+{%- endblock choice_widget_collapsed %}
+
{% block choice_widget_expanded -%}
{%- for child in form %}
diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig
new file mode 100644
index 0000000000..3c24166d48
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig
@@ -0,0 +1,130 @@
+{% use "bootstrap_5_layout.html.twig" %}
+
+{# Labels #}
+
+{% block form_label -%}
+ {%- if label is same as(false) -%}
+
+ {%- else -%}
+ {%- set row_class = row_class|default(row_attr.class|default('')) -%}
+ {%- if 'form-floating' not in row_class and 'input-group' not in row_class -%}
+ {%- if expanded is not defined or not expanded -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label')|trim}) -%}
+ {%- endif -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ block('form_label_class'))|trim}) -%}
+ {%- endif -%}
+ {{- parent() -}}
+ {%- endif -%}
+{%- endblock form_label %}
+
+{% block form_label_class -%}
+ col-sm-2
+{%- endblock form_label_class %}
+
+{# Rows #}
+
+{% block form_row -%}
+ {%- if expanded is defined and expanded -%}
+ {{ block('fieldset_form_row') }}
+ {%- else -%}
+ {%- set widget_attr = {} -%}
+ {%- if help is not empty -%}
+ {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
+ {%- endif -%}
+ {%- set row_class = row_class|default(row_attr.class|default('mb-3')) -%}
+ {%- set is_form_floating = is_form_floating|default('form-floating' in row_class) -%}
+ {%- set is_input_group = is_input_group|default('input-group' in row_class) -%}
+ {#- Remove behavior class from the main container -#}
+ {%- set row_class = row_class|replace({'form-floating': '', 'input-group': ''}) -%}
+
+ {%- if is_form_floating or is_input_group -%}
+
+
+ {%- else -%}
+ {{- form_label(form) -}}
+
+ {{- form_widget(form, widget_attr) -}}
+ {{- form_help(form) -}}
+ {{- form_errors(form) -}}
+
+ {%- endif -%}
+ {##}
+ {%- endif -%}
+{%- endblock form_row %}
+
+{% block fieldset_form_row -%}
+ {%- set widget_attr = {} -%}
+ {%- if help is not empty -%}
+ {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
+ {%- endif -%}
+
+{%- endblock fieldset_form_row %}
+
+{% block submit_row -%}
+
{#--#}
+
{#--#}
+
+ {{- form_widget(form) -}}
+
{#--#}
+
+{%- endblock submit_row %}
+
+{% block reset_row -%}
+
{#--#}
+
{#--#}
+
+ {{- form_widget(form) -}}
+
{#--#}
+
+{%- endblock reset_row %}
+
+{% block button_row -%}
+
{#--#}
+
{#--#}
+
+ {{- form_widget(form) -}}
+
{#--#}
+
+{%- endblock button_row %}
+
+{% block checkbox_row -%}
+
{#--#}
+
{#--#}
+
+ {{- form_widget(form) -}}
+ {{- form_help(form) -}}
+ {{- form_errors(form) -}}
+
{#--#}
+
+{%- endblock checkbox_row %}
+
+{% block form_group_class -%}
+ col-sm-10
+{%- endblock form_group_class %}
diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig
new file mode 100644
index 0000000000..eef6f606ed
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig
@@ -0,0 +1,374 @@
+{% use "bootstrap_base_layout.html.twig" %}
+
+{# Widgets #}
+
+{% block money_widget -%}
+ {%- set prepend = not (money_pattern starts with '{{') -%}
+ {%- set append = not (money_pattern ends with '}}') -%}
+ {%- if prepend or append -%}
+
+ {%- if prepend -%}
+ {{ money_pattern|form_encode_currency }}
+ {%- endif -%}
+ {{- block('form_widget_simple') -}}
+ {%- if append -%}
+ {{ money_pattern|form_encode_currency }}
+ {%- endif -%}
+
+ {%- else -%}
+ {{- block('form_widget_simple') -}}
+ {%- endif -%}
+{%- endblock money_widget %}
+
+{% block date_widget -%}
+ {%- if widget == 'single_text' -%}
+ {{- block('form_widget_simple') -}}
+ {%- else -%}
+ {% if not valid %}
+ {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
+ {% set valid = true %}
+ {% endif %}
+ {%- if datetime is not defined or not datetime -%}
+
+ {%- endif %}
+ {%- if label is not same as(false) -%}
+
+ {{- form_label(form.year) -}}
+ {{- form_label(form.month) -}}
+ {{- form_label(form.day) -}}
+
+ {%- endif -%}
+
+ {{- date_pattern|replace({
+ '{{ year }}': form_widget(form.year),
+ '{{ month }}': form_widget(form.month),
+ '{{ day }}': form_widget(form.day),
+ })|raw -}}
+
+ {%- if datetime is not defined or not datetime -%}
+
+ {%- endif -%}
+ {%- endif -%}
+{%- endblock date_widget %}
+
+{% block time_widget -%}
+ {%- if widget == 'single_text' -%}
+ {{- block('form_widget_simple') -}}
+ {%- else -%}
+ {% if not valid %}
+ {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
+ {% set valid = true %}
+ {% endif %}
+ {%- if datetime is not defined or false == datetime -%}
+
+ {%- endif -%}
+ {%- if label is not same as(false) -%}
+
+ {{- form_label(form.hour) -}}
+ {%- if with_minutes -%}{{ form_label(form.minute) }}{%- endif -%}
+ {%- if with_seconds -%}{{ form_label(form.second) }}{%- endif -%}
+
+ {%- endif -%}
+ {% if with_minutes or with_seconds %}
+
+ {% endif %}
+ {{- form_widget(form.hour) -}}
+ {%- if with_minutes -%}
+ :
+ {{- form_widget(form.minute) -}}
+ {%- endif -%}
+ {%- if with_seconds -%}
+ :
+ {{- form_widget(form.second) -}}
+ {%- endif -%}
+ {% if with_minutes or with_seconds %}
+
+ {% endif %}
+ {%- if datetime is not defined or false == datetime -%}
+
+ {%- endif -%}
+ {%- endif -%}
+{%- endblock time_widget %}
+
+{% block datetime_widget -%}
+ {%- if widget == 'single_text' -%}
+ {{- block('form_widget_simple') -}}
+ {%- else -%}
+ {% if not valid %}
+ {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
+ {% set valid = true %}
+ {% endif %}
+
+ {{- form_widget(form.date, { datetime: true } ) -}}
+ {{- form_errors(form.date) -}}
+ {{- form_widget(form.time, { datetime: true } ) -}}
+ {{- form_errors(form.time) -}}
+
+ {%- endif -%}
+{%- endblock datetime_widget %}
+
+{% block dateinterval_widget -%}
+ {%- if widget == 'single_text' -%}
+ {{- block('form_widget_simple') -}}
+ {%- else -%}
+ {% if not valid %}
+ {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
+ {% set valid = true %}
+ {% endif %}
+
+ {%- if with_years -%}
+
+ {{ form_label(form.years) }}
+ {{ form_widget(form.years) }}
+
+ {%- endif -%}
+ {%- if with_months -%}
+
+ {{ form_label(form.months) }}
+ {{ form_widget(form.months) }}
+
+ {%- endif -%}
+ {%- if with_weeks -%}
+
+ {{ form_label(form.weeks) }}
+ {{ form_widget(form.weeks) }}
+
+ {%- endif -%}
+ {%- if with_days -%}
+
+ {{ form_label(form.days) }}
+ {{ form_widget(form.days) }}
+
+ {%- endif -%}
+ {%- if with_hours -%}
+
+ {{ form_label(form.hours) }}
+ {{ form_widget(form.hours) }}
+
+ {%- endif -%}
+ {%- if with_minutes -%}
+
+ {{ form_label(form.minutes) }}
+ {{ form_widget(form.minutes) }}
+
+ {%- endif -%}
+ {%- if with_seconds -%}
+
+ {{ form_label(form.seconds) }}
+ {{ form_widget(form.seconds) }}
+
+ {%- endif -%}
+ {%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%}
+
+ {%- endif -%}
+{%- endblock dateinterval_widget %}
+
+{% block percent_widget -%}
+ {%- if symbol -%}
+
+ {{- block('form_widget_simple') -}}
+ {{ symbol|default('%') }}
+
+ {%- else -%}
+ {{- block('form_widget_simple') -}}
+ {%- endif -%}
+{%- endblock percent_widget %}
+
+{% block form_widget_simple -%}
+ {%- if type is not defined or type != 'hidden' %}
+ {%- set widget_class = ' form-control' %}
+ {%- if type|default('') == 'color' -%}
+ {%- set widget_class = widget_class ~ ' form-control-color' -%}
+ {%- elseif type|default('') == 'range' -%}
+ {%- set widget_class = ' form-range' -%}
+ {%- endif -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ widget_class)|trim}) -%}
+ {% endif -%}
+ {%- if type is defined and type in ['range', 'color'] %}
+ {# Attribute "required" is not supported #}
+ {% set required = false %}
+ {% endif -%}
+ {{- parent() -}}
+{%- endblock form_widget_simple %}
+
+{%- block widget_attributes -%}
+ {%- if not valid %}
+ {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %}
+ {% endif -%}
+ {{ parent() }}
+{%- endblock widget_attributes -%}
+
+{%- block button_widget -%}
+ {%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%}
+ {{- parent() -}}
+{%- endblock button_widget %}
+
+{%- block submit_widget -%}
+ {%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%}
+ {{- parent() -}}
+{%- endblock submit_widget %}
+
+{%- block checkbox_widget -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%}
+ {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
+ {%- set row_class = 'form-check' -%}
+ {%- if 'checkbox-inline' in parent_label_class %}
+ {% set row_class = row_class ~ ' form-check-inline' %}
+ {% endif -%}
+ {%- if 'checkbox-switch' in parent_label_class %}
+ {% set row_class = row_class ~ ' form-switch' %}
+ {% endif -%}
+
+ {{- form_label(form, null, { widget: parent() }) -}}
+
+{%- endblock checkbox_widget %}
+
+{%- block radio_widget -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%}
+ {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
+ {%- set row_class = 'form-check' -%}
+ {%- if 'radio-inline' in parent_label_class -%}
+ {%- set row_class = row_class ~ ' form-check-inline' -%}
+ {%- endif -%}
+
+ {{- form_label(form, null, { widget: parent() }) -}}
+
+{%- endblock radio_widget %}
+
+{%- block choice_widget_collapsed -%}
+ {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%}
+ {{- parent() -}}
+{%- endblock choice_widget_collapsed -%}
+
+{%- block choice_widget_expanded -%}
+
+ {%- for child in form %}
+ {{- form_widget(child, {
+ parent_label_class: label_attr.class|default(''),
+ translation_domain: choice_translation_domain,
+ valid: valid,
+ }) -}}
+ {% endfor -%}
+
+{%- endblock choice_widget_expanded %}
+
+{# Labels #}
+
+{%- block form_label -%}
+ {% if label is not same as(false) -%}
+ {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
+ {%- if compound is defined and compound -%}
+ {%- set element = 'legend' -%}
+ {%- if 'col-form-label' not in parent_label_class -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label' )|trim}) -%}
+ {%- endif -%}
+ {%- else -%}
+ {%- set row_class = row_class|default(row_attr.class|default('')) -%}
+ {%- set label_attr = label_attr|merge({for: id}) -%}
+ {%- if 'col-form-label' not in parent_label_class -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ('input-group' in row_class ? ' input-group-text' : ' form-label') )|trim}) -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- endif -%}
+ {{- parent() -}}
+{%- endblock form_label %}
+
+{%- block checkbox_radio_label -%}
+ {#- Do not display the label if widget is not defined in order to prevent double label rendering -#}
+ {%- if widget is defined -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-check-label')|trim}) -%}
+ {%- if not compound -%}
+ {% set label_attr = label_attr|merge({'for': id}) %}
+ {%- endif -%}
+ {%- if required -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) -%}
+ {%- endif -%}
+ {%- if parent_label_class is defined -%}
+ {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ parent_label_class)|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) -%}
+ {%- endif -%}
+ {%- if label is not same as(false) and label is empty -%}
+ {%- if label_format is not empty -%}
+ {%- set label = label_format|replace({
+ '%name%': name,
+ '%id%': id,
+ }) -%}
+ {%- else -%}
+ {%- set label = name|humanize -%}
+ {%- endif -%}
+ {%- endif -%}
+
+ {{ widget|raw }}
+
+ {%- endif -%}
+{%- endblock checkbox_radio_label %}
+
+{# Rows #}
+
+{%- block form_row -%}
+ {%- if compound is defined and compound -%}
+ {%- set element = 'fieldset' -%}
+ {%- endif -%}
+ {%- set widget_attr = {} -%}
+ {%- if help is not empty -%}
+ {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
+ {%- endif -%}
+ {%- set row_class = row_class|default(row_attr.class|default('mb-3')|trim) -%}
+ <{{ element|default('div') }}{% with {attr: row_attr|merge({class: row_class})} %}{{ block('attributes') }}{% endwith %}>
+ {%- if 'form-floating' in row_class -%}
+ {{- form_widget(form, widget_attr) -}}
+ {{- form_label(form) -}}
+ {%- else -%}
+ {{- form_label(form) -}}
+ {{- form_widget(form, widget_attr) -}}
+ {%- endif -%}
+ {{- form_help(form) -}}
+ {{- form_errors(form) -}}
+ {{ element|default('div') }}>
+{%- endblock form_row %}
+
+{%- block button_row -%}
+
+ {{- form_widget(form) -}}
+
+{%- endblock button_row %}
+
+{# Errors #}
+
+{%- block form_errors -%}
+ {%- if errors|length > 0 -%}
+ {%- for error in errors -%}
+
{{ error.message }}
+ {%- endfor -%}
+ {%- endif %}
+{%- endblock form_errors %}
+
+{# Help #}
+
+{%- block form_help -%}
+ {% set row_class = row_attr.class|default('') %}
+ {% set help_class = ' form-text' %}
+ {% if 'input-group' in row_class %}
+ {#- Hack to properly display help with input group -#}
+ {% set help_class = ' input-group-text' %}
+ {% endif %}
+ {%- if help is not empty -%}
+ {%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ help_class ~ ' mb-0')|trim}) -%}
+ {%- endif -%}
+ {{- parent() -}}
+{%- endblock form_help %}
diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig
index 1b0092859d..b8cb8c44aa 100644
--- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig
+++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig
@@ -143,11 +143,6 @@
{%- endif -%}
{%- endblock dateinterval_widget -%}
-{% block choice_widget_collapsed -%}
- {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
- {{- parent() -}}
-{%- endblock choice_widget_collapsed %}
-
{% block choice_widget_expanded -%}
{%- if '-inline' in label_attr.class|default('') -%}
{%- for child in form %}
diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php
new file mode 100644
index 0000000000..9fe231bfa1
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php
@@ -0,0 +1,353 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Extension;
+
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\DateType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\RadioType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormError;
+
+/**
+ * Abstract class providing test cases for the Bootstrap 5 horizontal Twig form theme.
+ *
+ * @author Romain Monteil
+ */
+abstract class AbstractBootstrap5HorizontalLayoutTest extends AbstractBootstrap5LayoutTest
+{
+ public function testRow()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $form->addError(new FormError('[trans]Error![/trans]'));
+ $html = $this->renderRow($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3 row"]
+ [
+ ./label
+ [@for="name"]
+ [@class="col-form-label col-sm-2 required"]
+ /following-sibling::div
+ [@class="col-sm-10"]
+ [
+ ./input[@id="name"]
+ /following-sibling::div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error![/trans]"]
+ ]
+ [count(./div)=1]
+ ]
+'
+ );
+ }
+
+ public function testRowWithCustomClass()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $form->addError(new FormError('[trans]Error![/trans]'));
+ $html = $this->renderRow($form->createView(), [
+ 'row_attr' => [
+ 'class' => 'mb-5',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-5 row"]
+ [
+ ./label
+ [@for="name"]
+ [@class="col-form-label col-sm-2 required"]
+ /following-sibling::div
+ [@class="col-sm-10"]
+ [
+ ./input[@id="name"]
+ /following-sibling::div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error![/trans]"]
+ ]
+ [count(./div)=1]
+ ]
+'
+ );
+ }
+
+ public function testLabelOnForm()
+ {
+ $form = $this->factory->createNamed('name', DateType::class);
+ $view = $form->createView();
+ $this->renderWidget($view, ['label' => 'foo']);
+ $html = $this->renderLabel($view);
+
+ $this->assertMatchesXpath($html,
+ '/legend
+ [@class="col-form-label col-sm-2 required"]
+ [.="[trans]Name[/trans]"]
+'
+ );
+ }
+
+ public function testLabelDoesNotRenderFieldAttributes()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+'/label
+ [@for="name"]
+ [@class="col-form-label col-sm-2 required"]
+'
+ );
+ }
+
+ public function testLabelWithCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+'/label
+ [@for="name"]
+ [@class="my&class col-form-label col-sm-2 required"]
+'
+ );
+ }
+
+ public function testLabelWithCustomTextAndCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), 'Custom label', [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+'/label
+ [@for="name"]
+ [@class="my&class col-form-label col-sm-2 required"]
+ [.="[trans]Custom label[/trans]"]
+'
+ );
+ }
+
+ public function testLabelWithCustomTextAsOptionAndCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Custom label',
+ ]);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+'/label
+ [@for="name"]
+ [@class="my&class col-form-label col-sm-2 required"]
+ [.="[trans]Custom label[/trans]"]
+'
+ );
+ }
+
+ public function testLabelHtmlDefaultIsFalse()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Bolded label',
+ ]);
+
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"][.="[trans]Bolded label[/trans]"]');
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"]/b[.="Bolded label"]', 0);
+ }
+
+ public function testLabelHtmlIsTrue()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Bolded label',
+ 'label_html' => true,
+ ]);
+
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"][.="[trans]Bolded label[/trans]"]', 0);
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"]/b[.="Bolded label"]');
+ }
+
+ public function testLegendOnExpandedType()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, null, [
+ 'label' => 'Custom label',
+ 'expanded' => true,
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ ]);
+ $view = $form->createView();
+ $this->renderWidget($view);
+ $html = $this->renderLabel($view);
+
+ $this->assertMatchesXpath($html,
+'/legend
+ [@class="col-sm-2 col-form-label required"]
+ [.="[trans]Custom label[/trans]"]
+'
+ );
+ }
+
+ public function testCheckboxRow()
+ {
+ $form = $this->factory->createNamed('name', CheckboxType::class);
+ $view = $form->createView();
+ $html = $this->renderRow($view, ['label' => 'foo']);
+
+ $this->assertMatchesXpath($html, '/div[@class="mb-3 row"]/div[@class="col-sm-2" or @class="col-sm-10"]', 2);
+ }
+
+ public function testCheckboxRowWithHelp()
+ {
+ $form = $this->factory->createNamed('name', CheckboxType::class);
+ $view = $form->createView();
+ $html = $this->renderRow($view, ['label' => 'foo', 'help' => 'really helpful text']);
+
+ $this->assertMatchesXpath($html,
+'/div
+ [@class="mb-3 row"]
+ [
+ ./div[@class="col-sm-2" or @class="col-sm-10"]
+ /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"]
+ [
+ ./p
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]really helpful text[/trans]"]
+ ]
+ ]
+'
+ );
+ }
+
+ public function testRadioRowWithHelp()
+ {
+ $form = $this->factory->createNamed('name', RadioType::class, false);
+ $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']);
+
+ $this->assertMatchesXpath($html,
+'/div
+ [@class="mb-3 row"]
+ [
+ ./div[@class="col-sm-2" or @class="col-sm-10"]
+ /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"]
+ [
+ ./p
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]really helpful text[/trans]"]
+ ]
+ ]
+'
+ );
+ }
+
+ public function testFileWithGroup()
+ {
+ $form = $this->factory->createNamed('name', FileType::class);
+ $html = $this->renderRow($form->createView(), [
+ 'id' => 'n/a',
+ 'attr' => [
+ 'class' => 'my&class',
+ ],
+ 'row_attr' => [
+ 'class' => 'input-group mb-3',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3 row"]
+ [
+ ./div
+ [@class="col-sm-2"]
+ /following-sibling::div
+ [@class="col-sm-10"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./label
+ [@class="input-group-text required"]
+ [.="[trans]Name[/trans]"]
+ /following-sibling::input
+ [@type="file"]
+ [@name="name"]
+ [@class="my&class form-control"]
+ ]
+ ]
+ ]
+'
+ );
+ }
+
+ public function testFloatingLabel()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'attr' => [
+ 'placeholder' => 'name',
+ ],
+ 'row_attr' => [
+ 'class' => 'form-floating mb-3',
+ ],
+ ]);
+
+ $html = $this->renderRow($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3 row"]
+ [
+ ./div
+ [@class="col-sm-2"]
+ /following-sibling::div
+ [@class="col-sm-10"]
+ [
+ ./div
+ [@class="form-floating"]
+ [
+ ./input
+ [@id="name"]
+ [@placeholder="[trans]name[/trans]"]
+ /following-sibling::label
+ [@for="name"]
+ ]
+ ]
+ ]
+'
+ );
+ }
+}
diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php
new file mode 100644
index 0000000000..c6b9e3baaa
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php
@@ -0,0 +1,1819 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Extension;
+
+use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\ColorType;
+use Symfony\Component\Form\Extension\Core\Type\CountryType;
+use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
+use Symfony\Component\Form\Extension\Core\Type\DateType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\LanguageType;
+use Symfony\Component\Form\Extension\Core\Type\LocaleType;
+use Symfony\Component\Form\Extension\Core\Type\MoneyType;
+use Symfony\Component\Form\Extension\Core\Type\PercentType;
+use Symfony\Component\Form\Extension\Core\Type\RadioType;
+use Symfony\Component\Form\Extension\Core\Type\RangeType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\Extension\Core\Type\TimeType;
+use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
+use Symfony\Component\Form\Extension\Core\Type\WeekType;
+use Symfony\Component\Form\FormError;
+
+/**
+ * Abstract class providing test cases for the Bootstrap 5 Twig form theme.
+ *
+ * @author Romain Monteil
+ */
+abstract class AbstractBootstrap5LayoutTest extends AbstractBootstrap4LayoutTest
+{
+ public function testRow()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $form->addError(new FormError('[trans]Error![/trans]'));
+ $html = $this->renderRow($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3"]
+ [
+ ./label[@for="name"]
+ /following-sibling::input[@id="name"]
+ /following-sibling::div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error![/trans]"]
+ ]
+ [count(./div)=1]
+'
+ );
+ }
+
+ public function testRowWithCustomClass()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $form->addError(new FormError('[trans]Error![/trans]'));
+ $html = $this->renderRow($form->createView(), [
+ 'row_attr' => [
+ 'class' => 'mb-5',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-5"]
+ [
+ ./label[@for="name"]
+ /following-sibling::input[@id="name"]
+ /following-sibling::div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error![/trans]"]
+ ]
+ [count(./div)=1]
+'
+ );
+ }
+
+ public function testLabelDoesNotRenderFieldAttributes()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/label
+ [@for="name"]
+ [@class="form-label required"]
+'
+ );
+ }
+
+ public function testLabelWithCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/label
+ [@for="name"]
+ [@class="my&class form-label required"]
+'
+ );
+ }
+
+ public function testLabelWithCustomTextAndCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $html = $this->renderLabel($form->createView(), 'Custom label', [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/label
+ [@for="name"]
+ [@class="my&class form-label required"]
+ [.="[trans]Custom label[/trans]"]
+'
+ );
+ }
+
+ public function testLabelWithCustomTextAsOptionAndCustomAttributesPassedDirectly()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Custom label',
+ ]);
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/label
+ [@for="name"]
+ [@class="my&class form-label required"]
+ [.="[trans]Custom label[/trans]"]
+'
+ );
+ }
+
+ public function testLabelHtmlDefaultIsFalse()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Bolded label',
+ ]);
+
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"][.="[trans]Bolded label[/trans]"]');
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"]/b[.="Bolded label"]', 0);
+ }
+
+ public function testLabelHtmlIsTrue()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'label' => 'Bolded label',
+ 'label_html' => true,
+ ]);
+
+ $html = $this->renderLabel($form->createView(), null, [
+ 'label_attr' => [
+ 'class' => 'my&class',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"][.="[trans]Bolded label[/trans]"]', 0);
+ $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"]/b[.="Bolded label"]');
+ }
+
+ public function testHelp()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'help' => 'Help text test!',
+ ]);
+ $view = $form->createView();
+ $html = $this->renderHelp($view);
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]Help text test![/trans]"]
+'
+ );
+ }
+
+ public function testHelpAttr()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'help' => 'Help text test!',
+ 'help_attr' => [
+ 'class' => 'class-test',
+ ],
+ ]);
+ $view = $form->createView();
+ $html = $this->renderHelp($view);
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="class-test form-text mb-0 help-text"]
+ [.="[trans]Help text test![/trans]"]
+'
+ );
+ }
+
+ public function testHelpHtmlDefaultIsFalse()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'help' => 'Help text test!',
+ ]);
+
+ $view = $form->createView();
+ $html = $this->renderHelp($view);
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]Help text test![/trans]"]
+'
+ );
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ /b
+ [.="text"]
+', 0
+ );
+ }
+
+ public function testHelpHtmlIsFalse()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'help' => 'Help text test!',
+ 'help_html' => false,
+ ]);
+
+ $view = $form->createView();
+ $html = $this->renderHelp($view);
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]Help text test![/trans]"]
+'
+ );
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ /b
+ [.="text"]
+', 0
+ );
+ }
+
+ public function testHelpHtmlIsTrue()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'help' => 'Help text test!',
+ 'help_html' => true,
+ ]);
+ $html = $this->renderHelp($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]Help text test![/trans]"]
+', 0
+ );
+
+ $this->assertMatchesXpath($html,
+ '/p
+ [@id="name_help"]
+ [@class="form-text mb-0 help-text"]
+ /b
+ [.="text"]
+'
+ );
+ }
+
+ public function testErrors()
+ {
+ $form = $this->factory->createNamed('name', TextType::class);
+ $form->addError(new FormError('[trans]Error 1[/trans]'));
+ $form->addError(new FormError('[trans]Error 2[/trans]'));
+ $html = $this->renderErrors($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error 1[/trans]"]
+ /following-sibling::div
+ [@class="invalid-feedback d-block"]
+ [.="[trans]Error 2[/trans]"]
+'
+ );
+ }
+
+ public function testErrorWithNoLabel()
+ {
+ self::markTestSkipped('Errors are no longer rendered inside label with Bootstrap 5.');
+ }
+
+ public function testSingleChoiceAttributesWithMainAttributes()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ 'attr' => ['class' => 'bar&baz'],
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'bar&baz']],
+ '/select
+ [@name="name"]
+ [@class="bar&baz form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testCheckboxRowWithHelp()
+ {
+ $form = $this->factory->createNamed('name', CheckboxType::class);
+ $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3"]
+ [
+ ./div[@class="form-check"]
+ [
+ ./input
+ [@type="checkbox"]
+ [@id="name"]
+ [@name="name"]
+ [@required="required"]
+ [@aria-describedby="name_help"]
+ [@class="form-check-input"]
+ [@value="1"]
+ /following-sibling::label
+ [@class="form-check-label required"]
+ [@for="name"]
+ [.="[trans]foo[/trans]"]
+ ]
+ /following-sibling::p
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]really helpful text[/trans]"]
+ ]
+'
+ );
+ }
+
+ public function testCheckboxSwitchWithValue()
+ {
+ $form = $this->factory->createNamed('name', CheckboxType::class, false, [
+ 'value' => 'foo&bar',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class'], 'label_attr' => ['class' => 'checkbox-switch']],
+ '/div
+ [@class="form-check form-switch"]
+ [
+ ./input[@type="checkbox"][@name="name"][@id="my&id"][@class="my&class form-check-input"][@value="foo&bar"]
+ /following-sibling::label
+ [@class="checkbox-switch form-check-label required"]
+ [.="[trans]Name[/trans]"]
+ ]
+'
+ );
+ }
+
+ public function testMultipleChoiceSkipsPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => true,
+ 'expanded' => false,
+ 'placeholder' => 'Test&Me',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name[]"]
+ [@class="my&class form-select"]
+ [@multiple="multiple"]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testSingleChoice()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testSingleChoiceWithoutTranslation()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ 'choice_translation_domain' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&a"][@selected="selected"][.="Choice&A"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testSingleChoiceWithPlaceholderWithoutTranslation()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ 'required' => false,
+ 'translation_domain' => false,
+ 'placeholder' => 'Placeholder&Not&Translated',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value=""][not(@selected)][not(@disabled)][.="Placeholder&Not&Translated"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="Choice&A"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceAttributes()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testSingleChoiceWithPreferred()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'preferred_choices' => ['&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=4]
+'
+ );
+ }
+
+ public function testSingleChoiceWithSelectedPreferred()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'preferred_choices' => ['&a'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&a"][not(@selected)][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=4]
+'
+ );
+ }
+
+ public function testSingleChoiceWithPreferredAndNoSeparator()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'preferred_choices' => ['&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['separator' => null, 'attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceWithPreferredAndBlankSeparator()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'preferred_choices' => ['&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '', 'attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ /following-sibling::option[@disabled="disabled"][not(@selected)][.=""]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=4]
+'
+ );
+ }
+
+ public function testChoiceWithOnlyPreferred()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'preferred_choices' => ['&a', '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@class="my&class form-select"]
+ [count(./option)=5]
+'
+ );
+ }
+
+ public function testSingleChoiceNonRequired()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => false,
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value=""][.=""]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceNonRequiredNoneSelected()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, null, [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => false,
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value=""][.=""]
+ /following-sibling::option[@value="&a"][not(@selected)][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceNonRequiredWithPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'multiple' => false,
+ 'expanded' => false,
+ 'required' => false,
+ 'placeholder' => 'Select&Anything&Not&Me',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [
+ ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Anything&Not&Me[/trans]"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceRequiredWithPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => true,
+ 'multiple' => false,
+ 'expanded' => false,
+ 'placeholder' => 'Test&Me',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [@required="required"]
+ [
+ ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Test&Me[/trans]"]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceRequiredWithPlaceholderViaView()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => true,
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['placeholder' => '', 'attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [@required="required"]
+ [
+ ./option[@value=""][not(@selected)][not(@disabled)][.=""]
+ /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=3]
+'
+ );
+ }
+
+ public function testSingleChoiceGrouped()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [
+ 'choices' => [
+ 'Group&1' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'Group&2' => ['Choice&C' => '&c'],
+ ],
+ 'multiple' => false,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [./optgroup[@label="[trans]Group&1[/trans]"]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+ ]
+ [./optgroup[@label="[trans]Group&2[/trans]"]
+ [./option[@value="&c"][not(@selected)][.="[trans]Choice&C[/trans]"]]
+ [count(./option)=1]
+ ]
+ [count(./optgroup)=2]
+'
+ );
+ }
+
+ public function testMultipleChoice()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => true,
+ 'multiple' => true,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name[]"]
+ [@class="my&class form-select"]
+ [@required="required"]
+ [@multiple="multiple"]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testMultipleChoiceAttributes()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']],
+ 'required' => true,
+ 'multiple' => true,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name[]"]
+ [@class="my&class form-select"]
+ [@required="required"]
+ [@multiple="multiple"]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testMultipleChoiceNonRequired()
+ {
+ $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [
+ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'],
+ 'required' => false,
+ 'multiple' => true,
+ 'expanded' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name[]"]
+ [@class="my&class form-select"]
+ [@multiple="multiple"]
+ [
+ ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"]
+ /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"]
+ ]
+ [count(./option)=2]
+'
+ );
+ }
+
+ public function testRadioRowWithHelp()
+ {
+ $form = $this->factory->createNamed('name', RadioType::class, false);
+ $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="mb-3"]
+ [
+ ./p
+ [@class="form-text mb-0 help-text"]
+ [.="[trans]really helpful text[/trans]"]
+ ]
+'
+ );
+ }
+
+ public function testFile()
+ {
+ $form = $this->factory->createNamed('name', FileType::class);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'n/a', 'attr' => ['class' => 'my&class']],
+ '/input
+ [@type="file"]
+ [@name="name"]
+ [@class="my&class form-control"]
+'
+ );
+ }
+
+ public function testFileWithGroup()
+ {
+ $form = $this->factory->createNamed('name', FileType::class);
+ $html = $this->renderRow($form->createView(), [
+ 'id' => 'n/a',
+ 'attr' => [
+ 'class' => 'my&class',
+ ],
+ 'row_attr' => [
+ 'class' => 'input-group mb-3',
+ ],
+ ]);
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="input-group mb-3"]
+ [
+ ./label
+ [@class="input-group-text required"]
+ [.="[trans]Name[/trans]"]
+ /following-sibling::input
+ [@type="file"]
+ [@name="name"]
+ [@class="my&class form-control"]
+ ]
+'
+ );
+ }
+
+ public function testFileWithPlaceholder()
+ {
+ self::markTestSkipped('Placeholder does not apply on input file.');
+ }
+
+ public function testCountry()
+ {
+ $form = $this->factory->createNamed('name', CountryType::class, 'AT');
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [./option[@value="AT"][@selected="selected"][.="Austria"]]
+ [count(./option)>200]
+'
+ );
+ }
+
+ public function testCountryWithPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', CountryType::class, 'AT', [
+ 'placeholder' => 'Select&Country',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Country[/trans]"]]
+ [./option[@value="AT"][@selected="selected"][.="Austria"]]
+ [count(./option)>201]
+'
+ );
+ }
+
+ public function testDateTime()
+ {
+ $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [
+ 'input' => 'string',
+ 'with_seconds' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+
+ ./select
+ [@id="name_date_month"]
+ [@class="form-select"]
+ [./option[@value="2"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_day"]
+ [@class="form-select"]
+ [./option[@value="3"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_year"]
+ [@class="form-select"]
+ [./option[@value="'.date('Y').'"][@selected="selected"]]
+ ]
+ /following-sibling::div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_time_hour"]
+ [@class="form-select"]
+ [./option[@value="4"][@selected="selected"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_time_minute"]
+ [@class="form-select"]
+ [./option[@value="5"][@selected="selected"]]
+ ]
+ ]
+ [count(.//select)=5]
+'
+ );
+ }
+
+ public function testDateTimeWithPlaceholderGlobal()
+ {
+ $form = $this->factory->createNamed('name', DateTimeType::class, null, [
+ 'input' => 'string',
+ 'placeholder' => 'Change&Me',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_date_month"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ /following-sibling::select
+ [@id="name_date_day"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ /following-sibling::select
+ [@id="name_date_year"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ ]
+ /following-sibling::div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_time_hour"]
+ [@class="form-select"]
+ [./option[@value=""][.="[trans]Change&Me[/trans]"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_time_minute"]
+ [@class="form-select"]
+ [./option[@value=""][.="[trans]Change&Me[/trans]"]]
+ ]
+ ]
+ [count(.//select)=5]
+'
+ );
+ }
+
+ public function testDateTimeWithHourAndMinute()
+ {
+ $data = ['year' => date('Y'), 'month' => '2', 'day' => '3', 'hour' => '4', 'minute' => '5'];
+
+ $form = $this->factory->createNamed('name', DateTimeType::class, $data, [
+ 'input' => 'array',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_date_month"]
+ [@class="form-select"]
+ [./option[@value="2"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_day"]
+ [@class="form-select"]
+ [./option[@value="3"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_year"]
+ [@class="form-select"]
+ [./option[@value="'.date('Y').'"][@selected="selected"]]
+ ]
+ /following-sibling::div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_time_hour"]
+ [@class="form-select"]
+ [./option[@value="4"][@selected="selected"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_time_minute"]
+ [@class="form-select"]
+ [./option[@value="5"][@selected="selected"]]
+ ]
+ ]
+ [count(.//select)=5]
+'
+ );
+ }
+
+ public function testDateTimeWithSeconds()
+ {
+ $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [
+ 'input' => 'string',
+ 'with_seconds' => true,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_date_month"]
+ [@class="form-select"]
+ [./option[@value="2"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_day"]
+ [@class="form-select"]
+ [./option[@value="3"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_date_year"]
+ [@class="form-select"]
+ [./option[@value="'.date('Y').'"][@selected="selected"]]
+ ]
+ /following-sibling::div
+ [@class="visually-hidden"]
+ /following-sibling::div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_time_hour"]
+ [@class="form-select"]
+ [./option[@value="4"][@selected="selected"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_time_minute"]
+ [@class="form-select"]
+ [./option[@value="5"][@selected="selected"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_time_second"]
+ [@class="form-select"]
+ [./option[@value="6"][@selected="selected"]]
+ ]
+ ]
+ [count(.//select)=6]
+'
+ );
+ }
+
+ public function testDateTimeSingleText()
+ {
+ $form = $this->factory->createNamed('name', DateTimeType::class, '2011-02-03 04:05:06', [
+ 'input' => 'string',
+ 'date_widget' => 'single_text',
+ 'time_widget' => 'single_text',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./input
+ [@type="date"]
+ [@id="name_date"]
+ [@name="name[date]"]
+ [@class="form-control"]
+ [@value="2011-02-03"]
+ /following-sibling::input
+ [@type="time"]
+ [@id="name_time"]
+ [@name="name[time]"]
+ [@class="form-control"]
+ [@value="04:05"]
+ ]
+'
+ );
+ }
+
+ public function testDateChoice()
+ {
+ $form = $this->factory->createNamed('name', DateType::class, date('Y').'-02-03', [
+ 'input' => 'string',
+ 'widget' => 'choice',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_month"]
+ [@class="form-select"]
+ [./option[@value="2"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_day"]
+ [@class="form-select"]
+ [./option[@value="3"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value="'.date('Y').'"][@selected="selected"]]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testDateChoiceWithPlaceholderGlobal()
+ {
+ $form = $this->factory->createNamed('name', DateType::class, null, [
+ 'input' => 'string',
+ 'widget' => 'choice',
+ 'placeholder' => 'Change&Me',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_month"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ /following-sibling::select
+ [@id="name_day"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ /following-sibling::select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testDateChoiceWithPlaceholderOnYear()
+ {
+ $form = $this->factory->createNamed('name', DateType::class, null, [
+ 'input' => 'string',
+ 'widget' => 'choice',
+ 'required' => false,
+ 'placeholder' => ['year' => 'Change&Me'],
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_month"]
+ [@class="form-select"]
+ [./option[@value="1"]]
+ /following-sibling::select
+ [@id="name_day"]
+ [@class="form-select"]
+ [./option[@value="1"]]
+ /following-sibling::select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testDateText()
+ {
+ $form = $this->factory->createNamed('name', DateType::class, '2011-02-03', [
+ 'input' => 'string',
+ 'widget' => 'text',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./input
+ [@id="name_month"]
+ [@type="text"]
+ [@class="form-control"]
+ [@value="2"]
+ /following-sibling::input
+ [@id="name_day"]
+ [@type="text"]
+ [@class="form-control"]
+ [@value="3"]
+ /following-sibling::input
+ [@id="name_year"]
+ [@type="text"]
+ [@class="form-control"]
+ [@value="2011"]
+ ]
+ [count(./input)=3]
+ ]
+'
+ );
+ }
+
+ public function testBirthDay()
+ {
+ $form = $this->factory->createNamed('name', BirthdayType::class, '2000-02-03', [
+ 'input' => 'string',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_month"]
+ [@class="form-select"]
+ [./option[@value="2"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_day"]
+ [@class="form-select"]
+ [./option[@value="3"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value="2000"][@selected="selected"]]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testBirthDayWithPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', BirthdayType::class, '1950-01-01', [
+ 'input' => 'string',
+ 'placeholder' => '',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_month"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.=""]]
+ [./option[@value="1"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_day"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.=""]]
+ [./option[@value="1"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.=""]]
+ [./option[@value="1950"][@selected="selected"]]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testLanguage()
+ {
+ $form = $this->factory->createNamed('name', LanguageType::class, 'de');
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [./option[@value="de"][@selected="selected"][.="German"]]
+ [count(./option)>200]
+'
+ );
+ }
+
+ public function testLocale()
+ {
+ $form = $this->factory->createNamed('name', LocaleType::class, 'de_AT');
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [./option[@value="de_AT"][@selected="selected"][.="German (Austria)"]]
+ [count(./option)>200]
+'
+ );
+ }
+
+ public function testMoney()
+ {
+ $form = $this->factory->createNamed('name', MoneyType::class, 1234.56, [
+ 'currency' => 'EUR',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']],
+ '/div
+ [@class="input-group"]
+ [
+ ./span
+ [@class="input-group-text"]
+ [contains(.., "€")]
+ /following-sibling::input
+ [@id="my&id"]
+ [@type="text"]
+ [@name="name"]
+ [@class="my&class form-control"]
+ [@value="1234.56"]
+ ]
+'
+ );
+ }
+
+ public function testPercent()
+ {
+ $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['rounding_mode' => \NumberFormatter::ROUND_CEILING]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']],
+ '/div
+ [@class="input-group"]
+ [
+ ./input
+ [@id="my&id"]
+ [@type="text"]
+ [@name="name"]
+ [@class="my&class form-control"]
+ [@value="10"]
+ /following-sibling::span
+ [@class="input-group-text"]
+ [contains(.., "%")]
+ ]
+'
+ );
+ }
+
+ public function testPercentCustomSymbol()
+ {
+ $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['symbol' => '‱', 'rounding_mode' => \NumberFormatter::ROUND_CEILING]);
+ $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']],
+ '/div
+ [@class="input-group"]
+ [
+ ./input
+ [@id="my&id"]
+ [@type="text"]
+ [@name="name"]
+ [@class="my&class form-control"]
+ [@value="10"]
+ /following-sibling::span
+ [@class="input-group-text"]
+ [contains(.., "‱")]
+ ]
+'
+ );
+ }
+
+ public function testRange()
+ {
+ $form = $this->factory->createNamed('name', RangeType::class, 42, ['attr' => ['min' => 5]]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/input
+ [@type="range"]
+ [@name="name"]
+ [@value="42"]
+ [@min="5"]
+ [@class="my&class form-range"]
+'
+ );
+ }
+
+ public function testRangeWithMinMaxValues()
+ {
+ $form = $this->factory->createNamed('name', RangeType::class, 42, ['attr' => ['min' => 5, 'max' => 57]]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/input
+ [@type="range"]
+ [@name="name"]
+ [@value="42"]
+ [@min="5"]
+ [@max="57"]
+ [@class="my&class form-range"]
+'
+ );
+ }
+
+ public function testColor()
+ {
+ $color = '#0000ff';
+ $form = $this->factory->createNamed('name', ColorType::class, $color);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/input
+ [@type="color"]
+ [@name="name"]
+ [@class="my&class form-control form-control-color"]
+ [@value="#0000ff"]
+'
+ );
+ }
+
+ public function testTime()
+ {
+ $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [
+ 'input' => 'string',
+ 'with_seconds' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_hour"]
+ [@class="form-select"]
+ [not(@size)]
+ [./option[@value="4"][@selected="selected"]]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_minute"]
+ [@class="form-select"]
+ [not(@size)]
+ [./option[@value="5"][@selected="selected"]]
+ ]
+ [count(./select)=2]
+ ]
+'
+ );
+ }
+
+ public function testTimeWithSeconds()
+ {
+ $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [
+ 'input' => 'string',
+ 'with_seconds' => true,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_hour"]
+ [@class="form-select"]
+ [not(@size)]
+ [./option[@value="4"][@selected="selected"]]
+ [count(./option)>23]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_minute"]
+ [@class="form-select"]
+ [not(@size)]
+ [./option[@value="5"][@selected="selected"]]
+ [count(./option)>59]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_second"]
+ [@class="form-select"]
+ [not(@size)]
+ [./option[@value="6"][@selected="selected"]]
+ [count(./option)>59]
+ ]
+ [count(./select)=3]
+ ]
+'
+ );
+ }
+
+ public function testTimeText()
+ {
+ $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [
+ 'input' => 'string',
+ 'widget' => 'text',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./input
+ [@type="text"]
+ [@id="name_hour"]
+ [@name="name[hour]"]
+ [@class="form-control"]
+ [@value="04"]
+ [@required="required"]
+ [not(@size)]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::input
+ [@type="text"]
+ [@id="name_minute"]
+ [@name="name[minute]"]
+ [@class="form-control"]
+ [@value="05"]
+ [@required="required"]
+ [not(@size)]
+ ]
+ [count(./input)=2]
+ ]
+'
+ );
+ }
+
+ public function testTimeWithPlaceholderGlobal()
+ {
+ $form = $this->factory->createNamed('name', TimeType::class, null, [
+ 'input' => 'string',
+ 'placeholder' => 'Change&Me',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_hour"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ [count(./option)>24]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_minute"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ [count(./option)>60]
+ ]
+ [count(./select)=2]
+ ]
+'
+ );
+ }
+
+ public function testTimeWithPlaceholderOnYear()
+ {
+ $form = $this->factory->createNamed('name', TimeType::class, null, [
+ 'input' => 'string',
+ 'required' => false,
+ 'placeholder' => ['hour' => 'Change&Me'],
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./div
+ [@class="input-group"]
+ [
+ ./select
+ [@id="name_hour"]
+ [@class="form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]]
+ [count(./option)>24]
+ /following-sibling::span
+ [@class="input-group-text"]
+ /following-sibling::select
+ [@id="name_minute"]
+ [./option[@value="1"]]
+ [count(./option)>59]
+ ]
+ [count(./select)=2]
+ ]
+'
+ );
+ }
+
+ public function testTimezone()
+ {
+ $form = $this->factory->createNamed('name', TimezoneType::class, 'Europe/Vienna');
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@name="name"]
+ [@class="my&class form-select"]
+ [not(@required)]
+ [./option[@value="Europe/Vienna"][@selected="selected"][.="Europe / Vienna"]]
+ [count(.//option)>200]
+'
+ );
+ }
+
+ public function testTimezoneWithPlaceholder()
+ {
+ $form = $this->factory->createNamed('name', TimezoneType::class, null, [
+ 'placeholder' => 'Select&Timezone',
+ 'required' => false,
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/select
+ [@class="my&class form-select"]
+ [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Timezone[/trans]"]]
+ [count(.//option)>201]
+'
+ );
+ }
+
+ public function testWeekChoices()
+ {
+ $this->requiresFeatureSet(404);
+
+ $data = ['year' => (int) date('Y'), 'week' => 1];
+
+ $form = $this->factory->createNamed('name', WeekType::class, $data, [
+ 'input' => 'array',
+ 'widget' => 'choice',
+ ]);
+
+ $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']],
+ '/div
+ [@class="my&class"]
+ [
+ ./select
+ [@id="name_year"]
+ [@class="form-select"]
+ [./option[@value="'.$data['year'].'"][@selected="selected"]]
+ /following-sibling::select
+ [@id="name_week"]
+ [@class="form-select"]
+ [./option[@value="'.$data['week'].'"][@selected="selected"]]
+ ]
+ [count(.//select)=2]'
+ );
+ }
+
+ public function testFloatingLabel()
+ {
+ $form = $this->factory->createNamed('name', TextType::class, null, [
+ 'attr' => [
+ 'placeholder' => 'name',
+ ],
+ 'row_attr' => [
+ 'class' => 'form-floating mb-3',
+ ],
+ ]);
+
+ $html = $this->renderRow($form->createView());
+
+ $this->assertMatchesXpath($html,
+ '/div
+ [@class="form-floating mb-3"]
+ [
+ ./input
+ [@id="name"]
+ [@placeholder="[trans]name[/trans]"]
+ /following-sibling::label
+ [@for="name"]
+ ]
+'
+ );
+ }
+}
diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php
new file mode 100644
index 0000000000..042cbf0e40
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php
@@ -0,0 +1,113 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Extension;
+
+use Symfony\Bridge\Twig\Extension\FormExtension;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
+use Symfony\Bridge\Twig\Form\TwigRendererEngine;
+use Symfony\Bridge\Twig\Tests\Extension\Fixtures\StubTranslator;
+use Symfony\Component\Form\FormRenderer;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+
+/**
+ * Class providing test cases for the Bootstrap 5 horizontal Twig form theme.
+ *
+ * @author Romain Monteil
+ */
+class FormExtensionBootstrap5HorizontalLayoutTest extends AbstractBootstrap5HorizontalLayoutTest
+{
+ use RuntimeLoaderProvider;
+
+ protected $testableFeatures = [
+ 'choice_attr',
+ ];
+
+ private $renderer;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $loader = new FilesystemLoader([
+ __DIR__.'/../../Resources/views/Form',
+ __DIR__.'/Fixtures/templates/form',
+ ]);
+
+ $environment = new Environment($loader, ['strict_variables' => true]);
+ $environment->addExtension(new TranslationExtension(new StubTranslator()));
+ $environment->addExtension(new FormExtension());
+
+ $rendererEngine = new TwigRendererEngine([
+ 'bootstrap_5_horizontal_layout.html.twig',
+ 'custom_widgets.html.twig',
+ ], $environment);
+ $this->renderer = new FormRenderer($rendererEngine, $this->getMockBuilder(CsrfTokenManagerInterface::class)->getMock());
+ $this->registerTwigRuntimeLoader($environment, $this->renderer);
+ }
+
+ protected function renderForm(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->renderBlock($view, 'form', $vars);
+ }
+
+ protected function renderLabel(FormView $view, $label = null, array $vars = []): string
+ {
+ if (null !== $label) {
+ $vars += ['label' => $label];
+ }
+
+ return (string) $this->renderer->searchAndRenderBlock($view, 'label', $vars);
+ }
+
+ protected function renderHelp(FormView $view): string
+ {
+ return (string) $this->renderer->searchAndRenderBlock($view, 'help');
+ }
+
+ protected function renderErrors(FormView $view): string
+ {
+ return (string) $this->renderer->searchAndRenderBlock($view, 'errors');
+ }
+
+ protected function renderWidget(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->searchAndRenderBlock($view, 'widget', $vars);
+ }
+
+ protected function renderRow(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->searchAndRenderBlock($view, 'row', $vars);
+ }
+
+ protected function renderRest(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->searchAndRenderBlock($view, 'rest', $vars);
+ }
+
+ protected function renderStart(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->renderBlock($view, 'form_start', $vars);
+ }
+
+ protected function renderEnd(FormView $view, array $vars = []): string
+ {
+ return (string) $this->renderer->renderBlock($view, 'form_end', $vars);
+ }
+
+ protected function setTheme(FormView $view, array $themes, $useDefaultThemes = true): void
+ {
+ $this->renderer->setTheme($view, $themes, $useDefaultThemes);
+ }
+}
diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php
new file mode 100644
index 0000000000..1bf4a315fa
--- /dev/null
+++ b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php
@@ -0,0 +1,165 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Extension;
+
+use Symfony\Bridge\Twig\Extension\FormExtension;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
+use Symfony\Bridge\Twig\Form\TwigRendererEngine;
+use Symfony\Bridge\Twig\Tests\Extension\Fixtures\StubTranslator;
+use Symfony\Component\Form\Extension\Core\Type\FormType;
+use Symfony\Component\Form\Extension\Core\Type\MoneyType;
+use Symfony\Component\Form\FormRenderer;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+
+/**
+ * Class providing test cases for the Bootstrap 5 Twig form theme.
+ *
+ * @author Romain Monteil
+ */
+class FormExtensionBootstrap5LayoutTest extends AbstractBootstrap5LayoutTest
+{
+ use RuntimeLoaderProvider;
+
+ /**
+ * @var FormRenderer
+ */
+ private $renderer;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $loader = new FilesystemLoader([
+ __DIR__.'/../../Resources/views/Form',
+ __DIR__.'/Fixtures/templates/form',
+ ]);
+
+ $environment = new Environment($loader, ['strict_variables' => true]);
+ $environment->addExtension(new TranslationExtension(new StubTranslator()));
+ $environment->addExtension(new FormExtension());
+
+ $rendererEngine = new TwigRendererEngine([
+ 'bootstrap_5_layout.html.twig',
+ 'custom_widgets.html.twig',
+ ], $environment);
+ $this->renderer = new FormRenderer($rendererEngine, $this->getMockBuilder(CsrfTokenManagerInterface::class)->getMock());
+ $this->registerTwigRuntimeLoader($environment, $this->renderer);
+ }
+
+ public function testStartTagHasNoActionAttributeWhenActionIsEmpty()
+ {
+ $form = $this->factory->create(FormType::class, null, [
+ 'method' => 'get',
+ 'action' => '',
+ ]);
+
+ $html = $this->renderStart($form->createView());
+
+ self::assertSame('