Form multi-step in JavaScript tra semantica e accessibilità

In questo articolo realizzeremo un form multi-step gestito da JavaScript in modo semantico e accessibile. Semantico perché useremo gli elementi dei form concepiti per gestire il raggruppamento degli elementi e accessibile perché a livello CSS non useremo display: none per nascondere gli elementi.

Gli elementi fieldset e legend

fieldset e legend, come accennavamo prima, servono a raggruppare gli elementi dei form in modo logico. In tal modo tutti gli elementi che hanno in comune una determinata caratteristica vengono a trovarsi sotto il medesimo genitore (fieldset).

Ad esempio le informazioni relative ad un indirizzo di spedizione, le informazioni personali e le clausole sulla privacy appartengono semanticamente a gruppi diversi.

Certo, è possibile usare div con classi e un ID specifico, ma così facendo useremmo dei contenitori generici privi di semantica intrinseca.

La struttura HTML

Il nostro form avrà una navigazione superiore costituita da pulsanti che una volta cliccati permetteranno all'utente di accedere allo step successivo ma, cosa fondamentale, ciò avverrà solo se l'utente avrà compilato i campi obbligatori.

In caso contrario verrà mostrato un errore di validazione. Ciascun pulsante ha un attributo di dati che contiene il selettore che punta all'elemento fieldset corrispondente (tramite ID). In questo modo è possibile gestire contemporaneamente la navigazione e la validazione.

<form action="#" class="form-multistep" novalidate>
            <nav class="form-multistep-navigation">
                <button type="button" data-step="#step-1" class="form-nav-btn active">Shipping Information</button>
                <button type="button" data-step="#step-2" class="form-nav-btn">Account Information</button>
                <button type="button" data-step="#step-3" class="form-nav-btn">Privacy</button>
            </nav>
            <fieldset id="step-1">
                <legend>Shipping Information</legend>
                <!-- Indirizzo di spedizione -->
            </fieldset>
            <fieldset class="hidden" id="step-2">
                <legend>Account Information</legend>
                <!-- Informazioni personali -->
            </fieldset>
            <fieldset class="hidden" id="step-3">
                <legend>Privacy</legend>
                <!-- Clausole sulla privacy -->
                <div class="form-submit">
                    <button type="submit" class="btn">Submit</button>
                </div>
            </fieldset>
            <div class="form-message hidden">
                <!-- Messaggio di validazione -->
        </div>
        </form>

La classe CSS hidden

In breve, i lettori di schermo interpretano la dichiarazione display: none come speak: none, se vogliamo usare un parallelismo con i fogli di stile acustici.

Di conseguenza, gli elementi nascosti con tale dichiarazione CSS vengono completamente ignorati rendendo nel nostro caso il form compilabile solo per gli elementi del primo step.

A livello CSS si può semplicemente spostare l'elemento fuori dalla viewport minimizzandone le dimensioni reali:

.hidden {
    position: absolute;
    top: -9999em;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

Questo ci consente di avere un form leggibile nella sua interezza e perfettamente compilabile.

Il codice JavaScript

Sappiamo che ciascuno step nel DOM è rappresentato da un elemento fieldset che può contenere campi obbligatori (required).

La prima cosa da implementare è la validazione di questi campi nel contesto dello step corrente. Qui dobbiamo chiarire un punto fondamentale: l'utente clicca sul pulsante di navigazione che porta allo step successivo, quindi lo step da validare è quello precedente.

function validateCurrentStep(step = null) {
        if(!step) return;
        const requiredInputs = step.querySelectorAll('input[required]');
        let valid = true;
        for(const input of requiredInputs) {
            if(!input.value || input.value.length === 0 || /^\s+$/.test(input.value)) {
                valid = false;
            }
        }
        return valid;
    }

Qui la validazione verifica solo se i campi non sono valorizzati, ossia se sono stati lasciati vuoti. Per "vuoto" si intende anche un campo con soli spazi: si tratta di un dettaglio da non trascurare nella validazione in quanto gli spazi contano come caratteri.

Dedichiamoci ora a nascondere gli step non attivi distinguendo tra lo step corrente e gli altri.

function hideSteps(form = null, currentStep = null) {
        if(!form || !currentStep) return;
        const steps = form.querySelectorAll('fieldset');
        currentStep.classList.remove('hidden');
        for(const step of steps) {
            if(step.id !== currentStep.id) {
                step.classList.add('hidden');
            }
        }
    }

L'aspetto interessante è la distinzione tra lo step corrente e gli altri. Ricordiamo che ciascun elemento fieldset ha un ID diverso, quindi possiamo utilizzare la proprietà id per operare questa distinzione.

Sulla falsariga della funzione precedente, possiamo aggiungere la classe active al pulsante di navigazione corrente rimuovendola dagli altri.

function disableNavigationButtons(nav = null, currentButton = null) {
        if(!nav || !currentButton) return;
        const buttons = nav.querySelectorAll('button');
        currentButton.classList.add('active');
        for(const button of buttons) {
            if(button.dataset.step !== currentButton.dataset.step) {
                button.classList.remove('active');
            }
        }
    }

La logica è la stessa, solo che qui utilizziamo l'attributo di dati data-step per operare la distinzione.

Ora possiamo finalmente gestire la logica principale della navigazione tra gli step.

function handleFormNavigation(form = null) {
        if(!form) return;
        const navigationButtons = form.querySelectorAll('.form-multistep-navigation button');
        const message = form.querySelector('.form-message');
        for(const button of navigationButtons) {
            button.addEventListener('click', function() {
                message.classList.add('hidden');
                message.classList.remove('error');
                message.innerText = '';
                const previous = this.previousElementSibling;
                if(!previous) {
                    hideSteps(form, document.querySelector(this.dataset.step));
                    disableNavigationButtons(this.parentElement, this);
                    return;
                }
                const step = document.querySelector(previous.dataset.step);
                const validated = validateCurrentStep(step);
                if(!validated) {
                    message.classList.add('error');
                    message.innerText = 'Missing required fields.';
                    message.classList.remove('hidden');
                    return;
                }
                hideSteps(form, document.querySelector(this.dataset.step));
                disableNavigationButtons(this.parentElement, this);
            }, false);
        }
    }

Al clic, viene resettato il messaggio di validazione come prima operazione. Quindi se non c'è uno step precedente, ossia un elemento button che precede quello cliccato nel DOM, si mostra lo step corrente nascondendo gli altri, si evidenzia solo il pulsante corrente e non si procede oltre.

Viceversa, si ottiene un riferimento allo step precedente tramite il selettore contenuto nell'attributo di dati, si validano i campi obbligatori e si mostra il messaggio di errore se i campi non sono stati compilati. Se invece la validazione ha successo, sis consente all'utente di visualizzare lo step successivo.

Demo

JavaScript Multi-Step Form

Conclusione

Usando la giusta combinazione di semantica degli elementi dei form e aggiungendo una semplice tecnica CSS per l'accessibilità, si possono raggiungere buoni risultati per quello che riguarda questo tipo particolare di form.