@jungherz-de/glasskit-elements 1.19.2 → 1.21.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +56 -31
  3. package/SKILL.md +124 -27
  4. package/dist/components/base.js +185 -12
  5. package/dist/components/glk-accordion-item.js +20 -5
  6. package/dist/components/glk-button.js +69 -2
  7. package/dist/components/glk-calendar.js +3 -0
  8. package/dist/components/glk-checkbox.js +12 -2
  9. package/dist/components/glk-input.js +25 -4
  10. package/dist/components/glk-modal.js +99 -21
  11. package/dist/components/glk-pill.js +14 -1
  12. package/dist/components/glk-popover.js +21 -3
  13. package/dist/components/glk-radio.js +26 -2
  14. package/dist/components/glk-range.js +3 -1
  15. package/dist/components/glk-search.js +14 -1
  16. package/dist/components/glk-segmented.js +4 -0
  17. package/dist/components/glk-select.js +8 -2
  18. package/dist/components/glk-textarea.js +8 -2
  19. package/dist/components/glk-toggle.js +18 -11
  20. package/dist/glasskit-elements.esm.js +528 -68
  21. package/dist/glasskit-elements.js +528 -68
  22. package/dist/glasskit-elements.min.js +1 -1
  23. package/package.json +3 -3
  24. package/src/base.js +184 -11
  25. package/src/components/buttons/glk-button.js +69 -2
  26. package/src/components/containers/glk-accordion-item.js +20 -5
  27. package/src/components/feedback/glk-modal.js +99 -21
  28. package/src/components/feedback/glk-popover.js +21 -3
  29. package/src/components/forms/glk-calendar.js +3 -0
  30. package/src/components/forms/glk-checkbox.js +12 -2
  31. package/src/components/forms/glk-input.js +25 -4
  32. package/src/components/forms/glk-radio.js +26 -2
  33. package/src/components/forms/glk-range.js +3 -1
  34. package/src/components/forms/glk-search.js +14 -1
  35. package/src/components/forms/glk-segmented.js +4 -0
  36. package/src/components/forms/glk-select.js +8 -2
  37. package/src/components/forms/glk-textarea.js +8 -2
  38. package/src/components/forms/glk-toggle.js +18 -11
  39. package/src/components/navigation/glk-pill.js +14 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.19.2",
3
+ "version": "1.21.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.19.1"
52
+ "@jungherz-de/glasskit": ">=1.21.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@jungherz-de/glasskit": "^1.19.1",
55
+ "@jungherz-de/glasskit": "^1.21.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
@@ -11,25 +11,58 @@ import { componentsSheet, tokensCss } from '@jungherz-de/glasskit/glasskit-style
11
11
  // inherits them like any other custom property. They are wrapped in a cascade
12
12
  // layer so an ordinary (unlayered) brand stylesheet wins over them, no matter
13
13
  // whether it loads before or after this module.
14
+ //
15
+ // Only the custom properties go there. GlassKit's token blocks also set
16
+ // color-scheme, and on the document that is no default: it switches the whole
17
+ // page to dark — native controls, scrollbars, the root text colour, link
18
+ // colours — on pages that never asked for GlassKit's theme. The elements set
19
+ // color-scheme on their own theme wrapper instead (host sheet below).
20
+ //
21
+ // The --gl-* names themselves are still global. A page that declares them
22
+ // itself, or wants them on part of the page only, switches the defaults off
23
+ // with <html data-glk-defaults="off">. The attribute is watched, so it may
24
+ // also be set or removed later.
14
25
 
15
26
  const TOKENS_INJECTED = '__glkDefaultTokensInjected';
27
+ const DEFAULTS_ATTR = 'data-glk-defaults';
28
+ let defaultTokensSheet = null;
16
29
 
