@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.
Files changed (55) hide show
  1. package/README.md +45 -1
  2. package/css/lightbox.scss +272 -0
  3. package/css/style.css +263 -3
  4. package/css/style.css.map +1 -1
  5. package/css/style.scss +1 -0
  6. package/js/bottom-sheet.js +4 -3
  7. package/js/bottom-sheet.ts +5 -3
  8. package/js/calendar.js +9 -5
  9. package/js/calendar.ts +7 -2
  10. package/js/carousel.js +15 -11
  11. package/js/carousel.ts +16 -11
  12. package/js/chart.js +4 -4
  13. package/js/chart.ts +5 -3
  14. package/js/datepicker.js +11 -3
  15. package/js/datepicker.ts +13 -3
  16. package/js/docs-nav.js +1 -0
  17. package/js/editor.js +28 -20
  18. package/js/editor.ts +28 -20
  19. package/js/file-uploader.js +6 -10
  20. package/js/file-uploader.ts +7 -11
  21. package/js/flyout-menu.js +8 -2
  22. package/js/flyout-menu.ts +7 -2
  23. package/js/gallery.js +6 -13
  24. package/js/gallery.ts +8 -16
  25. package/js/group-picker.js +10 -7
  26. package/js/group-picker.ts +11 -7
  27. package/js/lightbox.js +277 -0
  28. package/js/lightbox.ts +331 -0
  29. package/js/modal.js +5 -4
  30. package/js/modal.ts +6 -4
  31. package/js/popover.js +4 -2
  32. package/js/popover.ts +4 -2
  33. package/js/push-menu.js +3 -2
  34. package/js/push-menu.ts +4 -2
  35. package/js/scrollbar.js +31 -23
  36. package/js/scrollbar.ts +36 -26
  37. package/js/select.js +23 -9
  38. package/js/select.ts +29 -11
  39. package/js/stepper.js +5 -1
  40. package/js/stepper.ts +6 -1
  41. package/js/table.js +8 -3
  42. package/js/table.ts +9 -3
  43. package/js/timepicker.js +32 -21
  44. package/js/timepicker.ts +29 -21
  45. package/js/toast.js +3 -7
  46. package/js/toast.ts +4 -8
  47. package/js/tooltip.js +13 -4
  48. package/js/tooltip.ts +16 -4
  49. package/js/tree.js +4 -0
  50. package/js/tree.ts +5 -0
  51. package/js/utils.js +29 -1
  52. package/js/utils.ts +36 -1
  53. package/js/virtual-dropdown.js +4 -8
  54. package/js/virtual-dropdown.ts +5 -9
  55. package/package.json +1 -1
@@ -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" placeholder="${this.options.searchPlaceholder}" />
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
- if (!query) return text;
391
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
392
- const regex = new RegExp(`(${escaped})`, 'gi');
393
- return text.replace(regex, '<mark>$1</mark>');
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 = document.querySelector('.modal-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>');