@jungherz-de/glasskit-elements 1.15.1 → 1.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "Vanilla JavaScript Web Components wrapping the GlassKit CSS glassmorphism library",
5
5
  "license": "MIT",
6
6
  "homepage": "https://glasskit-elements.jungherz.com",
@@ -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"
@@ -6,10 +6,16 @@ import { GlkElement } from '../../base.js';
6
6
  // nothing definite to resolve against. Making the host a grid stretches its
7
7
  // single child instead, which needs no percentage at all. The card also becomes
8
8
  // a flex column so a footer can be pushed down with margin-top:auto.
9
+ //
10
+ // Both boxes are grid items now, and a grid item's min-width:auto is its
11
+ // min-content width — content that cannot wrap (a nowrap list subtitle) would
12
+ // widen the card past its cell instead of being truncated. min-width:0 lets the
13
+ // card and the host yield to the cell; min-height:0 does the same for a cell of
14
+ // fixed height, so a scrollable child inside the card can shrink and scroll.
9
15
  const fillSheet = new CSSStyleSheet();
10
16
  fillSheet.replaceSync(`
11
- :host([fill]) { display: grid; }
12
- :host([fill]) .glass-card { display: flex; flex-direction: column; }
17
+ :host([fill]) { display: grid; min-width: 0; }
18
+ :host([fill]) .glass-card { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
13
19
  `);
14
20
 
