Skip to content

loreanvictor/minicomp

Repository files navigation

npm package minimized gzipped size (select exports) npm type definitions version GitHub Workflow Status

Define Web Components using functions and hooks:

import { define, onConnected } from 'minicomp'

define('say-hi', ({ to }) => {
  onConnected(() => console.log('CONNECTED!'))

  return `<div>Hellow <span>${to}</span></div>`
})
<say-hi to="World"></say-hi>


Contents


Installation

On node:

npm i minicomp

In the browser:

import { define } from 'https://esm.sh/minicomp'

Usage

👉 Define a custom element:

import { define } from 'minicomp'

define('my-el', () => '<div>Hellow World!</div>')
<my-el></my-el>

A component function can return a Node or a string representation of some DOM.



👉 Attributes are passed as a parameter:

define('say-hi', ({ to }) => `<div>Hellow ${to}</div>`)



👉 Use hooks to tap into custom elements' life cycle callbacks:

import { define, onConnected, onDisconnected } from 'minicomp'

define('my-el', () => {
  onConnected(() => console.log('CONNECTED!'))
  onDisconnected(() => console.log('DISCONNECTED'))
  
  return '<div>Hellow World!</div>'
})

Or to respond to changes:

import { define, onAttribute, currentNode } from 'minicomp'

define('say-hi', () => {
  const host = currentNode().shadowRoot
  onAttribute('to', name => {
    host.querySelector('span').textContent = name
  })

  return '<div>Hellow <span></span></div>'
})



👉 Use using() to define a component that extends another built-in element:

import { using } from 'minicomp'

using({
  baseClass: HTMLParagraphElement,
  extends: 'p',
}).define('my-el', () => {/*...*/})
<p is="my-el"></p>



👉 Use .setProperty() method of defined elements to set their properties:

define('my-el', () => {/*...*/})

const el = document.createElement('my-el')
el.setProperty('myProp', { whatever: 'you want' })

⚠️ Don't set properties manually, as then the proper hooks won't be invoked.

In TypeScript, you can cast to PropableElement for proper type checking:

import { PropableElement } from 'minicomp'

const el = document.createElement('my-el') as PropableElement
el.setProperty('myProp', { whatever: 'you want' })

Common Hooks

The following hooks are commonly used by components:


onAttribute

onAttribute(
  name: string,
  hook: (value: string | typeof ATTRIBUTE_REMOVED | undefined) => void
)

Is called with the initial value of specified attribute (undefined if not passed initially) and whenever the value of specified attribute changes (via .setAttribute()). Will be called with ATTRIBUTE_REMOVED symbol when specified attribute is removed (via .removeAttribute()).

import { define, onAttribute } from 'minicomp'
import { ref, html } from 'rehtm'

define('say-hi', () => {
  const span = ref()
  onAttribute('to', name => span.current.textContent = name)
  
  return html`<div>Hellow <span ref=${span}></span></div>`
})

onProperty

onProperty(name: string, hook: (value: unknown) => void)
onProperty<T>(name: string, hook: (value: T) => void)

Is called with the initial value of specified property (undefined if not set initially) and whenever the value of specified property changes (via .setProperty()).


on

on(name: string, hook: (event: Event) => void)

Adds an event listener to the custom element (via .addEventListener()). For example, on('click', () => ...) will add a click listener to the element.


useDispatch

useDispatch<T>(name: string, options: EventInit = {}): (data: T) => void

Returns a dispatch function that will dispatch events of given name (and with given options) from the element. Dispatched events can be caught via .addEventListener(), or by by using attributes like on${name} (e.g. onmyevent):

import { define, useDispatch } from 'minicomp'
import { html } from 'rehtm'


define('my-el', () => {
  const dispatch = useDispatch('even')
  let count = 0
  
  return html`
    <button onclick=${() => ++count % 2 === 0 && dispatch(count)}>
      Click Me!
    </button>
  `
})
<my-el oneven="window.alert(event.detail)"></my-el>

currentNode

currentNode(): HTMLElement | undefined

Returns the current element being rendered, undefined if used out of a component function. Useful for custom hooks who need to conduct an operation during rendering (for hooks that operate after rendering, use .onRendered()).


attachControls

attachControls<ControlsType>(controls: ControlsType): void

Adds given controls to the current element, which are accessible via its .controls property. Useful for when your component needs to expose some functionality to its users.

import { define, attachControls } from 'minicomp'


define('my-video-player', () => {
  const video = document.createElement('video')
  const controls = {
    play: () => video.play(),
    pause: () => video.pause(),
    seek: (time) => video.currentTime = time,
  }
  
  attachControls(controls)
  
  return video
})
<my-video-player></my-video-player>
const player = document.querySelector('my-video-player')
player.controls.seek(10)

For typing controls, you can use the Controllable interface:

import { Controllable } from 'minicomp'


const player = document.querySelector('my-video-player') as Controllable<VideoControls>
// ...

Lifecycle Hooks

Use the following hooks to tap into life cycle events of custom elements:


onCleanup

onCleanup(hook: () => void)

Is called after the element is removed from the document and not added back immediately.


onConnected

onConnected(hook: (node: HTMLElement) => void)

Is called when the element is connected to the DOM. Might get called multiple times (e.g. when the elemnt is moved).


onDisconnected

