Version 0.11.0
UIElement - transform reusable markup, styles and behavior into powerful, reactive, and maintainable Web Components.
UIElement
is a base class for Web Components with reactive states and UI effects. UIElement is tiny, around 4kB gzipped JS code, of which unused functions can be tree-shaken by build tools. It uses Cause & Effect internally for state management with signals and for scheduled DOM updates.
- Reusable Components: Create highly modular and reusable components to encapsulate styles and behavior.
- Declarative States: Bring static, server-rendered content to life with dynamic interactivity and state management.
- Signal-Based Reactivity: Employ signals for efficient state propagation, ensuring your components react instantly to changes.
- Declarative Effects: Use granular effects to automatically synchronize UI states with minimal code.
- Context Support: Share global states across your component tree without tightly coupling logic.
# with npm
npm install @zeix/ui-element
# or with bun
bun add @zeix/ui-element
The full documentation is still work in progress. The following chapters are already reasonably complete:
Server-rendered markup:
<show-appreciation aria-label="Show appreciation">
<button type="button">
<span class="emoji">💐</span>
<span class="count">5</span>
</button>
</show-appreciation>
UIElement component:
import { UIElement, asInteger, setText } from '@zeix/ui-element'
class ShowAppreciation extends UIElement {
#count = Symbol() // Use a private Symbol as state key
connectedCallback() {
// Initialize count state
this.set(this.#count, asInteger(0)(this.querySelector('.count').textContent))
// Bind click event to increment count
this.first('button').on('click', () => {
this.set(this.#count, v => ++v)
})
// Update .count text when count changes
this.first('.count').sync(setText(this.#count))
}
// Expose read-only property for count
get count() {
return this.get(this.#count)
}
}
ShowAppreciation.define('show-appreciation')
Example styles:
show-appreciation {
display: inline-block;
& button {
display: flex;
flex-direction: row;
gap: var(--space-s);
border: 1px solid var(--color-border);
border-radius: var(--space-xs);
background-color: var(--color-secondary);
color: var(--color-text);
padding: var(--space-xs) var(--space-s);
cursor: pointer;
font-size: var(--font-size-m);
line-height: var(--line-height-xs);
transition: transform var(--transition-short) var(--easing-inout);
&:hover {
background-color: var(--color-secondary-hover);
}
&:active {
background-color: var(--color-secondary-active);
.emoji {
transform: scale(1.1);
}
}
}
}
An example demonstrating how to pass states from one component to another. Server-rendered markup:
<tab-list>
<menu>
<li><button type="button" aria-pressed="true">Tab 1</button></li>
<li><button type="button">Tab 2</button></li>
<li><button type="button">Tab 3</button></li>
</menu>
<details open>
<summary>Tab 1</summary>
<p>Content of tab panel 1</p>
</details>
<details>
<summary>Tab 2</summary>
<p>Content of tab panel 2</p>
</details>
<details>
<summary>Tab 3</summary>
<p>Content of tab panel 3</p>
</details>
</tab-list>
UIElement components:
import { UIElement, setAttribute, toggleAttribute } from '@zeix/ui-element'
class TabList extends UIElement {
static localName = 'tab-list'
static observedAttributes = ['accordion']
init = {
active: 0,
accordion: asBoolean,
}
connectedCallback() {
super.connectedCallback()
// Set inital active tab by querying details[open]
const getInitialActive = () => {
const panels = Array.from(this.querySelectorAll('details'))
for (let i = 0; i < panels.length; i++) {
if (panels[i].hasAttribute('open')) return i
}
return 0
}
this.set('active', getInitialActive())
// Reflect accordion attribute (may be used for styling)
this.self.sync(toggleAttribute('accordion'))
// Update active tab state and bind click handlers
this.all('menu button')
.on('click', (_, index) => () => {
this.set('active', index)
})
.sync(setProperty(
'ariaPressed',
(_, index) => String(this.get('active') === index)
))
// Update details panels open, hidden and disabled states
this.all('details').sync(
setProperty(
'open',
(_, index) => !!(this.get('active') === index)
),
setAttribute(
'aria-disabled',
() => String(!this.get('accordion'))
)
)
// Update summary visibility
this.all('summary').sync(toggleClass(
'visually-hidden',
() => !this.get('accordion')
))
}
}
TabList.define()
Example styles:
tab-list {
> menu {
list-style: none;
display: flex;
gap: 0.2rem;
padding: 0;
& button[aria-pressed="true"] {
color: purple;
}
}
> details {
&:not([open]) {
display: none;
}
&[aria-disabled] {
pointer-events: none;
}
}
&[accordion] {
> menu {
display: none;
}
> details:not([open]) {
display: block;
}
}
}
A more complex component demonstrating async fetch from the server:
<lazy-load src="/lazy-load/snippet.html">
<div class="loading" role="status">Loading...</div>
<div class="error" role="alert" aria-live="polite"></div>
</lazy-load>
import { UIElement, setProperty, setText, dangerouslySetInnerHTML } from '@zeix/ui-element'
class LazyLoad extends UIElement {
static localName = 'lazy-load'
// Remove the following line if you don't want to listen to changes in 'src' attribute
static observedAttributes = ['src']
init = {
src: v => { // Custom attribute parser
if (!v) {
this.set('error', 'No URL provided in src attribute')
return ''
} else if ((this.parentElement || this.getRootNode().host)?.closest(`${this.localName}[src="${v}"]`)) {
this.set('error', 'Recursive loading detected')
return ''
}
const url = new URL(v, location.href) // Ensure 'src' attribute is a valid URL
if (url.origin === location.origin) // Sanity check for cross-origin URLs
return url.toString()
this.set('error', 'Invalid URL origin')
return ''
},
content: async () => { // Async Computed callback
const url = this.get('src')
if (!url) return ''
try {
const response = await fetch(this.get('src'))
this.querySelector('.loading')?.remove()
if (response.ok) return response.text()
else this.set('error', response.statusText)
} catch (error) {
this.set('error', error.message)
}
return ''
},
error: '',
}
connectedCallback() {
super.connectedCallback()
// Effect to set error message
this.first('.error').sync(
setProperty('hidden', () => !this.get('error')),
setText('error'),
)
// Effect to set content in shadow root
// Remove the second argument (for shadowrootmode) if you prefer light DOM
this.self.sync(dangerouslySetInnerHTML('content', 'open'))
}
}
LazyLoad.define()