@skirbi/sugar 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changes +77 -0
- package/LICENSE +18 -0
- package/README.md +454 -0
- package/lib/aliases-register.mjs +21 -0
- package/lib/aliases.mjs +49 -0
- package/lib/boolean.mjs +22 -0
- package/lib/htmlelement-input.mjs +197 -0
- package/lib/htmlelement-select.mjs +251 -0
- package/lib/htmlelement.mjs +331 -0
- package/lib/index.mjs +7 -0
- package/lib/testing.mjs +35 -0
- package/package.json +53 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import { HTMLElementSugar } from './htmlelement.mjs';
|
|
6
|
+
import { parseBoolean } from './boolean.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HTMLElementSugarInput
|
|
10
|
+
*
|
|
11
|
+
* Sugar-layer for "real" form controls rendered in the *light DOM*.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - Subclass HtmlTemplate contains exactly one element matching `controlSelector`
|
|
15
|
+
* (default: [wc-control]) which is the actual <input>/<select>/<textarea>.
|
|
16
|
+
* - Host attributes not part of the component's attributeDefs are forwarded to the
|
|
17
|
+
* control element (e.g. wire:model.*, x-model, hx-*, aria-*, data-*, etc).
|
|
18
|
+
* - Native input/change events are re-emitted from the host.
|
|
19
|
+
* - Optional attribute mirroring (MutationObserver) is enabled via boolean
|
|
20
|
+
* attribute `data-sync` (parsed by parseBoolean).
|
|
21
|
+
*/
|
|
22
|
+
export class HTMLElementSugarInput extends HTMLElementSugar {
|
|
23
|
+
|
|
24
|
+
static controlSelector = '[wc-control]';
|
|
25
|
+
static valueAttr = 'value';
|
|
26
|
+
|
|
27
|
+
// NOTE: this gets merged with subclass attributeDefs. If it doesn't, we
|
|
28
|
+
// still treat it as a reserved config key via deny-set collection.
|
|
29
|
+
static attributeDefs = {
|
|
30
|
+
'data-sync': { parser: parseBoolean, default: false },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Collect attributeDefs keys across the inheritance chain
|
|
34
|
+
static _collectAttributeDefKeys(ctor) {
|
|
35
|
+
const keys = new Set();
|
|
36
|
+
let c = ctor;
|
|
37
|
+
while (c && c !== HTMLElementSugar) {
|
|
38
|
+
const defs = c.attributeDefs;
|
|
39
|
+
if (defs && typeof defs === 'object') {
|
|
40
|
+
for (const k of Object.keys(defs)) keys.add(k);
|
|
41
|
+
}
|
|
42
|
+
c = Object.getPrototypeOf(c);
|
|
43
|
+
}
|
|
44
|
+
return keys;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deny forwarding of:
|
|
49
|
+
* - all attributeDefs keys across the class chain
|
|
50
|
+
* - plus any keys present in getConfig() (extra safety if defs are not merged)
|
|
51
|
+
*/
|
|
52
|
+
getForwardDenySet() {
|
|
53
|
+
const keys = this.constructor._collectAttributeDefKeys(this.constructor);
|
|
54
|
+
|
|
55
|
+
// If Sugar's getConfig includes parsed attrs, treat those as config too.
|
|
56
|
+
// This makes deny-set robust even if base attributeDefs aren't merged.
|
|
57
|
+
const cfg = this.getConfig?.();
|
|
58
|
+
if (cfg && typeof cfg === 'object') {
|
|
59
|
+
for (const k of Object.keys(cfg)) keys.add(k);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return keys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Which attributes should never be forwarded by default
|
|
66
|
+
getForwardSkipSet() {
|
|
67
|
+
return new Set(['id', 'class', 'style']);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether we should mirror host attributes to the control after initial
|
|
71
|
+
// render.
|
|
72
|
+
shouldMirrorAttributes() {
|
|
73
|
+
const cfg = this.getConfig?.();
|
|
74
|
+
if (cfg && cfg['data-sync'] === true) return true;
|
|
75
|
+
|
|
76
|
+
// Fallback if attributeDefs inheritance/merge doesn't include data-sync:
|
|
77
|
+
const raw = this.getAttribute('data-sync');
|
|
78
|
+
if (raw === null) return false;
|
|
79
|
+
return parseBoolean(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Find the control inside a rendered fragment.
|
|
83
|
+
findControl(frag) {
|
|
84
|
+
const sel = this.constructor.controlSelector || '[wc-control]';
|
|
85
|
+
const el = frag.querySelector(sel);
|
|
86
|
+
if (!el) {
|
|
87
|
+
const tag = this.constructor.tag || this.constructor.name;
|
|
88
|
+
throw new Error(`${tag}: template missing control ${sel}`);
|
|
89
|
+
}
|
|
90
|
+
return el;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Forward current host attributes to the control, excluding deny/skip. */
|
|
94
|
+
forwardAttrsTo(control, deny = this.getForwardDenySet(), skip = this.getForwardSkipSet()) {
|
|
95
|
+
for (const { name, value } of Array.from(this.attributes)) {
|
|
96
|
+
if (deny.has(name)) continue;
|
|
97
|
+
if (skip.has(name)) continue;
|
|
98
|
+
control.setAttribute(name, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Re-emit native events from the control on the host and keep host valueAttr
|
|
103
|
+
// in sync.
|
|
104
|
+
setupFormControl(control) {
|
|
105
|
+
const valueAttr = this.constructor.valueAttr || 'value';
|
|
106
|
+
|
|
107
|
+
const syncHostValue = () => {
|
|
108
|
+
if ('value' in control) {
|
|
109
|
+
this.setAttribute(valueAttr, control.value ?? '');
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
control.addEventListener('input', () => {
|
|
114
|
+
syncHostValue();
|
|
115
|
+
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
control.addEventListener('change', () => {
|
|
119
|
+
syncHostValue();
|
|
120
|
+
this.dispatchEvent(new Event('change', { bubbles: true,
|
|
121
|
+
composed: true }));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Start mirroring host attribute changes to the control.
|
|
126
|
+
setupAttributeMirroring(control, deny = this.getForwardDenySet(), skip = this.getForwardSkipSet()) {
|
|
127
|
+
|
|
128
|
+
// Resolve MutationObserver from the current runtime
|
|
129
|
+
// (jsdom exposes it on window, not always on globalThis)
|
|
130
|
+
const MO = globalThis.MutationObserver || globalThis.window?.MutationObserver;
|
|
131
|
+
if (!MO) {
|
|
132
|
+
// data-sync requested but MutationObserver is unavailable; no-op
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const valueAttr = this.constructor.valueAttr || 'value';
|
|
136
|
+
|
|
137
|
+
// Avoid double observers on reconnect (HTMLElementSugar encourages
|
|
138
|
+
// idempotent connectedCallback)
|
|
139
|
+
this._attrObserver?.disconnect?.();
|
|
140
|
+
|
|
141
|
+
this._attrObserver = new MO((mutations) => {
|
|
142
|
+
for (const m of mutations) {
|
|
143
|
+
if (m.type !== 'attributes') continue;
|
|
144
|
+
const name = m.attributeName;
|
|
145
|
+
if (!name) continue;
|
|
146
|
+
|
|
147
|
+
// Mirror value as a property even if it's part of component config
|
|
148
|
+
// (e.g. value is usually in attributeDefs, so it's in the deny set).
|
|
149
|
+
if (name === valueAttr && 'value' in control) {
|
|
150
|
+
const newVal = this.getAttribute(name);
|
|
151
|
+
control.value = newVal ?? '';
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (deny.has(name)) continue;
|
|
156
|
+
if (skip.has(name)) continue;
|
|
157
|
+
|
|
158
|
+
const newVal = this.getAttribute(name);
|
|
159
|
+
|
|
160
|
+
if (newVal === null) control.removeAttribute(name);
|
|
161
|
+
else control.setAttribute(name, newVal);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this._attrObserver.observe(this, { attributes: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One-liner for subclasses:
|
|
170
|
+
* - find control in fragment
|
|
171
|
+
* - forward attrs
|
|
172
|
+
* - hook events
|
|
173
|
+
* - optionally mirror attrs (data-sync)
|
|
174
|
+
*
|
|
175
|
+
* Returns the control element.
|
|
176
|
+
*/
|
|
177
|
+
enhanceControl(frag) {
|
|
178
|
+
const control = this.findControl(frag);
|
|
179
|
+
|
|
180
|
+
const deny = this.getForwardDenySet();
|
|
181
|
+
const skip = this.getForwardSkipSet();
|
|
182
|
+
|
|
183
|
+
this.forwardAttrsTo(control, deny, skip);
|
|
184
|
+
this.setupFormControl(control);
|
|
185
|
+
|
|
186
|
+
if (this.shouldMirrorAttributes()) {
|
|
187
|
+
this.setupAttributeMirroring(control, deny, skip);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return control;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
disconnectedCallback() {
|
|
194
|
+
super.disconnectedCallback?.();
|
|
195
|
+
this._attrObserver?.disconnect?.();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import { HTMLElementSugarInput } from './htmlelement-input.mjs';
|
|
6
|
+
import { parseBoolean } from './boolean.mjs';
|
|
7
|
+
|
|
8
|
+
// Parse integer attributes with a default.
|
|
9
|
+
function parseIntOr(def) {
|
|
10
|
+
return (v) => {
|
|
11
|
+
if (v == null || v === '') return def;
|
|
12
|
+
const n = Number.parseInt(String(v), 10);
|
|
13
|
+
return Number.isFinite(n) ? n : def;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Tiny dotted-path getter: "a.b.c".
|
|
18
|
+
function getByPath(obj, path) {
|
|
19
|
+
if (!path) return obj;
|
|
20
|
+
const parts = String(path).split('.').filter(Boolean);
|
|
21
|
+
let cur = obj;
|
|
22
|
+
for (const p of parts) {
|
|
23
|
+
if (cur == null) return undefined;
|
|
24
|
+
cur = cur[p];
|
|
25
|
+
}
|
|
26
|
+
return cur;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Tiny template interpolation: "{foo} ({bar})".
|
|
30
|
+
// Supports dotted paths: "{user.email}".
|
|
31
|
+
function renderTemplate(str, item) {
|
|
32
|
+
return String(str).replace(/\{([^}]+)\}/g, (_, key) => {
|
|
33
|
+
const v = getByPath(item, key.trim());
|
|
34
|
+
return v == null ? '' : String(v);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* HTMLElementSugarSelect
|
|
40
|
+
*
|
|
41
|
+
* Waya-style select authoring base.
|
|
42
|
+
*
|
|
43
|
+
* Contract:
|
|
44
|
+
* - Template must contain exactly one <select wc-control>.
|
|
45
|
+
* - Always uses a real <select> in the light DOM. Options are real <option>s.
|
|
46
|
+
* - Mapping supports jpath + label/value paths and optional templates.
|
|
47
|
+
* - Templates win over jpath-label/jpath-value (no fail-fast).
|
|
48
|
+
*
|
|
49
|
+
* Notes:
|
|
50
|
+
* - Canonical static source attribute is `options` (JSON array).
|
|
51
|
+
* - `data-options` is accepted as a compatibility alias.
|
|
52
|
+
*/
|
|
53
|
+
export class HTMLElementSugarSelect extends HTMLElementSugarInput {
|
|
54
|
+
static controlSelector = 'select[wc-control]';
|
|
55
|
+
|
|
56
|
+
static attributeDefs = {
|
|
57
|
+
// Sources
|
|
58
|
+
endpoint: { default: '' },
|
|
59
|
+
method: { default: 'GET' },
|
|
60
|
+
param: { default: 'q' },
|
|
61
|
+
|
|
62
|
+
// Static options (JSON string)
|
|
63
|
+
options: { default: '' },
|
|
64
|
+
// Compatibility alias (not canonical)
|
|
65
|
+
'data-options': { default: '' },
|
|
66
|
+
|
|
67
|
+
// Search
|
|
68
|
+
searchable: { parser: parseBoolean, default: false },
|
|
69
|
+
'min-chars': { parser: parseIntOr(1), default: 1 },
|
|
70
|
+
debounce: { parser: parseIntOr(250), default: 250 },
|
|
71
|
+
'search-min': { parser: parseIntOr(0), default: 0 },
|
|
72
|
+
'nosearch-initial': { parser: parseBoolean, default: false },
|
|
73
|
+
|
|
74
|
+
// Mapping
|
|
75
|
+
jpath: { default: '' },
|
|
76
|
+
'jpath-label': { default: '' },
|
|
77
|
+
'jpath-value': { default: '' },
|
|
78
|
+
'jpath-label-template': { default: '' },
|
|
79
|
+
'jpath-value-template': { default: '' },
|
|
80
|
+
|
|
81
|
+
// Control-ish
|
|
82
|
+
value: { default: '' },
|
|
83
|
+
placeholder: { default: '' },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Keep this self-contained (no external <template> required).
|
|
87
|
+
static HtmlTemplate = this.tpl(`
|
|
88
|
+
<div skirbi-select>
|
|
89
|
+
<input skirbi-search type="search">
|
|
90
|
+
<select wc-control></select>
|
|
91
|
+
</div>
|
|
92
|
+
`);
|
|
93
|
+
|
|
94
|
+
connectedCallback() {
|
|
95
|
+
super.connectedCallback();
|
|
96
|
+
|
|
97
|
+
if (this._rendered) return;
|
|
98
|
+
this._rendered = true;
|
|
99
|
+
|
|
100
|
+
const frag = this.renderFromTemplate();
|
|
101
|
+
const cfg = this.getConfig();
|
|
102
|
+
|
|
103
|
+
const searchEl = frag.querySelector('[skirbi-search]');
|
|
104
|
+
const select = this.enhanceControl(frag);
|
|
105
|
+
|
|
106
|
+
const hasEndpoint = !!cfg.endpoint;
|
|
107
|
+
const rawOptions = cfg.options || cfg['data-options'] || '';
|
|
108
|
+
const hasStatic = !!rawOptions;
|
|
109
|
+
|
|
110
|
+
// Only show a search box if asked and there's something to search.
|
|
111
|
+
if (!(cfg.searchable && (hasEndpoint || hasStatic))) {
|
|
112
|
+
searchEl?.remove();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Placeholder option (optional).
|
|
116
|
+
if (cfg.placeholder) this._addPlaceholder(select, cfg.placeholder);
|
|
117
|
+
|
|
118
|
+
if (hasStatic) {
|
|
119
|
+
this._setOptionsFromOptions(select, rawOptions, cfg);
|
|
120
|
+
if (searchEl) this._setupLocalSearch(select, searchEl);
|
|
121
|
+
} else if (hasEndpoint) {
|
|
122
|
+
this._setupRemote(select, searchEl, cfg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Apply initial value after options exist.
|
|
126
|
+
if (cfg.value != null && cfg.value !== '') select.value = String(cfg.value);
|
|
127
|
+
|
|
128
|
+
this.replaceChildren(frag);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_addPlaceholder(select, text) {
|
|
132
|
+
const opt = document.createElement('option');
|
|
133
|
+
opt.value = '';
|
|
134
|
+
opt.textContent = text;
|
|
135
|
+
opt.disabled = true;
|
|
136
|
+
opt.selected = true;
|
|
137
|
+
select.appendChild(opt);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_clearOptions(select) {
|
|
141
|
+
while (select.firstChild) select.removeChild(select.firstChild);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_setOptions(select, opts) {
|
|
145
|
+
this._clearOptions(select);
|
|
146
|
+
for (const o of opts) {
|
|
147
|
+
const opt = document.createElement('option');
|
|
148
|
+
opt.value = o.value == null ? '' : String(o.value);
|
|
149
|
+
opt.textContent = o.label == null ? '' : String(o.label);
|
|
150
|
+
if (o.disabled) opt.disabled = true;
|
|
151
|
+
select.appendChild(opt);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// TODO: getByPath(x, json) might want to return a console.warn when it
|
|
156
|
+
// returns undefined. This would help developers spot mistakes? Yes/no/maybe?
|
|
157
|
+
|
|
158
|
+
// Templates win over path mapping. No fail-fast on overlap.
|
|
159
|
+
_mapItemToOption(item, cfg) {
|
|
160
|
+
const lt = cfg['jpath-label-template'];
|
|
161
|
+
const vt = cfg['jpath-value-template'];
|
|
162
|
+
const lp = cfg['jpath-label'];
|
|
163
|
+
const vp = cfg['jpath-value'];
|
|
164
|
+
|
|
165
|
+
const label = lt
|
|
166
|
+
? renderTemplate(lt, item)
|
|
167
|
+
: (lp ? getByPath(item, lp) : (item?.label ?? item?.name ?? item));
|
|
168
|
+
|
|
169
|
+
const value = vt
|
|
170
|
+
? renderTemplate(vt, item)
|
|
171
|
+
: (vp ? getByPath(item, vp) : (item?.value ?? item?.id ?? item));
|
|
172
|
+
|
|
173
|
+
return { value, label };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_itemsFromJson(json, cfg) {
|
|
177
|
+
const arr = cfg.jpath ? getByPath(json, cfg.jpath) : json;
|
|
178
|
+
return Array.isArray(arr) ? arr : [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_setOptionsFromOptions(select, raw, cfg) {
|
|
182
|
+
let data;
|
|
183
|
+
try {
|
|
184
|
+
data = JSON.parse(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
console.warn(`Unable to parse JSON from ${raw}`);
|
|
187
|
+
data = [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const items = Array.isArray(data) ? data : [];
|
|
191
|
+
const opts = items.map((it) => this._mapItemToOption(it, cfg));
|
|
192
|
+
this._setOptions(select, opts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_setupLocalSearch(select, searchEl) {
|
|
196
|
+
// Filter options by label using hidden=. Keep it minimal and stock.
|
|
197
|
+
const all = Array.from(select.querySelectorAll('option'));
|
|
198
|
+
searchEl.addEventListener('input', () => {
|
|
199
|
+
const q = (searchEl.value || '').toLowerCase();
|
|
200
|
+
for (const opt of all) {
|
|
201
|
+
const label = (opt.textContent || '').toLowerCase();
|
|
202
|
+
opt.hidden = q ? !label.includes(q) : false;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_setupRemote(select, searchEl, cfg) {
|
|
208
|
+
const doFetch = async (q) => {
|
|
209
|
+
const url = new URL(cfg.endpoint, window.location.href);
|
|
210
|
+
if (q && cfg.param) url.searchParams.set(cfg.param, q);
|
|
211
|
+
|
|
212
|
+
this._abort?.abort?.();
|
|
213
|
+
this._abort = new AbortController();
|
|
214
|
+
|
|
215
|
+
const res = await fetch(url.toString(), {
|
|
216
|
+
method: cfg.method || 'GET',
|
|
217
|
+
signal: this._abort.signal,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const json = await res.json();
|
|
221
|
+
const items = this._itemsFromJson(json, cfg);
|
|
222
|
+
const opts = items.map((it) => this._mapItemToOption(it, cfg));
|
|
223
|
+
this._setOptions(select, opts);
|
|
224
|
+
|
|
225
|
+
// search-min: hide the search UI for small results.
|
|
226
|
+
if (searchEl && cfg['search-min'] > 0) {
|
|
227
|
+
searchEl.hidden = opts.length <= cfg['search-min'];
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (!searchEl) {
|
|
232
|
+
if (!cfg['nosearch-initial']) {
|
|
233
|
+
void doFetch('');
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let t = null;
|
|
239
|
+
searchEl.addEventListener('input', () => {
|
|
240
|
+
const q = searchEl.value || '';
|
|
241
|
+
if (q.length < cfg['min-chars']) return;
|
|
242
|
+
|
|
243
|
+
if (t) clearTimeout(t);
|
|
244
|
+
t = setTimeout(() => void doFetch(q), cfg.debounce);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!cfg['nosearch-initial']) {
|
|
248
|
+
void doFetch('');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|