@doyosi/laraisy 1.0.2 → 1.0.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 (51) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/src/CodeInput.js +48 -48
  4. package/src/DSAlert.js +352 -352
  5. package/src/DSAvatar.js +207 -207
  6. package/src/DSDelete.js +274 -274
  7. package/src/DSForm.js +568 -568
  8. package/src/DSGridOrTable.js +453 -453
  9. package/src/DSLocaleSwitcher.js +239 -239
  10. package/src/DSLogout.js +293 -293
  11. package/src/DSNotifications.js +365 -365
  12. package/src/DSRestore.js +181 -181
  13. package/src/DSSelect.js +1071 -1071
  14. package/src/DSSelectBox.js +563 -563
  15. package/src/DSSimpleSlider.js +517 -517
  16. package/src/DSSvgFetch.js +69 -69
  17. package/src/DSTable/DSTableExport.js +68 -68
  18. package/src/DSTable/DSTableFilter.js +224 -224
  19. package/src/DSTable/DSTablePagination.js +136 -136
  20. package/src/DSTable/DSTableSearch.js +40 -40
  21. package/src/DSTable/DSTableSelection.js +192 -192
  22. package/src/DSTable/DSTableSort.js +58 -58
  23. package/src/DSTable.js +353 -353
  24. package/src/DSTabs.js +488 -488
  25. package/src/DSUpload.js +887 -887
  26. package/dist/CodeInput.d.ts +0 -10
  27. package/dist/DSAlert.d.ts +0 -112
  28. package/dist/DSAvatar.d.ts +0 -45
  29. package/dist/DSDelete.d.ts +0 -61
  30. package/dist/DSForm.d.ts +0 -151
  31. package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
  32. package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
  33. package/dist/DSGridOrTable.d.ts +0 -296
  34. package/dist/DSLocaleSwitcher.d.ts +0 -71
  35. package/dist/DSLogout.d.ts +0 -76
  36. package/dist/DSNotifications.d.ts +0 -54
  37. package/dist/DSRestore.d.ts +0 -56
  38. package/dist/DSSelect.d.ts +0 -221
  39. package/dist/DSSelectBox.d.ts +0 -123
  40. package/dist/DSSimpleSlider.d.ts +0 -136
  41. package/dist/DSSvgFetch.d.ts +0 -17
  42. package/dist/DSTable/DSTableExport.d.ts +0 -11
  43. package/dist/DSTable/DSTableFilter.d.ts +0 -40
  44. package/dist/DSTable/DSTablePagination.d.ts +0 -12
  45. package/dist/DSTable/DSTableSearch.d.ts +0 -8
  46. package/dist/DSTable/DSTableSelection.d.ts +0 -46
  47. package/dist/DSTable/DSTableSort.d.ts +0 -8
  48. package/dist/DSTable.d.ts +0 -116
  49. package/dist/DSTabs.d.ts +0 -156
  50. package/dist/DSUpload.d.ts +0 -220
  51. package/dist/index.d.ts +0 -17
