@skirbi/sugar
v0.0.11
Published
Lightweight base layer for writing custom elements with declarative attributes and template sugar.
Maintainers
Readme
@skirbi/sugar
A lightweight base layer for building Web Components.
It provides:
- Declarative attribute → config mapping
- Template helpers
- Alias utilities
- Form-control wrapping primitives
TL;DR
You can create Web Components like so:
class MyNewElement extends HTMLElementSugar {
static tag = 'my-new-element';
static attributeDefs = {
foo: { default: 'bar' },
bar: { parser: parseBoolean },
};
}You can also create aliases:
TrackItem.alias('some-song', { type: 'song' });
YourBook.alias('a-book');And to register them:
TrackItem.register();
YourBook.register();Observable patterns
You can define attributes to be observable:
class MyNewElement extends HTMLElementSugar {
static tag = 'my-new-element';
static attributeDefs = {
'foo': { default: 'bar' },
'bar': { parser: parseBoolean },
'baz': null,
'qux': {},
'toto': { observed: "" }, // not observed
'titi': { observed: 0 }, // not observed
'tata': { observed: false }, // not observed
'tete': { observed: null }, // not observed
'yes': { observed: true },
'yez': { observed: 1 },
'yep': { observed: "here be dragons" },
};
}Templates
Template via HTML template reference
class HTMLTemplate extends HTMLElementSugar {
static tag = 'html-template';
static HtmlTemplate = 'track-item-template'; // find in html
}HTMLElementSugar asserts that it can find the template on registration. Which means you need to have the template ready in HTML. It is therefore essential that you register your component in window.addEventListener('DOMContentLoaded', () => { }.
Template via javascript element reference
const tpl = document.createElement('template');
tpl.id = 'inline';
tpl.innerHTML =
`<div class="track-row"><span class="song">song</span></div>`;
document.body.appendChild(tpl);
class JSElement extends HTMLElementSugar {
static tag = 'js-element';
static HtmlTemplate = tpl;
}Template via javascript function
class TemplateFunction extends HTMLElementSugar {
static tag = 'template-function';
static HtmlTemplate() {
const t = document.createElement('template');
t.innerHTML =
`<div class="track-row"><div class="track-info">from fn</div></div>`;
return t;
}
}We recently added a helper you can now do this too:
class TemplateFunction extends HTMLElementSugar {
static tag = 'template-function';
static HtmlTemplate = this.tpl(`
<div class="track-row">
<div class="track-info">from tpl</div>
</div>
`);
}Custom template with fallback
class TemplateFunction extends HTMLElementSugar {
static tag = 'template-function';
static HtmlTemplate = [
'some-id-in-html',
() => {
const t = document.createElement('template');
t.innerHTML =
`<div class="track-row"><div class="track-info">from fn</div></div>`;
return t;
}
];
}No template (the ultimate minimalism)
class MinimalistComponent extends HTMLElementSugar {
static tag = 'ultimate-minimalist';
// Look at all this template code I'm not writing
}You can register your Web Components by running:
MyComponent.register();HTMLElementSugarInput
HTMLElementSugarInput extends HTMLElementSugar and is designed for
components that wrap a real form control in the light DOM.
It provides:
- Attribute forwarding to the real control
- Native
inputandchangere-emission - Optional attribute mirroring via
data-sync - Proper
valueproperty handling
Contract
A subclass must:
- Extend
HTMLElementSugarInput - Render exactly one element matching
[wc-control](or overridestatic controlSelector)
Example:
class MyInput extends HTMLElementSugarInput {
static tag = 'my-input';
static attributeDefs = {
label: { default: '' },
value: { default: '' },
};
static HtmlTemplate = this.tpl(`
<div>
<label wc-label></label>
<input wc-control type="text">
</div>
`);
connectedCallback() {
super.connectedCallback();
const frag = this.renderFromTemplate();
const control = this.enhanceControl(frag);
const { label, value } = this.getConfig();
frag.querySelector('[wc-label]').textContent = label;
control.value = value ?? '';
this.replaceChildren(frag);
}
}
MyInput.register();Attribute Forwarding
All host attributes that are not defined in attributeDefs
are forwarded to the real control element.
Example:
<my-input
label="Name"
wire:model.defer="name"
aria-label="Name"
></my-input>Results in:
<input
wire:model.defer="name"
aria-label="Name"
>Not forwarded
- Attributes defined in
attributeDefs id,class,style(kept on the host)
This keeps components framework-agnostic:
- Livewire (
wire:*) - Alpine (
x-*) - HTMX (
hx-*) aria-*data-*
Event Re-Emission
Native events from the control are re-emitted from the host:
inputchange
Listeners may bind to either the host or the internal control.
data-sync (Optional Attribute Mirroring)
By default, attributes are forwarded once during connect.
If you enable data-sync, subsequent attribute changes on the host are
mirrored to the control using a MutationObserver.
Example:
<my-input wire:model.defer="name" data-sync></my-input>When data-sync is enabled:
- Host attribute changes are mirrored to the control
valueis mirrored as a property (not an attribute)- Removing attributes removes them from the control
This is particularly useful in reactive environments such as Livewire.
HTMLElementSugarSelect
HTMLElementSugarSelect extends HTMLElementSugarInput and provides a
structured way to author <select>-based components.
It supports:
- Static option lists (via JSON)
- Remote option loading (via endpoint)
- Flexible data mapping via jpath
- Label/value templates
- Optional search input
- Attribute forwarding to the underlying
<select>
It always renders a real <select> in the light DOM.
Contract
A subclass must:
- Extend HTMLElementSugarSelect
- Render exactly one
<select wc-control> - Optionally include a search input if searchable is enabled
Example:
class MySelect extends HTMLElementSugarSelect {
static tag = 'my-select';
static HtmlTemplate = this.tpl(`
<select wc-control></select>
`);
}
MySelect.register();Static Options
You may provide options via the options attribute as a JSON array:
<my-select
options='[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]'
jpath-label="name"
jpath-value="id"
></my-select>Mapping Options
Data mapping is controlled via:
jpath: path to the array in a nested JSON responsejpath-label: path for the option labeljpath-value: path for the option valuejpath-label-template: template string for labeljpath-value-template: template string for value
Templates take precedence over path mappings.
Example:
<my-select
options='[
{ "id": 1, "name": "Alice", "email": "[email protected]" }
]'
jpath-label-template="{name} ({email})"
jpath-value-template="user:{id}"
></my-select>Missing template fields resolve to empty strings.
Remote Options
Options can be loaded from a remote endpoint:
<my-select
endpoint="/api/users"
method="GET"
param="q"
searchable
min-chars="2"
debounce="300"
nosearch-initial
></my-select>Behavior:
- Fetches JSON from endpoint
- Adds search query via param
- Applies mapping rules
- Replaces options on each fetch
If searchable is enabled:
A search input is rendered
Input is debounced
Fetch is triggered after min-chars is reached
By default, a remote endpoint triggers an initial fetch with an empty query on connect. If
nosearch-initialis enabled this behavior is negated.No initial searches are
Search Behavior (Static Mode)
When using static options, enabling searchable:
- Filters
<option>elements in place - Uses case-insensitive substring matching
- Does not modify underlying data
Value Handling
The value attribute behaves consistently with HTMLElementSugarInput:
- Initial value is applied after options render
- With data-sync, updates are mirrored to the
<select>element - Native input and change events are re-emitted from the host
Framework Compatibility
Because it renders a real <select> in the light DOM, it works naturally with:
- Livewire (
wire:*) - Alpine (
x-*) - HTMX (
hx-*) aria-*data-*
Attributes not defined in attributeDefs are forwarded to the <select>
element.
Why This Works
- No shadow DOM
- No custom dropdown UI
- No CSS lock-in
- Real
<option>elements - Progressive enhancement friendly
Code of Conduct
Be human.
Developer notes about this package
Versioning
This project does not follow semver. It follows a Perl-style release philosophy centered on backward compatibility. This translates to the following hard guarantee: We do not intentionally break working code. If a release causes breakage, it will be addressed accordingly.
The x.y.z version number should not be used to infer stability.
Consult the Changes file for important updates, deprecations,
and breaking changes.
The current 0.x.z range does not imply alpha, beta, or instability.
It is simply the starting point of the project.
In case we foresee breaking changes we'll add deprecation warnings. Giving you ample time to fix things before a breaking change will be introduced. When a change will be introduced is communicated in the Changes file. Security fixes may cause breakage at any given time without notice.
This package is released by @opndev/rzilla, changes to package.json will be
overridden. In addition to a little bit of promotion, this also means that
version numbers are autoincremented at release time and bumped in all relevant
files: Versioning for humans, not machines.
