-
Notifications
You must be signed in to change notification settings - Fork 8
Juris WebComponent Factory
jurisauthor edited this page Aug 26, 2025
·
1 revision
A comprehensive web component system that implements the complete web component standards while providing deep integration with the Juris reactive framework.
Core Web Component Standards
- Custom element registration with full lifecycle support
- Shadow DOM with configurable modes and focus delegation
- Observed attributes with automatic type coercion
- CSS encapsulation with
:hostselectors and custom properties
Reactive Props System
- Function-based reactive props that auto-update on state changes
- Mixed static and reactive prop support
- Automatic dependency tracking and subscription management
- Maintains Juris reactivity across component boundaries
Form Integration
- Native form participation with
formAssociated = true - Built-in validation with
setValidity()and form internals - Form lifecycle callbacks and state restoration
- Seamless integration with HTML form validation
Accessibility (A11y)
- ARIA attribute observation and forwarding
- Screen reader announcements with live regions
- Keyboard navigation and focus management
- Automatic accessibility patterns and roles
Advanced Slot System
- Named and unnamed slot support with change observation
- Content projection with automatic slot information
- Slot-based re-rendering triggers
Performance & Standards
- Global event delegation for optimal performance
- Memory leak prevention with automatic cleanup
- Complete standards compliance beyond basic web components
- Async rendering support with error boundaries
Include the WebComponent factory after the main Juris library:
<script src="juris.js"></script>
<script src="juris-webcomponent.js"></script>const juris = new Juris({
features: {
webComponentFactory: WebComponentFactory
}
});const MyButton = (props, { getState, setState }) => {
return {
// Observed HTML attributes
attributes: ['type', 'disabled'],
// Initial component state
initialState: {
clickCount: 0
},
// Web component options
options: {
shadowMode: 'open',
formAssociated: true,
delegatesFocus: true,
accessibility: {
defaultRole: 'button',
focusable: true
}
},
// Exposed API methods
api: {
click() {
this.click();
},
focus() {
this.focus();
},
getClickCount() {
return getState('clickCount', 0);
}
},
// Form integration
form: {
getValue: () => getState('value', ''),
validate: () => ({
flags: { valueMissing: !getState('value', '') },
message: 'Value is required'
})
},
// Accessibility configuration
accessibility: {
liveRegion: true,
keyHandlers: {
'Enter': () => this.click(),
'Space': () => this.click()
}
},
// Lifecycle hooks
hooks: {
onMount: (context) => {
context.component.announceToScreenReader('Button ready');
},
onSlotChange: (slotInfo, context) => {
console.log('Slot content changed:', slotInfo);
}
},
// Render function
render: () => ({
button: {
type: props.type || 'button',
disabled: () => props.disabled,
onclick: () => {
const count = getState('clickCount', 0);
setState('clickCount', count + 1);
},
children: [
{slot: { name: 'icon' }},
'Click me ',
() => `(${getState('clickCount', 0)})`
]
}
})
};
};// Auto-registration from config
juris.webComponentFactory.initializeFromConfig({
'my-button': MyButton
}, () => juris.createContext());
// Manual registration
juris.webComponentFactory.create('my-button', MyButton, {
shadowMode: 'open',
formAssociated: true
});juris.layout = {
div: {
children: [
{
'my-button': {
type: 'submit',
disabled: () => juris.getState('formInvalid', false),
label: () => `Submit (${juris.getState('pendingItems', 0)} pending)`
}
}
]
}
};
juris.render();const ComponentDefinition = (props, context) => {
return {
// Observed attributes
attributes: ['value', 'disabled', 'required'],
// Initial state
initialState: {
internalValue: '',
valid: true
},
// Web component options
options: {
shadowMode: 'open', // 'open' | 'closed' | 'none'
delegatesFocus: true, // Focus delegation
slotAssignment: 'named', // 'named' | 'manual'
formAssociated: true, // Enable form participation
enhanceMode: false // Use light DOM instead of shadow
},
// API methods exposed on element
api: {
getValue() { return getState('value', ''); },
setValue(value) { setState('value', value); },
validate() { return this.checkValidity(); }
},
// Form integration
form: {
getValue: (context) => getState('value', ''),
validate: (context) => ({
flags: {
valueMissing: !getState('value', ''),
patternMismatch: false
},
message: 'Please fill out this field'
}),
reset: (context) => setState('value', ''),
restore: (state, mode, context) => setState('value', state)
},
// Accessibility configuration
accessibility: {
defaultRole: 'textbox',
liveRegion: true,
focusable: true,
keyHandlers: {
'Enter': (e, context) => this.form.requestSubmit(),
'Escape': (e, context) => this.blur()
}
},
// Event delegation
events: {
'focus': (e, context) => setState('focused', true),
'blur': (e, context) => setState('focused', false)
},
// Lifecycle hooks
hooks: {
onConnect: (context) => console.log('Connected'),
onMount: (context) => console.log('Mounted'),
onUnmount: (context) => console.log('Unmounted'),
onAdopted: (context) => console.log('Adopted'),
onAttributeChange: (name, oldVal, newVal, context) => {},
onSlotChange: (slotInfo, context) => {},
onFormAssociated: (form, context) => {},
onFormReset: (context) => {},
onAriaChange: (attr, value, ariaState, context) => {}
},
// Render function
render: () => ({
div: {
class: () => `input-wrapper ${getState('focused', false) ? 'focused' : ''}`,
children: [
{input: {
type: 'text',
value: () => getState('value', ''),
oninput: (e) => setState('value', e.target.value)
}},
{slot: { name: 'helper-text' }}
]
}
})
};
};// In layout
'my-component': {
title: 'Hello World', // Static string
count: 42, // Static number
config: { theme: 'dark' } // Static object
}
// In component
render: () => ({
div: {
text: props.title, // 'Hello World'
class: props.config.theme // 'dark'
}
})// In layout
'my-component': {
title: () => juris.getState('pageTitle', 'Default'),
count: () => juris.getState('itemCount', 0),
config: () => juris.getState('appConfig', {})
}
// In component - props are reactive functions
render: () => ({
div: {
text: props.title, // Function that updates automatically
data-count: props.count, // Updates when state changes
class: () => props.config().theme
}
})const FormInput = (props, { getState, setState, component }) => {
return {
options: { formAssociated: true },
form: {
getValue: () => getState('value', ''),
validate: () => {
const value = getState('value', '');
const required = props.required && props.required();
return {
flags: {
valueMissing: required && !value,
tooShort: value.length < 3
},
message: !value ? 'This field is required' :
value.length < 3 ? 'Minimum 3 characters' : ''
};
},
reset: () => setState('value', ''),
restore: (state) => setState('value', state)
},
render: () => ({
input: {
type: 'text',
value: () => getState('value', ''),
oninput: (e) => {
setState('value', e.target.value);
component.setFormValue(e.target.value);
}
}
})
};
};const AccessibleCounter = (props, { getState, setState, component }) => {
return {
accessibility: {
defaultRole: 'button',
liveRegion: true,
keyHandlers: {
'Enter': () => increment(),
'Space': () => increment(),
'ArrowUp': () => increment(),
'ArrowDown': () => decrement()
}
},
api: {
increment() {
const newCount = getState('count', 0) + 1;
setState('count', newCount);
component.announceToScreenReader(`Count is now ${newCount}`);
}
},
render: () => ({
div: {
'aria-label': () => `Counter: ${getState('count', 0)}`,
tabindex: '0',
text: () => getState('count', 0)
}
})
};
};const CardComponent = (props, { component }) => {
return {
hooks: {
onSlotChange: (slots) => {
console.log('Available slots:', Object.keys(slots));
// Check if specific slots have content
if (slots.header?.assignedElements.length > 0) {
console.log('Header slot has content');
}
}
},
render: () => ({
div: {
class: 'card',
children: [
{header: {
class: 'card-header',
children: [{slot: { name: 'header' }}]
}},
{main: {
class: 'card-content',
children: [{slot: {}}] // Default slot
}},
{footer: {
class: 'card-footer',
children: [{slot: { name: 'actions' }}]
}}
]
}
})
};
};<my-card>
<h2 slot="header">Card Title</h2>
<p>This goes in the default slot</p>
<button slot="actions">Action Button</button>
</my-card>// Get component reference
const button = document.querySelector('my-button');
// Call API methods directly
button.increment();
button.reset();
const stats = button.getStats();
// Check validation state
const isValid = button.checkValidity();
const validation = button.getValidationState();
// Form methods
button.setCustomValidity('Custom error message');
button.reportValidity();- Use static props for unchanging data
// Good - static
title: 'Fixed Title'
// Avoid - reactive for static data
title: () => 'Fixed Title'- Batch state updates
juris.executeBatch(() => {
setState('prop1', value1);
setState('prop2', value2);
setState('prop3', value3);
});- Use form internals for form controls
form: {
getValue: () => getState('value', ''),
validate: () => ({ flags: {}, message: '' })
}- Leverage slot observation judiciously
hooks: {
onSlotChange: (slots) => {
// Only re-render if slot change affects component
if (slots.critical?.assignedElements.length > 0) {
this.render();
}
}
}- Modern browsers: Full support (Chrome 67+, Firefox 63+, Safari 13+)
- Form association: Chrome 77+, Firefox 93+, Safari 16.4+
- Slot assignment: Chrome 86+, Firefox 92+, Safari 16.4+
| Feature | Juris WebComponents | Lit | Stencil |
|---|---|---|---|
| Reactive Props | Function-based | Property-based | Property-based |
| Form Participation | Built-in | Manual | Manual |
| Accessibility | Automatic | Manual | Manual |
| State Integration | Deep integration | External | External |
| Slot Observation | Automatic | Manual | Manual |
| Event Delegation | Global | Per-component | Per-component |
| Bundle Size | Framework-dependent | ~5KB | ~1KB |
The WebComponent Factory is part of the Juris framework. Contributions should maintain backward compatibility and follow web component standards.
MIT License - see Juris framework license for details.