@jungherz-de/glasskit-elements 1.29.0 → 1.30.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.29.0",
3
+ "version": "1.30.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.29.0"
52
+ "@jungherz-de/glasskit": ">=1.30.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@jungherz-de/glasskit": "^1.29.0",
55
+ "@jungherz-de/glasskit": "^1.30.0",
56
56
  "@rollup/plugin-node-resolve": "^15.0.0",
57
57
  "@rollup/plugin-terser": "^0.4.0",
58
58
  "rollup": "^4.0.0"
@@ -2,18 +2,23 @@ import { GlkElement } from '../../base.js';
2
2
 
3
3
  const CHEVRON_SVG = `<span class="glass-accordion__trigger-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></span>`;
4
4
 
5
+ // Open, the content takes its own height (GlassKit 1.30.0: a grid row that
6
+ // slides from 0fr to 1fr; until then it ended at 500 px). Parts since
7
+ // 1.30.0: item, trigger, content, body.
8
+
5
9
  class GlkAccordionItem extends GlkElement {
6
10
  static get observedAttributes() {
7
11
  return ['title', 'open'];
8
12
  }
9
13
 
10
14
  render() {
11
- this._item = this.createElement('div', ['glass-accordion__item']);
15
+ this._item = this.createElement('div', ['glass-accordion__item'], { part: 'item' });
12
16
 
13
17
  // Trigger button — says whether its section is open, and which one it is
14
18
  this._trigger = this.createElement('button', ['glass-accordion__trigger'], {
15
19
  type: 'button',
16
- 'aria-controls': 'content'
20
+ 'aria-controls': 'content',
21
+ part: 'trigger'
17
22
  });
18
23
  this.takeTitle();
19
24
  this._triggerText = document.createTextNode(this._title || '');
@@ -21,8 +26,8 @@ class GlkAccordionItem extends GlkElement {
21
26
  this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
22
27
 
23
28
  // Content
24
- this._content = this.createElement('div', ['glass-accordion__content'], { id: 'content' });
25
- const body = this.createElement('div', ['glass-accordion__body']);
29
+ this._content = this.createElement('div', ['glass-accordion__content'], { id: 'content', part: 'content' });
30
+ const body = this.createElement('div', ['glass-accordion__body'], { part: 'body' });
26
31
  body.appendChild(document.createElement('slot'));
27
32
  this._content.appendChild(body);
28
33
 
@@ -1,4 +1,5 @@
1
1
  import { GlkElement } from '../../base.js';
2
+ import { scrollLock } from '../../scroll-lock.js';
2
3
 
3
4
  // Since 1.20.0 the overlay is a native <dialog>, opened with showModal(). The
4
5
  // browser then does what a modal needs: the dialog lies in the top layer, the
@@ -16,6 +17,10 @@ import { GlkElement } from '../../base.js';
16
17
  // Chrome takes the first field of the content, Safari the first action. An
17
18
  // element marked autofocus — in the content or among the actions — settles
18
19
  // it in both.
20
+ //
21
+ // Since 1.30.0 it emits glk-open whenever it opens — attribute, property or
22
+ // show() — and the page does not scroll while it is open (glk-close stays
23
+ // the user's close only).
19
24
 
20
25
  const CLOSE_FALLBACK_MS = 400;
21
26
 
@@ -85,6 +90,7 @@ class GlkModal extends GlkElement {
85
90
 
86
91
  this._overlay.appendChild(modal);
87
92
  this._wrapper.appendChild(this._overlay);
93
+ this._scrollLock = scrollLock();
88
94
  // `open` is applied in setupEvents, which also runs when a moved element
89
95
  // comes back.
90
96
  }
@@ -136,6 +142,7 @@ class GlkModal extends GlkElement {
136
142
  this._onClose = () => {
137
143
  if (this._overlay.open) return; // a late event from an earlier close
138
144
  this._overlay.classList.remove('is-active');
145
+ this._scrollLock.release();
139
146
  if (this.open) this._dismiss();
140
147
  };
141
148
  this._overlay.addEventListener('close', this._onClose);
@@ -154,6 +161,7 @@ class GlkModal extends GlkElement {
154
161
  this._overlay?.removeEventListener('transitionend', this._onFaded);
155
162
  this._overlay?.classList.remove('is-active');
156
163
  if (this._overlay?.open) this._overlay.close();
164
+ this._scrollLock?.release();
157
165
  }
158
166
 
159
167
  onAttributeChanged(name, _old, value) {
@@ -176,8 +184,10 @@ class GlkModal extends GlkElement {
176
184
  dialog.removeEventListener('transitionend', this._onFaded);
177
185
  if (!dialog.open) {
178
186
  dialog.showModal();
187
+ this._scrollLock.hold();
179
188
  // The actions are copies in the shadow root; the originals are not shown.
180
189
  (this._footer.querySelector('[autofocus]') || this.querySelector('[autofocus]'))?.focus();
190
+ this.emit('glk-open');
181
191
  }
182
192
  void dialog.offsetHeight; // reflow, so the fade starts from the closed state
183
193
  requestAnimationFrame(() => {
@@ -204,6 +214,7 @@ class GlkModal extends GlkElement {
204
214
 
205
215
  /** Closed by the user — a click outside, Escape. */
206
216
  _dismiss() {
217
+ if (!this.open) return; // Escape again while it fades out
207
218
  this.removeAttribute('open');
208
219
  this.emit('glk-close');
209
220
  }
@@ -1,4 +1,5 @@
1
1
  import { GlkElement } from '../../base.js';
2
+ import { scrollLock } from '../../scroll-lock.js';
2
3
 
3
4
  // Bottom sheet — the mobile sibling of <glk-modal>. Came back from
4
5
  // EhrenPfoten in 1.15.0.
@@ -6,15 +7,23 @@ import { GlkElement } from '../../base.js';
6
7
  // Attributes: open, inline (no overlay, always visible), title
7
8
  // Properties: open, inline · Methods: show(), close()
8
9
  // Slots: default (content), actions
9
- // Event: glk-close — only when the user closes it (scrim click, Escape),
10
- // like <glk-modal>
10
+ // Events: glk-open — whenever it opens: attribute, property or show()
11
+ // (since 1.30.0); glk-close — only when the user closes it
12
+ // (scrim click, Escape), like <glk-modal>
11
13
  // Parts: overlay, sheet, title, body, actions
12
14
  //
13
- // Opening and closing are animated by the CSS block: the overlay gets
14
- // .is-active one frame after it is unhidden, and on closing it is hidden
15
- // only after transitionend (safety net 400 ms), so the blurred overlay
16
- // leaves the layout instead of idling at opacity 0. Under
17
- // prefers-reduced-motion it switches at once.
15
+ // Since 1.30.0 the overlay is a native <dialog> opened with showModal(), as
16
+ // <glk-modal>'s. It lies in the top layer, above every fixed or transformed
17
+ // element of the page whatever its z-index — a fixed tab bar with a
18
+ // backdrop filter landed above the old fixed div now and then on iOS — the
19
+ // page behind it is inert and does not scroll, and Escape arrives as the
20
+ // dialog's cancel event. Inline, the panel sits in the flow and the dialog
21
+ // stays closed.
22
+ //
23
+ // Opening and closing are animated by the CSS block: .is-active is added one
24
+ // frame after showModal(), and on closing the dialog closes only after the
25
+ // panel's transitionend (safety net 400 ms). Under prefers-reduced-motion it
26
+ // switches at once.
18
27
 
19
28
  const HIDE_FALLBACK_MS = 400;
20
29
 
@@ -26,66 +35,84 @@ class GlkSheet extends GlkElement {
26
35
  static get observedAttributes() { return ['open', 'inline', 'title']; }
27
36
 
28
37
  render() {
29
- this._overlay = this.createElement('div', [], { part: 'overlay' });
30
- this._panel = this.createElement('div', ['glass-sheet'], { role: 'dialog', part: 'sheet' });
38
+ this._overlay = this.createElement('dialog', ['glass-sheet-overlay'], { part: 'overlay' });
39
+ this._panel = this.createElement('div', ['glass-sheet'], { part: 'sheet' });
31
40
  this._panel.appendChild(this.createElement('div', ['glass-sheet__grip'], { 'aria-hidden': 'true' }));
32
- this._titleEl = this.createElement('h2', ['glass-sheet__title'], { part: 'title' });
41
+ this._titleEl = this.createElement('h2', ['glass-sheet__title'], { id: 'title', part: 'title' });
33
42
  const body = this.createElement('div', ['glass-sheet__body'], { part: 'body' });
34
43
  body.appendChild(document.createElement('slot'));
35
44
  const actions = this.createElement('div', ['glass-sheet__actions'], { part: 'actions' });
36
45
  actions.appendChild(this.createElement('slot', [], { name: 'actions' }));
37
46
  this._panel.append(this._titleEl, body, actions);
38
- this._overlay.appendChild(this._panel);
39
47
  this._wrapper.appendChild(this._overlay);
48
+ this._scrollLock = scrollLock();
40
49
  this.takeTitle();
41
- this._applyTitle();
42
50
  this._applyMode();
51
+ // `open` is applied in setupEvents, which also runs when a moved element
52
+ // comes back.
43
53
  }
44
54
 
55
+ /** The title names the dialog, or the inline panel. */
45
56
  _applyTitle() {
46
57
  if (!this._titleEl) return;
47
58
  const title = this._title || '';
48
59
  this._titleEl.textContent = title;
49
60
  this._titleEl.hidden = !title;
50
- if (title) this._panel.setAttribute('aria-label', title);
51
- else this._panel.removeAttribute('aria-label');
61
+ const named = this.inline ? this._panel : this._overlay;
62
+ const other = this.inline ? this._overlay : this._panel;
63
+ other.removeAttribute('aria-labelledby');
64
+ if (title) named.setAttribute('aria-labelledby', 'title');
65
+ else named.removeAttribute('aria-labelledby');
52
66
  }
53
67
 
68
+ /** Inline the panel sits in the flow; else it lives in the dialog. */
54
69
  _applyMode() {
55
70
  const inline = this.inline;
56
- this._overlay.classList.toggle('glass-sheet-overlay', !inline);
57
71
  this._panel.classList.toggle('glass-sheet--inline', inline);
58
- this._panel.setAttribute('aria-modal', String(!inline));
59
- clearTimeout(this._hideTimer);
60
72
  if (inline) {
73
+ this._settle();
61
74
  this._overlay.classList.remove('is-active');
62
- this._overlay.hidden = false;
63
- } else if (this.open) {
64
- this._show();
75
+ if (this._overlay.open) this._overlay.close();
76
+ this._scrollLock.release();
77
+ this._panel.setAttribute('role', 'dialog');
78
+ this._wrapper.appendChild(this._panel);
65
79
  } else {
66
- this._overlay.classList.remove('is-active');
67
- this._overlay.hidden = true;
80
+ this._panel.removeAttribute('role');
81
+ this._overlay.appendChild(this._panel);
68
82
  }
83
+ this._applyTitle();
69
84
  }
70
85
 
71
- _show() {
86
+ /** Drops a pending close. */
87
+ _settle() {
72
88
  clearTimeout(this._hideTimer);
73
- this._panel.removeEventListener('transitionend', this._onHidden);
74
- this._overlay.hidden = false;
75
- void this._overlay.offsetHeight; // reflow, so the transition starts from the hidden state
89
+ if (this._onHidden) this._panel.removeEventListener('transitionend', this._onHidden);
90
+ }
91
+
92
+ _show() {
93
+ const dialog = this._overlay;
94
+ this._settle();
95
+ if (!dialog.open) {
96
+ dialog.showModal();
97
+ this._scrollLock.hold();
98
+ this.querySelector('[autofocus]')?.focus();
99
+ this.emit('glk-open');
100
+ }
101
+ void dialog.offsetHeight; // reflow, so the transition starts from the closed state
76
102
  requestAnimationFrame(() => {
77
- if (this.open && !this.inline) this._overlay.classList.add('is-active');
103
+ if (this.open && !this.inline) dialog.classList.add('is-active');
78
104
  });
79
105
  }
80
106
 
81
107
  _hide() {
82
- this._overlay.classList.remove('is-active');
83
- this._onHidden = () => {
84
- clearTimeout(this._hideTimer);
85
- this._panel.removeEventListener('transitionend', this._onHidden);
86
- if (!this.open && !this.inline) this._overlay.hidden = true;
108
+ const dialog = this._overlay;
109
+ dialog.classList.remove('is-active');
110
+ this._onHidden = (event) => {
111
+ if (event && event.target !== this._panel) return; // a transition inside the panel
112
+ this._settle();
113
+ if (!this.open && dialog.open) dialog.close();
87
114
  };
88
- if (reducedMotion()) {
115
+ if (!dialog.open || reducedMotion()) {
89
116
  this._onHidden();
90
117
  return;
91
118
  }
@@ -93,36 +120,60 @@ class GlkSheet extends GlkElement {
93
120
  this._hideTimer = setTimeout(this._onHidden, HIDE_FALLBACK_MS);
94
121
  }
95
122
 
123
+ /** Closed by the user — a click outside, Escape. */
96
124
  _dismiss() {
125
+ if (!this.open) return;
97
126
  this.removeAttribute('open');
98
127
  this.emit('glk-close');
99
128
  }
100
129
 
101
130
  setupEvents() {
102
131
  this._onOverlayClick = (event) => {
103
- if (!this.inline && event.target === this._overlay) this._dismiss();
132
+ if (event.target === this._overlay) this._dismiss();
104
133
  };
105
- this._onKeydown = (event) => {
106
- if (event.key === 'Escape' && this.open && !this.inline) this._dismiss();
134
+ // Escape: the dialog would close at once; it slides out like a click outside.
135
+ this._onCancel = (event) => {
136
+ event.preventDefault();
137
+ this._dismiss();
138
+ };
139
+ // Every close ends the lock. The browser may also close the dialog by
140
+ // itself — Chrome does on a second Escape without user activation.
141
+ this._onClose = () => {
142
+ if (this._overlay.open) return; // a late event from an earlier close
143
+ this._overlay.classList.remove('is-active');
144
+ this._scrollLock.release();
145
+ if (this.open && !this.inline) this._dismiss();
107
146
  };
108
147
  this._overlay.addEventListener('click', this._onOverlayClick);
109
- document.addEventListener('keydown', this._onKeydown);
148
+ this._overlay.addEventListener('cancel', this._onCancel);
149
+ this._overlay.addEventListener('close', this._onClose);
150
+ if (this.open && !this.inline) this._show();
110
151
  }
111
152
 
112
153
  teardownEvents() {
113
154
  this._overlay?.removeEventListener('click', this._onOverlayClick);
114
- document.removeEventListener('keydown', this._onKeydown);
155
+ this._overlay?.removeEventListener('cancel', this._onCancel);
156
+ this._overlay?.removeEventListener('close', this._onClose);
157
+ // Taken out of the document, the dialog leaves the top layer but stays
158
+ // open. Closed now, it opens as a modal again when the element comes back.
159
+ this._settle();
160
+ this._overlay?.classList.remove('is-active');
161
+ if (this._overlay?.open) this._overlay.close();
162
+ this._scrollLock?.release();
115
163
  }
116
164
 
117
165
  onAttributeChanged(name, _old, value) {
118
166
  if (!this._overlay) return;
119
167
  switch (name) {
120
168
  case 'open':
121
- if (this.inline) return;
169
+ if (this.inline || !this.isConnected) return; // setupEvents applies it on connect
122
170
  if (this.open) this._show();
123
171
  else this._hide();
124
172
  break;
125
- case 'inline': this._applyMode(); break;
173
+ case 'inline':
174
+ this._applyMode();
175
+ if (!this.inline && this.open && this.isConnected) this._show();
176
+ break;
126
177
  case 'title': if (this.takeTitle(value)) this._applyTitle(); break;
127
178
  }
128
179
  }
@@ -0,0 +1,42 @@
1
+ // Keep the page still behind an open <glk-modal> or <glk-sheet> (since
2
+ // 1.30.0). The top layer makes the page inert for pointer and keyboard, but
3
+ // a wheel or a finger over the dimmed area still scrolled the document.
4
+ // overflow: hidden on the root scroller stops that; inner scrollers of the
5
+ // page cannot be reached through the overlay, and the page's own scripts can
6
+ // still scroll it. One count for every open overlay, so closing one of two
7
+ // keeps the lock. Where a classic scrollbar disappears with it, the body
8
+ // takes its width as padding, so the page does not shift sideways; measured
9
+ // before and after, so a gutter the browser keeps is not paid twice.
10
+
11
+ let locks = 0;
12
+ let saved = null;
13
+
14
+ function lockPageScroll() {
15
+ if (locks++ > 0) return;
16
+ const root = document.documentElement, body = document.body;
17
+ const bar = window.innerWidth - root.clientWidth;
18
+ saved = { overflow: root.style.overflow, padding: body.style.paddingRight };
19
+ const padding = getComputedStyle(body).paddingRight;
20
+ root.style.overflow = 'hidden';
21
+ const gone = bar - (window.innerWidth - root.clientWidth);
22
+ if (gone > 0) body.style.paddingRight = `calc(${padding} + ${gone}px)`;
23
+ }
24
+
25
+ function unlockPageScroll() {
26
+ if (locks === 0 || --locks > 0) return;
27
+ document.documentElement.style.overflow = saved.overflow;
28
+ document.body.style.paddingRight = saved.padding;
29
+ saved = null;
30
+ }
31
+
32
+ /**
33
+ * One overlay's share of the lock: hold() while it is open, release() on
34
+ * every way it closes — both are safe to call twice.
35
+ */
36
+ export function scrollLock() {
37
+ let held = false;
38
+ return {
39
+ hold() { if (!held) { held = true; lockPageScroll(); } },
40
+ release() { if (held) { held = false; unlockPageScroll(); } },
41
+ };
42
+ }