Skip to content

Commit 0a291f5

Browse files
Merge pull request #47 from zeixcom/feature/cause-effect-v0.12.0
feat: cleanup of API for version 1.0rc1 (0.11.0) using Cause & Effect 0.12.4, bugfixes, alignment of examples in docs and tests
2 parents 72203da + d5d150b commit 0a291f5

File tree

149 files changed

+3958
-3592
lines changed

Some content is hidden

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

149 files changed

+3958
-3592
lines changed

README.md

Lines changed: 139 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
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
2424
bun 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

3129
The 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:
146146
import { UIElement, setAttribute, toggleAttribute } from '@zeix/ui-element'
147147

148148
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']
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

184205
Example 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

221259
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
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
```

bun.lockb

4.07 KB
Binary file not shown.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class AccordionPanel extends UIElement<{
77
static readonly localName = 'accordion-panel'
88
static observedAttributes = ['open', 'collapsible']
99

10-
states = {
10+
init = {
1111
open: asBoolean,
1212
collapsible: asBoolean
1313
}

docs-src/components/code-block/code-block.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class CodeBlock extends UIElement<{ collapsed: boolean }> {
1010
static readonly localName = 'code-block'
1111
static observedAttributes = ['collapsed']
1212

13-
states = {
13+
init = {
1414
collapsed: asBoolean
1515
}
1616

docs-src/components/hello-world/hello-world.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { setText, UIElement, RESET } from "../../../"
22

3-
export class HelloWorld extends UIElement<{ name?: string }> {
3+
export class HelloWorld extends UIElement {
44
static localName = 'hello-world'
55

66
connectedCallback() {

docs-src/components/input-button/input-button.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import { asBoolean, setProperty, setText, UIElement } from "../../../"
1+
import { UIElement, asBoolean, setProperty, setText } from "../../../"
22

3-
export class InputButton extends UIElement<{
4-
disabled: boolean,
5-
label?: string,
6-
badge?: string,
7-
}> {
3+
export class InputButton extends UIElement<{ disabled: boolean }> {
84
static localName = 'input-button'
95
static observedAttributes = ['disabled']
106

11-
states = {
7+
init = {
128
disabled: asBoolean,
139
}
1410

docs-src/components/input-checkbox/input-checkbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export class InputCheckbox extends UIElement<{ checked: boolean }> {
44
static localName = 'input-checkbox'
55
static observedAttributes = ['checked']
66

7-
states = {
7+
init = {
88
checked: asBoolean
99
}
1010

0 commit comments

Comments
 (0)