@jungherz-de/glasskit-elements 1.19.1 → 1.20.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 (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +40 -27
  3. package/SKILL.md +107 -25
  4. package/dist/components/base.js +145 -10
  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 +7 -4
  8. package/dist/components/glk-checkbox.js +12 -2
  9. package/dist/components/glk-date-strip.js +5 -4
  10. package/dist/components/glk-input.js +25 -4
  11. package/dist/components/glk-modal.js +99 -21
  12. package/dist/components/glk-pill.js +14 -1
  13. package/dist/components/glk-popover.js +21 -3
  14. package/dist/components/glk-radio.js +26 -2
  15. package/dist/components/glk-range.js +3 -1
  16. package/dist/components/glk-search.js +14 -1
  17. package/dist/components/glk-segmented.js +4 -0
  18. package/dist/components/glk-select.js +8 -2
  19. package/dist/components/glk-textarea.js +8 -2
  20. package/dist/components/glk-toggle.js +18 -11
  21. package/dist/components/shared/{dates-CNWaJf09.js → dates-D4JTqoiG.js} +25 -4
  22. package/dist/glasskit-elements.esm.js +519 -75
  23. package/dist/glasskit-elements.js +519 -75
  24. package/dist/glasskit-elements.min.js +1 -1
  25. package/package.json +3 -3
  26. package/src/base.js +144 -9
  27. package/src/components/buttons/glk-button.js +69 -2
  28. package/src/components/containers/glk-accordion-item.js +20 -5
  29. package/src/components/feedback/glk-modal.js +99 -21
  30. package/src/components/feedback/glk-popover.js +21 -3
  31. package/src/components/forms/glk-calendar.js +6 -3
  32. package/src/components/forms/glk-checkbox.js +12 -2
  33. package/src/components/forms/glk-input.js +25 -4
  34. package/src/components/forms/glk-radio.js +26 -2
  35. package/src/components/forms/glk-range.js +3 -1
  36. package/src/components/forms/glk-search.js +14 -1
  37. package/src/components/forms/glk-segmented.js +4 -0
  38. package/src/components/forms/glk-select.js +8 -2
  39. package/src/components/forms/glk-textarea.js +8 -2
  40. package/src/components/forms/glk-toggle.js +18 -11
  41. package/src/components/navigation/glk-date-strip.js +4 -3
  42. package/src/components/navigation/glk-pill.js +14 -1
  43. package/src/dates.js +25 -4
@@ -1,6 +1,29 @@
1
1
  import { GlkElement } from './base.js';
2
2
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
3
 
4
+ // Since 1.20.0 the overlay is a native <dialog>, opened with showModal(). The
5
+ // browser then does what a modal needs: the dialog lies in the top layer, the
6
+ // page behind it is inert — for the pointer, the keyboard and screen readers
7
+ // alike — focus moves in on opening and goes back to where it came from on
8
+ // closing, and the content is out of reach while the modal is closed.
9
+ // GlassKit (1.20.0) styles dialog.glass-modal-overlay for this.
10
+ //
11
+ // The element keeps the fade: the dialog opens before .is-active is set and
12
+ // closes only after the overlay has faded (safety net 400 ms; at once under
13
+ // prefers-reduced-motion). Escape arrives as the dialog's cancel event and
14
+ // takes the same path as a click on the dimmed area.
15
+ //
16
+ // Which element gets the focus on opening, the browsers decide differently:
17
+ // Chrome takes the first field of the content, Safari the first action. An
18
+ // element marked autofocus — in the content or among the actions — settles
19
+ // it in both.
20
+
21
+ const CLOSE_FALLBACK_MS = 400;
22
+
23
+ function reducedMotion() {
24
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
25
+ }
26
+
4
27
  class GlkModal extends GlkElement {
5
28
  static get observedAttributes() {
6
29
  return ['open', 'title'];
@@ -9,13 +32,13 @@ class GlkModal extends GlkElement {
9
32
  static get observesLightDom() { return true; }
10
33
 
11
34
  render() {
12
- this._overlay = this.createElement('div', ['glass-modal-overlay'], { part: 'overlay' });
35
+ this._overlay = this.createElement('dialog', ['glass-modal-overlay'], { part: 'overlay' });
13
36
 
14
37
  const modal = this.createElement('div', ['glass-modal'], { part: 'modal' });
15
38
 
16
- // Header
39
+ // Header — the title names the dialog
17
40
  const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
18
- this._titleEl = this.createElement('h2', ['glass-modal__title']);
41
+ this._titleEl = this.createElement('h2', ['glass-modal__title'], { id: 'title' });
19
42
  this.takeTitle();
20
43
  this._applyTitle();
21
44
  header.appendChild(this._titleEl);
@@ -36,10 +59,8 @@ class GlkModal extends GlkElement {
36
59
 
37
60
  this._overlay.appendChild(modal);
38
61
  this._wrapper.appendChild(this._overlay);
39
-
40
- if (this.getBoolAttr('open')) {
41
- this._overlay.classList.add('is-active');
42
- }
62
+ // `open` is applied in setupEvents, which also runs when a moved element
63
+ // comes back.
43
64
  }
44
65
 
45
66
  projectLightDom() {
@@ -64,33 +85,49 @@ class GlkModal extends GlkElement {
64
85
  setupEvents() {
65
86
  // Close on overlay click (outside modal)
66
87
  this._onOverlayClick = (e) => {
67
- if (e.target === this._overlay) {
68
- this.removeAttribute('open');
69
- this.emit('glk-close');
70
- }
88
+ if (e.target === this._overlay) this._dismiss();
71
89
  };
72
90
  this._overlay.addEventListener('click', this._onOverlayClick);
73
91
 
74
- // Close on Escape
75
- this._onKeydown = (e) => {
76
- if (e.key === 'Escape' && this.getBoolAttr('open')) {
77
- this.removeAttribute('open');
78
- this.emit('glk-close');
79
- }
92
+ // Escape: the dialog would close at once; it fades out like a click outside.
93
+ this._onCancel = (e) => {
94
+ e.preventDefault();
95
+ this._dismiss();
80
96
  };
81
- document.addEventListener('keydown', this._onKeydown);
97
+ this._overlay.addEventListener('cancel', this._onCancel);
98
+
99
+ // The browser may also close the dialog by itself — Chrome does on a
100
+ // second Escape without user activation in between, skipping cancel.
101
+ this._onClose = () => {
102
+ if (this._overlay.open) return; // a late event from an earlier close
103
+ this._overlay.classList.remove('is-active');
104
+ if (this.open) this._dismiss();
105
+ };
106
+ this._overlay.addEventListener('close', this._onClose);
107
+
108
+ if (this.open) this._show();
82
109
  }
83
110
 
84
111
  teardownEvents() {
85
112
  this._overlay?.removeEventListener('click', this._onOverlayClick);
86
- document.removeEventListener('keydown', this._onKeydown);
113
+ this._overlay?.removeEventListener('cancel', this._onCancel);
114
+ this._overlay?.removeEventListener('close', this._onClose);
115
+ // Taken out of the document, the dialog leaves the top layer but stays
116
+ // open — shown in place, not modal. Closed now, it opens as a modal again
117
+ // when the element comes back.
118
+ clearTimeout(this._closeTimer);
119
+ this._overlay?.removeEventListener('transitionend', this._onFaded);
120
+ this._overlay?.classList.remove('is-active');
121
+ if (this._overlay?.open) this._overlay.close();
87
122
  }
88
123
 
89
124
  onAttributeChanged(name, _old, value) {
90
125
  if (!this._overlay) return;
91
126
  switch (name) {
92
127
  case 'open':
93
- this._overlay.classList.toggle('is-active', this.getBoolAttr('open'));
128
+ if (!this.isConnected) return; // setupEvents applies it on connect
129
+ if (this.open) this._show();
130
+ else this._hide();
94
131
  break;
95
132
  case 'title':
96
133
  if (this.takeTitle(value)) this._applyTitle();
@@ -98,11 +135,52 @@ class GlkModal extends GlkElement {
98
135
  }
99
136
  }
100
137
 
138
+ _show() {
139
+ const dialog = this._overlay;
140
+ clearTimeout(this._closeTimer);
141
+ dialog.removeEventListener('transitionend', this._onFaded);
142
+ if (!dialog.open) {
143
+ dialog.showModal();
144
+ // The actions are copies in the shadow root; the originals are not shown.
145
+ (this._footer.querySelector('[autofocus]') || this.querySelector('[autofocus]'))?.focus();
146
+ }
147
+ void dialog.offsetHeight; // reflow, so the fade starts from the closed state
148
+ requestAnimationFrame(() => {
149
+ if (this.open) dialog.classList.add('is-active');
150
+ });
151
+ }
152
+
153
+ _hide() {
154
+ const dialog = this._overlay;
155
+ dialog.classList.remove('is-active');
156
+ this._onFaded = (event) => {
157
+ if (event && event.target !== dialog) return; // the panel's own transition
158
+ clearTimeout(this._closeTimer);
159
+ dialog.removeEventListener('transitionend', this._onFaded);
160
+ if (!this.open && dialog.open) dialog.close();
161
+ };
162
+ if (!dialog.open || reducedMotion()) {
163
+ this._onFaded();
164
+ return;
165
+ }
166
+ dialog.addEventListener('transitionend', this._onFaded);
167
+ this._closeTimer = setTimeout(this._onFaded, CLOSE_FALLBACK_MS);
168
+ }
169
+
170
+ /** Closed by the user — a click outside, Escape. */
171
+ _dismiss() {
172
+ this.removeAttribute('open');
173
+ this.emit('glk-close');
174
+ }
175
+
101
176
  show() { this.setAttribute('open', ''); }
102
177
  close() { this.removeAttribute('open'); }
103
178
 
104
179
  _applyTitle() {
105
- if (this._titleEl) this._titleEl.textContent = this._title || '';
180
+ if (!this._titleEl) return;
181
+ this._titleEl.textContent = this._title || '';
182
+ if (this._title) this._overlay.setAttribute('aria-labelledby', 'title');
183
+ else this._overlay.removeAttribute('aria-labelledby');
106
184
  }
107
185
 
108
186
  // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
@@ -1,9 +1,13 @@
1
1
  import { GlkElement } from './base.js';
2
2
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
3
 
4
+ // Handed down to the native button, which is what assistive technology reads —
5
+ // <glk-popover> sets aria-expanded on its trigger.
6
+ const FORWARDED_ARIA = ['aria-expanded', 'aria-haspopup', 'aria-pressed'];
7
+
4
8
  class GlkPill extends GlkElement {
5
9
  static get observedAttributes() {
6
- return ['label', 'disabled'];
10
+ return ['label', 'disabled', ...FORWARDED_ARIA];
7
11
  }
8
12
 
9
13
  render() {
@@ -13,6 +17,7 @@ class GlkPill extends GlkElement {
13
17
 
14
18
  const ariaLabel = this.getAttribute('label');
15
19
  if (ariaLabel) this._btn.setAttribute('aria-label', ariaLabel);
20
+ for (const attr of FORWARDED_ARIA) this._forward(attr);
16
21
 
17
22
  // Slotted content (SVG icons, text)
18
23
  this._btn.appendChild(document.createElement('slot'));
@@ -41,8 +46,16 @@ class GlkPill extends GlkElement {
41
46
  case 'disabled':
42
47
  this._btn.disabled = this.getBoolAttr('disabled');
43
48
  break;
49
+ default:
50
+ if (FORWARDED_ARIA.includes(name)) this._forward(name);
44
51
  }
45
52
  }
53
+
54
+ _forward(attr) {
55
+ const value = this.getAttribute(attr);
56
+ if (value === null) this._btn.removeAttribute(attr);
57
+ else this._btn.setAttribute(attr, value);
58
+ }
46
59
  }
47
60
 
48
61
  customElements.define('glk-pill', GlkPill);
@@ -11,9 +11,9 @@ class GlkPopover extends GlkElement {
11
11
  render() {
12
12
  this._anchor = this.createElement('div', ['glass-popover-anchor']);
13
13
 
14
- const triggerSlot = document.createElement('slot');
15
- triggerSlot.setAttribute('name', 'trigger');
16
- this._anchor.appendChild(triggerSlot);
14
+ this._triggerSlot = document.createElement('slot');
15
+ this._triggerSlot.setAttribute('name', 'trigger');
16
+ this._anchor.appendChild(this._triggerSlot);
17
17
 
18
18
  this._popover = this.createElement('div', ['glass-popover']);
19
19
  this._applyPlacement();
@@ -37,7 +37,23 @@ class GlkPopover extends GlkElement {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * The trigger says whether the popover is open. It lives in the light DOM,
42
+ * so aria-expanded goes on it directly; <glk-button> and <glk-pill> hand
43
+ * it on to their native button. Runs again when the trigger is swapped.
44
+ */
45
+ _applyExpanded() {
46
+ const open = String(this.getBoolAttr('open'));
47
+ for (const trigger of this._triggerSlot.assignedElements()) {
48
+ trigger.setAttribute('aria-expanded', open);
49
+ }
50
+ }
51
+
40
52
  setupEvents() {
53
+ this._onSlotChange = () => this._applyExpanded();
54
+ this._triggerSlot.addEventListener('slotchange', this._onSlotChange);
55
+ this._applyExpanded();
56
+
41
57
  // Toggle when the slotted trigger is clicked.
42
58
  this._onTriggerClick = (e) => {
43
59
  const path = e.composedPath();
@@ -69,6 +85,7 @@ class GlkPopover extends GlkElement {
69
85
  }
70
86
 
71
87
  teardownEvents() {
88
+ this._triggerSlot?.removeEventListener('slotchange', this._onSlotChange);
72
89
  this.removeEventListener('click', this._onTriggerClick);
73
90
  document.removeEventListener('click', this._onDocClick);
74
91
  document.removeEventListener('keydown', this._onKeydown);
@@ -80,6 +97,7 @@ class GlkPopover extends GlkElement {
80
97
  case 'open': {
81
98
  const isOpen = this.getBoolAttr('open');
82
99
  this._popover.classList.toggle('is-open', isOpen);
100
+ this._applyExpanded();
83
101
  this.emit(isOpen ? 'glk-open' : 'glk-close');
84
102
  break;
85
103
  }
@@ -1,4 +1,4 @@
1
- import { GlkFormElement } from './base.js';
1
+ import { GlkFormElement, checkControlSheet, forwardHostClicks } from './base.js';
2
2
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
3
 
4
4
  // ── Grouping ──
@@ -30,9 +30,11 @@ function syncGroupTabIndex(group) {
30
30
 
31
31
  class GlkRadio extends GlkFormElement {
32
32
  static get observedAttributes() {
33
- return ['checked', 'disabled', 'label', 'name', 'value'];
33
+ return ['checked', 'disabled', 'label', 'name', 'value', 'required'];
34
34
  }
35
35
 
36
+ static get hostStyles() { return checkControlSheet; }
37
+
36
38
  render() {
37
39
  const label = this.createElement('label', ['glass-radio']);
38
40
 
@@ -87,11 +89,13 @@ class GlkRadio extends GlkFormElement {
87
89
 
88
90
  this._input.addEventListener('change', this._onChange);
89
91
  this._input.addEventListener('keydown', this._onKeyDown);
92
+ this._unforwardClicks = forwardHostClicks(this, () => this._input);
90
93
  }
91
94
 
92
95
  teardownEvents() {
93
96
  this._input?.removeEventListener('change', this._onChange);
94
97
  this._input?.removeEventListener('keydown', this._onKeyDown);
98
+ this._unforwardClicks?.();
95
99
  }
96
100
 
97
101
  /** Shared by user change and arrow-key selection, so both look identical. */
@@ -126,6 +130,26 @@ class GlkRadio extends GlkFormElement {
126
130
  }
127
131
  }
128
132
 
133
+ get _validityField() { return this._input; }
134
+
135
+ /**
136
+ * Radios are missing a value as a group: one required radio makes the
137
+ * whole group required, and any checked one satisfies it. Each input here
138
+ * sits alone in its shadow root, so the group's verdict is worked out across
139
+ * the elements and handed to every member. The inner input carries
140
+ * `required` only while the group lacks a value — that way it produces the
141
+ * browser's own message for it.
142
+ */
143
+ syncValidity() {
144
+ const group = this._group();
145
+ const missing = group.some(el => el.hasAttribute('required')) && !group.some(el => el.checked);
146
+ for (const el of group) {
147
+ if (!el._input) continue; // not rendered yet; syncs when it is
148
+ el._input.required = missing;
149
+ super.syncValidity.call(el);
150
+ }
151
+ }
152
+
129
153
  onAttributeChanged(name) {
130
154
  if (this._syncing) return;
131
155
  if (!this._input) return;
@@ -11,7 +11,8 @@ class GlkRange extends GlkFormElement {
11
11
 
12
12
  const header = this.createElement('div', ['glass-range-header']);
13
13
 
14
- this._labelEl = this.createElement('label', []);
14
+ // Tied to the slider by an id that only has to be unique in this shadow root.
15
+ this._labelEl = this.createElement('label', [], { for: 'field' });
15
16
  this._labelEl.textContent = this.getAttribute('label') || '';
16
17
 
17
18
  this._valueEl = this.createElement('span', ['glass-range-value']);
@@ -20,6 +21,7 @@ class GlkRange extends GlkFormElement {
20
21
  header.appendChild(this._valueEl);
21
22
 
22
23
  this._input = this.createElement('input', ['glass-range'], {
24
+ id: 'field',
23
25
  type: 'range',
24
26
  min: this.getAttribute('min') || '0',
25
27
  max: this.getAttribute('max') || '100',
@@ -5,7 +5,7 @@ const SEARCH_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
5
5
 
6
6
  class GlkSearch extends GlkFormElement {
7
7
  static get observedAttributes() {
8
- return ['placeholder', 'name', 'value', 'disabled'];
8
+ return ['placeholder', 'name', 'value', 'disabled', 'label'];
9
9
  }
10
10
 
11
11
  render() {
@@ -21,6 +21,10 @@ class GlkSearch extends GlkFormElement {
21
21
  const placeholder = this.getAttribute('placeholder');
22
22
  if (placeholder) this._input.setAttribute('placeholder', placeholder);
23
23
 
24
+ // The search field has no visible label; `label` names it for assistive
25
+ // technology. Without it the placeholder is all a screen reader can say.
26
+ this._applyLabel();
27
+
24
28
  const name = this.getAttribute('name');
25
29
  if (name) this._input.setAttribute('name', name);
26
30
 
@@ -65,9 +69,18 @@ class GlkSearch extends GlkFormElement {
65
69
  case 'disabled':
66
70
  this._input.disabled = this.getBoolAttr('disabled');
67
71
  break;
72
+ case 'label':
73
+ this._applyLabel();
74
+ break;
68
75
  }
69
76
  }
70
77
 
78
+ _applyLabel() {
79
+ const label = this.getAttribute('label');
80
+ if (label) this._input.setAttribute('aria-label', label);
81
+ else this._input.removeAttribute('aria-label');
82
+ }
83
+
71
84
  _syncFormValue() {
72
85
  this.setFormValue(this._input.value);
73
86
  }
@@ -40,6 +40,10 @@ function parseList(json) {
40
40
  class GlkSegmented extends GlkFormElement {
41
41
  static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label']; }
42
42
 
43
+ // A row of buttons with one tab stop: delegated focus would land on the
44
+ // first button, not the chosen one.
45
+ static get delegatesFocus() { return false; }
46
+
43
47
  render() {
44
48
  this._group = this.createElement('div', ['glass-segmented'], { role: 'group', part: 'group' });
45
49
  this._wrapper.appendChild(this._group);
@@ -11,10 +11,11 @@ class GlkSelect extends GlkFormElement {
11
11
  render() {
12
12
  const group = this.createElement('div', ['glass-input-group']);
13
13
 
14
- this._labelEl = this.createElement('label', ['glass-label']);
14
+ // Tied to the field by an id that only has to be unique in this shadow root.
15
+ this._labelEl = this.createElement('label', ['glass-label'], { for: 'field' });
15
16
  this._labelEl.textContent = this.getAttribute('label') || '';
16
17
 
17
- this._select = this.createElement('select', ['glass-select']);
18
+ this._select = this.createElement('select', ['glass-select'], { id: 'field' });
18
19
 
19
20
  const name = this.getAttribute('name');
20
21
  if (name) this._select.setAttribute('name', name);
@@ -102,9 +103,14 @@ class GlkSelect extends GlkFormElement {
102
103
  this._applyValue(this.getAttribute('value'));
103
104
  this._syncFormValue();
104
105
  break;
106
+ case 'required':
107
+ this._select.required = this.getBoolAttr('required');
108
+ break;
105
109
  }
106
110
  }
107
111
 
112
+ get _validityField() { return this._select; }
113
+
108
114
  _syncFormValue() {
109
115
  this.setFormValue(this._select.value);
110
116
  }
@@ -9,10 +9,11 @@ class GlkTextarea extends GlkFormElement {
9
9
  render() {
10
10
  const group = this.createElement('div', ['glass-input-group']);
11
11
 
12
- this._labelEl = this.createElement('label', ['glass-label']);
12
+ // Tied to the field by an id that only has to be unique in this shadow root.
13
+ this._labelEl = this.createElement('label', ['glass-label'], { for: 'field' });
13
14
  this._labelEl.textContent = this.getAttribute('label') || '';
14
15
 
15
- this._textarea = this.createElement('textarea', ['glass-textarea']);
16
+ this._textarea = this.createElement('textarea', ['glass-textarea'], { id: 'field' });
16
17
  const placeholder = this.getAttribute('placeholder');
17
18
  if (placeholder) this._textarea.setAttribute('placeholder', placeholder);
18
19
 
@@ -70,9 +71,14 @@ class GlkTextarea extends GlkFormElement {
70
71
  this._textarea.value = this.getAttribute('value') || '';
71
72
  this._syncFormValue();
72
73
  break;
74
+ case 'required':
75
+ this._textarea.required = this.getBoolAttr('required');
76
+ break;
73
77
  }
74
78
  }
75
79
 
80
+ get _validityField() { return this._textarea; }
81
+
76
82
  _syncFormValue() {
77
83
  this.setFormValue(this._textarea.value);
78
84
  }
@@ -1,16 +1,23 @@
1
- import { GlkFormElement } from './base.js';
1
+ import { GlkFormElement, checkControlSheet, forwardHostClicks } from './base.js';
2
2
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
3
 
4
4
  class GlkToggle extends GlkFormElement {
5
5
  static get observedAttributes() {
6
- return ['checked', 'disabled', 'label', 'name', 'value'];
6
+ return ['checked', 'disabled', 'label', 'name', 'value', 'required'];
7
7
  }
8
8
 
9
+ static get hostStyles() { return checkControlSheet; }
10
+
9
11
  render() {
10
12
  const label = this.createElement('label', ['glass-toggle']);
11
13
 
14
+ // The native checkbox is the switch: role, checked state and name (from
15
+ // the label around it) all sit on one control. The host has no role of
16
+ // its own — it used to carry role="switch" as well, and screen readers met
17
+ // a switch with a checkbox inside.
12
18
  this._input = this.createElement('input', ['glass-toggle__input'], {
13
- type: 'checkbox'
19
+ type: 'checkbox',
20
+ role: 'switch'
14
21
  });
15
22
 
16
23
  const name = this.getAttribute('name');
@@ -29,14 +36,12 @@ class GlkToggle extends GlkFormElement {
29
36
 
30
37
  if (this.getBoolAttr('checked')) this._input.checked = true;
31
38
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
39
+ if (this.getBoolAttr('required')) this._input.required = true;
32
40
 
33
41
  this._defaultChecked = this.getBoolAttr('checked');
34
42
  this._wrapper.appendChild(label);
35
43
 
36
44
  this._syncFormValue();
37
- this.setAttribute('role', 'switch');
38
- this.setAttribute('aria-checked', String(this._input.checked));
39
- if (this.getBoolAttr('disabled')) this.setAttribute('aria-disabled', 'true');
40
45
  }
41
46
 
42
47
  setupEvents() {
@@ -44,16 +49,17 @@ class GlkToggle extends GlkFormElement {
44
49
  this._syncing = true;
45
50
  this.setBoolAttr('checked', this._input.checked);
46
51
  this._syncing = false;
47
- this.setAttribute('aria-checked', String(this._input.checked));
48
52
  this._syncFormValue();
49
53
  this.emit('glk-change', { checked: this._input.checked });
50
54
  this.dispatchEvent(new Event('change', { bubbles: true }));
51
55
  };
52
56
  this._input.addEventListener('change', this._onChange);
57
+ this._unforwardClicks = forwardHostClicks(this, () => this._input);
53
58
  }
54
59
 
55
60
  teardownEvents() {
56
61
  this._input?.removeEventListener('change', this._onChange);
62
+ this._unforwardClicks?.();
57
63
  }
58
64
 
59
65
  onAttributeChanged(name) {
@@ -62,12 +68,10 @@ class GlkToggle extends GlkFormElement {
62
68
  switch (name) {
63
69
  case 'checked':
64
70
  this._input.checked = this.getBoolAttr('checked');
65
- this.setAttribute('aria-checked', String(this._input.checked));
66
71
  this._syncFormValue();
67
72
  break;
68
73
  case 'disabled':
69
74
  this._input.disabled = this.getBoolAttr('disabled');
70
- this.setAttribute('aria-disabled', String(this.getBoolAttr('disabled')));
71
75
  break;
72
76
  case 'label':
73
77
  this._labelEl.textContent = this.getAttribute('label') || '';
@@ -75,9 +79,14 @@ class GlkToggle extends GlkFormElement {
75
79
  case 'name':
76
80
  this._input.setAttribute('name', this.getAttribute('name') || '');
77
81
  break;
82
+ case 'required':
83
+ this._input.required = this.getBoolAttr('required');
84
+ break;
78
85
  }
79
86
  }
80
87
 
88
+ get _validityField() { return this._input; }
89
+
81
90
  _syncFormValue() {
82
91
  const val = this.getAttribute('value') || 'on';
83
92
  this.setFormValue(this._input.checked ? val : null);
@@ -86,7 +95,6 @@ class GlkToggle extends GlkFormElement {
86
95
  resetValue() {
87
96
  this._input.checked = this._defaultChecked;
88
97
  this.setBoolAttr('checked', this._defaultChecked);
89
- this.setAttribute('aria-checked', String(this._defaultChecked));
90
98
  this._syncFormValue();
91
99
  }
92
100
 
@@ -101,7 +109,6 @@ class GlkToggle extends GlkFormElement {
101
109
  set checked(v) {
102
110
  if (this._input) this._input.checked = v;
103
111
  this.setBoolAttr('checked', v);
104
- this.setAttribute('aria-checked', String(v));
105
112
  this._syncFormValue();
106
113
  }
107
114
 
@@ -1,8 +1,8 @@
1
1
  // Date helpers shared by <glk-date-strip> and <glk-calendar>. Everything
2
2
  // works on local calendar days — a date is "YYYY-MM-DD" in the element API
3
3
  // and a local Date inside — so a day never shifts across midnight the way a
4
- // UTC timestamp would. Names come from Intl, keyed by the element's locale
5
- // attribute or the browser language.
4
+ // UTC timestamp would. Names come from Intl, in the language resolveLocale()
5
+ // finds for the element.
6
6
 
7
7
  const pad = n => String(n).padStart(2, '0');
8
8
 
@@ -30,8 +30,29 @@ function parseJsonObject(text) {
30
30
  }
31
31
  }
32
32
 
33
- function resolveLocale(attr) {
34
- return attr || (typeof navigator !== 'undefined' && navigator.language) || 'en';
33
+ /**
34
+ * The language to name days and months in: the element's locale attribute;
35
+ * else the language of the page around it — the lang of the nearest
36
+ * ancestor, looked up across shadow roots through their hosts, so
37
+ * <html lang="de"> reaches an element inside another component's shadow
38
+ * tree and a <section lang="en"> in a German page stays English; else the
39
+ * browser's; else English. An empty lang means "unknown" in HTML and falls
40
+ * through to the browser's language. (Since 1.19.2 — before, the browser's
41
+ * language came right after the attribute.)
42
+ */
43
+ function resolveLocale(attr, el) {
44
+ if (attr) return attr;
45
+ for (let node = el; node; ) {
46
+ const tagged = node.closest?.('[lang]');
47
+ if (tagged) {
48
+ const lang = tagged.getAttribute('lang').trim();
49
+ if (lang) return lang;
50
+ break;
51
+ }
52
+ const root = node.getRootNode?.();
53
+ node = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null;
54
+ }
55
+ return (typeof navigator !== 'undefined' && navigator.language) || 'en';
35
56
  }
36
57
 
37
58
  // Intl formatters are cheap to keep and not to make; one set per locale.