0.0.47 • Published 2 years ago

@interstice/mx v0.0.47

Weekly downloads
-
License
MIT
Repository
-
Last release
2 years ago

mx

WebComponents powered by Million

Warning This was a thought experiment of creating a concise api to facilitate WebComponents creation using a lightweight vdom that supports jsx. This is not production ready by any stretch.

Getting started

npm i @interstice/mx

CustomElement

Creating a CustomElement is similar to any jsx based rendering lib

import { MXElement, CustomElement, Prop, State } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Prop()
    title: string = "this is a default prop value"

    @State()
    count: number = 0

    increment = () => {
        this.count++
    }

    styles() {
        return /* css */ `
          button {
            border: none;
            background: hotpink;
            color: black;
            padding: 0.5rem 0.75rem;
          }
    `
    }

    render() {
        return (
            <button title={this.title} onclick={this.increment}>
                {this.count}
            </button>
        )
    }
}

Dynamic Elements

To allow for a CustomElement to naturally codesplit on an element and its children, you can dynamically import and await the promises of the child elements in an elements async method. You are always free to dynamically import lazily from any other method if wanting to defer a specific child element to button click or state change for example.

A safe default of importing and nesting elements.

import { MXElement, CustomElement, Prop, State } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    async elements() {
        await import('./my-icon.tsx')
    }

    render() {
        return (
            <button>
                Edit <my-icon name="pencil"></my-icon>
            </button>
        )
    }
}

Lifecycle Methods

import { MXElement, CustomElement, Prop, State } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    // Beginning of connectedCallback of CustomElement
    async connected(): Promise<void> {
        // This is likely where you will want to fetch data from api's and do other work before the DOM is ready.
        // This is a setup method, can be also used for manually adding any event listeners. Prefer using `On` decorator for event listeners.
    }

    // End of connectedCallback of CustomElement, DOM exists and is mounted
    mounted(): void {
        // There is now access to DOM nodes, and VDOM has executed it's first render.
    }

    // Beginning of disconnectedCallback of CustomElement
    async disconnected(): Promise<void> {
        // This is a teardown method, can be also used for analytics beacons or to remove any event listeners that were manually attached.
    }

    // End of disconnectedCallback of CustomElement, DOM no longer exists and is unmounted
    unmounted(): void {
        // There is no access to DOM nodes, and VDOM is also removed.
    }

    render() {
        return (
            <button>
                Edit <my-icon name="pencil"></my-icon>
            </button>
        )
    }
}

Events

MX Supports event emission through a method decorator Dispatch and event listening through the method decorator On.

Dispatch

Option

  • string (required eventName)

Sending events is done in the natively supported way of the web platform, by creating and dispatching events.

The single string property serves as the name of the CustomEvent you will create and dispatch, the return value of the method becomes the detail value of the event.

import { MXElement, CustomElement, State, Dispatch } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @State()
    count: number = 0
    
    @Dispatch('updateCountTotal')
    increment = () => {
        this.count++
        
        return this.count
    }
    
    
    render() {
        return (
            <button title={this.title} onclick={this.increment}>
                My Button
            </button>
        )
    }
}

On

Option

  • string (required eventName)

Receiving events is done in the natively supported way of the web platform, by listening to events.

The single string property serves as the name of the Event you will listen for.

import { MXElement, CustomElement, State, On } from '@interstice/mx'

@MXElement({ tag: 'my-counter' })
class MyButton extends CustomElement {

    @State()
    totalCount: number = 0
    
    @On('updateCountTotal')
    updateTotal(e: CustomEvent<number>) {
        this.totalCount = e.detail
    }
    
    render() {
        return (
            <div>
                <h1>Total clicked: {this.totalCount}</h1>
                <my-button></my-button>
            <div>
        )
    }
}

State Management

MX supports various forms of state through the following property decorators. Decorators can be used in conjunction but are limited to one decorator type per element property and execute in the order of appearance.

Prop

Serves as the basis of parent -> child data passing via observed attributes. Changes to these properties will cause automatic rerender of the element.

import { MXElement, CustomElement, Prop } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Prop()
    title: string = "this is a default prop value"

    render() {
        return (
            <button title={this.title}>
                My Button
            </button>
        )
    }
}

State

