@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.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/src/CodeInput.js +48 -48
- package/src/DSAlert.js +352 -352
- package/src/DSAvatar.js +207 -207
- package/src/DSDelete.js +274 -274
- package/src/DSForm.js +568 -568
- package/src/DSGridOrTable.js +453 -453
- package/src/DSLocaleSwitcher.js +239 -239
- package/src/DSLogout.js +293 -293
- package/src/DSNotifications.js +365 -365
- package/src/DSRestore.js +181 -181
- package/src/DSSelect.js +1071 -1071
- package/src/DSSelectBox.js +563 -563
- package/src/DSSimpleSlider.js +517 -517
- package/src/DSSvgFetch.js +69 -69
- package/src/DSTable/DSTableExport.js +68 -68
- package/src/DSTable/DSTableFilter.js +224 -224
- package/src/DSTable/DSTablePagination.js +136 -136
- package/src/DSTable/DSTableSearch.js +40 -40
- package/src/DSTable/DSTableSelection.js +192 -192
- package/src/DSTable/DSTableSort.js +58 -58
- package/src/DSTable.js +353 -353
- package/src/DSTabs.js +488 -488
- package/src/DSUpload.js +887 -887
- package/dist/CodeInput.d.ts +0 -10
- package/dist/DSAlert.d.ts +0 -112
- package/dist/DSAvatar.d.ts +0 -45
- package/dist/DSDelete.d.ts +0 -61
- package/dist/DSForm.d.ts +0 -151
- package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
- package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
- package/dist/DSGridOrTable.d.ts +0 -296
- package/dist/DSLocaleSwitcher.d.ts +0 -71
- package/dist/DSLogout.d.ts +0 -76
- package/dist/DSNotifications.d.ts +0 -54
- package/dist/DSRestore.d.ts +0 -56
- package/dist/DSSelect.d.ts +0 -221
- package/dist/DSSelectBox.d.ts +0 -123
- package/dist/DSSimpleSlider.d.ts +0 -136
- package/dist/DSSvgFetch.d.ts +0 -17
- package/dist/DSTable/DSTableExport.d.ts +0 -11
- package/dist/DSTable/DSTableFilter.d.ts +0 -40
- package/dist/DSTable/DSTablePagination.d.ts +0 -12
- package/dist/DSTable/DSTableSearch.d.ts +0 -8
- package/dist/DSTable/DSTableSelection.d.ts +0 -46
- package/dist/DSTable/DSTableSort.d.ts +0 -8
- package/dist/DSTable.d.ts +0 -116
- package/dist/DSTabs.d.ts +0 -156
- package/dist/DSUpload.d.ts +0 -220
- package/dist/index.d.ts +0 -17
package/src/DSSelectBox.js
CHANGED
|
@@ -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;
|