Lightweight utilities for building native Web Components with JavaScript.
This package is designed to solve three repetitive tasks when working with Custom Elements:
- Attributes (
Attr):get/sethelpers for reading and writing attributes with casting and validation. - Components (
ComponentUtils): helpers for creating HTML/SVG nodes and querying elements with composed selectors. - Styles (
ComponentStyleSheets/ComponentStyles):raw,links, andadoptedStyleSheetsstyle collections for applying styles to ashadowRoot.
importAttrfrom'@components-1812/utils/attributes/index.js';import{ComponentUtilsas$}from'@components-1812/utils/component/index.js';import{ComponentStyleSheets,ComponentStyles}from'@components-1812/utils/styles/index.js';Note:
Attrcurrently exposesstring,number,boolean,color, andlistfromattributes/index.js.
Each attribute utility follows the same idea:
get(element, name, defaultValue?, options?)reads the attribute and returns a normalized value.set(element, name, value, options?)validates, normalizes, and writes the attribute.- If
value == null,setremoves the attribute. - Options usually accept
validate, a function that decides whether the value is valid before writing or returning it.
Converts any value to string, trims it by default, and allows text validation.
constelement=document.createElement('user-card');Attr.string.set(element,'label',' Franco ');element.getAttribute('label');// "Franco"Attr.string.get(element,'label','Anonymous');// "Franco"Attr.string.set(element,'label','ok',{validate: (value)=>value.length>=3});// false, does not overwrite the previous attributeAttr.string.get(element,'missing','Anonymous');// "Anonymous"You can also disable trimming when reading:
element.setAttribute('label',' Keep spaces ');Attr.string.get(element,'label',null,{trim: false});// " Keep spaces "Reads and writes numeric attributes. If the value cannot be converted with Number(value), it returns the default value or refuses to write.
constelement=document.createElement('my-box');Attr.number.set(element,'width','320');element.getAttribute('width');// "320"Attr.number.get(element,'width',100);// 320Attr.number.set(element,'width',-10,{validate: (value)=>value>=0});// falseAttr.number.get(element,'height',100);// 100Typical usage inside a Web Component property:
getwidth(){returnAttr.number.get(this,'width',this.constructor.defaults.width);}setwidth(value){Attr.number.set(this,'width',value,{validate: (number)=>number>=0});}Works with boolean attributes. Writing true adds the attribute; writing false removes it through toggleAttribute.
constelement=document.createElement('my-toggle');Attr.boolean.set(element,'disabled',true);element.hasAttribute('disabled');// trueAttr.boolean.get(element,'disabled');// trueAttr.boolean.set(element,'disabled',false);element.hasAttribute('disabled');// falseAttr.boolean.get(element,'disabled');// falseIt also accepts the strings "true" and "false":
Attr.boolean.set(element,'open','true');Attr.boolean.get(element,'open');// trueAttr.boolean.set(element,'open','false');Attr.boolean.get(element,'open');// falseValidates CSS colors with CSS.supports('color', value) and returns a Color instance.
constelement=document.createElement('color-chip');Attr.color.set(element,'color','rgba(255, 144, 20, 0.68)');constcolor=Attr.color.get(element,'color');color.value;// "rgba(255, 144, 20, 0.68)"color.hex;// "#ff9015ad"color.rgb;// "rgb(255 144 20 / 0.68)"color.hsl;// "hsl(32 100% 54% / 0.68)"color.alpha;// 0.68color.channels.r;// 255You can request alternate formats:
color.toRgb({legacy: true});// "rgba(255, 144, 20, 0.68)"color.toHsl({legacy: true});// "hsla(32, 100%, 54%, 0.68)"color.toHex({alpha: false});// "#ff9015"And validate before writing:
Attr.color.set(element,'color','#111',{validate: (color)=>color.alpha===1});Returns a token list associated with an attribute, similar to DOMTokenList, with optional support for allowed tokens.
constelement=document.createElement('my-panel');conststate=Attr.list.get(element,'state',{supportedTokens: ['open','closed','loading']});state.add('open');element.getAttribute('state');// "open"state.toggle('loading');element.getAttribute('state');// "open loading"state.contains('open');// truestate.replace('open','closed');// truestate.value;// "closed loading"state[0];// "closed"Unsupported tokens are ignored:
state.add('invalid');element.getAttribute('state');// "closed loading"You can also iterate over the list:
for(consttokenofstate){console.log(token);}state.forEach((token,index)=>{console.log(index,token);});Recommended import:
import{ComponentUtilsas$}from'@components-1812/utils/component/index.js';Creates HTML elements and lets you configure classes, attributes, data-*, text, inner HTML, and children.
consttitle=$.create.html('h2',{classes: ['card-title'],textContent: 'Profile'});constbutton=$.create.html('button',{classes: ['primary'],attributes: {type: 'button','aria-label': 'Open profile'},data: {action: 'open',id: 'user-1'},textContent: 'Open'});constcard=$.create.html('article',{classes: ['card'],children: [title,button]});Approximate result:
<articleclass="card"><h2class="card-title">Profile</h2><buttontype="button" aria-label="Open profile" data-action="open" data-id="user-1">
Open
</button></article>If you need HTML content that you control:
consticon=$.create.html('span',{classes: ['icon'],html: '<strong>!</strong>'});Creates SVG nodes using document.createElementNS.
constsvg=$.create.svg('svg',{attributes: {viewBox: '0 0 24 24',width: '24',height: '24','aria-hidden': 'true'},children: [$.create.svg('circle',{attributes: {cx: '12',cy: '12',r: '10',fill: 'currentColor'}})]});Builds a selector by combining:
- A base selector.
- Exact attributes.
- Exact data attributes.
allmode to return an array.
constroot=this.shadowRoot;constbutton=$.query(root,'button',{attributes: {type: 'button'},data: {action: 'save'}});Equivalent to:
root.querySelector('button[type="button"][data-action="save"]');To get all matching results:
constitems=$.query(root,'.item',{data: {selected: 'true'},all: true});items.forEach((item)=>{item.classList.add('is-visible');});StyleCollection stores unique values and lets you define a validator and a mapper. It is the base collection used by the internal style helpers.
import{StyleCollection}from'@components-1812/utils/styles/index.js';constlinks=newStyleCollection({validator: (value)=>URL.canParse(value,document.baseURI),mapper: (value)=>newURL(value,document.baseURI).href});links.add('./theme.css','./layout.css','./theme.css');links.size;// 2links.has(newURL('./theme.css',document.baseURI).href);// truelinks.toArray();// normalized URLsfor(consthrefoflinks){console.log(href);}links.clear();ComponentStyleSheets groups styles that can later be used by ComponentStyles inside a component.
import{ComponentStyleSheets,ComponentStyles}from'@components-1812/utils/styles/index.js';constsheet=newCSSStyleSheet();sheet.replaceSync(':host { display: block; }');constsharedStyles=newComponentStyleSheets({raw: ` .box { inline-size: 100%; block-size: 100%; } `,links: ['./theme.css'],adopted: [sheet]});Inside a Custom Element:
this.attachShadow({mode: 'open'});this.componentStyles=newComponentStyles(this,this.constructor.styleSheets);this.componentStyles.apply();When link rel="stylesheet" resources finish loading, ComponentStyles:
- Dispatches the
ready-linksevent. - Adds the
ready-linksattribute to the element.
this.addEventListener('ready-links',(event)=>{console.log(event.detail.results);});The proposed pattern separates each component into two classes:
- Base class: defines defaults, Custom Element registration, and public properties synchronized with attributes.
- Final class: manages
shadowRoot, styles, lifecycle, internal state, and rendering.
The advantage is that the component API stays isolated from its visual representation. The base class explains how the component is used; the final class explains how it is rendered and how it reacts.
importAttrfrom'@components-1812/utils/attributes/index.js';import{ComponentStyleSheets}from'@components-1812/utils/styles/index.js';exportclassCounterButtonBaseextendsHTMLElement{staticVERSION='0.0.0';staticDEFAULT_TAG_NAME='counter-button';staticdefaults={count: 0,label: 'Clicks',disabled: false,color: 'royalblue'};staticstyleSheets=null;staticdefine(tagName=this.DEFAULT_TAG_NAME,styleSheets={}){if(window.customElements.get(tagName)){console.warn(`Custom element with tag name "${tagName}" is already defined.`);return;}this.styleSheets=newComponentStyleSheets(styleSheets);window.customElements.define(tagName,this);}getcount(){returnAttr.number.get(this,'count',this.constructor.defaults.count);}setcount(value){Attr.number.set(this,'count',value,{validate: (count)=>count>=0});}getlabel(){returnAttr.string.get(this,'label',this.constructor.defaults.label);}setlabel(value){Attr.string.set(this,'label',value,{validate: (label)=>label.length>0});}getdisabled(){returnAttr.boolean.get(this,'disabled',this.constructor.defaults.disabled);}setdisabled(value){Attr.boolean.set(this,'disabled',value);}getcolor(){returnAttr.color.get(this,'color',Attr.color.parseColor(this.constructor.defaults.color));}setcolor(value){Attr.color.set(this,'color',value);}}import{ComponentUtilsas$}from'@components-1812/utils/component/index.js';import{ComponentStyles}from'@components-1812/utils/styles/index.js';import{CounterButtonBase}from'./CounterButtonBase.js';exportclassCounterButtonextendsCounterButtonBase{staticobservedAttributes=['count','label','disabled','color'];
#connected =false;constructor(){super();this.attachShadow({mode: 'open'});this.componentStyles=newComponentStyles(this,this.constructor.styleSheets);this.componentStyles.apply();}connectedCallback(){this.#connected =true;this.render();}disconnectedCallback(){this.#connected =false;}attributeChangedCallback(name,oldValue,newValue){if(this.#connected &&oldValue!==newValue){this.render();}}increment(){if(this.disabled)return;this.count+=1;this.dispatchEvent(newCustomEvent('counter-change',{detail: {count: this.count},bubbles: true,composed: true}));}render(){letbutton=$.query(this.shadowRoot,'button',{data: {role: 'counter'}});if(!button){button=$.create.html('button',{attributes: {type: 'button'},data: {role: 'counter'}});button.addEventListener('click',()=>this.increment());this.shadowRoot.append(button);}button.disabled=this.disabled;button.style.setProperty('--counter-color',this.color.hex);button.textContent=`${this.label}: ${this.count}`;}}import{CounterButton}from'./CounterButton.js';CounterButton.define('counter-button',{raw: ` button { color: white; border: 0; border-radius: 6px; padding: 0.65rem 0.9rem; background: var(--counter-color, royalblue); cursor: pointer; } button:disabled { opacity: 0.55; cursor: not-allowed; } `});<counter-buttoncount="3" label="Likes" color="#1c89bf"></counter-button>constcounter=document.querySelector('counter-button');counter.addEventListener('counter-change',(event)=>{console.log(event.detail.count);});counter.count=10;counter.disabled=false;