If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLook closer at the Bootstrap help feature: to be nice to screen readers, we should add an aria-describedby
attribute to the field that points to an id
that we add to the help span:
<label class="sr-only" for="inputHelpBlock">Input with help text</label>
<input type="text" id="inputHelpBlock" class="form-control" aria-describedby="helpBlock">
...
<span id="helpBlock" class="help-block">
A block of help text that breaks onto a new line and may extend beyond one line.
</span>
That way, when a screen reader focuses on the text box, it will read the help text, which is pretty rad. It also turns out that pulling this off is a cool challenge!
Let's start with a plan: when the form_widget()
function is called inside form_row
:
... lines 1 - 2 | |
{% block form_row -%} | |
... line 4 | |
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}"> | |
... line 6 | |
{{- form_widget(form) -}} | |
... lines 8 - 11 | |
</div> | |
{%- endblock form_row %} | |
... lines 14 - 26 |
we want the attr
variable to have a new key called aria-describedby
. We've seen magic like this before: in the Bootstrap layout, the form_widget_simple
block modifies the attr
variable before calling the parent block:
... lines 1 - 4 | |
{% block form_widget_simple -%} | |
{% if type is not defined or type not in ['file', 'hidden'] %} | |
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} | |
{% endif %} | |
{{- parent() -}} | |
{%- endblock form_widget_simple %} | |
... lines 11 - 246 |
That's what we want to do!
Back in our block, before form_widget()
, add another if help|default
. Inside, set attr = attr|merge()
with an array argument. The core merge
filter will array_merge()
the argument back into the attr
variable. Add aria-describedby
set to... well, nothing yet:
... lines 1 - 2 | |
{% block form_row -%} | |
... line 4 | |
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}"> | |
... line 6 | |
{% if help|default %} | |
{# set the aria-describedby attribute #} | |
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%} | |
{% endif %} | |
... lines 11 - 15 | |
</div> | |
{%- endblock form_row %} | |
... lines 18 - 30 |
First, we need to give our help span an id. Do that: set it to help-block-
then print the id
variable to make sure this is unique:
... lines 1 - 2 | |
{% block form_row -%} | |
... line 4 | |
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}"> | |
... line 6 | |
{% if help|default %} | |
{# set the aria-describedby attribute #} | |
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%} | |
{% endif %} | |
... line 11 | |
{% if help|default %} | |
<span class="help-block" id="help-block-{{ id }}">{{ help }}</span> | |
{% endif %} | |
... line 15 | |
</div> | |
{%- endblock form_row %} | |
... lines 18 - 30 |
The id
variable will become the id
attribute on the field itself.
Now set the aria-describedby
to help-block-
, a ~
then id:
... lines 1 - 2 | |
{% block form_row -%} | |
... line 4 | |
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}"> | |
... line 6 | |
{% if help|default %} | |
{# set the aria-describedby attribute #} | |
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%} | |
{% endif %} | |
... lines 11 - 15 | |
</div> | |
{%- endblock form_row %} | |
... lines 18 - 30 |
The ~
is Twig's rarely-used concatenation operator, so, it's like .
in PHP.
Ok! Now that attr
is changed before we call form_widget
, it'll hopefully render on that widget. Time to give it a try. Refresh!
Ok, go dig into the source to see if the attribute is there. Umm... it's not! There is not any aria-describedby
. This tutorial is a LIE!
No no, it's cool. It turns out that there's a very subtle, but important detail that I'm neglecting. Let me show you: click to open the parent form_div_layout.html.twig
template. We're letting Symfony guess this, but the speciesCount
is a NumberType
, meaning it'll render as an <input type="number" />
field:
... lines 1 - 13 | |
class GenusFormType extends AbstractType | |
{ | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
$builder | |
... lines 19 - 26 | |
->add('speciesCount') | |
... lines 28 - 39 | |
; | |
} | |
... lines 42 - 48 | |
} |
Inside the layout file, find the number_widget
block that renders this:
... lines 1 - 133 | |
{%- block number_widget -%} | |
{# type="number" doesn't work with floats #} | |
{%- set type = type|default('text') -%} | |
{{ block('form_widget_simple') }} | |
{%- endblock number_widget -%} | |
... lines 139 - 372 |
Ok, check it out: it sets a type
variable, and then calls the form_widget_simple
block. Then, when form_widget_simple
executes, type
is set to number
:
... lines 1 - 10 | |
{%- block form_widget_simple -%} | |
{%- set type = type|default('text') -%} | |
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/> | |
{%- endblock form_widget_simple -%} | |
... lines 15 - 372 |
So, why does that work, but not our code? Well look at that code again: it sets a variable and then calls the block function. When you call block()
, all variables flow through to that block.
But now check out our code: we set a variable, but then we don't execute a block! We call form_widget()
. Hey! that's a form rendering function - the same kind that we use inside our normal templates. In this case, the variables do not magically flow through. But that's ok! We already know how to pass variables into form_widget()
. Add a second argument, and pass attr
set to attr
:
... lines 1 - 2 | |
{% block form_row -%} | |
... line 4 | |
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}"> | |
... lines 6 - 10 | |
{{- form_widget(form, { | |
'attr': attr | |
}) -}} | |
... lines 14 - 17 | |
</div> | |
{%- endblock form_row %} | |
... lines 20 - 32 |
Let's refresh! Inspect the field, well, not any field - inspect the isPublished
field. This time, we got it!
So not only are you a form-theming pro, but you're quickly becoming a Twig all star.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": "^7.1.3",
"symfony/symfony": "3.4.*", // v3.4.49
"doctrine/orm": "^2.5", // 2.7.5
"doctrine/doctrine-bundle": "^1.6", // 1.12.13
"doctrine/doctrine-cache-bundle": "^1.2", // 1.4.0
"symfony/swiftmailer-bundle": "^2.3", // v2.6.7
"symfony/monolog-bundle": "^2.8", // v2.12.1
"symfony/polyfill-apcu": "^1.0", // v1.23.0
"sensio/distribution-bundle": "^5.0", // v5.0.25
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
"incenteev/composer-parameter-handler": "^2.0", // v2.1.4
"composer/package-versions-deprecated": "^1.11", // 1.11.99.4
"knplabs/knp-markdown-bundle": "^1.4", // 1.9.0
"doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
"stof/doctrine-extensions-bundle": "^1.2" // v1.3.0
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.1.7
"symfony/phpunit-bridge": "^3.0", // v3.4.47
"nelmio/alice": "^2.1", // v2.3.6
"doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
}
}