Snippets

Advanced filtering tutorial

Simple page transition setup vanilla JS

js

import axios from "axios";
import "./bootstrap"; // Laravel default bootstrap.js file

/*
This is the base code for easy page transitions.

You need to add this at the very start of your body
<script>document.body.classList.add('hidden');</script>

And add the `data-page` attribute to the body for each page

And have some kind of transition in your css like so
.body {
    opacity: 1;
    transition: opacity 300ms ease-out;
}

.body.hidden {
    opacity: 0;
}

And you should add `data-transition="true"` to the elements which should activate the transition on click.
*/

import axios from "axios";
import "./bootstrap"; // laravel default bootstrap.js file

class App {
    constructor() {
        this.controller = new AbortController();
    }
}

class TransitionEngine {
    constructor() {
        this.setDefaults();
        this.init();
        this.handlePopstate();
        this.getPage(document.body);
    }

    setDefaults() {
        if (history.scrollRestoration) {
            history.scrollRestoration = "manual";
        }

        this.isPopstate = false;
        this.lastPopstateTime = 0;
        this.transitionDelay = 400;
    }

    init() {
        window.app.controller.abort();
        window.app.controller = new AbortController();

        this.getElements();
        this.setEvents();

        document.body.classList.remove("hidden");
    }

    getElements() {
        this.transitionLinks = document.querySelectorAll("[data-transition=true]");
    }

    setEvents() {
        this.transitionLinks.forEach((link) => {
            link.addEventListener(
                "click",
                async (e) => {
                    e.preventDefault();

                    window.sessionStorage.setItem(window.location.href, window.scrollY);

                    this.lastPopstateTime = Date.now();
                    this.handleHistory(e.target.href ?? e.currentTarget.href);
                    const newPage = await this.fetchNewPage(e.target.href ?? e.currentTarget.href);

                    this.transitionPage(newPage);
                },
                { signal: window.app.controller.signal },
            );
        });
    }

    getPage(el) {
        switch (el.dataset.page) {
            case "home":
                return new HomePage(el);
        }
    }

    handlePopstate() {
        window.addEventListener("popstate", async () => {
            const currentTime = Date.now();
            const timeSinceLastPopstate = currentTime - this.lastPopstateTime;

            if (timeSinceLastPopstate < 1000) {
                window.location.reload();
                return;
            }

            this.lastPopstateTime = currentTime;
            this.isPopstate = true;

            window.sessionStorage.setItem(this.previousPage, window.scrollY);
            this.handleHistory(window.location.href);

            const newPage = await this.fetchNewPage(window.location.href);
            this.transitionPage(newPage);
        });
    }

    async fetchNewPage(href) {
        const response = await axios.get(href);
        const parser = new DOMParser();

        return parser.parseFromString(response.data.html, "text/html");
    }

    async transitionPage(newPage) {
        document.body.classList.add("hidden");

        await this.waitFor(this.transitionDelay);

        this.replaceHead(newPage);

        requestAnimationFrame(() => {
            this.replaceBody(newPage);

            this.handleScrollPosition();

            this.isPopstate = false;

            requestAnimationFrame(() => {
                this.init();
            });
        });
    }

    handleHistory(href) {
        if (!this.isPopstate) {
            history.pushState(null, null, href.substring(href.indexOf("/")));
        }

        this.previousPage = window.location.href;
    }

    async waitFor(delay) {
        return new Promise((resolve) => setTimeout(resolve, delay));
    }

    replaceHead(newPage) {
        const oldStylesheets = Array.from(document.head.querySelectorAll("link[rel='stylesheet']"));
        const oldScripts = Array.from(document.head.querySelectorAll("script"));

        const newHead = newPage.querySelector("head");
        // Remove all from the head but keep old scripts and styles to avoid flickering
        Array.from(document.head.children).forEach((child) => {
            if (!oldStylesheets.includes(child) && !oldScripts.includes(child)) {
                child.remove();
            }
        });

        Array.from(newHead.children).forEach((child) => {
            document.head.appendChild(child.cloneNode(true));
        });

        setTimeout(() => {
            oldStylesheets.forEach((el) => el.remove());
            oldScripts.forEach((el) => el.remove());
        }, this.transitionDelay);
    }

    replaceBody(newPage) {
        const newBody = newPage.querySelector("body");
        document.body.innerHTML = newBody.innerHTML;
        document.body.dataset.page = newBody.dataset.page;

        this.getPage(document.body);
    }

    handleScrollPosition() {
        let scrollAmount = 0;
        if (this.isPopstate) {
            scrollAmount = window.sessionStorage.getItem(window.location.href);
        }

        window.scrollTo(0, scrollAmount);
    }
}

class HomePage {
    constructor(el) {
        this.el = el;

        this.getElements();
        this.initElements();
    }

    getElements() {
        // get all the elements you need
    }

    initElements() {
        // instantiate all the JS classes you need on the elements
    }
}

window.addEventListener("DOMContentLoaded", () => {
    window.app = new App();
    new TransitionEngine();
});