Aula 4: HTMX e UI

Introdução

Interfaces de Usuário

Uma Interface de Usuário é como visitantes de uma página web interagem com essa página. Nosso objetivo como desenvolvedores web é tornar essas interações o mais agradáveis possível para o usuário, e há muitos métodos que podemos usar para isso.

Single Page Applications (SPAs)

Anteriormente, se quiséssemos um site com múltiplas páginas, faríamos isso usando rotas diferentes em nossa aplicação Django. Agora, temos a capacidade de carregar apenas uma única página e então usar JavaScript para manipular a DOM. Uma grande vantagem disso é que só precisamos modificar a parte da página que está realmente mudando. Por exemplo, se temos uma Barra de Navegação que não muda baseada na página atual, não gostaríamos de ter que renderizar novamente essa Barra toda vez que mudamos para uma nova parte da página.

Requests AJAX

Requests AJAX (Asynchronous JavaScript and XML) permitem que páginas web se comuniquem com o servidor de forma assíncrona, ou seja, sem recarregar a página inteira. Isso melhora a experiência do usuário, pois apenas partes específicas da página são atualizadas. AJAX se refere a uma técnica e não uma tecnologia ou ferramenta específica, por isso existem várias formas de realizar esse tipo de request e atualizar o conteúdo da página.

Abaixo temos um exemplo em que atualizamos nossa página com uma resposta HTML do servidor:

document.addEventListener('DOMContentLoaded', function() {
            const button = document.getElementById('fetchHtml');
            const result = document.getElementById('result');
            
            button.addEventListener('click', async function() {
                try {
                    const response = await fetch('/server-endpoint');
                    if (!response.ok) {
                        throw new Error('Erro ao buscar o HTML');
                    }
                    const html = await response.text();
                    result.innerHTML = html; // Insere o HTML retornado no elemento result
                } catch (error) {
                    result.innerHTML = `<p style="color: red;">${error.message}</p>`;
                }
            });
        });

Abaixo temos um exemplo em que atualizamos nossa página com uma resposta JSON do servidor:

document.addEventListener('DOMContentLoaded', function() {
            const button = document.getElementById('fetchJson');
            const result = document.getElementById('result');
        
            button.addEventListener('click', async function() {
                try {
                    const response = await fetch('/server-endpoint'); // Substitua pelo endpoint do servidor
                    if (!response.ok) {
                        throw new Error('Erro ao buscar os dados');
                    }
                    const data = await response.json();
                    result.innerHTML = `
                        <h4>${data.title}</h4>
                        <p>${data.body}</p>
                    `;
                } catch (error) {
                    result.innerHTML = `<p style="color: red;">${error.message}</p>`;
                }
            });
        });

HTMX

HTMX é uma biblioteca que permite acessar recursos modernos dos navegadores diretamente do HTML, em vez de usar JavaScript.

Para entender o HTMX, primeiro vamos dar uma olhada em uma tag âncora:

<a href="/blog">Blog</a>

Esta tag âncora diz ao navegador:

"Quando um usuário clicar neste link, faça uma requisição HTTP GET para '/blog' e carregue o conteúdo da resposta na janela do navegador".

Com isso em mente, considere o seguinte trecho de HTML:

<button hx-post="/clicked"
        hx-trigger="click"
        hx-target="#parent-div"
        hx-swap="outerHTML">
        Clique Aqui!
</button>

Isso diz ao HTMX:

"Quando um usuário clicar neste botão, faça uma requisição HTTP POST para '/clicked' e use o conteúdo da resposta para substituir o elemento com o id parent-div no DOM"

O HTMX estende e generaliza a ideia central do HTML como hipertexto, abrindo muitas mais possibilidades diretamente na linguagem:

Observe que quando você está usando HTMX, no lado do servidor você normalmente responde com HTML, não com JSON.

Falar sobre hx-push-url

Requests HTMX

Vamos começar com um exemplo simples, entendendo como usar HTMX para atualizar o conteúdo da tela de forma dinâmica. No exemplo abaixo temos um site mostrando uma página inicial:

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>Meu Site</title>
        <style>...</style>
    </head>
    <body>
        <h1>Meu Site</h1>
        <hr>
        <h2>Página 1</h2>
        <p>Conteúdo da página um</p>
    </body>
</html>

Antes, quando pensávamos na navegação para outras páginas dentro da lógica de uma MPA (Multi Page Application), pensávamos em adicionar anchor tags com links que recarregariam a tela e nos levariam para uma outra página a partir de uma resposta do servidor contendo um arquivo HTML completo. Com HTMX, entretanto, podemos ser mais eficientes e trocar somente as partes que nos interessam.

No exemplo acima, em vez de adicionar uma anchor tag, podemos então adicionar um botão que troca somente o conteúdo após a tag hr

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>Olá</title>
        <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
    </head>
    <body>
        <h1>Meu Site</h1>
        <nav>
            <button hx-get="pagina2"
                    hx-target="main">
                Página 2
            </button>
        </nav>
        <hr>
        <main>
            <h2>Página 1</h2>
            <p>Conteúdo da página um</p>
        </main>
    </body>
