-
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.