|
1 | 1 | # UIElement
|
2 | 2 |
|
3 |
| -Version 0.11.0 |
| 3 | +Version 0.12.0 |
4 | 4 |
|
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. |
6 | 6 |
|
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. |
8 | 10 |
|
9 | 11 | ## Key Features
|
10 | 12 |
|
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. |
16 | 22 |
|
17 | 23 | ## Installation
|
18 | 24 |
|
@@ -53,30 +59,18 @@ Server-rendered markup:
|
53 | 59 | UIElement component:
|
54 | 60 |
|
55 | 61 | ```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' |
60 | 63 |
|
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 => [ |
64 | 67 |
|
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++ })), |
69 | 70 |
|
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 | +]) |
80 | 74 | ```
|
81 | 75 |
|
82 | 76 | Example styles:
|
@@ -143,63 +137,41 @@ An example demonstrating how to pass states from one component to another. Serve
|
143 | 137 | UIElement components:
|
144 | 138 |
|
145 | 139 | ```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 | +}) |
203 | 175 | ```
|
204 | 176 |
|
205 | 177 | Example styles:
|
@@ -254,58 +226,51 @@ A more complex component demonstrating async fetch from the server:
|
254 | 226 | ```
|
255 | 227 |
|
256 | 228 | ```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 '' |
309 | 247 | }
|
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 | +]) |
311 | 276 | ```
|
0 commit comments