Allows for element owned state to be tracked. Any updates to these properties will cause automatic rerender of the element.

import { MXElement, CustomElement, State } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @State()
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Storage

Storage as the name suggests is a way to interface with a backing storage through a property decorator. There are currently four supported adapters for the storage decorator: local, session, cookie, and memory.

Local (LocalStorage)

Options

  • key: string
  • storageType: 'local' (default)
  • scope: string|(el: CustomElement) => string (optional)

LocalStorage is supported by default and can optionally be scoped to a value specific to the element instance, and can be dynamic to another property.

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count' })
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Session

Options

  • key: string
  • storageType: 'session'
  • scope: string|(el: CustomElement) => string (optional)

SessionStorage is supported in the identical way as LocalStorage.

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count', storageType: 'session' })
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Cookie

Options

  • key: string
  • storageType: 'cookie'
  • expiry: 1 (optional) expiry in days
  • scope: string|(el: CustomElement) => string (optional)

CookieStorage is currently supported much like local and session storage, and as a result cannot yet pass the full range cookie options. Currently only expiry is supported.

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count', storageType: 'cookie', expiry: 1 })
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Memory

Options

  • key: string
  • storageType: 'memory'
  • scope: string|(el: CustomElement) => string (optional)

This stores information in a Map, and is global to all elements.

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count', storageType: 'memory' })
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}
Multiple

Property decorators stack and can be used to work with various states in sync. If for example, you want a component to rerender automatically when interfacing with Storage adapters, you can add a State decorator as well.

This ensures the data is loaded from local storage initially, but updates to the property not only update local storage it will rerender the component automatically.

import { MXElement, CustomElement, Storage, State } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count' })
    @State()
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}
Scoped

This allows storage access to be scoped or grouped to specific data and can operate on other instance properties of the element.

Static Reference:

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    @Storage({ key: 'count', scope: 'group:1' })
    count: number = 0
    
    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Dynamic Reference:

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    id: string
    
    @Storage({ key: 'count', scope: '@scopeId' })
    count: number = 0
    
    scopeId() {
        return `group:${el.id}`
    }

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Dynamic Function:

import { MXElement, CustomElement, Storage } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {

    id: string
    
    @Storage({ key: 'count', scope: (el: MyButton) => `group:${el.id}` })
    count: number = 0

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Styles

To more easily streamline styling, any string that is returned from a CustomElement styles method will be inserted into the ShadowDOM of the element. Styling native WebComponents can be done in any possible way that leads to the styles property returning a string, babel macros or other solutions may be possible here as decorated functionality though not explicitly supported.

For Design systems and theming management check out the OpenProps project.

import { MXElement, CustomElement } from '@interstice/mx'

@MXElement({ tag: 'my-button' })
class MyButton extends CustomElement {
    styles() {
        return /* css */ `
          button {
            border: none;
            background: hotpink;
            color: black;
            padding: 0.5rem 0.75rem;
          }
        `
    }

    render() {
        return (
            <button>
                {this.count}
            </button>
        )
    }
}

Dark mode

Dark mode toggling can be achieved through a couple custom elements and a few of the earlier introduced concepts.

ThemeProvider - manage the style state

import { CustomElement, MXElement, On, Storage } from "@interstice/mx";

@MXElement({ tag: "theme-provider" })
export class ThemeProvider extends CustomElement {
    @Storage({ key: "darkMode" })
    darkMode: boolean | undefined = undefined;

    @On("updateDarkMode")
    updateDarkMode(e: CustomEvent<boolean>) {
        this.darkMode = e.detail;
        super.updateStyles();
    }

    async connected() {
        if (typeof this.darkMode !== "boolean") {
            this.darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
        }
    }

    darkModeStyles() {
        return `
          --background-color: black;
          --color: white;
        `;
    }

    lightModeStyles() {
        return `
          --background-color: white;
          --color: black;
        `;
    }

    styles() {
        return `
          ${super.styles()}
          :host {
            ${this.darkMode ? this.darkModeStyles() : this.lightModeStyles()}
          }
        `;
    }

    render() {
        return (
            <slot></slot>
        );
    }
}

ThemeToggle - a button to trigger the change in the ThemeProvider

import {
    CustomElement,
    Dispatch,
    MXElement,
    State,
    Storage,
} from "@interstice/mx";

@MXElement({ tag: "theme-toggle" })
export class ThemeToggle extends CustomElement {
    @Storage({ key: "darkMode" })
    @State()
    enabled: boolean | undefined = undefined;

    @Dispatch("updateDarkMode")
    toggle = () => {
        this.enabled = !this.enabled;

        return this.enabled;
    };

    styles() {
        return /* css */ `
          button {
            background-color: transparent;
            cursor: pointer;
            font-size: 20px;
            appearance: none;
            border: none;
            height: 34px;
            width: 34px;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 6px;
            will-change: background-color;
            transition: background-color 0.2s ease-out;
          }
          button:hover {
            background-color: var(--background-color);
          }
        `;
    }

    render() {
        return (
            <button type="button" onclick={this.toggle}>
                {this.enabled ? "🌙" : "☀️️️"}
            </button>
        );
    }
}

Using the ThemeProvider and ThemeToggle in an AppShell like element:

import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "my-app" })
export class MyApp extends CustomElement {
    async elements() {
        await Promise.all([
            import('./theme-provider.tsx'),
            import('./theme-toggle.tsx')
        ])
    }
    
