Reveal the Drupal tab holding an invalid required field
When a Drupal node form splits its fields across horizontal tabs, a required field on a hidden tab blocks the browser's validation: the form refuses to submit, yet the invalid control cannot be shown. The fix is a small behaviour that listens for the invalid event in the capture phase and focuses the tab pane that contains the first invalid control.
Why does the form just refuse to submit?
With native HTML5 validation, submitting a form with an empty required field makes the browser focus the first invalid control and show a message. If that control sits in a tab pane that is currently hidden, the browser cannot focus it. Depending on the browser the user sees nothing, or a console message that an invalid form control is not focusable. To an editor it looks like the Save button is broken.
Why a normal event listener does not work
The invalid event is fired on each invalid control, but it does not bubble. A listener attached to the form with the default settings never hears it. Two options remain: attach a listener to every control, or listen on the form in the capture phase, where the event passes on its way down to the target. Capture is one listener and survives controls being added or replaced.
What does the behaviour look like?
A teammate's fix, rewritten here as a minimal example, uses a Drupal behaviour and once() so it attaches a single time per form:
((Drupal, once, $) => {
Drupal.behaviors.revealInvalidTab = {
attach(context) {
once('reveal-invalid-tab', 'form.node-form', context).forEach((form) => {
form.addEventListener(
'invalid',
(event) => {
const tab = $(event.target)
.closest('.horizontal-tabs-pane')
.data('horizontalTab');
if (tab) {
tab.focus();
}
},
true,
);
});
},
};
})(Drupal, once, jQuery);
Drupal core's horizontal tabs store a tab object on each pane through jQuery data. Calling focus() on that object selects the tab and shows its pane, which makes the invalid control focusable just in time for the browser's own attempt to focus it.
Why reveal only the first invalid control's tab?
The first version had a subtle flaw. When several controls are invalid, the browser fires invalid on every one of them, in document order. Each handler revealed its own tab, so the last invalid control won and the editor landed on the wrong tab, while the browser focuses only the first invalid control.
The follow-up guard compares the event target with the first invalid control in the form and returns early otherwise:
const first = form.querySelector(
'input:invalid, select:invalid, textarea:invalid'
);
if (event.target !== first) {
return;
}
This relies on the browser's own definition of "first": the earliest matching control in document order, which is also the one it tries to focus.
How do you attach it to the right forms?
The library is attached only to the forms that need it. In this case an event subscriber alters the form for a single content type's add and edit forms and adds the library. Attaching it to every node form would also work, but then you are changing behaviour for content types whose tabs nobody asked you to touch. If your content types use vertical tabs or details elements, the selector and the way the pane is revealed differ, so check what object your widget stores before reusing the snippet.
What is not covered?
This handles client-side validation of native required attributes. Errors returned by the server after a submit are a separate path, and errors inside collapsed details elements need their own handling.
Key takeaways
- A required field inside a hidden tab pane cannot be focused by the browser, so the form seems to refuse to submit for no reason.
- The
invalidevent does not bubble, so use a capture-phase listener on the form. - Focus the tab object that Drupal's horizontal tabs keep on the pane, so the control becomes visible before the browser tries to focus it.
- Only act on the first invalid control, otherwise the last invalid control's tab wins.
- Attach the library only to the forms that need it, and use
once()so the listener is added a single time.