@@ -1,563 +1,563 @@
1
- /**
2
- * DSSelectBox
3
- *
4
- * A dual-list selector component for transferring items between two lists.
5
- * Supports:
6
- * - Static options (JSON array/object) or AJAX URL
7
- * - Bi-directional transfer (left ↔ right)
8
- * - Search/filter for both lists
9
- * - Select All and Invert Selection buttons
10
- * - Event system for selection changes
11
- *
12
- * Usage:
13
- * const selectBox = new DSSelectBox('#container', {
14
- * availableOptions: [...], // or axiosUrl: '/api/items'
15
- * selectedOptions: [...], // Pre-selected items
16
- * valueKey: 'id',
17
- * labelKey: 'name',
18
- * onChange: (selected) => console.log(selected)
19
- * });
20
- */
21
- export class DSSelectBox {
22
- static instances = new Map();
23
- static instanceCounter = 0;
24
-
25
- static icons = {
26
- moveRight: `<span class="material-symbols-outlined text-lg">chevron_right</span>`,
27
- moveLeft: `<span class="material-symbols-outlined text-lg">chevron_left</span>`,
28
- moveAllRight: `<span class="material-symbols-outlined text-lg">keyboard_double_arrow_right</span>`,
29
- moveAllLeft: `<span class="material-symbols-outlined text-lg">keyboard_double_arrow_left</span>`,
30
- search: `<span class="material-symbols-outlined text-sm opacity-50">search</span>`
31
- };
32
-
33
- static defaults = {
34
- // Data sources
35
- availableOptions: [], // [{id: 1, name: 'Item'}] or {1: 'Item'}
36
- selectedOptions: [], // Pre-selected items
37
- axiosUrl: null, // Remote data URL for available items
38
- axiosMethod: 'GET',
39
- axiosParams: {},
40
- axiosDataPath: 'data',
41
-
42
- // Value configuration
43
- valueKey: 'id',
44
- labelKey: 'name',
45
-
46
- // UI Labels
47
- availableTitle: 'Available Items',
48
- selectedTitle: 'Selected Items',
49
- selectAllText: 'Select All',
50
- invertSelectionText: 'Invert',
51
- searchPlaceholder: 'Search...',
52
- noItemsText: 'No items',
53
-
54
- // Height
55
- listHeight: '300px',
56
-
57
- // Callbacks
58
- onChange: null, // (selectedItems) => {}
59
- onMove: null, // (direction, items) => {}
60
-
61
- // Classes
62
- wrapperClass: '',
63
- };
64
-
65
- constructor(selector, config = {}) {
66
- this.instanceId = `ds-selectbox-${++DSSelectBox.instanceCounter}`;
67
- this.wrapper = typeof selector === 'string'
68
- ? document.querySelector(selector)
69
- : selector;
70
-
71
- if (!this.wrapper) {
72
- throw new Error('DSSelectBox: Container element not found.');
73
- }
74
-
75
- this.cfg = { ...DSSelectBox.defaults, ...config };
76
-
77
- // State
78
- this._availableItems = [];
79
- this._selectedItems = [];
80
- this._filteredAvailable = [];
81
- this._filteredSelected = [];
82
- this._highlightedAvailable = new Set();
83
- this._highlightedSelected = new Set();
84
- this._isLoading = false;
85
-
86
- this._init();
87
-
88
- DSSelectBox.instances.set(this.instanceId, this);
89
- this.wrapper.dataset.dsSelectboxId = this.instanceId;
90
- }
91
-
92
- static getInstance(element) {
93
- const el = typeof element === 'string' ? document.querySelector(element) : element;
94
- if (!el) return null;
95
- const id = el.dataset.dsSelectboxId;
96
- return id ? DSSelectBox.instances.get(id) : null;
97
- }
98
-
99
- // ==================== INITIALIZATION ====================
100
-
101
- _init() {
102
- this._buildDOM();
103
- this._cacheElements();
104
- this._loadOptions();
105
- this._bindEvents();
106
- }
107
-
108
- _buildDOM() {
109
- this.wrapper.classList.add('ds-selectbox-wrapper', 'w-full');
110
- if (this.cfg.wrapperClass) {
111
- this.wrapper.classList.add(...this.cfg.wrapperClass.split(' '));
112
- }
113
-
114
- this.wrapper.innerHTML = `
115
- <div class="flex gap-4 items-stretch">
116
- <!-- Available Items (Left) -->
117
- <div class="flex-1 flex flex-col border border-base-300 rounded-box bg-base-100">
118
- <div class="p-3 border-b border-base-300 bg-base-200 rounded-t-box">
119
- <h4 class="font-semibold text-sm mb-2">${this.cfg.availableTitle}</h4>
120
- <div class="relative">
121
- <input type="text"
122
- class="input input-sm input-bordered w-full pl-8"
123
- placeholder="${this.cfg.searchPlaceholder}"
124
- data-search="available">
125
- <span class="absolute left-2 top-1/2 -translate-y-1/2">${DSSelectBox.icons.search}</span>
126
- </div>
127
- </div>
128
- <div class="flex-1 overflow-y-auto p-2" style="height: ${this.cfg.listHeight}" data-list="available">
129
- <div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>
130
- </div>
131
- <div class="p-2 border-t border-base-300 flex gap-2">
132
- <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="invert-available">
133
- ${this.cfg.invertSelectionText}
134
- </button>
135
- <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="select-all-available">
136
- ${this.cfg.selectAllText}
137
- </button>
138
- </div>
139
- </div>
140
-
141
- <!-- Transfer Buttons (Center) -->
142
- <div class="flex flex-col justify-center gap-2">
143
- <button type="button" class="btn btn-sm btn-outline" data-action="move-right" title="Move selected to right">
144
- ${DSSelectBox.icons.moveRight}
145
- </button>
146
- <button type="button" class="btn btn-sm btn-outline" data-action="move-all-right" title="Move all to right">
147
- ${DSSelectBox.icons.moveAllRight}
148
- </button>
149
- <button type="button" class="btn btn-sm btn-outline" data-action="move-left" title="Move selected to left">
150
- ${DSSelectBox.icons.moveLeft}
151
- </button>
152
- <button type="button" class="btn btn-sm btn-outline" data-action="move-all-left" title="Move all to left">
153
- ${DSSelectBox.icons.moveAllLeft}
154
- </button>
155
- </div>
156
-
157
- <!-- Selected Items (Right) -->
158
- <div class="flex-1 flex flex-col border border-base-300 rounded-box bg-base-100">
159
- <div class="p-3 border-b border-base-300 bg-base-200 rounded-t-box">
160
- <h4 class="font-semibold text-sm mb-2">${this.cfg.selectedTitle}</h4>
161
- <div class="relative">
162
- <input type="text"
163
- class="input input-sm input-bordered w-full pl-8"
164
- placeholder="${this.cfg.searchPlaceholder}"
165
- data-search="selected">
166
- <span class="absolute left-2 top-1/2 -translate-y-1/2">${DSSelectBox.icons.search}</span>
167
- </div>
168
- </div>
169
- <div class="flex-1 overflow-y-auto p-2" style="height: ${this.cfg.listHeight}" data-list="selected">
170
- <div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>
171
- </div>
172
- <div class="p-2 border-t border-base-300 flex gap-2">
173
- <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="invert-selected">
174
- ${this.cfg.invertSelectionText}
175
- </button>
176
- <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="select-all-selected">
177
- ${this.cfg.selectAllText}
178
- </button>
179
- </div>
180
- </div>
181
- </div>
182
-
183
- <!-- Hidden inputs for form submission -->
184
- <div data-hidden-inputs></div>
185
- `;
186
- }
187
-
188
- _cacheElements() {
189
- this.elements = {
190
- availableList: this.wrapper.querySelector('[data-list="available"]'),
191
- selectedList: this.wrapper.querySelector('[data-list="selected"]'),
192
- availableSearch: this.wrapper.querySelector('[data-search="available"]'),
193
- selectedSearch: this.wrapper.querySelector('[data-search="selected"]'),
194
- hiddenInputs: this.wrapper.querySelector('[data-hidden-inputs]'),
195
- btnMoveRight: this.wrapper.querySelector('[data-action="move-right"]'),
196
- btnMoveAllRight: this.wrapper.querySelector('[data-action="move-all-right"]'),
197
- btnMoveLeft: this.wrapper.querySelector('[data-action="move-left"]'),
198
- btnMoveAllLeft: this.wrapper.querySelector('[data-action="move-all-left"]'),
199
- };
200
- }
201
-
202
- _loadOptions() {
203
- // Load available options
204
- if (this.cfg.axiosUrl) {
205
- this._fetchRemoteOptions();
206
- } else {
207
- this._availableItems = this._normalizeOptions(this.cfg.availableOptions);
208
- }
209
-
210
- // Load pre-selected options
211
- this._selectedItems = this._normalizeOptions(this.cfg.selectedOptions);
212
-
213
- // Remove pre-selected from available
214
- const selectedValues = new Set(this._selectedItems.map(i => i.value));
215
- this._availableItems = this._availableItems.filter(i => !selectedValues.has(i.value));
216
-
217
- this._updateFiltered();
218
- this._render();
219
- }
220
-
221
- _normalizeOptions(options) {
222
- if (Array.isArray(options)) {
223
- return options.map(opt => {
224
- if (typeof opt === 'object') {
225
- return {
226
- value: String(opt[this.cfg.valueKey] ?? opt.id ?? opt.value),
227
- label: opt[this.cfg.labelKey] ?? opt.name ?? opt.label ?? opt.text,
228
- data: opt
229
- };
230
- }
231
- return { value: String(opt), label: String(opt), data: opt };
232
- });
233
- }
234
-
235
- if (typeof options === 'object') {
236
- return Object.entries(options).map(([key, value]) => ({
237
- value: String(key),
238
- label: String(value),
239
- data: { [this.cfg.valueKey]: key, [this.cfg.labelKey]: value }
240
- }));
241
- }
242
-
243
- return [];
244
- }
245
-
246
- async _fetchRemoteOptions() {
247
- this._isLoading = true;
248
- this._renderLoading();
249
-
250
- try {
251
- let response;
252
- if (window.axios) {
253
- response = await window.axios({
254
- method: this.cfg.axiosMethod,
255
- url: this.cfg.axiosUrl,
256
- params: this.cfg.axiosMethod.toUpperCase() === 'GET' ? this.cfg.axiosParams : undefined,
257
- data: this.cfg.axiosMethod.toUpperCase() !== 'GET' ? this.cfg.axiosParams : undefined
258
- });
259
- response = response.data;
260
- } else {
261
- const url = new URL(this.cfg.axiosUrl, window.location.origin);
262
- if (this.cfg.axiosMethod.toUpperCase() === 'GET') {
263
- Object.entries(this.cfg.axiosParams).forEach(([k, v]) => url.searchParams.set(k, v));
264
- }
265
- const res = await fetch(url.toString(), { method: this.cfg.axiosMethod });
266
- response = await res.json();
267
- }
268
-
269
- let data = response;
270
- if (this.cfg.axiosDataPath) {
271
- const paths = this.cfg.axiosDataPath.split('.');
272
- for (const path of paths) {
273
- data = data?.[path];
274
- }
275
- }
276
-
277
- this._availableItems = this._normalizeOptions(data || []);
278
-
279
- // Remove already selected items
280
- const selectedValues = new Set(this._selectedItems.map(i => i.value));
281
- this._availableItems = this._availableItems.filter(i => !selectedValues.has(i.value));
282
-
283
- this._updateFiltered();
284
- this._render();
285
- } catch (error) {
286
- console.error('DSSelectBox: Failed to fetch options', error);
287
- } finally {
288
- this._isLoading = false;
289
- }
290
- }
291
-
292
- // ==================== EVENTS ====================
293
-
294
- _bindEvents() {
295
- // Search inputs
296
- this.elements.availableSearch.addEventListener('input', (e) => {
297
- this._filterList('available', e.target.value);
298
- });
299
-
300
- this.elements.selectedSearch.addEventListener('input', (e) => {
301
- this._filterList('selected', e.target.value);
302
- });
303
-
304
- // List item clicks
305
- this.elements.availableList.addEventListener('click', (e) => {
306
- const item = e.target.closest('[data-value]');
307
- if (item) this._toggleHighlight('available', item.dataset.value, e.ctrlKey || e.metaKey);
308
- });
309
-
310
- this.elements.selectedList.addEventListener('click', (e) => {
311
- const item = e.target.closest('[data-value]');
312
- if (item) this._toggleHighlight('selected', item.dataset.value, e.ctrlKey || e.metaKey);
313
- });
314
-
315
- // Double-click to move
316
- this.elements.availableList.addEventListener('dblclick', (e) => {
317
- const item = e.target.closest('[data-value]');
318
- if (item) {
319
- this._highlightedAvailable.clear();
320
- this._highlightedAvailable.add(item.dataset.value);
321
- this._moveRight();
322
- }
323
- });
324
-
325
- this.elements.selectedList.addEventListener('dblclick', (e) => {
326
- const item = e.target.closest('[data-value]');
327
- if (item) {
328
- this._highlightedSelected.clear();
329
- this._highlightedSelected.add(item.dataset.value);
330
- this._moveLeft();
331
- }
332
- });
333
-
334
- // Transfer buttons
335
- this.elements.btnMoveRight.addEventListener('click', () => this._moveRight());
336
- this.elements.btnMoveAllRight.addEventListener('click', () => this._moveAllRight());
337
- this.elements.btnMoveLeft.addEventListener('click', () => this._moveLeft());
338
- this.elements.btnMoveAllLeft.addEventListener('click', () => this._moveAllLeft());
339
-
340
- // Select All / Invert
341
- this.wrapper.querySelector('[data-action="select-all-available"]').addEventListener('click', () => {
342
- this._filteredAvailable.forEach(i => this._highlightedAvailable.add(i.value));
343
- this._render();
344
- });
345
-
346
- this.wrapper.querySelector('[data-action="select-all-selected"]').addEventListener('click', () => {
347
- this._filteredSelected.forEach(i => this._highlightedSelected.add(i.value));
348
- this._render();
349
- });
350
-
351
- this.wrapper.querySelector('[data-action="invert-available"]').addEventListener('click', () => {
352
- this._filteredAvailable.forEach(i => {
353
- if (this._highlightedAvailable.has(i.value)) {
354
- this._highlightedAvailable.delete(i.value);
355
- } else {
356
- this._highlightedAvailable.add(i.value);
357
- }
358
- });
359
- this._render();
360
- });
361
-
362
- this.wrapper.querySelector('[data-action="invert-selected"]').addEventListener('click', () => {
363
- this._filteredSelected.forEach(i => {
364
- if (this._highlightedSelected.has(i.value)) {
365
- this._highlightedSelected.delete(i.value);
366
- } else {
367
- this._highlightedSelected.add(i.value);
368
- }
369
- });
370
- this._render();
371
- });
372
- }
373
-
374
- // ==================== ACTIONS ====================
375
-
376
- _toggleHighlight(list, value, multi = false) {
377
- const set = list === 'available' ? this._highlightedAvailable : this._highlightedSelected;
378
-
379
- if (!multi) {
380
- set.clear();
381
- }
382
-
383
- if (set.has(value)) {
384
- set.delete(value);
385
- } else {
386
- set.add(value);
387
- }
388
-
389
- this._render();
390
- }
391
-
392
- _moveRight() {
393
- if (this._highlightedAvailable.size === 0) return;
394
-
395
- const toMove = this._availableItems.filter(i => this._highlightedAvailable.has(i.value));
396
- this._availableItems = this._availableItems.filter(i => !this._highlightedAvailable.has(i.value));
397
- this._selectedItems.push(...toMove);
398
- this._highlightedAvailable.clear();
399
-
400
- this._updateFiltered();
401
- this._render();
402
- this._emitChange('right', toMove);
403
- }
404
-
405
- _moveAllRight() {
406
- const toMove = [...this._filteredAvailable];
407
- this._availableItems = this._availableItems.filter(i => !this._filteredAvailable.some(f => f.value === i.value));
408
- this._selectedItems.push(...toMove);
409
- this._highlightedAvailable.clear();
410
-
411
- this._updateFiltered();
412
- this._render();
413
- this._emitChange('right', toMove);
414
- }
415
-
416
- _moveLeft() {
417
- if (this._highlightedSelected.size === 0) return;
418
-
419
- const toMove = this._selectedItems.filter(i => this._highlightedSelected.has(i.value));
420
- this._selectedItems = this._selectedItems.filter(i => !this._highlightedSelected.has(i.value));
421
- this._availableItems.push(...toMove);
422
- this._highlightedSelected.clear();
423
-
424
- this._updateFiltered();
425
- this._render();
426
- this._emitChange('left', toMove);
427
- }
428
-
429
- _moveAllLeft() {
430
- const toMove = [...this._filteredSelected];
431
- this._selectedItems = this._selectedItems.filter(i => !this._filteredSelected.some(f => f.value === i.value));
432
- this._availableItems.push(...toMove);
433
- this._highlightedSelected.clear();
434
-
435
- this._updateFiltered();
436
- this._render();
437
- this._emitChange('left', toMove);
438
- }
439
-
440
- _filterList(list, term) {
441
- const searchTerm = term.toLowerCase().trim();
442
-
443
- if (list === 'available') {
444
- this._filteredAvailable = searchTerm
445
- ? this._availableItems.filter(i => i.label.toLowerCase().includes(searchTerm))
446
- : [...this._availableItems];
447
- } else {
448
- this._filteredSelected = searchTerm
449
- ? this._selectedItems.filter(i => i.label.toLowerCase().includes(searchTerm))
450
- : [...this._selectedItems];
451
- }
452
-
453
- this._render();
454
- }
455
-
456
- _updateFiltered() {
457
- this._filteredAvailable = [...this._availableItems];
458
- this._filteredSelected = [...this._selectedItems];
459
- }
460
-
461
- // ==================== RENDERING ====================
462
-
463
- _render() {
464
- this._renderList('available');
465
- this._renderList('selected');
466
- this._updateHiddenInputs();
467
- }
468
-
469
- _renderList(list) {
470
- const container = list === 'available' ? this.elements.availableList : this.elements.selectedList;
471
- const items = list === 'available' ? this._filteredAvailable : this._filteredSelected;
472
- const highlighted = list === 'available' ? this._highlightedAvailable : this._highlightedSelected;
473
-
474
- if (items.length === 0) {
475
- container.innerHTML = `<div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>`;
476
- return;
477
- }
478
-
479
- container.innerHTML = items.map(item => `
480
- <div class="px-3 py-2 rounded cursor-pointer text-sm transition-colors select-none
481
- ${highlighted.has(item.value) ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}"
482
- data-value="${this._escapeHtml(item.value)}">
483
- ${this._escapeHtml(item.label)}
484
- </div>
485
- `).join('');
486
- }
487
-
488
- _renderLoading() {
489
- this.elements.availableList.innerHTML = `
490
- <div class="text-center py-4">
491
- <span class="loading loading-spinner loading-md"></span>
492
- </div>
493
- `;
494
- }
495
-
496
- _updateHiddenInputs() {
497
- const name = this.wrapper.dataset.name || 'selected';
498
- this.elements.hiddenInputs.innerHTML = this._selectedItems.map(item =>
499
- `<input type="hidden" name="${name}[]" value="${this._escapeHtml(item.value)}">`
500
- ).join('');
501
- }
502
-
503
- _escapeHtml(str) {
504
- const div = document.createElement('div');
505
- div.textContent = str;
506
- return div.innerHTML;
507
- }
508
-
509
- // ==================== EVENTS ====================
510
-
511
- _emitChange(direction, movedItems) {
512
- if (this.cfg.onMove) {
513
- this.cfg.onMove(direction, movedItems);
514
- }
515
-
516
- if (this.cfg.onChange) {
517
- this.cfg.onChange(this._selectedItems);
518
- }
519
-
520
- this.wrapper.dispatchEvent(new CustomEvent('dsselectbox:change', {
521
- bubbles: true,
522
- detail: { selected: this._selectedItems, direction, moved: movedItems }
523
- }));
524
- }
525
-
526
- // ==================== PUBLIC API ====================
527
-
528
- getSelected() {
529
- return this._selectedItems.map(i => i.value);
530
- }
531
-
532
- getSelectedItems() {
533
- return [...this._selectedItems];
534
- }
535
-
536
- setSelected(values) {
537
- const valueSet = new Set(values.map(String));
538
-
539
- // Move matching items from available to selected
540
- const toSelect = this._availableItems.filter(i => valueSet.has(i.value));
541
- this._availableItems = this._availableItems.filter(i => !valueSet.has(i.value));
542
- this._selectedItems.push(...toSelect);
543
-
544
- this._updateFiltered();
545
- this._render();
546
- }
547
-
548
- reset() {
549
- this._availableItems.push(...this._selectedItems);
550
- this._selectedItems = [];
551
- this._highlightedAvailable.clear();
552
- this._highlightedSelected.clear();
553
- this._updateFiltered();
554
- this._render();
555
- }
556
-
557
- destroy() {
558
- this.wrapper.innerHTML = '';
559
- DSSelectBox.instances.delete(this.instanceId);
560
- }
561
- }
562
-
563
- export default DSSelectBox;
1
+ /**
2
+ * DSSelectBox
3
+ *
4
+ * A dual-list selector component for transferring items between two lists.
5
+ * Supports:
6
+ * - Static options (JSON array/object) or AJAX URL
7
+ * - Bi-directional transfer (left ↔ right)
8
+ * - Search/filter for both lists
9
+ * - Select All and Invert Selection buttons
10
+ * - Event system for selection changes
11
+ *
12
+ * Usage:
13
+ * const selectBox = new DSSelectBox('#container', {
14
+ * availableOptions: [...], // or axiosUrl: '/api/items'
15
+ * selectedOptions: [...], // Pre-selected items
16
+ * valueKey: 'id',
17
+ * labelKey: 'name',
18
+ * onChange: (selected) => console.log(selected)
19
+ * });
20
+ */
21
+ export class DSSelectBox {
22
+ static instances = new Map();
23
+ static instanceCounter = 0;
24
+
25
+ static icons = {
26
+ moveRight: `<span class="material-symbols-outlined text-lg">chevron_right</span>`,
27
+ moveLeft: `<span class="material-symbols-outlined text-lg">chevron_left</span>`,
28
+ moveAllRight: `<span class="material-symbols-outlined text-lg">keyboard_double_arrow_right</span>`,
29
+ moveAllLeft: `<span class="material-symbols-outlined text-lg">keyboard_double_arrow_left</span>`,
30
+ search: `<span class="material-symbols-outlined text-sm opacity-50">search</span>`
31
+ };
32
+
33
+ static defaults = {
34
+ // Data sources
35
+ availableOptions: [], // [{id: 1, name: 'Item'}] or {1: 'Item'}
36
+ selectedOptions: [], // Pre-selected items
37
+ axiosUrl: null, // Remote data URL for available items
38
+ axiosMethod: 'GET',
39
+ axiosParams: {},
40
+ axiosDataPath: 'data',
41
+
42
+ // Value configuration
43
+ valueKey: 'id',
44
+ labelKey: 'name',
45
+
46
+ // UI Labels
47
+ availableTitle: 'Available Items',
48
+ selectedTitle: 'Selected Items',
49
+ selectAllText: 'Select All',
50
+ invertSelectionText: 'Invert',
51
+ searchPlaceholder: 'Search...',
52
+ noItemsText: 'No items',
53
+
54
+ // Height
55
+ listHeight: '300px',
56
+
57
+ // Callbacks
58
+ onChange: null, // (selectedItems) => {}
59
+ onMove: null, // (direction, items) => {}
60
+
61
+ // Classes
62
+ wrapperClass: '',
63
+ };
64
+
65
+ constructor(selector, config = {}) {
66
+ this.instanceId = `ds-selectbox-${++DSSelectBox.instanceCounter}`;
67
+ this.wrapper = typeof selector === 'string'
68
+ ? document.querySelector(selector)
69
+ : selector;
70
+
71
+ if (!this.wrapper) {
72
+ throw new Error('DSSelectBox: Container element not found.');
73
+ }
74
+
75
+ this.cfg = { ...DSSelectBox.defaults, ...config };
76
+
77
+ // State
78
+ this._availableItems = [];
79
+ this._selectedItems = [];
80
+ this._filteredAvailable = [];
81
+ this._filteredSelected = [];
82
+ this._highlightedAvailable = new Set();
83
+ this._highlightedSelected = new Set();
84
+ this._isLoading = false;
85
+
86
+ this._init();
87
+
88
+ DSSelectBox.instances.set(this.instanceId, this);
89
+ this.wrapper.dataset.dsSelectboxId = this.instanceId;
90
+ }
91
+
92
+ static getInstance(element) {
93
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
94
+ if (!el) return null;
95
+ const id = el.dataset.dsSelectboxId;
96
+ return id ? DSSelectBox.instances.get(id) : null;
97
+ }
98
+
99
+ // ==================== INITIALIZATION ====================
100
+
101
+ _init() {
102
+ this._buildDOM();
103
+ this._cacheElements();
104
+ this._loadOptions();
105
+ this._bindEvents();
106
+ }
107
+
108
+ _buildDOM() {
109
+ this.wrapper.classList.add('ds-selectbox-wrapper', 'w-full');
110
+ if (this.cfg.wrapperClass) {
111
+ this.wrapper.classList.add(...this.cfg.wrapperClass.split(' '));
112
+ }
113
+
114
+ this.wrapper.innerHTML = `
115
+ <div class="flex gap-4 items-stretch">
116
+ <!-- Available Items (Left) -->
117
+ <div class="flex-1 flex flex-col border border-base-300 rounded-box bg-base-100">
118
+ <div class="p-3 border-b border-base-300 bg-base-200 rounded-t-box">
119
+ <h4 class="font-semibold text-sm mb-2">${this.cfg.availableTitle}</h4>
120
+ <div class="relative">
121
+ <input type="text"
122
+ class="input input-sm input-bordered w-full pl-8"
123
+ placeholder="${this.cfg.searchPlaceholder}"
124
+ data-search="available">
125
+ <span class="absolute left-2 top-1/2 -translate-y-1/2">${DSSelectBox.icons.search}</span>
126
+ </div>
127
+ </div>
128
+ <div class="flex-1 overflow-y-auto p-2" style="height: ${this.cfg.listHeight}" data-list="available">
129
+ <div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>
130
+ </div>
131
+ <div class="p-2 border-t border-base-300 flex gap-2">
132
+ <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="invert-available">
133
+ ${this.cfg.invertSelectionText}
134
+ </button>
135
+ <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="select-all-available">
136
+ ${this.cfg.selectAllText}
137
+ </button>
138
+ </div>
139
+ </div>
140
+
141
+ <!-- Transfer Buttons (Center) -->
142
+ <div class="flex flex-col justify-center gap-2">
143
+ <button type="button" class="btn btn-sm btn-outline" data-action="move-right" title="Move selected to right">
144
+ ${DSSelectBox.icons.moveRight}
145
+ </button>
146
+ <button type="button" class="btn btn-sm btn-outline" data-action="move-all-right" title="Move all to right">
147
+ ${DSSelectBox.icons.moveAllRight}
148
+ </button>
149
+ <button type="button" class="btn btn-sm btn-outline" data-action="move-left" title="Move selected to left">
150
+ ${DSSelectBox.icons.moveLeft}
151
+ </button>
152
+ <button type="button" class="btn btn-sm btn-outline" data-action="move-all-left" title="Move all to left">
153
+ ${DSSelectBox.icons.moveAllLeft}
154
+ </button>
155
+ </div>
156
+
157
+ <!-- Selected Items (Right) -->
158
+ <div class="flex-1 flex flex-col border border-base-300 rounded-box bg-base-100">
159
+ <div class="p-3 border-b border-base-300 bg-base-200 rounded-t-box">
160
+ <h4 class="font-semibold text-sm mb-2">${this.cfg.selectedTitle}</h4>
161
+ <div class="relative">
162
+ <input type="text"
163
+ class="input input-sm input-bordered w-full pl-8"
164
+ placeholder="${this.cfg.searchPlaceholder}"
165
+ data-search="selected">
166
+ <span class="absolute left-2 top-1/2 -translate-y-1/2">${DSSelectBox.icons.search}</span>
167
+ </div>
168
+ </div>
169
+ <div class="flex-1 overflow-y-auto p-2" style="height: ${this.cfg.listHeight}" data-list="selected">
170
+ <div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>
171
+ </div>
172
+ <div class="p-2 border-t border-base-300 flex gap-2">
173
+ <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="invert-selected">
174
+ ${this.cfg.invertSelectionText}
175
+ </button>
176
+ <button type="button" class="btn btn-xs btn-ghost flex-1" data-action="select-all-selected">
177
+ ${this.cfg.selectAllText}
178
+ </button>
179
+ </div>
180
+ </div>
181
+ </div>
182
+
183
+ <!-- Hidden inputs for form submission -->
184
+ <div data-hidden-inputs></div>
185
+ `;
186
+ }
187
+
188
+ _cacheElements() {
189
+ this.elements = {
190
+ availableList: this.wrapper.querySelector('[data-list="available"]'),
191
+ selectedList: this.wrapper.querySelector('[data-list="selected"]'),
192
+ availableSearch: this.wrapper.querySelector('[data-search="available"]'),
193
+ selectedSearch: this.wrapper.querySelector('[data-search="selected"]'),
194
+ hiddenInputs: this.wrapper.querySelector('[data-hidden-inputs]'),
195
+ btnMoveRight: this.wrapper.querySelector('[data-action="move-right"]'),
196
+ btnMoveAllRight: this.wrapper.querySelector('[data-action="move-all-right"]'),
197
+ btnMoveLeft: this.wrapper.querySelector('[data-action="move-left"]'),
198
+ btnMoveAllLeft: this.wrapper.querySelector('[data-action="move-all-left"]'),
199
+ };
200
+ }
201
+
202
+ _loadOptions() {
203
+ // Load available options
204
+ if (this.cfg.axiosUrl) {
205
+ this._fetchRemoteOptions();
206
+ } else {
207
+ this._availableItems = this._normalizeOptions(this.cfg.availableOptions);
208
+ }
209
+
210
+ // Load pre-selected options
211
+ this._selectedItems = this._normalizeOptions(this.cfg.selectedOptions);
212
+
213
+ // Remove pre-selected from available
214
+ const selectedValues = new Set(this._selectedItems.map(i => i.value));
215
+ this._availableItems = this._availableItems.filter(i => !selectedValues.has(i.value));
216
+
217
+ this._updateFiltered();
218
+ this._render();
219
+ }
220
+
221
+ _normalizeOptions(options) {
222
+ if (Array.isArray(options)) {
223
+ return options.map(opt => {
224
+ if (typeof opt === 'object') {
225
+ return {
226
+ value: String(opt[this.cfg.valueKey] ?? opt.id ?? opt.value),
227
+ label: opt[this.cfg.labelKey] ?? opt.name ?? opt.label ?? opt.text,
228
+ data: opt
229
+ };
230
+ }
231
+ return { value: String(opt), label: String(opt), data: opt };
232
+ });
233
+ }
234
+
235
+ if (typeof options === 'object') {
236
+ return Object.entries(options).map(([key, value]) => ({
237
+ value: String(key),
238
+ label: String(value),
239
+ data: { [this.cfg.valueKey]: key, [this.cfg.labelKey]: value }
240
+ }));
241
+ }
242
+
243
+ return [];
244
+ }
245
+
246
+ async _fetchRemoteOptions() {
247
+ this._isLoading = true;
248
+ this._renderLoading();
249
+
250
+ try {
251
+ let response;
252
+ if (window.axios) {
253
+ response = await window.axios({
254
+ method: this.cfg.axiosMethod,
255
+ url: this.cfg.axiosUrl,
256
+ params: this.cfg.axiosMethod.toUpperCase() === 'GET' ? this.cfg.axiosParams : undefined,
257
+ data: this.cfg.axiosMethod.toUpperCase() !== 'GET' ? this.cfg.axiosParams : undefined
258
+ });
259
+ response = response.data;
260
+ } else {
261
+ const url = new URL(this.cfg.axiosUrl, window.location.origin);
262
+ if (this.cfg.axiosMethod.toUpperCase() === 'GET') {
263
+ Object.entries(this.cfg.axiosParams).forEach(([k, v]) => url.searchParams.set(k, v));
264
+ }
265
+ const res = await fetch(url.toString(), { method: this.cfg.axiosMethod });
266
+ response = await res.json();
267
+ }
268
+
269
+ let data = response;
270
+ if (this.cfg.axiosDataPath) {
271
+ const paths = this.cfg.axiosDataPath.split('.');
272
+ for (const path of paths) {
273
+ data = data?.[path];
274
+ }
275
+ }
276
+
277
+ this._availableItems = this._normalizeOptions(data || []);
278
+
279
+ // Remove already selected items
280
+ const selectedValues = new Set(this._selectedItems.map(i => i.value));
281
+ this._availableItems = this._availableItems.filter(i => !selectedValues.has(i.value));
282
+
283
+ this._updateFiltered();
284
+ this._render();
285
+ } catch (error) {
286
+ console.error('DSSelectBox: Failed to fetch options', error);
287
+ } finally {
288
+ this._isLoading = false;
289
+ }
290
+ }
291
+
292
+ // ==================== EVENTS ====================
293
+
294
+ _bindEvents() {
295
+ // Search inputs
296
+ this.elements.availableSearch.addEventListener('input', (e) => {
297
+ this._filterList('available', e.target.value);
298
+ });
299
+
300
+ this.elements.selectedSearch.addEventListener('input', (e) => {
301
+ this._filterList('selected', e.target.value);
302
+ });
303
+
304
+ // List item clicks
305
+ this.elements.availableList.addEventListener('click', (e) => {
306
+ const item = e.target.closest('[data-value]');
307
+ if (item) this._toggleHighlight('available', item.dataset.value, e.ctrlKey || e.metaKey);
308
+ });
309
+
310
+ this.elements.selectedList.addEventListener('click', (e) => {
311
+ const item = e.target.closest('[data-value]');
312
+ if (item) this._toggleHighlight('selected', item.dataset.value, e.ctrlKey || e.metaKey);
313
+ });
314
+
315
+ // Double-click to move
316
+ this.elements.availableList.addEventListener('dblclick', (e) => {
317
+ const item = e.target.closest('[data-value]');
318
+ if (item) {
319
+ this._highlightedAvailable.clear();
320
+ this._highlightedAvailable.add(item.dataset.value);
321
+ this._moveRight();
322
+ }
323
+ });
324
+
325
+ this.elements.selectedList.addEventListener('dblclick', (e) => {
326
+ const item = e.target.closest('[data-value]');
327
+ if (item) {
328
+ this._highlightedSelected.clear();
329
+ this._highlightedSelected.add(item.dataset.value);
330
+ this._moveLeft();
331
+ }
332
+ });
333
+
334
+ // Transfer buttons
335
+ this.elements.btnMoveRight.addEventListener('click', () => this._moveRight());
336
+ this.elements.btnMoveAllRight.addEventListener('click', () => this._moveAllRight());
337
+ this.elements.btnMoveLeft.addEventListener('click', () => this._moveLeft());
338
+ this.elements.btnMoveAllLeft.addEventListener('click', () => this._moveAllLeft());
339
+
340
+ // Select All / Invert
341
+ this.wrapper.querySelector('[data-action="select-all-available"]').addEventListener('click', () => {
342
+ this._filteredAvailable.forEach(i => this._highlightedAvailable.add(i.value));
343
+ this._render();
344
+ });
345
+
346
+ this.wrapper.querySelector('[data-action="select-all-selected"]').addEventListener('click', () => {
347
+ this._filteredSelected.forEach(i => this._highlightedSelected.add(i.value));
348
+ this._render();
349
+ });
350
+
351
+ this.wrapper.querySelector('[data-action="invert-available"]').addEventListener('click', () => {
352
+ this._filteredAvailable.forEach(i => {
353
+ if (this._highlightedAvailable.has(i.value)) {
354
+ this._highlightedAvailable.delete(i.value);
355
+ } else {
356
+ this._highlightedAvailable.add(i.value);
357
+ }
358
+ });
359
+ this._render();
360
+ });
361
+
362
+ this.wrapper.querySelector('[data-action="invert-selected"]').addEventListener('click', () => {
363
+ this._filteredSelected.forEach(i => {
364
+ if (this._highlightedSelected.has(i.value)) {
365
+ this._highlightedSelected.delete(i.value);
366
+ } else {
367
+ this._highlightedSelected.add(i.value);
368
+ }
369
+ });
370
+ this._render();
371
+ });
372
+ }
373
+
374
+ // ==================== ACTIONS ====================
375
+
376
+ _toggleHighlight(list, value, multi = false) {
377
+ const set = list === 'available' ? this._highlightedAvailable : this._highlightedSelected;
378
+
379
+ if (!multi) {
380
+ set.clear();
381
+ }
382
+
383
+ if (set.has(value)) {
384
+ set.delete(value);
385
+ } else {
386
+ set.add(value);
387
+ }
388
+
389
+ this._render();
390
+ }
391
+
392
+ _moveRight() {
393
+ if (this._highlightedAvailable.size === 0) return;
394
+
395
+ const toMove = this._availableItems.filter(i => this._highlightedAvailable.has(i.value));
396
+ this._availableItems = this._availableItems.filter(i => !this._highlightedAvailable.has(i.value));
397
+ this._selectedItems.push(...toMove);
398
+ this._highlightedAvailable.clear();
399
+
400
+ this._updateFiltered();
401
+ this._render();
402
+ this._emitChange('right', toMove);
403
+ }
404
+
405
+ _moveAllRight() {
406
+ const toMove = [...this._filteredAvailable];
407
+ this._availableItems = this._availableItems.filter(i => !this._filteredAvailable.some(f => f.value === i.value));
408
+ this._selectedItems.push(...toMove);
409
+ this._highlightedAvailable.clear();
410
+
411
+ this._updateFiltered();
412
+ this._render();
413
+ this._emitChange('right', toMove);
414
+ }
415
+
416
+ _moveLeft() {
417
+ if (this._highlightedSelected.size === 0) return;
418
+
419
+ const toMove = this._selectedItems.filter(i => this._highlightedSelected.has(i.value));
420
+ this._selectedItems = this._selectedItems.filter(i => !this._highlightedSelected.has(i.value));
421
+ this._availableItems.push(...toMove);
422
+ this._highlightedSelected.clear();
423
+
424
+ this._updateFiltered();
425
+ this._render();
426
+ this._emitChange('left', toMove);
427
+ }
428
+
429
+ _moveAllLeft() {
430
+ const toMove = [...this._filteredSelected];
431
+ this._selectedItems = this._selectedItems.filter(i => !this._filteredSelected.some(f => f.value === i.value));
432
+ this._availableItems.push(...toMove);
433
+ this._highlightedSelected.clear();
434
+
435
+ this._updateFiltered();
436
+ this._render();
437
+ this._emitChange('left', toMove);
438
+ }
439
+
440
+ _filterList(list, term) {
441
+ const searchTerm = term.toLowerCase().trim();
442
+
443
+ if (list === 'available') {
444
+ this._filteredAvailable = searchTerm
445
+ ? this._availableItems.filter(i => i.label.toLowerCase().includes(searchTerm))
446
+ : [...this._availableItems];
447
+ } else {
448
+ this._filteredSelected = searchTerm
449
+ ? this._selectedItems.filter(i => i.label.toLowerCase().includes(searchTerm))
450
+ : [...this._selectedItems];
451
+ }
452
+
453
+ this._render();
454
+ }
455
+
456
+ _updateFiltered() {
457
+ this._filteredAvailable = [...this._availableItems];
458
+ this._filteredSelected = [...this._selectedItems];
459
+ }
460
+
461
+ // ==================== RENDERING ====================
462
+
463
+ _render() {
464
+ this._renderList('available');
465
+ this._renderList('selected');
466
+ this._updateHiddenInputs();
467
+ }
468
+
469
+ _renderList(list) {
470
+ const container = list === 'available' ? this.elements.availableList : this.elements.selectedList;
471
+ const items = list === 'available' ? this._filteredAvailable : this._filteredSelected;
472
+ const highlighted = list === 'available' ? this._highlightedAvailable : this._highlightedSelected;
473
+
474
+ if (items.length === 0) {
475
+ container.innerHTML = `<div class="text-sm text-base-content/50 text-center py-4">${this.cfg.noItemsText}</div>`;
476
+ return;
477
+ }
478
+
479
+ container.innerHTML = items.map(item => `
480
+ <div class="px-3 py-2 rounded cursor-pointer text-sm transition-colors select-none
481
+ ${highlighted.has(item.value) ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}"
482
+ data-value="${this._escapeHtml(item.value)}">
483
+ ${this._escapeHtml(item.label)}
484
+ </div>
485
+ `).join('');
486
+ }
487
+
488
+ _renderLoading() {
489
+ this.elements.availableList.innerHTML = `
490
+ <div class="text-center py-4">
491
+ <span class="loading loading-spinner loading-md"></span>
492
+ </div>
493
+ `;
494
+ }
495
+
496
+ _updateHiddenInputs() {
497
+ const name = this.wrapper.dataset.name || 'selected';
498
+ this.elements.hiddenInputs.innerHTML = this._selectedItems.map(item =>
499
+ `<input type="hidden" name="${name}[]" value="${this._escapeHtml(item.value)}">`
500
+ ).join('');
501
+ }
502
+
503
+ _escapeHtml(str) {
504
+ const div = document.createElement('div');
505
+ div.textContent = str;
506
+ return div.innerHTML;
507
+ }
508
+
509
+ // ==================== EVENTS ====================
510
+
511
+ _emitChange(direction, movedItems) {
512
+ if (this.cfg.onMove) {
513
+ this.cfg.onMove(direction, movedItems);
514
+ }
515
+
516
+ if (this.cfg.onChange) {
517
+ this.cfg.onChange(this._selectedItems);
518
+ }
519
+
520
+ this.wrapper.dispatchEvent(new CustomEvent('dsselectbox:change', {
521
+ bubbles: true,
522
+ detail: { selected: this._selectedItems, direction, moved: movedItems }
523
+ }));
524
+ }
525
+
526
+ // ==================== PUBLIC API ====================
527
+
528
+ getSelected() {
529
+ return this._selectedItems.map(i => i.value);
530
+ }
531
+
532
+ getSelectedItems() {
533
+ return [...this._selectedItems];
534
+ }
535
+
536
+ setSelected(values) {
537
+ const valueSet = new Set(values.map(String));
538
+
539
+ // Move matching items from available to selected
540
+ const toSelect = this._availableItems.filter(i => valueSet.has(i.value));
541
+ this._availableItems = this._availableItems.filter(i => !valueSet.has(i.value));
542
+ this._selectedItems.push(...toSelect);
543
+
544
+ this._updateFiltered();
545
+ this._render();
546
+ }
547
+
548
+ reset() {
549
+ this._availableItems.push(...this._selectedItems);
550
+ this._selectedItems = [];
551
+ this._highlightedAvailable.clear();
552
+ this._highlightedSelected.clear();
553
+ this._updateFiltered();
554
+ this._render();
555
+ }
556
+
557
+ destroy() {
558
+ this.wrapper.innerHTML = '';
559
+ DSSelectBox.instances.delete(this.instanceId);
560
+ }
561
+ }
562
+
563
+ export default DSSelectBox;