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