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.
import Attr from '@components-1812/utils/attributes/index.js';
import { ComponentUtils as $ } 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.
const element = 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 attribute
Attr.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.
const element = document.createElement('my-box');
Attr.number.set(element, 'width', '320');
element.getAttribute('width'); // "320"
Attr.number.get(element, 'width', 100); // 320
Attr.number.set(element, 'width', -10, {
validate: (value) => value >= 0
}); // false
Attr.number.get(element, 'height', 100); // 100Typical usage inside a Web Component property:
get width() {
return Attr.number.get(this, 'width', this.constructor.defaults.width);
}
set width(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.
const element = document.createElement('my-toggle');
Attr.boolean.set(element, 'disabled', true);
element.hasAttribute('disabled'); // true
Attr.boolean.get(element, 'disabled'); // true
Attr.boolean.set(element, 'disabled', false);
element.hasAttribute('disabled'); // false
Attr.boolean.get(element, 'disabled'); // falseIt also accepts the strings "true" and "false":
Attr.boolean.set(element, 'open', 'true');
Attr.boolean.get(element, 'open'); // true
Attr.boolean.set(element, 'open', 'false');
Attr.boolean.get(element, 'open'); // falseValidates CSS colors with CSS.supports('color', value) and returns a Color instance.
const element = document.createElement('color-chip');
Attr.color.set(element, 'color', 'rgba(255, 144, 20, 0.68)');
const color = 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.68
color.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.
const element = document.createElement('my-panel');
const state = 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'); // true
state.replace('open', 'closed'); // true
state.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 (const token of state) {
console.log(token);
}
state.forEach((token, index) => {
console.log(index, token);
});Recommended import:
import { ComponentUtils as $ } from '@components-1812/utils/component/index.js';Creates HTML elements and lets you configure classes, attributes, data-*, text, inner HTML, and children.
const title = $.create.html('h2', {
classes: ['card-title'],
textContent: 'Profile'
});
const button = $.create.html('button', {
classes: ['primary'],
attributes: {
type: 'button',
'aria-label': 'Open profile'
},
data: {
action: 'open',
id: 'user-1'
},
textContent: 'Open'
});
const card = $.create.html('article', {
classes: ['card'],
children: [title, button]
});Approximate result:
<article class="card">
<h2 class="card-title">Profile</h2>
<button type="button" aria-label="Open profile" data-action="open" data-id="user-1">
Open
</button>
</article>If you need HTML content that you control:
const icon = $.create.html('span', {
classes: ['icon'],
html: '<strong>!</strong>'
});Creates SVG nodes using document.createElementNS.
const svg = $.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.
const root = this.shadowRoot;
const button = $.query(root, 'button', {
attributes: {
type: 'button'
},
data: {
action: 'save'
}
});Equivalent to:
root.querySelector('button[type="button"][data-action="save"]');To get all matching results:
const items = $.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';
const links = new StyleCollection({
validator: (value) => URL.canParse(value, document.baseURI),
mapper: (value) => new URL(value, document.baseURI).href
});
links.add('./theme.css', './layout.css', './theme.css');
links.size; // 2
links.has(new URL('./theme.css', document.baseURI).href); // true
links.toArray(); // normalized URLs
for (const href of links) {
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';
const sheet = new CSSStyleSheet();
sheet.replaceSync(':host { display: block; }');
const sharedStyles = new ComponentStyleSheets({
raw: `
.box {
inline-size: 100%;
block-size: 100%;
}
`,
links: ['./theme.css'],
adopted: [sheet]
});Inside a Custom Element:
this.attachShadow({ mode: 'open' });
this.componentStyles = new ComponentStyles(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.
import Attr from '@components-1812/utils/attributes/index.js';
import { ComponentStyleSheets } from '@components-1812/utils/styles/index.js';
export class CounterButtonBase extends HTMLElement {
static VERSION = '0.0.0';
static DEFAULT_TAG_NAME = 'counter-button';
static defaults = {
count: 0,
label: 'Clicks',
disabled: false,
color: 'royalblue'
};
static styleSheets = null;
static define(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 = new ComponentStyleSheets(styleSheets);
window.customElements.define(tagName, this);
}
get count() {
return Attr.number.get(this, 'count', this.constructor.defaults.count);
}
set count(value) {
Attr.number.set(this, 'count', value, {
validate: (count) => count >= 0
});
}
get label() {
return Attr.string.get(this, 'label', this.constructor.defaults.label);
}
set label(value) {
Attr.string.set(this, 'label', value, {
validate: (label) => label.length > 0
});
}
get disabled() {
return Attr.boolean.get(this, 'disabled', this.constructor.defaults.disabled);
}
set disabled(value) {
Attr.boolean.set(this, 'disabled', value);
}
get color() {
return Attr.color.get(this, 'color', Attr.color.parseColor(this.constructor.defaults.color));
}
set color(value) {
Attr.color.set(this, 'color', value);
}
}import { ComponentUtils as $ } from '@components-1812/utils/component/index.js';
import { ComponentStyles } from '@components-1812/utils/styles/index.js';
import { CounterButtonBase } from './CounterButtonBase.js';
export class CounterButton extends CounterButtonBase {
static observedAttributes = ['count', 'label', 'disabled', 'color'];
#connected = false;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.componentStyles = new ComponentStyles(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(new CustomEvent('counter-change', {
detail: { count: this.count },
bubbles: true,
composed: true
}));
}
render() {
let button = $.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-button count="3" label="Likes" color="#1c89bf"></counter-button>const counter = document.querySelector('counter-button');
counter.addEventListener('counter-change', (event) => {
console.log(event.detail.count);
});
counter.count = 10;
counter.disabled = false;