    render() {
        return (
            <theme-provider>
                <theme-toggle></theme-toggle>
                {<!-- Remainder of application -->}
            </theme-provider>
        );
    }
}

Routing

MX includes a nested router capable of transforming any element into a RouteElement by simply specifying that the element responds to a route pattern.

Route patterns are just regex or strings containing regex.

import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "page-index", route: '^/$' })
export class PageIndex extends CustomElement {
    render() {
        return (
            <div>
                Page Index matches `/` only.
                <slot></slot>
            </div>
        );
    }
}

Routes can match dynamic portions

import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "page-details", route: '^\/details\/?(\\d+)?$' })
export class PageDetails extends CustomElement {
    render() {
        return (
            <div>
                Page Details matches `/details` or `/details/` or `/details/1`, but not `/details/a.
            </div>
        );
    }
}

Dynamic portions can be named for easier reference later. Any value between < and > and immediately preceding a capture group will name that group. The <name> capture group labels are removed and do not affect route matching.

import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "page-details", route: '^\/details\/?<id>(\\d+)?$' })
export class PageDetails extends CustomElement {
    render() {
        return (
            <div>
                Page Details matches `/details` or `/details/` or `/details/1`, but not `/details/a.
            </div>
        );
    }
}
import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "my-app" })
export class MyApp extends CustomElement {
    render() {
        return (
            <div>
                <page-index>
                    <page-details></page-details>
                </page-index>
            </div>
        );
    }
}

Route Params

All the details of a matched route can be mapped to a nested elements properties via a set of three property decorators: Param, QueryParam, HashParam.

Param

Given this button is used within the previous page-details element example the id Param can be retrieved in 2 ways.

Indexed

The params are matched within a RegExpMatchArray, so the first group match will be index 1...n

import { CustomElement, MXElement, Param } from "@interstice/mx";

@MXElement({ tag: "my-button" })
export class MyApp extends CustomElement {
    @Param(1)
    id: number|undefined = undefined

    render() {
        return (
            <button>
                Add {this.id} to cart.
            </button>
        );
    }
}
Named
import { CustomElement, MXElement, Param } from "@interstice/mx";

@MXElement({ tag: "my-button" })
export class MyApp extends CustomElement {
    @Param('id')
    id: number|undefined = undefined
    
    render() {
        return (
            <button>
                Add {this.id} to cart.
            </button>
        );
    }
}

QueryParam

Any query params contained in the url can be interacted with.

import { CustomElement, MXElement, Param, QueryParam } from "@interstice/mx";

@MXElement({ tag: "my-button" })
export class MyApp extends CustomElement {
    @Param('id')
    id: number|undefined = undefined
    
    @QueryParam('addedToCart')
    addedToCart: string = ''
    
    toggle = () => {
        this.addedToCart = this.addedToCart ? '' : this.id.toString()
    }

    render() {
        return (
            <button onClick={this.clicked}>
                {this.addedToCart ? `Add ${this.id} to cart` : `Remove ${this.id} from cart`}
            </button>
        );
    }
}

HashParam

Any hash params contained in the url can be interacted with.

