@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
@@ -7,7 +7,7 @@ import { isoDate, parseIso, addDays, parseJsonObject, resolveLocale, formatters,
7
7
  // today), value (YYYY-MM-DD), today (YYYY-MM-DD), min, max
8
8
  // (YYYY-MM-DD; days outside cannot be picked), marks (JSON
9
9
  // {date: tone | [tone, …]}, up to three dots), locale (default
10
- // the browser language), week-start (0 = Sunday … 6 = Saturday;
10
+ // the page language, else the browser's), week-start (0 = Sunday … 6 = Saturday;
11
11
  // default from the locale, Monday where the browser cannot say),
12
12
  // label (aria-label of the day group), prev-label / next-label
13
13
  // (names of the nav buttons, English by default)
@@ -45,6 +45,9 @@ class GlkCalendar extends GlkFormElement {
45
45
  return ['month', 'value', 'today', 'min', 'max', 'marks', 'locale', 'week-start', 'label', 'prev-label', 'next-label'];
46
46
  }
47
47
 
48
+ // Delegated focus would land on the month arrow, not on the chosen day.
49
+ static get delegatesFocus() { return false; }
50
+
48
51
  render() {
49
52
  this._root = this.createElement('div', ['glass-calendar'], { part: 'calendar' });
50
53
  const head = this.createElement('div', ['glass-calendar__head'], { part: 'head' });
@@ -65,7 +68,7 @@ class GlkCalendar extends GlkFormElement {
65
68
  get _todayIso() { return this.getAttribute('today') || isoDate(new Date()); }
66
69
 
67
70
  _build() {
68
- const locale = resolveLocale(this.getAttribute('locale'));
71
+ const locale = resolveLocale(this.getAttribute('locale'), this);
69
72
  const f = formatters(locale);
70
73
  const month = this.month;
71
74
  const first = parseIso(month);
@@ -242,7 +245,7 @@ class GlkCalendar extends GlkFormElement {
242
245
  get marks() { return parseJsonObject(this.getAttribute('marks')); }
243
246
  set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
244
247
 
245
- get locale() { return resolveLocale(this.getAttribute('locale')); }
248
+ get locale() { return resolveLocale(this.getAttribute('locale'), this); }
246
249
  set locale(v) {
247
250
  if (v) this.setAttribute('locale', v);
248
251
  else this.removeAttribute('locale');
@@ -1,12 +1,14 @@
1
- import { GlkFormElement } from '../../base.js';
1
+ import { GlkFormElement, checkControlSheet, forwardHostClicks } from '../../base.js';
2
2
 
3
3
  const CHECKMARK_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
4
4
 
5
5
  class GlkCheckbox extends GlkFormElement {
6
6
  static get observedAttributes() {
7
- return ['checked', 'disabled', 'label', 'name', 'value'];
7
+ return ['checked', 'disabled', 'label', 'name', 'value', 'required'];
8
8
  }
9
9
 
10
+ static get hostStyles() { return checkControlSheet; }
11
+
10
12
  render() {
11
13
  const label = this.createElement('label', ['glass-checkbox']);
12
14
 
@@ -29,6 +31,7 @@ class GlkCheckbox extends GlkFormElement {
29
31
 
30
32
  if (this.getBoolAttr('checked')) this._input.checked = true;
31
33
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
34
+ if (this.getBoolAttr('required')) this._input.required = true;
32
35
 
33
36
  this._defaultChecked = this.getBoolAttr('checked');
34
37
  this._wrapper.appendChild(label);
@@ -45,10 +48,12 @@ class GlkCheckbox extends GlkFormElement {
45
48
  this.dispatchEvent(new Event('change', { bubbles: true }));
46
49
  };
47
50
  this._input.addEventListener('change', this._onChange);
51
+ this._unforwardClicks = forwardHostClicks(this, () => this._input);
48
52
  }
49
53
 
50
54
  teardownEvents() {
51
55
  this._input?.removeEventListener('change', this._onChange);
56
+ this._unforwardClicks?.();
52
57
  }
53
58
 
54
59
  onAttributeChanged(name) {
@@ -68,9 +73,14 @@ class GlkCheckbox extends GlkFormElement {
68
73
  case 'name':
69
74
  this._input.setAttribute('name', this.getAttribute('name') || '');
70
75
  break;
76
+ case 'required':
77
+ this._input.required = this.getBoolAttr('required');
78
+ break;
71
79
  }
72
80
  }
73
81
 
82
+ get _validityField() { return this._input; }
83
+
74
84
  _syncFormValue() {
75
85
  const val = this.getAttribute('value') || 'on';
76
86
  this.setFormValue(this._input.checked ? val : null);
@@ -14,12 +14,15 @@ class GlkInput extends GlkFormElement {
14
14
  render() {
15
15
  const group = this.createElement('div', ['glass-input-group']);
16
16
 
17
- // Label
18
- this._labelEl = this.createElement('label', ['glass-label']);
17
+ // Label — tied to the field by id, which only has to be unique inside
18
+ // this shadow root. That gives the field its accessible name, and a click
19
+ // on the label text focuses it.
20
+ this._labelEl = this.createElement('label', ['glass-label'], { for: 'field' });
19
21
  this._labelEl.textContent = this.getAttribute('label') || '';
20
22
 
21
23
  // Input
22
24
  this._input = this.createElement('input', this._computeInputClasses(), {
25
+ id: 'field',
23
26
  type: this.getAttribute('type') || 'text'
24
27
  });
25
28
 
@@ -36,8 +39,8 @@ class GlkInput extends GlkFormElement {
36
39
  if (this.getBoolAttr('required')) this._input.required = true;
37
40
  for (const attr of FORWARDED) this._forward(attr);
38
41
 
39
- // Hint
40
- this._hintEl = this.createElement('span', this._computeHintClasses());
42
+ // Hint — read out with the field as its description
43
+ this._hintEl = this.createElement('span', this._computeHintClasses(), { id: 'hint' });
41
44
  this._hintEl.textContent = this.getAttribute('hint') || '';
42
45
 
43
46
  group.appendChild(this._labelEl);
@@ -47,6 +50,7 @@ class GlkInput extends GlkFormElement {
47
50
  this._group = group;
48
51
  this._wrapper.appendChild(group);
49
52
 
53
+ this._applyDescription();
50
54
  this._syncFormValue();
51
55
  }
52
56
 
@@ -84,12 +88,14 @@ class GlkInput extends GlkFormElement {
84
88
  case 'error':
85
89
  this._input.className = this._computeInputClasses().join(' ');
86
90
  this._hintEl.className = this._computeHintClasses().join(' ');
91
+ this._applyDescription();
87
92
  break;
88
93
  case 'hint':
89
94
  this._hintEl.textContent = this.getAttribute('hint') || '';
90
95
  if (this.getAttribute('hint') && !this._hintEl.parentNode) {
91
96
  this._group.appendChild(this._hintEl);
92
97
  }
98
+ this._applyDescription();
93
99
  break;
94
100
  case 'disabled':
95
101
  this._input.disabled = this.getBoolAttr('disabled');
@@ -109,6 +115,19 @@ class GlkInput extends GlkFormElement {
109
115
  }
110
116
  }
111
117
 
118
+ /**
119
+ * The hint describes the field; with `error` set it is the error message
120
+ * and the field is marked invalid. Server-side errors arrive this way, so
121
+ * `error` stays a marker for assistive technology and does not block the
122
+ * form — only the field's own constraints (required, pattern …) do.
123
+ */
124
+ _applyDescription() {
125
+ if (this.getAttribute('hint')) this._input.setAttribute('aria-describedby', 'hint');
126
+ else this._input.removeAttribute('aria-describedby');
127
+ if (this.getBoolAttr('error')) this._input.setAttribute('aria-invalid', 'true');
128
+ else this._input.removeAttribute('aria-invalid');
129
+ }
130
+
112
131
  _forward(attr) {
113
132
  const value = this.getAttribute(attr);
114
133
  if (value === null) this._input.removeAttribute(attr);
@@ -127,6 +146,8 @@ class GlkInput extends GlkFormElement {
127
146
  return classes;
128
147
  }
129
148
 
149
+ get _validityField() { return this._input; }
150
+
130
151
  _syncFormValue() {
131
152
  this.setFormValue(this._input.value);
132
153
  }
@@ -1,4 +1,4 @@
1
- import { GlkFormElement } from '../../base.js';
1
+ import { GlkFormElement, checkControlSheet, forwardHostClicks } from '../../base.js';
2
2
 
3
3
  // ── Grouping ──
4
4
  // Every <glk-radio> keeps its <input type="radio"> in its own shadow root, and
@@ -29,9 +29,11 @@ function syncGroupTabIndex(group) {
29
29
 
30
30
  class GlkRadio extends GlkFormElement {
31
31
  static get observedAttributes() {
32
- return ['checked', 'disabled', 'label', 'name', 'value'];
32
+ return ['checked', 'disabled', 'label', 'name', 'value', 'required'];
33
33
  }
34
34
 
35
+ static get hostStyles() { return checkControlSheet; }
36
+
35
37
  render() {
36
38
  const label = this.createElement('label', ['glass-radio']);
37
39
 
@@ -86,11 +88,13 @@ class GlkRadio extends GlkFormElement {
86
88
 
87
89
  this._input.addEventListener('change', this._onChange);
88
90
  this._input.addEventListener('keydown', this._onKeyDown);
91
+ this._unforwardClicks = forwardHostClicks(this, () => this._input);
89
92
  }
90
93
 
91
94
  teardownEvents() {
92
95
  this._input?.removeEventListener('change', this._onChange);
93
96
  this._input?.removeEventListener('keydown', this._onKeyDown);
97
+ this._unforwardClicks?.();
94
98
  }
95
99
 
96
100
  /** Shared by user change and arrow-key selection, so both look identical. */
@@ -125,6 +129,26 @@ class GlkRadio extends GlkFormElement {
125
129
  }
126
130
  }
127
131
 
132
+ get _validityField() { return this._input; }
133
+
134
+ /**
135
+ * Radios are missing a value as a group: one required radio makes the
136
+ * whole group required, and any checked one satisfies it. Each input here
137
+ * sits alone in its shadow root, so the group's verdict is worked out across
138
+ * the elements and handed to every member. The inner input carries
139
+ * `required` only while the group lacks a value — that way it produces the
140
+ * browser's own message for it.
141
+ */
142
+ syncValidity() {
143
+ const group = this._group();
144
+ const missing = group.some(el => el.hasAttribute('required')) && !group.some(el => el.checked);
145
+ for (const el of group) {
146
+ if (!el._input) continue; // not rendered yet; syncs when it is
147
+ el._input.required = missing;
148
+ super.syncValidity.call(el);
149
+ }
150
+ }
151
+
128
152
  onAttributeChanged(name) {
129
153
  if (this._syncing) return;
130
154
  if (!this._input) return;
@@ -10,7 +10,8 @@ class GlkRange extends GlkFormElement {
10
10
 
11
11
  const header = this.createElement('div', ['glass-range-header']);
12
12
 
13
- this._labelEl = this.createElement('label', []);
13
+ // Tied to the slider by an id that only has to be unique in this shadow root.
14
+ this._labelEl = this.createElement('label', [], { for: 'field' });
14
15
  this._labelEl.textContent = this.getAttribute('label') || '';
15
16
 
16
17
  this._valueEl = this.createElement('span', ['glass-range-value']);
@@ -19,6 +20,7 @@ class GlkRange extends GlkFormElement {
19
20
  header.appendChild(this._valueEl);
20
21
 
21
22
  this._input = this.createElement('input', ['glass-range'], {
23
+ id: 'field',
22
24
  type: 'range',
23
25
  min: this.getAttribute('min') || '0',
24
26
  max: this.getAttribute('max') || '100',
@@ -4,7 +4,7 @@ const SEARCH_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
4
4
 
5
5
  class GlkSearch extends GlkFormElement {
6
6
  static get observedAttributes() {
7
- return ['placeholder', 'name', 'value', 'disabled'];
7
+ return ['placeholder', 'name', 'value', 'disabled', 'label'];
8
8
  }
9
9
 
10
10
  render() {
@@ -20,6 +20,10 @@ class GlkSearch extends GlkFormElement {
20
20
  const placeholder = this.getAttribute('placeholder');
21
21
  if (placeholder) this._input.setAttribute('placeholder', placeholder);
22
22
 
23
+ // The search field has no visible label; `label` names it for assistive
24
+ // technology. Without it the placeholder is all a screen reader can say.
25
+ this._applyLabel();
26
+
23
27
  const name = this.getAttribute('name');
24
28
  if (name) this._input.setAttribute('name', name);
25
29
 
@@ -64,9 +68,18 @@ class GlkSearch extends GlkFormElement {
64
68
  case 'disabled':
65
69
  this._input.disabled = this.getBoolAttr('disabled');
66
70
  break;
71
+ case 'label':
72
+ this._applyLabel();
73
+ break;
67
74
  }
68
75
  }
69
76
 
77
+ _applyLabel() {
78
+ const label = this.getAttribute('label');
79
+ if (label) this._input.setAttribute('aria-label', label);
80
+ else this._input.removeAttribute('aria-label');
81
+ }
82
+
70
83
  _syncFormValue() {
71
84
  this.setFormValue(this._input.value);
72
85
  }
@@ -39,6 +39,10 @@ function parseList(json) {
39
39
  class GlkSegmented extends GlkFormElement {
40
40
  static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label']; }
41
41
 
42
+ // A row of buttons with one tab stop: delegated focus would land on the
43
+ // first button, not the chosen one.
44
+ static get delegatesFocus() { return false; }
45
+
42
46
  render() {
43
47
  this._group = this.createElement('div', ['glass-segmented'], { role: 'group', part: 'group' });
44
48
  this._wrapper.appendChild(this._group);
@@ -10,10 +10,11 @@ class GlkSelect extends GlkFormElement {
10
10
  render() {
11
11
  const group = this.createElement('div', ['glass-input-group']);
12
12
 
13
- this._labelEl = this.createElement('label', ['glass-label']);
13
+ // Tied to the field by an id that only has to be unique in this shadow root.
14
+ this._labelEl = this.createElement('label', ['glass-label'], { for: 'field' });
14
15
  this._labelEl.textContent = this.getAttribute('label') || '';
15
16
 
16
- this._select = this.createElement('select', ['glass-select']);
17
+ this._select = this.createElement('select', ['glass-select'], { id: 'field' });
17
18
 
18
19
  const name = this.getAttribute('name');
19
20
  if (name) this._select.setAttribute('name', name);
@@ -101,9 +102,14 @@ class GlkSelect extends GlkFormElement {
101
102
  this._applyValue(this.getAttribute('value'));
102
103
  this._syncFormValue();
103
104
  break;
105
+ case 'required':
106
+ this._select.required = this.getBoolAttr('required');
107
+ break;
104
108
  }
105
109
  }
106
110
 
111
+ get _validityField() { return this._select; }
112
+
107
113
  _syncFormValue() {
108
114
  this.setFormValue(this._select.value);
109
115
  }
@@ -8,10 +8,11 @@ class GlkTextarea extends GlkFormElement {
8
8
  render() {
9
9
  const group = this.createElement('div', ['glass-input-group']);
10
10
 
11
- this._labelEl = this.createElement('label', ['glass-label']);
11
+ // Tied to the field by an id that only has to be unique in this shadow root.
12
+ this._labelEl = this.createElement('label', ['glass-label'], { for: 'field' });
12
13
  this._labelEl.textContent = this.getAttribute('label') || '';
13
14
 
14
- this._textarea = this.createElement('textarea', ['glass-textarea']);
15
+ this._textarea = this.createElement('textarea', ['glass-textarea'], { id: 'field' });
15
16
  const placeholder = this.getAttribute('placeholder');
16
17
  if (placeholder) this._textarea.setAttribute('placeholder', placeholder);
17
18
 
@@ -69,9 +70,14 @@ class GlkTextarea extends GlkFormElement {
69
70
  this._textarea.value = this.getAttribute('value') || '';
70
71
  this._syncFormValue();
71
72
  break;
73
+ case 'required':
74
+ this._textarea.required = this.getBoolAttr('required');
75
+ break;
72
76
  }
73
77
  }
74
78
 
79
+ get _validityField() { return this._textarea; }
80
+
75
81
  _syncFormValue() {
76
82
  this.setFormValue(this._textarea.value);
77
83
  }
@@ -1,15 +1,22 @@
1
- import { GlkFormElement } from '../../base.js';
1
+ import { GlkFormElement, checkControlSheet, forwardHostClicks } from '../../base.js';
2
2
 
3
3
  class GlkToggle extends GlkFormElement {
4
4
  static get observedAttributes() {
5
- return ['checked', 'disabled', 'label', 'name', 'value'];
5
+ return ['checked', 'disabled', 'label', 'name', 'value', 'required'];
6
6
  }
7
7
 
8
+ static get hostStyles() { return checkControlSheet; }
9
+
8
10
  render() {
9
11
  const label = this.createElement('label', ['glass-toggle']);
10
12
 
13
+ // The native checkbox is the switch: role, checked state and name (from
14
+ // the label around it) all sit on one control. The host has no role of
15
+ // its own — it used to carry role="switch" as well, and screen readers met
16
+ // a switch with a checkbox inside.
11
17
  this._input = this.createElement('input', ['glass-toggle__input'], {
12
- type: 'checkbox'
18
+ type: 'checkbox',
19
+ role: 'switch'
13
20
  });
14
21
 
15
22
  const name = this.getAttribute('name');
@@ -28,14 +35,12 @@ class GlkToggle extends GlkFormElement {
28
35
 
29
36
  if (this.getBoolAttr('checked')) this._input.checked = true;
30
37
  if (this.getBoolAttr('disabled')) this._input.disabled = true;
38
+ if (this.getBoolAttr('required')) this._input.required = true;
31
39
 
32
40
  this._defaultChecked = this.getBoolAttr('checked');
33
41
  this._wrapper.appendChild(label);
34
42
 
35
43
  this._syncFormValue();
36
- this.setAttribute('role', 'switch');
37
- this.setAttribute('aria-checked', String(this._input.checked));
38
- if (this.getBoolAttr('disabled')) this.setAttribute('aria-disabled', 'true');
39
44
  }
40
45
 
41
46
  setupEvents() {
@@ -43,16 +48,17 @@ class GlkToggle extends GlkFormElement {
43
48
  this._syncing = true;
44
49
  this.setBoolAttr('checked', this._input.checked);
45
50
  this._syncing = false;
46
- this.setAttribute('aria-checked', String(this._input.checked));
47
51
  this._syncFormValue();
48
52
  this.emit('glk-change', { checked: this._input.checked });
49
53
  this.dispatchEvent(new Event('change', { bubbles: true }));
50
54
  };
51
55
  this._input.addEventListener('change', this._onChange);
56
+ this._unforwardClicks = forwardHostClicks(this, () => this._input);
52
57
  }
53
58
 
54
59
  teardownEvents() {
55
60
  this._input?.removeEventListener('change', this._onChange);
61
+ this._unforwardClicks?.();
56
62
  }
57
63
 
58
64
  onAttributeChanged(name) {
@@ -61,12 +67,10 @@ class GlkToggle extends GlkFormElement {
61
67
  switch (name) {
62
68
  case 'checked':
63
69
  this._input.checked = this.getBoolAttr('checked');
64
- this.setAttribute('aria-checked', String(this._input.checked));
65
70
  this._syncFormValue();
66
71
  break;
67
72
  case 'disabled':
68
73
  this._input.disabled = this.getBoolAttr('disabled');
69
- this.setAttribute('aria-disabled', String(this.getBoolAttr('disabled')));
70
74
  break;
71
75
  case 'label':
72
76
  this._labelEl.textContent = this.getAttribute('label') || '';
@@ -74,9 +78,14 @@ class GlkToggle extends GlkFormElement {
74
78
  case 'name':
75
79
  this._input.setAttribute('name', this.getAttribute('name') || '');
76
80
  break;
81
+ case 'required':
82
+ this._input.required = this.getBoolAttr('required');
83
+ break;
77
84
  }
78
85
  }
79
86
 
87
+ get _validityField() { return this._input; }
88
+
80
89
  _syncFormValue() {
81
90
  const val = this.getAttribute('value') || 'on';
82
91
  this.setFormValue(this._input.checked ? val : null);
@@ -85,7 +94,6 @@ class GlkToggle extends GlkFormElement {
85
94
  resetValue() {
86
95
  this._input.checked = this._defaultChecked;
87
96
  this.setBoolAttr('checked', this._defaultChecked);
88
- this.setAttribute('aria-checked', String(this._defaultChecked));
89
97
  this._syncFormValue();
90
98
  }
91
99
 
@@ -100,7 +108,6 @@ class GlkToggle extends GlkFormElement {
100
108
  set checked(v) {
101
109
  if (this._input) this._input.checked = v;
102
110
  this.setBoolAttr('checked', v);
103
- this.setAttribute('aria-checked', String(v));
104
111
  this._syncFormValue();
105
112
  }
106
113
 
@@ -8,7 +8,8 @@ import { revealCentered } from '../../reveal.js';
8
8
  // Attributes: start (YYYY-MM-DD, default today), days (count, default 7),
9
9
  // value (YYYY-MM-DD), today (YYYY-MM-DD, default the browser's
10
10
  // day), marks (JSON {date: tone | {tone, disabled}}), locale
11
- // (default the browser language), label (aria-label of the group)
11
+ // (default the page language, else the browser's), label
12
+ // (aria-label of the group)
12
13
  // Properties: value, marks (object or JSON text), locale, label
13
14
  // Event: glk-change { value } — only on a change made by the user
14
15
  // Part: strip
@@ -41,7 +42,7 @@ class GlkDateStrip extends GlkElement {
41
42
  }
42
43
 
43
44
  _build() {
44
- const f = formatters(resolveLocale(this.getAttribute('locale')));
45
+ const f = formatters(resolveLocale(this.getAttribute('locale'), this));
45
46
  const marks = parseJsonObject(this.getAttribute('marks'));
46
47
  const start = parseIso(this.getAttribute('start')) ?? new Date();
47
48
  const today = this.getAttribute('today') || isoDate(new Date());
@@ -153,7 +154,7 @@ class GlkDateStrip extends GlkElement {
153
154
  get marks() { return parseJsonObject(this.getAttribute('marks')); }
154
155
  set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
155
156
 
156
- get locale() { return resolveLocale(this.getAttribute('locale')); }
157
+ get locale() { return resolveLocale(this.getAttribute('locale'), this); }
157
158
  set locale(v) {
158
159
  if (v) this.setAttribute('locale', v);
159
160
  else this.removeAttribute('locale');
@@ -1,8 +1,12 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
 
3
+ // Handed down to the native button, which is what assistive technology reads —
4
+ // <glk-popover> sets aria-expanded on its trigger.
5
+ const FORWARDED_ARIA = ['aria-expanded', 'aria-haspopup', 'aria-pressed'];
6
+
3
7
  class GlkPill extends GlkElement {
4
8
  static get observedAttributes() {
5
- return ['label', 'disabled'];
9
+ return ['label', 'disabled', ...FORWARDED_ARIA];
6
10
  }
7
11
 
8
12
  render() {
@@ -12,6 +16,7 @@ class GlkPill extends GlkElement {
12
16
 
13
17
  const ariaLabel = this.getAttribute('label');
14
18
  if (ariaLabel) this._btn.setAttribute('aria-label', ariaLabel);
19
+ for (const attr of FORWARDED_ARIA) this._forward(attr);
15
20
 
16
21
  // Slotted content (SVG icons, text)
17
22
  this._btn.appendChild(document.createElement('slot'));
@@ -40,8 +45,16 @@ class GlkPill extends GlkElement {
40
45
  case 'disabled':
41
46
  this._btn.disabled = this.getBoolAttr('disabled');
42
47
  break;
48
+ default:
49
+ if (FORWARDED_ARIA.includes(name)) this._forward(name);
43
50
  }
44
51
  }
52
+
53
+ _forward(attr) {
54
+ const value = this.getAttribute(attr);
55
+ if (value === null) this._btn.removeAttribute(attr);
56
+ else this._btn.setAttribute(attr, value);
57
+ }
45
58
  }
46
59
 
47
60
  customElements.define('glk-pill', GlkPill);
package/src/dates.js CHANGED
@@ -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
  export const pad = n => String(n).padStart(2, '0');
8
8
 
@@ -30,8 +30,29 @@ export function parseJsonObject(text) {
30
30
  }
31
31
  }
32
32
 
33
- export 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
+ export 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.