Skip to content

Commit 198253c

Browse files
Merge pull request #49 from zeixcom/next
Feature: switch to property accessors instead of Map interface
2 parents 02d65a1 + 14dbd46 commit 198253c

File tree

176 files changed

+6102
-5316
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

176 files changed

+6102
-5316
lines changed

README.md

Lines changed: 104 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
# UIElement
22

3-
Version 0.11.0
3+
Version 0.12.0
44

5-
**UIElement** - transform reusable markup, styles and behavior into powerful, reactive, and maintainable Web Components.
5+
**UIElement** - the HTML-first microframework bringing reactivity to Web Components.
66

7-
`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](https://github.com/zeixcom/cause-effect) internally for state management with signals and for scheduled DOM updates.
7+
UIElement is a set of functions to build reusable, loosely coupled Web Components with reactive properties. It provides structure through components and simplifies state management and DOM synchronization using declarative signals and effects, leading to more organized and maintainable code without a steep learning curve.
8+
9+
Unlike SPA frameworks (React, Vue, Svelte, etc.) UIElement takes a HTML-first approach, progressively enhancing sever-rendered HTML rather than recreating (rendering) it using JavaScript. UIElement achieves the same result as SPA frameworks with SSR, but with a simpler, more efficient approach. It works with a backend written in any language or with any static site generator.
810

911
## Key Features
1012

11-
* **Reusable Components**: Create highly modular and reusable components to encapsulate styles and behavior.
12-
* **Declarative States**: Bring static, server-rendered content to life with dynamic interactivity and state management.
13-
* **Signal-Based Reactivity**: Employ signals for efficient state propagation, ensuring your components react instantly to changes.
14-
* **Declarative Effects**: Use granular effects to automatically synchronize UI states with minimal code.
15-
* **Context Support**: Share global states across your component tree without tightly coupling logic.
13+
* 🧱 **HTML Web Components**: Build on standard HTML and enhance it with encapsulated, reusable Web Components. No virtual DOM – UIElement works directly with the real DOM.
14+
* 🚦 **Reactive Properties**: Define reactive properties for fine-grained, efficient state management (signals). Changes automatically propagate only to the parts of the DOM that need updating, avoiding unnecessary re-renders.
15+
* 🧩 **Function Composition**: Declare component behavior by composing small, reusable functions (attribute parsers and effects). This promotes cleaner code compared to spaghetti code problems that commonly occur when writing low-level imperative code.
16+
* 🛠️ **Customizable**: UIElement is designed to be easily customizable and extensible. You can create your own custom attribute parsers and effects to suit your specific needs.
17+
* 🌐 **Context Support**: Share global states across components without prop drilling or tightly coupling logic.
18+
* 🪶 **Tiny footprint**: Minimal core (~4kB gzipped) with tree-shaking support, adding only the necessary JavaScript to enhance your HTML.
19+
* 🛡️ **Type Safety**: Get early warnings when types don't match, improving code quality and reducing bugs.
20+
21+
UIElement uses [Cause & Effect](https://github.com/zeixcom/cause-effect) internally for state management with signals and for scheduled DOM updates. But you could easily rewrite the `component()` function to use a signals library of your choice or to produce something else than Web Components.
1622

1723
## Installation
1824

@@ -53,30 +59,18 @@ Server-rendered markup:
5359
UIElement component:
5460

5561
```js
56-
import { UIElement, asInteger, setText } from '@zeix/ui-element'
57-
58-
class ShowAppreciation extends UIElement {
59-
#count = Symbol() // Use a private Symbol as state key
62+
import { component, asInteger, first, setText } from '@zeix/ui-element'
6063

61-
connectedCallback() {
62-
// Initialize count state
63-
this.set(this.#count, asInteger(0)(this.querySelector('.count').textContent))
64+
component('show-appreciation', {
65+
count: asInteger(RESET) // Get initial value from .count element
66+
}, el => [
6467

65-
// Bind click event to increment count
66-
this.first('button').on('click', () => {
67-
this.set(this.#count, v => ++v)
68-
})
68+
// Bind click event to increment count
69+
first('button', on('click', () => { el.count++ })),
6970

70-
// Update .count text when count changes
71-
this.first('.count').sync(setText(this.#count))
72-
}
73-
74-
// Expose read-only property for count
75-
get count() {
76-
return this.get(this.#count)
77-
}
78-
}
79-
ShowAppreciation.define('show-appreciation')
71+
// Update.count text when count changes
72+
first('.count', setText('count'))
73+
])
8074
```
8175

8276
Example styles:
@@ -143,63 +137,41 @@ An example demonstrating how to pass states from one component to another. Serve
143137
UIElement components:
144138

145139
```js
146-
import { UIElement, setAttribute, toggleAttribute } from '@zeix/ui-element'
147-
148-
class TabList extends UIElement {
149-
static localName = 'tab-list'
150-
static observedAttributes = ['accordion']
151-
152-
init = {
153-
active: 0,
154-
accordion: asBoolean,
155-
}
156-
157-
connectedCallback() {
158-
super.connectedCallback()
159-
160-
// Set inital active tab by querying details[open]
161-
const getInitialActive = () => {
162-
const panels = Array.from(this.querySelectorAll('details'))
163-
for (let i = 0; i < panels.length; i++) {
164-
if (panels[i].hasAttribute('open')) return i
165-
}
166-
return 0
167-
}
168-
this.set('active', getInitialActive())
169-
170-
// Reflect accordion attribute (may be used for styling)
171-
this.self.sync(toggleAttribute('accordion'))
172-
173-
// Update active tab state and bind click handlers
174-
this.all('menu button')
175-
.on('click', (_, index) => () => {
176-
this.set('active', index)
177-
})
178-
.sync(setProperty(
179-
'ariaPressed',
180-
(_, index) => String(this.get('active') === index)
181-
))
182-
183-
// Update details panels open, hidden and disabled states
184-
this.all('details').sync(
185-
setProperty(
186-
'open',
187-
(_, index) => !!(this.get('active') === index)
188-
),
189-
setAttribute(
190-
'aria-disabled',
191-
() => String(!this.get('accordion'))
192-
)
193-
)
194-
195-
// Update summary visibility
196-
this.all('summary').sync(toggleClass(
197-
'visually-hidden',
198-
() => !this.get('accordion')
199-
))
200-
}
201-
}
202-
TabList.define()
140+
import { asBoolean, component, all, on, setAttribute, setProperty, toggleAttribute, toggleClass } from '@zeix/ui-element'
141+
142+
component('tab-list', {
143+
active: 0,
144+
accordion: asBoolean,
145+
}, el => {
146+
// Set inital active tab by querying details[open]
147+
const panels = Array.from(el.querySelectorAll('details'))
148+
el.active = panels.findIndex(panel => panel.hasAttribute('open'))
149+
150+
return [
151+
// Reflect accordion attribute (may be used for styling)
152+
toggleAttribute('accordion'),
153+
154+
// Update active tab state and bind click handlers
155+
all('menu button',
156+
on('click', (_, index) => () => {
157+
el.active = index
158+
}),
159+
setProperty('ariaPressed', (_, index) => String(el.active === index))
160+
),
161+
162+
// Update details panels open, hidden and disabled states
163+
all('details',
164+
on('toggle', (_, index) => () => {
165+
el.active = el.active === index ? -1 : index
166+
}),
167+
setProperty('open', (_, index) => el.active === index),
168+
setAttribute('aria-disabled', () => String(!el.accordion))
169+
),
170+
171+
// Update summary visibility
172+
all('summary', toggleClass('visually-hidden', () => !el.accordion))
173+
]
174+
})
203175
```
204176

205177
Example styles:
@@ -254,58 +226,51 @@ A more complex component demonstrating async fetch from the server:
254226
```
255227

256228
```js
257-
import { UIElement, setProperty, setText, dangerouslySetInnerHTML } from '@zeix/ui-element'
258-
259-
class LazyLoad extends UIElement {
260-
static localName = 'lazy-load'
261-
262-
// Remove the following line if you don't want to listen to changes in 'src' attribute
263-
static observedAttributes = ['src']
264-
265-
init = {
266-
src: v => { // Custom attribute parser
267-
if (!v) {
268-
this.set('error', 'No URL provided in src attribute')
269-
return ''
270-
} else if ((this.parentElement || this.getRootNode().host)?.closest(`${this.localName}[src="${v}"]`)) {
271-
this.set('error', 'Recursive loading detected')
272-
return ''
273-
}
274-
const url = new URL(v, location.href) // Ensure 'src' attribute is a valid URL
275-
if (url.origin === location.origin) // Sanity check for cross-origin URLs
276-
return url.toString()
277-
this.set('error', 'Invalid URL origin')
278-
return ''
279-
},
280-
content: async () => { // Async Computed callback
281-
const url = this.get('src')
282-
if (!url) return ''
283-
try {
284-
const response = await fetch(this.get('src'))
285-
this.querySelector('.loading')?.remove()
286-
if (response.ok) return response.text()
287-
else this.set('error', response.statusText)
288-
} catch (error) {
289-
this.set('error', error.message)
290-
}
291-
return ''
292-
},
293-
error: '',
294-
}
295-
296-
connectedCallback() {
297-
super.connectedCallback()
298-
299-
// Effect to set error message
300-
this.first('.error').sync(
301-
setProperty('hidden', () => !this.get('error')),
302-
setText('error'),
303-
)
304-
305-
// Effect to set content in shadow root
306-
// Remove the second argument (for shadowrootmode) if you prefer light DOM
307-
this.self.sync(dangerouslySetInnerHTML('content', 'open'))
308-
}
229+
import { component, first, dangerouslySetInnerHTML, setProperty, setText } from '@zeix/ui-element'
230+
231+
// Custom attribute parser
232+
const asURL = (el, v) => {
233+
if (!v) {
234+
el.error = 'No URL provided in src attribute'
235+
return ''
236+
} else if ((el.parentElement || (el.getRootNode() as ShadowRoot).host)?.closest(`${el.localName}[src="${v}"]`)) {
237+
el.error = 'Recursive loading detected'
238+
return ''
239+
}
240+
const url = new URL(v, location.href) // Ensure 'src' attribute is a valid URL
241+
if (url.origin === location.origin) { // Sanity check for cross-origin URLs
242+
el.error = '' // Success: wipe previous error if there was any
243+
return String(url)
244+
}
245+
el.error = 'Invalid URL origin'
246+
return ''
309247
}
310-
LazyLoad.define()
248+
249+
// Custom signal producer, needs src and error properties on element
250+
const fetchText = el =>
251+
async abort => { // Async Computed callback
252+
const url = el.src
253+
if (!url) return ''
254+
try {
255+
const response = await fetch(url, { signal: abort })
256+
el.querySelector('.loading')?.remove()
257+
if (response.ok) return response.text()
258+
else el.error = response.statusText
259+
} catch (error) {
260+
el.error = error.message
261+
}
262+
return ''
263+
}
264+
265+
component('lazy-load', {
266+
error: '',
267+
src: asURL,
268+
content: fetchText
269+
}, el => [
270+
dangerouslySetInnerHTML('content', 'open'),
271+
first('.error',
272+
setText('error'),
273+
setProperty('hidden', () => !el.error)
274+
)
275+
])
311276
```

bun.lockb

1018 Bytes
Binary file not shown.

docs-src/components/accordion-panel/accordion-panel.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
<accordion-panel>
2-
<details open aria-disabled="true">
3-
<summary class="visually-hidden">
2+
<details open>
3+
<summary>
44
<div class="summary">Tab 1</div>
55
</summary>
66
Content for Tab 1
77
</details>
88
</accordion-panel>
99
<accordion-panel>
10-
<details aria-disabled="true">
11-
<summary class="visually-hidden">
10+
<details>
11+
<summary>
1212
<div class="summary">Tab 2</div>
1313
</summary>
1414
Content for Tab 2
1515
</details>
1616
</accordion-panel>
1717
<accordion-panel>
18-
<details aria-disabled="true">
19-
<summary class="visually-hidden">
18+
<details>
19+
<summary>
2020
<div class="summary">Tab 3</div>
2121
</summary>
2222
Content for Tab 3
Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
1-
import { asBoolean, setProperty, toggleAttribute, UIElement } from "../../../"
1+
import { type Component, asBoolean, component, first, setProperty, toggleAttribute } from '../../../'
22

3-
export class AccordionPanel extends UIElement<{
4-
open: boolean,
5-
collapsible: boolean,
6-
}> {
7-
static readonly localName = 'accordion-panel'
8-
static observedAttributes = ['open', 'collapsible']
9-
10-
init = {
11-
open: asBoolean,
12-
collapsible: asBoolean
13-
}
14-
15-
connectedCallback() {
16-
super.connectedCallback()
3+
export type AccordionPanelProps = {
4+
open: boolean
5+
collapsible: boolean
6+
}
177

18-
// Handle open and collapsible state changes
19-
this.self.sync(
20-
toggleAttribute('open'),
21-
toggleAttribute('collapsible'),
22-
setProperty('hidden', () => !this.get('open') && !this.get('collapsible'))
23-
)
8+
export default component('accordion-panel', {
9+
open: asBoolean,
10+
collapsible: asBoolean
11+
}, el => [
12+
toggleAttribute('open'),
13+
toggleAttribute('collapsible'),
14+
setProperty('hidden', () => !el.open && !el.collapsible),
15+
first<AccordionPanelProps, HTMLDetailsElement>('details',
16+
setProperty('open'),
17+
setProperty('ariaDisabled', () => String(!el.collapsible))
18+
)
19+
])
2420

25-
// Control inner details panel
26-
this.first<HTMLDetailsElement>('details').sync(
27-
setProperty('open'),
28-
setProperty('ariaDisabled', () => String(!this.get('collapsible')))
29-
)
21+
declare global {
22+
interface HTMLElementTagNameMap {
23+
'accordion-panel': Component<AccordionPanelProps>
3024
}
3125
}
32-
AccordionPanel.define()

0 commit comments

Comments
 (0)