SipaComponent
With SipaComponent, Sipa provides a very easy to use, powerful basic component to encapsulate logic.
Compared to components in other frameworks, SipaComponent is much easier and simpler.
It is just HTML/Javascript without any transpiling, that runs directly in your browser.
The API documentation for SipaComponent can be found here.
Use case
The typical use cases to use SipaComponent are custom form elements as well as more complex gui logic, that you want to reuse more often or at least encapsulate into smaller, logical pieces.
Features
SipaComponent has the following features:
- Templating with EJS - so it's pure Javascript
 - Component nesting - reuse components inside components
 - Component slots - embed content easily
 - Global Scope and access of all components
 - Integrated support for hide/show (including its automatic state management)
 - Easy access to nested or parent components by aliases
 - Referenced data state from parents to its children
 - Auto support to render only if data changes or component is in view area (TODO)
 - Declarative and programmatically use and initialization
 
Create a new component
To create a new component, just run the Sipa generator by
sipa g
and select component.
This will create a new component inside app/assets/components consisting of a prepared template of a Javascript and Stylesheet file.
├── app
│ └── assets
│   └──components
│     └── example-component
│       ├── example-component.js
│       └── example-component.(s)css
If you only want to use this component on a special page as page encapsulation, then create a subfolder components in your page directory and move the component directory inside it. E.g. app/views/pages/my-page/components/example-component and rerun sipa i.
Component Structure
The new component template looks like this:
class ExampleComponent extends SipaComponent {
    constructor(data = {}, opts = {}) {
        // define your defaults here
        data.example ??= "world";
        super(data, opts);
        this.events().subscribe("before_destroy", this.onDestroy);
    }
    onDestroy() {
        // this is called, before the component is destroyed by destroy()
    }
    showAlert() {
        alert(`Bye, bye, ${this._data.example}!`);
    }
}
//--- TEMPLATE ---------------------------------------------------------------------------------------------------------
ExampleComponent.template = () => {
    return `
<example-component onclick="instance(this).showAlert();" class="template-class">
    <span>Hello <%= example => with id <%= _meta.sipa.id %>!</span>
</example-component>
    `.trim();
}
SipaComponent.registerComponent(ExampleComponent);
example-component {
}
Explanation
- Component classes are usually named with the prefix 
Componentand inherit from theSipaComponent. It's tag name is the same name indash-case. - Default values can be set in the constructor by 
data.my_value ??= "default value". - Data state is stored in the 
_dataattribute of the class. It is not really private by purpose to give you the full control, if you need it. - To access instance methods of your component inside your template, use 
instance(this)to get your instance class. It is a shortcut forExampleComponent.instanceOfElement(this) - The template is defined by a static 
templatemethod. The template itself supports EJS for templating. In EJS you have access to all children variables of_data. You can also access the_metaby_meta.my_variable. - The component must be registered to the SipaComponent, to unleash its full power! (or to work at all)
 
Using and initializing your component
You can either initialize your components declarative or programmatically, depending on your needs.
Declarative initialization
For declarative initialization you can embed your component on any page inside the HTML.
Data parameters are given by attribute names. If you want to overwrite or add some real attribute to the initialized instance, use the prefix attr-.
Furthermore there are special attributes with the prefix sipa- to provide special features.
<div id="some-container">
    <example-component example="'Pinky'" other="1+1" attr-id="my-custom-id" attr-style="color: red;" attr-class="declarative-class" sipa-hidden="true"></example-component>
</div>
In this example we pass the data example with the string value Pinky. Ensure to put the string in quotes, as the value inside the example attribute is pure javascript. You can also put in numbers or even run some code.
We also use the integrated hide/show feature and instance the component in hidden state.
The result of this example will be the following, rendered component:
<div id="some-container">
    <example-component sipa-id="1" style="color: red;" class="template-class declarative-class">
        <span>Hello Pinky!</span>
    </example-component>
