npm package: @skirbi/sugar
Description: Lightweight base layer for writing custom elements with declarative attributes and template sugar.
Author: Wesley Schwengle
Latest version: 0.0.11

<!--
SPDX-FileCopyrightText: 2025, 2026 Wesley Schwengle <wesleys@opperschaap.net>

SPDX-License-Identifier: MIT
-->

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

```javascript
class MyNewElement extends HTMLElementSugar {
  static tag = 'my-new-element';
  static attributeDefs = {
    foo: { default: 'bar' },
    bar: { parser: parseBoolean },
  };
}
```

You can also create aliases:

```javascript
TrackItem.alias('some-song', { type: 'song' });
YourBook.alias('a-book');
```

And to register them:

```javascript
TrackItem.register();
YourBook.register();
```

## Observable patterns

You can define attributes to be observable:

```javascript
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

```javascript
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

```javascript
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

```javascript
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:

```javascript
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

```javascript
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)

```javascript
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:

```javascript
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:

```html
<my-input
  label="Name"
  wire:model.defer="name"
  aria-label="Name"
></my-input>
```

Results in:

```html
<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:

```html
<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:

```javascript
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:

```html
<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:

```html
<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:

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


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