@skirbi/sugar 0.0.26 → 0.1.0
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 +23 -0
- package/lib/htmlelement-input.mjs +91 -36
- package/lib/htmlelement.mjs +123 -49
- package/lib/index.mjs +1 -1
- package/package.json +1 -1
package/Changes
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
Revision history for @skirbi/sugar
|
|
2
2
|
|
|
3
|
+
0.1.0 2026-06-04 01:20:59Z
|
|
4
|
+
|
|
5
|
+
* BREAKING: Sugar components may no longer manually define
|
|
6
|
+
observedAttributes, attributeMap, or defaultConfig. Use attributeDefs
|
|
7
|
+
instead.
|
|
8
|
+
|
|
9
|
+
attributeDefs is now the only sugar attribute declaration API. The native
|
|
10
|
+
fields are derived by sugar so inheritance chains can work without static
|
|
11
|
+
generated state poisoning subclasses.
|
|
12
|
+
|
|
13
|
+
And because this is a rather big shift, we bump the minor. We had all the
|
|
14
|
+
letters of the alphabet ;)
|
|
15
|
+
|
|
16
|
+
* Add label position to (form)-inputs, this allows for a better DX I think.
|
|
17
|
+
It's small utility that allows the CSS to be predefined, easy recipe
|
|
18
|
+
handling without too much trouble. For those who want to override it, pick
|
|
19
|
+
`left`.
|
|
20
|
+
|
|
21
|
+
* Add error label position to (form)-inputs, this allows for a better DX I
|
|
22
|
+
think. It's small utility that allows the CSS to be predefined, easy recipe
|
|
23
|
+
handling without too much trouble. For those who want to override it, pick
|
|
24
|
+
`left`.
|
|
25
|
+
|
|
3
26
|
0.0.26 2026-05-30 00:49:13Z
|
|
4
27
|
|
|
5
28
|
* 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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
34
|
+
static inputReservedAttributes = [
|
|
35
|
+
'data-sync',
|
|
36
|
+
'label-position',
|
|
37
|
+
'label-error-position',
|
|
38
|
+
];
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/htmlelement.mjs
CHANGED
|
@@ -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 `
|
|
10
|
-
* - Automatic attribute observation
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
44
|
-
*
|
|
45
|
-
* multiple times on the component is
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
+
if (!('observed' in spec) || spec.observed) {
|
|
189
|
+
observedAttributes.push(key);
|
|
190
|
+
}
|
|
114
191
|
|
|
115
|
-
if ('default' in spec && defined(spec.default))
|
|
116
|
-
|
|
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
|
-
|
|
201
|
+
attributeMap[key] = spec.parser;
|
|
123
202
|
}
|
|
124
|
-
|
|
125
203
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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
package/package.json
CHANGED