minicomp
v0.4.1
Published
Minimalistic library for creating Web Components
Downloads
278
Maintainers
Readme
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>
- 🌱 Tiny wrapper over custom elements
- 🧁 Minimalistic components
- ⛓ Composable hooks
- 🧩 Interoperable: create and update your DOM however you want
- 🧬 SSR support with isomorphic components
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 [email protected]: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