@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 +8 -5
- package/dist/components/base.js +18 -0
- package/dist/components/glk-accordion-item.js +15 -3
- package/dist/components/glk-calendar.js +261 -0
- package/dist/components/glk-date-strip.js +180 -0
- package/dist/components/glk-empty.js +15 -2
- package/dist/components/glk-image-picker.js +175 -0
- package/dist/components/glk-list-item.js +15 -3
- package/dist/components/glk-modal.js +15 -3
- package/dist/components/glk-sheet.js +12 -3
- package/dist/components/shared/dates-CNWaJf09.js +76 -0
- package/dist/glasskit-elements.esm.js +770 -17
- package/dist/glasskit-elements.js +772 -16
- package/dist/glasskit-elements.min.js +1 -1
- package/package.json +3 -3
- package/src/base.js +18 -0
- package/src/components/containers/glk-accordion-item.js +15 -3
- package/src/components/containers/glk-list-item.js +15 -3
- package/src/components/content/glk-empty.js +15 -2
- package/src/components/feedback/glk-modal.js +15 -3
- package/src/components/feedback/glk-sheet.js +12 -3
- package/src/components/forms/glk-calendar.js +259 -0
- package/src/components/forms/glk-image-picker.js +173 -0
- package/src/components/navigation/glk-date-strip.js +178 -0
- package/src/dates.js +74 -0
- package/src/index.js +3 -0
|
@@ -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 };
|
package/src/dates.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
export const pad = n => String(n).padStart(2, '0');
|
|
8
|
+
|
|
9
|
+
/** Local Date → "YYYY-MM-DD". */
|
|
10
|
+
export 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
|
+
export 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
|
+
export const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
22
|
+
|
|
23
|
+
/** A JSON object attribute → object; anything else → {}. */
|
|
24
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export const isTone = v => typeof v === 'string' && /^[a-z][a-z0-9-]*$/.test(v);
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { GlkTabItem } from './components/navigation/glk-tab-item.js';
|
|
|
15
15
|
export { GlkTabDock } from './components/navigation/glk-tab-dock.js';
|
|
16
16
|
export { GlkTabAccessory } from './components/navigation/glk-tab-accessory.js';
|
|
17
17
|
export { GlkSteps } from './components/navigation/glk-steps.js';
|
|
18
|
+
export { GlkDateStrip } from './components/navigation/glk-date-strip.js';
|
|
18
19
|
|
|
19
20
|
// Content
|
|
20
21
|
export { GlkCard } from './components/content/glk-card.js';
|
|
@@ -38,6 +39,8 @@ export { GlkCheckbox } from './components/forms/glk-checkbox.js';
|
|
|
38
39
|
export { GlkRadio } from './components/forms/glk-radio.js';
|
|
39
40
|
export { GlkRange } from './components/forms/glk-range.js';
|
|
40
41
|
export { GlkSegmented } from './components/forms/glk-segmented.js';
|
|
42
|
+
export { GlkCalendar } from './components/forms/glk-calendar.js';
|
|
43
|
+
export { GlkImagePicker } from './components/forms/glk-image-picker.js';
|
|
41
44
|
|
|
42
45
|
// Feedback
|
|
43
46
|
export { GlkProgress } from './components/feedback/glk-progress.js';
|