</html>

Perceba que mudamos algumas coisas, importamos o HTMX dentro da head e encapsulamos o conteúdo da página dentro de uma tag main. Esse encapsulamento serve para podermos "mirar" no conteúdo que queremos trocar com o parâmetro hx-target. Além disso, não especificamos o hx-swap que nos diz como a resposta do servidor vai substituir nosso alvo, isso porque o hx-swap padrão é o 'innerHTML'. Isso significa que a resposta do servidor vai entrar entre as tags main. Um exemplo de resposta seria este:

<h2>Página 2</h2>
<p>Conteúdo da página dois</p>

Podemos então adicionar um botão que nos leva de volta à primeira página, assim como um endpoint que resposta com o conteúdo da primeira página:

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>Olá</title>
        <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
    </head>
    <body>
        <h1>Meu Site</h1>
        <nav>
            <button hx-get="pagina1"
                    hx-target="main">
                Página 1
            </button>
            <button hx-get="pagina2"
                    hx-target="main">
                Página 2
            </button>
        </nav>
        <hr>
        <main>
            <h2>Página 1</h2>
            <p>Conteúdo da página um</p>
        </main>
    </body>
</html>
<h2>Página 1</h2>
<p>Conteúdo da página um</p>

Como temos todo o código modularizado agora, podemos até mesmo omitir o conteúdo da página um da nossa primeira página. Observe que isso pode ser feito com um block ao trabalhar com templates Django, mas também podemos fazer algo parecido com HTMX para deixar que o cliente peça o conteúdo de outra forma. Uma forma possível é solicitar a página um no momento que a página inicial for carregada.

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>Olá</title>
        <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
    </head>
    <body>
        <h1>Meu Site</h1>
        <nav>
            <button hx-get="pagina1"
                    hx-target="main">
                Página 1
            </button>
            <button hx-get="pagina2"
                    hx-target="main">
                Página 2
            </button>
        </nav>
        <hr>
        <main hx-get="pagina1"
              hx-target="main"
              hx-trigger="load">
        </main>
    </body> 
</html>

Paginação

Paginação é uma técnica de bancos de dados que permite retornar as linhas de uma tabela em páginas. Isso é útil para quando temos conteúdo demais em uma tabela e queremos mostrá-lo em partes para os usuários.

Para experimentar com paginação usando Django e HTMX podemos usar o modelo padrão de usuários:

views.py:

from django.shortcuts import render
from django.core.paginator import Paginator
from django.contrib.auth.models import User
from django.views.decorators.http import require_http_methods

@require_http_methods(["GET"])
def index(request):
    return render(request, "ccapp/index.html")

@require_http_methods(["GET"])
def usuarios(request):
    usuarios = User.objects.all().order_by('id')
    paginator = Paginator(usuarios, 2)
    pagina = request.GET.get('pagina', 1)
    usuarios = paginator.page(pagina)
    ultima_pagina = not usuarios.has_next()

    return render(request, "ccapp/usuarios.html", {
        "usuarios": usuarios,
        "ultima_pagina": ultima_pagina,
        "pagina": pagina,
    })

index.html:

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>CodeClass</title>
        <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
    </head>
    <body>
        <h1>CodeClass</h1>
        <nav>
            <button hx-get="cursos"
                    hx-target="main">
                Cursos
            </button>
            <button hx-get="usuarios"
                    hx-target="main">
                Usuários
            </button>
        </nav>
        <hr>
        <main hx-get="usuarios"
              hx-target="main"
              hx-trigger="load">
        </main>
    </body> 
</html>

usuarios.html:

<div>
    {% for usuario in usuarios %}
    <h3>{{ usuario }}</h3>
    {% endfor %}
    <p><strong>Página {{ pagina }}</strong></p>
    <button hx-get="usuarios?pagina={{ pagina|add:'-1' }}"
            hx-target="closest main"
            {% if pagina == 1 %} disabled {% endif %}>
        Anterior
    </button>
    <button hx-get="usuarios?pagina={{ pagina|add:'1' }}"
            hx-target="closest main"
            {% if ultima_pagina %} disabled {% endif %}>
        Próximo
    </button>
</div>

Scroll Infinito

usuarios.html:

<div>
    {% if ultima_pagina is not True %}
    <div hx-get="usuarios?pagina={{ pagina|add:'1'}}"
         hx-trigger="intersect"
         hx-target="closest main"
         hx-swap="beforeend">
    </div>
    {% else %}
    <!--Loop nas páginas partindo do começo-->
    <div hx-get="usuarios?pagina=1"
         hx-trigger="intersect"
         hx-target="closest main"
         hx-swap="beforeend">
    </div>
    {% endif %}
    {% for usuario in usuarios %}
    <div style="background-color: green; height: 300px; width: 300px;">
        <h3>{{ usuario }}</h3>
    </div>
    {% endfor %}
