@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.
@@ -0,0 +1,175 @@
1
+ import { GlkElement } from './base.js';
2
+ import '@jungherz-de/glasskit/glasskit-styles.js';
3
+
4
+ // One image with a preview, resized on the client before it goes anywhere.
5
+ // Came back from EhrenPfoten in 1.16.0, where it was the photo picker.
6
+ //
7
+ // Attributes: src (starting image: URL or data URL), label (visible; names
8
+ // the group), hint (small muted line; none by default), round
9
+ // (circular preview, for avatars), max (longest edge after
10
+ // resizing, default 1024), type (output MIME, default
11
+ // image/jpeg; image/webp where the browser encodes it, PNG
12
+ // otherwise), quality (0–1, default 0.82), accept (file dialog
13
+ // filter, default image/*), choose-label, change-label,
14
+ // remove-label (button texts, English by default)
15
+ // Property: src — the current image as a data URL or the given URL; set
16
+ // it to swap the preview. Not reflected to the attribute, so a
17
+ // data URL of megabytes never lands in the DOM.
18
+ // Events: glk-change { dataUrl, width, height, size } — after a pick;
19
+ // on remove with an empty dataUrl and zeros
20
+ // glk-error { message, name } — when the file cannot be decoded
21
+ // (Chrome cannot read HEIC; a corrupt file)
22
+ // Parts: picker, preview, meta, label, hint, actions
23
+ //
24
+ // Decoding goes through createImageBitmap with imageOrientation 'from-image',
25
+ // which applies the EXIF rotation of a phone photo without a library, then
26
+ // onto a canvas no larger than max. The file input is hidden and opened from
27
+ // a real button — a <label> around a hidden input cannot be reached by
28
+ // keyboard — and cleared after every pick, so the same file can be chosen
29
+ // again. Not form-associated: the form value would be either the original
30
+ // file, which is not what gets uploaded, or a data URL of megabytes. Listen
31
+ // to glk-change and upload, or copy the data URL into a hidden field.
32
+
33
+ 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>';
34
+
35
+ function dataUrlBytes(url) {
36
+ const b64 = url.slice(url.indexOf(',') + 1);
37
+ const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
38
+ return Math.floor((b64.length * 3) / 4) - padding;
39
+ }
40
+
41
+ class GlkImagePicker extends GlkElement {
42
+ static get observedAttributes() {
43
+ return ['src', 'label', 'hint', 'round', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label'];
44
+ }
45
+
46
+ constructor() {
47
+ super();
48
+ this._src = '';
49
+ }
50
+
51
+ render() {
52
+ this._src = this.getAttribute('src') || '';
53
+ this._root = this.createElement('div', ['glass-image-picker'], { part: 'picker', role: 'group' });
54
+ this._preview = this.createElement('div', ['glass-image-picker__preview'], { part: 'preview' });
55
+ const meta = this.createElement('div', ['glass-image-picker__meta'], { part: 'meta' });
56
+ this._label = this.createElement('span', ['glass-image-picker__label'], { part: 'label', id: 'label' });
57
+ this._hint = this.createElement('span', ['glass-image-picker__hint'], { part: 'hint', id: 'hint' });
58
+ const actions = this.createElement('div', ['glass-image-picker__actions'], { part: 'actions' });
59
+ this._choose = this.createElement('button', ['glass-btn', 'glass-btn--secondary', 'glass-btn--sm', 'glass-btn--auto'], { type: 'button' });
60
+ this._remove = this.createElement('button', ['glass-btn', 'glass-btn--tertiary', 'glass-btn--sm', 'glass-btn--auto'], { type: 'button' });
61
+ this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
62
+ this._input.hidden = true;
63
+ actions.append(this._choose, this._remove, this._input);
64
+ meta.append(this._label, this._hint, actions);
65
+ this._root.append(this._preview, meta);
66
+ this._wrapper.appendChild(this._root);
67
+ this._update();
68
+ }
69
+
70
+ get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 1024); }
71
+ get _type() { return this.getAttribute('type') || 'image/jpeg'; }
72
+ get _quality() {
73
+ const q = Number(this.getAttribute('quality'));
74
+ return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82;
75
+ }
76
+
77
+ _update() {
78
+ if (!this._root) return;
79
+ const label = this.getAttribute('label') || '';
80
+ const hint = this.getAttribute('hint') || '';
81
+ this._preview.classList.toggle('glass-image-picker__preview--round', this.getBoolAttr('round'));
82
+ if (!this._src) {
83
+ this._img = null;
84
+ this._preview.innerHTML = PLACEHOLDER;
85
+ } else if (!this._img || this._img.getAttribute('src') !== this._src) {
86
+ this._img = this.createElement('img', [], { alt: '', src: this._src });
87
+ this._preview.replaceChildren(this._img);
88
+ }
89
+ this._label.textContent = label;
90
+ this._label.hidden = !label;
91
+ this._hint.textContent = hint;
92
+ this._hint.hidden = !hint;
93
+ if (label) this._root.setAttribute('aria-labelledby', 'label');
94
+ else this._root.removeAttribute('aria-labelledby');
95
+ if (hint) this._root.setAttribute('aria-describedby', 'hint');
96
+ else this._root.removeAttribute('aria-describedby');
97
+ this._choose.textContent = this._src
98
+ ? (this.getAttribute('change-label') || 'Change')
99
+ : (this.getAttribute('choose-label') || 'Choose');
100
+ this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
101
+ this._remove.hidden = !this._src;
102
+ this._input.accept = this.getAttribute('accept') || 'image/*';
103
+ }
104
+
105
+ async _pick(file) {
106
+ if (!file) return;
107
+ try {
108
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
109
+ const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
110
+ const canvas = document.createElement('canvas');
111
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
112
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
113
+ const ctx = canvas.getContext('2d');
114
+ if (this._type === 'image/jpeg') {
115
+ // JPEG has no alpha; without this a transparent PNG comes out on black.
116
+ ctx.fillStyle = '#fff';
117
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
118
+ }
119
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
120
+ bitmap.close();
121
+ const dataUrl = canvas.toDataURL(this._type, this._quality);
122
+ this.src = dataUrl;
123
+ this.emit('glk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
124
+ } catch (error) {
125
+ this.emit('glk-error', { message: error?.message || String(error), name: file.name });
126
+ }
127
+ }
128
+
129
+ setupEvents() {
130
+ this._onChoose = () => this._input.click();
131
+ this._onChange = (event) => {
132
+ const file = event.target.files?.[0];
133
+ event.target.value = '';
134
+ this._pick(file);
135
+ };
136
+ this._onRemove = () => {
137
+ this.src = '';
138
+ this._choose.focus(); // the remove button just went away under the focus
139
+ this.emit('glk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
140
+ };
141
+ this._choose.addEventListener('click', this._onChoose);
142
+ this._input.addEventListener('change', this._onChange);
143
+ this._remove.addEventListener('click', this._onRemove);
144
+ }
145
+
146
+ teardownEvents() {
147
+ this._choose?.removeEventListener('click', this._onChoose);
148
+ this._input?.removeEventListener('change', this._onChange);
149
+ this._remove?.removeEventListener('click', this._onRemove);
150
+ }
151
+
152
+ onAttributeChanged(name, _old, value) {
153
+ if (name === 'src') this._src = value || '';
154
+ this._update();
155
+ }
156
+
157
+ get src() { return this._src; }
158
+ set src(v) {
159
+ this._src = v || '';
160
+ this._update();
161
+ }
162
+
163
+ get label() { return this.getAttribute('label') || ''; }
164
+ set label(v) {
165
+ if (v) this.setAttribute('label', v);
166
+ else this.removeAttribute('label');
167
+ }
168
+
169
+ get round() { return this.getBoolAttr('round'); }
170
+ set round(v) { this.setBoolAttr('round', v); }
171
+ }
172
+
173
+ customElements.define('glk-image-picker', GlkImagePicker);
174
+
175
+ export { GlkImagePicker };
@@ -49,7 +49,8 @@ class GlkListItem extends GlkElement {
49
49
  // Content — title + optional subtitle.
50
50
  this._content = this.createElement('div', ['glass-list__content']);
51
51
  this._titleEl = this.createElement('div', ['glass-list__title']);
52
- this._titleEl.textContent = this.getAttribute('title') || '';
52
+ this.takeTitle();
53
+ this._titleEl.textContent = this._title || '';
53
54
  this._subtitleEl = this.createElement('div', ['glass-list__subtitle']);
54
55
  if (this.getBoolAttr('wrap')) this._subtitleEl.classList.add('glass-list__subtitle--wrap');
55
56
  const initialSubtitle = this.getAttribute('subtitle') || '';
@@ -115,11 +116,11 @@ class GlkListItem extends GlkElement {
115
116
  this._item?.removeEventListener('click', this._onClick);
116
117
  }
117
118
 
118
- onAttributeChanged(name) {
119
+ onAttributeChanged(name, _old, value) {
119
120
  if (!this._item) return;
120
121
  switch (name) {
121
122
  case 'title':
122
- this._titleEl.textContent = this.getAttribute('title') || '';
123
+ if (this.takeTitle(value)) this._applyTitle();
123
124
  break;
124
125
  case 'subtitle': {
125
126
  const value = this.getAttribute('subtitle') || '';
@@ -159,6 +160,17 @@ class GlkListItem extends GlkElement {
159
160
  }
160
161
  }
161
162
 
163
+ _applyTitle() {
164
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
165
+ }
166
+
167
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
168
+ get title() { return this._title ?? ''; }
169
+ set title(v) {
170
+ this._title = v == null ? '' : String(v);
171
+ this._applyTitle();
172
+ }
173
+
162
174
  get interactive() { return this.getBoolAttr('interactive'); }
163
175
  set interactive(v) { this.setBoolAttr('interactive', v); }
164
176
 
@@ -16,7 +16,8 @@ class GlkModal extends GlkElement {
16
16
  // Header
17
17
  const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
18
18
  this._titleEl = this.createElement('h2', ['glass-modal__title']);
19
- this._titleEl.textContent = this.getAttribute('title') || '';
19
+ this.takeTitle();
20
+ this._applyTitle();
20
21
  header.appendChild(this._titleEl);
21
22
 
22
23
  // Body
@@ -85,14 +86,14 @@ class GlkModal extends GlkElement {
85
86
  document.removeEventListener('keydown', this._onKeydown);
86
87
  }
87
88
 
88
- onAttributeChanged(name) {
89
+ onAttributeChanged(name, _old, value) {
89
90
  if (!this._overlay) return;
90
91
  switch (name) {
91
92
  case 'open':
92
93
  this._overlay.classList.toggle('is-active', this.getBoolAttr('open'));
93
94
  break;
94
95
  case 'title':
95
- this._titleEl.textContent = this.getAttribute('title') || '';
96
+ if (this.takeTitle(value)) this._applyTitle();
96
97
  break;
97
98
  }
98
99
  }
@@ -100,6 +101,17 @@ class GlkModal extends GlkElement {
100
101
  show() { this.setAttribute('open', ''); }
101
102
  close() { this.removeAttribute('open'); }
102
103
 
104
+ _applyTitle() {
105
+ if (this._titleEl) this._titleEl.textContent = this._title || '';
106
+ }
107
+
108
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
109
+ get title() { return this._title ?? ''; }
110
+ set title(v) {
111
+ this._title = v == null ? '' : String(v);
112
+ this._applyTitle();
113
+ }
114
+
103
115
  get open() { return this.getBoolAttr('open'); }
104
116
  set open(v) { this.setBoolAttr('open', v); }
105
117
  }
@@ -38,12 +38,14 @@ class GlkSheet extends GlkElement {
38
38
  this._panel.append(this._titleEl, body, actions);
39
39
  this._overlay.appendChild(this._panel);
40
40
  this._wrapper.appendChild(this._overlay);
41
+ this.takeTitle();
41
42
  this._applyTitle();
42
43
  this._applyMode();
43
44
  }
44
45
 
45
46
  _applyTitle() {
46
- const title = this.getAttribute('title') || '';
47
+ if (!this._titleEl) return;
48
+ const title = this._title || '';
47
49
  this._titleEl.textContent = title;
48
50
  this._titleEl.hidden = !title;
49
51
  if (title) this._panel.setAttribute('aria-label', title);
@@ -113,7 +115,7 @@ class GlkSheet extends GlkElement {
113
115
  document.removeEventListener('keydown', this._onKeydown);
114
116
  }
115
117
 
116
- onAttributeChanged(name) {
118
+ onAttributeChanged(name, _old, value) {
117
119
  if (!this._overlay) return;
118
120
  switch (name) {
119
121
  case 'open':
@@ -122,13 +124,20 @@ class GlkSheet extends GlkElement {
122
124
  else this._hide();
123
125
  break;
124
126
  case 'inline': this._applyMode(); break;
125
- case 'title': this._applyTitle(); break;
127
+ case 'title': if (this.takeTitle(value)) this._applyTitle(); break;
126
128
  }
127
129
  }
128
130
 
129
131
  show() { this.setAttribute('open', ''); }
130
132
  close() { this.removeAttribute('open'); }
131
133
 
134
+ // `title` is the heading, never a tooltip — see GlkElement.takeTitle().
135
+ get title() { return this._title ?? ''; }
136
+ set title(v) {
137
+ this._title = v == null ? '' : String(v);
138
+ this._applyTitle();
139
+ }
140
+
132
141
  get open() { return this.getBoolAttr('open'); }
133
142
  set open(v) { this.setBoolAttr('open', v); }
134
143
 
@@ -0,0 +1,76 @@
1
+ // Date helpers shared by <glk-date-strip> and <glk-calendar>. Everything
2
+ // works on local calendar days — a date is "YYYY-MM-DD" in the element API
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.
6
+
7
+ const pad = n => String(n).padStart(2, '0');
8
+
9
+ /** Local Date → "YYYY-MM-DD". */
10
+ const isoDate = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
11
+
12
+ /** "YYYY-MM-DD" or "YYYY-MM" → local Date, or null when unreadable. */
13
+ function parseIso(text) {
14
+ const m = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(String(text ?? '').trim());
15
+ if (!m) return null;
16
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3] ?? 1));
17
+ return Number.isNaN(d.getTime()) ? null : d;
18
+ }
19
+
20
+ /** Days after a date; the Date constructor rolls over month and year ends. */
21
+ const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
22
+
23
+ /** A JSON object attribute → object; anything else → {}. */
24
+ function parseJsonObject(text) {
25
+ try {
26
+ const v = JSON.parse(text || '{}');
27
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ function resolveLocale(attr) {
34
+ return attr || (typeof navigator !== 'undefined' && navigator.language) || 'en';
35
+ }
36
+
37
+ // Intl formatters are cheap to keep and not to make; one set per locale.
38
+ const cache = new Map();
39
+ function formatters(locale) {
40
+ let f = cache.get(locale);
41
+ if (!f) {
42
+ try {
43
+ f = {
44
+ weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }),
45
+ day: new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
46
+ month: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
47
+ };
48
+ } catch {
49
+ return formatters('en'); // an unknown locale tag throws a RangeError
50
+ }
51
+ cache.set(locale, f);
52
+ }
53
+ return f;
54
+ }
55
+
56
+ /** Short weekday without the trailing dot some locales add ("Mo." → "Mo"). */
57
+ const weekdayShort = (f, d) => f.weekday.format(d).replace(/\.$/, '');
58
+
59
+ /**
60
+ * First day of the week for a locale, 0 = Sunday … 6 = Saturday. Read from
61
+ * Intl.Locale where the browser has it (getWeekInfo(), or the older weekInfo
62
+ * accessor); Monday where it cannot say.
63
+ */
64
+ function firstWeekday(locale) {
65
+ try {
66
+ const loc = new Intl.Locale(locale);
67
+ const info = typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : loc.weekInfo;
68
+ if (info && info.firstDay >= 1 && info.firstDay <= 7) return info.firstDay % 7;
69
+ } catch { /* no Intl.Locale, or an unknown tag */ }
70
+ return 1;
71
+ }
72
+
73
+ /** A tone is a short lowercase token that becomes a __mark--<tone> modifier. */
74
+ const isTone = v => typeof v === 'string' && /^[a-z][a-z0-9-]*$/.test(v);
75
+
76
+ export { firstWeekday as a, parseJsonObject as b, addDays as c, isTone as d, formatters as f, isoDate as i, parseIso as p, resolveLocale as r, weekdayShort as w };