onDisconnected(hook: (node: HTMLElement) => void)

Is called when the element is disconnected from the DOM. Might get called multiple times (e.g. when the element is moved).


onAttributeChanged

onAttributeChanged(
  hook: (
    name: string,
    value: string | typeof ATTRIBUTE_REMOVED,
    node: HTMLElement
  ) => void
)

Is called when .setAttribute() is called on the element, changing value of an attribute. Will pass ATTRIBUTE_REMOVED symbol when the attribute is removed (via .removeAttribute()).


onPropertyChanged

onPropertyChanged(hook: (name: string, value: any, node: HTMLElement) => void)

Is called when .setProperty() method of the element is called.


onRendered

onRendered(hook: (node: HTMLElement) => void)

Is called after the returned DOM is attached to the element's shadow root.


Hooks for SSR

The following hooks are useful for server side rendering:


ownerDocument

ownerDocument(): Document

Returns the document that the element is in. Useful for components (and hooks) that want to be operable in environments where there is no global document object.


onHydrated

onHydrated(hook: (node: HTMLElement) => void)

Is called when the element is hydrated on the client.


onFirstRender

onFirstRender(hook: (node: HTMLElement) => void)

Is called when the element is rendered for the first time (either on the server or on the client).


Rules for Hooks

Hooks MUST be called synchronously within the component function, before it returns its corresponding DOM. Besides that, there are no additional hooks rules, so use them freely (within a for loop, conditionally, etc.).

If you use hooks outside of a component function, they will simply have no effect.


Custom Hooks

// define a custom hook for creating a timer that is stopped
// whenever the element is not connected to the DOM.

import { onConnected, onDisconnected } from 'minicomp'

export const useInterval = (ms, callback) => {
  let interval
  let counter = 0

  onConnected(() => interval = setInterval(() => callback(++counter), ms))
  onDisconnected(() => clearInterval(interval))
}
// now use the custom hook:

import { template, use } from 'htmplate'
import { define } from 'minicomp'

const tmpl$ = template`<div>Elapsed: <span>0</span> seconds</div>`

define('my-timer', () => {
  const host$ = use(tmpl$)
  const span$ = host$.querySelector('span')
  
  useInterval(1000, c => span$.textContent = c)
  
  return host$
})
<my-timer></my-timer>

Server Side Rendering

minicomp provides support for SSR and isomorphic components (hydrating pre-rendered content in general) via declarative shadow DOM. On browsers supporting the feature, server rendered content will be rehydrated. On browsers that don't, it will fallback to client side rendering. You would also need a serializer supporting declarative shadow DOM, such as Puppeteer or Happy DOM.

To enable SSR support on your component, return an SSRTemplate object instead of a string or a DOM element. Use libraries such as rehtm to easily create SSR templates:

import { define } from 'minicomp'
import { ref, template } from 'rehtm'

define('a-button', () => {
  const span = ref()
  let count = 0
  
  return template`
    <button onclick=${() => span.current.textContent = ++count}>
      Client <span ref=${span} role="status">0</span>
    </button>
  `
})

You can also manually create SSR templates:

define('my-comp', () => {
  const clicked = () => console.log('CLICKED!')
   
  return {
   
    // first time render,
    // lets make the DOM and hydrate it.
    //
    create: () => {
      const btn = document.createElement('button')
      btn.addEventListener('click', clicked)
       
      return btn
    },
     
    // pre-rendered content, lets just
    // rehydrate it:
    //
    hydrateRoot: root => {
      root.firstChild.addEventListener('click', clicked)
    }
  }
})

Global Window Object

In some environments (for example, during server-side rendering), a global window object might not be present. Use window option of using() helper to create a component for a specific window instance:

import { using, define } from 'minicomp'

using({ window: myWindow }).define('my-comp', () => {
  // ...
})

Or

import { using, component } from 'minicomp'

const myComp = using({ window: myWindow }).component(() => {
  // ...
})

myWindow.customElements.define('my-comp', myComp)

If you need to use the document object in these components, use ownerDocument() helper:

import { using, define, ownerDocument } from 'minicomp'

using({ window: myWindow }).define('my-comp', () => {
  const doc = ownerDocument()
  const btn = doc.createElement('button')

  // ...
})

It might be useful to describe components independent of the window object, and then define them on different window instances. Use definable to separate component description from the window object:

import { definable, ownerDocument } from 'minicomp'
import { re } from 'rehtm'

export default definable('say-hi', ({ to }) => {
  const { html } = re(ownerDocument())
  return html`<div>Hellow ${to}!</div>`
})
import { using } from 'minicomp'
import SayHi from './say-hi'

const window = new Window()
using({ window }).define(SayHi)

window.document.body.innerHTML = '<say-hi to="Jack"></say-hi>'

Contribution

You need node, NPM to start and git to start.

# clone the code
git clone git@github.com:loreanvictor/minicomp.git
# install stuff
npm i

Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all the linting rules. The code is typed with TypeScript, Jest is used for testing and coverage reports, ESLint and TypeScript ESLint are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, VSCode supports TypeScript out of the box and has this nice ESLint plugin), but you could also use the following commands:

# run tests
npm test
# check code coverage
npm run coverage
# run linter
npm run lint
# run type checker
npm run typecheck





About

Minimalistic Web Components with SSR Support

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published