DEV Community

Cover image for A tabbed form that silently refused to submit — required fields hidden behind another tab
Susumu Takahashi
Susumu Takahashi

Posted on Originally published at en.wpmm.jp

A tabbed form that silently refused to submit — required fields hidden behind another tab

Background

The site edit modal kept accumulating fields — site name, category, SSH connection details, WordPress install location — until editing anything meant scrolling up and down a single long form to find the right field. To clean this up, we split it into three tabs: "Registration info," "SSH," and "WordPress info." That change broke form submission itself, in a way that was hard to spot at first.

What tabbing broke

The tab implementation itself is straightforward. Each tab's fields live in a <div class="site-tab-content" data-tab="...">, and CSS toggles which one is visible.

.site-tab-content { display: none; }
.site-tab-content.active { display: block; }
Enter fullscreen mode Exit fullscreen mode

An inactive tab is hidden with display: none. Nothing unusual so far, and visually it worked fine.

The problem showed up when a required field sat in a tab that was not currently active, and the user left it empty while saving from a different tab. Clicking the save button did nothing. No error message appeared. The form just looked stuck.

Root cause: a browser cannot report an error on a field it cannot show

HTML5 form validation works by having the browser automatically block the submit event whenever a constrained field (like required) fails, then focusing that field and showing its standard validation bubble (equivalent to calling reportValidity()).

Note: reportValidity() is a method from the HTML5 Constraint Validation API. It checks whether a form element's value satisfies its constraints (required, pattern, etc.) and, if not, displays the browser's standard error bubble.

But when the failing field sits inside a tab hidden with display: none, the browser has nowhere to anchor that error bubble. It still faithfully blocks the submit — but it cannot visualize the error, so it simply stops without any visible feedback. From the user's side, this looks exactly like a button that does not respond.

Before tabbing, every field lived on the same screen, so this never surfaced. Introducing tabs — a UI pattern that deliberately limits what's visible at once — broke an implicit assumption the browser's built-in validation depends on: that an invalid field is always visible.

The fix: novalidate plus JS-driven validation

The fix was to stop relying on the browser's automatic blocking. Adding novalidate to the form disables that automatic block and guarantees the submit event always fires, letting JavaScript take full control of validation.