17
30
  function injectDefaultTokens() {
18
31
  if (typeof document === 'undefined') return; // SSR / non-DOM
19
32
  if (globalThis[TOKENS_INJECTED]) return; // another bundle copy did it
20
33
  globalThis[TOKENS_INJECTED] = true;
21
34
 
22
- const sheet = new CSSStyleSheet();
23
- sheet.replaceSync(`@layer glasskit-defaults { ${tokensCss} }`);
24
- // Append — never assign — so an app's own adopted sheets survive.
25
- document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
35
+ const tokens = tokensCss.replace(/color-scheme\s*:[^;}]*;?/g, '');
36
+ defaultTokensSheet = new CSSStyleSheet();
37
+ defaultTokensSheet.replaceSync(`@layer glasskit-defaults { ${tokens} }`);
38
+ syncDefaultTokens();
39
+ }
40
+
41
+ /** Adopts or drops the default tokens to match <html data-glk-defaults>. */
42
+ function syncDefaultTokens() {
43
+ if (!defaultTokensSheet) return;
44
+ const wanted = document.documentElement.getAttribute(DEFAULTS_ATTR) !== 'off';
45
+ const sheets = document.adoptedStyleSheets;
46
+ if (wanted === sheets.includes(defaultTokensSheet)) return;
47
+ // Add or remove only this sheet — never assign a fresh list — so an app's
48
+ // own adopted sheets survive.
49
+ document.adoptedStyleSheets = wanted
50
+ ? [...sheets, defaultTokensSheet]
51
+ : sheets.filter(sheet => sheet !== defaultTokensSheet);
26
52
  }
27
53
 
28
54
  injectDefaultTokens();
29
55
 
30
- // ── Global Theme Sync ──
31
- // Single MutationObserver that watches data-theme on <html>
56
+ // ── Global Theme & Density Sync ──
57
+ // Single MutationObserver that watches data-theme and data-density on <html>
32
58
  // and notifies all GlkElement instances.
59
+ //
60
+ // The density tokens themselves arrive from <html> by inheritance, like every
61
+ // other token: GlassKit's [data-density="compact"] preset is part of
62
+ // tokensCss, not of the components sheet. The wrapper mirrors the attribute
63
+ // anyway, as it mirrors data-theme — a rule keyed on it (a component rule, a
64
+ // subclass's hostStyles) can match inside a shadow root only on an element
65
+ // there.
33
66
 
34
67
  const instances = new Set();
35
68
 
@@ -37,6 +70,11 @@ function getCurrentTheme() {
37
70
  return document.documentElement.getAttribute('data-theme') || 'dark';
38
71
  }
39
72
 
