11# UIElement
22
3- Version 0.10.1
3+ Version 0.11.0
44
55** UIElement** - transform reusable markup, styles and behavior into powerful, reactive, and maintainable Web Components.
66
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.
88
99## Key Features
1010
@@ -24,8 +24,6 @@ npm install @zeix/ui-element
2424bun add @zeix/ui-element
2525```
2626
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-
2927## Documentation
3028
3129The full documentation is still work in progress. The following chapters are already reasonably complete:
@@ -62,10 +60,12 @@ class ShowAppreciation extends UIElement {
6260
6361 connectedCallback () {
6462 // 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 ))
6664
6765 // 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+ })
6969
7070 // Update .count text when count changes
7171 this .first (' .count' ).sync (setText (this .#count))
@@ -121,22 +121,22 @@ An example demonstrating how to pass states from one component to another. Serve
121121``` html
122122<tab-list >
123123 <menu >
124- < li>< button type= " button" > Tab 1 < / button>< / li>
124+ <li ><button type =" button" aria-pressed = " true " >Tab 1</button ></li >
125125 <li ><button type =" button" >Tab 2</button ></li >
126126 <li ><button type =" button" >Tab 3</button ></li >
127127 </menu >
128- < tab - panel open>
129- < h2 > Tab 1 < / h2 >
128+ <details open >
129+ <summary >Tab 1</summary >
130130 <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 >
134134 <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 >
138138 <p >Content of tab panel 3</p >
139- < / tab - panel >
139+ </details >
140140</tab-list >
141141```
142142
@@ -146,60 +146,98 @@ UIElement components:
146146import { UIElement , setAttribute , toggleAttribute } from ' @zeix/ui-element'
147147
148148class 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' ]
157151
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,
172155 }
173- }
174- TabList .define (' tab-list' )
175156
176- class TabPanel extends UIElement {
177157 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+ ))
179200 }
180201}
181- TabPanel .define (' tab-panel ' )
202+ TabList .define ()
182203```
183204
184205Example styles:
185206
186207``` 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+ }
195219 }
196- }
197220
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 ] {
200233
201- & [open] {
202- display: block;
234+ > menu {
235+ display : none ;
236+ }
237+
238+ > details :not ([open ]) {
239+ display : block ;
240+ }
203241 }
204242}
205243```
@@ -210,70 +248,64 @@ A more complex component demonstrating async fetch from the server:
210248
211249``` html
212250<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 >
215253</lazy-load >
216254```
217255
218256``` js
219- import { UIElement , setText , setProperty , effect , enqueue } from ' @zeix/ui-element'
257+ import { UIElement , setProperty , setText , dangerouslySetInnerHTML } from ' @zeix/ui-element'
220258
221259class 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
222263 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
234276 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: ' ' ,
237294 }
238295
239296 connectedCallback () {
297+ super .connectedCallback ()
240298
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+ )
249304
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' ))
276308 }
277309}
278- LazyLoad .define (' lazy-load ' )
310+ LazyLoad .define ()
279311` ` `
0 commit comments