@skirbi/sugar 0.0.26 → 0.1.1

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 CHANGED
@@ -1,5 +1,63 @@
1
1
  Revision history for @skirbi/sugar
2
2
 
3
+ 0.1.1 2026-06-04 15:34:20Z
4
+
5
+ * Added morph-* attributes to withConnectedSugar, the API of this is
6
+ unstable and should probably only be used when debugging Livewire morphing
7
+ or similar tools.
8
+
9
+ Some debugging artifacts have become morph-* attributes:
10
+
11
+ * morph-ignore — disarm the observer entirely (no-op against full node
12
+ replacement by Livewire, but prevents cascade
13
+ re-renders during normal morphing)
14
+ * morph-child — observe childList only (explicit opt-in)
15
+ * morph-attribute — observe attribute changes only
16
+ * morph-subtree — observe the full descendant subtree
17
+ * morph-debug — Enable debuging used by `_armMorphObserver`.
18
+
19
+ When none are present, the existing default (childList: true) applies.
20
+
21
+ * Add `withMorphObserverPaused(fn)` to withConnectedSugar.
22
+ Components that mutate their own children in morphedCallbackSugar or
23
+ connectedCallbackSugar risk triggering their own MutationObserver,
24
+ causing infinite loops.
25
+
26
+ `withMorphObserverPaused(fn)` disconnects the observer, runs `fn`, then
27
+ reconnects with the original options. It is a public API intended for
28
+ use by component authors.
29
+
30
+ Observer options are now stored as `_morphObserveOptions` when the
31
+ observer is armed, so they can be reused by withMorphObserverPaused
32
+ and any future callers without re-deriving them.
33
+
34
+ This again is a WIP feature that may change based on Livewire morphing
35
+ insights.
36
+
37
+
38
+ 0.1.0 2026-06-04 01:20:59Z
39
+
40
+ * BREAKING: Sugar components may no longer manually define
41
+ observedAttributes, attributeMap, or defaultConfig. Use attributeDefs
42
+ instead.
43
+
44
+ attributeDefs is now the only sugar attribute declaration API. The native
45
+ fields are derived by sugar so inheritance chains can work without static
46
+ generated state poisoning subclasses.
47
+
48
+ And because this is a rather big shift, we bump the minor. We had all the
49
+ letters of the alphabet ;)
50
+
51
+ * Add label position to (form)-inputs, this allows for a better DX I think.
52
+ It's small utility that allows the CSS to be predefined, easy recipe
53
+ handling without too much trouble. For those who want to override it, pick
54
+ `left`.
55
+
56
+ * Add error label position to (form)-inputs, this allows for a better DX I
57
+ think. It's small utility that allows the CSS to be predefined, easy recipe
58
+ handling without too much trouble. For those who want to override it, pick
59
+ `left`.
60
+
3
61
  0.0.26 2026-05-30 00:49:13Z
4
62
 
5
63
  * Introduce morphedCallbackSugar. Morphing is a delicate matter for
