Welcome again, friends! 🥳
In the previous topic, we explored about how to get started with the TiniJS Framework, if you have not read it yet, please see Getting started with TiniJS framework.
Today topic we will explore:
The Tini Router and alternatives
Working with Pages and layouts
Employ route guards
Scroll to anchors inside Shadow DOM
Title and meta tags management
Fetch and render async data
To get started, you can download the Blank starter template, or run:
Pages
Pages in TiniJS apps are special components which purpose are to represent views or endpoints of the app. Creating and working with pages is very similar to how we would work with components.
To quickly create a page, we can use the Tini CLI to generate it.
Or, create a ./app/pages/xxx.ts file manually, a page looks like this:
import {Page, TiniComponent} from ‘@tinijs/core‘;
@Page({
name: ‘app-page-xxx‘,
})
export class AppPageXXX extends TiniComponent {
protected render() {
return html`<p>This is a page!</p>`;
}
static styles = css“;
}
Beside the @Page() decorator, everything else would work the same as any component. But, please note the name: ‘app-page-xxx’ property, it plays a role later when we setup the Tini Router.
Layouts
Layouts in TiniJS apps are also special components which purpose are to share common elements between pages. You can think of layouts as containers of pages.
To quickly create a layout, we can use the Tini CLI to generate it.
Or, create a ./app/layouts/xxx.ts file manually, a layout looks like this:
import {Layout, TiniComponent} from ‘@tinijs/core‘;
@Layout({
name: ‘app-layout-xxx‘,
})
export class AppLayoutXXX extends TiniComponent {
protected render() {
return html`
<div class=”page”>
<header>…</header>
<slot></slot>
<footer>…</footer>
</div>
`;
}
static styles = css“;
}
Beside the @Layout() decorator and the <slot></slot> in the template, everything else would work the same as any component. But, please note the name: ‘app-layout-xxx’ property, it plays a role later when we setup the Tini Router.
Tini Router
Tini Router is the default way to add routing capability to TiniJS apps. There are also other routers you may use with TiniJS, such as: Vaadin Router and Lit Router.
For today topic, we will only explore the usage of Tini Router, it has several useful features:
Bundle or lazy load pages
Routes with layouts
Many param patterns
Navigate using the a tag
Route guards
404 pages
And more
Define routes
To define routes, we create the file ./app/routes.ts and add the route entries, for example:
export const routes: Route[] = [
{
path: ”,
component: ‘app-layout-default‘,
children: [
{
path: ”,
component: ‘app-page-home‘,
action: () => import(‘./pages/home.js‘),
},
{
path: ‘post/:slug‘,
component: ‘app-page-post‘,
action: () => import(‘./pages/post.js‘),
},
// more app routes
],
},
{
path: ‘admin‘,
component: ‘app-layout-admin‘,
children: [
{
path: ”,
component: ‘app-page-admin-home‘,
action: () => import(‘./pages/admin-home.js‘),
},
// more admin routes
],
},
{
path: ‘**‘,
component: ‘app-page-404‘,
action: () => import(‘./pages/404.js‘),
},
];
We can model our app routing system in several ways.
Without layout
Serve pages directly without a layout.
{
path: ”,
component: ‘app-page-home‘,
},
{
path: ‘about‘,
component: ‘app-page-about‘,
},
];
With layouts
Share similar elements between pages.
{
path: ”,
component: ‘app-layout-default‘,
children: [
{
path: ”,
component: ‘app-page-home‘,
},
{
path: ‘about‘,
component: ‘app-page-about‘,
},
],
},
];
Bundle or lazy load
You can either bundle or lazy load pages and layouts. Please note that for bundled layouts and pages, they must be imported first either in routes.ts or app.ts.
import ‘./pages/home.js‘;
export const routes: Route[] = [
// bundled layout
{
path: ”,
component: ‘app-layout-default‘,
children: [
// bundled page
{
path: ”,
component: ‘app-page-home‘,
},
// lazy-loaded page
{
path: ‘about‘,
component: ‘app-page-about‘,
action: () => import(‘./pages/about.js‘),
},
],
},
// lazy-loaded layout
{
path: ‘admin‘,
component: ‘app-layout-admin‘,
action: () => import(‘./layouts/admin.js‘),
children: [
// …
],
},
];
Route parameters
Route parameters are defined using an express.js-like syntax. The implementation is based on the path-to-regexp library, which is commonly used in modern frontend libraries and frameworks.
The following features are supported:
Type
Syntax
Named parameters
profile/:user
Optional parameters
:size/:color?
Zero-or-more segments
kb/:path*
One-or-more segments
kb/:path+
Custom parameter patterns
image-:size(d+)px
Unnamed parameters
(user[s]?)/:id
export const routes: Route[] = [
{path: ”, component: ‘app-page-home‘},
{path: ‘profile/:user‘, component: ‘app-page-profile‘},
{path: ‘image/:size/:color?‘, component: ‘app-page-image‘},
{path: ‘kb/:path*‘, component: ‘app-page-knowledge‘},
{path: ‘image-:size(\d+)px‘, component: ‘app-page-image‘},
{path: ‘(user[s]?)/:id‘, component: ‘app-page-profile‘},
];
404 routes
You can define one or more 404 routes to catch not found routes, set path to **.
There are 2 level of 404: layout level and global level. The router will use the layout 404 first if no definition found at the layout level, it will then use the global 404.
{
path: ”,
component: ‘app-layout-default‘,
children: [
// layout routes
{
path: ‘**‘,
component: ‘app-page-404-layout-default‘,
action: () => import(‘./pages/404-layout-default.js‘),
},
],
},
// other routes
// global 404
{
path: ‘**‘,
component: ‘app-page-404-global‘,
action: () => import(‘./pages/404-global.js‘),
},
];
Init the Router
After defining routes for the app, next step would be init a router instance and register the routes.
From the app.ts we import the defined routes, create a router instance and add the router outlet to the template.
import {routes} from ‘./routes.js‘;
@App({})
export class AppRoot extends TiniComponent {
readonly router = createRouter(routes, {linkTrigger: true});
protected render() {
return html`<router-outlet .router=${this.router}></router-outlet>`;
}
}
Navigate between pages
With the option linkTrigger: true enabled, you can navigate between pages using the a just like normal links.
<a href=“/about”>About</a>
<a href=“/post/post-1”>Post 1</a>
You can also use the <tini-link> component provided by the Tini UI when link trigger disabled (not set or linkTrigger: false), it has a similar signature compared to the a tag and other useful stuffs, such as marked as active link.
<tini-link href=“/about” active=“activated”>About</tini-link>
<tini-link href=“/post/post-1” active=“activated”>Post 1</tini-link>
Access router and params
You can also navigate between pages in the imperative manner by using the go() method from a router instance.
@Page({})
export class AppPageXXX extends TiniComponent {
// via decorator
@UseRouter() readonly router!: Router;
// or, via util
readonly router = getRouter();
protected render() {
return html`<button @click=${() => this.router.go(‘/‘)}>Go home</button>`;
}
}
Access current route and params is similar to access router instance.
@Page({})
export class AppPageXXX extends TiniComponent {
// current route
@UseRoute() route!: ActivatedRoute;
// route params
@UseParams() readonly params!: {slug: string};
}
Route hooks and guards
Lifecycle hooks are used to perform actions when a route is activated or deactivated:
onBeforeEnter(): called when the route is about to be activated
onAfterEnter(): called when the route is activated
onBeforeLeave(): called when the route is about to be deactivated
onAfterLeave(): called when the route is deactivated
You can intercept the navigation process by returning a string or a function from the onBeforeEnter and onBeforeLeave hook:
nullish: continue the navigation process
string: cancel and redirect to the path
function: cancel and execute the function
export class AppPageAccount extends TiniComponent {
onBeforeEnter() {
if (user) return; // continue
return ‘/login‘; // redirect to login page
}
}
You can also perform async actions inside hooks, then it will wait for the actions to be resolved before process further.
Scroll to anchors
Because we use the Shadow DOM to encapsulate our app, the browser seems to be unable to serve us the correct section when we present a link with an anchor fragment /post/post-1#section-a.
Tini Router provides some methods to direct visitors to the respected sections and retrieve section headings for outlined purpose (aka. table of content).
@Page({})
export class AppPageXXX extends TiniComponent {
@UseRouter() readonly router!: Router;
private _articleRef: Ref<HTMLElement> = createRef();
onRenders() {
// will scroll to the #whatever section if presented
this.router.renewFragments(
this._articleRef.value!,
{ delay: 500 }
);
// will scroll to the #whatever section if presented
// add extract all the available headings
// IMPORTANT!!!:
// + never change a local state in onRenders() or updated() or it will cause a render loop
// + store ‘fragments’ in a global state or emit out to the parent component or employ render checkers
const fragments = this.router
.renewFragments(this._articleRef.value!, {delay: 500})
.retrieveFragments();
}
protected render() {
return html`
<article ${ref(this._articleRef)}>
…
</article>
`;
}
}
Async data
Pages are usually rendered based on some async data from server. You can use these techniques to work with such cases.
Task render
The @lit/task package provides a Task reactive controller to help manage this async data workflow.
@Page({})
export class AppPageXXX extends TiniComponent {
@Reactive() productId?: string;
private _productTask = new Task(this, {
task: async ([productId], {signal}) => {
const response = await fetch(`http://example.com/product/${productId}`, {signal});
if (!response.ok) { throw new Error(response.status); }
return response.json() as Product;
},
args: () => [this.productId]
});
protected render() {
return this._productTask.render({
pending: () => html`<p>Loading product…</p>`,
complete: (product) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
error: (e) => html`<p>Error: ${e}</p>`
});
}
}
For more detail, please see https://lit.dev/docs/data/task/
Section render
Similar to Task Render, Section Render renders a section of a page based on the values of local states. There are 4 render states:
loading: all dependencies are undefined
empty: all are null or [] or {} or zero-size Map
error: any instanceof Error
main: fulfilled scenario
render as sectionRender, // renamed to sectionRender in v0.18.0
type RenderData as SectionRenderData // renamed to SectionRenderData in v0.18.0
} from ‘@tinijs/core‘;
@Page({})
export class AppPageXXX extends TiniComponent {
@Reactive() product: SectionRenderData<Product>;
async onInit() {
this.product = await fetchProduct();
}
protected render() {
return sectionRender([this.product], {
loading: () => html`<p>Loading product …</p>`,
empty: () => html`<p>No product found!</p>`,
error: () => html`<p>Errors!</p>`
main: ([product]) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
});
}
}
Title and meta tags
To update page title and meta when navigating to different pages, init a meta instance at app.ts.
@App({})
export class AppRoot extends TiniComponent {
readonly meta = initMeta({
metadata: undefined, // “undefined” means use the extracted values from index.html
autoPageMetadata: true,
});
}
Static pages
When autoPageMetadata: true for page which is static, meta can be provide via the metadata property.
@Page({})
export class AppPageXXX extends TiniComponent {
readonly metadata: PageMetadata = {
title: ‘Some title‘,
description: ‘Some description …‘,
// …
};
}
Dynamic pages
For pages with data comes from the server, we can access the meta instance and set page metadata accordingly.
@Page({})
export class AppPageXXX extends TiniComponent {
@UseMeta() readonly meta!: Meta;
async onInit() {
this.post = await fetchPost();
this.meta.setPageMetadata(post);
}
}
Next topic will be: Bringing functionalities to TiniJS apps.
Thank you for spending time with me. If there is anything not working for you, please leave a comment or contact me on Discord, I’m happy to assist.
Wish you all the best and happy coding! 💖