@dodlhuat/basix 1.2.2 → 1.2.4
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 +45 -1
- package/css/lightbox.scss +272 -0
- package/css/style.css +263 -3
- package/css/style.css.map +1 -1
- package/css/style.scss +1 -0
- package/js/bottom-sheet.js +4 -3
- package/js/bottom-sheet.ts +5 -3
- package/js/calendar.js +9 -5
- package/js/calendar.ts +7 -2
- package/js/carousel.js +15 -11
- package/js/carousel.ts +16 -11
- package/js/chart.js +4 -4
- package/js/chart.ts +5 -3
- package/js/datepicker.js +11 -3
- package/js/datepicker.ts +13 -3
- package/js/docs-nav.js +1 -0
- package/js/editor.js +28 -20
- package/js/editor.ts +28 -20
- package/js/file-uploader.js +6 -10
- package/js/file-uploader.ts +7 -11
- package/js/flyout-menu.js +8 -2
- package/js/flyout-menu.ts +7 -2
- package/js/gallery.js +6 -13
- package/js/gallery.ts +8 -16
- package/js/group-picker.js +10 -7
- package/js/group-picker.ts +11 -7
- package/js/lightbox.js +277 -0
- package/js/lightbox.ts +331 -0
- package/js/modal.js +5 -4
- package/js/modal.ts +6 -4
- package/js/popover.js +4 -2
- package/js/popover.ts +4 -2
- package/js/push-menu.js +3 -2
- package/js/push-menu.ts +4 -2
- package/js/scrollbar.js +31 -23
- package/js/scrollbar.ts +36 -26
- package/js/select.js +23 -9
- package/js/select.ts +29 -11
- package/js/stepper.js +5 -1
- package/js/stepper.ts +6 -1
- package/js/table.js +8 -3
- package/js/table.ts +9 -3
- package/js/timepicker.js +32 -21
- package/js/timepicker.ts +29 -21
- package/js/toast.js +3 -7
- package/js/toast.ts +4 -8
- package/js/tooltip.js +13 -4
- package/js/tooltip.ts +16 -4
- package/js/tree.js +4 -0
- package/js/tree.ts +5 -0
- package/js/utils.js +29 -1
- package/js/utils.ts +36 -1
- package/js/virtual-dropdown.js +4 -8
- package/js/virtual-dropdown.ts +5 -9
- package/package.json +1 -1
package/js/group-picker.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { escapeHtml } from './utils.js';
|
|
2
|
+
|
|
1
3
|
interface SubgroupData {
|
|
2
4
|
id: string;
|
|
3
5
|
label: string;
|
|
@@ -86,9 +88,10 @@ class GroupPicker {
|
|
|
86
88
|
searchWrap.className = 'group-picker__search';
|
|
87
89
|
searchWrap.innerHTML = `
|
|
88
90
|
<span class="icon icon-search group-picker__search-icon" aria-hidden="true"></span>
|
|
89
|
-
<input type="text"
|
|
91
|
+
<input type="text" />
|
|
90
92
|
`;
|
|
91
93
|
this.searchInput = searchWrap.querySelector('input')!;
|
|
94
|
+
this.searchInput.placeholder = this.options.searchPlaceholder;
|
|
92
95
|
|
|
93
96
|
// List
|
|
94
97
|
this.listEl = document.createElement('div');
|
|
@@ -160,7 +163,7 @@ class GroupPicker {
|
|
|
160
163
|
label.className = 'group-picker__group-label';
|
|
161
164
|
label.innerHTML = query && groupMatches
|
|
162
165
|
? this.highlightText(group.label, query)
|
|
163
|
-
: group.label;
|
|
166
|
+
: escapeHtml(group.label);
|
|
164
167
|
|
|
165
168
|
if (hasChildren) {
|
|
166
169
|
// Chevron — Basix font icon
|
|
@@ -208,7 +211,7 @@ class GroupPicker {
|
|
|
208
211
|
const subEl = document.createElement('span');
|
|
209
212
|
subEl.className = 'chip clickable group-picker__subgroup';
|
|
210
213
|
subEl.dataset.subId = sub.id;
|
|
211
|
-
subEl.innerHTML = query ? this.highlightText(sub.label, query) : sub.label;
|
|
214
|
+
subEl.innerHTML = query ? this.highlightText(sub.label, query) : escapeHtml(sub.label);
|
|
212
215
|
|
|
213
216
|
const isSubSelected = this.selectedSubs.get(group.id)?.has(sub.id) ?? false;
|
|
214
217
|
if (isSubSelected) subEl.classList.add('is-selected');
|
|
@@ -387,10 +390,11 @@ class GroupPicker {
|
|
|
387
390
|
}
|
|
388
391
|
|
|
389
392
|
private highlightText(text: string, query: string): string {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
393
|
+
const safeText = escapeHtml(text);
|
|
394
|
+
if (!query) return safeText;
|
|
395
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
396
|
+
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
|
397
|
+
return safeText.replace(regex, '<mark>$1</mark>');
|
|
394
398
|
}
|
|
395
399
|
|
|
396
400
|
// Public API
|
package/js/lightbox.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
class Lightbox {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
this.wrapper = null;
|
|
4
|
+
this.imgEl = null;
|
|
5
|
+
this.captionEl = null;
|
|
6
|
+
this.counterEl = null;
|
|
7
|
+
this.isZoomed = false;
|
|
8
|
+
this.abortController = new AbortController();
|
|
9
|
+
if (options.images && options.images.length > 0) {
|
|
10
|
+
this.images = options.images;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
this.images = [{ src: options.src ?? '', alt: options.alt, caption: options.caption }];
|
|
14
|
+
}
|
|
15
|
+
this.currentIndex = options.startIndex ?? 0;
|
|
16
|
+
this.closeable = options.closeable ?? true;
|
|
17
|
+
this.onOpen = options.onOpen;
|
|
18
|
+
this.onClose = options.onClose;
|
|
19
|
+
this.hide = this.hide.bind(this);
|
|
20
|
+
this.handleKeydown = this.handleKeydown.bind(this);
|
|
21
|
+
this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
|
|
22
|
+
}
|
|
23
|
+
show() {
|
|
24
|
+
this.hide();
|
|
25
|
+
const wrapper = document.createElement('div');
|
|
26
|
+
wrapper.className = 'lightbox-wrapper';
|
|
27
|
+
wrapper.setAttribute('role', 'dialog');
|
|
28
|
+
wrapper.setAttribute('aria-modal', 'true');
|
|
29
|
+
wrapper.setAttribute('aria-label', 'Image lightbox');
|
|
30
|
+
wrapper.setAttribute('tabindex', '-1');
|
|
31
|
+
wrapper.innerHTML = this.buildTemplate();
|
|
32
|
+
document.body.append(wrapper);
|
|
33
|
+
this.wrapper = wrapper;
|
|
34
|
+
this.imgEl = wrapper.querySelector('.lightbox-img');
|
|
35
|
+
this.captionEl = wrapper.querySelector('.lightbox-caption');
|
|
36
|
+
this.counterEl = wrapper.querySelector('.lightbox-counter');
|
|
37
|
+
const sig = { signal: this.abortController.signal };
|
|
38
|
+
if (this.closeable) {
|
|
39
|
+
wrapper.querySelector('.lightbox-close')?.addEventListener('click', this.hide, sig);
|
|
40
|
+
wrapper.querySelector('.lightbox-background')?.addEventListener('click', this.handleBackgroundClick, sig);
|
|
41
|
+
}
|
|
42
|
+
document.addEventListener('keydown', this.handleKeydown, sig);
|
|
43
|
+
if (this.images.length > 1) {
|
|
44
|
+
wrapper.querySelector('.lightbox-prev')?.addEventListener('click', () => this.prev(), sig);
|
|
45
|
+
wrapper.querySelector('.lightbox-next')?.addEventListener('click', () => this.next(), sig);
|
|
46
|
+
}
|
|
47
|
+
wrapper.querySelector('.lightbox-img-wrap')?.addEventListener('click', () => this.toggleZoom(), sig);
|
|
48
|
+
this.addTouchSupport();
|
|
49
|
+
document.body.style.overflow = 'hidden';
|
|
50
|
+
this.loadImage(this.currentIndex);
|
|
51
|
+
this.updateNav();
|
|
52
|
+
requestAnimationFrame(() => {
|
|
53
|
+
wrapper.classList.add('is-visible');
|
|
54
|
+
wrapper.focus();
|
|
55
|
+
});
|
|
56
|
+
this.onOpen?.();
|
|
57
|
+
}
|
|
58
|
+
hide() {
|
|
59
|
+
const wrapper = this.wrapper;
|
|
60
|
+
if (!wrapper)
|
|
61
|
+
return;
|
|
62
|
+
this.abortController.abort();
|
|
63
|
+
this.abortController = new AbortController();
|
|
64
|
+
document.body.style.overflow = '';
|
|
65
|
+
wrapper.classList.remove('is-visible');
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
wrapper.remove();
|
|
68
|
+
this.wrapper = null;
|
|
69
|
+
this.imgEl = null;
|
|
70
|
+
this.captionEl = null;
|
|
71
|
+
this.counterEl = null;
|
|
72
|
+
this.isZoomed = false;
|
|
73
|
+
this.onClose?.();
|
|
74
|
+
}, 300);
|
|
75
|
+
}
|
|
76
|
+
next() {
|
|
77
|
+
if (this.images.length <= 1)
|
|
78
|
+
return;
|
|
79
|
+
this.currentIndex = (this.currentIndex + 1) % this.images.length;
|
|
80
|
+
this.loadImage(this.currentIndex);
|
|
81
|
+
this.updateNav();
|
|
82
|
+
}
|
|
83
|
+
prev() {
|
|
84
|
+
if (this.images.length <= 1)
|
|
85
|
+
return;
|
|
86
|
+
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
|
87
|
+
this.loadImage(this.currentIndex);
|
|
88
|
+
this.updateNav();
|
|
89
|
+
}
|
|
90
|
+
isVisible() {
|
|
91
|
+
return this.wrapper !== null && document.body.contains(this.wrapper);
|
|
92
|
+
}
|
|
93
|
+
destroy() {
|
|
94
|
+
this.hide();
|
|
95
|
+
}
|
|
96
|
+
loadImage(index) {
|
|
97
|
+
const wrap = this.wrapper?.querySelector('.lightbox-img-wrap');
|
|
98
|
+
if (!wrap || !this.imgEl)
|
|
99
|
+
return;
|
|
100
|
+
this.isZoomed = false;
|
|
101
|
+
this.imgEl.classList.remove('is-zoomed');
|
|
102
|
+
wrap.classList.remove('is-zoomed', 'is-error');
|
|
103
|
+
wrap.classList.add('is-loading');
|
|
104
|
+
this.imgEl.classList.remove('is-loaded');
|
|
105
|
+
const { src, alt, caption } = this.images[index];
|
|
106
|
+
const tempImg = new Image();
|
|
107
|
+
tempImg.onload = () => {
|
|
108
|
+
if (!this.imgEl)
|
|
109
|
+
return;
|
|
110
|
+
this.imgEl.src = src;
|
|
111
|
+
this.imgEl.alt = alt ?? '';
|
|
112
|
+
wrap.classList.remove('is-loading');
|
|
113
|
+
this.imgEl.classList.add('is-loaded');
|
|
114
|
+
};
|
|
115
|
+
tempImg.onerror = () => {
|
|
116
|
+
wrap.classList.remove('is-loading');
|
|
117
|
+
wrap.classList.add('is-error');
|
|
118
|
+
};
|
|
119
|
+
tempImg.src = src;
|
|
120
|
+
if (this.captionEl) {
|
|
121
|
+
if (caption) {
|
|
122
|
+
this.captionEl.textContent = caption;
|
|
123
|
+
this.captionEl.hidden = false;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
this.captionEl.hidden = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.preloadAdjacent(index);
|
|
130
|
+
}
|
|
131
|
+
preloadAdjacent(index) {
|
|
132
|
+
if (this.images.length <= 1)
|
|
133
|
+
return;
|
|
134
|
+
const prevIdx = (index - 1 + this.images.length) % this.images.length;
|
|
135
|
+
const nextIdx = (index + 1) % this.images.length;
|
|
136
|
+
[prevIdx, nextIdx].forEach(i => {
|
|
137
|
+
const img = new Image();
|
|
138
|
+
img.src = this.images[i].src;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
updateNav() {
|
|
142
|
+
if (!this.wrapper)
|
|
143
|
+
return;
|
|
144
|
+
const isGallery = this.images.length > 1;
|
|
145
|
+
if (this.counterEl) {
|
|
146
|
+
this.counterEl.textContent = isGallery ? `${this.currentIndex + 1} / ${this.images.length}` : '';
|
|
147
|
+
this.counterEl.hidden = !isGallery;
|
|
148
|
+
}
|
|
149
|
+
const prevBtn = this.wrapper.querySelector('.lightbox-prev');
|
|
150
|
+
const nextBtn = this.wrapper.querySelector('.lightbox-next');
|
|
151
|
+
if (prevBtn)
|
|
152
|
+
prevBtn.hidden = !isGallery;
|
|
153
|
+
if (nextBtn)
|
|
154
|
+
nextBtn.hidden = !isGallery;
|
|
155
|
+
}
|
|
156
|
+
toggleZoom() {
|
|
157
|
+
if (!this.imgEl)
|
|
158
|
+
return;
|
|
159
|
+
this.isZoomed = !this.isZoomed;
|
|
160
|
+
this.imgEl.classList.toggle('is-zoomed', this.isZoomed);
|
|
161
|
+
this.wrapper?.querySelector('.lightbox-img-wrap')?.classList.toggle('is-zoomed', this.isZoomed);
|
|
162
|
+
}
|
|
163
|
+
handleKeydown(e) {
|
|
164
|
+
switch (e.key) {
|
|
165
|
+
case 'Escape':
|
|
166
|
+
if (this.closeable)
|
|
167
|
+
this.hide();
|
|
168
|
+
break;
|
|
169
|
+
case 'ArrowRight':
|
|
170
|
+
this.next();
|
|
171
|
+
break;
|
|
172
|
+
case 'ArrowLeft':
|
|
173
|
+
this.prev();
|
|
174
|
+
break;
|
|
175
|
+
case 'Tab':
|
|
176
|
+
this.trapFocus(e);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
trapFocus(e) {
|
|
181
|
+
if (!this.wrapper)
|
|
182
|
+
return;
|
|
183
|
+
const focusable = Array.from(this.wrapper.querySelectorAll('button:not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])')).filter(el => el.offsetParent !== null);
|
|
184
|
+
if (focusable.length === 0)
|
|
185
|
+
return;
|
|
186
|
+
const first = focusable[0];
|
|
187
|
+
const last = focusable[focusable.length - 1];
|
|
188
|
+
if (e.shiftKey) {
|
|
189
|
+
if (document.activeElement === first) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
last.focus();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
if (document.activeElement === last) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
first.focus();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
handleBackgroundClick(e) {
|
|
202
|
+
if (e.target?.classList.contains('lightbox-background')) {
|
|
203
|
+
this.hide();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
addTouchSupport() {
|
|
207
|
+
const wrap = this.wrapper?.querySelector('.lightbox-img-wrap');
|
|
208
|
+
if (!wrap)
|
|
209
|
+
return;
|
|
210
|
+
let startX = 0;
|
|
211
|
+
let isDragging = false;
|
|
212
|
+
wrap.addEventListener('touchstart', (e) => {
|
|
213
|
+
startX = e.touches[0].clientX;
|
|
214
|
+
isDragging = true;
|
|
215
|
+
}, { passive: true, signal: this.abortController.signal });
|
|
216
|
+
wrap.addEventListener('touchend', (e) => {
|
|
217
|
+
if (!isDragging)
|
|
218
|
+
return;
|
|
219
|
+
const deltaX = e.changedTouches[0].clientX - startX;
|
|
220
|
+
if (Math.abs(deltaX) > 50) {
|
|
221
|
+
deltaX < 0 ? this.next() : this.prev();
|
|
222
|
+
}
|
|
223
|
+
isDragging = false;
|
|
224
|
+
}, { signal: this.abortController.signal });
|
|
225
|
+
}
|
|
226
|
+
buildTemplate() {
|
|
227
|
+
return `
|
|
228
|
+
${this.closeable ? '<button class="lightbox-close" aria-label="Close lightbox"><span class="icon icon-close"></span></button>' : ''}
|
|
229
|
+
<div class="lightbox" role="document">
|
|
230
|
+
<div class="lightbox-img-wrap">
|
|
231
|
+
<div class="lightbox-spinner"><div class="spinner"></div></div>
|
|
232
|
+
<img class="lightbox-img" src="" alt="" draggable="false" />
|
|
233
|
+
</div>
|
|
234
|
+
<p class="lightbox-caption" hidden></p>
|
|
235
|
+
<div class="lightbox-counter" hidden></div>
|
|
236
|
+
</div>
|
|
237
|
+
<button class="lightbox-prev" aria-label="Previous image" hidden>
|
|
238
|
+
<span class="icon icon-navigate_before"></span>
|
|
239
|
+
</button>
|
|
240
|
+
<button class="lightbox-next" aria-label="Next image" hidden>
|
|
241
|
+
<span class="icon icon-navigate_next"></span>
|
|
242
|
+
</button>
|
|
243
|
+
<div class="lightbox-background"></div>
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
static bind(selector = '[data-lightbox]') {
|
|
247
|
+
const elements = document.querySelectorAll(selector);
|
|
248
|
+
const groups = new Map();
|
|
249
|
+
elements.forEach(el => {
|
|
250
|
+
const groupKey = el.dataset.lightbox || `__solo__${el.dataset.lightboxId ?? Math.random()}`;
|
|
251
|
+
const src = el instanceof HTMLAnchorElement
|
|
252
|
+
? el.href
|
|
253
|
+
: el instanceof HTMLImageElement
|
|
254
|
+
? el.src
|
|
255
|
+
: (el.dataset.src ?? '');
|
|
256
|
+
const imgChild = el.querySelector('img');
|
|
257
|
+
const alt = el instanceof HTMLImageElement ? el.alt : (imgChild?.alt ?? '');
|
|
258
|
+
const caption = el.dataset.lightboxCaption;
|
|
259
|
+
if (!groups.has(groupKey))
|
|
260
|
+
groups.set(groupKey, []);
|
|
261
|
+
groups.get(groupKey).push({ el, image: { src, alt, caption } });
|
|
262
|
+
});
|
|
263
|
+
groups.forEach(items => {
|
|
264
|
+
items.forEach(({ el }, idx) => {
|
|
265
|
+
el.style.cursor = 'zoom-in';
|
|
266
|
+
el.addEventListener('click', e => {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
new Lightbox({
|
|
269
|
+
images: items.map(i => i.image),
|
|
270
|
+
startIndex: idx,
|
|
271
|
+
}).show();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export { Lightbox };
|
package/js/lightbox.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
interface LightboxImage {
|
|
2
|
+
src: string;
|
|
3
|
+
alt?: string;
|
|
4
|
+
caption?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface LightboxOptions {
|
|
8
|
+
src?: string;
|
|
9
|
+
alt?: string;
|
|
10
|
+
caption?: string;
|
|
11
|
+
closeable?: boolean;
|
|
12
|
+
images?: LightboxImage[];
|
|
13
|
+
startIndex?: number;
|
|
14
|
+
onOpen?: () => void;
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Lightbox {
|
|
19
|
+
private images: LightboxImage[];
|
|
20
|
+
private currentIndex: number;
|
|
21
|
+
private readonly closeable: boolean;
|
|
22
|
+
private readonly onOpen?: () => void;
|
|
23
|
+
private readonly onClose?: () => void;
|
|
24
|
+
private wrapper: HTMLElement | null = null;
|
|
25
|
+
private imgEl: HTMLImageElement | null = null;
|
|
26
|
+
private captionEl: HTMLElement | null = null;
|
|
27
|
+
private counterEl: HTMLElement | null = null;
|
|
28
|
+
private isZoomed = false;
|
|
29
|
+
private abortController = new AbortController();
|
|
30
|
+
|
|
31
|
+
constructor(options: LightboxOptions) {
|
|
32
|
+
if (options.images && options.images.length > 0) {
|
|
33
|
+
this.images = options.images;
|
|
34
|
+
} else {
|
|
35
|
+
this.images = [{ src: options.src ?? '', alt: options.alt, caption: options.caption }];
|
|
36
|
+
}
|
|
37
|
+
this.currentIndex = options.startIndex ?? 0;
|
|
38
|
+
this.closeable = options.closeable ?? true;
|
|
39
|
+
this.onOpen = options.onOpen;
|
|
40
|
+
this.onClose = options.onClose;
|
|
41
|
+
|
|
42
|
+
this.hide = this.hide.bind(this);
|
|
43
|
+
this.handleKeydown = this.handleKeydown.bind(this);
|
|
44
|
+
this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public show(): void {
|
|
48
|
+
this.hide();
|
|
49
|
+
|
|
50
|
+
const wrapper = document.createElement('div');
|
|
51
|
+
wrapper.className = 'lightbox-wrapper';
|
|
52
|
+
wrapper.setAttribute('role', 'dialog');
|
|
53
|
+
wrapper.setAttribute('aria-modal', 'true');
|
|
54
|
+
wrapper.setAttribute('aria-label', 'Image lightbox');
|
|
55
|
+
wrapper.setAttribute('tabindex', '-1');
|
|
56
|
+
wrapper.innerHTML = this.buildTemplate();
|
|
57
|
+
document.body.append(wrapper);
|
|
58
|
+
|
|
59
|
+
this.wrapper = wrapper;
|
|
60
|
+
this.imgEl = wrapper.querySelector('.lightbox-img');
|
|
61
|
+
this.captionEl = wrapper.querySelector('.lightbox-caption');
|
|
62
|
+
this.counterEl = wrapper.querySelector('.lightbox-counter');
|
|
63
|
+
|
|
64
|
+
const sig = { signal: this.abortController.signal };
|
|
65
|
+
|
|
66
|
+
if (this.closeable) {
|
|
67
|
+
wrapper.querySelector('.lightbox-close')?.addEventListener('click', this.hide, sig);
|
|
68
|
+
wrapper.querySelector('.lightbox-background')?.addEventListener('click', this.handleBackgroundClick, sig);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
document.addEventListener('keydown', this.handleKeydown, sig);
|
|
72
|
+
|
|
73
|
+
if (this.images.length > 1) {
|
|
74
|
+
wrapper.querySelector('.lightbox-prev')?.addEventListener('click', () => this.prev(), sig);
|
|
75
|
+
wrapper.querySelector('.lightbox-next')?.addEventListener('click', () => this.next(), sig);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
wrapper.querySelector('.lightbox-img-wrap')?.addEventListener('click', () => this.toggleZoom(), sig);
|
|
79
|
+
|
|
80
|
+
this.addTouchSupport();
|
|
81
|
+
|
|
82
|
+
document.body.style.overflow = 'hidden';
|
|
83
|
+
this.loadImage(this.currentIndex);
|
|
84
|
+
this.updateNav();
|
|
85
|
+
|
|
86
|
+
requestAnimationFrame(() => {
|
|
87
|
+
wrapper.classList.add('is-visible');
|
|
88
|
+
wrapper.focus();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.onOpen?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public hide(): void {
|
|
95
|
+
const wrapper = this.wrapper;
|
|
96
|
+
if (!wrapper) return;
|
|
97
|
+
|
|
98
|
+
this.abortController.abort();
|
|
99
|
+
this.abortController = new AbortController();
|
|
100
|
+
|
|
101
|
+
document.body.style.overflow = '';
|
|
102
|
+
wrapper.classList.remove('is-visible');
|
|
103
|
+
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
wrapper.remove();
|
|
106
|
+
this.wrapper = null;
|
|
107
|
+
this.imgEl = null;
|
|
108
|
+
this.captionEl = null;
|
|
109
|
+
this.counterEl = null;
|
|
110
|
+
this.isZoomed = false;
|
|
111
|
+
this.onClose?.();
|
|
112
|
+
}, 300);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public next(): void {
|
|
116
|
+
if (this.images.length <= 1) return;
|
|
117
|
+
this.currentIndex = (this.currentIndex + 1) % this.images.length;
|
|
118
|
+
this.loadImage(this.currentIndex);
|
|
119
|
+
this.updateNav();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public prev(): void {
|
|
123
|
+
if (this.images.length <= 1) return;
|
|
124
|
+
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
|
125
|
+
this.loadImage(this.currentIndex);
|
|
126
|
+
this.updateNav();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public isVisible(): boolean {
|
|
130
|
+
return this.wrapper !== null && document.body.contains(this.wrapper);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public destroy(): void {
|
|
134
|
+
this.hide();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private loadImage(index: number): void {
|
|
138
|
+
const wrap = this.wrapper?.querySelector('.lightbox-img-wrap');
|
|
139
|
+
if (!wrap || !this.imgEl) return;
|
|
140
|
+
|
|
141
|
+
this.isZoomed = false;
|
|
142
|
+
this.imgEl.classList.remove('is-zoomed');
|
|
143
|
+
wrap.classList.remove('is-zoomed', 'is-error');
|
|
144
|
+
wrap.classList.add('is-loading');
|
|
145
|
+
this.imgEl.classList.remove('is-loaded');
|
|
146
|
+
|
|
147
|
+
const { src, alt, caption } = this.images[index];
|
|
148
|
+
|
|
149
|
+
const tempImg = new Image();
|
|
150
|
+
tempImg.onload = () => {
|
|
151
|
+
if (!this.imgEl) return;
|
|
152
|
+
this.imgEl.src = src;
|
|
153
|
+
this.imgEl.alt = alt ?? '';
|
|
154
|
+
wrap.classList.remove('is-loading');
|
|
155
|
+
this.imgEl.classList.add('is-loaded');
|
|
156
|
+
};
|
|
157
|
+
tempImg.onerror = () => {
|
|
158
|
+
wrap.classList.remove('is-loading');
|
|
159
|
+
wrap.classList.add('is-error');
|
|
160
|
+
};
|
|
161
|
+
tempImg.src = src;
|
|
162
|
+
|
|
163
|
+
if (this.captionEl) {
|
|
164
|
+
if (caption) {
|
|
165
|
+
this.captionEl.textContent = caption;
|
|
166
|
+
this.captionEl.hidden = false;
|
|
167
|
+
} else {
|
|
168
|
+
this.captionEl.hidden = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.preloadAdjacent(index);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private preloadAdjacent(index: number): void {
|
|
176
|
+
if (this.images.length <= 1) return;
|
|
177
|
+
const prevIdx = (index - 1 + this.images.length) % this.images.length;
|
|
178
|
+
const nextIdx = (index + 1) % this.images.length;
|
|
179
|
+
[prevIdx, nextIdx].forEach(i => {
|
|
180
|
+
const img = new Image();
|
|
181
|
+
img.src = this.images[i].src;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private updateNav(): void {
|
|
186
|
+
if (!this.wrapper) return;
|
|
187
|
+
const isGallery = this.images.length > 1;
|
|
188
|
+
|
|
189
|
+
if (this.counterEl) {
|
|
190
|
+
this.counterEl.textContent = isGallery ? `${this.currentIndex + 1} / ${this.images.length}` : '';
|
|
191
|
+
this.counterEl.hidden = !isGallery;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const prevBtn = this.wrapper.querySelector<HTMLButtonElement>('.lightbox-prev');
|
|
195
|
+
const nextBtn = this.wrapper.querySelector<HTMLButtonElement>('.lightbox-next');
|
|
196
|
+
if (prevBtn) prevBtn.hidden = !isGallery;
|
|
197
|
+
if (nextBtn) nextBtn.hidden = !isGallery;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private toggleZoom(): void {
|
|
201
|
+
if (!this.imgEl) return;
|
|
202
|
+
this.isZoomed = !this.isZoomed;
|
|
203
|
+
this.imgEl.classList.toggle('is-zoomed', this.isZoomed);
|
|
204
|
+
this.wrapper?.querySelector('.lightbox-img-wrap')?.classList.toggle('is-zoomed', this.isZoomed);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private handleKeydown(e: KeyboardEvent): void {
|
|
208
|
+
switch (e.key) {
|
|
209
|
+
case 'Escape':
|
|
210
|
+
if (this.closeable) this.hide();
|
|
211
|
+
break;
|
|
212
|
+
case 'ArrowRight':
|
|
213
|
+
this.next();
|
|
214
|
+
break;
|
|
215
|
+
case 'ArrowLeft':
|
|
216
|
+
this.prev();
|
|
217
|
+
break;
|
|
218
|
+
case 'Tab':
|
|
219
|
+
this.trapFocus(e);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private trapFocus(e: KeyboardEvent): void {
|
|
225
|
+
if (!this.wrapper) return;
|
|
226
|
+
const focusable = Array.from(
|
|
227
|
+
this.wrapper.querySelectorAll<HTMLElement>(
|
|
228
|
+
'button:not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])'
|
|
229
|
+
)
|
|
230
|
+
).filter(el => el.offsetParent !== null);
|
|
231
|
+
|
|
232
|
+
if (focusable.length === 0) return;
|
|
233
|
+
const first = focusable[0];
|
|
234
|
+
const last = focusable[focusable.length - 1];
|
|
235
|
+
|
|
236
|
+
if (e.shiftKey) {
|
|
237
|
+
if (document.activeElement === first) {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
last.focus();
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
if (document.activeElement === last) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
first.focus();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private handleBackgroundClick(e: Event): void {
|
|
250
|
+
if ((e.target as HTMLElement)?.classList.contains('lightbox-background')) {
|
|
251
|
+
this.hide();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private addTouchSupport(): void {
|
|
256
|
+
const wrap = this.wrapper?.querySelector('.lightbox-img-wrap');
|
|
257
|
+
if (!wrap) return;
|
|
258
|
+
let startX = 0;
|
|
259
|
+
let isDragging = false;
|
|
260
|
+
|
|
261
|
+
wrap.addEventListener('touchstart', (e: Event) => {
|
|
262
|
+
startX = (e as TouchEvent).touches[0].clientX;
|
|
263
|
+
isDragging = true;
|
|
264
|
+
}, { passive: true, signal: this.abortController.signal } as AddEventListenerOptions);
|
|
265
|
+
|
|
266
|
+
wrap.addEventListener('touchend', (e: Event) => {
|
|
267
|
+
if (!isDragging) return;
|
|
268
|
+
const deltaX = (e as TouchEvent).changedTouches[0].clientX - startX;
|
|
269
|
+
if (Math.abs(deltaX) > 50) {
|
|
270
|
+
deltaX < 0 ? this.next() : this.prev();
|
|
271
|
+
}
|
|
272
|
+
isDragging = false;
|
|
273
|
+
}, { signal: this.abortController.signal } as AddEventListenerOptions);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private buildTemplate(): string {
|
|
277
|
+
return `
|
|
278
|
+
${this.closeable ? '<button class="lightbox-close" aria-label="Close lightbox"><span class="icon icon-close"></span></button>' : ''}
|
|
279
|
+
<div class="lightbox" role="document">
|
|
280
|
+
<div class="lightbox-img-wrap">
|
|
281
|
+
<div class="lightbox-spinner"><div class="spinner"></div></div>
|
|
282
|
+
<img class="lightbox-img" src="" alt="" draggable="false" />
|
|
283
|
+
</div>
|
|
284
|
+
<p class="lightbox-caption" hidden></p>
|
|
285
|
+
<div class="lightbox-counter" hidden></div>
|
|
286
|
+
</div>
|
|
287
|
+
<button class="lightbox-prev" aria-label="Previous image" hidden>
|
|
288
|
+
<span class="icon icon-navigate_before"></span>
|
|
289
|
+
</button>
|
|
290
|
+
<button class="lightbox-next" aria-label="Next image" hidden>
|
|
291
|
+
<span class="icon icon-navigate_next"></span>
|
|
292
|
+
</button>
|
|
293
|
+
<div class="lightbox-background"></div>
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
static bind(selector: string = '[data-lightbox]'): void {
|
|
298
|
+
const elements = document.querySelectorAll<HTMLElement>(selector);
|
|
299
|
+
const groups = new Map<string, { el: HTMLElement; image: LightboxImage }[]>();
|
|
300
|
+
|
|
301
|
+
elements.forEach(el => {
|
|
302
|
+
const groupKey = el.dataset.lightbox || `__solo__${el.dataset.lightboxId ?? Math.random()}`;
|
|
303
|
+
const src = el instanceof HTMLAnchorElement
|
|
304
|
+
? el.href
|
|
305
|
+
: el instanceof HTMLImageElement
|
|
306
|
+
? el.src
|
|
307
|
+
: (el.dataset.src ?? '');
|
|
308
|
+
const imgChild = el.querySelector<HTMLImageElement>('img');
|
|
309
|
+
const alt = el instanceof HTMLImageElement ? el.alt : (imgChild?.alt ?? '');
|
|
310
|
+
const caption = el.dataset.lightboxCaption;
|
|
311
|
+
|
|
312
|
+
if (!groups.has(groupKey)) groups.set(groupKey, []);
|
|
313
|
+
groups.get(groupKey)!.push({ el, image: { src, alt, caption } });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
groups.forEach(items => {
|
|
317
|
+
items.forEach(({ el }, idx) => {
|
|
318
|
+
(el as HTMLElement).style.cursor = 'zoom-in';
|
|
319
|
+
el.addEventListener('click', e => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
new Lightbox({
|
|
322
|
+
images: items.map(i => i.image),
|
|
323
|
+
startIndex: idx,
|
|
324
|
+
}).show();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { Lightbox, type LightboxOptions, type LightboxImage };
|
package/js/modal.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeHtml } from './utils.js';
|
|
1
2
|
const CLOSE_ICON = '<div class="icon icon-close close"></div>';
|
|
2
3
|
class Modal {
|
|
3
4
|
constructor(contentOrOptions, header, footer, closeable = true, type = 'default') {
|
|
@@ -45,7 +46,7 @@ class Modal {
|
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
48
|
hide() {
|
|
48
|
-
const wrapper =
|
|
49
|
+
const wrapper = this.modalWrapper;
|
|
49
50
|
if (!wrapper)
|
|
50
51
|
return;
|
|
51
52
|
// Remove event listeners
|
|
@@ -78,11 +79,11 @@ class Modal {
|
|
|
78
79
|
}
|
|
79
80
|
if (this.header !== undefined) {
|
|
80
81
|
const headerClass = `header ${this.type}-bg`;
|
|
81
|
-
parts.push(`<div class="${headerClass}">${this.header}</div>`);
|
|
82
|
+
parts.push(`<div class="${headerClass}">${sanitizeHtml(this.header)}</div>`);
|
|
82
83
|
}
|
|
83
|
-
parts.push(this.content);
|
|
84
|
+
parts.push(sanitizeHtml(this.content));
|
|
84
85
|
if (this.footer !== undefined) {
|
|
85
|
-
parts.push(`<div class="footer">${this.footer}</div>`);
|
|
86
|
+
parts.push(`<div class="footer">${sanitizeHtml(this.footer)}</div>`);
|
|
86
87
|
}
|
|
87
88
|
parts.push('</div>');
|
|
88
89
|
parts.push('<div class="modal-background"></div>');
|