1
1
# UIElement
2
2
3
- Version 0.10.1
3
+ Version 0.11.0
4
4
5
5
** UIElement** - transform reusable markup, styles and behavior into powerful, reactive, and maintainable Web Components.
6
6
7
- ` UIElement ` is a base class for Web Components with reactive states and UI effects. UIElement is tiny, around 3kB 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 [ Pulse ] ( https://github.com/zeixcom/pulse ) for scheduled DOM updates.
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.
8
8
9
9
## Key Features
10
10
@@ -24,8 +24,6 @@ npm install @zeix/ui-element
24
24
bun add @zeix/ui-element
25
25
```
26
26
27
- For the functional core of your application we recommend [ FlowSure] ( https://github.com/zeixcom/flow-sure ) to create a robust and expressive data flow, supporting error handling and async processing with ` Result ` monads.
28
-
29
27
## Documentation
30
28
31
29
The full documentation is still work in progress. The following chapters are already reasonably complete:
@@ -62,10 +60,12 @@ class ShowAppreciation extends UIElement {
62
60
63
61
connectedCallback () {
64
62
// Initialize count state
65
- this .set (this .#count, asInteger (this .querySelector (' .count' ).textContent ) ?? 0 )
63
+ this .set (this .#count, asInteger (0 )( this .querySelector (' .count' ).textContent ))
66
64
67
65
// Bind click event to increment count
68
- this .first (' button' ).on (' click' , () => this .set (this .#count, v => ++ v))
66
+ this .first (' button' ).on (' click' , () => {
67
+ this .set (this .#count, v => ++ v)
68
+ })
69
69
70
70
// Update .count text when count changes
71
71
this .first (' .count' ).sync (setText (this .#count))
@@ -121,22 +121,22 @@ An example demonstrating how to pass states from one component to another. Serve
121
121
``` html
122
122
<tab-list >
123
123
<menu >
124
- < li>< button type= " button" > Tab 1 < / button>< / li>
124
+ <li ><button type =" button" aria-pressed = " true " >Tab 1</button ></li >
125
125
<li ><button type =" button" >Tab 2</button ></li >
126
126
<li ><button type =" button" >Tab 3</button ></li >
127
127
</menu >
128
- < tab - panel open>
129
- < h2 > Tab 1 < / h2 >
128
+ <details open >
129
+ <summary >Tab 1</summary >
130
130
<p >Content of tab panel 1</p >
131
- < / tab - panel >
132
- < tab - panel >
133
- < h2 > Tab 2 < / h2 >
131
+ </details >
132
+ <details >
133
+ <summary >Tab 2</summary >
134
134
<p >Content of tab panel 2</p >
135
- < / tab - panel >
136
- < tab - panel >
137
- < h2 > Tab 3 < / h2 >
135
+ </details >
136
+ <details >
137
+ <summary >Tab 3</summary >
138
138
<p >Content of tab panel 3</p >
139
- < / tab - panel >
139
+ </details >
140
140
</tab-list >
141
141
```
142
142
@@ -146,60 +146,98 @@ UIElement components:
146
146
import { UIElement , setAttribute , toggleAttribute } from ' @zeix/ui-element'
147
147
148
148
class TabList extends UIElement {
149
- connectedCallback () {
150
-
151
- // Set inital active tab by querying tab-panel[open]
152
- let openPanelIndex = 0 ;
153
- this .querySelectorAll (' tab-panel' ).forEach ((el , index ) => {
154
- if (el .hasAttribute (' open' )) openPanelIndex = index
155
- })
156
- this .set (' active' , openPanelIndex)
149
+ static localName = ' tab-list'
150
+ static observedAttributes = [' accordion' ]
157
151
158
- // Handle click events on menu buttons and update active tab index
159
- this .all (' menu button' )
160
- .on (' click' , (_el , index ) => () => this .set (' active' , index))
161
- .sync ((host , target , index ) => {
162
- setAttribute (
163
- ' aria-pressed' ,
164
- () => host .get (' active' ) === index ? ' true' : ' false'
165
- )(host, target)
166
- })
167
-
168
- // Pass open attribute to tab-panel elements based on active tab index
169
- this .all (' tab-panel' ).pass ((_el , index ) => ({
170
- open : () => index === this .get (' active' )
171
- }))
152
+ init = {
153
+ active: 0 ,
154
+ accordion: asBoolean,
172
155
}
173
- }
174
- TabList .define (' tab-list' )
175
156
176
- class TabPanel extends UIElement {
177
157
connectedCallback () {
178
- this .self .sync (toggleAttribute (' open' ))
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
+ ))
179
200
}
180
201
}
181
- TabPanel .define (' tab-panel ' )
202
+ TabList .define ()
182
203
```
183
204
184
205
Example styles:
185
206
186
207
``` css
187
- tab- list menu {
188
- list- style: none;
189
- display: flex;
190
- gap: 0 .2rem ;
191
- padding: 0 ;
192
-
193
- & button[aria- pressed= " true" ] {
194
- color: red;
208
+ tab-list {
209
+
210
+ > menu {
211
+ list-style : none ;
212
+ display : flex ;
213
+ gap : 0.2rem ;
214
+ padding : 0 ;
215
+
216
+ & button [aria-pressed ="true "] {
217
+ color : purple ;
218
+ }
195
219
}
196
- }
197
220
198
- tab- panel {
199
- display: none;
221
+ > details {
222
+
223
+ &:not([open ]) {
224
+ display : none ;
225
+ }
226
+
227
+ &[aria-disabled ] {
228
+ pointer-events : none ;
229
+ }
230
+ }
231
+
232
+ &[accordion ] {
200
233
201
- & [open] {
202
- display: block;
234
+ > menu {
235
+ display : none ;
236
+ }
237
+
238
+ > details :not ([open ]) {
239
+ display : block ;
240
+ }
203
241
}
204
242
}
205
243
```
@@ -210,70 +248,64 @@ A more complex component demonstrating async fetch from the server:
210
248
211
249
``` html
212
250
<lazy-load src =" /lazy-load/snippet.html" >
213
- < div class = " loading" > Loading... < / div>
214
- < div class = " error" >< / div>
251
+ <div class =" loading" role = " status " >Loading...</div >
252
+ <div class =" error" role = " alert " aria-live = " polite " ></div >
215
253
</lazy-load >
216
254
```
217
255
218
256
``` js
219
- import { UIElement , setText , setProperty , effect , enqueue } from ' @zeix/ui-element'
257
+ import { UIElement , setProperty , setText , dangerouslySetInnerHTML } from ' @zeix/ui-element'
220
258
221
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
222
263
static observedAttributes = [' src' ]
223
- states = {
224
- src : v => {
225
- let url = ' '
226
- try {
227
- url = new URL (v, location .href ) // ensure 'src' attribute is a valid URL
228
- if (url .origin !== location .origin ) // sanity check for cross-origin URLs
229
- throw new TypeError (' Invalid URL origin' )
230
- } catch (error) {
231
- console .error (error, url)
232
- url = ' '
233
- }
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
234
276
return url .toString ()
235
- },
236
- error: ' '
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: ' ' ,
237
294
}
238
295
239
296
connectedCallback () {
297
+ super .connectedCallback ()
240
298
241
- // Show / hide loading message
242
- this .first (' .loading' )
243
- .sync (setProperty (' hidden' , () => !! this .get (' error' )))
244
-
245
- // Set and show / hide error message
246
- this .first (' .error' )
247
- .sync (setText (' error' ))
248
- .sync (setProperty (' hidden' , () => ! this .get (' error' )))
299
+ // Effect to set error message
300
+ this .first (' .error' ).sync (
301
+ setProperty (' hidden' , () => ! this .get (' error' )),
302
+ setText (' error' ),
303
+ )
249
304
250
- // Load content from provided URL
251
- effect (async () => {
252
- const src = this .get (' src' )
253
- if (! src) return // silently fail if no valid URL is provided
254
- try {
255
- const response = await fetch (src)
256
- if (response .ok ) {
257
- const content = await response .text ()
258
- enqueue (() => {
259
- // UNSAFE!, use only trusted sources in 'src' attribute
260
- this .root .innerHTML = content
261
- this .root .querySelectorAll (' script' ).forEach (script => {
262
- const newScript = document .createElement (' script' )
263
- newScript .appendChild (document .createTextNode (script .textContent ))
264
- this .root .appendChild (newScript)
265
- script .remove ()
266
- })
267
- }, [this .root , ' h' ])
268
- this .set (' error' , ' ' )
269
- } else {
270
- this .set (' error' , response .status + ' :' + response .statusText )
271
- }
272
- } catch (error) {
273
- this .set (' error' , error)
274
- }
275
- })
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' ))
276
308
}
277
309
}
278
- LazyLoad .define (' lazy-load ' )
310
+ LazyLoad .define ()
279
311
` ` `
0 commit comments