</div>
Classes on the components tag level are managed automatically, if their attribute is used twice, they will be merged.
Let's access the instance of the element on the console and retrieve a clone of its data:
c = ExampleComponent.all()[0]; // get first instance of ExampleComponent
d = ExampleComponent.byId("my-custom-id"); // get instance of ExampleComponent with given id attribute
console.log(d.cloneData());
// => { example: "Pinky", other: 2 }
If we update the component, the state will be set and the view be rerendered.
    c = ExampleComponent.bySipaId(1);
    c.update({ example: "Crocodile"});
    c.removeClass("declarative-class");
    c.addClass("super-class");
<div id="some-container">
    <example-component sipa-id="1" style="color: red;" class="template-class super-class">
        <span>Hello Crocodile!</span>
    </example-component>
</div>
Be aware, never to manipulate classes externally in the DOM on your own, as their state will not be stored. Always store your state in _data and respect the values with EJS inside your teamplate. When you use the removeClass() or addClass() provided by SipaComponent, it will take care of its state internally. So if you rerender your component, the state will remain. If you would manipulate the DOM externally, your changes will dissappear after the next render.
The initialization of declarative components can be done manually, especially when adding new components the declarative way dynamically. Otherwise by default, they are initialized in the page init hook of your Sipa app:
// ...
SipaHooks.beforeInitPage('on', () => {
    // This will initialize all registered SipaComponents and its inherited components in the DOM
    SipaComponent.init();
    // This would only initialize all ExampleComponents in the DOM
    // ExampleComponent.init();
});
// ...
Initialize programatically
The same result as shown before the declarative way can be archived by the following HTML and Javascript code:
<div id="some-container">
</div>
class MyPage extends SipaBasicView {
    // ...
    onInit() {
        const c = new ExampleComponent({ 
            example: "Pinky", 
            other: 1 + 1, 
            sipa_hidden: true, 
            sipa_custom_attributes: { style: "color: red", id: "my-custom-id" }}
        );
        c.append("#some-container"); // append the instance to the container
        // We can also prepend the instance to the container:        
        // c.prepend("#some-container");
    }
    // ...
}
Nesting components
You are able to nest components and make complex compositions for components in components.
Declarative nesting
To do so, you have to embed them in your template() method the declarative way.
All embedded components must have declared by a sipa-alias attribute, to give them a name to access them by their parent instance.
// ...
CarComponent.template = () => {
    return `
<car-component onclick="instance(this).children().steering_wheel.update({locked: false });">
    <h1><%= title %></h1>
    <span>Locked state of steering wheel: <%= steering_wheel.locked %></span>
    <steering-wheel-component sipa-alias="steering_wheel" locked="true"></steering-wheel-component>
    <wheel-component sipa-alias="wheel_front_left" state="'ok'"></wheel-component>
    <wheel-component sipa-alias="wheel_front_right" state="'hot'"></wheel-component>
    <wheel-component sipa-alias="wheel_back_left" state="'ok'"></wheel-component>
    <wheel-component sipa-alias="wheel_back_right" state="'broken'"></wheel-component>
    <brake-lights-component state="'<%= brake.put_on ? 'on' : 'off' %>'"></brake-lights-component>
    <starter-component attr-onclick="instance(this).parentTop().update({ title: 'NewCarTitle' });"></starter-component>
    <% if(wheel_front_left.state === 'broken' && wheel_front_right.state === 'broken) { %>
        <span class="warn-message">Both frond wheels are broken!</span>
    <% } %>
<car-component>    
    `.trim();
}
// ...
In nested components, you can access children instances by children(), the parent top with parent() and the top level parent by parentTop() if nested over several levels.
Of course you can access these methods not only in the template, but also in the class instance or everywhere in your code.
But children data for EJS is accessible in the parent class at the attribute defined with sipa-alias.
Nesting programmatically
Full customized nesting
In this example we will insert the wheel-components programmatically the very custom way.
The next section you will find a example using a more easy and automated way to solve this.
// ...
CarComponent.template = () => {
    return `
<car-component onclick="instance(this).children().steering_wheel.update({locked: false });">
    <h1><%= title %></h1>
    <span>Locked state of steering wheel: <%= steering_wheel.locked %></span>
    <steering-wheel-component sipa-alias="steering_wheel" locked="true"></steering-wheel-component>
    <div class="wheel_container"></div>
    <brake-lights-component state="'<%= brake.put_on ? 'on' : 'off' %>'"></brake-lights-component>
    <starter-component attr-onclick="instance(this).parentTop().update({ title: 'NewCarTitle' });"></starter-component>
    <% if(wheel_front_left.state === 'broken' && wheel_front_right.state === 'broken) { %>
        <span class="warn-message">Both frond wheels are broken!</span>
    <% } %>
<car-component>    
    `.trim();
}
// ...
class CarComponent extends SipaComponent {
    // ...
    
