SPA com Roteamento
SPA com Roteamento
Section titled “SPA com Roteamento”Este exemplo demonstra como criar uma Single Page Application (SPA) usando o roteador do Slash: navegação client-side, route params, query strings, guards e um fallback 404.
Funcionalidades
Section titled “Funcionalidades”- ✅ Roteamento client-side (SPA)
- ✅ Navegação com
<Link>e programática - ✅ Route params e query strings
- ✅ Navigation guards (redirecionam com uma string de caminho)
- ✅ Fallback 404
- ✅ Hash mode e History mode (em hash mode, links diretos como
/#/products?category=booksfuncionam)
Estrutura do Projeto
Section titled “Estrutura do Projeto”src/├── index.ts # Entry point├── router.ts # Configuração do router├── pages/│ ├── Home.ts # Página inicial│ ├── Products.ts # Lista de produtos│ ├── ProductDetail.ts # Detalhe do produto│ ├── Dashboard.ts # Dashboard (protegido)│ ├── Login.ts # Login│ └── NotFound.ts # 404├── components/│ ├── Layout.ts # Layout base│ └── Header.ts # Header com navegação├── services/│ └── auth.ts # Serviço de autenticação└── types.ts # TiposTipos TypeScript
Section titled “Tipos TypeScript”export interface User { id: string name: string email: string role: 'user' | 'admin'}
export interface Product { id: string name: string description: string price: number category: string image: string}
export interface RouteParams { id?: string [key: string]: string | undefined}Serviço de Autenticação
Section titled “Serviço de Autenticação”import { createState } from '@_bashell/slash'import type { User } from '../types'
type AuthState = { user: User | null isAuthenticated: boolean isLoading: boolean}
// Lê o usuário salvo de forma síncrona: o guard roda na primeira navegação, que// começa assim que o router é criado. O estado precisa estar pronto antes disso.const loadSavedAuth = (): AuthState => { try { const saved = localStorage.getItem('user') if (saved) { return { user: JSON.parse(saved) as User, isAuthenticated: true, isLoading: false } } } catch { // JSON inválido ou localStorage indisponível: segue como visitante } return { user: null, isAuthenticated: false, isLoading: false }}
// Estado de autenticação (já carregado do localStorage)export const authState = createState<AuthState>(loadSavedAuth())
// Loginexport const login = async (email: string, password: string): Promise<void> => { // Simula API call await new Promise(resolve => setTimeout(resolve, 1000))
// Mock user const user: User = { id: '1', name: 'John Doe', email, role: 'user' }
localStorage.setItem('user', JSON.stringify(user)) authState.set({ user, isAuthenticated: true, isLoading: false })}
// Logoutexport const logout = (): void => { localStorage.removeItem('user') authState.set({ user: null, isAuthenticated: false, isLoading: false })}
// Check if user has roleexport const hasRole = (role: string): boolean => { const state = authState.get() return state.isAuthenticated && state.user?.role === role}Configuração do Router
Section titled “Configuração do Router”As rotas são funções component(state) que recebem o RouterState (com params, query, meta) e devolvem um template. Cada rota monta a página como componente (<${Page} />), para que a página re-renderize quando os states que ela lê mudarem. Guards devolvem true/void para permitir, false para bloquear ou uma string com o caminho de redirecionamento:
import { html } from '@_bashell/slash'import { createRouter, type NavigationGuard, type RouteConfig } from '@_bashell/slash/router'import { authState } from './services/auth'import { Home } from './pages/Home'import { Products } from './pages/Products'import { ProductDetail } from './pages/ProductDetail'import { Dashboard } from './pages/Dashboard'import { Login } from './pages/Login'import { NotFound } from './pages/NotFound'
// Guard de autenticação: redireciona para /login guardando o destino na queryconst authGuard: NavigationGuard = (to) => { if (!authState.get().isAuthenticated) { return `/login?redirect=${encodeURIComponent(to.path)}` } return true}
const routes: RouteConfig[] = [ { path: '/', component: () => html`<${Home} />`, meta: { title: 'Home' } }, { path: '/products', component: (state) => html`<${Products} category=${state.query.category ?? 'all'} />`, meta: { title: 'Products' } }, { path: '/products/:id', component: (state) => html`<${ProductDetail} id=${state.params.id} />`, meta: { title: 'Product Details' } }, { path: '/dashboard', component: () => html`<${Dashboard} />`, meta: { title: 'Dashboard', requiresAuth: true }, guards: [authGuard] }, { path: '/login', component: (state) => html`<${Login} redirect=${state.query.redirect ?? '/dashboard'} />`, meta: { title: 'Login' } }, { path: '/404', component: () => html`<${NotFound} />`, meta: { title: '404 Not Found' } }]
export const router = createRouter({ mode: 'history', // ou 'hash': a URL fica /#/products e a rota inicial vem de location.hash routes, fallback: '/404' // caminho usado quando nenhuma rota casa})
// Observar a navegação: atualiza o título da páginarouter.watch((state) => { const title = state.currentRoute?.meta?.title if (title) document.title = `${title} - My SPA`})Guards e a URL inicial
Section titled “Guards e a URL inicial”O guard também roda na primeira navegação, quando o usuário abre /dashboard direto pela barra de endereço ou por um link salvo. Enquanto ele decide, o estado do router é currentRoute: null, isNavigating: true, então a página protegida não é renderizada nem aparece por um instante (sem flash). Depois da decisão vem a rota pedida, o destino do redirect (aqui /login?redirect=...) ou, se o guard devolver false, currentRoute: null: trate esse caso com uma tela vazia ou de erro.
Se você precisar esperar a navegação inicial terminar (por exemplo, para medir ou para só então esconder um splash), use await router.ready:
import { router } from './router'
await router.ready // resolve quando a navegação inicial termina (redirects incluídos)console.log(router.get().currentRoute?.path)Cada navegação segue no máximo 10 redirects encadeados, e uma navegação mais nova substitui uma anterior ainda pendente.
Componente Header
Section titled “Componente Header”O header lê authState e router (que é um State), então re-renderiza quando o usuário loga ou quando a rota muda. Link exige a prop router:
import { html } from '@_bashell/slash'import { Link } from '@_bashell/slash/router'import { router } from '../router'import { authState, logout } from '../services/auth'
export const Header = () => { // Estes get() inscrevem o componente const auth = authState.get() const currentPath = router.get().currentRoute?.path ?? '/'
const isActive = (path: string) => currentPath === path
const handleLogout = () => { logout() router.push('/') }
return html` <header class="header"> <div class="container"> <nav class="nav"> <div class="nav-brand"> <${Link} to="/" router=${router}>My SPA<//> </div>
<ul class="nav-links"> <li class=${isActive('/') ? 'active' : ''}> <${Link} to="/" router=${router}>Home<//> </li> <li class=${isActive('/products') ? 'active' : ''}> <${Link} to="/products" router=${router}>Products<//> </li>
${auth.isAuthenticated ? html` <li class=${isActive('/dashboard') ? 'active' : ''}> <${Link} to="/dashboard" router=${router}>Dashboard<//> </li> <li> <button class="btn-link" onClick=${handleLogout}>Logout</button> </li> <li class="user-info">${auth.user?.name}</li> ` : html` <li class=${isActive('/login') ? 'active' : ''}> <${Link} to="/login" router=${router}>Login<//> </li> `} </ul> </nav> </div> </header> `}Página Home
Section titled “Página Home”import { html } from '@_bashell/slash'import { Link } from '@_bashell/slash/router'import { router } from '../router'import { authState } from '../services/auth'
export const Home = () => { const auth = authState.get()
return html` <div class="home"> <section class="hero"> <h1>Welcome to My SPA</h1> <p>A single page application built with Slash</p>
${auth.isAuthenticated ? html` <p class="welcome">Hello, ${auth.user?.name}!</p> <${Link} to="/dashboard" router=${router} class="btn btn-primary">Go to Dashboard<//> ` : html` <${Link} to="/login" router=${router} class="btn btn-primary">Get Started<//> `} </section>
<section class="cta"> <h2>Browse Our Products</h2> <${Link} to="/products" router=${router} class="btn btn-secondary">View Products<//> </section> </div> `}Página Products
Section titled “Página Products”A rota passa o filtro da query como prop. Clicar num filtro navega para a mesma rota com outra query, e o roteador re-renderiza a página:
import { html } from '@_bashell/slash'import { Link } from '@_bashell/slash/router'import { router } from '../router'import type { Product } from '../types'
// Mock de produtosexport const mockProducts: Product[] = [ { id: '1', name: 'Laptop Pro', description: 'High-performance laptop', price: 1299, category: 'electronics', image: '/images/laptop.jpg' }, { id: '2', name: 'Wireless Mouse', description: 'Ergonomic wireless mouse', price: 29, category: 'accessories', image: '/images/mouse.jpg' }, { id: '3', name: 'Mechanical Keyboard', description: 'RGB mechanical keyboard', price: 149, category: 'accessories', image: '/images/keyboard.jpg' }]
const categories = ['all', 'electronics', 'accessories']
export const Products = ({ category }: { category: string }) => { const filteredProducts = mockProducts.filter(p => category === 'all' || p.category === category)
const handleFilterChange = (next: string) => { router.push(next === 'all' ? '/products' : `/products?category=${next}`) }
return html` <div class="products-page"> <h1>Our Products</h1>
<div class="filter-buttons"> ${categories.map(cat => html` <button class=${`filter-btn ${category === cat ? 'active' : ''}`} onClick=${() => handleFilterChange(cat)} > ${cat.charAt(0).toUpperCase() + cat.slice(1)} </button> `)} </div>
<div class="products-grid"> ${filteredProducts.map(product => html` <div class="product-card"> <img src=${product.image} alt=${product.name} /> <h3>${product.name}</h3> <p>${product.description}</p> <div class="product-footer"> <span class="price">$${product.price}</span> <${Link} to=${`/products/${product.id}`} router=${router} class="btn btn-sm">View Details<//> </div> </div> `)} </div>
${filteredProducts.length === 0 ? html`<p>No products found in this category.</p>` : null} </div> `}Página ProductDetail
Section titled “Página ProductDetail”Detalhe de produto com route param (state.params.id chega como prop id):
import { html } from '@_bashell/slash'import { Link } from '@_bashell/slash/router'import { router } from '../router'import { mockProducts } from './Products'
export const ProductDetail = ({ id }: { id: string }) => { const product = mockProducts.find(p => p.id === id)
if (!product) { return html` <div class="error"> <h2>Product not found</h2> <${Link} to="/products" router=${router}>← Back to products<//> </div> ` }
return html` <div class="product-detail"> <${Link} to="/products" router=${router} class="back-link">← Back to products<//>
<div class="product-content"> <img src=${product.image} alt=${product.name} /> <div class="product-info"> <h1>${product.name}</h1> <div class="product-category">Category: ${product.category}</div> <p class="product-description">${product.description}</p> <div class="product-price">$${product.price}</div> <button class="btn btn-primary" onClick=${() => alert(`Added ${product.name} to cart!`)}> Add to Cart </button> </div> </div> </div> `}Página Login
Section titled “Página Login”O formulário guarda o que o usuário digita fora de qualquer state lido no render (os <input> não são recriados a cada tecla). Só LoginStatus lê o state do envio:
import { html, createState } from '@_bashell/slash'import { router } from '../router'import { login } from '../services/auth'
const status = createState({ isLoading: false, error: '' })const draft = { email: '', password: '' }
const LoginStatus = () => { const { isLoading, error } = status.get()
return html` <div> ${error ? html`<div class="alert alert-error">${error}</div>` : null} <button type="submit" class="btn btn-primary btn-block" disabled=${isLoading}> ${isLoading ? 'Logging in...' : 'Login'} </button> </div> `}
export const Login = ({ redirect }: { redirect: string }) => { const handleSubmit = async (e: Event) => { e.preventDefault() status.set({ isLoading: true, error: '' })
try { await login(draft.email, draft.password) // Vai para onde o usuário queria ir; replace evita voltar para o login await router.replace(redirect) } catch (error: any) { status.set({ isLoading: false, error: error.message || 'Login failed' }) } }
return html` <div class="login-page"> <div class="login-card"> <h1>Login</h1>
<form onSubmit=${handleSubmit}> <div class="form-group"> <label for="email">Email</label> <input id="email" type="email" required onInput=${(e: Event) => { draft.email = (e.target as HTMLInputElement).value }} /> </div>
<div class="form-group"> <label for="password">Password</label> <input id="password" type="password" required onInput=${(e: Event) => { draft.password = (e.target as HTMLInputElement).value }} /> </div>
<${LoginStatus} /> </form>
<p class="hint">Use any email and password to login</p> </div> </div> `}Página Dashboard (Protegida)
Section titled “Página Dashboard (Protegida)”A proteção está no guard da rota. A página só lê authState:
import { html } from '@_bashell/slash'import { authState } from '../services/auth'
export const Dashboard = () => { const auth = authState.get()
return html` <div class="dashboard"> <h1>Dashboard</h1>
<div class="welcome"> <h2>Welcome back, ${auth.user?.name}!</h2> <p>Email: ${auth.user?.email}</p> <p>Role: ${auth.user?.role}</p> </div> </div> `}Página NotFound
Section titled “Página NotFound”import { html } from '@_bashell/slash'import { Link } from '@_bashell/slash/router'import { router } from '../router'
export const NotFound = () => html` <div class="not-found"> <h1>404</h1> <p>Page not found</p> <${Link} to="/" router=${router}>Go home<//> </div>`Layout Principal
Section titled “Layout Principal”O Router é usado como componente. O Header também é montado como componente (<${Header} />) para ter a própria reatividade:
import { html } from '@_bashell/slash'import { Router } from '@_bashell/slash/router'import { Header } from './Header'import { router } from '../router'
export const Layout = () => html` <div class="app"> <${Header} />
<main class="main"> <div class="container"> <${Router} router=${router} /> </div> </main>
<footer class="footer"> <div class="container"> <p>© 2026 My SPA. Built with Slash.</p> </div> </footer> </div>`Entry Point
Section titled “Entry Point”import { html, render } from '@_bashell/slash'import { Layout } from './components/Layout'import './styles.css'
// O router é criado quando './router' é importado (via Layout) e já começa a// navegação inicial pela URL atual (window.location). O estado de autenticação// é carregado na criação de `authState`, antes de qualquer guard rodar.const root = document.getElementById('app')if (root) { render(html`<${Layout} />`, root)}Navegação Programática
Section titled “Navegação Programática”import { router } from './router'
// Navegação simplesawait router.push('/products')
// Com paramsawait router.push('/products/123')
// Com query strings (na própria string)await router.push('/products?category=electronics&page=1')
// Voltar / avançarrouter.back()router.forward()
// Ir para índice específico no históricorouter.go(-2) // volta 2 páginas
// Replace (não adiciona ao histórico)await router.replace('/login')
// Rota atualrouter.currentRoute() // RouteMatch | nullrouter.get() // { currentRoute, params, query, meta, isNavigating }Executando o Projeto
Section titled “Executando o Projeto”# Desenvolvimentobun run dev
# Buildbun run buildMelhorias Possíveis
Section titled “Melhorias Possíveis”- Breadcrumbs: Navegação hierárquica
- Loading States: indicador usando
router.get().isNavigating - Error Boundaries: Tratamento de erros por rota
- Nested Routes: subrotas com
children - Route Meta: Permissões, títulos, analytics
- Scroll Restoration: lembrar a posição do scroll (manual, com
router.watch)
Pontos-Chave de Aprendizado
Section titled “Pontos-Chave de Aprendizado”- SPA: Navegação instantânea sem reload
- Route Guards: Controle de acesso, redirecionando com uma string
- Páginas como componentes:
component: () => html`<${Page} />`para reatividade - Query Params: Estado na URL para compartilhamento
Linkcomrouter: a proprouteré obrigatória
Próximos Passos
Section titled “Próximos Passos”- Roteamento - Documentação completa do Router
- Navigation Guards - Guards avançados
- Form Validation - Validação avançada de formulários