@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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <a href="#"><img src="https://img.shields.io/badge/vanilla_JS-no_dependencies-44cc11?style=flat-square" alt="Vanilla JS"></a>
6
6
  <a href="#"><img src="https://img.shields.io/badge/components-33-7ec8e3?style=flat-square" alt="33 Components"></a>
7
7
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License"></a>
8
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.15.2-lightgrey?style=flat-square" alt="Changelog"></a>
8
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.16.1-lightgrey?style=flat-square" alt="Changelog"></a>
9
9
  <a href="https://www.npmjs.com/package/@jungherz-de/glasskit-elements"><img src="https://img.shields.io/badge/npm-%40jungherz--de%2Fglasskit--elements-cb3837?style=flat-square&logo=npm" alt="npm"></a>
10
10
  </p>
11
11
 
@@ -66,7 +66,7 @@ It is the **app layer** of the GlassKit family — three layers, one design lang
66
66
 
67
67
  ```html
68
68
  <!-- 1. GlassKit CSS -->
69
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.15/glasskit.min.css">
69
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.16/glasskit.min.css">
70
70
 
71
71
  <!-- 2. GlassKit Elements -->
72
72
  <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit-elements/dist/glasskit-elements.min.js"></script>
@@ -104,7 +104,7 @@ The per-component files leave `@jungherz-de/glasskit/glasskit-styles.js` as an e
104
104
  <!DOCTYPE html>
105
105
  <html data-theme="dark">
106
106
  <head>
107
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.15/glasskit.min.css">
107
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.16/glasskit.min.css">
108
108
  <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit-elements/dist/glasskit-elements.min.js"></script>
109
109
  </head>
110
110
  <body>
@@ -130,6 +130,7 @@ The per-component files leave `@jungherz-de/glasskit/glasskit-styles.js` as an e
130
130
  | `<glk-tab-dock>` | Wrapper for floating tab bar + optional accessory | `accessory-left` |
131
131
  | `<glk-tab-accessory>` | Standalone glass capsule (search, compose…) | `label`, `variant` (accent, success, error), `disabled` |
132
132
  | `<glk-steps>` | Progress through a short flow | `steps`, `current`, `label` |
133
+ | `<glk-date-strip>` | Scrolling row of day chips with tone dots; arrow keys, chosen chip centred | `start`, `days`, `value`, `today`, `marks`, `locale`, `label` |
133
134
 
134
135
  ### Content
135
136
 
@@ -151,7 +152,7 @@ The per-component files leave `@jungherz-de/glasskit/glasskit-styles.js` as an e
151
152
 
152
153
  ### Form Elements
153
154
 
154
- All form components support `name`, `value`, `disabled` and participate in native `<form>` submission via `ElementInternals`.
155
+ All form components support `name`, `value`, `disabled` and participate in native `<form>` submission via `ElementInternals` — except `<glk-image-picker>`, which hands its result to `glk-change` instead: a data URL is no form value.
155
156
 
156
157
  | Component | Description | Key Attributes |
157
158
  |---|---|---|
@@ -164,6 +165,8 @@ All form components support `name`, `value`, `disabled` and participate in nativ
164
165
  | `<glk-radio>` | Radio button | `label`, `name`, `value`, `checked` |
165
166
  | `<glk-range>` | Range slider | `label`, `min`, `max`, `value`, `step` |
166
167
  | `<glk-segmented>` | Small exclusive choice as one control; form-associated | `options` (JSON), `value`, `full`, `label`, `name` |
168
+ | `<glk-calendar>` | One month with a day to pick; keyboard navigation, `Intl` names; form-associated | `month`, `value`, `today`, `min`, `max`, `marks`, `locale`, `week-start`, `label`, `name` |
169
+ | `<glk-image-picker>` | One image with preview, resized on the client, EXIF rotation applied; not form-associated | `src`, `label`, `hint`, `round`, `max`, `type`, `quality`, `accept` |
167
170
 
168
171
  ### Feedback & Notifications
169
172
 
@@ -317,7 +320,7 @@ Requires `adoptedStyleSheets`, `ElementInternals`, and `customElements` v1.
317
320
 
318
321
  ## 🤖 AI / LLM Reference
319
322
 
320
- [`SKILL.md`](SKILL.md) is a tag-based, machine-readable reference for LLMs and AI copilots. It contains copy-paste-ready HTML for all 33 elements, attribute / slot / event tables, composition patterns, and a common-mistakes section. It is the companion to the class-based `SKILL.md` in [GlassKit CSS](https://github.com/JUNGHERZ/GlassKit) — use both together for complete coverage of the glass stack.
323
+ [`SKILL.md`](SKILL.md) is a tag-based, machine-readable reference for LLMs and AI copilots. It contains copy-paste-ready HTML for all 36 elements, attribute / slot / event tables, composition patterns, and a common-mistakes section. It is the companion to the class-based `SKILL.md` in [GlassKit CSS](https://github.com/JUNGHERZ/GlassKit) — use both together for complete coverage of the glass stack.
321
324
 
322
325
  ---
323
326
 
@@ -181,6 +181,24 @@ 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
  }
@@ -14,7 +14,8 @@ class GlkAccordionItem extends GlkElement {
14
14
 
15
15
  // Trigger button
16
16
  this._trigger = this.createElement('button', ['glass-accordion__trigger']);
17
- this._triggerText = document.createTextNode(this.getAttribute('title') || '');
17
+ this.takeTitle();
18
+ this._triggerText = document.createTextNode(this._title || '');
18
19
  this._trigger.appendChild(this._triggerText);
19
20
  this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
20
21
 
@@ -42,18 +43,29 @@ class GlkAccordionItem extends GlkElement {
42
43
  this._trigger?.removeEventListener('click', this._onClick);
43
44
  }
44
45
 
45
- onAttributeChanged(name) {
46
+ onAttributeChanged(name, _old, value) {
46
47
  if (!this._item) return;
47
48
  switch (name) {
48
49
  case 'open':
49
50
  this._item.classList.toggle('is-open', this.getBoolAttr('open'));
50
51
  break;
51
52
  case 'title':
52
- this._triggerText.textContent = this.getAttribute('title') || '';
53
+ if (this.takeTitle(value)) this._applyTitle();
53
54
  break;
54
55
  }
55
56
  }
56
57
 
58
+ _applyTitle() {
59
+ if (this._triggerText) this._triggerText.textContent = this._title || '';
60
+ }
61
+
62
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
63
+ get title() { return this._title ?? ''; }
64
+ set title(v) {
65
+ this._title = v == null ? '' : String(v);
66
+ this._applyTitle();
67
+ }
68
+
57
69
  get open() { return this.getBoolAttr('open'); }
58
70
  set open(v) { this.setBoolAttr('open', v); }
59
71
  }
@@ -0,0 +1,261 @@
1
+ import { GlkFormElement } from './base.js';
2
+ import { i as isoDate, r as resolveLocale, f as formatters, p as parseIso, a as firstWeekday, b as parseJsonObject, w as weekdayShort, c as addDays, d as isTone } from './shared/dates-CNWaJf09.js';
3
+ import '@jungherz-de/glasskit/glasskit-styles.js';
4
+
5
+ // One month with a day to pick. Came back from EhrenPfoten in 1.16.0.
6
+ //
7
+ // Attributes: month (YYYY-MM; without it the month of the value, else of
8
+ // today), value (YYYY-MM-DD), today (YYYY-MM-DD), min, max
9
+ // (YYYY-MM-DD; days outside cannot be picked), marks (JSON
10
+ // {date: tone | [tone, …]}, up to three dots), locale (default
11
+ // the browser language), week-start (0 = Sunday … 6 = Saturday;
12
+ // default from the locale, Monday where the browser cannot say),
13
+ // label (aria-label of the day group), prev-label / next-label
14
+ // (names of the nav buttons, English by default)
15
+ // Properties: month, value, marks, locale, label
16
+ // Form: associated — a surrounding <form> receives name=value, reset
17
+ // restores the initial value, like <glk-segmented>
18
+ // Events: glk-change { value } — on a pick by the user
19
+ // glk-month { month } — when the user moves to another month
20
+ // Parts: calendar, head, title, grid
21
+ //
22
+ // The days are buttons in a role="group" with aria-pressed on the chosen
23
+ // one, each named with its full date; the weekday row is decorative. One tab
24
+ // stop (roving tabindex): arrows move by a day or a week, Home/End to the
25
+ // ends of the week, PageUp/PageDown by a month; moving past the month's edge
26
+ // shows that month and emits glk-month. Arrows only move focus — Enter or
27
+ // Space picks — so a calendar that opens a sheet on every pick stays quiet
28
+ // while the user looks around. Days outside min/max carry aria-disabled
29
+ // rather than disabled: they stay in the arrow path and are announced, but
30
+ // neither click nor Enter picks them. A value set to another month flips the
31
+ // calendar to that month.
32
+
33
+ const CHEVRON_LEFT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>';
34
+ const CHEVRON_RIGHT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
35
+ const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
36
+ const CELLS = 42;
37
+ const MONTH = /^\d{4}-\d{2}$/;
38
+
39
+ function tonesOf(entry) {
40
+ const list = Array.isArray(entry) ? entry : [entry];
41
+ return list.filter(isTone).slice(0, 3);
42
+ }
43
+
44
+ class GlkCalendar extends GlkFormElement {
45
+ static get observedAttributes() {
46
+ return ['month', 'value', 'today', 'min', 'max', 'marks', 'locale', 'week-start', 'label', 'prev-label', 'next-label'];
47
+ }
48
+
49
+ render() {
50
+ this._root = this.createElement('div', ['glass-calendar'], { part: 'calendar' });
51
+ const head = this.createElement('div', ['glass-calendar__head'], { part: 'head' });
52
+ this._prev = this.createElement('button', ['glass-calendar__nav'], { type: 'button' });
53
+ this._prev.innerHTML = CHEVRON_LEFT;
54
+ this._title = this.createElement('div', ['glass-calendar__title'], { part: 'title', 'aria-live': 'polite' });
55
+ this._next = this.createElement('button', ['glass-calendar__nav'], { type: 'button' });
56
+ this._next.innerHTML = CHEVRON_RIGHT;
57
+ head.append(this._prev, this._title, this._next);
58
+ this._grid = this.createElement('div', ['glass-calendar__grid'], { role: 'group', part: 'grid' });
59
+ this._root.append(head, this._grid);
60
+ this._wrapper.appendChild(this._root);
61
+ this._initialValue = this.value;
62
+ this._applyLabels();
63
+ this._build();
64
+ }
65
+
66
+ get _todayIso() { return this.getAttribute('today') || isoDate(new Date()); }
67
+
68
+ _build() {
69
+ const locale = resolveLocale(this.getAttribute('locale'));
70
+ const f = formatters(locale);
71
+ const month = this.month;
72
+ const first = parseIso(month);
73
+ const weekStartAttr = this.hasAttribute('week-start') ? Number(this.getAttribute('week-start')) : NaN;
74
+ this._weekStart = Number.isInteger(weekStartAttr) && weekStartAttr >= 0 && weekStartAttr <= 6 ? weekStartAttr : firstWeekday(locale);
75
+ const marks = parseJsonObject(this.getAttribute('marks'));
76
+ const today = this._todayIso;
77
+ const min = this.getAttribute('min') || '';
78
+ const max = this.getAttribute('max') || '';
79
+ this._shown = month;
80
+ this._title.textContent = f.month.format(first);
81
+ this._grid.replaceChildren();
82
+ const offset = (first.getDay() - this._weekStart + 7) % 7; // cells before the 1st
83
+ for (let i = 0; i < 7; i += 1) {
84
+ const wd = this.createElement('span', ['glass-calendar__wd'], { 'aria-hidden': 'true' });
85
+ wd.textContent = weekdayShort(f, addDays(first, i - offset));
86
+ this._grid.appendChild(wd);
87
+ }
88
+ for (let i = 0; i < CELLS; i += 1) {
89
+ const date = addDays(first, i - offset);
90
+ const iso = isoDate(date);
91
+ const day = this.createElement('button', ['glass-calendar__day'], { type: 'button', 'data-value': iso, 'aria-label': f.day.format(date) });
92
+ if (date.getMonth() !== first.getMonth()) day.classList.add('glass-calendar__day--other');
93
+ if (iso === today) day.classList.add('glass-calendar__day--today');
94
+ if ((min && iso < min) || (max && iso > max)) day.setAttribute('aria-disabled', 'true');
95
+ const num = this.createElement('span');
96
+ num.textContent = String(date.getDate());
97
+ const dots = this.createElement('span', ['glass-calendar__marks']);
98
+ for (const tone of tonesOf(marks[iso])) {
99
+ dots.appendChild(this.createElement('span', ['glass-calendar__mark', `glass-calendar__mark--${tone}`]));
100
+ }
101
+ day.append(num, dots);
102
+ this._grid.appendChild(day);
103
+ }
104
+ this._syncValue();
105
+ }
106
+
107
+ _days() { return [...this._grid.querySelectorAll('button[data-value]')]; }
108
+ _dayFor(iso) { return this._grid.querySelector(`button[data-value="${iso}"]`); }
109
+
110
+ _syncValue() {
111
+ const value = this.value;
112
+ let pressed = null;
113
+ for (const day of this._days()) {
114
+ const on = day.dataset.value === value;
115
+ day.setAttribute('aria-pressed', String(on));
116
+ if (on) pressed = day;
117
+ }
118
+ const today = this._todayIso;
119
+ const fallback = today.startsWith(this._shown) ? this._dayFor(today) : this._dayFor(`${this._shown}-01`);
120
+ this._setTabStop(pressed ?? fallback ?? this._days()[0]);
121
+ this.setFormValue(value);
122
+ }
123
+
124
+ _setTabStop(target) {
125
+ for (const day of this._days()) day.tabIndex = day === target ? 0 : -1;
126
+ }
127
+
128
+ _applyLabels() {
129
+ const label = this.getAttribute('label');
130
+ if (label) this._grid.setAttribute('aria-label', label);
131
+ else this._grid.removeAttribute('aria-label');
132
+ this._prev.setAttribute('aria-label', this.getAttribute('prev-label') || 'Previous month');
133
+ this._next.setAttribute('aria-label', this.getAttribute('next-label') || 'Next month');
134
+ }
135
+
136
+ // Show another month because the user asked for it — nav buttons or a key
137
+ // that crossed the month's edge. Setting the attribute rebuilds the grid.
138
+ _show(month) {
139
+ this.month = month;
140
+ this.emit('glk-month', { month });
141
+ }
142
+
143
+ _shift(delta) {
144
+ const first = parseIso(this.month);
145
+ const next = new Date(first.getFullYear(), first.getMonth() + delta, 1);
146
+ this._show(isoDate(next).slice(0, 7));
147
+ }
148
+
149
+ _focusDay(date) {
150
+ const iso = isoDate(date);
151
+ if (!iso.startsWith(this._shown)) this._show(iso.slice(0, 7));
152
+ const day = this._dayFor(iso);
153
+ if (!day) return;
154
+ this._setTabStop(day);
155
+ day.focus();
156
+ }
157
+
158
+ _pick(value) {
159
+ if (value === this.value) return;
160
+ this.value = value;
161
+ this.emit('glk-change', { value });
162
+ }
163
+
164
+ setupEvents() {
165
+ this._onPrev = () => this._shift(-1);
166
+ this._onNext = () => this._shift(1);
167
+ this._onClick = (event) => {
168
+ const day = event.target.closest('button[data-value]');
169
+ if (!day) return;
170
+ this._setTabStop(day);
171
+ if (day.getAttribute('aria-disabled') === 'true') return;
172
+ this._pick(day.dataset.value);
173
+ };
174
+ this._onKeydown = (event) => {
175
+ const from = event.target.closest('button[data-value]');
176
+ if (!from) return;
177
+ const date = parseIso(from.dataset.value);
178
+ const weekday = (date.getDay() - this._weekStart + 7) % 7;
179
+ let target;
180
+ if (event.key in STEPS) target = addDays(date, STEPS[event.key]);
181
+ else if (event.key === 'Home') target = addDays(date, -weekday);
182
+ else if (event.key === 'End') target = addDays(date, 6 - weekday);
183
+ else if (event.key === 'PageUp' || event.key === 'PageDown') {
184
+ const months = event.key === 'PageUp' ? -1 : 1;
185
+ const last = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
186
+ target = new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last));
187
+ } else return;
188
+ event.preventDefault();
189
+ this._focusDay(target);
190
+ };
191
+ this._prev.addEventListener('click', this._onPrev);
192
+ this._next.addEventListener('click', this._onNext);
193
+ this._grid.addEventListener('click', this._onClick);
194
+ this._grid.addEventListener('keydown', this._onKeydown);
195
+ }
196
+
197
+ teardownEvents() {
198
+ this._prev?.removeEventListener('click', this._onPrev);
199
+ this._next?.removeEventListener('click', this._onNext);
200
+ this._grid?.removeEventListener('click', this._onClick);
201
+ this._grid?.removeEventListener('keydown', this._onKeydown);
202
+ }
203
+
204
+ onAttributeChanged(name) {
205
+ if (!this._grid) return;
206
+ switch (name) {
207
+ case 'value': {
208
+ const value = this.value;
209
+ if (value && !value.startsWith(this._shown)) this.month = value.slice(0, 7); // rebuilds via 'month'
210
+ else this._syncValue();
211
+ break;
212
+ }
213
+ case 'label':
214
+ case 'prev-label':
215
+ case 'next-label':
216
+ this._applyLabels();
217
+ break;
218
+ default:
219
+ this._build();
220
+ }
221
+ }
222
+
223
+ resetValue() { this.value = this._initialValue; }
224
+ restoreValue(state) { if (typeof state === 'string') this.value = state; }
225
+
226
+ get month() {
227
+ const attr = this.getAttribute('month');
228
+ if (attr && MONTH.test(attr) && parseIso(attr)) return attr;
229
+ const value = this.value;
230
+ return (value && parseIso(value) ? value : this._todayIso).slice(0, 7);
231
+ }
232
+ set month(v) {
233
+ if (v && MONTH.test(String(v))) this.setAttribute('month', String(v));
234
+ else this.removeAttribute('month');
235
+ }
236
+
237
+ get value() { return this.getAttribute('value') ?? ''; }
238
+ set value(v) {
239
+ if (v == null || v === '') this.removeAttribute('value');
240
+ else this.setAttribute('value', String(v));
241
+ }
242
+
243
+ get marks() { return parseJsonObject(this.getAttribute('marks')); }
244
+ set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
245
+
246
+ get locale() { return resolveLocale(this.getAttribute('locale')); }
247
+ set locale(v) {
248
+ if (v) this.setAttribute('locale', v);
249
+ else this.removeAttribute('locale');
250
+ }
251
+
252
+ get label() { return this.getAttribute('label') || ''; }
253
+ set label(v) {
254
+ if (v) this.setAttribute('label', v);
255
+ else this.removeAttribute('label');
256
+ }
257
+ }
258
+
259
+ customElements.define('glk-calendar', GlkCalendar);
260
+
261
+ export { GlkCalendar };
@@ -0,0 +1,180 @@
1
+ import { GlkElement } from './base.js';
2
+ import { f as formatters, r as resolveLocale, b as parseJsonObject, p as parseIso, i as isoDate, c as addDays, w as weekdayShort, d as isTone } from './shared/dates-CNWaJf09.js';
3
+ import '@jungherz-de/glasskit/glasskit-styles.js';
4
+
5
+ // A row of day chips that scrolls sideways — a booking horizon, the days
6
+ // around today. Came back from EhrenPfoten in 1.16.0.
7
+ //
8
+ // Attributes: start (YYYY-MM-DD, default today), days (count, default 7),
9
+ // value (YYYY-MM-DD), today (YYYY-MM-DD, default the browser's
10
+ // day), marks (JSON {date: tone | {tone, disabled}}), locale
11
+ // (default the browser language), label (aria-label of the group)
12
+ // Properties: value, marks (object or JSON text), locale, label
13
+ // Event: glk-change { value } — only on a change made by the user
14
+ // Part: strip
15
+ //
16
+ // The chips are buttons in a role="group" with aria-pressed on the chosen
17
+ // one, like <glk-segmented>; each chip is named with its full date, so a
18
+ // screen reader hears "Monday, 22 September 2026" rather than "Mo 22". One
19
+ // tab stop: arrow keys move between chips, Home/End jump to the ends, Enter
20
+ // or Space presses. The chosen chip is scrolled to the middle of the strip
21
+ // after the first layout and on every value change — the strip alone
22
+ // scrolls, never the page. A value change only re-sets state; the chips are
23
+ // rebuilt for start, days, today, marks and locale.
24
+
25
+ const KEYS = { ArrowLeft: -1, ArrowRight: 1, Home: -Infinity, End: Infinity };
26
+
27
+ function readMark(entry) {
28
+ if (isTone(entry)) return { tone: entry, disabled: false };
29
+ if (entry && typeof entry === 'object') return { tone: isTone(entry.tone) ? entry.tone : '', disabled: Boolean(entry.disabled) };
30
+ return { tone: '', disabled: false };
31
+ }
32
+
33
+ class GlkDateStrip extends GlkElement {
34
+ static get observedAttributes() { return ['start', 'days', 'value', 'today', 'marks', 'locale', 'label']; }
35
+
36
+ render() {
37
+ this._strip = this.createElement('div', ['glass-date-strip'], { role: 'group', part: 'strip' });
38
+ this._wrapper.appendChild(this._strip);
39
+ this._build();
40
+ this._applyLabel();
41
+ }
42
+
43
+ _build() {
44
+ const f = formatters(resolveLocale(this.getAttribute('locale')));
45
+ const marks = parseJsonObject(this.getAttribute('marks'));
46
+ const start = parseIso(this.getAttribute('start')) ?? new Date();
47
+ const today = this.getAttribute('today') || isoDate(new Date());
48
+ const count = Math.max(1, Math.floor(Number(this.getAttribute('days'))) || 7);
49
+ this._strip.replaceChildren();
50
+ for (let i = 0; i < count; i += 1) {
51
+ const date = addDays(start, i);
52
+ const iso = isoDate(date);
53
+ const mark = readMark(marks[iso]);
54
+ const chip = this.createElement('button', ['glass-date-strip__day'], { type: 'button', 'data-value': iso, 'aria-label': f.day.format(date) });
55
+ if (iso === today) chip.classList.add('glass-date-strip__day--today');
56
+ if (mark.disabled) chip.disabled = true;
57
+ const wd = this.createElement('span', ['glass-date-strip__wd']);
58
+ wd.textContent = weekdayShort(f, date);
59
+ const num = this.createElement('span', ['glass-date-strip__num']);
60
+ num.textContent = String(date.getDate());
61
+ const dot = this.createElement('span', ['glass-date-strip__mark']);
62
+ if (mark.tone) dot.classList.add(`glass-date-strip__mark--${mark.tone}`);
63
+ chip.append(wd, num, dot);
64
+ this._strip.appendChild(chip);
65
+ }
66
+ this._syncValue();
67
+ }
68
+
69
+ _chips() { return [...this._strip.querySelectorAll('button[data-value]')]; }
70
+
71
+ _syncValue() {
72
+ const value = this.value;
73
+ let chosen = null;
74
+ for (const chip of this._chips()) {
75
+ const pressed = chip.dataset.value === value;
76
+ chip.setAttribute('aria-pressed', String(pressed));
77
+ if (pressed) chosen = chip;
78
+ }
79
+ const chips = this._chips();
80
+ this._setTabStop(chosen ?? chips.find(chip => !chip.disabled) ?? chips[0]);
81
+ this._reveal(this._revealedAt ? 'smooth' : 'instant');
82
+ }
83
+
84
+ _setTabStop(target) {
85
+ for (const chip of this._chips()) chip.tabIndex = chip === target ? 0 : -1;
86
+ }
87
+
88
+ // Scroll the chosen chip to the middle of the strip. Computed from client
89
+ // rects and applied to the strip's own scrollLeft, so the page never moves —
90
+ // scrollIntoView would scroll every ancestor. Before the first layout the
91
+ // strip has no width; the ResizeObserver in setupEvents centres then, and
92
+ // again whenever the strip's width changes — a parent that upgrades later
93
+ // and adds its padding, a window resize, a strip that starts out hidden. A
94
+ // value change glides unless the user asked for reduced motion; a size
95
+ // change snaps, so a settling layout never animates.
96
+ _reveal(behavior = 'instant') {
97
+ const chip = this._strip.querySelector('[aria-pressed="true"]');
98
+ const width = this._strip.clientWidth;
99
+ if (!chip || !width) return;
100
+ const strip = this._strip.getBoundingClientRect();
101
+ const box = chip.getBoundingClientRect();
102
+ const left = this._strip.scrollLeft + (box.left - strip.left) - (strip.width - box.width) / 2;
103
+ const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
104
+ this._strip.scrollTo({ left: Math.max(0, left), behavior: smooth ? 'smooth' : 'instant' });
105
+ this._revealedAt = width;
106
+ }
107
+
108
+ _applyLabel() {
109
+ const label = this.getAttribute('label');
110
+ if (label) this._strip.setAttribute('aria-label', label);
111
+ else this._strip.removeAttribute('aria-label');
112
+ }
113
+
114
+ setupEvents() {
115
+ this._onClick = (event) => {
116
+ const chip = event.target.closest('button[data-value]');
117
+ if (!chip || chip.disabled || chip.getAttribute('aria-disabled') === 'true') return;
118
+ this._setTabStop(chip);
119
+ const { value } = chip.dataset;
120
+ if (value === this.value) return;
121
+ this.value = value;
122
+ this.emit('glk-change', { value });
123
+ };
124
+ this._onKeydown = (event) => {
125
+ const step = KEYS[event.key];
126
+ if (step === undefined) return;
127
+ const chips = this._chips().filter(chip => !chip.disabled);
128
+ const from = chips.indexOf(event.target.closest('button[data-value]'));
129
+ if (from < 0) return;
130
+ const to = Number.isFinite(step) ? Math.min(chips.length - 1, Math.max(0, from + step)) : (step < 0 ? 0 : chips.length - 1);
131
+ event.preventDefault();
132
+ this._setTabStop(chips[to]);
133
+ chips[to].focus();
134
+ };
135
+ this._onResize = () => { if (this._strip.clientWidth !== this._revealedAt) this._reveal('instant'); };
136
+ this._strip.addEventListener('click', this._onClick);
137
+ this._strip.addEventListener('keydown', this._onKeydown);
138
+ this._resizeObserver = new ResizeObserver(this._onResize);
139
+ this._resizeObserver.observe(this._strip);
140
+ }
141
+
142
+ teardownEvents() {
143
+ this._strip?.removeEventListener('click', this._onClick);
144
+ this._strip?.removeEventListener('keydown', this._onKeydown);
145
+ this._resizeObserver?.disconnect();
146
+ this._resizeObserver = null;
147
+ }
148
+
149
+ onAttributeChanged(name) {
150
+ if (!this._strip) return;
151
+ if (name === 'value') this._syncValue();
152
+ else if (name === 'label') this._applyLabel();
153
+ else this._build();
154
+ }
155
+
156
+ get value() { return this.getAttribute('value') ?? ''; }
157
+ set value(v) {
158
+ if (v == null || v === '') this.removeAttribute('value');
159
+ else this.setAttribute('value', String(v));
160
+ }
161
+
162
+ get marks() { return parseJsonObject(this.getAttribute('marks')); }
163
+ set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
164
+
165
+ get locale() { return resolveLocale(this.getAttribute('locale')); }
166
+ set locale(v) {
167
+ if (v) this.setAttribute('locale', v);
168
+ else this.removeAttribute('locale');
169
+ }
170
+
171
+ get label() { return this.getAttribute('label') || ''; }
172
+ set label(v) {
173
+ if (v) this.setAttribute('label', v);
174
+ else this.removeAttribute('label');
175
+ }
176
+ }
177
+
178
+ customElements.define('glk-date-strip', GlkDateStrip);
179
+
180
+ export { GlkDateStrip };
@@ -25,11 +25,12 @@ class GlkEmpty extends GlkElement {
25
25
  action.appendChild(this.createElement('slot', [], { name: 'action' }));
26
26
  root.append(icon, this._titleEl, this._textEl, action);
27
27
  this._wrapper.appendChild(root);
28
+ this.takeTitle();
28
29
  this._update();
29
30
  }
30
31
 
31
32
  _update() {
32
- const title = this.getAttribute('title') || '';
33
+ const title = this._title || '';
33
34
  const text = this.getAttribute('text') || '';
34
35
  this._titleEl.textContent = title;
35
36
  this._titleEl.hidden = !title;
@@ -37,10 +38,22 @@ class GlkEmpty extends GlkElement {
37
38
  this._textEl.hidden = !text;
38
39
  }
39
40
 
40
- onAttributeChanged() {
41
+ onAttributeChanged(name, _old, value) {
42
+ if (name === 'title' && !this.takeTitle(value)) return;
41
43
  if (this._titleEl) this._update();
42
44
  }
43
45
 
46
+ _applyTitle() {
47
+ if (this._titleEl) this._update();
48
+ }
49
+
50
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
51
+ get title() { return this._title ?? ''; }
52
+ set title(v) {
53
+ this._title = v == null ? '' : String(v);
54
+ this._applyTitle();
55
+ }
56
+
44
57
  get text() { return this.getAttribute('text') || ''; }
45
58
  set text(v) {
46
59
  if (v) this.setAttribute('text', v);