@jungherz-de/glasskit-elements 1.16.0 → 1.17.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.16.0",
3
+ "version": "1.17.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",
@@ -45,10 +45,10 @@
45
45
  "vanilla-js"
46
46
  ],
47
47
  "peerDependencies": {
48
- "@jungherz-de/glasskit": ">=1.16.0"
48
+ "@jungherz-de/glasskit": ">=1.17.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@jungherz-de/glasskit": "^1.16.0",
51
+ "@jungherz-de/glasskit": "^1.17.0",
52
52
  "@rollup/plugin-node-resolve": "^15.0.0",
53
53
  "@rollup/plugin-terser": "^0.4.0",
54
54
  "rollup": "^4.0.0"
package/src/base.js CHANGED
@@ -181,6 +181,24 @@ export class GlkElement extends HTMLElement {
181
181
 
182
182
  // ── Utility Methods ──
183
183
 
184
+ /**
185
+ * For elements whose heading arrives as `title`. That is also the global
186
+ * HTML attribute, and a title on the host shows as a browser tooltip — the
187
+ * sheet's heading floating over its form. So the value is read into
188
+ * this._title and the attribute is taken off the host; a later
189
+ * setAttribute('title') comes through attributeChangedCallback, is taken
190
+ * the same way and removed again, and the element's `title` accessor
191
+ * answers from this._title. The removal fires the callback with null,
192
+ * which is ignored here — only an empty string clears the heading.
193
+ * Returns whether a value was taken.
194
+ */
195
+ takeTitle(value = this.getAttribute('title')) {
196
+ if (value === null) return false;
197
+ this._title = value;
198
+ this.removeAttribute('title');
199
+ return true;
200
+ }
201
+
184
202
  getBoolAttr(name) {
185
203
  return this.hasAttribute(name);
186
204
  }
@@ -13,7 +13,8 @@ class GlkAccordionItem extends GlkElement {
13
13
 
14
14
  // Trigger button
15
15
  this._trigger = this.createElement('button', ['glass-accordion__trigger']);
16
- this._triggerText = document.createTextNode(this.getAttribute('title') || '');
16
+ this.takeTitle();
17
+ this._triggerText = document.createTextNode(this._title || '');
17
18
  this._trigger.appendChild(this._triggerText);
18
19
  this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
19
20
 
@@ -41,18 +42,29 @@ class GlkAccordionItem extends GlkElement {
41
42
  this._trigger?.removeEventListener('click', this._onClick);
42
43
  }
43
44
 
44
- onAttributeChanged(name) {
45
+ onAttributeChanged(name, _old, value) {
45
46
  if (!this._item) return;
46
47
  switch (name) {
47
48
  case 'open':
48
49
  this._item.classList.toggle('is-open', this.getBoolAttr('open'));
49
50
  break;
50
51
  case 'title':
51
- this._triggerText.textContent = this.getAttribute('title') || '';
52
+ if (this.takeTitle(value)) this._applyTitle();
52
53
  break;
53
54
  }
54
55
  }
55
56
 
57
+ _applyTitle() {
58
+ if (this._triggerText) this._triggerText.textContent = this._title || '';
59
+ }
60
+
61
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
62
+ get title() { return this._title ?? ''; }
63
+ set title(v) {
64
+ this._title = v == null ? '' : String(v);
65
+ this._applyTitle();
66
+ }
67
+
56
68
  get open() { return this.getBoolAttr('open'); }
57
69
  set open(v) { this.setBoolAttr('open', v); }
58
70
  }
@@ -48,7 +48,8 @@ class GlkListItem extends GlkElement {
48
48
  // Content — title + optional subtitle.
49
49
  this._content = this.createElement('div', ['glass-list__content']);
50
50
  this._titleEl = this.createElement('div', ['glass-list__title']);
51
- this._titleEl.textContent = this.getAttribute('title') || '';
51
+ this.takeTitle();
52
+ this._titleEl.textContent = this._title || '';
52
53
  this._subtitleEl = this.createElement('div', ['glass-list__subtitle']);
53
54
  if (this.getBoolAttr('wrap')) this._subtitleEl.classList.add('glass-list__subtitle--wrap');
54
55
  const initialSubtitle = this.getAttribute('subtitle') || '';
@@ -114,11 +115,11 @@ class GlkListItem extends GlkElement {
114
115
  this._item?.removeEventListener('click', this._onClick);
115
116
  }
116
117
 
117
- onAttributeChanged(name) {
118
+ onAttributeChanged(name, _old, value) {
118
119
  if (!this._item) return;
119
120
  switch (name) {
120
121
  case 'title':
121
- this._titleEl.textContent = this.getAttribute('title') || '';
122
+ if (this.takeTitle(value)) this._applyTitle();
122
123
  break;
123
124
  case 'subtitle': {
124
125
  const value = this.getAttribute('subtitle') || '';
@@ -162,6 +163,17 @@ class GlkListItem extends GlkElement {
162
163
  }
163
164
  }
164
165
 
166
+ _applyTitle() {
167
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
168
+ }
169
+
170
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
171
+ get title() { return this._title ?? ''; }
172
+ set title(v) {
173
+ this._title = v == null ? '' : String(v);
174
+ this._applyTitle();
175
+ }
176
+
165
177
  get interactive() { return this.getBoolAttr('interactive'); }
166
178
  set interactive(v) { this.setBoolAttr('interactive', v); }
167
179
 
@@ -24,11 +24,12 @@ class GlkEmpty extends GlkElement {
24
24
  action.appendChild(this.createElement('slot', [], { name: 'action' }));
25
25
  root.append(icon, this._titleEl, this._textEl, action);
26
26
  this._wrapper.appendChild(root);
27
+ this.takeTitle();
27
28
  this._update();
28
29
  }
29
30
 
30
31
  _update() {
31
- const title = this.getAttribute('title') || '';
32
+ const title = this._title || '';
32
33
  const text = this.getAttribute('text') || '';
33
34
  this._titleEl.textContent = title;
34
35
  this._titleEl.hidden = !title;
@@ -36,10 +37,22 @@ class GlkEmpty extends GlkElement {
36
37
  this._textEl.hidden = !text;
37
38
  }
38
39
 
39
- onAttributeChanged() {
40
+ onAttributeChanged(name, _old, value) {
41
+ if (name === 'title' && !this.takeTitle(value)) return;
40
42
  if (this._titleEl) this._update();
41
43
  }
42
44
 
45
+ _applyTitle() {
46
+ if (this._titleEl) this._update();
47
+ }
48
+
49
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
50
+ get title() { return this._title ?? ''; }
51
+ set title(v) {
52
+ this._title = v == null ? '' : String(v);
53
+ this._applyTitle();
54
+ }
55
+
43
56
  get text() { return this.getAttribute('text') || ''; }
44
57
  set text(v) {
45
58
  if (v) this.setAttribute('text', v);
@@ -15,7 +15,8 @@ class GlkModal extends GlkElement {
15
15
  // Header
16
16
  const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
17
17
  this._titleEl = this.createElement('h2', ['glass-modal__title']);
18
- this._titleEl.textContent = this.getAttribute('title') || '';
18
+ this.takeTitle();
19
+ this._applyTitle();
19
20
  header.appendChild(this._titleEl);
20
21
 
21
22
  // Body
@@ -84,14 +85,14 @@ class GlkModal extends GlkElement {
84
85
  document.removeEventListener('keydown', this._onKeydown);
85
86
  }
86
87
 
87
- onAttributeChanged(name) {
88
+ onAttributeChanged(name, _old, value) {
88
89
  if (!this._overlay) return;
89
90
  switch (name) {
90
91
  case 'open':
91
92
  this._overlay.classList.toggle('is-active', this.getBoolAttr('open'));
92
93
  break;
93
94
  case 'title':
94
- this._titleEl.textContent = this.getAttribute('title') || '';
95
+ if (this.takeTitle(value)) this._applyTitle();
95
96
  break;
96
97
  }
97
98
  }
@@ -99,6 +100,17 @@ class GlkModal extends GlkElement {
99
100
  show() { this.setAttribute('open', ''); }
100
101
  close() { this.removeAttribute('open'); }
101
102
 
103
+ _applyTitle() {
104
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
105
+ }
106
+
107
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
108
+ get title() { return this._title ?? ''; }
109
+ set title(v) {
110
+ this._title = v == null ? '' : String(v);
111
+ this._applyTitle();
112
+ }
113
+
102
114
  get open() { return this.getBoolAttr('open'); }
103
115
  set open(v) { this.setBoolAttr('open', v); }
104
116
  }
@@ -37,12 +37,14 @@ class GlkSheet extends GlkElement {
37
37
  this._panel.append(this._titleEl, body, actions);
38
38
  this._overlay.appendChild(this._panel);
39
39
  this._wrapper.appendChild(this._overlay);
40
+ this.takeTitle();
40
41
  this._applyTitle();
41
42
  this._applyMode();
42
43
  }
43
44
 
44
45
  _applyTitle() {
45
- const title = this.getAttribute('title') || '';
46
+ if (!this._titleEl) return;
47
+ const title = this._title || '';
46
48
  this._titleEl.textContent = title;
47
49
  this._titleEl.hidden = !title;
48
50
  if (title) this._panel.setAttribute('aria-label', title);
@@ -112,7 +114,7 @@ class GlkSheet extends GlkElement {
112
114
  document.removeEventListener('keydown', this._onKeydown);
113
115
  }
114
116
 
115
- onAttributeChanged(name) {
117
+ onAttributeChanged(name, _old, value) {
116
118
  if (!this._overlay) return;
117
119
  switch (name) {
118
120
  case 'open':
@@ -121,13 +123,20 @@ class GlkSheet extends GlkElement {
121
123
  else this._hide();
122
124
  break;
123
125
  case 'inline': this._applyMode(); break;
124
- case 'title': this._applyTitle(); break;
126
+ case 'title': if (this.takeTitle(value)) this._applyTitle(); break;
125
127
  }
126
128
  }
127
129
 
128
130
  show() { this.setAttribute('open', ''); }
129
131
  close() { this.removeAttribute('open'); }
130
132
 
133
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
134
+ get title() { return this._title ?? ''; }
135
+ set title(v) {
136
+ this._title = v == null ? '' : String(v);
137
+ this._applyTitle();
138
+ }
139
+
131
140
  get open() { return this.getBoolAttr('open'); }
132
141
  set open(v) { this.setBoolAttr('open', v); }
133
142
 
@@ -1,8 +1,14 @@
1
1
  import { GlkFormElement } from '../../base.js';
2
2
 
3
+ // Attributes that belong to the inner <input> and are handed down unchanged:
4
+ // the picker's range and step, the typing limits, the pattern, autofill and
5
+ // the on-screen keyboard. Absent on the host means absent inside (since
6
+ // 1.17.0 — before, a date field with min still offered every past day).
7
+ const FORWARDED = ['min', 'max', 'step', 'minlength', 'maxlength', 'pattern', 'autocomplete', 'inputmode'];
8
+
3
9
  class GlkInput extends GlkFormElement {
4
10
  static get observedAttributes() {
5
- return ['label', 'type', 'placeholder', 'error', 'hint', 'disabled', 'name', 'value', 'required'];
11
+ return ['label', 'type', 'placeholder', 'error', 'hint', 'disabled', 'name', 'value', 'required', ...FORWARDED];
6
12
  }
7
13
 
8
14
  render() {
@@ -28,6 +34,7 @@ class GlkInput extends GlkFormElement {
28
34
 
29
35
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
30
36
  if (this.getBoolAttr('required')) this._input.required = true;
37
+ for (const attr of FORWARDED) this._forward(attr);
31
38
 
32
39
  // Hint
33
40
  this._hintEl = this.createElement('span', this._computeHintClasses());
@@ -97,9 +104,17 @@ class GlkInput extends GlkFormElement {
97
104
  case 'required':
98
105
  this._input.required = this.getBoolAttr('required');
99
106
  break;
107
+ default:
108
+ if (FORWARDED.includes(name)) this._forward(name);
100
109
  }
101
110
  }
102
111
 
112
+ _forward(attr) {
113
+ const value = this.getAttribute(attr);
114
+ if (value === null) this._input.removeAttribute(attr);
115
+ else this._input.setAttribute(attr, value);
116
+ }
117
+
103
118
  _computeInputClasses() {
104
119
  const classes = ['glass-input'];
105
120
  if (this.getBoolAttr('error')) classes.push('glass-input--error');
@@ -1,19 +1,31 @@
1
1
  import { GlkFormElement } from '../../base.js';
2
+ import { revealCentered } from '../../reveal.js';
2
3
 
3
4
  // One button per option, the chosen one marked aria-pressed="true" — GlassKit
4
5
  // styles exactly that attribute. Came back from EhrenPfoten in 1.15.0.
5
6
  //
6
7
  // Attributes: options (JSON [{value,label,tone?,disabled?}]), value, full,
7
- // label (aria-label of the group), name (form field name)
8
- // Properties: value, options (array or JSON text), full, label
8
+ // overflow ("scroll" | "wrap": what happens when the options
9
+ // do not fit — without it the row stays one line and runs past
10
+ // the edge), label (aria-label of the group), name (form field)
11
+ // Properties: value, options (array or JSON text), full, overflow, label
9
12
  // Event: glk-change { value } — only on a change made by the user
10
13
  // Part: group
11
14
  //
12
15
  // Form-associated: a surrounding <form> receives name=value, reset restores
13
16
  // the initial value. A value change only re-sets aria-pressed, never rebuilds,
14
17
  // so focus stays on the button that was pressed.
18
+ //
19
+ // overflow="scroll" keeps the chosen option in view: it is centred in the
20
+ // row after the first layout, on every value change and when the row's width
21
+ // changes, through the row's own scrolling, so the page never moves (since
22
+ // 1.17.0). The attribute is not called "scroll": that is a method of every
23
+ // element, and a framework that sets a property whenever the element has one
24
+ // (hybrids, Vue, React 19) would overwrite the method instead of setting the
25
+ // attribute.
15
26
 
16
27
  const TONES = ['success', 'warning', 'error'];
28
+ const OVERFLOWS = ['scroll', 'wrap'];
17
29
 
18
30
  function parseList(json) {
19
31
  try {
@@ -25,14 +37,14 @@ function parseList(json) {
25
37
  }
26
38
 
27
39
  class GlkSegmented extends GlkFormElement {
28
- static get observedAttributes() { return ['options', 'value', 'full', 'label']; }
40
+ static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label']; }
29
41
 
30
42
  render() {
31
43
  this._group = this.createElement('div', ['glass-segmented'], { role: 'group', part: 'group' });
32
44
  this._wrapper.appendChild(this._group);
33
45
  this._initialValue = this.value;
34
46
  this._build();
35
- this._applyFull();
47
+ this._applyLayout();
36
48
  this._applyLabel();
37
49
  }
38
50
 
@@ -58,10 +70,20 @@ class GlkSegmented extends GlkFormElement {
58
70
  button.setAttribute('aria-pressed', String(button.dataset.value === value));
59
71
  }
60
72
  this.setFormValue(value);
73
+ this._reveal(this._revealedAt ? 'smooth' : 'instant');
61
74
  }
62
75
 
63
- _applyFull() {
76
+ _applyLayout() {
77
+ const overflow = this.overflow;
64
78
  this._group.classList.toggle('glass-segmented--full', this.getBoolAttr('full'));
79
+ for (const mode of OVERFLOWS) this._group.classList.toggle(`glass-segmented--${mode}`, overflow === mode);
80
+ this._reveal('instant');
81
+ }
82
+
83
+ _reveal(behavior) {
84
+ if (this.overflow !== 'scroll') return;
85
+ const pressed = this._group.querySelector('[aria-pressed="true"]');
86
+ if (revealCentered(this._group, pressed, behavior === 'smooth')) this._revealedAt = this._group.clientWidth;
65
87
  }
66
88
 
67
89
  _applyLabel() {
@@ -79,11 +101,16 @@ class GlkSegmented extends GlkFormElement {
79
101
  this.value = value;
80
102
  this.emit('glk-change', { value });
81
103
  };
104
+ this._onResize = () => { if (this._group.clientWidth !== this._revealedAt) this._reveal('instant'); };
82
105
  this._group.addEventListener('click', this._onClick);
106
+ this._resizeObserver = new ResizeObserver(this._onResize);
107
+ this._resizeObserver.observe(this._group);
83
108
  }
84
109
 
85
110
  teardownEvents() {
86
111
  this._group?.removeEventListener('click', this._onClick);
112
+ this._resizeObserver?.disconnect();
113
+ this._resizeObserver = null;
87
114
  }
88
115
 
89
116
  onAttributeChanged(name) {
@@ -91,7 +118,8 @@ class GlkSegmented extends GlkFormElement {
91
118
  switch (name) {
92
119
  case 'value': this._syncValue(); break;
93
120
  case 'options': this._build(); break;
94
- case 'full': this._applyFull(); break;
121
+ case 'full':
122
+ case 'overflow': this._applyLayout(); break;
95
123
  case 'label': this._applyLabel(); break;
96
124
  }
97
125
  }
@@ -111,6 +139,15 @@ class GlkSegmented extends GlkFormElement {
111
139
  get full() { return this.getBoolAttr('full'); }
112
140
  set full(v) { this.setBoolAttr('full', v); }
113
141
 
142
+ get overflow() {
143
+ const v = this.getAttribute('overflow');
144
+ return OVERFLOWS.includes(v) ? v : '';
145
+ }
146
+ set overflow(v) {
147
+ if (v) this.setAttribute('overflow', String(v));
148
+ else this.removeAttribute('overflow');
149
+ }
150
+
114
151
  get label() { return this.getAttribute('label') || ''; }
115
152
  set label(v) {
116
153
  if (v) this.setAttribute('label', v);
@@ -1,5 +1,6 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
  import { isoDate, parseIso, addDays, parseJsonObject, resolveLocale, formatters, weekdayShort, isTone } from '../../dates.js';
3
+ import { revealCentered } from '../../reveal.js';
3
4
 
4
5
  // A row of day chips that scrolls sideways — a booking horizon, the days
5
6
  // around today. Came back from EhrenPfoten in 1.16.0.
@@ -84,24 +85,15 @@ class GlkDateStrip extends GlkElement {
84
85
  for (const chip of this._chips()) chip.tabIndex = chip === target ? 0 : -1;
85
86
  }
86
87
 
87
- // Scroll the chosen chip to the middle of the strip. Computed from client
88
- // rects and applied to the strip's own scrollLeft, so the page never moves —
89
- // scrollIntoView would scroll every ancestor. Before the first layout the
90
- // strip has no width; the ResizeObserver in setupEvents centres then, and
91
- // again whenever the strip's width changes — a parent that upgrades later
92
- // and adds its padding, a window resize, a strip that starts out hidden. A
93
- // value change glides unless the user asked for reduced motion; a size
94
- // change snaps, so a settling layout never animates.
88
+ // Centre the chosen chip in the strip (see reveal.js). Before the first
89
+ // layout the strip has no width; the ResizeObserver in setupEvents centres
90
+ // then, and again whenever the strip's width changes — a parent that
91
+ // upgrades later and adds its padding, a window resize, a strip that
92
+ // starts out hidden. A value change glides; a size change snaps, so a
93
+ // settling layout never animates.
95
94
  _reveal(behavior = 'instant') {
96
95
  const chip = this._strip.querySelector('[aria-pressed="true"]');
97
- const width = this._strip.clientWidth;
98
- if (!chip || !width) return;
99
- const strip = this._strip.getBoundingClientRect();
100
- const box = chip.getBoundingClientRect();
101
- const left = this._strip.scrollLeft + (box.left - strip.left) - (strip.width - box.width) / 2;
102
- const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
103
- this._strip.scrollTo({ left: Math.max(0, left), behavior: smooth ? 'smooth' : 'instant' });
104
- this._revealedAt = width;
96
+ if (revealCentered(this._strip, chip, behavior === 'smooth')) this._revealedAt = this._strip.clientWidth;
105
97
  }
106
98
 
107
99
  _applyLabel() {
package/src/reveal.js ADDED
@@ -0,0 +1,19 @@
1
+ // Scroll an item to the middle of its scrolling row — shared by
2
+ // <glk-date-strip> and <glk-segmented overflow="scroll">. Computed from
3
+ // client rects and applied to the row alone, where scrollIntoView would move
4
+ // every scrolling ancestor, the page included. A relative scrollBy works in
5
+ // both writing directions: scrollLeft runs negative in RTL, the distance on
6
+ // screen does not. Returns false while the row has no width — before the
7
+ // first layout, or hidden — so the caller can try again from a
8
+ // ResizeObserver. Pass smooth for a reveal that follows a value change, not
9
+ // for one caused by layout; reduced motion wins over it.
10
+
11
+ export function revealCentered(scroller, item, smooth) {
12
+ if (!item || !scroller.clientWidth) return false;
13
+ const port = scroller.getBoundingClientRect();
14
+ const box = item.getBoundingClientRect();
15
+ const delta = box.left + box.width / 2 - (port.left + scroller.clientLeft + scroller.clientWidth / 2);
16
+ const glide = smooth && !matchMedia('(prefers-reduced-motion: reduce)').matches;
17
+ scroller.scrollBy({ left: delta, behavior: glide ? 'smooth' : 'instant' });
18
+ return true;
19
+ }