73
+ /** The density on <html>, or null — there is no default to fall back to. */
74
+ function getCurrentDensity() {
75
+ return document.documentElement.getAttribute('data-density');
76
+ }
77
+
40
78
  function syncAllThemes() {
41
79
  const theme = getCurrentTheme();
42
80
  for (const instance of instances) {
@@ -44,32 +82,103 @@ function syncAllThemes() {
44
82
  }
45
83
  }
46
84
 
85
+ function syncAllDensities() {
86
+ const density = getCurrentDensity();
87
+ for (const instance of instances) {
88
+ instance._syncDensity(density);
89
+ }
90
+ }
91
+
47
92
  if (typeof window !== 'undefined' && typeof MutationObserver !== 'undefined') {
48
- const observer = new MutationObserver(syncAllThemes);
93
+ const observer = new MutationObserver(records => {
94
+ if (records.some(r => r.attributeName === 'data-theme')) syncAllThemes();
95
+ if (records.some(r => r.attributeName === 'data-density')) syncAllDensities();
96
+ if (records.some(r => r.attributeName === DEFAULTS_ATTR)) syncDefaultTokens();
97
+ });
49
98
  observer.observe(document.documentElement, {
50
99
  attributes: true,
51
- attributeFilter: ['data-theme']
100
+ attributeFilter: ['data-theme', 'data-density', DEFAULTS_ATTR]
52
101
  });
53
102
  }
54
103
 
55
104
  // ── Host Stylesheet ──
56
105
  // Sets display:block on all custom elements by default.
57
106
  // Inline components (badge, avatar) override this.
107
+ //
108
+ // The wrapper carries the theme's color-scheme, so native parts inside the
109
+ // elements — a select's list, a date picker, scrollbars — match the element,
110
+ // whatever the page around it uses.
111
+
112
+ const wrapperRules = `
113
+ .glk-wrapper { display: contents; }
114
+ .glk-wrapper[data-theme="dark"] { color-scheme: dark; }
115
+ .glk-wrapper[data-theme="light"] { color-scheme: light; }
116
+ `;
58
117
 
59
118
  const hostSheet = new CSSStyleSheet();
60
119
  hostSheet.replaceSync(`
61
120
  :host { display: block; }
62
121
  :host([hidden]) { display: none; }
63
- .glk-wrapper { display: contents; }
122
+ ${wrapperRules}
64
123
  `);
65
124
 
66
125
  const inlineHostSheet = new CSSStyleSheet();
67
126
  inlineHostSheet.replaceSync(`
68
127
  :host { display: inline-block; }
69
128
  :host([hidden]) { display: none; }
70
- .glk-wrapper { display: contents; }
129
+ ${wrapperRules}
71
130
  `);
72
131
 
132
+ /**
133
+ * Host rules for glk-checkbox, glk-radio and glk-toggle. Their control is an
134
+ * inline-flex label inside a block host; sitting on the text baseline it left
135
+ * room for descenders below, so the host was about 4px taller than the
136
+ * control and the control sat high in a centred row. Aligned to the top of
137
+ * its line, the label is as tall as the host.
138
+ */
139
+ export const checkControlSheet = new CSSStyleSheet();
140
+ checkControlSheet.replaceSync(`
141
+ .glass-checkbox, .glass-radio, .glass-toggle { vertical-align: top; }
142
+ `);
143
+
144
+ // ── Clicks Aimed at the Host ──
145
+ // The innermost target of the latest pointerdown anywhere in the document;
146
+ // tracked once, on first use.
147
+ let lastPressed = null;
148
+ let trackingPresses = false;
149
+
150
+ /**
151
+ * For elements whose control is a native input or button inside the shadow
152
+ * root: glk-checkbox, glk-radio, glk-toggle, glk-button. A click aimed at the
153
+ * host itself — host.click(), or the click a <label> around the element or
154
+ * naming it dispatches — reaches only the host. The control never saw it, so
155
+ * nothing changed where a native checkbox toggles and a native button
156
+ * submits. Such a click is handed to the control; the original is stopped, so
157
+ * listeners see one click, the control's.
158
+ *
159
+ * A pointer press on the host's own empty box (the host is a block, the
160
+ * control is narrower) also lands on the host. Chrome cannot tell it apart
161
+ * from a label's click by the event, only by where the pointer went down; it
162
+ * stays a no-op, as before. Returns the teardown.
163
+ */
164
+ export function forwardHostClicks(host, getField) {
165
+ if (!trackingPresses && typeof window !== 'undefined') {
166
+ trackingPresses = true;
167
+ window.addEventListener('pointerdown', e => { lastPressed = e.composedPath()[0]; },
168
+ { capture: true, passive: true });
169
+ }
170
+ const onClick = (e) => {
171
+ if (e.composedPath()[0] !== host) return; // from inside: the input had it
172
+ if (e.isTrusted && lastPressed === host) return; // a press on the empty box
173
+ const field = getField();
174
+ if (!field || field.disabled) return;
175
+ e.stopImmediatePropagation();
176
+ field.click();
177
+ };
178
+ host.addEventListener('click', onClick);
179
+ return () => host.removeEventListener('click', onClick);
180
+ }
181
+
73
182
  // ── Base Class ──
74
183
 
75
184
  export class GlkElement extends HTMLElement {
@@ -92,6 +201,13 @@ export class GlkElement extends HTMLElement {
92
201
  */
93
202
  static get observesLightDom() { return false; }
94
203
 
204
+ /**
205
+ * Turned on by elements built around one native field: focus() on the
206
+ * host, a click on the label text and a <label for> outside all land in
207
+ * that field.
208
+ */
209
+ static get delegatesFocus() { return false; }
210
+
95
211
  static get observedAttributes() {
96
212
  return [];
97
213
  }
@@ -99,7 +215,7 @@ export class GlkElement extends HTMLElement {
99
215
  constructor() {
100
216
  super();
101
217
  this._initialized = false;
102
- this._shadow = this.attachShadow({ mode: 'open' });
218
+ this._shadow = this.attachShadow({ mode: 'open', delegatesFocus: this.constructor.delegatesFocus });
103
219
  const displaySheet = this.constructor.displayInline ? inlineHostSheet : hostSheet;
104
220
  const sheets = [componentsSheet, displaySheet];
105
221
  const extra = this.constructor.hostStyles;
@@ -115,9 +231,17 @@ export class GlkElement extends HTMLElement {
115
231
  this._wrapper = document.createElement('div');
116
232
  this._wrapper.className = 'glk-wrapper';
117
233
  this._wrapper.setAttribute('data-theme', getCurrentTheme());
234
+ this._syncDensity(getCurrentDensity());
118
235
  this._shadow.appendChild(this._wrapper);
119
236
 
120
237
  this.render();
238
+ } else {
239
+ // Back in the document. While it was out, the observer did not reach
240
+ // it, so a theme or density switched in the meantime is caught up here
241
+ // — the wrapper's data-theme also sets the color-scheme of the native
242
+ // parts inside.
243
+ this._syncTheme(getCurrentTheme());
244
+ this._syncDensity(getCurrentDensity());
121
245
  }
122
246
 
123
247
  // Everything below runs on every connect, not just the first. Moving an
@@ -153,6 +277,16 @@ export class GlkElement extends HTMLElement {
153
277
  }
154
278
  }
155
279
 
280
+ /** Mirrors data-density from <html>; without one there, the wrapper has none. */
281
+ _syncDensity(density) {
282
+ if (!this._wrapper) return;
283
+ if (density) {
284
+ this._wrapper.setAttribute('data-density', density);
285
+ } else {
286
+ this._wrapper.removeAttribute('data-density');
287
+ }
288
+ }
289
+
156
290
  /** Subclasses override to build inner DOM inside this._wrapper. */
157
291
  render() {}
158
292
 
@@ -240,14 +374,26 @@ export class GlkFormElement extends GlkElement {
240
374
 
241
375
  static formAssociated = true;
242
376
 
377
+ // One native field inside, so focus goes there. The button groups
378
+ // (glk-segmented, glk-calendar) turn this off again: their first button is
379
+ // not the chosen one.
380
+ static get delegatesFocus() { return true; }
381
+
243
382
  constructor() {
244
383
  super();
245
384
  this._internals = this.attachInternals();
246
385
  }
247
386
 
387
+ attributeChangedCallback(name, oldValue, newValue) {
388
+ super.attributeChangedCallback(name, oldValue, newValue);
389
+ // required, min, pattern, type … all change what counts as valid.
390
+ if (this._initialized) this.syncValidity();
391
+ }
392
+
248
393
  get form() { return this._internals.form; }
249
394
  get validationMessage() { return this._internals.validationMessage; }
250
395
  get validity() { return this._internals.validity; }
396
+ get willValidate() { return this._internals.willValidate; }
251
397
 
252
398
  checkValidity() { return this._internals.checkValidity(); }
253
399
  reportValidity() { return this._internals.reportValidity(); }
@@ -266,9 +412,36 @@ export class GlkFormElement extends GlkElement {
266
412
 
267
413
  setFormValue(value) {
268
414
  this._internals.setFormValue(value);
415
+ this.syncValidity();
269
416
  }
270
417
 
271
418
  setValidity(flags, message, anchor) {
272
419
  this._internals.setValidity(flags, message, anchor);
273
420
  }
421
+
422
+ /**
423
+ * The native field whose constraint validation the host reports, or null.
424
+ * Subclasses built around one field return it.
425
+ */
426
+ get _validityField() { return null; }
427
+
428
+ /**
429
+ * A form asks the host — checkValidity(), reportValidity(), submitting — and
430
+ * the host knows nothing by itself, so the inner field's verdict is copied
431
+ * over after everything that can change it: a value (every setFormValue
432
+ * lands here), an attribute, a reset. The field is the anchor: the browser's
433
+ * bubble points at it, and reportValidity() focuses it.
434
+ */
435
+ syncValidity() {
436
+ const field = this._validityField;
437
+ if (!field) return;
438
+ const message = field.validationMessage;
439
+ // A field barred from validation (disabled) can still carry flags, but
440
+ // it has no message, and setValidity() refuses flags without one.
441
+ if (!field.willValidate || field.validity.valid || !message) {
442
+ this._internals.setValidity({});
443
+ } else {
444
+ this._internals.setValidity(field.validity, message, field);
445
+ }
446
+ }
274
447
  }
@@ -1,11 +1,26 @@
1
- import { GlkElement } from '../../base.js';
1
+ import { GlkElement, forwardHostClicks } from '../../base.js';
2
2
 
3
3
  const VARIANTS = ['primary', 'secondary', 'tertiary'];
4
4
  const SIZES = ['sm', 'md', 'lg', 'auto'];
5
5
 
6
+ // States that whoever owns the element sets on it — <glk-popover> sets
7
+ // aria-expanded on its trigger. Assistive technology reads the native button
8
+ // inside, so they are handed down to it.
9
+ const FORWARDED_ARIA = ['aria-expanded', 'aria-haspopup', 'aria-pressed'];
10
+
6
11
  class GlkButton extends GlkElement {
7
12
  static get observedAttributes() {
8
- return ['variant', 'size', 'disabled', 'type'];
13
+ return ['variant', 'size', 'disabled', 'type', ...FORWARDED_ARIA];
14
+ }
15
+
16
+ // The native button sits in the shadow root, outside every form. Form
17
+ // association gives the element its form, so type="submit" and "reset"
18
+ // can act on it (see _activateForm).
19
+ static formAssociated = true;
20
+
21
+ constructor() {
22
+ super();
23
+ this._internals = this.attachInternals();
9
24
  }
10
25
 
11
26
  render() {
@@ -16,6 +31,7 @@ class GlkButton extends GlkElement {
16
31
  if (this.getBoolAttr('disabled')) {
17
32
  this._btn.disabled = true;
18
33
  }
34
+ for (const attr of FORWARDED_ARIA) this._forward(attr);
19
35
 
20
36
  this._btn.appendChild(document.createElement('slot'));
21
37
  this._wrapper.appendChild(this._btn);
@@ -29,12 +45,50 @@ class GlkButton extends GlkElement {
29
45
  return;
30
46
  }
31
47
  this.emit('glk-click');
48
+ this._activateForm(e);
32
49
  };
33
50
  this._btn.addEventListener('click', this._onClick);
51
+ // host.click() and a <label> for the element reach the host only.
52
+ this._unforwardClicks = forwardHostClicks(this, () => this._btn);
34
53
  }
35
54
 
36
55
  teardownEvents() {
37
56
  this._btn?.removeEventListener('click', this._onClick);
57
+ this._unforwardClicks?.();
58
+ }
59
+
60
+ /**
61
+ * type="submit" submits the element's form, type="reset" resets it. Both
62
+ * wait until the click has finished bubbling, so a listener that calls
63
+ * preventDefault() on the click stops them — as on a native button.
64
+ */
65
+ _activateForm(click) {
66
+ const type = this.getAttribute('type');
67
+ const form = this._internals.form;
68
+ if (!form || (type !== 'submit' && type !== 'reset')) return;
69
+ // Through the prototype: a control with id or name "reset" or
70
+ // "requestSubmit" in the form shadows the methods on the form itself.
71
+ const { reset, requestSubmit } = HTMLFormElement.prototype;
72
+ setTimeout(() => {
73
+ if (click.defaultPrevented || this._internals.form !== form) return;
74
+ if (type === 'reset') {
75
+ reset.call(form);
76
+ return;
77
+ }
78
+ // requestSubmit() accepts native buttons only as the submitter, so the
79
+ // submit event is told about this element instead. The listener goes
80
+ // again right away: a form that fails validation fires no submit event,
81
+ // and the next submit must not inherit this submitter.
82
+ const nameSubmitter = (e) => {
83
+ Object.defineProperty(e, 'submitter', { configurable: true, get: () => this });
84
+ };
85
+ form.addEventListener('submit', nameSubmitter, { capture: true });
86
+ try {
87
+ requestSubmit.call(form);
88
+ } finally {
89
+ form.removeEventListener('submit', nameSubmitter, { capture: true });
90
+ }
91
+ });
38
92
  }
39
93
 
40
94
  onAttributeChanged(name) {
@@ -50,9 +104,17 @@ class GlkButton extends GlkElement {
50
104
  case 'type':
51
105
  this._btn.setAttribute('type', this.getAttribute('type') || 'button');
52
106
  break;
107
+ default:
108
+ if (FORWARDED_ARIA.includes(name)) this._forward(name);
53
109
  }
54
110
  }
55
111
 
112
+ _forward(attr) {
113
+ const value = this.getAttribute(attr);
114
+ if (value === null) this._btn.removeAttribute(attr);
115
+ else this._btn.setAttribute(attr, value);
116
+ }
117
+
56
118
  _computeClasses() {
57
119
  const classes = ['glass-btn'];
58
120
  const variant = this.getAttribute('variant');
@@ -74,6 +136,11 @@ class GlkButton extends GlkElement {
74
136
 
75
137
  get disabled() { return this.getBoolAttr('disabled'); }
76
138
  set disabled(v) { this.setBoolAttr('disabled', v); }
139
+
140
+ get type() { return this.getAttribute('type') || 'button'; }
141
+ set type(v) { this.setAttribute('type', v); }
142
+
143
+ get form() { return this._internals.form; }
77
144
  }
78
145
 
79
146
  customElements.define('glk-button', GlkButton);
@@ -9,17 +9,19 @@ class GlkAccordionItem extends GlkElement {
9
9
 
10
10
  render() {
11
11
  this._item = this.createElement('div', ['glass-accordion__item']);
12
- if (this.getBoolAttr('open')) this._item.classList.add('is-open');
13
12
 
14
- // Trigger button
15
- this._trigger = this.createElement('button', ['glass-accordion__trigger']);
13
+ // Trigger button — says whether its section is open, and which one it is
14
+ this._trigger = this.createElement('button', ['glass-accordion__trigger'], {
15
+ type: 'button',
16
+ 'aria-controls': 'content'
17
+ });
16
18
  this.takeTitle();
17
19
  this._triggerText = document.createTextNode(this._title || '');
18
20
  this._trigger.appendChild(this._triggerText);
19
21
  this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
20
22
 
21
23
  // Content
22
- this._content = this.createElement('div', ['glass-accordion__content']);
24
+ this._content = this.createElement('div', ['glass-accordion__content'], { id: 'content' });
23
25
  const body = this.createElement('div', ['glass-accordion__body']);
24
26
  body.appendChild(document.createElement('slot'));
25
27
  this._content.appendChild(body);
@@ -28,6 +30,19 @@ class GlkAccordionItem extends GlkElement {
28
30
  this._item.appendChild(this._content);
29
31
 
30
32
  this._wrapper.appendChild(this._item);
33
+ this._applyOpen();
34
+ }
35
+
36
+ /**
37
+ * A closed section is only squeezed to zero height; its links and fields
38
+ * stayed in the tab order. inert takes them out, and out of the
39
+ * accessibility tree, until the section opens.
40
+ */
41
+ _applyOpen() {
42
+ const open = this.getBoolAttr('open');
43
+ this._item.classList.toggle('is-open', open);
44
+ this._trigger.setAttribute('aria-expanded', String(open));
45
+ this._content.inert = !open;
31
46
  }
32
47
 
33
48
  setupEvents() {
@@ -46,7 +61,7 @@ class GlkAccordionItem extends GlkElement {
46
61
  if (!this._item) return;
47
62
  switch (name) {
48
63
  case 'open':
49
- this._item.classList.toggle('is-open', this.getBoolAttr('open'));
64
+ this._applyOpen();
50
65
  break;
51
66
  case 'title':
52
67
  if (this.takeTitle(value)) this._applyTitle();
@@ -1,5 +1,28 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
 
3
+ // Since 1.20.0 the overlay is a native <dialog>, opened with showModal(). The
4
+ // browser then does what a modal needs: the dialog lies in the top layer, the
5
+ // page behind it is inert — for the pointer, the keyboard and screen readers
6
+ // alike — focus moves in on opening and goes back to where it came from on
7
+ // closing, and the content is out of reach while the modal is closed.
8
+ // GlassKit (1.20.0) styles dialog.glass-modal-overlay for this.
9
+ //
10
+ // The element keeps the fade: the dialog opens before .is-active is set and
11
+ // closes only after the overlay has faded (safety net 400 ms; at once under
12
+ // prefers-reduced-motion). Escape arrives as the dialog's cancel event and
13
+ // takes the same path as a click on the dimmed area.
14
+ //
15
+ // Which element gets the focus on opening, the browsers decide differently:
16
+ // Chrome takes the first field of the content, Safari the first action. An
17
+ // element marked autofocus — in the content or among the actions — settles
18
+ // it in both.
19
+
20
+ const CLOSE_FALLBACK_MS = 400;
21
+
22
+ function reducedMotion() {
23
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
24
+ }
25
+
3
26
  class GlkModal extends GlkElement {
4
27
  static get observedAttributes() {
5
28
  return ['open', 'title'];
@@ -8,13 +31,13 @@ class GlkModal extends GlkElement {
8
31
  static get observesLightDom() { return true; }
9
32
 
10
33
  render() {
11
- this._overlay = this.createElement('div', ['glass-modal-overlay'], { part: 'overlay' });
34
+ this._overlay = this.createElement('dialog', ['glass-modal-overlay'], { part: 'overlay' });
12
35
 
13
36
  const modal = this.createElement('div', ['glass-modal'], { part: 'modal' });
14
37
 
15
- // Header
38
+ // Header — the title names the dialog
16
39
  const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
17
- this._titleEl = this.createElement('h2', ['glass-modal__title']);
40
+ this._titleEl = this.createElement('h2', ['glass-modal__title'], { id: 'title' });
18
41
  this.takeTitle();
19
42
  this._applyTitle();
20
43
  header.appendChild(this._titleEl);
@@ -35,10 +58,8 @@ class GlkModal extends GlkElement {
35
58
 
36
59
  this._overlay.appendChild(modal);
37
60
  this._wrapper.appendChild(this._overlay);
38
-
39
- if (this.getBoolAttr('open')) {
40
- this._overlay.classList.add('is-active');
41
- }
61
+ // `open` is applied in setupEvents, which also runs when a moved element
62
+ // comes back.
42
63
  }
43
64
 
44
65
  projectLightDom() {
@@ -63,33 +84,49 @@ class GlkModal extends GlkElement {
63
84
  setupEvents() {
64
85
  // Close on overlay click (outside modal)
65
86
  this._onOverlayClick = (e) => {
66
- if (e.target === this._overlay) {
67
- this.removeAttribute('open');
68
- this.emit('glk-close');
69
- }
87
+ if (e.target === this._overlay) this._dismiss();
70
88
  };
71
89
  this._overlay.addEventListener('click', this._onOverlayClick);
72
90
 
73
- // Close on Escape
74
- this._onKeydown = (e) => {
75
- if (e.key === 'Escape' && this.getBoolAttr('open')) {
76
- this.removeAttribute('open');
77
- this.emit('glk-close');
78
- }
91
+ // Escape: the dialog would close at once; it fades out like a click outside.
92
+ this._onCancel = (e) => {
93
+ e.preventDefault();
94
+ this._dismiss();
79
95
  };
80
- document.addEventListener('keydown', this._onKeydown);
96
+ this._overlay.addEventListener('cancel', this._onCancel);
97
+
98
+ // The browser may also close the dialog by itself — Chrome does on a
99
+ // second Escape without user activation in between, skipping cancel.
100
+ this._onClose = () => {
101
+ if (this._overlay.open) return; // a late event from an earlier close
102
+ this._overlay.classList.remove('is-active');
103
+ if (this.open) this._dismiss();
104
+ };
105
+ this._overlay.addEventListener('close', this._onClose);
106
+
107
+ if (this.open) this._show();
81
108
  }
82
109
 
83
110
  teardownEvents() {
84
111
  this._overlay?.removeEventListener('click', this._onOverlayClick);
85
- document.removeEventListener('keydown', this._onKeydown);
112
+ this._overlay?.removeEventListener('cancel', this._onCancel);
113
+ this._overlay?.removeEventListener('close', this._onClose);
114
+ // Taken out of the document, the dialog leaves the top layer but stays
115
+ // open — shown in place, not modal. Closed now, it opens as a modal again
116
+ // when the element comes back.
117
+ clearTimeout(this._closeTimer);
118
+ this._overlay?.removeEventListener('transitionend', this._onFaded);
119
+ this._overlay?.classList.remove('is-active');
120
+ if (this._overlay?.open) this._overlay.close();
86
121
  }
87
122
 
88
123
  onAttributeChanged(name, _old, value) {
89
124
  if (!this._overlay) return;
90
125
  switch (name) {
91
126
  case 'open':
92
- this._overlay.classList.toggle('is-active', this.getBoolAttr('open'));
127
+ if (!this.isConnected) return; // setupEvents applies it on connect
128
+ if (this.open) this._show();
129
+ else this._hide();
93
130
  break;
94
131
  case 'title':
95
132
  if (this.takeTitle(value)) this._applyTitle();
@@ -97,11 +134,52 @@ class GlkModal extends GlkElement {
97
134
  }
98
135
  }
99
136
 
137
+ _show() {
138
+ const dialog = this._overlay;
139
+ clearTimeout(this._closeTimer);
140
+ dialog.removeEventListener('transitionend', this._onFaded);
141
+ if (!dialog.open) {
142
+ dialog.showModal();
143
+ // The actions are copies in the shadow root; the originals are not shown.
144
+ (this._footer.querySelector('[autofocus]') || this.querySelector('[autofocus]'))?.focus();
145
+ }
146
+ void dialog.offsetHeight; // reflow, so the fade starts from the closed state
147
+ requestAnimationFrame(() => {
148
+ if (this.open) dialog.classList.add('is-active');
149
+ });
150
+ }
151
+
152
+ _hide() {
153
+ const dialog = this._overlay;
154
+ dialog.classList.remove('is-active');
155
+ this._onFaded = (event) => {
156
+ if (event && event.target !== dialog) return; // the panel's own transition
157
+ clearTimeout(this._closeTimer);
158
+ dialog.removeEventListener('transitionend', this._onFaded);
159
+ if (!this.open && dialog.open) dialog.close();
160
+ };
161
+ if (!dialog.open || reducedMotion()) {
162
+ this._onFaded();
163
+ return;
164
+ }
165
+ dialog.addEventListener('transitionend', this._onFaded);
166
+ this._closeTimer = setTimeout(this._onFaded, CLOSE_FALLBACK_MS);
167
+ }
168
+
169
+ /** Closed by the user — a click outside, Escape. */
170
+ _dismiss() {
171
+ this.removeAttribute('open');
172
+ this.emit('glk-close');
173
+ }
174
+
100
175
  show() { this.setAttribute('open', ''); }
101
176
  close() { this.removeAttribute('open'); }
102
177
 
103
178
  _applyTitle() {
104
- if (this._titleEl) this._titleEl.textContent = this._title || '';
179
+ if (!this._titleEl) return;
180
+ this._titleEl.textContent = this._title || '';
181
+ if (this._title) this._overlay.setAttribute('aria-labelledby', 'title');
182
+ else this._overlay.removeAttribute('aria-labelledby');
105
183
  }
106
184
 
107
185
  // `title` is the heading, never a tooltip — see GlkElement.takeTitle().