npm.io
0.1.14 • Published yesterday

@skirbi/sugar

Licence
MIT
Version
0.1.14
Deps
2
Size
92 kB
Vulns
0
Weekly
0

@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:

import { HTMLElementSugar } from '@skirbi/sugar';

class MyNewElement extends HTMLElementSugar {
  static tag = 'my-new-element';
  static attributeDefs = {
    foo: { default: 'bar' },
    bar: { parser: parseBoolean },
  };
}
MyNewElement.register();

You can also create aliases:

import { registerDevAlias } from '@skirbi/sugar';
registerDevAlias('some-song', 'track-item', { type: 'song' });
registerDevAlias('a-book', 'your-book');

And to register them:

MyNewElement.register();

Installation

Just like any other npm package:

npm install @skirbi/sugar

It is however advised to use an override on your project for @skirbi/sugar if you either use @skirbi/semtic, @skibri/dibuho or @skirbi/bolbe. This will allow correct hoisting.

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 input and change re-emission
  • Optional attribute mirroring via data-sync
  • Proper value property handling

Contract

A subclass must:

  • Extend HTMLElementSugarInput
  • Render exactly one element matching [wc-control] (or override static 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:

  • input
  • change

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
  • value is 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 response
  • jpath-label: path for the option label
  • jpath-value: path for the option value
  • jpath-label-template: template string for label
  • jpath-value-template: template string for value

Templates take precedence over path mappings.

Example:

<my-select
  options='[
    { "id": 1, "name": "Alice", "email": "a@example.com" }
  ]'
  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-initial is enabled this behavior is negated and no initial searches are executed.
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.

Livewire

For Livewire applications using Sugar components, import the optional Livewire integration:

import '@skirbi/sugar/livewire';

This fixes a fundamental incompatibility between Livewire's morph algorithm and Web Components. Livewire compares its authored HTML against an already rendered DOM — structurally different trees cause cascade disconnects, reconnects, and focus loss on every update.

The integration hydrates Livewire's new HTML in a staging element before morphing starts, so Alpine compares two fully rendered trees instead of authored vs rendered. No cascade, no focus loss.

Import it before registering your components:

import '@skirbi/sugar/livewire';
import '@skirbi/semtic/register-semtic';
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.