@jungherz-de/glasskit-elements 1.15.2 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.15.2",
3
+ "version": "1.16.1",
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",
@@ -45,10 +45,10 @@
45
45
  "vanilla-js"
46
46
  ],
47
47
  "peerDependencies": {
48
- "@jungherz-de/glasskit": ">=1.15.1"
48
+ "@jungherz-de/glasskit": ">=1.16.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@jungherz-de/glasskit": "^1.15.1",
51
+ "@jungherz-de/glasskit": "^1.16.0",
52
52
  "@rollup/plugin-node-resolve": "^15.0.0",
53
53
  "@rollup/plugin-terser": "^0.4.0",
54
54
  "rollup": "^4.0.0"
package/src/base.js CHANGED
@@ -181,6 +181,24 @@ export class GlkElement extends HTMLElement {
181
181
 
182
182
  // ── Utility Methods ──
183
183
 
184
+ /**
185
+ * For elements whose heading arrives as `title`. That is also the global
186
+ * HTML attribute, and a title on the host shows as a browser tooltip — the
187
+ * sheet's heading floating over its form. So the value is read into
188
+ * this._title and the attribute is taken off the host; a later
189
+ * setAttribute('title') comes through attributeChangedCallback, is taken
190
+ * the same way and removed again, and the element's `title` accessor
191
+ * answers from this._title. The removal fires the callback with null,
192
+ * which is ignored here — only an empty string clears the heading.
193
+ * Returns whether a value was taken.
194
+ */
195
+ takeTitle(value = this.getAttribute('title')) {
196
+ if (value === null) return false;
197
+ this._title = value;
198
+ this.removeAttribute('title');
199
+ return true;
200
+ }
201
+
184
202
  getBoolAttr(name) {
185
203
  return this.hasAttribute(name);
186
204
  }
@@ -13,7 +13,8 @@ class GlkAccordionItem extends GlkElement {
13
13
 
14
14
  // Trigger button
15
15
  this._trigger = this.createElement('button', ['glass-accordion__trigger']);
16
- this._triggerText = document.createTextNode(this.getAttribute('title') || '');
16
+ this.takeTitle();
17
+ this._triggerText = document.createTextNode(this._title || '');
17
18
  this._trigger.appendChild(this._triggerText);
18
19
  this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
19
20
 
@@ -41,18 +42,29 @@ class GlkAccordionItem extends GlkElement {
41
42
  this._trigger?.removeEventListener('click', this._onClick);
42
43
  }
43
44
 
44
- onAttributeChanged(name) {
45
+ onAttributeChanged(name, _old, value) {
45
46
  if (!this._item) return;
46
47
  switch (name) {
47
48
  case 'open':
48
49
  this._item.classList.toggle('is-open', this.getBoolAttr('open'));
49
50
  break;
50
51
  case 'title':
51
- this._triggerText.textContent = this.getAttribute('title') || '';
52
+ if (this.takeTitle(value)) this._applyTitle();
52
53
  break;
53
54
  }
54
55
  }
55
56
 
57
+ _applyTitle() {
58
+ if (this._triggerText) this._triggerText.textContent = this._title || '';
59
+ }
60
+
61
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
62
+ get title() { return this._title ?? ''; }
63
+ set title(v) {
64
+ this._title = v == null ? '' : String(v);
65
+ this._applyTitle();
66
+ }
67
+
56
68
  get open() { return this.getBoolAttr('open'); }
57
69
  set open(v) { this.setBoolAttr('open', v); }
58
70
  }
@@ -48,7 +48,8 @@ class GlkListItem extends GlkElement {
48
48
  // Content — title + optional subtitle.
49
49
  this._content = this.createElement('div', ['glass-list__content']);
50
50
  this._titleEl = this.createElement('div', ['glass-list__title']);
51
- this._titleEl.textContent = this.getAttribute('title') || '';
51
+ this.takeTitle();
52
+ this._titleEl.textContent = this._title || '';
52
53
  this._subtitleEl = this.createElement('div', ['glass-list__subtitle']);
53
54
  if (this.getBoolAttr('wrap')) this._subtitleEl.classList.add('glass-list__subtitle--wrap');
54
55
  const initialSubtitle = this.getAttribute('subtitle') || '';
@@ -114,11 +115,11 @@ class GlkListItem extends GlkElement {
114
115
  this._item?.removeEventListener('click', this._onClick);
115
116
  }
116
117
 
117
- onAttributeChanged(name) {
118
+ onAttributeChanged(name, _old, value) {
118
119
  if (!this._item) return;
119
120
  switch (name) {
120
121
  case 'title':
121
- this._titleEl.textContent = this.getAttribute('title') || '';
122
+ if (this.takeTitle(value)) this._applyTitle();
122
123
  break;
123
124
  case 'subtitle': {
124
125
  const value = this.getAttribute('subtitle') || '';
@@ -162,6 +163,17 @@ class GlkListItem extends GlkElement {
162
163
  }
163
164
  }
164
165
 
166
+ _applyTitle() {
167
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
168
+ }
169
+
170
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
171
+ get title() { return this._title ?? ''; }
172
+ set title(v) {
173
+ this._title = v == null ? '' : String(v);
174
+ this._applyTitle();
175
+ }
176
+
165
177
  get interactive() { return this.getBoolAttr('interactive'); }
166
178
  set interactive(v) { this.setBoolAttr('interactive', v); }
167
179
 
@@ -24,11 +24,12 @@ class GlkEmpty extends GlkElement {
24
24
  action.appendChild(this.createElement('slot', [], { name: 'action' }));
25
25
  root.append(icon, this._titleEl, this._textEl, action);
26
26
  this._wrapper.appendChild(root);
27
+ this.takeTitle();
27
28
  this._update();
28
29
  }
29
30
 
30
31
  _update() {
31
- const title = this.getAttribute('title') || '';
32
+ const title = this._title || '';
32
33
  const text = this.getAttribute('text') || '';
33
34
  this._titleEl.textContent = title;
34
35
  this._titleEl.hidden = !title;
@@ -36,10 +37,22 @@ class GlkEmpty extends GlkElement {
36
37
  this._textEl.hidden = !text;
37
38
  }
38
39
 
39
- onAttributeChanged() {
40
+ onAttributeChanged(name, _old, value) {
41
+ if (name === 'title' && !this.takeTitle(value)) return;
40
42
  if (this._titleEl) this._update();
41
43
  }
42
44
 
45
+ _applyTitle() {
46
+ if (this._titleEl) this._update();
47
+ }
48
+
49
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
50
+ get title() { return this._title ?? ''; }
51
+ set title(v) {
52
+ this._title = v == null ? '' : String(v);
53
+ this._applyTitle();
54
+ }
55
+
43
56
  get text() { return this.getAttribute('text') || ''; }
44
57
  set text(v) {
45
58
  if (v) this.setAttribute('text', v);
@@ -15,7 +15,8 @@ class GlkModal extends GlkElement {
15
15
  // Header
16
16
  const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
17
17
  this._titleEl = this.createElement('h2', ['glass-modal__title']);
18
- this._titleEl.textContent = this.getAttribute('title') || '';
18
+ this.takeTitle();
19
+ this._applyTitle();
19
20
  header.appendChild(this._titleEl);
20
21
 
21
22
  // Body
@@ -84,14 +85,14 @@ class GlkModal extends GlkElement {
84
85
  document.removeEventListener('keydown', this._onKeydown);
85
86
  }
86
87
 
87
- onAttributeChanged(name) {
88
+ onAttributeChanged(name, _old, value) {
88
89
  if (!this._overlay) return;
89
90
  switch (name) {
90
91
  case 'open':
91
92
  this._overlay.classList.toggle('is-active', this.getBoolAttr('open'));
92
93
  break;
93
94
  case 'title':
94
- this._titleEl.textContent = this.getAttribute('title') || '';
95
+ if (this.takeTitle(value)) this._applyTitle();
95
96
  break;
96
97
  }
97
98
  }
@@ -99,6 +100,17 @@ class GlkModal extends GlkElement {
99
100
  show() { this.setAttribute('open', ''); }
100
101
  close() { this.removeAttribute('open'); }
101
102
 
103
+ _applyTitle() {
104
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
105
+ }
106
+
107
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
108
+ get title() { return this._title ?? ''; }
109
+ set title(v) {
110
+ this._title = v == null ? '' : String(v);
111
+ this._applyTitle();
112
+ }
113
+
102
114
  get open() { return this.getBoolAttr('open'); }
103
115
  set open(v) { this.setBoolAttr('open', v); }
104
116
  }
@@ -37,12 +37,14 @@ class GlkSheet extends GlkElement {
37
37
  this._panel.append(this._titleEl, body, actions);
38
38
  this._overlay.appendChild(this._panel);
39
39
  this._wrapper.appendChild(this._overlay);
40
+ this.takeTitle();
40
41
  this._applyTitle();
41
42
  this._applyMode();
42
43
  }
43
44
 
44
45
  _applyTitle() {
45
- const title = this.getAttribute('title') || '';
46
+ if (!this._titleEl) return;
47
+ const title = this._title || '';
46
48
  this._titleEl.textContent = title;
47
49
  this._titleEl.hidden = !title;
48
50
  if (title) this._panel.setAttribute('aria-label', title);
@@ -112,7 +114,7 @@ class GlkSheet extends GlkElement {
112
114
  document.removeEventListener('keydown', this._onKeydown);
113
115
  }
114
116
 
115
- onAttributeChanged(name) {
117
+ onAttributeChanged(name, _old, value) {
116
118
  if (!this._overlay) return;
117
119
  switch (name) {
118
120
  case 'open':
@@ -121,13 +123,20 @@ class GlkSheet extends GlkElement {
121
123
  else this._hide();
122
124
  break;
123
125
  case 'inline': this._applyMode(); break;
124
- case 'title': this._applyTitle(); break;
126
+ case 'title': if (this.takeTitle(value)) this._applyTitle(); break;
125
127
  }
126
128
  }
127
129
 
128
130
  show() { this.setAttribute('open', ''); }
129
131
  close() { this.removeAttribute('open'); }
130
132
 
133
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
134
+ get title() { return this._title ?? ''; }
135
+ set title(v) {
136
+ this._title = v == null ? '' : String(v);
137
+ this._applyTitle();
138
+ }
139
+
131
140
  get open() { return this.getBoolAttr('open'); }
132
141
  set open(v) { this.setBoolAttr('open', v); }
133
142
 
@@ -0,0 +1,259 @@
1
+ import { GlkFormElement } from '../../base.js';
2
+ import { isoDate, parseIso, addDays, parseJsonObject, resolveLocale, formatters, weekdayShort, firstWeekday, isTone } from '../../dates.js';
3
+
4
+ // One month with a day to pick. Came back from EhrenPfoten in 1.16.0.
5
+ //
6
+ // Attributes: month (YYYY-MM; without it the month of the value, else of
7
+ // today), value (YYYY-MM-DD), today (YYYY-MM-DD), min, max
8
+ // (YYYY-MM-DD; days outside cannot be picked), marks (JSON
9
+ // {date: tone | [tone, …]}, up to three dots), locale (default
10
+ // the browser language), week-start (0 = Sunday … 6 = Saturday;
11
+ // default from the locale, Monday where the browser cannot say),
12
+ // label (aria-label of the day group), prev-label / next-label
13
+ // (names of the nav buttons, English by default)
14
+ // Properties: month, value, marks, locale, label
15
+ // Form: associated — a surrounding <form> receives name=value, reset
16
+ // restores the initial value, like <glk-segmented>
17
+ // Events: glk-change { value } — on a pick by the user
18
+ // glk-month { month } — when the user moves to another month
19
+ // Parts: calendar, head, title, grid
20
+ //
21
+ // The days are buttons in a role="group" with aria-pressed on the chosen
22
+ // one, each named with its full date; the weekday row is decorative. One tab
23
+ // stop (roving tabindex): arrows move by a day or a week, Home/End to the
24
+ // ends of the week, PageUp/PageDown by a month; moving past the month's edge
25
+ // shows that month and emits glk-month. Arrows only move focus — Enter or
26
+ // Space picks — so a calendar that opens a sheet on every pick stays quiet
27
+ // while the user looks around. Days outside min/max carry aria-disabled
28
+ // rather than disabled: they stay in the arrow path and are announced, but
29
+ // neither click nor Enter picks them. A value set to another month flips the
30
+ // calendar to that month.
31
+
32
+ const CHEVRON_LEFT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>';
33
+ const CHEVRON_RIGHT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
34
+ const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
35
+ const CELLS = 42;
36
+ const MONTH = /^\d{4}-\d{2}$/;
37
+
38
+ function tonesOf(entry) {
39
+ const list = Array.isArray(entry) ? entry : [entry];
40
+ return list.filter(isTone).slice(0, 3);
41
+ }
42
+
43
+ class GlkCalendar extends GlkFormElement {
44
+ static get observedAttributes() {
45
+ return ['month', 'value', 'today', 'min', 'max', 'marks', 'locale', 'week-start', 'label', 'prev-label', 'next-label'];
46
+ }
47
+
48
+ render() {
49
+ this._root = this.createElement('div', ['glass-calendar'], { part: 'calendar' });
50
+ const head = this.createElement('div', ['glass-calendar__head'], { part: 'head' });
51
+ this._prev = this.createElement('button', ['glass-calendar__nav'], { type: 'button' });
52
+ this._prev.innerHTML = CHEVRON_LEFT;
53
+ this._title = this.createElement('div', ['glass-calendar__title'], { part: 'title', 'aria-live': 'polite' });
54
+ this._next = this.createElement('button', ['glass-calendar__nav'], { type: 'button' });
55
+ this._next.innerHTML = CHEVRON_RIGHT;
56
+ head.append(this._prev, this._title, this._next);
57
+ this._grid = this.createElement('div', ['glass-calendar__grid'], { role: 'group', part: 'grid' });
58
+ this._root.append(head, this._grid);
59
+ this._wrapper.appendChild(this._root);
60
+ this._initialValue = this.value;
61
+ this._applyLabels();
62
+ this._build();
63
+ }
64
+
65
+ get _todayIso() { return this.getAttribute('today') || isoDate(new Date()); }
66
+
67
+ _build() {
68
+ const locale = resolveLocale(this.getAttribute('locale'));
69
+ const f = formatters(locale);
70
+ const month = this.month;
71
+ const first = parseIso(month);
72
+ const weekStartAttr = this.hasAttribute('week-start') ? Number(this.getAttribute('week-start')) : NaN;
73
+ this._weekStart = Number.isInteger(weekStartAttr) && weekStartAttr >= 0 && weekStartAttr <= 6 ? weekStartAttr : firstWeekday(locale);
74
+ const marks = parseJsonObject(this.getAttribute('marks'));
75
+ const today = this._todayIso;
76
+ const min = this.getAttribute('min') || '';
77
+ const max = this.getAttribute('max') || '';
78
+ this._shown = month;
79
+ this._title.textContent = f.month.format(first);
80
+ this._grid.replaceChildren();
81
+ const offset = (first.getDay() - this._weekStart + 7) % 7; // cells before the 1st
82
+ for (let i = 0; i < 7; i += 1) {
83
+ const wd = this.createElement('span', ['glass-calendar__wd'], { 'aria-hidden': 'true' });
84
+ wd.textContent = weekdayShort(f, addDays(first, i - offset));
85
+ this._grid.appendChild(wd);
86
+ }
87
+ for (let i = 0; i < CELLS; i += 1) {
88
+ const date = addDays(first, i - offset);
89
+ const iso = isoDate(date);
90
+ const day = this.createElement('button', ['glass-calendar__day'], { type: 'button', 'data-value': iso, 'aria-label': f.day.format(date) });
91
+ if (date.getMonth() !== first.getMonth()) day.classList.add('glass-calendar__day--other');
92
+ if (iso === today) day.classList.add('glass-calendar__day--today');
93
+ if ((min && iso < min) || (max && iso > max)) day.setAttribute('aria-disabled', 'true');
94
+ const num = this.createElement('span');
95
+ num.textContent = String(date.getDate());
96
+ const dots = this.createElement('span', ['glass-calendar__marks']);
97
+ for (const tone of tonesOf(marks[iso])) {
98
+ dots.appendChild(this.createElement('span', ['glass-calendar__mark', `glass-calendar__mark--${tone}`]));
99
+ }
100
+ day.append(num, dots);
101
+ this._grid.appendChild(day);
102
+ }
103
+ this._syncValue();
104
+ }
105
+
106
+ _days() { return [...this._grid.querySelectorAll('button[data-value]')]; }
107
+ _dayFor(iso) { return this._grid.querySelector(`button[data-value="${iso}"]`); }
108
+
109
+ _syncValue() {
110
+ const value = this.value;
111
+ let pressed = null;
112
+ for (const day of this._days()) {
113
+ const on = day.dataset.value === value;
114
+ day.setAttribute('aria-pressed', String(on));
115
+ if (on) pressed = day;
116
+ }
117
+ const today = this._todayIso;
118
+ const fallback = today.startsWith(this._shown) ? this._dayFor(today) : this._dayFor(`${this._shown}-01`);
119
+ this._setTabStop(pressed ?? fallback ?? this._days()[0]);
120
+ this.setFormValue(value);
121
+ }
122
+
123
+ _setTabStop(target) {
124
+ for (const day of this._days()) day.tabIndex = day === target ? 0 : -1;
125
+ }
126
+
127
+ _applyLabels() {
128
+ const label = this.getAttribute('label');
129
+ if (label) this._grid.setAttribute('aria-label', label);
130
+ else this._grid.removeAttribute('aria-label');
131
+ this._prev.setAttribute('aria-label', this.getAttribute('prev-label') || 'Previous month');
132
+ this._next.setAttribute('aria-label', this.getAttribute('next-label') || 'Next month');
133
+ }
134
+
135
+ // Show another month because the user asked for it — nav buttons or a key
136
+ // that crossed the month's edge. Setting the attribute rebuilds the grid.
137
+ _show(month) {
138
+ this.month = month;
139
+ this.emit('glk-month', { month });
140
+ }
141
+
142
+ _shift(delta) {
143
+ const first = parseIso(this.month);
144
+ const next = new Date(first.getFullYear(), first.getMonth() + delta, 1);
145
+ this._show(isoDate(next).slice(0, 7));
146
+ }
147
+
148
+ _focusDay(date) {
149
+ const iso = isoDate(date);
150
+ if (!iso.startsWith(this._shown)) this._show(iso.slice(0, 7));
151
+ const day = this._dayFor(iso);
152
+ if (!day) return;
153
+ this._setTabStop(day);
154
+ day.focus();
155
+ }
156
+
157
+ _pick(value) {
158
+ if (value === this.value) return;
159
+ this.value = value;
160
+ this.emit('glk-change', { value });
161
+ }
162
+
163
+ setupEvents() {
164
+ this._onPrev = () => this._shift(-1);
165
+ this._onNext = () => this._shift(1);
166
+ this._onClick = (event) => {
167
+ const day = event.target.closest('button[data-value]');
168
+ if (!day) return;
169
+ this._setTabStop(day);
170
+ if (day.getAttribute('aria-disabled') === 'true') return;
171
+ this._pick(day.dataset.value);
172
+ };
173
+ this._onKeydown = (event) => {
174
+ const from = event.target.closest('button[data-value]');
175
+ if (!from) return;
176
+ const date = parseIso(from.dataset.value);
177
+ const weekday = (date.getDay() - this._weekStart + 7) % 7;
178
+ let target;
179
+ if (event.key in STEPS) target = addDays(date, STEPS[event.key]);
180
+ else if (event.key === 'Home') target = addDays(date, -weekday);
181
+ else if (event.key === 'End') target = addDays(date, 6 - weekday);
182
+ else if (event.key === 'PageUp' || event.key === 'PageDown') {
183
+ const months = event.key === 'PageUp' ? -1 : 1;
184
+ const last = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
185
+ target = new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last));
186
+ } else return;
187
+ event.preventDefault();
188
+ this._focusDay(target);
189
+ };
190
+ this._prev.addEventListener('click', this._onPrev);
191
+ this._next.addEventListener('click', this._onNext);
192
+ this._grid.addEventListener('click', this._onClick);
193
+ this._grid.addEventListener('keydown', this._onKeydown);
194
+ }
195
+
196
+ teardownEvents() {
197
+ this._prev?.removeEventListener('click', this._onPrev);
198
+ this._next?.removeEventListener('click', this._onNext);
199
+ this._grid?.removeEventListener('click', this._onClick);
200
+ this._grid?.removeEventListener('keydown', this._onKeydown);
201
+ }
202
+
203
+ onAttributeChanged(name) {
204
+ if (!this._grid) return;
205
+ switch (name) {
206
+ case 'value': {
207
+ const value = this.value;
208
+ if (value && !value.startsWith(this._shown)) this.month = value.slice(0, 7); // rebuilds via 'month'
209
+ else this._syncValue();
210
+ break;
211
+ }
212
+ case 'label':
213
+ case 'prev-label':
214
+ case 'next-label':
215
+ this._applyLabels();
216
+ break;
217
+ default:
218
+ this._build();
219
+ }
220
+ }
221
+
222
+ resetValue() { this.value = this._initialValue; }
223
+ restoreValue(state) { if (typeof state === 'string') this.value = state; }
224
+
225
+ get month() {
226
+ const attr = this.getAttribute('month');
227
+ if (attr && MONTH.test(attr) && parseIso(attr)) return attr;
228
+ const value = this.value;
229
+ return (value && parseIso(value) ? value : this._todayIso).slice(0, 7);
230
+ }
231
+ set month(v) {
232
+ if (v && MONTH.test(String(v))) this.setAttribute('month', String(v));
233
+ else this.removeAttribute('month');
234
+ }
235
+
236
+ get value() { return this.getAttribute('value') ?? ''; }
237
+ set value(v) {
238
+ if (v == null || v === '') this.removeAttribute('value');
239
+ else this.setAttribute('value', String(v));
240
+ }
241
+
242
+ get marks() { return parseJsonObject(this.getAttribute('marks')); }
243
+ set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
244
+
245
+ get locale() { return resolveLocale(this.getAttribute('locale')); }
246
+ set locale(v) {
247
+ if (v) this.setAttribute('locale', v);
248
+ else this.removeAttribute('locale');
249
+ }
250
+
251
+ get label() { return this.getAttribute('label') || ''; }
252
+ set label(v) {
253
+ if (v) this.setAttribute('label', v);
254
+ else this.removeAttribute('label');
255
+ }
256
+ }
257
+
258
+ customElements.define('glk-calendar', GlkCalendar);
259
+ export { GlkCalendar };