</div>

Busca

usuarios.html:

<input type="text" 
       name="busca"
       style="margin-top: 10px;"
       hx-get="usuarios"
       hx-trigger="input changed delay:500ms"
       hx-target="#div-usuarios"
       hx-select="#div-usuarios">
<div id="div-usuarios">
    {% for usuario in usuarios %}
        <h3>{{ usuario }}</h3>
    {% empty %}
        <h3>Nenhum usuário encontrado</h3>
    {% endfor %}
</div>

Carregamento

É importante saber lidar com tempos de carregamento maiores para que os usuários não se sintam frustrados. Podemos simular uma resposta mais demorado usando o time.sleep no nosso views.py

views.py:

from django.shortcuts import render
from django.core.paginator import Paginator
from django.contrib.auth.models import User
from django.views.decorators.http import require_http_methods
import time

@require_http_methods(["GET"])
def index(request):
    return render(request, "ccapp/index.html")

@require_http_methods(["GET"])
def usuarios(request):
    busca = request.GET.get('busca')
    if busca:
        usuarios = User.objects.filter(username__icontains=busca)
    else:
        usuarios = User.objects.all()

    time.sleep(2)
    return render(request, "ccapp/usuarios.html", {
        "usuarios": usuarios,
    })

Já no nosso usuarios.html, usamos o atributo hx-indicator com uma classe própria definida no index. Também usamos o hx-on para fazer o resultado atual sumir e o novo aparecer. Usamos o bootstrap para placeholder.

usuarios.html:

<input type="text" 
        name="busca"
        style="margin-top: 10px;"
        hx-get="usuarios"
        hx-trigger="input changed delay:500ms"
        hx-target="#div-usuarios"
        hx-select="#div-usuarios"
        hx-indicator="#loading"
        hx-on::before-request="document.getElementById('div-usuarios').style.display = 'none';"
        hx-on::after-request="document.getElementById('div-usuarios').style.display = 'block';">
 
 <p id="loading" class="placeholder-glow my-indicator">
     <span class="placeholder w-100"></span>
 </p>
 
 <div id="div-usuarios">
     {% for usuario in usuarios %}
         <h3>{{ usuario }}</h3>
     {% empty %}
         <h3>Nenhum usuário encontrado</h3>
     {% endfor %}
 </div>

Importamos o bootstrap e definimos a classe my-indicator no index.html:

<!DOCTYPE html>
<html lang="pt-br">
    <head>
        <title>CodeClass</title>
        <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js" integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO" crossorigin="anonymous"></script>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
    </head>
    <style>
        body {
            margin: 0;
            padding: 0;
            display: flex;
            flex-direction: column;
            align-items: center;
        }
        .my-indicator{
            display: none;
        }
        .htmx-request .my-indicator{
            display: inline;
        }
        .htmx-request.my-indicator{
            display: inline;
        }
    </style>
    <body>
        <h1>CodeClass</h1>
        <nav>
            <button hx-get="cursos"
                    hx-target="main">
                Cursos
            </button>
            <button hx-get="usuarios"
                    hx-target="main">
                Usuários
            </button>
        </nav>
        <hr class="w-50">
        <main hx-get="usuarios"
              hx-target="main"
              hx-trigger="load">
        </main>
    </body> 
</html>

Animações

Existem várias formas de animar elementos, uma delas é usar a propriedade transition de CSS. Como um exemplo, vamos animar o clique em um usuário para que apareça um fundo laranja temporariamente.

dentro da tag style do index.html:

h3 {
    transition: background-color 0.2s ease-in-out;
}
.highlight {
    background-color: rgb(207, 166, 90);
}

div-usuarios no usuarios.html:

<div id="div-usuarios">
    {% for usuario in usuarios %}
        <h3 hx-on:click="
                this.classList.add('highlight');
                setTimeout(() => this.classList.remove('highlight'), 500)">
            {{ usuario }}
        </h3>
    {% empty %}
        <h3>Nenhum usuário encontrado</h3>
    {% endfor %}
</div>

Também podemos animar usando keyframes CSS. Keyframes permitem definir múltiplos estágios de uma animação, dando mais controle sobre o movimento e aparência dos elementos.

@keyframes pulseBackgroundColor {
    0% {
        background-color: transparent; /* Cor inicial */
    }
    50% {
        background-color: rgb(207, 166, 90);
    }
    100% {
        background-color: transparent; /* Cor final, voltando ao inicial */
    }
}
.animate-pulse {
  animation: pulseBackgroundColor 0.5s ease-in-out; /* Duração e timing da animação */
}
<h3 hx-on:click="
    this.classList.add('animate-pulse');
    setTimeout(() => this.classList.remove('animate-pulse'), 500)">
    {{ usuario }}
</h3>