@jungherz-de/glasskit-elements 1.17.0 → 1.19.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.17.0",
3
+ "version": "1.19.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.17.0"
48
+ "@jungherz-de/glasskit": ">=1.19.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@jungherz-de/glasskit": "^1.17.0",
51
+ "@jungherz-de/glasskit": "^1.19.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
@@ -220,10 +220,15 @@ export class GlkElement extends HTMLElement {
220
220
  return el;
221
221
  }
222
222
 
223
- emit(eventName, detail = null) {
224
- this.dispatchEvent(new CustomEvent(eventName, {
223
+ /**
224
+ * Dispatch a bubbling, composed CustomEvent. With { cancelable: true } a
225
+ * listener may call preventDefault(); the return value is false then.
226
+ */
227
+ emit(eventName, detail = null, { cancelable = false } = {}) {
228
+ return this.dispatchEvent(new CustomEvent(eventName, {
225
229
  bubbles: true,
226
230
  composed: true,
231
+ cancelable,
227
232
  detail
228
233
  }));
229
234
  }
@@ -1,6 +1,8 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
 
3
- const VARIANTS = ['primary', 'success', 'error'];
3
+ // warning since 1.18.0 (needs GlassKit 1.18.0). Any other value falls back to
4
+ // the neutral badge, as an unknown value of an enumerated attribute does.
5
+ const VARIANTS = ['primary', 'success', 'warning', 'error'];
4
6
 
5
7
  class GlkBadge extends GlkElement {
6
8
  static get displayInline() { return true; }
@@ -1,34 +1,105 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
 
3
+ // A short message at the top of the screen that hides itself — and, since
4
+ // 1.19.0, one that offers something: "A new version · Reload".
5
+ //
6
+ // Attributes: message, variant (success | error | warning), duration (ms;
7
+ // 0 keeps it up), visible, action-label, action-value (reported
8
+ // in glk-action; the label when missing), dismissible (an ×
9
+ // without an action), close-label (name of the ×, "Close" by
10
+ // default)
11
+ // Slot: icon — replaces the built-in icon; leave its stroke unset and
12
+ // it takes the variant colour
13
+ // Methods: show(message, variant, duration)
14
+ // show(message, { variant, duration, action: { label, value }, dismissible })
15
+ // dismiss()
16
+ // Events: glk-dismiss — hidden after its duration
17
+ // glk-action { action, label } — the action was used; the toast
18
+ // closes unless a listener calls preventDefault() or shows the
19
+ // next message from the handler
20
+ // glk-close — closed with the × or Escape
21
+ //
22
+ // A toast with an action brings an × and stays until one of them is used,
23
+ // unless a duration is set; while the pointer or the focus is on a toast
24
+ // with buttons, it does not time out. It is a polite live region, so the
25
+ // message is announced; hidden, its buttons are inert. Closing it from the
26
+ // keyboard hands the focus back to where it came from.
27
+
3
28
  const VARIANTS = ['success', 'error', 'warning'];
29
+ const DEFAULT_DURATION = 3000;
4
30
 
31
+ // Stroke, fill and caps come from .glass-toast__icon, so the variant colours
32
+ // the icon — an own stroke attribute here would win over the inherited one.
5
33
  const ICONS = {
6
- success: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
7
- error: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
8
- warning: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`
34
+ success: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
35
+ error: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
36
+ warning: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
9
37
  };
38
+ const CLOSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
10
39
 
11
40
  class GlkToast extends GlkElement {
12
41
  static get observedAttributes() {
13
- return ['message', 'variant', 'duration', 'visible'];
42
+ return ['message', 'variant', 'duration', 'visible', 'action-label', 'action-value', 'dismissible', 'close-label'];
14
43
  }
15
44
 
16
45
  render() {
17
- this._toast = this.createElement('div', this._computeClasses());
46
+ this._toast = this.createElement('div', this._computeClasses(), { role: 'status', 'aria-live': 'polite' });
18
47
 
19
48
  this._iconEl = this.createElement('span', ['glass-toast__icon']);
49
+ this._iconSlot = this.createElement('slot', [], { name: 'icon' });
50
+ this._iconEl.appendChild(this._iconSlot);
20
51
  this._textEl = this.createElement('span', ['glass-toast__text']);
21
52
  this._textEl.textContent = this.getAttribute('message') || '';
53
+ this._actionBtn = this.createElement('button', ['glass-toast__action'], { type: 'button' });
54
+ this._closeBtn = this.createElement('button', ['glass-toast__close'], { type: 'button' });
55
+ this._closeBtn.innerHTML = CLOSE_ICON;
22
56
 
23
57
  this._updateIcon();
24
-
25
- this._toast.appendChild(this._iconEl);
26
- this._toast.appendChild(this._textEl);
58
+ this._toast.append(this._iconEl, this._textEl);
27
59
  this._wrapper.appendChild(this._toast);
60
+ this._syncControls();
28
61
 
29
- if (this.getBoolAttr('visible')) {
30
- this._show();
31
- }
62
+ if (this.getBoolAttr('visible')) this._show();
63
+ }
64
+
65
+ setupEvents() {
66
+ this._onClick = (event) => {
67
+ const path = event.composedPath();
68
+ if (path.includes(this._closeBtn)) this._close();
69
+ else if (path.includes(this._actionBtn)) this._takeAction();
70
+ };
71
+ this._onKeydown = (event) => {
72
+ if (event.key === 'Escape' && this._hasButtons()) { event.stopPropagation(); this._close(); }
73
+ };
74
+ // While the pointer or the focus is on a toast with buttons, it waits.
75
+ this._onHold = (event) => {
76
+ if (event.type === 'focusin' && !this._toast.contains(event.relatedTarget)) this._returnFocus = event.relatedTarget;
77
+ if (!this._hasButtons()) return;
78
+ this._held = true;
79
+ clearTimeout(this._timer);
80
+ };
81
+ this._onRelease = (event) => {
82
+ if (event.type === 'focusout' && this._toast.contains(event.relatedTarget)) return;
83
+ this._held = this._toast.matches(':hover') || this._toast.contains(this._shadow.activeElement);
84
+ if (!this._held) this._arm();
85
+ };
86
+ this._toast.addEventListener('click', this._onClick);
87
+ this._toast.addEventListener('keydown', this._onKeydown);
88
+ this._toast.addEventListener('pointerenter', this._onHold);
89
+ this._toast.addEventListener('focusin', this._onHold);
90
+ this._toast.addEventListener('pointerleave', this._onRelease);
91
+ this._toast.addEventListener('focusout', this._onRelease);
92
+ }
93
+
94
+ teardownEvents() {
95
+ clearTimeout(this._timer);
96
+ if (!this._toast) return;
97
+ this._toast.removeEventListener('click', this._onClick);
98
+ this._toast.removeEventListener('keydown', this._onKeydown);
99
+ this._toast.removeEventListener('pointerenter', this._onHold);
100
+ this._toast.removeEventListener('focusin', this._onHold);
101
+ this._toast.removeEventListener('pointerleave', this._onRelease);
102
+ this._toast.removeEventListener('focusout', this._onRelease);
32
103
  }
33
104
 
34
105
  onAttributeChanged(name) {
@@ -42,11 +113,14 @@ class GlkToast extends GlkElement {
42
113
  this._updateIcon();
43
114
  break;
44
115
  case 'visible':
45
- if (this.getBoolAttr('visible')) {
46
- this._show();
47
- } else {
48
- this._hide();
49
- }
116
+ if (this.getBoolAttr('visible')) this._show();
117
+ else this._hide();
118
+ break;
119
+ case 'action-label':
120
+ case 'action-value':
121
+ case 'dismissible':
122
+ case 'close-label':
123
+ this._syncControls();
50
124
  break;
51
125
  }
52
126
  }
@@ -54,22 +128,55 @@ class GlkToast extends GlkElement {
54
128
  _computeClasses() {
55
129
  const classes = ['glass-toast'];
56
130
  const variant = this.getAttribute('variant');
57
- if (variant && VARIANTS.includes(variant)) {
58
- classes.push(`glass-toast--${variant}`);
59
- }
131
+ if (variant && VARIANTS.includes(variant)) classes.push(`glass-toast--${variant}`);
132
+ if (this.getBoolAttr('visible')) classes.push('is-visible');
60
133
  return classes;
61
134
  }
62
135
 
63
136
  _updateIcon() {
64
- const variant = this.getAttribute('variant');
65
- this._iconEl.innerHTML = ICONS[variant] || ICONS.success;
137
+ // Fallback content of the icon slot: shown while nothing is slotted.
138
+ this._iconSlot.innerHTML = ICONS[this.getAttribute('variant')] || ICONS.success;
66
139
  }
67
140
 
68
- _show() {
69
- this._toast.classList.add('is-visible');
70
- const duration = parseInt(this.getAttribute('duration') || '3000', 10);
71
- if (duration > 0) {
72
- clearTimeout(this._timer);
141
+ _hasButtons() { return this._actionBtn.isConnected || this._closeBtn.isConnected; }
142
+
143
+ // The action and the × sit in the tree only when they are wanted: an
144
+ // action brings its ×, `dismissible` brings the × alone.
145
+ _syncControls() {
146
+ const label = this.getAttribute('action-label');
147
+ if (label) {
148
+ this._actionBtn.textContent = label;
149
+ if (!this._actionBtn.isConnected) this._textEl.after(this._actionBtn);
150
+ } else {
151
+ this._actionBtn.remove();
152
+ }
153
+ if (label || this.getBoolAttr('dismissible')) {
154
+ if (!this._closeBtn.isConnected) this._toast.appendChild(this._closeBtn);
155
+ } else {
156
+ this._closeBtn.remove();
157
+ }
158
+ this._closeBtn.setAttribute('aria-label', this.getAttribute('close-label') || 'Close');
159
+ this._syncInert();
160
+ }
161
+
162
+ _syncInert() {
163
+ const hidden = !this.getBoolAttr('visible');
164
+ this._actionBtn.inert = hidden;
165
+ this._closeBtn.inert = hidden;
166
+ }
167
+
168
+ // Effective duration: the attribute when set; otherwise none for a toast
169
+ // with an action, the default for a plain one.
170
+ _duration() {
171
+ const attr = this.getAttribute('duration');
172
+ if (attr !== null && attr.trim() !== '' && Number.isFinite(Number(attr))) return Number(attr);
173
+ return this.getAttribute('action-label') ? 0 : DEFAULT_DURATION;
174
+ }
175
+
176
+ _arm() {
177
+ clearTimeout(this._timer);
178
+ const duration = this._duration();
179
+ if (duration > 0 && !this._held && this.getBoolAttr('visible')) {
73
180
  this._timer = setTimeout(() => {
74
181
  this.removeAttribute('visible');
75
182
  this.emit('glk-dismiss');
@@ -77,27 +184,84 @@ class GlkToast extends GlkElement {
77
184
  }
78
185
  }
79
186
 
187
+ _show() {
188
+ this._toast.classList.add('is-visible');
189
+ this._syncInert();
190
+ this._arm();
191
+ }
192
+
80
193
  _hide() {
81
194
  this._toast.classList.remove('is-visible');
82
195
  clearTimeout(this._timer);
196
+ this._held = false;
197
+ // A hidden toast must not keep the focus: hand it back, else let it go.
198
+ if (this._toast.contains(this._shadow.activeElement)) {
199
+ const back = this._returnFocus;
200
+ if (back?.isConnected) back.focus({ preventScroll: true });
201
+ if (this._toast.contains(this._shadow.activeElement)) this._shadow.activeElement.blur();
202
+ }
203
+ this._returnFocus = null;
204
+ this._syncInert();
83
205
  }
84
206
 
85
- /** Programmatic show */
86
- show(message, variant, duration) {
207
+ _close() {
208
+ this.removeAttribute('visible');
209
+ this.emit('glk-close');
210
+ }
211
+
212
+ _takeAction() {
213
+ const label = this.getAttribute('action-label') || '';
214
+ const action = this.getAttribute('action-value') ?? label;
215
+ const shown = this._shown;
216
+ // A listener that shows the next message ("Restored") keeps the toast up,
217
+ // and so does one that calls preventDefault().
218
+ if (this.emit('glk-action', { action, label }, { cancelable: true }) && this._shown === shown) {
219
+ this.removeAttribute('visible');
220
+ }
221
+ }
222
+
223
+ /**
224
+ * show(message, variant, duration) — as before.
225
+ * show(message, { variant, duration, action: { label, value }, dismissible })
226
+ * — keys that are left out keep their attribute, except action: a message
227
+ * without one shows no button, and one with an action but no duration stays.
228
+ */
229
+ show(message, variantOrOptions, duration) {
230
+ this._shown = (this._shown ?? 0) + 1;
87
231
  if (message) this.setAttribute('message', message);
88
- if (variant) this.setAttribute('variant', variant);
89
- if (duration) this.setAttribute('duration', String(duration));
90
- this.setAttribute('visible', '');
232
+ if (variantOrOptions && typeof variantOrOptions === 'object') {
233
+ const o = variantOrOptions;
234
+ if ('variant' in o) this._setOrRemove('variant', o.variant);
235
+ if (o.action) {
236
+ this.setAttribute('action-label', String(o.action.label ?? o.action.value ?? ''));
237
+ this._setOrRemove('action-value', o.action.value);
238
+ } else {
239
+ this.removeAttribute('action-label');
240
+ this.removeAttribute('action-value');
241
+ }
242
+ if ('duration' in o) this._setOrRemove('duration', o.duration);
243
+ else if (o.action) this.removeAttribute('duration');
244
+ if ('dismissible' in o) this.setBoolAttr('dismissible', o.dismissible);
245
+ } else {
246
+ if (variantOrOptions) this.setAttribute('variant', variantOrOptions);
247
+ if (duration) this.setAttribute('duration', String(duration));
248
+ }
249
+ // Already visible: the attribute does not change, so restart by hand.
250
+ if (this.getBoolAttr('visible')) this._show();
251
+ else this.setAttribute('visible', '');
252
+ }
253
+
254
+ _setOrRemove(name, value) {
255
+ if (value == null || value === '') this.removeAttribute(name);
256
+ else this.setAttribute(name, String(value));
91
257
  }
92
258
 
93
259
  dismiss() {
94
260
  this.removeAttribute('visible');
95
261
  }
96
262
 
97
- disconnectedCallback() {
98
- super.disconnectedCallback();
99
- clearTimeout(this._timer);
100
- }
263
+ get dismissible() { return this.getBoolAttr('dismissible'); }
264
+ set dismissible(v) { this.setBoolAttr('dismissible', v); }
101
265
  }
102
266
 
103
267
  customElements.define('glk-toast', GlkToast);