15
21
  class GlkCard extends GlkElement {
@@ -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 };
@@ -0,0 +1,173 @@
1
+ import { GlkElement } from '../../base.js';
2
+
3
+ // One image with a preview, resized on the client before it goes anywhere.
4
+ // Came back from EhrenPfoten in 1.16.0, where it was the photo picker.
5
+ //
6
+ // Attributes: src (starting image: URL or data URL), label (visible; names
7
+ // the group), hint (small muted line; none by default), round
8
+ // (circular preview, for avatars), max (longest edge after
9
+ // resizing, default 1024), type (output MIME, default
10
+ // image/jpeg; image/webp where the browser encodes it, PNG
11
+ // otherwise), quality (0–1, default 0.82), accept (file dialog
12
+ // filter, default image/*), choose-label, change-label,
13
+ // remove-label (button texts, English by default)
14
+ // Property: src — the current image as a data URL or the given URL; set
15
+ // it to swap the preview. Not reflected to the attribute, so a
16
+ // data URL of megabytes never lands in the DOM.
17
+ // Events: glk-change { dataUrl, width, height, size } — after a pick;
18
+ // on remove with an empty dataUrl and zeros
19
+ // glk-error { message, name } — when the file cannot be decoded
20
+ // (Chrome cannot read HEIC; a corrupt file)
21
+ // Parts: picker, preview, meta, label, hint, actions
22
+ //
23
+ // Decoding goes through createImageBitmap with imageOrientation 'from-image',
24
+ // which applies the EXIF rotation of a phone photo without a library, then
25
+ // onto a canvas no larger than max. The file input is hidden and opened from
26
+ // a real button — a <label> around a hidden input cannot be reached by
27
+ // keyboard — and cleared after every pick, so the same file can be chosen
28
+ // again. Not form-associated: the form value would be either the original
29
+ // file, which is not what gets uploaded, or a data URL of megabytes. Listen
30
+ // to glk-change and upload, or copy the data URL into a hidden field.
31
+
32
+ const PLACEHOLDER = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 15l-5-4-8 8"/></svg>';
33
+
34
+ function dataUrlBytes(url) {
35
+ const b64 = url.slice(url.indexOf(',') + 1);
36
+ const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
37
+ return Math.floor((b64.length * 3) / 4) - padding;
38
+ }
39
+
40
+ class GlkImagePicker extends GlkElement {
41
+ static get observedAttributes() {
42
+ return ['src', 'label', 'hint', 'round', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label'];
43
+ }
44
+
45
+ constructor() {
46
+ super();
47
+ this._src = '';
48
+ }
49
+
50
+ render() {
51
+ this._src = this.getAttribute('src') || '';
52
+ this._root = this.createElement('div', ['glass-image-picker'], { part: 'picker', role: 'group' });
53
+ this._preview = this.createElement('div', ['glass-image-picker__preview'], { part: 'preview' });
54
+ const meta = this.createElement('div', ['glass-image-picker__meta'], { part: 'meta' });
55
+ this._label = this.createElement('span', ['glass-image-picker__label'], { part: 'label', id: 'label' });
56
+ this._hint = this.createElement('span', ['glass-image-picker__hint'], { part: 'hint', id: 'hint' });
57
+ const actions = this.createElement('div', ['glass-image-picker__actions'], { part: 'actions' });
58
+ this._choose = this.createElement('button', ['glass-btn', 'glass-btn--secondary', 'glass-btn--sm', 'glass-btn--auto'], { type: 'button' });
59
+ this._remove = this.createElement('button', ['glass-btn', 'glass-btn--tertiary', 'glass-btn--sm', 'glass-btn--auto'], { type: 'button' });
60
+ this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
61
+ this._input.hidden = true;
62
+ actions.append(this._choose, this._remove, this._input);
63
+ meta.append(this._label, this._hint, actions);
64
+ this._root.append(this._preview, meta);
65
+ this._wrapper.appendChild(this._root);
66
+ this._update();
67
+ }
68
+
69
+ get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 1024); }
70
+ get _type() { return this.getAttribute('type') || 'image/jpeg'; }
71
+ get _quality() {
72
+ const q = Number(this.getAttribute('quality'));
73
+ return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82;
74
+ }
75
+
76
+ _update() {
77
+ if (!this._root) return;
78
+ const label = this.getAttribute('label') || '';
79
+ const hint = this.getAttribute('hint') || '';
80
+ this._preview.classList.toggle('glass-image-picker__preview--round', this.getBoolAttr('round'));
81
+ if (!this._src) {
82
+ this._img = null;
83
+ this._preview.innerHTML = PLACEHOLDER;
84
+ } else if (!this._img || this._img.getAttribute('src') !== this._src) {
85
+ this._img = this.createElement('img', [], { alt: '', src: this._src });
86
+ this._preview.replaceChildren(this._img);
87
+ }
88
+ this._label.textContent = label;
89
+ this._label.hidden = !label;
90
+ this._hint.textContent = hint;
91
+ this._hint.hidden = !hint;
92
+ if (label) this._root.setAttribute('aria-labelledby', 'label');
93
+ else this._root.removeAttribute('aria-labelledby');
94
+ if (hint) this._root.setAttribute('aria-describedby', 'hint');
95
+ else this._root.removeAttribute('aria-describedby');
96
+ this._choose.textContent = this._src
97
+ ? (this.getAttribute('change-label') || 'Change')
98
+ : (this.getAttribute('choose-label') || 'Choose');
99
+ this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
100
+ this._remove.hidden = !this._src;
101
+ this._input.accept = this.getAttribute('accept') || 'image/*';
102
+ }
103
+
104
+ async _pick(file) {
105
+ if (!file) return;
106
+ try {
107
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
108
+ const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
109
+ const canvas = document.createElement('canvas');
110
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
111
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
112
+ const ctx = canvas.getContext('2d');
113
+ if (this._type === 'image/jpeg') {
114
+ // JPEG has no alpha; without this a transparent PNG comes out on black.
115
+ ctx.fillStyle = '#fff';
116
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
117
+ }
118
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
119
+ bitmap.close();
120
+ const dataUrl = canvas.toDataURL(this._type, this._quality);
121
+ this.src = dataUrl;
122
+ this.emit('glk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
123
+ } catch (error) {
124
+ this.emit('glk-error', { message: error?.message || String(error), name: file.name });
125
+ }
126
+ }
127
+
128
+ setupEvents() {
129
+ this._onChoose = () => this._input.click();
130
+ this._onChange = (event) => {
131
+ const file = event.target.files?.[0];
132
+ event.target.value = '';
133
+ this._pick(file);
134
+ };
135
+ this._onRemove = () => {
136
+ this.src = '';
137
+ this._choose.focus(); // the remove button just went away under the focus
138
+ this.emit('glk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
139
+ };
140
+ this._choose.addEventListener('click', this._onChoose);
141
+ this._input.addEventListener('change', this._onChange);
142
+ this._remove.addEventListener('click', this._onRemove);
143
+ }
144
+
145
+ teardownEvents() {
146
+ this._choose?.removeEventListener('click', this._onChoose);
147
+ this._input?.removeEventListener('change', this._onChange);
148
+ this._remove?.removeEventListener('click', this._onRemove);
149
+ }
150
+
151
+ onAttributeChanged(name, _old, value) {
152
+ if (name === 'src') this._src = value || '';
153
+ this._update();
154
+ }
155
+
156
+ get src() { return this._src; }
157
+ set src(v) {
158
+ this._src = v || '';
159
+ this._update();
160
+ }
161
+
162
+ get label() { return this.getAttribute('label') || ''; }
163
+ set label(v) {
164
+ if (v) this.setAttribute('label', v);
165
+ else this.removeAttribute('label');
166
+ }
167
+
168
+ get round() { return this.getBoolAttr('round'); }
169
+ set round(v) { this.setBoolAttr('round', v); }
170
+ }
171
+
172
+ customElements.define('glk-image-picker', GlkImagePicker);
173
+ export { GlkImagePicker };
@@ -0,0 +1,178 @@
1
+ import { GlkElement } from '../../base.js';
2
+ import { isoDate, parseIso, addDays, parseJsonObject, resolveLocale, formatters, weekdayShort, isTone } from '../../dates.js';
3
+
4
+ // A row of day chips that scrolls sideways — a booking horizon, the days
5
+ // around today. Came back from EhrenPfoten in 1.16.0.
6
+ //
7
+ // Attributes: start (YYYY-MM-DD, default today), days (count, default 7),
8
+ // value (YYYY-MM-DD), today (YYYY-MM-DD, default the browser's
9
+ // day), marks (JSON {date: tone | {tone, disabled}}), locale
10
+ // (default the browser language), label (aria-label of the group)
11
+ // Properties: value, marks (object or JSON text), locale, label
12
+ // Event: glk-change { value } — only on a change made by the user
13
+ // Part: strip
14
+ //
15
+ // The chips are buttons in a role="group" with aria-pressed on the chosen
16
+ // one, like <glk-segmented>; each chip is named with its full date, so a
17
+ // screen reader hears "Monday, 22 September 2026" rather than "Mo 22". One
18
+ // tab stop: arrow keys move between chips, Home/End jump to the ends, Enter
19
+ // or Space presses. The chosen chip is scrolled to the middle of the strip
20
+ // after the first layout and on every value change — the strip alone
21
+ // scrolls, never the page. A value change only re-sets state; the chips are
22
+ // rebuilt for start, days, today, marks and locale.
23
+
24
+ const KEYS = { ArrowLeft: -1, ArrowRight: 1, Home: -Infinity, End: Infinity };
25
+
26
+ function readMark(entry) {
27
+ if (isTone(entry)) return { tone: entry, disabled: false };
28
+ if (entry && typeof entry === 'object') return { tone: isTone(entry.tone) ? entry.tone : '', disabled: Boolean(entry.disabled) };
29
+ return { tone: '', disabled: false };
30
+ }
31
+
32
+ class GlkDateStrip extends GlkElement {
33
+ static get observedAttributes() { return ['start', 'days', 'value', 'today', 'marks', 'locale', 'label']; }
34
+
35
+ render() {
36
+ this._strip = this.createElement('div', ['glass-date-strip'], { role: 'group', part: 'strip' });
37
+ this._wrapper.appendChild(this._strip);
38
+ this._build();
39
+ this._applyLabel();
40
+ }
41
+
42
+ _build() {
43
+ const f = formatters(resolveLocale(this.getAttribute('locale')));
44
+ const marks = parseJsonObject(this.getAttribute('marks'));
45
+ const start = parseIso(this.getAttribute('start')) ?? new Date();
46
+ const today = this.getAttribute('today') || isoDate(new Date());
47
+ const count = Math.max(1, Math.floor(Number(this.getAttribute('days'))) || 7);
48
+ this._strip.replaceChildren();
49
+ for (let i = 0; i < count; i += 1) {
50
+ const date = addDays(start, i);
51
+ const iso = isoDate(date);
52
+ const mark = readMark(marks[iso]);
53
+ const chip = this.createElement('button', ['glass-date-strip__day'], { type: 'button', 'data-value': iso, 'aria-label': f.day.format(date) });
54
+ if (iso === today) chip.classList.add('glass-date-strip__day--today');
55
+ if (mark.disabled) chip.disabled = true;
56
+ const wd = this.createElement('span', ['glass-date-strip__wd']);
57
+ wd.textContent = weekdayShort(f, date);
58
+ const num = this.createElement('span', ['glass-date-strip__num']);
59
+ num.textContent = String(date.getDate());
60
+ const dot = this.createElement('span', ['glass-date-strip__mark']);
61
+ if (mark.tone) dot.classList.add(`glass-date-strip__mark--${mark.tone}`);
62
+ chip.append(wd, num, dot);
63
+ this._strip.appendChild(chip);
64
+ }
65
+ this._syncValue();
66
+ }
67
+
68
+ _chips() { return [...this._strip.querySelectorAll('button[data-value]')]; }
69
+
70
+ _syncValue() {
71
+ const value = this.value;
72
+ let chosen = null;
73
+ for (const chip of this._chips()) {
74
+ const pressed = chip.dataset.value === value;
75
+ chip.setAttribute('aria-pressed', String(pressed));
76
+ if (pressed) chosen = chip;
77
+ }
78
+ const chips = this._chips();
79
+ this._setTabStop(chosen ?? chips.find(chip => !chip.disabled) ?? chips[0]);
80
+ this._reveal(this._revealedAt ? 'smooth' : 'instant');
81
+ }
82
+
83
+ _setTabStop(target) {
84
+ for (const chip of this._chips()) chip.tabIndex = chip === target ? 0 : -1;
85
+ }
86
+
87
+ // Scroll the chosen chip to the middle of the strip. Computed from client
88
+ // rects and applied to the strip's own scrollLeft, so the page never moves —
89
+ // scrollIntoView would scroll every ancestor. Before the first layout the
90
+ // strip has no width; the ResizeObserver in setupEvents centres then, and
91
+ // again whenever the strip's width changes — a parent that upgrades later
92
+ // and adds its padding, a window resize, a strip that starts out hidden. A
93
+ // value change glides unless the user asked for reduced motion; a size
94
+ // change snaps, so a settling layout never animates.
95
+ _reveal(behavior = 'instant') {
96
+ const chip = this._strip.querySelector('[aria-pressed="true"]');
97
+ const width = this._strip.clientWidth;
98
+ if (!chip || !width) return;
99
+ const strip = this._strip.getBoundingClientRect();
100
+ const box = chip.getBoundingClientRect();
101
+ const left = this._strip.scrollLeft + (box.left - strip.left) - (strip.width - box.width) / 2;
102
+ const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
103
+ this._strip.scrollTo({ left: Math.max(0, left), behavior: smooth ? 'smooth' : 'instant' });
104
+ this._revealedAt = width;
105
+ }
106
+
107
+ _applyLabel() {
108
+ const label = this.getAttribute('label');
109
+ if (label) this._strip.setAttribute('aria-label', label);
110
+ else this._strip.removeAttribute('aria-label');
111
+ }
112
+
113
+ setupEvents() {
114
+ this._onClick = (event) => {
115
+ const chip = event.target.closest('button[data-value]');
116
+ if (!chip || chip.disabled || chip.getAttribute('aria-disabled') === 'true') return;
117
+ this._setTabStop(chip);
118
+ const { value } = chip.dataset;
119
+ if (value === this.value) return;
120
+ this.value = value;
121
+ this.emit('glk-change', { value });
122
+ };
123
+ this._onKeydown = (event) => {
124
+ const step = KEYS[event.key];
125
+ if (step === undefined) return;
126
+ const chips = this._chips().filter(chip => !chip.disabled);
127
+ const from = chips.indexOf(event.target.closest('button[data-value]'));
128
+ if (from < 0) return;
129
+ const to = Number.isFinite(step) ? Math.min(chips.length - 1, Math.max(0, from + step)) : (step < 0 ? 0 : chips.length - 1);
130
+ event.preventDefault();
131
+ this._setTabStop(chips[to]);
132
+ chips[to].focus();
133
+ };
134
+ this._onResize = () => { if (this._strip.clientWidth !== this._revealedAt) this._reveal('instant'); };
135
+ this._strip.addEventListener('click', this._onClick);
136
+ this._strip.addEventListener('keydown', this._onKeydown);
137
+ this._resizeObserver = new ResizeObserver(this._onResize);
138
+ this._resizeObserver.observe(this._strip);
139
+ }
140
+
141
+ teardownEvents() {
142
+ this._strip?.removeEventListener('click', this._onClick);
143
+ this._strip?.removeEventListener('keydown', this._onKeydown);
144
+ this._resizeObserver?.disconnect();
145
+ this._resizeObserver = null;
146
+ }
147
+
148
+ onAttributeChanged(name) {
149
+ if (!this._strip) return;
150
+ if (name === 'value') this._syncValue();
151
+ else if (name === 'label') this._applyLabel();
152
+ else this._build();
153
+ }
154
+
155
+ get value() { return this.getAttribute('value') ?? ''; }
156
+ set value(v) {
157
+ if (v == null || v === '') this.removeAttribute('value');
158
+ else this.setAttribute('value', String(v));
159
+ }
160
+
161
+ get marks() { return parseJsonObject(this.getAttribute('marks')); }
162
+ set marks(v) { this.setAttribute('marks', typeof v === 'string' ? v : JSON.stringify(v ?? {})); }
163
+
164
+ get locale() { return resolveLocale(this.getAttribute('locale')); }
165
+ set locale(v) {
166
+ if (v) this.setAttribute('locale', v);
167
+ else this.removeAttribute('locale');
168
+ }
169
+
170
+ get label() { return this.getAttribute('label') || ''; }
171
+ set label(v) {
172
+ if (v) this.setAttribute('label', v);
173
+ else this.removeAttribute('label');
174
+ }
175
+ }
176
+
177
+ customElements.define('glk-date-strip', GlkDateStrip);
178
+ export { GlkDateStrip };