@dodlhuat/basix 1.2.1 → 1.2.3

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 +252 -5
  2. package/css/lightbox.scss +272 -0
  3. package/css/style.css +256 -0
  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 +20 -21
  44. package/js/timepicker.ts +23 -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 -3
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>');
package/js/modal.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { sanitizeHtml } from './utils.js';
2
+
1
3
  const CLOSE_ICON = '<div class="icon icon-close close"></div>';
2
4
 
3
5
  type ModalType = 'default' | 'success' | 'error' | 'warning' | 'info';
@@ -81,7 +83,7 @@ class Modal {
81
83
  }
82
84
 
83
85
  public hide(): void {
84
- const wrapper = document.querySelector('.modal-wrapper');
86
+ const wrapper = this.modalWrapper;
85
87
  if (!wrapper) return;
86
88
 
87
89
  // Remove event listeners
@@ -123,13 +125,13 @@ class Modal {
123
125
 
124
126
  if (this.header !== undefined) {
125
127
  const headerClass = `header ${this.type}-bg`;
126
- parts.push(`<div class="${headerClass}">${this.header}</div>`);
128
+ parts.push(`<div class="${headerClass}">${sanitizeHtml(this.header)}</div>`);
127
129
  }
128
130
 
129
- parts.push(this.content);
131
+ parts.push(sanitizeHtml(this.content));
130
132
 
131
133
  if (this.footer !== undefined) {
132
- parts.push(`<div class="footer">${this.footer}</div>`);
134
+ parts.push(`<div class="footer">${sanitizeHtml(this.footer)}</div>`);
133
135
  }
134
136
 
135
137
  parts.push('</div>');
package/js/popover.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { computePosition } from './position.js';
2
+ import { sanitizeHtml } from './utils.js';
2
3
  // Must match $arrow in popover.scss
3
4
  const ARROW_SIZE = 6;
4
5
  class Popover {
@@ -119,9 +120,10 @@ class Popover {
119
120
  // Wrap plain content in .popover-body so it gets proper padding.
120
121
  // Skip wrapping when content already uses structured popover elements.
121
122
  const hasStructure = /class="popover-(header|body|footer|menu)/.test(this.opts.content);
123
+ const safeContent = sanitizeHtml(this.opts.content);
122
124
  el.innerHTML = hasStructure
123
- ? this.opts.content
124
- : `<div class="popover-body">${this.opts.content}</div>`;
125
+ ? safeContent
126
+ : `<div class="popover-body">${safeContent}</div>`;
125
127
  this.trigger.setAttribute('aria-expanded', 'true');
126
128
  this.trigger.setAttribute('aria-controls', id);
127
129
  return el;
package/js/popover.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { computePosition } from './position.js';
2
2
  import type { Placement } from './position.js';
3
+ import { sanitizeHtml } from './utils.js';
3
4
 
4
5
  type PopoverPlacement = Placement | 'auto';
5
6
  type PopoverAlign = 'start' | 'center' | 'end';
@@ -142,9 +143,10 @@ class Popover {
142
143
  // Wrap plain content in .popover-body so it gets proper padding.
143
144
  // Skip wrapping when content already uses structured popover elements.
144
145
  const hasStructure = /class="popover-(header|body|footer|menu)/.test(this.opts.content);
146
+ const safeContent = sanitizeHtml(this.opts.content);
145
147
  el.innerHTML = hasStructure
146
- ? this.opts.content
147
- : `<div class="popover-body">${this.opts.content}</div>`;
148
+ ? safeContent
149
+ : `<div class="popover-body">${safeContent}</div>`;
148
150
 
149
151
  this.trigger.setAttribute('aria-expanded', 'true');
150
152
  this.trigger.setAttribute('aria-controls', id);
package/js/push-menu.js CHANGED
@@ -9,7 +9,8 @@ class PushMenu {
9
9
  throw new Error('PushMenu: Required elements not found (.navigation, .push-content)');
10
10
  }
11
11
  this.buildPanels();
12
- this.elements.navigation.addEventListener('change', this.handleNavigationChange.bind(this));
12
+ this.boundHandleNavigationChange = this.handleNavigationChange.bind(this);
13
+ this.elements.navigation.addEventListener('change', this.boundHandleNavigationChange);
13
14
  this.elements.backdrop?.addEventListener('click', this.handleBackdropClick);
14
15
  this.initialized = true;
15
16
  }
@@ -179,7 +180,7 @@ class PushMenu {
179
180
  static destroy() {
180
181
  if (!this.initialized)
181
182
  return;
182
- this.elements.navigation?.removeEventListener('change', this.handleNavigationChange);
183
+ this.elements.navigation?.removeEventListener('change', this.boundHandleNavigationChange);
183
184
  this.elements.content?.removeEventListener('click', this.clickNav);
184
185
  this.elements.backdrop?.removeEventListener('click', this.handleBackdropClick);
185
186
  this.close();
package/js/push-menu.ts CHANGED
@@ -19,6 +19,7 @@ class PushMenu {
19
19
 
20
20
  private static initialized = false;
21
21
  private static panelStack: HTMLElement[] = [];
22
+ private static boundHandleNavigationChange: () => void;
22
23
 
23
24
  public static init(): void {
24
25
  if (this.initialized) {
@@ -34,7 +35,8 @@ class PushMenu {
34
35
 
35
36
  this.buildPanels();
36
37
 
37
- this.elements.navigation.addEventListener('change', this.handleNavigationChange.bind(this));
38
+ this.boundHandleNavigationChange = this.handleNavigationChange.bind(this);
39
+ this.elements.navigation.addEventListener('change', this.boundHandleNavigationChange);
38
40
  this.elements.backdrop?.addEventListener('click', this.handleBackdropClick);
39
41
 
40
42
  this.initialized = true;
@@ -252,7 +254,7 @@ class PushMenu {
252
254
  public static destroy(): void {
253
255
  if (!this.initialized) return;
254
256
 
255
- this.elements.navigation?.removeEventListener('change', this.handleNavigationChange);
257
+ this.elements.navigation?.removeEventListener('change', this.boundHandleNavigationChange);
256
258
  this.elements.content?.removeEventListener('click', this.clickNav);
257
259
  this.elements.backdrop?.removeEventListener('click', this.handleBackdropClick);
258
260
 
package/js/scrollbar.js CHANGED
@@ -1,20 +1,9 @@
1
1
  class Scrollbar {
2
- constructor(elementOrSelector) {
2
+ constructor(container) {
3
3
  this.dragging = false;
4
4
  this.activePointerId = null;
5
5
  this.startPointerY = 0;
6
6
  this.startThumbTop = 0;
7
- const container = typeof elementOrSelector === 'string'
8
- ? document.querySelector(elementOrSelector)
9
- : elementOrSelector;
10
- if (!container) {
11
- throw new Error(`Scrollbar: Element not found for selector "${elementOrSelector}"`);
12
- }
13
- // Return existing instance if already initialized
14
- const existingInstance = Scrollbar.instances.get(container);
15
- if (existingInstance) {
16
- return existingInstance;
17
- }
18
7
  this.container = container;
19
8
  // Query and validate required elements
20
9
  const elements = this.getRequiredElements(container);
@@ -37,7 +26,8 @@ class Scrollbar {
37
26
  // Initialize
38
27
  this.attachEventListeners();
39
28
  Scrollbar.instances.set(container, this);
40
- // Install global listeners once for all instances
29
+ // Track instances and install global listeners once for all
30
+ Scrollbar.instanceCount++;
41
31
  if (!Scrollbar.globalListenersInstalled) {
42
32
  Scrollbar.installGlobalListeners();
43
33
  }
@@ -64,16 +54,17 @@ class Scrollbar {
64
54
  return Math.max(absoluteMin, parsed || defaultMin);
65
55
  }
66
56
  static installGlobalListeners() {
67
- // Route pointer events to the active scrollbar instance
57
+ const ac = new AbortController();
58
+ Scrollbar.globalListenerAbortController = ac;
68
59
  document.addEventListener('pointermove', (e) => {
69
60
  Scrollbar.activeInstance?.boundPointerMove(e);
70
- }, { passive: false });
61
+ }, { passive: false, signal: ac.signal });
71
62
  document.addEventListener('pointerup', (e) => {
72
63
  Scrollbar.activeInstance?.boundPointerUp(e);
73
- });
64
+ }, { signal: ac.signal });
74
65
  document.addEventListener('pointercancel', (e) => {
75
66
  Scrollbar.activeInstance?.boundPointerUp(e);
76
- });
67
+ }, { signal: ac.signal });
77
68
  Scrollbar.globalListenersInstalled = true;
78
69
  }
79
70
  attachEventListeners() {
@@ -209,24 +200,41 @@ class Scrollbar {
209
200
  if (Scrollbar.activeInstance === this) {
210
201
  Scrollbar.activeInstance = null;
211
202
  }
203
+ // Uninstall global listeners when last instance is destroyed
204
+ Scrollbar.instanceCount--;
205
+ if (Scrollbar.instanceCount === 0) {
206
+ Scrollbar.globalListenerAbortController?.abort();
207
+ Scrollbar.globalListenerAbortController = null;
208
+ Scrollbar.globalListenersInstalled = false;
209
+ }
212
210
  }
213
211
  // Static factory methods
212
+ static create(elementOrSelector) {
213
+ const container = typeof elementOrSelector === 'string'
214
+ ? document.querySelector(elementOrSelector)
215
+ : elementOrSelector;
216
+ if (!container) {
217
+ throw new Error(`Scrollbar: Element not found for selector "${elementOrSelector}"`);
218
+ }
219
+ const existing = Scrollbar.instances.get(container);
220
+ if (existing)
221
+ return existing;
222
+ return new Scrollbar(container);
223
+ }
214
224
  static initAll(selector) {
215
225
  const containers = document.querySelectorAll(selector);
216
- return Array.from(containers).map(container => new Scrollbar(container));
226
+ return Array.from(containers).map(container => Scrollbar.create(container));
217
227
  }
218
228
  static initOne(elementOrSelector) {
219
- return new Scrollbar(elementOrSelector);
229
+ return Scrollbar.create(elementOrSelector);
220
230
  }
221
231
  static getInstance(container) {
222
232
  return Scrollbar.instances.get(container);
223
233
  }
224
- static destroyAll() {
225
- // Note: WeakMap doesn't support iteration, so this is a no-op
226
- // Individual instances should be destroyed by calling destroy()
227
- }
228
234
  }
229
235
  Scrollbar.instances = new WeakMap();
230
236
  Scrollbar.activeInstance = null;
231
237
  Scrollbar.globalListenersInstalled = false;
238
+ Scrollbar.instanceCount = 0;
239
+ Scrollbar.globalListenerAbortController = null;
232
240
  export { Scrollbar };