import { CustomElement, MXElement, Param, HashParam } from "@interstice/mx";

@MXElement({ tag: "my-button" })
export class MyApp extends CustomElement {
    @Param('id')
    id: number|undefined = undefined
    
    @HashParam('addedToCart')
    addedToCart: string = ''
    
    toggle = () => {
        this.addedToCart = this.addedToCart ? '' : this.id.toString()
    }

    render() {
        return (
            <button onClick={this.clicked}>
                {this.addedToCart ? `Add ${this.id} to cart` : `Remove ${this.id} from cart`}
            </button>
        );
    }
}

MXLink

To single page route between pages a CustomElement is already defined mx-link which can be used to route between any matching route elements.

import { CustomElement, MXElement } from "@interstice/mx";

@MXElement({ tag: "my-app" })
export class MyApp extends CustomElement {
    // Active links can be styled
    // Links which are set as `root` are considered `aria-current="page"` and have a shadow part `anchor-current-page`.
    // All other Links are are considered `aria-current="location"` and have a shadow part `anchor-current-location` denoting nested matched route links.
    styles() {
        return `
          mx-link::part(anchor),
          mx-link::part(anchor-current-page),
          mx-link::part(anchor-current-location) {
            color: white;
            background-color: black;
            box-sizing: border-box;
            display: flex;
            align-items: center;
            justify-content: center;
            text-decoration: none;
            height: 36px;
            padding-inline: 1rem;
            will-change: background-color;
            transition: background-color 0.2s ease-out;
          }
          mx-link:hover::part(anchor) {
            color: black;
            background-color: pink;
          }
          mx-link::part(anchor-current-page)  {
            color: black;
            background-color: lavender;
          }
          mx-link::part(anchor-current-location)  {
            color: black;
            background-color: hotpink;
          }
        `
    }
    
    render() {
        return (
            <div>
                <nav>
                    <mx-link href="/" root="">Home</mx-link>
                    <mx-link href="/details">Details</mx-link>
                </nav>
                <page-index>
                    <page-details></page-details>
                </page-index>
            </div>
        );
    }
}

navigate

It is possible to navigate in code using the same underlying function as is used in mx-link, navigate.

import {CustomElement, MXElement, navigate} from "@interstice/mx";

@MXElement({tag: "my-app"})
export class MyApp extends CustomElement {
    selectedId: number = 1
    
    loadSelectedDetails = (e: any) => {
        e.preventDefault()
        navigate(e.target.href)
    }

    render() {
        return (
            <div>
                <nav>
                    <mx-link href="/" root="">Home</mx-link>
                    <mx-link href="/details">Details</mx-link>
                    <a href={`/details/${this.selectedId}`} onClick={this.loadSelectedDetails}>Selected Details</a>
                </nav>
                <page-index>
                    <page-details></page-details>
                </page-index>
            </div>
        );
    }
}
0.0.40

2 years ago

0.0.41

2 years ago

0.0.42

2 years ago

0.0.43

2 years ago

0.0.44

2 years ago

0.0.45

2 years ago

0.0.46

2 years ago

0.0.47

2 years ago

0.0.37

2 years ago

0.0.38

2 years ago

0.0.39

2 years ago

0.0.35

2 years ago

0.0.36

2 years ago

0.0.34

2 years ago

0.0.33

2 years ago

0.0.32

2 years ago

0.0.31

2 years ago

0.0.30

2 years ago

0.0.29

2 years ago

0.0.28

2 years ago

0.0.27

2 years ago

0.0.26

2 years ago

0.0.25

2 years ago

0.0.24

2 years ago

0.0.23

2 years ago

0.0.22

2 years ago

0.0.21

2 years ago

0.0.20

2 years ago

0.0.19

2 years ago

0.0.18

2 years ago

0.0.17

2 years ago

0.0.16

2 years ago

0.0.15

2 years ago

0.0.14

2 years ago

0.0.13

2 years ago

0.0.12

2 years ago

0.0.11

2 years ago

0.0.10

2 years ago

0.0.9

2 years ago

0.0.8

2 years ago

0.0.7

2 years ago

0.0.6

2 years ago

0.0.5

2 years ago

0.0.4

2 years ago

0.0.3

2 years ago

0.0.2

2 years ago

0.0.1

2 years ago