@@ -24,42 +24,38 @@ export class HTMLElementSugarInput extends HTMLElementSugar {
24
24
  static controlSelector = '[wc-control]';
25
25
  static valueAttr = 'value';
26
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
27
  /**
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)
28
+ * Attributes owned by HTMLElementSugarInput itself.
29
+ *
30
+ * These are host-level behavior/layout flags. They are intentionally not part
31
+ * of `attributeDefs`, because subclasses define their own complete Sugar
32
+ * attribute contract and `attributeDefs` shadows through inheritance.
51
33
  */
52
- getForwardDenySet() {
53
- const keys = this.constructor._collectAttributeDefKeys(this.constructor);
34
+ static inputReservedAttributes = [
35
+ 'data-sync',
36
+ 'label-position',
37
+ 'label-error-position',
38
+ ];
54
39
 
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
- }
40
+ /**
41
+ * Valid label positions for controls that choose to use the shared helper.
42
+ * Components may override this, e.g. a pill toggle may add `inside`.
43
+ */
44
+ static labelPositions = ['top', 'right', 'bottom', 'left'];
45
+ static defaultLabelPosition = 'left';
46
+ static labelErrorPositions = ['top', 'right', 'bottom', 'left', 'inline'];
47
+ static defaultLabelErrorPosition = 'bottom';
61
48
 
62
- return keys;
49
+ /**
50
+ * Deny forwarding of component config attributes and SugarInput-reserved host
51
+ * attributes. Do not walk the inheritance chain here: `attributeDefs` follows
52
+ * normal JavaScript static lookup/shadowing.
53
+ */
54
+ getForwardDenySet() {
55
+ return new Set([
56
+ ...Object.keys(this.constructor.attributeDefs ?? {}),
57
+ ...(this.constructor.inputReservedAttributes ?? []),
58
+ ]);
63
59
  }
64
60
 
65
61
  // Which attributes should never be forwarded by default
@@ -70,15 +66,71 @@ export class HTMLElementSugarInput extends HTMLElementSugar {
70
66
  // Whether we should mirror host attributes to the control after initial
71
67
  // render.
72
68
  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
69
  const raw = this.getAttribute('data-sync');
78
70
  if (raw === null) return false;
79
71
  return parseBoolean(raw);
80
72
  }
81
73
 
74
+ /**
75
+ * Resolve the effective label position for form-control components.
76
+ *
77
+ * This helper does not render anything by itself. Components that expose a
78
+ * label can call it and mirror the result to the host so Pinta can style the
79
+ * control layout with simple attribute selectors.
80
+ *
81
+ * @param {string} [defaultPosition]
82
+ * @returns {string}
83
+ */
84
+ getLabelPosition(defaultPosition = this.constructor.defaultLabelPosition || 'left') {
85
+ const raw = this.getAttribute('label-position') || defaultPosition;
86
+ const allowed = this.constructor.labelPositions ?? ['top', 'right', 'bottom', 'left'];
87
+
88
+ if (!allowed.includes(raw)) {
89
+ const tag = this.constructor.tag || this.constructor.name;
90
+ throw new Error(
91
+ `${tag}: invalid label-position "${raw}"; expected one of ${allowed.join(', ')}`
92
+ );
93
+ }
94
+
95
+ return raw;
96
+ }
97
+
98
+ /**
99
+ * Set the effective `label-position` on the host when it is omitted.
100
+ *
101
+ * @param {string} [defaultPosition]
102
+ * @returns {string}
103
+ */
104
+ syncLabelPosition(defaultPosition = this.constructor.defaultLabelPosition || 'left') {
105
+ const position = this.getLabelPosition(defaultPosition);
106
+ if (!this.hasAttribute('label-position')) {
107
+ this.setAttribute('label-position', position);
108
+ }
109
+ return position;
110
+ }
111
+
112
+ getLabelErrorPosition(defaultPosition = this.constructor.defaultLabelErrorPosition || 'bottom') {
113
+ const raw = this.getAttribute('label-error-position') || defaultPosition;
114
+ const allowed = this.constructor.labelErrorPositions ?? ['top', 'right', 'bottom', 'left', 'inline'];
115
+
116
+ if (!allowed.includes(raw)) {
117
+ const tag = this.constructor.tag || this.constructor.name;
118
+ throw new Error(
119
+ `${tag}: invalid label-error-position "${raw}"; expected one of ${allowed.join(', ')}`
120
+ );
121
+ }
122
+
123
+ return raw;
124
+ }
125
+
126
+ syncLabelErrorPosition(defaultPosition = this.constructor.defaultLabelErrorPosition || 'bottom') {
127
+ const position = this.getLabelErrorPosition(defaultPosition);
128
+ if (!this.hasAttribute('label-error-position')) {
129
+ this.setAttribute('label-error-position', position);
130
+ }
131
+ return position;
132
+ }
133
+
82
134
  // Find the control inside a rendered fragment.
83
135
  findControl(frag) {
84
136
  const sel = this.constructor.controlSelector || '[wc-control]';
@@ -180,6 +232,9 @@ export class HTMLElementSugarInput extends HTMLElementSugar {
180
232
  const deny = this.getForwardDenySet();
181
233
  const skip = this.getForwardSkipSet();
182
234
 
235
+ this.syncLabelPosition();
236
+ this.syncLabelErrorPosition();
237
+
183
238
  this.forwardAttrsTo(control, deny, skip);
184
239
  this.setupFormControl(control);
185
240
 
@@ -6,16 +6,16 @@
6
6
  * Lightweight base class for authoring Web Components.
7
7
  *
8
8
  * Provides:
9
- * - Declarative attribute -> config mapping via static `attributeMap`
10
- * - Automatic attribute observation via `observedAttributes`
9
+ * - Declarative attribute -> config mapping via static `attributeDefs`
10
+ * - Automatic native attribute observation derived from `attributeDefs`
11
11
  * - Utility methods to register the element and define aliases
12
12
  *
13
13
  * @example
14
14
  * class MyComponent extends HTMLElementSugar {
15
15
  * static tag = 'opndev-my-component';
16
- * static observedAttributes = ['foo'];
17
- * static attributeMap = { foo: parseBoolean };
18
- * static defaultConfig = { foo: 'bar' };
16
+ * static attributeDefs = {
17
+ * foo: { default: 'bar', parser: parseBoolean },
18
+ * };
19
19
  * }
20
20
  *
21
21
  * MyComponent.register();
@@ -25,6 +25,13 @@
25
25
  import { defined } from '@opndev/util/defined';
26
26
  import { registerDevAlias } from './aliases.mjs';
27
27
 
28
+ const attributeMetaCache = new WeakMap();
29
+ const sugarOwnedAttributeKeys = [
30
+ 'observedAttributes',
31
+ 'attributeMap',
32
+ 'defaultConfig',
33
+ ];
34
+
28
35
  export class HTMLElementSugar extends HTMLElement {
29
36
 
30
37
  static renderGuardSelector = '';
@@ -40,20 +47,21 @@ export class HTMLElementSugar extends HTMLElement {
40
47
  }
41
48
 
42
49
  /**
43
- * Sets up the element and transforms `attributeDefs` to
44
- * `observedAttributes`, `attributeMap` and `defaultConfig`. Calling this
45
- * multiple times on the component is ok, but discouraged.
50
+ * Sets up the element tag and template contract. Attribute metadata is
51
+ * derived lazily from `attributeDefs` and cached per constructor.
52
+ * Calling this multiple times on the same component is safe.
46
53
  */
47
54
  static init() {
55
+ if (Object.hasOwn(this, '_sugarInitialized')) return;
48
56
 
49
57
  this.checkTag();
50
-
51
- if (!Object.hasOwn(this, '_sugarInitialized')) {
52
- this.checkAttributes();
53
- this._sugarInitialized = true;
54
- }
55
-
58
+ this.checkAttributes();
56
59
  this.checkTemplate();
60
+
61
+ Object.defineProperty(this, '_sugarInitialized', {
62
+ value: true,
63
+ configurable: false,
64
+ });
57
65
  }
58
66
 
59
67
  static deriveTagFromClass() {
@@ -69,66 +77,132 @@ export class HTMLElementSugar extends HTMLElement {
69
77
  }
70
78
  }
71
79
 
80
+ /**
81
+ * Native attribute APIs are owned by Sugar. Component authors should define
82
+ * `attributeDefs`; Sugar derives `observedAttributes`, `attributeMap`, and
83
+ * `defaultConfig` from it.
84
+ */
72
85
  static checkAttributes() {
73
- const hasDefs = !!this.attributeDefs;
74
- const hasManualObserved = !!this.observedAttributes;
75
- const hasManualMap = !!this.attributeMap;
76
- const hasManualDefaults = !!this.defaultConfig;
77
-
78
- if (!hasDefs) {
79
- if (!hasManualObserved) this.observedAttributes = [];
80
- if (!hasManualMap) this.attributeMap = {};
81
- if (!hasManualDefaults) this.defaultConfig = {};
82
- return;
86
+ for (const key of sugarOwnedAttributeKeys) {
87
+ const owner = this._findOwnStaticInChain(key);
88
+
89
+ if (owner && owner !== HTMLElementSugar) {
90
+ throw new Error(
91
+ `${this.name}: do not define static ${key}; use static attributeDefs`
92
+ );
93
+ }
83
94
  }
84
95
 
85
- const hasManual = hasManualObserved || hasManualMap || hasManualDefaults;
96
+ this._sugarAttributeMeta();
97
+ }
98
+
99
+ /**
100
+ * Find the constructor in the inheritance chain that owns a static property.
101
+ *
102
+ * @param {string} key - Static property name to find.
103
+ * @returns {Function|null}
104
+ */
105
+ static _findOwnStaticInChain(key) {
106
+ let klass = this;
86
107
 
87
- if (hasDefs && hasManual) {
88
- throw new Error(
89
- `${this.name}: Do not mix 'attributeDefs' with manual fields: ` +
90
- `observedAttributes, attributeMap, defaultConfig`
91
- );
108
+ while (klass && klass !== Function.prototype) {
109
+ if (Object.hasOwn(klass, key)) return klass;
110
+ klass = Object.getPrototypeOf(klass);
92
111
  }
93
112
 
94
- const defs = this.attributeDefs;
113
+ return null;
114
+ }
95
115
 
96
- this.observedAttributes = [];
97
- this.attributeMap = {};
98
- this.defaultConfig = {};
116
+ /**
117
+ * Native Custom Elements bridge. The browser reads this during
118
+ * `customElements.define()`.
119
+ *
120
+ * @returns {string[]}
121
+ */
122
+ static get observedAttributes() {
123
+ return this._sugarAttributeMeta().observedAttributes;
124
+ }
125
+
126
+ /**
127
+ * Attribute parser map derived from `attributeDefs`.
128
+ *
129
+ * @returns {Record<string, Function>}
130
+ */
131
+ static get attributeMap() {
132
+ return this._sugarAttributeMeta().attributeMap;
133
+ }
134
+
135
+ /**
136
+ * Default config derived from `attributeDefs`.
137
+ *
138
+ * @returns {Record<string, any>}
139
+ */
140
+ static get defaultConfig() {
141
+ return this._sugarAttributeMeta().defaultConfig;
142
+ }
143
+
144
+ /**
145
+ * Return compiled attribute metadata for this constructor.
146
+ * `attributeDefs` follows normal JavaScript static inheritance: inherited by
147
+ * lookup, shadowed by subclasses, and never merged by Sugar.
148
+ *
149
+ * @returns {{observedAttributes: string[], attributeMap: Record<string, Function>, defaultConfig: Record<string, any>}}
150
+ */
151
+ static _sugarAttributeMeta() {
152
+ if (attributeMetaCache.has(this)) {
153
+ return attributeMetaCache.get(this);
154
+ }
155
+
156
+ const meta = this._compileAttributeDefs(this.attributeDefs ?? {});
157
+ attributeMetaCache.set(this, meta);
158
+ return meta;
159
+ }
160
+
161
+ /**
162
+ * Compile `attributeDefs` into the native/Sugar attribute metadata.
163
+ *
164
+ * @param {Record<string, object|null|undefined>} defs
165
+ * @returns {{observedAttributes: string[], attributeMap: Record<string, Function>, defaultConfig: Record<string, any>}}
166
+ */
167
+ static _compileAttributeDefs(defs) {
168
+ if (Array.isArray(defs) || typeof defs !== 'object') {
169
+ throw new Error(`${this.name}: static attributeDefs must be an object`);
170
+ }
171
+
172
+ const observedAttributes = [];
173
+ const attributeMap = {};
174
+ const defaultConfig = {};
99
175
 
100
176
  for (const [key, spec] of Object.entries(defs)) {
101
177
  if (!defined(spec)) {
102
- this.observedAttributes.push(key);
178
+ observedAttributes.push(key);
103
179
  continue;
104
180
  }
105
181
 
106
- if (Array.isArray(spec)|| typeof spec !== 'object') {
182
+ if (Array.isArray(spec) || typeof spec !== 'object') {
107
183
  throw new Error(
108
184
  `${this.name}: 'attributeDefs' for key ${key} has an invalid spec`
109
185
  );
110
186
  }
111
187
 
112
- if (!('observed' in spec) || spec.observed)
113
- this.observedAttributes.push(key);
188
+ if (!('observed' in spec) || spec.observed) {
189
+ observedAttributes.push(key);
190
+ }
114
191
 
115
- if ('default' in spec && defined(spec.default))
116
- this.defaultConfig[key] = spec.default;
192
+ if ('default' in spec && defined(spec.default)) {
193
+ defaultConfig[key] = spec.default;
194
+ }
117
195
 
118
196
  if ('parser' in spec && defined(spec.parser)) {
119
- if (typeof spec.parser !== 'function')
197
+ if (typeof spec.parser !== 'function') {
120
198
  throw new Error(`${this.name}: parser for ${key} must be a function`);
199
+ }
121
200
 
122
- this.attributeMap[key] = spec.parser;
201
+ attributeMap[key] = spec.parser;
123
202
  }
124
-
125
203
  }
126
- // This delete is intentional. We cannot keep it because it would otherwise
127
- // break any subsequent init()/register() calls. For those coming from Perl
128
- // this is a JS workaround for a Moose initarg. We essentially forbid both
129
- // attributeDefs and formal HTMLElement
130
- // observedAttributes/defaultConfig/attributeMap to coexist
131
- delete this.attributeDefs;
204
+
205
+ return { observedAttributes, attributeMap, defaultConfig };
132
206
  }
133
207
 
134
208
  static checkTemplate() {
package/lib/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
4
 
5
- const VERSION = "0.0.26";
5
+ const VERSION = "0.1.1";
6
6
 
7
7
  export { HTMLElementSugar } from './htmlelement.mjs';
8
8
  export { HTMLElementSugarInput } from './htmlelement-input.mjs';
@@ -27,19 +27,51 @@ export const withConnectedSugar = (Base) =>
27
27
 
28
28
  _armMorphObserver() {
29
29
  if (this._sugarMo) return;
30
+ if (this.hasAttribute('morph-ignore')) return;
30
31
 
31
- this._sugarMo = new MutationObserver(() => {
32
- if (this._shouldRenderOnMorph()) {
33
- this.connectedCallbackSugar();
34
- return;
35
- }
36
- if (this.hasRenderedShape() && this.morphedCallbackSugar) {
37
- this.morphedCallbackSugar();
38
- return;
32
+ const hasExplicit = this.hasAttribute('morph-child') ||
33
+ this.hasAttribute('morph-attribute') ||
34
+ this.hasAttribute('morph-subtree');
35
+
36
+ this._morphObserveOptions = hasExplicit ? {
37
+ childList: this.hasAttribute('morph-child'),
38
+ attributes: this.hasAttribute('morph-attribute'),
39
+ subtree: this.hasAttribute('morph-subtree'),
40
+ } : {
41
+ childList: true,
42
+ };
43
+
44
+ this._sugarMo = new MutationObserver((mutations) => {
45
+
46
+ if (this.hasAttribute('morph-debug')) {
47
+ console.group(`[sugar-morph] ${this.tagName}${this.id ? '#' + this.id : ''}${this.getAttribute('label') ? `[label="${this.getAttribute('label')}"]` : ''}${this.getAttribute('title') ? `[title="${this.getAttribute('title')}"]` : ''}`);
48
+ console.log('parent:', this.parentElement?.tagName);
49
+ console.log('mutations:', mutations.map(m => ({
50
+ type: m.type,
51
+ added: Array.from(m.addedNodes).map(n => n.nodeName),
52
+ removed: Array.from(m.removedNodes).map(n => n.nodeName)
53
+ })));
54
+ console.log('hasRenderedShape:', this.hasRenderedShape());
55
+ console.log('shouldRenderOnMorph:', this._shouldRenderOnMorph());
56
+ setTimeout(() => console.log('innerHTML after:', this.innerHTML), 0);
57
+ console.groupEnd();
39
58
  }
59
+
60
+
61
+ queueMicrotask(() => {
62
+ if (this._shouldRenderOnMorph()) {
63
+ this.connectedCallbackSugar();
64
+ return;
65
+ }
66
+ if (this.hasRenderedShape() && this.morphedCallbackSugar) {
67
+ this.morphedCallbackSugar();
68
+ return;
69
+ }
70
+ });
71
+
40
72
  });
41
73
 
42
- this._sugarMo.observe(this, { childList: true });
74
+ this._sugarMo.observe(this, this._morphObserveOptions);
43
75
  }
44
76
 
45
77
  _shouldRenderOnMorph() {
@@ -58,6 +90,17 @@ export const withConnectedSugar = (Base) =>
58
90
  return true;
59
91
  }
60
92
 
93
+ withMorphObserverPaused(fn) {
94
+ this._sugarMo?.disconnect();
95
+ try {
96
+ fn();
97
+ } finally {
98
+ if (this._morphObserveOptions) {
99
+ this._sugarMo?.observe(this, this._morphObserveOptions);
100
+ }
101
+ }
102
+ }
103
+
61
104
  disconnectedCallback() {
62
105
  this._sugarMo?.disconnect();
63
106
  this._sugarMo = null;
package/package.json CHANGED
@@ -52,5 +52,5 @@
52
52
  },
53
53
  "sideEffects": false,
54
54
  "type": "module",
55
- "version": "0.0.26"
55
+ "version": "0.1.1"
56
56
  }