ardi
v0.3.20
Published
Welcome to the Weightless Web. Ardi is a tiny (but fierce) web component framework, weighing just 4kb compressed.
Downloads
217
Maintainers
Readme
Ardi
Welcome to the Weightless Web
Ardi makes it almost too easy to create reactive custom elements that work with any site or framework.
Check out the demos!
Features
- Object-oriented API
- Single-file components
- Reactive props and state
- Easy-to-use Context API
- Templates in µhtml, JSX, or Handlebars
- Helpful lifecycle callbacks
- No building, compiling, or tooling
Installation
You can use Ardi from NPM or a CDN.
NPM
npm i ardi
import ardi, { html } from 'ardi'
ardi({ tag: 'my-component' })
CDN
<script type="module">
import ardi, { html } from '//unpkg.com/ardi'
ardi({ tag: 'my-component' })
</script>
API
Ardi uses a straightforward object-oriented API. To demonstrate the API, we'll be looking at simplified code from the podcast demo.
Tag
Define the component's tag. The tag must follow the custom element naming convention. We'll call this component 'podcast-embed'.
ardi({
tag: 'podcast-embed',
})
Extends
If you are building a component that extends a default element, you can define the prototype and tag here. Note that Safari still does not support extending built-in elements 😭.
ardi({
extends: [HTMLAnchorElement, 'a'],
})
Shadow
Ardi renders to the Shadow DOM by default. You can disable this behavior if you need to.
ardi({
shadow: false,
})
Props
Props allow you to configure a component using the element's attributes. To create a property, add a key under props
whose value is an array containing a handler function and (optionally) a default value. The handler takes the string value from the prop's attribute and transforms it, i.e. from a string '4'
to a number 4
. The handler can be a built-in function (like String, Number, or JSON.parse) or an arrow function. Every prop is reactive, which means that whether a prop's value is set internally or via its attribute, the change will trigger a render. Prop values are accessible directly from this
, i.e. this.pagesize
.
Here are the props configured in the podcast demo.
ardi({
props: {
feed: [String, 'https://feeds.simplecast.com/54nAGcIl'],
pagesize: [Number, 10],
pagelabel: [String, 'Page'],
prevpagelabel: [String, 'Prevous Page'],
nextpagelabel: [String, 'Next Page'],
pauselabel: [String, 'pause'],
playlabel: [String, 'play'],
},
})
State
State is a reactive container for data, which means any change will trigger a render. Values declared in state are accessible from this
, i.e. this.episodes
.
Here is how state is defined in the podcast demo.
ardi({
state: () => ({
feedJSON: {},
nowPlaying: null,
page: 0,
paused: true,
}),
})
Template
μhtml is the default template library, and it's just like JSX except you create your templates using tagged template literals. μhtml is extremely efficient. When the component's state changes, instead of re-rendering an entire element, μhtml makes tiny, surgical DOM updates as-needed.
Event Handlers
Event handlers can be applied to an element using React's on
syntax (onClick
) or Vue's @
syntax (@click
). Here is a snippet showing the play/pause button for an episode in the podcast demo.
ardi({
template() {
return html`
...
<button
part="play-button"
@click=${() => this.playPause(track)}
aria-label=${this.nowPlaying === track && !this.paused
? this.pauselabel
: this.playlabel}
>
${this.icon(
this.nowPlaying === track && !this.paused ? 'pause' : 'play'
)}
</button>
...
`
},
})
Lists
Lists are handled using the Array.map()
method. In the podcast demo, we will use a map to list the episodes that are returned by the xml feed. Lists generally do not require a key, but in cases where the order of elements changes you can add a key using html.for(key)
.
ardi({
template() {
return html`
...
<div part="episodes">
${this.episodes.map((episode) => {
return html`<div part="episode">...</div>`
})}
</div>
...
`
},
})
Conditional Rendering
Ternary operators are the recommended way to handle conditional rendering. The snippet below shows how elements can be conditionally rendered based on the available data.
ardi({
template() {
return html`
...
<audio ref="player" src=${this.nowPlaying} />
<div part="header">
${image ? html`<img part="image" src=${image} />` : ''}
<div part="header-wrapper">
${title ? html`<p part="title">${title}</p>` : ''}
${author ? html`<p part="author">${author}</p>` : ''}
${link ? html`<a part="link" href=${link}>${link}</a>` : ''}
</div>
</div>
${description ? html`<p part="description">${description}</p>` : ''}
...
`
},
})
If you prefer a more HTML-like syntax, Ardi provides a <if-else>
element that you can use instead. To use it, just assign the if
prop with a condition and nest your element inside. If you want to provide a fallback element, you can assign it to the else
slot and it will be displayed if the condition is falsey. You can see this in action in the Employee Card component.
ardi({
template() {
return html`
<if-else if=${image}>
<img part="image" src=${image} />
<svg slot="else" viewBox="0 0 24 24">
<path d="..." />
</svg>
</if-else>
`
},
})
Slots
Ardi components use the Shadow DOM by default, which means you can use <slot> tags to project nested elements into your templates. You can use a single default slot or multiple named slots.
The podcast demo has two named slots allowing the pagination button icons to be customized.
ardi({
template() {
return html`
...
<button
part="pagination-prev"
@click=${() => this.page--}
disabled=${this.page > 0 ? null : true}
aria-label=${this.prevpagelabel}
>
<slot name="prev-icon"> ${this.icon('leftArrow')} </slot>
</button>
...
`
},
})
Refs
Ardi allows you to add ref attributes to elements in your template, which are accessible from this.refs
.
In the podcast component, the player
ref is used by the togglePlayback
method to control playback.
ardi({
template() {
return html`<audio ref="player" src=${this.nowPlaying} />...`
},
togglePlayback() {
// ...
this.refs.player.play()
// ...
},
})
Methods
You can add any number of methods in your component and access them via this
. Custom methods can be used in your template, in lifecycle callbacks, or inside of other methods. For examples, you can view the complete code for the podcast demo. There are many more examples in components listed on the demos page.
Context
Ardi has a powerful and easy to use context api, allowing one component to share and synchronize its props or state with multiple child components. You can see this API in action in the i18n demo, this CodePen example, and in the CSS section below.
To share context from a parent component, add the context
attribute with a descriptive name, i.e. context="theme"
You can then use this.context("theme")
to reference the element and access its props or state. When a child component uses the context to make changes to the parent element's props or state, the parent element will notify every other child component that accesses the same values, keeping the context synchronized throughout the application.
<ardi-component context="theme"></ardi-component>
Styles
Ardi components use the Shadow DOM by default. Elements in the Shadow DOM can access CSS variables declared on the page. Elements can also be styled using part attributes.
Inline CSS
You can use Javascript in an inline style attribute.
ardi({
template() {
const { bg, color } = this.context('theme')
return html`<nav style=${`background: ${bg}; color: ${color};`}>...</nav>`
},
})
Styles Key
If you have a lot of CSS, it's cleaner to create a styles
key. Ardi provides a css
helper function to facilitate working with VSCode and other IDEs that support tagged template literals.
import ardi, { css, html } from '//unpkg.com/ardi'
ardi({
template() {
const { bg, color } = this.context('theme')
return html`<nav style=${`--bg: ${bg}; --color: ${color};`}>...</nav>`
},
styles: css`
nav {
background: var(--bg);
color: var(--color);
}
`,
})
Styles Function
If you prefer, you can also use Javascript variables and functions directly in your CSS by creating the styles
key as a function.
ardi({
template() {
return html`<nav>...</nav>`
},
styles() {
const { bg, color } = this.context('theme')
return `
nav {
background: ${bg};
color: ${color};
}
`
},
})
CSS Pre-Processors
Ardi is a runtime framework, designed to work with any app, site, or platform. Since Sass and Less are build-time languages, no official support is provided. If you want to write styles in sass and use them in your components, you can always compile to native CSS and @import
the file using Ardi's styles
key.
Many Sass features are redundant when using the Shadow DOM. Complex BEM selectors requiring &
nesting are unnecessary because styles are scoped to the component, and CSS has native support for variables. Nesting is even coming soon. That being said, if cannot live without using SASS (or another pre-processor) for prototyping, here is a demo showing how you can.
Lifecycle
Ardi has several lifecycle callbacks, providing a convenient way to fetch data or apply effects.
created()
This callback runs as soon as the component is initialized. This is a good place to load data, setup observers, etc.
A great example of this is in the forecast demo, where a resize observer is created to apply styles based on the component's rendered width (regardless of the viewport width).
ardi({
tag: 'ardi-forecast',
created() {
new ResizeObserver(() =>
requestAnimationFrame(
() => (this.small = this.clientWidth <= this.breakpoint)
)
).observe(this)
},
})
ready()
This callback runs as soon as the component's template is rendered, allowing you to call methods that access refs defined in the template.
rendered()
This method runs each time the component renders an update. This was added to support event listeners when writing templates with Handlebars or untagged template literals, but you can use this method for any purpose.
changed()
Although props are reactive, meaning the template is automatically updated when a prop's value changes, you may encounter scenarios where you need to handle a property's value manually, i.e. to fetch data or apply an effect. You can use this callback to observe and respond to prop updates.
Here is an example from the forecast demo.
ardi({
tag: 'ardi-forecast',
changed(prop) {
if (
prop.old &&
prop.new &&
['lat', 'lon', 'locale', 'unit'].includes(prop.name)
) {
this.fetchForecast()
}
},
})
intersected()
This method is called when the component is scrolled into view. You can use the ratio parameter to determine how much of the component should be visible before you apply an effect. Ardi will only create the intersection observer if you include this method, so omit it if you do not intend to use it.
In the forecast demo, the intersect method is used to lazy-load data once the component is scrolled into view. This trick can save a lot of money if you use paid APIs!
ardi({
tag: 'ardi-forecast',
intersected(r) {
if (!this.gotWeather && r > 0.2) {
this.fetchForecast()
}
},
})
Template Options
μhtml is tiny, fast and efficient, and we strongly recommend it. However, JSX is king right now, and Handlebars is still holding on strong. That's why Ardi allows you to use whatever template library you prefer. Sample code for each supported option is provided below, for comparison. There is also an interactive CodePen demo showing all three examples.
μhtml
import ardi, { html } from '//unpkg.com/ardi'
ardi({
tag: 'uhtml-counter',
state: () => ({ count: 0 }),
template() {
return html`
<button @click=${() => this.count++}>
Count: ${this.count}
</button>`
},
})
JSX-Dom
import ardi, { html } from '//unpkg.com/ardi'
import React from '//cdn.skypack.dev/jsx-dom'
ardi({
tag: 'jsx-counter',
state: () => ({ count: 0 }),
template() {
return (
<button onClick={() => this.count++}>
Count: {this.count}
</button>
)
},
})
Handlebars
With Handlebars (or any template that returns a simple string: i.e. an untagged template literal), event listeners can be added to the rendered
method. If present, the rendered
method will run after each render.
import ardi, { html } from '//unpkg.com/ardi'
import handlebars from 'https://cdn.skypack.dev/[email protected]'
ardi({
tag: 'hbs-counter',
state: () => ({ count: 0 }),
template() {
return handlebars.compile(
`<button ref='counter'>Count: {{count}}</button>`
)(this)
},
rendered() {
this.refs.counter.addEventListener('click', () => this.count++)
},
})