@dodlhuat/basix 1.2.7 → 1.2.9
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 +1 -1
- package/js/bottom-sheet.d.ts +37 -0
- package/js/calendar.d.ts +115 -0
- package/js/carousel.d.ts +34 -0
- package/js/chart.d.ts +73 -0
- package/js/code-viewer.d.ts +16 -0
- package/js/context-menu.d.ts +31 -0
- package/js/datepicker.d.ts +55 -0
- package/js/dropdown.d.ts +30 -0
- package/js/editor.d.ts +41 -0
- package/js/file-uploader.d.ts +48 -0
- package/js/flyout-menu.d.ts +37 -0
- package/js/gallery.d.ts +35 -0
- package/js/group-picker.d.ts +59 -0
- package/js/lightbox.d.ts +46 -0
- package/js/modal.d.ts +28 -0
- package/js/popover.d.ts +46 -0
- package/js/position.d.ts +31 -0
- package/js/push-menu.d.ts +31 -0
- package/js/range-slider.d.ts +9 -0
- package/js/scroll.d.ts +15 -0
- package/js/scrollbar.d.ts +48 -0
- package/js/select.d.ts +16 -0
- package/js/sidebar-nav.d.ts +22 -0
- package/js/stepper.d.ts +26 -0
- package/js/table.d.ts +98 -0
- package/js/tabs.d.ts +57 -0
- package/js/theme.d.ts +65 -0
- package/js/timepicker.d.ts +37 -0
- package/js/toast.d.ts +26 -0
- package/js/tooltip.d.ts +34 -0
- package/js/tree.d.ts +40 -0
- package/js/utils.d.ts +24 -0
- package/js/virtual-dropdown.d.ts +55 -0
- package/package.json +1 -1
- package/js/bottom-sheet.ts +0 -224
- package/js/calendar.ts +0 -774
- package/js/carousel.ts +0 -222
- package/js/chart.ts +0 -694
- package/js/code-viewer.ts +0 -188
- package/js/context-menu.ts +0 -252
- package/js/datepicker.ts +0 -640
- package/js/dropdown.ts +0 -180
- package/js/editor.ts +0 -492
- package/js/file-uploader.ts +0 -361
- package/js/flyout-menu.ts +0 -255
- package/js/gallery.ts +0 -237
- package/js/group-picker.ts +0 -451
- package/js/lightbox.ts +0 -333
- package/js/modal.ts +0 -171
- package/js/popover.ts +0 -221
- package/js/position.ts +0 -111
- package/js/push-menu.ts +0 -286
- package/js/range-slider.ts +0 -33
- package/js/scroll.ts +0 -47
- package/js/scrollbar.ts +0 -335
- package/js/select.ts +0 -235
- package/js/sidebar-nav.ts +0 -66
- package/js/stepper.ts +0 -109
- package/js/table.ts +0 -459
- package/js/tabs.ts +0 -280
- package/js/theme.ts +0 -235
- package/js/timepicker.ts +0 -202
- package/js/toast.ts +0 -134
- package/js/tooltip.ts +0 -196
- package/js/tree.ts +0 -244
- package/js/tsconfig.json +0 -18
- package/js/utils.ts +0 -119
- package/js/virtual-dropdown.ts +0 -396
package/js/file-uploader.ts
DELETED
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
import { escapeHtml } from './utils.js';
|
|
2
|
-
|
|
3
|
-
interface FileData {
|
|
4
|
-
file: File;
|
|
5
|
-
element: HTMLDivElement;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface UploadCompletedDetail {
|
|
9
|
-
fileCount: number;
|
|
10
|
-
files: File[];
|
|
11
|
-
results: PromiseSettledResult<unknown>[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface FileValidationErrorDetail {
|
|
15
|
-
file: File;
|
|
16
|
-
reason: 'size' | 'type';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface FileUploaderConfig {
|
|
20
|
-
uploadUrl?: string;
|
|
21
|
-
maxFileSize?: number;
|
|
22
|
-
allowedTypes?: string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
class FileUploader {
|
|
26
|
-
private container: HTMLElement;
|
|
27
|
-
private dropZone: HTMLElement;
|
|
28
|
-
private fileInput: HTMLInputElement;
|
|
29
|
-
private fileList: HTMLElement;
|
|
30
|
-
private uploadBtn: HTMLButtonElement;
|
|
31
|
-
private files: Map<string, FileData> = new Map();
|
|
32
|
-
private uploadUrl: string;
|
|
33
|
-
private maxFileSize?: number;
|
|
34
|
-
private allowedTypes?: string[];
|
|
35
|
-
private abortControllers: Map<string, () => void> = new Map();
|
|
36
|
-
|
|
37
|
-
constructor(elementOrSelector: string | HTMLElement, config: FileUploaderConfig = {}) {
|
|
38
|
-
const container = typeof elementOrSelector === 'string'
|
|
39
|
-
? document.querySelector<HTMLElement>(elementOrSelector)
|
|
40
|
-
: elementOrSelector;
|
|
41
|
-
|
|
42
|
-
if (!container) {
|
|
43
|
-
throw new Error(`FileUploader: Element not found for selector "${elementOrSelector}"`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
this.container = container;
|
|
47
|
-
this.container.classList.add('file-uploader');
|
|
48
|
-
|
|
49
|
-
const dropZone = container.querySelector<HTMLElement>('.drop-zone');
|
|
50
|
-
const fileInput = container.querySelector<HTMLInputElement>('.file-input');
|
|
51
|
-
const fileList = container.querySelector<HTMLElement>('.file-list');
|
|
52
|
-
const uploadBtn = container.querySelector<HTMLButtonElement>('.upload-btn');
|
|
53
|
-
|
|
54
|
-
if (!dropZone || !fileInput || !fileList || !uploadBtn) {
|
|
55
|
-
throw new Error('Required elements not found in container');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.dropZone = dropZone;
|
|
59
|
-
this.fileInput = fileInput;
|
|
60
|
-
this.fileList = fileList;
|
|
61
|
-
this.uploadBtn = uploadBtn;
|
|
62
|
-
|
|
63
|
-
this.uploadUrl = config.uploadUrl ?? 'https://httpbin.org/post';
|
|
64
|
-
this.maxFileSize = config.maxFileSize;
|
|
65
|
-
this.allowedTypes = config.allowedTypes;
|
|
66
|
-
|
|
67
|
-
this.init();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private init(): void {
|
|
71
|
-
this.setupEventListeners();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private fileKey(file: File): string {
|
|
75
|
-
return `${file.name}-${file.size}-${file.lastModified}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
private setupEventListeners(): void {
|
|
79
|
-
(['dragenter', 'dragover', 'dragleave', 'drop'] as const).forEach(event => {
|
|
80
|
-
this.dropZone.addEventListener(event, this.preventDefaults);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
(['dragenter', 'dragover'] as const).forEach(event => {
|
|
84
|
-
this.dropZone.addEventListener(event, this.handleDragEnter);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
(['dragleave', 'drop'] as const).forEach(event => {
|
|
88
|
-
this.dropZone.addEventListener(event, this.handleDragLeave);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
this.dropZone.addEventListener('drop', this.handleDrop);
|
|
92
|
-
this.dropZone.addEventListener('click', this.handleDropZoneClick);
|
|
93
|
-
this.fileInput.addEventListener('change', this.handleFileInputChange);
|
|
94
|
-
this.uploadBtn.addEventListener('click', this.handleUploadClick);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private preventDefaults = (e: Event): void => {
|
|
98
|
-
e.preventDefault();
|
|
99
|
-
e.stopPropagation();
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
private handleDragEnter = (): void => {
|
|
103
|
-
this.dropZone.classList.add('drag-over');
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
private handleDragLeave = (): void => {
|
|
107
|
-
this.dropZone.classList.remove('drag-over');
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
private handleDrop = (e: DragEvent): void => {
|
|
111
|
-
const droppedFiles = e.dataTransfer?.files;
|
|
112
|
-
if (droppedFiles) {
|
|
113
|
-
this.handleFiles(droppedFiles);
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
private handleDropZoneClick = (): void => {
|
|
118
|
-
this.fileInput.click();
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
private handleFileInputChange = (e: Event): void => {
|
|
122
|
-
const target = e.target as HTMLInputElement;
|
|
123
|
-
if (target.files) {
|
|
124
|
-
this.handleFiles(target.files);
|
|
125
|
-
target.value = '';
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
private handleUploadClick = async (): Promise<void> => {
|
|
130
|
-
if (this.files.size === 0) return;
|
|
131
|
-
|
|
132
|
-
this.uploadBtn.disabled = true;
|
|
133
|
-
this.uploadBtn.textContent = 'Uploading...';
|
|
134
|
-
|
|
135
|
-
const uploadPromises = Array.from(this.files.values()).map(({ file, element }) =>
|
|
136
|
-
this.uploadFile(file, element)
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
const results = await Promise.allSettled(uploadPromises);
|
|
140
|
-
|
|
141
|
-
this.uploadBtn.textContent = 'Upload Complete';
|
|
142
|
-
|
|
143
|
-
setTimeout(() => {
|
|
144
|
-
this.dispatchUploadCompletedEvent(results);
|
|
145
|
-
this.fileList.innerHTML = '';
|
|
146
|
-
this.files.clear();
|
|
147
|
-
this.updateUploadButton();
|
|
148
|
-
}, 1500);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
private handleFiles(fileList: FileList): void {
|
|
152
|
-
Array.from(fileList).forEach(file => {
|
|
153
|
-
const key = this.fileKey(file);
|
|
154
|
-
if (this.validateFile(file) && !this.files.has(key)) {
|
|
155
|
-
const element = this.addFileToUI(file);
|
|
156
|
-
this.files.set(key, { file, element });
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
this.updateUploadButton();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private validateFile(file: File): boolean {
|
|
163
|
-
if (this.maxFileSize && file.size > this.maxFileSize) {
|
|
164
|
-
this.container.dispatchEvent(new CustomEvent<FileValidationErrorDetail>('file-validation-error', {
|
|
165
|
-
detail: { file, reason: 'size' },
|
|
166
|
-
bubbles: true,
|
|
167
|
-
}));
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (this.allowedTypes && !this.allowedTypes.includes(file.type)) {
|
|
172
|
-
this.container.dispatchEvent(new CustomEvent<FileValidationErrorDetail>('file-validation-error', {
|
|
173
|
-
detail: { file, reason: 'type' },
|
|
174
|
-
bubbles: true,
|
|
175
|
-
}));
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private addFileToUI(file: File): HTMLDivElement {
|
|
183
|
-
const key = this.fileKey(file);
|
|
184
|
-
const item = document.createElement('div');
|
|
185
|
-
item.className = 'file-item';
|
|
186
|
-
|
|
187
|
-
const escapedFileName = escapeHtml(file.name);
|
|
188
|
-
|
|
189
|
-
item.innerHTML = `
|
|
190
|
-
<div class="file-item-header">
|
|
191
|
-
<div class="file-info">
|
|
192
|
-
<div class="file-icon">
|
|
193
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
194
|
-
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
|
195
|
-
<polyline points="13 2 13 9 20 9"></polyline>
|
|
196
|
-
</svg>
|
|
197
|
-
</div>
|
|
198
|
-
<div class="file-details">
|
|
199
|
-
<span class="file-name" title="${escapedFileName}">${escapedFileName}</span>
|
|
200
|
-
<span class="file-size">${this.formatSize(file.size)}</span>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
<button class="remove-btn" type="button" aria-label="Remove file">
|
|
204
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
205
|
-
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
206
|
-
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
207
|
-
</svg>
|
|
208
|
-
</button>
|
|
209
|
-
</div>
|
|
210
|
-
<div class="progress-container">
|
|
211
|
-
<div class="progress-bar"></div>
|
|
212
|
-
</div>
|
|
213
|
-
<div class="status-text">Waiting...</div>
|
|
214
|
-
`;
|
|
215
|
-
|
|
216
|
-
const removeBtn = item.querySelector<HTMLButtonElement>('.remove-btn');
|
|
217
|
-
if (removeBtn) {
|
|
218
|
-
removeBtn.addEventListener('click', (e) => {
|
|
219
|
-
e.stopPropagation();
|
|
220
|
-
this.removeFile(key);
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
this.fileList.appendChild(item);
|
|
225
|
-
return item;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private uploadFile(file: File, element: HTMLDivElement): Promise<unknown> {
|
|
229
|
-
return new Promise((resolve, reject) => {
|
|
230
|
-
const progressContainer = element.querySelector<HTMLElement>('.progress-container');
|
|
231
|
-
const progressBar = element.querySelector<HTMLElement>('.progress-bar');
|
|
232
|
-
const statusText = element.querySelector<HTMLElement>('.status-text');
|
|
233
|
-
const removeBtn = element.querySelector<HTMLElement>('.remove-btn');
|
|
234
|
-
|
|
235
|
-
if (!progressContainer || !progressBar || !statusText || !removeBtn) {
|
|
236
|
-
reject(new Error('Required UI elements not found'));
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
progressContainer.style.display = 'block';
|
|
241
|
-
statusText.style.display = 'block';
|
|
242
|
-
statusText.textContent = '0%';
|
|
243
|
-
removeBtn.style.display = 'none';
|
|
244
|
-
|
|
245
|
-
const xhr = new XMLHttpRequest();
|
|
246
|
-
const key = this.fileKey(file);
|
|
247
|
-
this.abortControllers.set(key, () => xhr.abort());
|
|
248
|
-
|
|
249
|
-
xhr.upload.addEventListener('progress', (e) => {
|
|
250
|
-
if (e.lengthComputable) {
|
|
251
|
-
const pct = Math.round((e.loaded / e.total) * 100);
|
|
252
|
-
progressBar.style.width = pct + '%';
|
|
253
|
-
statusText.textContent = pct + '%';
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
xhr.addEventListener('load', () => {
|
|
258
|
-
this.abortControllers.delete(key);
|
|
259
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
260
|
-
progressBar.style.width = '100%';
|
|
261
|
-
progressBar.style.backgroundColor = 'var(--success)';
|
|
262
|
-
statusText.textContent = 'Completed';
|
|
263
|
-
statusText.classList.add('success');
|
|
264
|
-
try {
|
|
265
|
-
resolve(JSON.parse(xhr.responseText));
|
|
266
|
-
} catch {
|
|
267
|
-
resolve(xhr.responseText);
|
|
268
|
-
}
|
|
269
|
-
} else {
|
|
270
|
-
progressBar.style.backgroundColor = 'var(--error)';
|
|
271
|
-
statusText.textContent = 'Failed';
|
|
272
|
-
statusText.classList.add('error');
|
|
273
|
-
removeBtn.style.display = 'flex';
|
|
274
|
-
reject(new Error(`Upload failed: ${xhr.statusText}`));
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
xhr.addEventListener('error', () => {
|
|
279
|
-
this.abortControllers.delete(key);
|
|
280
|
-
progressBar.style.backgroundColor = 'var(--error)';
|
|
281
|
-
statusText.textContent = 'Network Error';
|
|
282
|
-
statusText.classList.add('error');
|
|
283
|
-
removeBtn.style.display = 'flex';
|
|
284
|
-
reject(new Error('Network error'));
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
xhr.addEventListener('abort', () => {
|
|
288
|
-
this.abortControllers.delete(key);
|
|
289
|
-
statusText.textContent = 'Cancelled';
|
|
290
|
-
statusText.classList.add('error');
|
|
291
|
-
removeBtn.style.display = 'flex';
|
|
292
|
-
reject(new Error('Upload aborted'));
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
const formData = new FormData();
|
|
296
|
-
formData.append('file', file);
|
|
297
|
-
xhr.open('POST', this.uploadUrl);
|
|
298
|
-
xhr.send(formData);
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
private removeFile(key: string): void {
|
|
303
|
-
const abort = this.abortControllers.get(key);
|
|
304
|
-
if (abort) abort();
|
|
305
|
-
|
|
306
|
-
const fileData = this.files.get(key);
|
|
307
|
-
if (fileData) {
|
|
308
|
-
fileData.element.remove();
|
|
309
|
-
this.files.delete(key);
|
|
310
|
-
this.updateUploadButton();
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private updateUploadButton(): void {
|
|
315
|
-
this.uploadBtn.disabled = this.files.size === 0;
|
|
316
|
-
this.uploadBtn.textContent = this.files.size > 0
|
|
317
|
-
? `Upload ${this.files.size} File${this.files.size === 1 ? '' : 's'}`
|
|
318
|
-
: 'Upload Files';
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
private dispatchUploadCompletedEvent(results: PromiseSettledResult<unknown>[]): void {
|
|
322
|
-
const files = Array.from(this.files.values()).map(({ file }) => file);
|
|
323
|
-
this.container.dispatchEvent(new CustomEvent<UploadCompletedDetail>('upload-completed', {
|
|
324
|
-
detail: { fileCount: this.files.size, files, results },
|
|
325
|
-
bubbles: true,
|
|
326
|
-
}));
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private formatSize(bytes: number): string {
|
|
330
|
-
if (bytes === 0) return '0 Bytes';
|
|
331
|
-
const k = 1024;
|
|
332
|
-
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] as const;
|
|
333
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
334
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
public destroy(): void {
|
|
338
|
-
this.abortControllers.forEach(abort => abort());
|
|
339
|
-
this.abortControllers.clear();
|
|
340
|
-
|
|
341
|
-
(['dragenter', 'dragover', 'dragleave', 'drop'] as const).forEach(event => {
|
|
342
|
-
this.dropZone.removeEventListener(event, this.preventDefaults);
|
|
343
|
-
});
|
|
344
|
-
(['dragenter', 'dragover'] as const).forEach(event => {
|
|
345
|
-
this.dropZone.removeEventListener(event, this.handleDragEnter);
|
|
346
|
-
});
|
|
347
|
-
(['dragleave', 'drop'] as const).forEach(event => {
|
|
348
|
-
this.dropZone.removeEventListener(event, this.handleDragLeave);
|
|
349
|
-
});
|
|
350
|
-
this.dropZone.removeEventListener('drop', this.handleDrop);
|
|
351
|
-
this.dropZone.removeEventListener('click', this.handleDropZoneClick);
|
|
352
|
-
this.fileInput.removeEventListener('change', this.handleFileInputChange);
|
|
353
|
-
this.uploadBtn.removeEventListener('click', this.handleUploadClick);
|
|
354
|
-
|
|
355
|
-
this.files.clear();
|
|
356
|
-
this.fileList.innerHTML = '';
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export { FileUploader };
|
|
361
|
-
export type { FileUploaderConfig, UploadCompletedDetail, FileValidationErrorDetail };
|
package/js/flyout-menu.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
interface FlyoutMenuOptions {
|
|
2
|
-
triggerSelector?: string;
|
|
3
|
-
menuSelector?: string;
|
|
4
|
-
overlaySelector?: string;
|
|
5
|
-
closeSelector?: string;
|
|
6
|
-
submenuToggleSelector?: string;
|
|
7
|
-
linkSelector?: string;
|
|
8
|
-
direction?: 'right' | 'left';
|
|
9
|
-
title?: string;
|
|
10
|
-
footerText?: string;
|
|
11
|
-
enableHeader?: boolean;
|
|
12
|
-
enableFooter?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
class FlyoutMenu {
|
|
16
|
-
private options: Required<FlyoutMenuOptions>;
|
|
17
|
-
private menuTrigger: HTMLElement | null;
|
|
18
|
-
private readonly flyoutMenu: HTMLElement | null;
|
|
19
|
-
private flyoutOverlay: HTMLElement | null;
|
|
20
|
-
private closeBtn: HTMLElement | null = null;
|
|
21
|
-
private submenuToggles: NodeListOf<HTMLElement> | null = null;
|
|
22
|
-
private menuLinks: NodeListOf<HTMLAnchorElement> | null = null;
|
|
23
|
-
private submenuHandlers = new Map<HTMLElement, (e: Event) => void>();
|
|
24
|
-
|
|
25
|
-
constructor(options: FlyoutMenuOptions = {}) {
|
|
26
|
-
this.options = {
|
|
27
|
-
triggerSelector: '.menu-trigger',
|
|
28
|
-
menuSelector: '#flyoutMenu',
|
|
29
|
-
overlaySelector: '#flyoutOverlay',
|
|
30
|
-
closeSelector: '.close-menu',
|
|
31
|
-
submenuToggleSelector: '.submenu-toggle',
|
|
32
|
-
linkSelector: '.flyout-links > li > a',
|
|
33
|
-
direction: 'right',
|
|
34
|
-
title: 'Menu',
|
|
35
|
-
footerText: '© 2025 Brand Inc.',
|
|
36
|
-
enableHeader: true,
|
|
37
|
-
enableFooter: true,
|
|
38
|
-
...options
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
this.menuTrigger = document.querySelector(this.options.triggerSelector);
|
|
42
|
-
this.flyoutMenu = document.querySelector(this.options.menuSelector);
|
|
43
|
-
this.flyoutOverlay = document.querySelector(this.options.overlaySelector);
|
|
44
|
-
|
|
45
|
-
this.open = this.open.bind(this);
|
|
46
|
-
this.close = this.close.bind(this);
|
|
47
|
-
this.handleSubmenu = this.handleSubmenu.bind(this);
|
|
48
|
-
this.handleKeydown = this.handleKeydown.bind(this);
|
|
49
|
-
|
|
50
|
-
this.init();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private init(): void {
|
|
54
|
-
if (!this.flyoutMenu) {
|
|
55
|
-
throw new Error(`FlyoutMenu: Menu element not found for selector "${this.options.menuSelector}"`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.hydrateMenu();
|
|
59
|
-
|
|
60
|
-
if (this.options.enableHeader) this.renderHeader();
|
|
61
|
-
if (this.options.enableFooter) this.renderFooter();
|
|
62
|
-
|
|
63
|
-
this.closeBtn = document.querySelector(this.options.closeSelector);
|
|
64
|
-
this.submenuToggles = this.flyoutMenu.querySelectorAll(this.options.submenuToggleSelector);
|
|
65
|
-
this.menuLinks = this.flyoutMenu.querySelectorAll('a');
|
|
66
|
-
|
|
67
|
-
this.setDirection(this.options.direction);
|
|
68
|
-
|
|
69
|
-
this.bindEvents();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
private hydrateMenu(): void {
|
|
73
|
-
if (!this.flyoutMenu) return;
|
|
74
|
-
|
|
75
|
-
const rootUl = this.flyoutMenu.querySelector('ul');
|
|
76
|
-
if (rootUl) {
|
|
77
|
-
rootUl.classList.add('flyout-links');
|
|
78
|
-
this.processListItems(rootUl);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
private processListItems(ul: HTMLUListElement): void {
|
|
83
|
-
const items = Array.from(ul.children) as HTMLLIElement[];
|
|
84
|
-
items.forEach((li, index) => {
|
|
85
|
-
// Check if it has a nested UL
|
|
86
|
-
const nestedUl = li.querySelector('ul') as HTMLUListElement | null;
|
|
87
|
-
if (nestedUl) {
|
|
88
|
-
li.classList.add('has-submenu');
|
|
89
|
-
nestedUl.classList.add('submenu');
|
|
90
|
-
|
|
91
|
-
// Get text content (excluding nested UL text)
|
|
92
|
-
const textNode = Array.from(li.childNodes).find(
|
|
93
|
-
node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== ''
|
|
94
|
-
) as Text | undefined;
|
|
95
|
-
const text = textNode?.textContent?.trim() || 'Menu Item';
|
|
96
|
-
textNode?.remove();
|
|
97
|
-
|
|
98
|
-
// Create Toggle Button
|
|
99
|
-
const button = document.createElement('button');
|
|
100
|
-
button.className = 'submenu-toggle';
|
|
101
|
-
button.style.setProperty('--delay', `${(index + 1) * 0.1}s`);
|
|
102
|
-
button.innerHTML = `
|
|
103
|
-
${text}
|
|
104
|
-
<svg class="chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
105
|
-
<polyline points="6 9 12 15 18 9"></polyline>
|
|
106
|
-
</svg>
|
|
107
|
-
`;
|
|
108
|
-
|
|
109
|
-
li.insertBefore(button, nestedUl);
|
|
110
|
-
|
|
111
|
-
// Recursively process nested UL
|
|
112
|
-
this.processListItems(nestedUl);
|
|
113
|
-
} else {
|
|
114
|
-
// Leaf node - ensure it has a link
|
|
115
|
-
const link = li.querySelector('a');
|
|
116
|
-
if (link) {
|
|
117
|
-
link.style.setProperty('--delay', `${(index + 1) * 0.1}s`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
private renderHeader(): void {
|
|
124
|
-
if (!this.flyoutMenu) return;
|
|
125
|
-
|
|
126
|
-
const header = document.createElement('div');
|
|
127
|
-
header.className = 'flyout-header';
|
|
128
|
-
header.innerHTML = `
|
|
129
|
-
<span class="flyout-title">${this.options.title}</span>
|
|
130
|
-
<button class="close-menu" aria-label="Close Menu">
|
|
131
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
132
|
-
stroke-linecap="round" stroke-linejoin="round">
|
|
133
|
-
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
134
|
-
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
135
|
-
</svg>
|
|
136
|
-
</button>
|
|
137
|
-
`;
|
|
138
|
-
this.flyoutMenu.prepend(header);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private renderFooter(): void {
|
|
142
|
-
if (!this.flyoutMenu) return;
|
|
143
|
-
|
|
144
|
-
const footer = document.createElement('div');
|
|
145
|
-
footer.className = 'flyout-footer';
|
|
146
|
-
footer.style.setProperty('--delay', '0.6s');
|
|
147
|
-
footer.innerHTML = `<p>${this.options.footerText}</p>`;
|
|
148
|
-
this.flyoutMenu.append(footer);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private bindEvents(): void {
|
|
152
|
-
// Open
|
|
153
|
-
this.menuTrigger?.addEventListener('click', this.open);
|
|
154
|
-
|
|
155
|
-
// Close
|
|
156
|
-
this.closeBtn?.addEventListener('click', this.close);
|
|
157
|
-
this.flyoutOverlay?.addEventListener('click', this.close);
|
|
158
|
-
|
|
159
|
-
// Submenus
|
|
160
|
-
this.submenuToggles?.forEach(toggle => {
|
|
161
|
-
const handler = (e: Event) => this.handleSubmenu(e, toggle);
|
|
162
|
-
this.submenuHandlers.set(toggle, handler);
|
|
163
|
-
toggle.addEventListener('click', handler);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// Close on Link Click
|
|
167
|
-
this.menuLinks?.forEach(link => {
|
|
168
|
-
link.addEventListener('click', this.close);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// Keyboard navigation
|
|
172
|
-
document.addEventListener('keydown', this.handleKeydown);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private open(): void {
|
|
176
|
-
this.flyoutMenu?.classList.add('is-open');
|
|
177
|
-
this.flyoutOverlay?.classList.add('is-visible');
|
|
178
|
-
document.body.style.overflow = 'hidden';
|
|
179
|
-
this.menuTrigger?.setAttribute('aria-expanded', 'true');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private close(): void {
|
|
183
|
-
this.flyoutMenu?.classList.remove('is-open');
|
|
184
|
-
this.flyoutOverlay?.classList.remove('is-visible');
|
|
185
|
-
document.body.style.overflow = '';
|
|
186
|
-
this.menuTrigger?.setAttribute('aria-expanded', 'false');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private handleSubmenu(e: Event, toggle: HTMLElement): void {
|
|
190
|
-
e.preventDefault();
|
|
191
|
-
e.stopPropagation();
|
|
192
|
-
|
|
193
|
-
const submenu = toggle.nextElementSibling as HTMLElement | null;
|
|
194
|
-
const parentLi = toggle.parentElement as HTMLLIElement | null;
|
|
195
|
-
const parentUl = parentLi?.parentElement as HTMLUListElement | null;
|
|
196
|
-
|
|
197
|
-
if (!parentUl || !parentLi) return;
|
|
198
|
-
|
|
199
|
-
// Close other submenus at the same level
|
|
200
|
-
const siblings = Array.from(parentUl.children) as HTMLLIElement[];
|
|
201
|
-
siblings.forEach(sibling => {
|
|
202
|
-
if (sibling !== parentLi) {
|
|
203
|
-
const siblingSubmenu = sibling.querySelector('.submenu');
|
|
204
|
-
const siblingToggle = sibling.querySelector('.submenu-toggle');
|
|
205
|
-
if (siblingSubmenu?.classList.contains('is-open')) {
|
|
206
|
-
siblingSubmenu.classList.remove('is-open');
|
|
207
|
-
siblingToggle?.classList.remove('active');
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
toggle.classList.toggle('active');
|
|
213
|
-
submenu?.classList.toggle('is-open');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private handleKeydown(e: KeyboardEvent): void {
|
|
217
|
-
if (e.key === 'Escape' && this.flyoutMenu?.classList.contains('is-open')) {
|
|
218
|
-
this.close();
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
public setDirection(direction: 'left' | 'right'): void {
|
|
223
|
-
if (!this.flyoutMenu) return;
|
|
224
|
-
|
|
225
|
-
const validDirections: Array<'left' | 'right'> = ['left', 'right'];
|
|
226
|
-
if (!validDirections.includes(direction)) return;
|
|
227
|
-
|
|
228
|
-
this.flyoutMenu.classList.remove('flyout-from-right', 'flyout-from-left');
|
|
229
|
-
this.flyoutMenu.classList.add(`flyout-from-${direction}`);
|
|
230
|
-
|
|
231
|
-
this.options.direction = direction;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
public destroy(): void {
|
|
235
|
-
this.menuTrigger?.removeEventListener('click', this.open);
|
|
236
|
-
this.closeBtn?.removeEventListener('click', this.close);
|
|
237
|
-
this.flyoutOverlay?.removeEventListener('click', this.close);
|
|
238
|
-
|
|
239
|
-
this.submenuToggles?.forEach(toggle => {
|
|
240
|
-
const handler = this.submenuHandlers.get(toggle);
|
|
241
|
-
if (handler) toggle.removeEventListener('click', handler);
|
|
242
|
-
});
|
|
243
|
-
this.submenuHandlers.clear();
|
|
244
|
-
|
|
245
|
-
this.menuLinks?.forEach(link => {
|
|
246
|
-
link.removeEventListener('click', this.close);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
document.removeEventListener('keydown', this.handleKeydown);
|
|
250
|
-
|
|
251
|
-
document.body.style.overflow = '';
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export { FlyoutMenu, type FlyoutMenuOptions };
|