    // we instance our elements in the constructor
    constructor(data = {}, opts = {}) {
        // define your defaults here
        data.example ??= "world";
        data.wheels = [
            new WheelComponent({ sipa_alias: "wheel_front_left", state: "ok", locked: true }),
            new WheelComponent({ sipa_alias: "wheel_front_right", state: "hot" }),
            new WheelComponent({ sipa_alias: "wheel_back_left", state: "ok" }),
            new WheelComponent({ sipa_alias: "wheel_back_right", state: "broken" }),
        ]; 
        super(data, opts);
    }    
    
    
    // we overwrite the render method and render the elements after 
    // the original rendering has been finished
    render(options = {}) {
        const result = super.render(options);
        this._data.wheels.eachWithIndex((wheel, i) => {
            wheel.append(`${this.selector()} > .container`);
        });
        return result;
    }
    
    // ...
}
Using sipa-list for generic lists
As dynamic lists are a common use case, Sipa provides a feature to make the programmatically use of them very easy.
When defining a sipa-list attribute on an element of your choice, we can reference it to a variable of type Array inside of _data.
This array can contain any SipaComponents, even a mixed list of different ones!
In case if you want even to define only one component programmatically, you can use sipa-list and only add one item.
class MyListingComponent {
// ...
    constructor(data = {}, opts = {}) {
        // define your defaults here
        data.items = [];
        super(data, opts);
    }
// ...
    add() {
        this._counter ??= 1;
        this._data.items.push(new ListItemComponent({ name: randomName() }, { sipa_alias: "item_" + this._counter }));
        this.update();
    }
// ...    
}
// ...
MyListingComponent.template = () => {
    return `
<my-listing-component>
    <h1><%= title %></h1>
    <button onclick="instance(this).add();">Add item</button>
    <div sipa-list="items"></div>
<my-listing-component>    
    `.trim();
}
// ...
To remove an item from a sipa-list, just call its destroy() method, that will automatically remove it from the list and all its references automatically!
Updating data
Usually you should only use the update() or resetToData() method to update your data.
Manually modifying the _data attribute
If you have a very special case, where you modify _data manually, ensure that you call the update() method after to synchronize _data references to parent and children components and calling its "update" events.
Events
Default class method events
SipaComponent provides some default class method events, that you can optionally define in your component class.
The available events are:
onDestroy()- called, before the component is destroyed bydestroy()
Example:
class MyComponent extends SipaComponent {
    constructor(data = {}, opts = {}) {
        super(data, opts);
        this._initEventListeners();
    }
    // ...
    onDestroy() {
        // ensure to remove event listener on destroy to avoid them to 
        // be still active and to be registered several times in feature
        document.removeEventListener('keydown', this._eventListenerEsc);
    }
    // ...
    _initEventListeners() {
        // bind the event listener to this instance
        this._eventListenerEsc = this._eventListenerEsc.bind(this);
        // add the event listener
        document.addEventListener('keydown', this._eventListenerEsc);
    }
    // ...
    _eventListenerEsc(event) {
        if (event.key === "Escape" && this?._data?.state?.is_open) {
            alert("ESC");
            this.close();
        }
    }
}
SipaEvents
SipaComponents have defined a SipaEvents instance at events() to manage events.
By default, SipaComponent ships with the following events: before_update,after_update,before_destroy,after_destroy.
These events are of type SipaEvents and can be subscribed to. A typical use case is listening to updates of children components by a parent component.
class MyParentComponent {
// ...
    constructor(data = {}, opts = {}) {
        // define your defaults here
        data.items = [];
        super(data, opts);
        this.initTemplate(); // ensure the template and its children are initialized, otherwise this.children() will be empty
        this.children().childy.events().subscribe("before_update", this.onBeforeChildrenUpdate);
        this.children().childy.events().subscribe("after_update", this.onAfterChildrenUpdate);
        this.events().createEvents("before_open","after_open"); // create new events on the fly
    }
// ...
    onBeforeChildrenUpdate(child, data, options) {
        // here we can react or even modify the 'data' and 'options' parameters of update(data, options), as they are passed by reference
        if(data.some_data === true) {
            data.additional_data ??= "foo";   
        }
        // check if some data property is beeing updated/changed
        if(data?.foo?.hasOwnProperty("bar")) {
            // data.foo.bar is beeing updated
        }
    }
// ...
    onAfterChildrenUpdate(child, data, options) {
        // here we can do some stuff, after the childrens update() method had been called
        if(child._data.some_attribute === "bar") {
            this.doSomeOtherUpdateOrAction();
        }
        // be aware, that child._data contains the latest data and 'data' is the original parameter data!
        // check if some data property has been updated/changed
        if(data?.other?.hasOwnProperty("example")) {
            // data.other.example has been updated
        }        
    }
// ...
    open() {
        this.events().trigger("before_open");
        // some opening stuff ...   
        const foo = "bar";
        this.events().trigger("after_open");
    }
}
// ...
MyParentComponent.template = () => {
    return `
<my-parent-component>
    <h1><%= title %></h1>
    <my-children-component sipa-alias="childy"></my-children-component>
<my-parent-component>    
    `.trim();
}
Slots
SipaComponent provides slots to embed content.
// ...
SlotComponent.template = () => {
    return `
<slot-component>
    <h1><%= title %></h1>
    This is general content
    <slot>Default content, if no content is given</slot>
<slot-component>    
    `.trim();
}
// ...
For example the following definition
declarative
<slot-component title="'MyTitle'">
    This is <span>some</span> embedded content!
</slot-component>
programatically
new SlotComponent({
    title: "MyTitle",
}, {
    content: "This is <span>some</span> embedded content!",
})
will result in
<slot-component>
    <h1>MyTitle</h1>
    This is general content
    This is <span>some</span> embedded content!
</slot-component>
Named slots
If you want to define and use several slots, you need to name them.
You can use the default slot or even use named slots. A slot without name is automatically named default.
// ...
NamedSlotComponent.template = () => {
    return `
<named-slot-component>
    <h1><slot name="title"></slot></h1>
    <slot name="default"></slot>
    <slot name="body"></slot>
    <slot name="footer"></slot>
<named-slot-component>    
    `.trim();
}
// ...
For example the following usage
declarative
<named-slot-component>
    <div slot="footer">(C) 2024 Company</div>
    <span slot="title">Attention!</span>
    Default slot content
    <p slot="body">This is the body!</p>
    and more
</named-slot-component>
will result in
<named-slot-component>
    <h1><span slot="title">Attention!</span></h1>
    Default slot content and more
    <p slot="body">This is the body!</p>
    <div slot="footer">(C) 2024 Company</div>
</named-slot-component>