@jungherz-de/glasskit-elements 1.21.2 → 1.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.21.2",
3
+ "version": "1.22.0",
4
4
  "description": "Vanilla JavaScript Web Components wrapping the GlassKit CSS glassmorphism library",
5
5
  "license": "MIT",
6
6
  "homepage": "https://glasskit-elements.jungherz.com",
@@ -49,10 +49,10 @@
49
49
  "vanilla-js"
50
50
  ],
51
51
  "peerDependencies": {
52
- "@jungherz-de/glasskit": ">=1.21.2"
52
+ "@jungherz-de/glasskit": ">=1.22.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@jungherz-de/glasskit": "^1.21.2",
55
+ "@jungherz-de/glasskit": "^1.22.0",
56
56
  "@rollup/plugin-node-resolve": "^15.0.0",
57
57
  "@rollup/plugin-terser": "^0.4.0",
58
58
  "rollup": "^4.0.0"
package/src/base.js CHANGED
@@ -225,6 +225,9 @@ export class GlkElement extends HTMLElement {
225
225
 
226
226
  connectedCallback() {
227
227
  if (!this._initialized) {
228
+ // Before _initialized: setters that set attributes must not reach
229
+ // onAttributeChanged yet — render() reads the attributes anyway.
230
+ this._upgradeProperties();
228
231
  this._initialized = true;
229
232
 
230
233
  // Create theme wrapper (display:contents makes it layout-transparent)
@@ -259,6 +262,30 @@ export class GlkElement extends HTMLElement {
259
262
  }
260
263
  }
261
264
 
265
+ /**
266
+ * A property set on the element before its class was defined — markup a
267
+ * framework fills before the bundle has loaded — lands on the instance
268
+ * and hides the class's accessor: the setter never ran, and the value was
269
+ * lost (a glk-select kept its first option, a glk-progress stayed at 0).
270
+ * Such a value is taken off the instance and set again through the
271
+ * setter, which keeps what it cannot apply before render().
272
+ */
273
+ _upgradeProperties() {
274
+ for (const key of Object.keys(this)) {
275
+ let proto = Object.getPrototypeOf(this);
276
+ let setter = null;
277
+ while (proto && proto !== HTMLElement.prototype) {
278
+ const desc = Object.getOwnPropertyDescriptor(proto, key);
279
+ if (desc) { setter = desc.set; break; }
280
+ proto = Object.getPrototypeOf(proto);
281
+ }
282
+ if (!setter) continue;
283
+ const value = this[key];
284
+ delete this[key];
285
+ this[key] = value;
286
+ }
287
+ }
288
+
262
289
  disconnectedCallback() {
263
290
  instances.delete(this);
264
291
  this._lightDomObserver?.disconnect();
@@ -92,11 +92,12 @@ class GlkCheckbox extends GlkFormElement {
92
92
  this._syncFormValue();
93
93
  }
94
94
 
95
- get checked() { return this._input?.checked ?? false; }
95
+ // Before render() the attribute carries the state: render() reads it.
96
+ get checked() { return this._input ? this._input.checked : this.getBoolAttr('checked'); }
96
97
  set checked(v) {
97
98
  if (this._input) this._input.checked = v;
98
99
  this.setBoolAttr('checked', v);
99
- this._syncFormValue();
100
+ if (this._input) this._syncFormValue();
100
101
  }
101
102
 
102
103
  get disabled() { return this.getBoolAttr('disabled'); }
@@ -6,11 +6,26 @@ import { GlkFormElement } from '../../base.js';
6
6
  // 1.17.0 — before, a date field with min still offered every past day).
7
7
  const FORWARDED = ['min', 'max', 'step', 'minlength', 'maxlength', 'pattern', 'autocomplete', 'inputmode'];
8
8
 
9
+ // Prefix and suffix (since 1.22.0): two slots inside the field's box, on
10
+ // GlassKit's .glass-input-wrap. An affix shows only while something is
11
+ // slotted into it, and its width is measured, so the text keeps clear of a
12
+ // unit of any length. GlassKit lets clicks through an affix to the field;
13
+ // slotted elements take them here instead — a button or a link inside
14
+ // works — and a click on slotted text or an icon still lands in the field,
15
+ // because the shadow root delegates focus to it.
16
+ const affixSheet = new CSSStyleSheet();
17
+ affixSheet.replaceSync(`
18
+ .glass-input-wrap__prefix ::slotted(*),
19
+ .glass-input-wrap__suffix ::slotted(*) { pointer-events: auto; }
20
+ `);
21
+
9
22
  class GlkInput extends GlkFormElement {
10
23
  static get observedAttributes() {
11
- return ['label', 'type', 'placeholder', 'error', 'hint', 'disabled', 'name', 'value', 'required', ...FORWARDED];
24
+ return ['label', 'type', 'placeholder', 'error', 'hint', 'disabled', 'readonly', 'name', 'value', 'required', ...FORWARDED];
12
25
  }
13
26
 
27
+ static get hostStyles() { return affixSheet; }
28
+
14
29
  render() {
15
30
  const group = this.createElement('div', ['glass-input-group']);
16
31
 
@@ -32,19 +47,28 @@ class GlkInput extends GlkFormElement {
32
47
  const name = this.getAttribute('name');
33
48
  if (name) this._input.setAttribute('name', name);
34
49
 
35
- const value = this.getAttribute('value');
50
+ // A value set as a property before render() has waited for it.
51
+ const value = this._pendingValue ?? this.getAttribute('value');
52
+ this._pendingValue = undefined;
36
53
  if (value) this._input.value = value;
37
54
 
38
55
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
56
+ if (this.getBoolAttr('readonly')) this._input.readOnly = true;
39
57
  if (this.getBoolAttr('required')) this._input.required = true;
40
58
  for (const attr of FORWARDED) this._forward(attr);
41
59
 
60
+ // The field's box: the input, a prefix and a suffix slot on top of it
61
+ this._box = this.createElement('div', ['glass-input-wrap'], { part: 'box' });
62
+ this._prefix = this._createAffix('prefix');
63
+ this._suffix = this._createAffix('suffix');
64
+ this._box.append(this._prefix, this._input, this._suffix);
65
+
42
66
  // Hint — read out with the field as its description
43
67
  this._hintEl = this.createElement('span', this._computeHintClasses(), { id: 'hint' });
44
68
  this._hintEl.textContent = this.getAttribute('hint') || '';
45
69
 
46
70
  group.appendChild(this._labelEl);
47
- group.appendChild(this._input);
71
+ group.appendChild(this._box);
48
72
  if (this.getAttribute('hint')) group.appendChild(this._hintEl);
49
73
 
50
74
  this._group = group;
@@ -54,6 +78,12 @@ class GlkInput extends GlkFormElement {
54
78
  this._syncFormValue();
55
79
  }
56
80
 
81
+ _createAffix(name) {
82
+ const affix = this.createElement('span', [`glass-input-wrap__${name}`], { id: name, part: name, hidden: '' });
83
+ affix.appendChild(this.createElement('slot', [], { name }));
84
+ return affix;
85
+ }
86
+
57
87
  setupEvents() {
58
88
  this._onInput = () => {
59
89
  this._syncFormValue();
@@ -66,11 +96,26 @@ class GlkInput extends GlkFormElement {
66
96
  };
67
97
  this._input.addEventListener('input', this._onInput);
68
98
  this._input.addEventListener('change', this._onChangeNative);
99
+
100
+ // Affixes: shown while something is slotted, measured while shown.
101
+ this._onSlotChange = () => this._applyAffixes();
102
+ this._box.addEventListener('slotchange', this._onSlotChange);
103
+ this._affixObserver = new ResizeObserver(entries => {
104
+ for (const entry of entries) {
105
+ const size = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
106
+ this._box.style.setProperty(`--gl-input-${entry.target.id}-size`, `${size}px`);
107
+ }
108
+ });
109
+ this._affixObserver.observe(this._prefix);
110
+ this._affixObserver.observe(this._suffix);
111
+ this._applyAffixes();
69
112
  }
70
113
 
71
114
  teardownEvents() {
72
115
  this._input?.removeEventListener('input', this._onInput);
73
116
  this._input?.removeEventListener('change', this._onChangeNative);
117
+ this._box?.removeEventListener('slotchange', this._onSlotChange);
118
+ this._affixObserver?.disconnect();
74
119
  }
75
120
 
76
121
  onAttributeChanged(name) {
@@ -100,6 +145,9 @@ class GlkInput extends GlkFormElement {
100
145
  case 'disabled':
101
146
  this._input.disabled = this.getBoolAttr('disabled');
102
147
  break;
148
+ case 'readonly':
149
+ this._input.readOnly = this.getBoolAttr('readonly');
150
+ break;
103
151
  case 'name':
104
152
  this._input.setAttribute('name', this.getAttribute('name') || '');
105
153
  break;
@@ -115,14 +163,27 @@ class GlkInput extends GlkFormElement {
115
163
  }
116
164
  }
117
165
 
166
+ /** An affix shows, and makes room in the field, only while something is slotted. */
167
+ _applyAffixes() {
168
+ for (const affix of [this._prefix, this._suffix]) {
169
+ const filled = affix.firstElementChild.assignedNodes().length > 0;
170
+ affix.hidden = !filled;
171
+ this._box.classList.toggle(`glass-input-wrap--${affix.id}`, filled);
172
+ }
173
+ this._applyDescription();
174
+ }
175
+
118
176
  /**
119
- * The hint describes the field; with `error` set it is the error message
120
- * and the field is marked invalid. Server-side errors arrive this way, so
121
- * `error` stays a marker for assistive technology and does not block the
122
- * form — only the field's own constraints (required, pattern …) do.
177
+ * Prefix, suffix and hint describe the field — a unit belongs to the value
178
+ * it stands next to. With `error` set the hint is the error message and the
179
+ * field is marked invalid. Server-side errors arrive this way, so `error`
180
+ * stays a marker for assistive technology and does not block the form —
181
+ * only the field's own constraints (required, pattern …) do.
123
182
  */
124
183
  _applyDescription() {
125
- if (this.getAttribute('hint')) this._input.setAttribute('aria-describedby', 'hint');
184
+ const ids = [this._prefix, this._suffix].filter(affix => affix && !affix.hidden).map(affix => affix.id);
185
+ if (this.getAttribute('hint')) ids.push('hint');
186
+ if (ids.length) this._input.setAttribute('aria-describedby', ids.join(' '));
126
187
  else this._input.removeAttribute('aria-describedby');
127
188
  if (this.getBoolAttr('error')) this._input.setAttribute('aria-invalid', 'true');
128
189
  else this._input.removeAttribute('aria-invalid');
@@ -157,15 +218,22 @@ class GlkInput extends GlkFormElement {
157
218
  this._syncFormValue();
158
219
  }
159
220
 
160
- get value() { return this._input?.value ?? ''; }
221
+ get value() { return this._input ? this._input.value : (this._pendingValue ?? this.getAttribute('value') ?? ''); }
161
222
  set value(v) {
162
- if (this._input) this._input.value = v;
223
+ const value = v == null ? '' : String(v);
224
+ // Frameworks set properties right after creating the element. Before
225
+ // render() the value waits for it — it used to throw.
226
+ if (!this._input) { this._pendingValue = value; return; }
227
+ this._input.value = value;
163
228
  this._syncFormValue();
164
229
  }
165
230
 
166
231
  get disabled() { return this.getBoolAttr('disabled'); }
167
232
  set disabled(v) { this.setBoolAttr('disabled', v); }
168
233
 
234
+ get readOnly() { return this.getBoolAttr('readonly'); }
235
+ set readOnly(v) { this.setBoolAttr('readonly', v); }
236
+
169
237
  get name() { return this.getAttribute('name'); }
170
238
  set name(v) { this.setAttribute('name', v); }
171
239
  }
@@ -189,14 +189,15 @@ class GlkRadio extends GlkFormElement {
189
189
  syncGroupTabIndex(this._group());
190
190
  }
191
191
 
192
- get checked() { return this._input?.checked ?? false; }
192
+ // Before render() the attribute carries the state: render() reads it.
193
+ get checked() { return this._input ? this._input.checked : this.getBoolAttr('checked'); }
193
194
  set checked(v) {
194
195
  if (this._input) this._input.checked = v;
195
196
  // Also covers the case where the attribute is already present, so
196
197
  // setBoolAttr stays silent and onAttributeChanged never runs.
197
198
  if (v) this._uncheckPeers();
198
199
  this.setBoolAttr('checked', v);
199
- this._syncFormValue();
200
+ if (this._input) this._syncFormValue();
200
201
  syncGroupTabIndex(this._group());
201
202
  }
202
203
 
@@ -35,13 +35,17 @@ class GlkRange extends GlkFormElement {
35
35
 
36
36
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
37
37
 
38
+ // The reset value is the attribute's; a value set as a property before
39
+ // render() has waited for it and comes on top.
40
+ this._defaultValue = this._input.value;
41
+ if (this._pendingValue !== undefined) this._input.value = this._pendingValue;
42
+ this._pendingValue = undefined;
38
43
  this._updateValueDisplay();
39
44
 
40
45
  group.appendChild(header);
41
46
  group.appendChild(this._input);
42
47
 
43
48
  this._wrapper.appendChild(group);
44
- this._defaultValue = this._input.value;
45
49
  this._syncFormValue();
46
50
  }
47
51
 
@@ -102,9 +106,13 @@ class GlkRange extends GlkFormElement {
102
106
  this._syncFormValue();
103
107
  }
104
108
 
105
- get value() { return this._input?.value ?? ''; }
109
+ get value() { return this._input ? this._input.value : (this._pendingValue ?? this.getAttribute('value') ?? '50'); }
106
110
  set value(v) {
107
- if (this._input) this._input.value = v;
111
+ const value = v == null ? '' : String(v);
112
+ // Frameworks set properties right after creating the element. Before
113
+ // render() the value waits for it — it used to throw.
114
+ if (!this._input) { this._pendingValue = value; return; }
115
+ this._input.value = value;
108
116
  this._updateValueDisplay();
109
117
  this._syncFormValue();
110
118
  }
@@ -27,7 +27,9 @@ class GlkSearch extends GlkFormElement {
27
27
  const name = this.getAttribute('name');
28
28
  if (name) this._input.setAttribute('name', name);
29
29
 
30
- const value = this.getAttribute('value');
30
+ // A value set as a property before render() has waited for it.
31
+ const value = this._pendingValue ?? this.getAttribute('value');
32
+ this._pendingValue = undefined;
31
33
  if (value) this._input.value = value;
32
34
 
33
35
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
@@ -89,9 +91,13 @@ class GlkSearch extends GlkFormElement {
89
91
  this._syncFormValue();
90
92
  }
91
93
 
92
- get value() { return this._input?.value ?? ''; }
94
+ get value() { return this._input ? this._input.value : (this._pendingValue ?? this.getAttribute('value') ?? ''); }
93
95
  set value(v) {
94
- if (this._input) this._input.value = v;
96
+ const value = v == null ? '' : String(v);
97
+ // Frameworks set properties right after creating the element. Before
98
+ // render() the value waits for it — it used to throw.
99
+ if (!this._input) { this._pendingValue = value; return; }
100
+ this._input.value = value;
95
101
  this._syncFormValue();
96
102
  }
97
103
  }
@@ -1,5 +1,15 @@
1
1
  import { GlkFormElement } from '../../base.js';
2
2
 
3
+ // The wanted value. A select can show a value only once an option carries it,
4
+ // and the options arrive late: they are copied in a frame after connecting,
5
+ // and a framework may add them later still — while it sets `value` as a
6
+ // property right after creating the element. So the value the attribute or
7
+ // the property asks for is kept as wanted until an option carries it; a
8
+ // choice of the user replaces it. Before 1.22.0 only the attribute was kept:
9
+ // a property set before connecting threw, and one set before the options
10
+ // were copied was lost — the select showed its first option while the app
11
+ // held the other value.
12
+
3
13
  class GlkSelect extends GlkFormElement {
4
14
  static get observedAttributes() {
5
15
  return ['label', 'disabled', 'name', 'value', 'required'];
@@ -15,6 +25,8 @@ class GlkSelect extends GlkFormElement {
15
25
  this._labelEl.textContent = this.getAttribute('label') || '';
16
26
 
17
27
  this._select = this.createElement('select', ['glass-select'], { id: 'field' });
28
+ // A value set as a property before render() is wanted already.
29
+ if (this._wanted === undefined) this._wanted = this.getAttribute('value');
18
30
 
19
31
  const name = this.getAttribute('name');
20
32
  if (name) this._select.setAttribute('name', name);
@@ -46,10 +58,10 @@ class GlkSelect extends GlkFormElement {
46
58
 
47
59
  this._moveOptions();
48
60
 
49
- // Keep the live selection when it survived the rebuild; otherwise fall back
50
- // to the value attribute. Without this the selection jumps back to the first
51
- // entry every time the list is updated.
52
- if (!this._applyValue(previous)) this._applyValue(this.getAttribute('value'));
61
+ // The wanted value first, then the live selection if it survived the
62
+ // rebuild — without that, the selection would jump back to the first entry
63
+ // every time the list is updated.
64
+ if (!this._applyValue(this._wanted)) this._applyValue(previous);
53
65
  this._syncFormValue();
54
66
  }
55
67
 
@@ -75,6 +87,7 @@ class GlkSelect extends GlkFormElement {
75
87
 
76
88
  setupEvents() {
77
89
  this._onChange = () => {
90
+ this._wanted = null; // the user's choice replaces it
78
91
  this._syncFormValue();
79
92
  this.emit('glk-change', { value: this._select.value });
80
93
  this.dispatchEvent(new Event('change', { bubbles: true }));
@@ -99,7 +112,8 @@ class GlkSelect extends GlkFormElement {
99
112
  this._select.setAttribute('name', this.getAttribute('name') || '');
100
113
  break;
101
114
  case 'value':
102
- this._applyValue(this.getAttribute('value'));
115
+ this._wanted = this.getAttribute('value');
116
+ this._applyValue(this._wanted);
103
117
  this._syncFormValue();
104
118
  break;
105
119
  case 'required':
@@ -115,13 +129,22 @@ class GlkSelect extends GlkFormElement {
115
129
  }
116
130
 
117
131
  resetValue() {
118
- this._select.selectedIndex = 0;
132
+ // Back to what the markup asked for, else the first option.
133
+ this._wanted = this.getAttribute('value');
134
+ if (!this._applyValue(this._wanted)) this._select.selectedIndex = 0;
119
135
  this._syncFormValue();
120
136
  }
121
137
 
122
- get value() { return this._select?.value ?? ''; }
138
+ get value() {
139
+ // Until options are there, the wanted value is the answer: a framework
140
+ // reads back what it has just set.
141
+ if (!this._select?.options.length) return this._wanted ?? this.getAttribute('value') ?? '';
142
+ return this._select.value;
143
+ }
123
144
  set value(v) {
124
- if (this._select) this._select.value = v;
145
+ this._wanted = v == null ? '' : String(v);
146
+ if (!this._select) return; // render() takes it from here
147
+ this._applyValue(this._wanted);
125
148
  this._syncFormValue();
126
149
  }
127
150
 
@@ -2,7 +2,7 @@ import { GlkFormElement } from '../../base.js';
2
2
 
3
3
  class GlkTextarea extends GlkFormElement {
4
4
  static get observedAttributes() {
5
- return ['label', 'placeholder', 'rows', 'disabled', 'name', 'value', 'required'];
5
+ return ['label', 'placeholder', 'rows', 'disabled', 'readonly', 'name', 'value', 'required'];
6
6
  }
7
7
 
8
8
  render() {
@@ -22,10 +22,13 @@ class GlkTextarea extends GlkFormElement {
22
22
  const name = this.getAttribute('name');
23
23
  if (name) this._textarea.setAttribute('name', name);
24
24
 
25
- const value = this.getAttribute('value');
25
+ // A value set as a property before render() has waited for it.
26
+ const value = this._pendingValue ?? this.getAttribute('value');
27
+ this._pendingValue = undefined;
26
28
  if (value) this._textarea.value = value;
27
29
 
28
30
  if (this.getBoolAttr('disabled')) this._textarea.disabled = true;
31
+ if (this.getBoolAttr('readonly')) this._textarea.readOnly = true;
29
32
  if (this.getBoolAttr('required')) this._textarea.required = true;
30
33
 
31
34
  group.appendChild(this._labelEl);
@@ -63,6 +66,9 @@ class GlkTextarea extends GlkFormElement {
63
66
  case 'disabled':
64
67
  this._textarea.disabled = this.getBoolAttr('disabled');
65
68
  break;
69
+ case 'readonly':
70
+ this._textarea.readOnly = this.getBoolAttr('readonly');
71
+ break;
66
72
  case 'name':
67
73
  this._textarea.setAttribute('name', this.getAttribute('name') || '');
68
74
  break;
@@ -87,14 +93,21 @@ class GlkTextarea extends GlkFormElement {
87
93
  this._syncFormValue();
88
94
  }
89
95
 
90
- get value() { return this._textarea?.value ?? ''; }
96
+ get value() { return this._textarea ? this._textarea.value : (this._pendingValue ?? this.getAttribute('value') ?? ''); }
91
97
  set value(v) {
92
- if (this._textarea) this._textarea.value = v;
98
+ const value = v == null ? '' : String(v);
99
+ // Frameworks set properties right after creating the element. Before
100
+ // render() the value waits for it — it used to throw.
101
+ if (!this._textarea) { this._pendingValue = value; return; }
102
+ this._textarea.value = value;
93
103
  this._syncFormValue();
94
104
  }
95
105
 
96
106
  get disabled() { return this.getBoolAttr('disabled'); }
97
107
  set disabled(v) { this.setBoolAttr('disabled', v); }
108
+
109
+ get readOnly() { return this.getBoolAttr('readonly'); }
110
+ set readOnly(v) { this.setBoolAttr('readonly', v); }
98
111
  }
99
112
 
100
113
  customElements.define('glk-textarea', GlkTextarea);
@@ -104,11 +104,12 @@ class GlkToggle extends GlkFormElement {
104
104
  }
105
105
  }
106
106
 
107
- get checked() { return this._input?.checked ?? false; }
107
+ // Before render() the attribute carries the state: render() reads it.
108
+ get checked() { return this._input ? this._input.checked : this.getBoolAttr('checked'); }
108
109
  set checked(v) {
109
110
  if (this._input) this._input.checked = v;
110
111
  this.setBoolAttr('checked', v);
111
- this._syncFormValue();
112
+ if (this._input) this._syncFormValue();
112
113
  }
113
114
 
114
115
  get disabled() { return this.getBoolAttr('disabled'); }