Introduction to Component
To explore the possibilities of @lirx/dom
, we'll create a simple todo list:
This tutorial will cover the most essential parts of this framework through a comprehensive and generic example. After following it, you'll be able to create your own components and start to develop great web applications.
Everything is available in this repository. I encourage you to clone it, as it is a very good starting point.
Our first component
First, let's define what is a component:
A component is the most basic UI building block of a web app. It is equivalent to an Element, but it has its own template, style, properties, and interactions. It is not provided natively by the browser, but constructed by ourselves or from an external library using
@lirx/dom
.
An application is simply formed from a tree of components, starting from a "root" component.
Files structure
Just like Angular, @lirx/dom
follows the MVC pattern.
This means that a component is usually constituted of 3 files:
An html file whose name usually ends with .component.html
.
This file contains the template of the component, which extends the HTML syntax with custom attributes and tag names.
It is used to generate and manipulate the DOM.
A scss file whose name usually ends with .component.scss
.
This file contains the style of the component, and may contain css variables to allow the parent elements to easily modify the style of this component.
.css
files are supported too, but .scss
is strongly recommended, as it is much more concise and practical.
A typescript file whose name usually ends with .component.ts
.
This file defines the component itself, imports the template and styles, registers interactions from the user or other events,
and sets the values required by the template. .js
files are supported too, but we encourage the usage of typescript.
We'll focus now on the "rows" of our todo list. We'll call this component todo-list-item
.
It's simply a container with a message field and a remove button:
So let's start by creating a folder for this component:
mkdir todo-list-item
cd todo-list-item
Then we have to create the template, style, and definition of this component (in three separate files):
touch todo-list-item.component.html todo-list-item.component.scss todo-list-item.component.ts
We end up with this files' structure:
Template
The template of our component will use a custom HTML syntax called reactive-html
.
It is based on custom attributes and tags, is compatible with the standard HTML, and allows us to easily manipulate the DOM through Observables.
We'll start by editing the file todo-list-item.component.html
:
<div class="message">
{{ $.message$ }}
</div>
<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>
Every template receives a local $
variable.
This variable contains the data used by the template, and they will be reflected on the DOM.
We'll see later that the content of $
is defined into the .ts
file (here todo-list-item.component.ts
).
The first block creates a <div> those content is a Text node updated each time $.message$
changes.
Such a node is created by putting an Observable in double curly brackets, {{}}
(documentation).
<div class="message">
{{ $.message$ }}
</div>
The second block creates a <button> with a click
EventLister, whose events are sent to the Observer $.$onClickRemoveButton
.
This is achieved through the custom attribute (click)
(documentation).
<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>
So, when the user will click on this button, it will call the function $.$onClickRemoveButton
.
@lirx/dom
takes care to subscribe/unsubscribe to the Observables when the nodes are connected or leave to the DOM.
This guaranties an optimal usage of the resources.
Style
A component is nothing without style, so let's add some to it:
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
padding: 8px;
border-radius: 4px;
border: 1px solid #e1e1e1;
background-color: #fafafa;
& > .remove-button {
padding: 0 10px;
background-color: #ff6060;
border-radius: 4px;
border: 0;
text-transform: uppercase;
line-height: 30px;
color: #fff;
cursor: pointer;
}
}
The css of the component is the same as one for a custom element.
It supports :host
and :host-context
to select the element.
By default, a component does not use the ShadowDOM, so the css is not purely scoped and strictly limited to this component.
Yet, it's possible to enable the ShadowDOM per component with the option mode: 'shadow'
.
In consequence, by default, without attention, some style may leak on the descendants or on the global scope. This would result in unwanted behaviour, so we encourage the developers to follow these rules:
- always put the style and selectors into an
:host
. If we write a selector outside an:host
, it won't be scoped to the component, and it will apply globally. Sometimes it may be useful, if, for example, we want to style some elements outside our component, but it must be used with extreme precautions. - use the @scope rule,
or use the
>
(child combinator) selector. Else, we may select deep children, and apply unwanted style to them. - always set a display to the component.
We chose to remove by default the constraints imposed by the shadowDOM, as we want the developers to be able to style external components (coming from an external library), which is common, and especially useful on unmaintained ones. However, with great power comes great responsibility, so it's your role to take care not to leak any css outside your component's scope.
Component
The component itself is defined into a typescript file (in our example: todo-list-item.component.ts
).
Import the template and style
We'll start by importing the template and the style of the component:
// @ts-ignore
import html from './todo-list-item.component.html?raw';
// @ts-ignore
import style from './todo-list-item.component.scss?inline';
Using vitejs or the aot compiler, we may import various file contents using the esm syntax.
Define the component's inputs and outputs
After importing our template and style(s), we have to define the inputs and the outputs of the component:
- an
Input
is similar to the property of an element likeinput.value
, but is reactive, and can be controlled by a parent component. - an
Output
is similar to anEvent
dispatched withdispatchEvent
, and is usually intercepted too by the parent component.
Let's create an interface for the inputs and outputs of our component:
interface ITodoListItemComponentData {
readonly message: Input<string>;
readonly remove: Output<void>;
}
The message
input, of type string
, contains the message to display on this "todo-list-item",
and the remove
output, emits when the user clicks on the "remove" button of this component.
Define the component's template data
Then, we'll define the interface of the data ($
) required by our template:
interface ITemplateData {
readonly message$: IObservable<string>;
readonly $onClickRemoveButton: IObserver<any>;
}
Let's remember that our template requires a $.message$
variable, displayed in a <div>.
In consequence, we'll have to provide an Observable<string>
to fill this content.
Furthermore, we want to intercept the user's clicks on the remove button.
So we'll use the Observer $.$onClickRemoveButton
for this usage.
@lirx/dom
aims to be strongly typed. It's more verbose, but it grants pre-compilation bug detection, and guaranties more robust applications.
Component's template and inputs/outputs should have their own interfaces.
It will help other developers and yourself understanding in one look what your component is capable of.
Create the component
To create a component, we'll use the class Component and some options:
export const TodoListItemComponent = new Component<HTMLElement, ITodoListItemComponentData, ITemplateData>({
name: 'app-todo-list-item',
template: compileReactiveHTMLAsComponentTemplate({ html }),
styles: [compileStyleAsComponentStyle(style)],
componentData: (): ITodoListItemComponentData => ({
message: input<string>(),
remove: output<void>(),
}),
templateData: (node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>): ITemplateData => {
const message$ = node.input$('message');
const $onClickRemoveButton = node.$output('remove');
return {
message$,
$onClickRemoveButton,
};
},
});
Alright, we'll explain the lines one by one:
new Component<HTMLElement, ITodoListItemComponentData, ITemplateData>(...)
We start by creating a new component of type HTMLElement
with the inputs/outputs defined in ITodoListItemComponentData
and the template's data ITemplateData
.
name: 'app-todo-list-item',
Then we set a name for the component.
We recommend to always prefix the components with the same string.
Usually it will be app-
. It permits to easily do the distinction between the native elements, our app's components,
and the components coming from external libraries. This is similar to a namespace, but for components.
template: compileReactiveHTMLAsComponentTemplate({ html }),
Before being assigned to the component, a template requires to be compiled using the function compileReactiveHTMLAsComponentTemplate. Compiling the templates to executable javascript, allows many internal optimizations, permits AOT compilation, and drastically increase the performances of the application.
styles: [compileStyleAsComponentStyle(style)],
Styles requires to be compiled too, using the function compileStyleAsComponentStyle.
The compilation of the html template as well as the css is not done by the class Component
.
It must be done by the developer using the corresponding functions.
This is a design choice which allows the developers to share and reuse templates and styles across many components.
componentData: (): ITodoListItemComponentData => ({
message: input<string>(),
remove: output<void>(),
}),
If our component has inputs and/or outputs, we have to define them using the function componentData
.
This function is called immediately when we create a component, and must return the inputs/outputs defined in the Data
interface (here ITodoListItemComponentData
).
templateData: (node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>): ITemplateData => {
// ...
},
Finally, we have the templateData
function.
This is where we'll define the interactions, the data, the pipelines, etc. of our component,
and return the data used in the template.
This templateData function is called when the component is instanced, and
receives a single argument: node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>
.
This is a representation of the node in the DOM.
It has some useful methods and properties to: bind Observables with this node, listen to events, subscribe to inputs, dispatch outputs, etc.
Then, this function returns the data required by the template.
So, we'll use this function to bind the inputs and outputs with the data required by the template:
// gets the 'message' input as an Observable
const message$ = node.input$('message');
// gets the 'remove' output as an Observer
const $onClickRemoveButton = node.$output('remove');
// returns the data required by the template
return {
message$,
$onClickRemoveButton,
};
Bring all together
Phew 😵💫, it was a technical part with a lot of new things to remember ! But we did it 🥳.
Here's the code for our todo-list-item
component:
- HTML
- SCSS
- TS
<div class="message">
{{ $.message$ }}
</div>
<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
padding: 8px;
border-radius: 4px;
border: 1px solid #e1e1e1;
background-color: #fafafa;
& > .remove-button {
padding: 0 10px;
background-color: #ff6060;
border-radius: 4px;
border: 0;
text-transform: uppercase;
line-height: 30px;
color: #fff;
cursor: pointer;
}
}
import { IObservable, IObserver } from '@lirx/core';
import {
compileReactiveHTMLAsComponentTemplate,
compileStyleAsComponentStyle,
Component,
output,
input,
Input,
Output,
VirtualComponentNode,
} from '@lirx/dom';
// @ts-ignore
import html from './todo-list-item.component.html?raw';
// @ts-ignore
import style from './todo-list-item.component.scss?inline';
/**
* COMPONENT: 'app-todo-list-item'
**/
interface ITodoListItemComponentData {
readonly message: Input<string>;
readonly remove: Output<void>;
}
interface ITemplateData {
readonly message$: IObservable<string>;
readonly $onClickRemoveButton: IObserver<any>;
}
export const TodoListItemComponent = new Component<HTMLElement, ITodoListItemComponentData, ITemplateData>({
name: 'app-todo-list-item',
template: compileReactiveHTMLAsComponentTemplate({ html }),
styles: [compileStyleAsComponentStyle(style)],
componentData: (): ITodoListItemComponentData => ({
message: input<string>(),
remove: output<void>(),
}),
templateData: (node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>): ITemplateData => {
const message$ = node.input$('message');
const $onClickRemoveButton = node.$output('remove');
return {
message$,
$onClickRemoveButton,
};
},
});
The todo-list component
Alright, we've created our first component displaying a "message" provided as input and sending a "remove" event as output.
Now, we'll create the component initializing and managing many todo-list-item
, and call it todo-list
.
When the user enters a new message, it will append a new todo-list-item
to a list, and when the user clicks on the "remove" button,
this item will be removed from this list.
Let's begin by creating the correct directory, and its associated files for this component:
cd ..
mkdir todo-list
cd todo-list
touch todo-list.component.html todo-list.component.scss todo-list.component.ts
I'll give you now the content of each file. However, do not focus yet on the code, as I'll explain it line by line right after.
I'll skip too the explanation for the .scss
file. It's only there to give a nice look to our component with just css.
- HTML
- SCSS
- TS
<form
class="input-form"
(submit)="$.onFormSubmit"
>
<input
[value]="$.inputValue"
(input)="$.onInput"
placeholder="Enter task description"
/>
<button type="submit">
Add
</button>
</form>
<div class="list">
<app-todo-list-item
*for="let item of $.items"
$[message]="item.message"
$(remove)="() => $.removeItem(item)"
></app-todo-list-item>
</div>
:host {
display: block;
max-width: 600px;
margin: 0 auto;
padding: 10px;
& > .input-form {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
& > * {
height: 36px;
border-radius: 4px;
}
& > input {
flex-grow: 1;
padding: 5px 5px;
border: 1px solid #a6a6a6;
}
& > button {
padding: 0 10px;
background-color: #e1e1e1;
border: 0;
text-transform: uppercase;
line-height: 36px;
cursor: pointer;
}
}
& > .list {
padding-top: 10px;
& > * {
margin: 5px 0;
}
}
}
import { signal, ISignal, $log, unknownToObservableStrict } from '@lirx/core';
import { compileReactiveHTMLAsComponentTemplate, compileStyleAsComponentStyle, Component } from '@lirx/dom';
import { Writable } from '@lirx/utils';
import { TodoListItemComponent } from '../../todo-list-item/todo-list-item.component';
import { ITodoListItemsList, ITodoListItem } from '../todo-list.types';
// @ts-ignore
import html from './todo-list-with-signals.component.html?raw';
// @ts-ignore
import style from '../todo-list.component.scss?inline';
/**
* COMPONENT: 'app-todo-list-with-signals'
**/
interface ITemplateData {
readonly inputValue: ISignal<string>;
readonly onInput: (event: Event) => void;
readonly onFormSubmit: (event: Event) => void;
readonly items: ISignal<ITodoListItemsList>;
readonly removeItem: (item: ITodoListItem) => void;
}
export const TodoListWithSignalsComponent = new Component({
name: 'app-todo-list-with-signals',
template: compileReactiveHTMLAsComponentTemplate({
html,
components: [
TodoListItemComponent,
],
}),
styles: [compileStyleAsComponentStyle(style)],
templateData: (): ITemplateData => {
/* ITEMS */
const items = signal<ITodoListItemsList>([]);
const addItem = (
message: string,
): void => {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items,
{
message,
},
];
});
};
const removeItem = (
item: ITodoListItem,
): void => {
const index: number = items().indexOf(item);
if (index !== -1) {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items.slice(0, index),
...items.slice(index + 1),
];
});
}
};
addItem('Check this awesome tutorial');
addItem('Write your own components');
/* INPUT */
const inputValue = signal<string>('');
const onInput = (event: Event): void => {
inputValue.set((event.target as HTMLInputElement).value);
};
const onFormSubmit = (event: Event): void => {
event.preventDefault();
const value = inputValue().trim();
if (value !== '') {
addItem(value);
}
inputValue.set('');
};
return {
inputValue,
onInput,
onFormSubmit,
items,
removeItem,
};
},
});
Let's start with the template:
<form
class="input-form"
(submit)="$.onFormSubmit"
>
...
</form>
This creates a form containing an input with a submit button.
It is used to let the user enter a message and submit it.
As seen into the previous component, we subscribe to the "submit" event using the syntax: (submit)="$.onFormSubmit"
.
So, the function $.onFormSubmit
is called when the form is submitted.
This functions will look like this:
const onFormSubmit = (event: Event): void => {
event.preventDefault(); // we want to prevent our form to be submitted
// get the input's value
const value: string = inputValue().trim();
if (value !== '') {
// add a new entry
addItem(value);
}
// reset the input's value
inputValue.set('');
};
The next step - the input itself:
<input
[value]="$.inputValue"
(input)="$.onInput"
placeholder="Enter task description"
/>
[value]="$.inputValue"
: we ask the framework to subscribe to the Signal $.inputValue
, and assign the received values to the <input>'s property value
-> input.value = ...
(documentation).
Thus, in the .ts
file, we have to define this Signal:
const inputValue = signal<string>('');
Now, if update the signal though inputValue.set(...)
(ex: inputValue.set('some content')
), then, the input will immediately reflect this value.
(input)="$.onInput"
: you probably guess what it does 😉. It subscribes to the "input" events, and sends them to the function $.onInput
.
In this function, we'll read the inputs' value, and update the Signal inputValue
with this value:
const onInput = (event: Event): void => {
inputValue.set((event.target as HTMLInputElement).value);
};
Alright, let's continue
<app-todo-list-item
*for="let item of $.items"
$[message]="item.message"
$(remove)="() => $.removeItem(item)"
></app-todo-list-item>
*for="let item of $.items"
: this iterates over the array of "items" sent by the Signal $.items
(documentation).
For each value presents in this array, it creates an app-todo-list-item
component.
Each of these values are individually represented by the local variable item
.
Now, we have to define $.items
:
const items = signal<ITodoListItemsList>([]);
Just like we did with the input's value, here, we're using a Signal to store our items.
These items are simply objects with a message
property:
interface ITodoListItem {
readonly message: string;
}
type ITodoListItemsList = readonly ITodoListItem[];
As you may notice, we're only using readonly arrays and readonly properties. Let me explain why:
Observables and Signals are streams of data. So mutating the data itself (like the property of an object or the content of an array), won't emit a new value into the corresponding Observable/Signal. In our case, it means "no DOM updates". Instead, we have to use immutable values, and send new references when a change occurs.
For a Signal, we'll have to use the .update(...)
function:
const addItem = (
message: string,
): void => {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items,
{
message,
},
];
});
};
$[message]="item.message"
: this line sets item.message
as the value of the 'message' input of the component app-todo-list-item
(documentation).
Instead of a static value, we could have used an Observable or Signal.
It's up to you, as most attribute's binding accept a Reactive Value.
$(remove)="() => $.removeItem(item)"
: it subscribes to the output 'remove', and calls the function $.removeItem
when it appends (documentation).
This function gets the list of items, searches for the index of the removed one, and updates the Signal items
with a new list not containing this item:
const removeItem = (
item: ITodoListItem,
): void => {
const index: number = items().indexOf(item);
if (index !== -1) {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items.slice(0, index),
...items.slice(index + 1),
];
});
}
};
Finally, we have to declare which components we're using in this template:
template: compileReactiveHTMLAsComponentTemplate({
html,
customElements: [
TodoListItemComponent,
],
}),
Here, we're employing app-todo-list-item
, so we have to import the real component into the .ts
file.
If we don't, the framework will emit an Error.
This is a security to ensure that every component exists and is properly loaded before rendered.
Hurrah 🤩, you've survived to the longest part !
The todo-list
is ready and functional, however, we still have to define an entry point for our application.
Starting the application
A little more patience, we're almost done 😌.
We have all the required components, so the last step is to "start" our application. To do this, we'll use the function bootstrap:
import { bootstrap } from '@lirx/dom';
import { TodoList } from './todo-list/todo-list.component';
bootstrap(TodoList);
It simply takes as input the "root" component of our application and injects it into the DOM, starting our application.
And voilà ! The application is ready to be used.
Conclusion
You are now a champion of @lirx/dom
🏆.
You've learned the basics, and know perfectly:
- what is a component
- what is a template, and how to create one
- what are the components' inputs and outputs
- how you can use the Observables and Signals to interact with your template
Now, it's time to create your own application 🚀.