async function saveSite(e) {
    e.preventDefault();
    const form = e.target;

    // If checkValidity() fails, switch to the tab containing the first
    // :invalid element, then focus it and call reportValidity().
    if (!form.checkValidity()) {
        const firstInvalid = form.querySelector(':invalid');
        if (firstInvalid) {
            const tabContent = firstInvalid.closest('.site-tab-content');
            if (tabContent && tabContent.getAttribute('data-tab')) {
                switchSiteTab(tabContent.getAttribute('data-tab'));
            }
            // Focus/scroll may not work right after a tab switch,
            // so call reportValidity() on the next frame.
            requestAnimationFrame(() => {
                try { firstInvalid.focus({ preventScroll: false }); } catch (_) {}
                firstInvalid.reportValidity();
            });
        }
        return;  // abort submission on validation failure
    }
    // ...normal save flow continues here
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

  1. novalidate does not disable the constraints themselves. Attributes like required and pattern remain in effect, and both checkValidity() and the :invalid pseudo-class still work as expected. What novalidate disables is only the browser's automatic "block submit and show the error" behavior.
  2. The code finds the first :invalid element and forces a switch to its tab before calling reportValidity(). By making the field visible first, the browser can render its error bubble without issue. Since a tab switch may not have finished painting yet, the code waits one frame via requestAnimationFrame before calling focus() and reportValidity().

A concrete case: making the SSH profile conditionally required

This mechanism paid off directly with the SSH profile requirement. The site edit modal has an "Update via browser only (no SSH)" checkbox; leaving it unchecked makes selecting an SSH profile mandatory. We wanted to prevent saving a site that satisfies neither option — no profile selected, and the checkbox left unchecked.

function toggleSSHFields() {
    const isBrowserOnly = document.getElementById('browser_only_check').checked;
    const profileSelect = document.getElementById('server_profile_select');

    if (isBrowserOnly) {
        // Browser-only mode doesn't need a profile
        profileSelect.value = "";
        profileSelect.removeAttribute('required');
    } else {
        // SSH mode requires a profile
        profileSelect.setAttribute('required', '');
    }
}
Enter fullscreen mode Exit fullscreen mode

The required attribute is toggled dynamically based on the checkbox state, and the rest is handled by the cross-tab validation logic in saveSite described above. Trying to save without a profile selected automatically opens the "SSH" tab and shows the error bubble right on the profile dropdown. The user never has to hunt for why saving isn't working.

A side benefit: accessibility via ARIA

Alongside the tab split, we added role="tablist" / role="tab" / role="tabpanel" and aria-selected / aria-controls / aria-labelledby.

<div class="site-tabs" role="tablist" aria-label="Site edit tabs">
    <button type="button" class="site-tab-btn active" role="tab"
            id="site-tab-btn-info" aria-selected="true"
            aria-controls="site-tab-panel-info">Registration info</button>
    <!-- SSH and WordPress info tabs follow the same pattern -->
</div>
Enter fullscreen mode Exit fullscreen mode

The tab-switching JavaScript keeps aria-selected in sync as well.

function switchSiteTab(tabName) {
    form.querySelectorAll('.site-tab-btn').forEach(btn => {
        const isActive = btn.getAttribute('data-tab-target') === tabName;
        btn.classList.toggle('active', isActive);
        btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
    });
}
Enter fullscreen mode Exit fullscreen mode

It is not just a visual toggle — screen reader users also get told which tab is currently selected.

Wrap-up

Splitting a form into tabs feels like an intuitive cleanup, but it can quietly break an assumption the browser's built-in validation depends on: that an invalid field is always visible on screen. Disabling the automatic block with novalidate, keeping the constraint checks alive through checkValidity() / :invalid, and making the failing field visible before calling reportValidity() — that small extra step is what lets a tabbed UI coexist with standard form validation. It's a modest but broadly reusable pattern for any design that splits a form across multiple views.

Top comments (10)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

One edge in form.querySelector(':invalid'): :invalid also matches a fieldset that merely contains an invalid control, so if a tab body is wrapped in one, the query returns the wrapper, closest('.site-tab-content') can resolve to the wrong tab, and reportValidity() on a non-candidate element shows no bubble at all. That puts you back at the original silent failure with the fix in place, which is a nasty one to debug twice. Taking the first element whose willValidate is true keeps the search on real controls. The query is also DOM order rather than tab order, so with invalid fields in two tabs the bubble may open in the one the user was not editing.

Collapse
 
susumun profile image
Susumu Takahashi

This is a real gap, thanks for digging into it. To be precise about this codebase: the tab bodies here are plain

, not , so :invalid only ever matches the actual input in practice — this specific failure mode doesn't currently trigger for us.

But you're right that it's a landmine for anyone applying this same pattern with fieldset-wrapped sections, since a

does match :invalid when it contains an invalid descendant, and document order puts it first. Filtering for willValidate is the correct fix — I'd probably write it as:

const firstInvalid = Array.from(form.querySelectorAll(':invalid'))
.find(el => el.willValidate);

Good reminder that "first :invalid match" is an assumption specific to this DOM shape, not a general guarantee. Might add a note about this in the post.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Worth keeping in that note: willValidate stays true for a control hidden by an ancestor's display:none, so the filtered query still hands you the input in the inactive tab, which is fine as long as the tab switch happens before reportValidity(). I checked in Chrome 151 with a required input inside a display:none wrapper - willValidate is true, reportValidity() returns false, and focus does not move, so calling it in the wrong order puts you back at the silent failure with the fix in place. A checkVisibility() assert right before the report call is a cheap way to make that ordering explicit instead of implied.

Thread Thread
 
susumun profile image
Susumu Takahashi

Appreciate you actually verifying this in Chrome — that's a more rigorous check than I did. You're right that the requestAnimationFrame in the post is implicitly relying on that ordering (tab switch paints → then focus/reportValidity), and nothing enforces it explicitly. It happens to hold today because switchSiteTab() is synchronous and one frame is enough for the browser to apply the display change, but that's an assumption baked into the timing, not a guarantee.

A checkVisibility() assert right before reportValidity() is a good, cheap safeguard — it'd fail loudly if someone later refactors the tab-switch to be async, or the frame timing changes, instead of quietly reintroducing the exact bug this post is about. Might genuinely add that as a defensive check. Thanks for pressure-testing this as far as you have.

Thread Thread
 
vinhnguyenthanhdn profile image
Vinh Nguyen

One correction to what I suggested: checkVisibility() with no arguments is narrower than the name implies. I checked in Chrome 151 - a required input inside visibility:hidden or opacity:0 still returns true, and only display:none returns false. So if the panels are ever hidden by anything other than display, the assert needs checkVisibility({checkOpacity: true, checkVisibilityCSS: true}) to fail in the case where the bubble would not appear.

Collapse
 
crdtcto profile image
Kane Lim

One detail I’d add is that the browser can still determine that a display:none field violates a constraint—the bigger issue is that native error reporting/focusing becomes unreliable or effectively invisible when the invalid control is not rendered. That distinction is useful when debugging similar forms.

I especially like the requestAnimationFrame() step after switching tabs. For a production implementation, I’d consider extracting the validation flow into a reusable function:

checkValidity() → identify invalid field → locate owning tab → activate tab → focus/reportValidity()

That makes the pattern easier to reuse with dynamically added fields and future tabs instead of coupling it directly to saveSite().

The accessibility work is also a good direction. Beyond aria-selected, aria-controls, and the tab/tabpanel roles, I’d make sure the component supports keyboard navigation (ArrowLeft/Right, Home, End) and follows a consistent focus-management strategy.

The broader lesson here is valuable: when native browser behavior interacts with custom UI state, the transition between the two needs to be explicit. The pattern you've described is applicable well beyond tabs—multi-step forms, accordions, dialogs, conditional sections, and other interfaces where invalid controls may be temporarily hidden.

Collapse
 
susumun profile image
Susumu Takahashi

Good catch, and that's a more precise way to put it than I did in the post. checkValidity() still correctly flags the hidden field as invalid — it's specifically the rendering of the error bubble that fails silently when the element isn't visible. I should have been clearer that the constraint check itself isn't the broken part.

The checkValidity → locate tab → activate → focus/reportValidity extraction is exactly right, and coupling it to saveSite() directly was mostly "get it working first." Keyboard nav (Arrow/Home/End) on the tablist is a fair callout too — we did the ARIA roles but stopped short of full keyboard support. Appreciate the thorough read.

Collapse
 
crdtcto profile image
Kane Lim

I am glad that my opinion was helpful.
I would like to get to know you better. Would you please contact me? t_g_@CRDT_CTO

Collapse
 
bert_programmer profile image
Bert Shim

The silent part is what makes this expensive. I drive a lot of forms from scripts, and this failure looks identical to the click not landing, so you retry.

Ran into the mirror image today. A submit that looked like it failed, box still full, had actually gone through. A retry there would have posted twice. Now I check the target state before assuming anything about what the click did.

Collapse
 
susumun profile image
Susumu Takahashi

That's a really good parallel — a silently-blocked submit and a click that didn't land produce the exact same signal from the outside (nothing happened), so a script has no way to tell them apart without checking state.

Your mirror case is the scarier one honestly, since retrying on a false negative just double-submits. "Verify the target state before trusting what the click did" sounds like the right general rule for any UI automation, not just forms — thanks for sharing that.