defuss-shadcn / Guides / routing
Client-side Routing
defuss-dom-router turns URLs into route requests and orders navigations against the browser's History API - it never touches the page. You say what a route shows: prepare() builds the next view without changing the live page, commit() puts it on screen. One file of about 9 kB compressed, no dependencies; every Application Scaffold and every Website template (Shop, Search & Navigation ...) routes with it.
On this page (11)
§Load it
dist/index.js imports nothing and starts nothing when imported - no listener until start(). Load it as a module from jsDelivr, or install it for a bundler. It brings no renderer: the views are your markup, the DOM work goes through the components' df$.
<script type="module"> import { createRouter } from 'https://cdn.jsdelivr.net/npm/defuss-dom-router@0.1.1/dist/index.js';</script>// bun add defuss-dom-routerimport { createRouter } from 'defuss-dom-router';§Views are sections, a route un-hides one
The scaffolds keep every view in the markup - a <section> each, all but the first hidden - so the page reads before any script runs and a view's components keep their state when it hides. Links are real anchors with data-router-link and a route in the fragment (href="#/messages/2"); attachLinks() handles their clicks and leaves modified clicks, downloads and other targets to the browser. The commit un-hides the view the route names and marks the current link. A :param names a record, a lone * catches everything else:
§A mailbox with routes
Routes: #/ (inbox), #/archive, #/messages/:id and * (not found - try message 9 under Archive). The line under the title shows what the route matched. In this preview the router cannot own the address (see Inside a docs preview below) - the same routes match the frame's hash.
| Call / markup | What it does |
|---|---|
createRouter({ mode: 'hash', routes, prepare }) | Validates and copies the configuration; routes match in order, the first one wins |
{ id: 'message', path: '/messages/:id' } | A named route with a parameter: to.params.id, decoded once (an encoded / stays data) |
{ id: 'missing', path: '*' } | Everything else - last, since the first match wins |
prepare({ to, signal }) → { commit } | Build the next view off the page (fetch with signal - a newer navigation aborts it), show it in commit |
router.attachLinks(root) | One delegated click listener for a[data-router-link] under root |
await router.start() | Takes the window and runs the first navigation - the address the page was opened with |
§Hash or history
Hash mode keeps the route after the #: the server always serves the same file, so any static host works - this site is served by GitHub Pages, which has no fallback for deep paths, so every scaffold uses it. History mode (the default) gives clean paths like /messages/2; your server then answers every app path with the app's HTML, and basePath names the folder the app lives in. router.href({ id, params, query }) builds a link that is right in both modes.
const router = createRouter({ basePath: '/app', routes: [{ id: 'message', path: '/messages/:id' }, { id: 'missing', path: '*' }], prepare: ({ to }) => ({ commit: () => show(to) }), afterCommit: ({ to }) => { document.title = df$('#mail-subject').text(); df$('main h1').get(0)?.focus(); // title, focus and ARIA belong after the commit },});router.href({ id: 'message', params: { id: '2' } }); // '/app/messages/2'§Results, not exceptions
navigate() never rejects - it resolves to exactly one result, so a script knows whether the screen changed:
| status | Meaning |
|---|---|
committed | History was written (or adopted) and the view committed |
unchanged | Same address and state as the current entry - nothing ran |
blocked | A guard or beforeLeave returned exactly false; history is untouched or was walked back |
superseded | A newer navigation took over before this one committed; its prepared view was disposed, never shown |
rejected | Invalid input, or a router not started or destroyed |
error | A guard, prepare(), the history write or commit() failed |
§Guards and leaving
Guards run before pushState, so a veto leaves history as it was; a vetoed Back is walked forward again. A view's beforeLeave asks when its route changes - an Alert Dialog makes the question; only an exact false blocks.
const ask = () => new Promise((resolve) => { const dialog = df$('#leave-draft').get(0); // <dialog class="alert-dialog"> with Stay / Leave buttons dialog.addEventListener('close', () => resolve(dialog.returnValue === 'leave'), { once: true }); dialog.showModal();});const router = createRouter({ mode: 'hash', routes, prepare: ({ to }) => ({ commit: () => show(to), beforeLeave: () => (draft.value.trim() ? ask() : true), }),});router.beforeEach(({ to }) => to.routeId !== 'admin' || signedIn); // false blocks§Navigating from a script
A script that holds the router calls await router.navigate('#/messages/2'). Code that does not - the scaffolds' older wiring, a data grid's row handler - changes the fragment: location.hash = '/issues/ENG-142'. The router follows hashchange like a link, and so does the docs preview's fallback. Never call history.pushState yourself while a router owns the window: entries it did not write carry none of its metadata, and a blocked Back cannot be walked back across them.
§With the components
- Sidebar, Breadcrumb, Command. A Sidebar link is the router link itself; the commit moves its
aria-current. A Command item is a button - give it the route (data-go="/orders") and navigate on its click. - Hidden views keep their components. A data grid, a chart or an editor in a hidden section keeps its state and its listeners; only the commit decides what shows. The Messenger's thread is a Session that keeps its scroll position.
- An announcement for the rest of the page. When older wiring needs to know the view changed, the commit dispatches an event on the app (the Issue Tracker's
pm-route), and that wiring listens instead of readinglocation. - A component that owns its links is driven, not bypassed. Pagination claims the clicks on its previous / next links. The Search & Navigation template makes it data-driven, sets its page from the route through its State API (
pager.api.setState('default', { activePage })) and puts the page its store lands on into the address (pager.store.subscribe) - the component's contract, not a second click handler. - A page that scrolls itself. After a navigation the router scrolls to the top (and restores the position on Back); the Landing Page scrolls to the section a route names, so it sets
scroll: 'manual'- the router's scroll would stop the glide. Its deep link jumps withbehavior: 'instant':'auto'takes the page'sscroll-behavior: smooth. df$().each()stops onfalse. Hiding views in an arrow function that returns the assignment -(_i, v) => (v.hidden = v.id !== id)- ends the loop at the view being shown; give the callback a block body.
§Routing and translation
They do not depend on each other: a language switch does not navigate, a navigation does not re-render the texts. Because views are hidden rather than rendered, every translated region stays bound when it hides (Translation). Text a commit derives from translated markup - the Admin Dashboard's breadcrumb copies the current sidebar label - is read again on defuss-i18n:change.
§Inside a docs preview
The router routes HTTP(S) addresses of its own window. Every example on this site runs in a sandboxed about:srcdoc frame, which has no such address: start() resolves rejected with invalid-url. The frame's links still change its fragment, and the router's pure resolve() works without a window when a second router is given a baseUrl - so the examples match the same routes there. Open a scaffold or a Website template full screen (each has one) to see the router own a real address, with Back, Forward and deep links.
if ((await router.start()).status === 'rejected') { const preview = createRouter({ mode: 'hash', routes, baseUrl: 'https://preview.invalid/' }); const follow = () => show(preview.resolve(location.hash.startsWith('#/') ? location.hash : '#/')); addEventListener('hashchange', follow); follow();}§Where it is used
| Scaffold | Routes |
|---|---|
| Admin Dashboard | #/, #/orders, #/customers, #/settings |
| Messenger | #/ (the list), #/chats/:id |
| Issue Tracker | #/, #/my-issues, #/inbox, #/cycles, #/projects, #/issues/:id |
| Notes & Docs | #/, #/pages/:slug - new pages too |
| Status Page | #/, #/history, #/manage, #/incidents/:id |
| Desktop | #/apps/:app, #/files/:name, #/browse/:site - what a launcher opens |
| Document Editor | #/docs/:id |
| Template | Routes | What a route does |
|---|---|---|
| Landing Page | #/features, #/pricing, #/faq | Scrolls to the section |
| News | #/section/:topic | Shows the section's stories |
| Blog | #/category/:category, #/archive/:year/:month | Filters the posts |
| Article | #/articles/:slug | prepare() builds the article from a data block |
| Comments | #/comments/:id, ?sort= | Highlights the comment, orders the thread |
| Company | #/team, #/jobs/:department, #/press | The site's pages, a department filter |
| Events & Promotions | #/track/:track, #/sessions/:id | Filters the schedule, opens a session |
| Media | #/photos/:n | Opens the slideshow on the photo |
| Shop | #/cart, #/checkout, #/order/:id | One step of the purchase |
| Account & States | #/profile, #/settings, #/onboarding, #/states | One part of the account |
| Docs & Help | #/docs/:page | Link, breadcrumb, title and pager follow |
| Search & Navigation | #/?q=&category=&sort=&page=&tag= | The search is the query string |
Comments, ideas or improvements? Edit this page's source on GitHub