wally-ui 1.14.1 → 1.16.0

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 (62) hide show
  1. package/dist/cli.js +0 -0
  2. package/package.json +1 -1
  3. package/playground/showcase/public/sitemap.xml +15 -0
  4. package/playground/showcase/src/app/app.routes.server.ts +8 -0
  5. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
  6. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
  7. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
  8. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
  9. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
  10. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
  11. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
  12. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
  13. package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.css +0 -0
  14. package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.html +41 -0
  15. package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.spec.ts +228 -0
  16. package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.ts +217 -0
  17. package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.css +0 -0
  18. package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.html +3 -0
  19. package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.spec.ts +56 -0
  20. package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.ts +11 -0
  21. package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.css +0 -0
  22. package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.html +11 -0
  23. package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.spec.ts +57 -0
  24. package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.ts +11 -0
  25. package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.css +0 -0
  26. package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.html +71 -0
  27. package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.spec.ts +468 -0
  28. package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.ts +90 -0
  29. package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.css +0 -0
  30. package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.html +58 -0
  31. package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.spec.ts +173 -0
  32. package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.ts +37 -0
  33. package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.css +0 -0
  34. package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html +11 -0
  35. package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.spec.ts +166 -0
  36. package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.ts +36 -0
  37. package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.css +0 -0
  38. package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.html +8 -0
  39. package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.spec.ts +137 -0
  40. package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.ts +30 -0
  41. package/playground/showcase/src/app/components/combobox/combobox.css +0 -0
  42. package/playground/showcase/src/app/components/combobox/combobox.html +3 -0
  43. package/playground/showcase/src/app/components/combobox/combobox.spec.ts +391 -0
  44. package/playground/showcase/src/app/components/combobox/combobox.ts +59 -0
  45. package/playground/showcase/src/app/components/combobox/lib/models/combobox.model.ts +13 -0
  46. package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.spec.ts +530 -0
  47. package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.ts +191 -0
  48. package/playground/showcase/src/app/components/combobox/lib/types/combobox-position.type.ts +1 -0
  49. package/playground/showcase/src/app/components/combobox/lib/types/combobox-trigger-mode.type.ts +1 -0
  50. package/playground/showcase/src/app/core/services/seo.service.ts +100 -0
  51. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
  52. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
  53. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
  54. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
  55. package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.css +0 -0
  56. package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.html +383 -0
  57. package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.spec.ts +23 -0
  58. package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.ts +333 -0
  59. package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.examples.ts +226 -0
  60. package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
  61. package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +8 -0
  62. package/playground/showcase/src/app/pages/home/home.html +1 -1
@@ -0,0 +1,530 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { ComboboxService } from './combobox.service';
4
+ import { ComboboxInterface } from '../models/combobox.model';
5
+
6
+ describe('ComboboxService', () => {
7
+ let service: ComboboxService;
8
+
9
+ const mockData: ComboboxInterface[] = [
10
+ { value: 1, label: 'Apple', description: 'A red fruit' },
11
+ { value: 2, label: 'Banana', description: 'A yellow fruit' },
12
+ { value: 3, label: 'Orange', description: 'A citrus fruit' },
13
+ { value: 4, label: 'Grape', description: 'Purple berries' }
14
+ ];
15
+
16
+ const mockGroupedData: ComboboxInterface[] = [
17
+ { value: 1, label: 'JavaScript', group: 'Frontend' },
18
+ { value: 2, label: 'TypeScript', group: 'Frontend' },
19
+ { value: 3, label: 'Python', group: 'Backend' },
20
+ { value: 4, label: 'Java', group: 'Backend' }
21
+ ];
22
+
23
+ beforeEach(() => {
24
+ TestBed.configureTestingModule({
25
+ providers: [ComboboxService]
26
+ });
27
+ service = TestBed.inject(ComboboxService);
28
+ });
29
+
30
+ it('should be created', () => {
31
+ expect(service).toBeTruthy();
32
+ });
33
+
34
+ describe('Data Management', () => {
35
+ it('should initialize with empty data', () => {
36
+ expect(service.data()).toEqual([]);
37
+ });
38
+
39
+ it('should set data', () => {
40
+ service.setData(mockData);
41
+ expect(service.data()).toEqual(mockData);
42
+ });
43
+
44
+ it('should update data when setData is called multiple times', () => {
45
+ service.setData(mockData);
46
+ expect(service.data().length).toBe(4);
47
+
48
+ const newData = [{ value: 5, label: 'Mango' }];
49
+ service.setData(newData);
50
+ expect(service.data()).toEqual(newData);
51
+ expect(service.data().length).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('UI State', () => {
56
+ it('should initialize with closed state', () => {
57
+ expect(service.isOpen()).toBe(false);
58
+ });
59
+
60
+ it('should open combobox', () => {
61
+ service.open();
62
+ expect(service.isOpen()).toBe(true);
63
+ });
64
+
65
+ it('should close combobox', () => {
66
+ service.open();
67
+ service.close();
68
+ expect(service.isOpen()).toBe(false);
69
+ });
70
+
71
+ it('should toggle combobox state', () => {
72
+ expect(service.isOpen()).toBe(false);
73
+ service.toggle();
74
+ expect(service.isOpen()).toBe(true);
75
+ service.toggle();
76
+ expect(service.isOpen()).toBe(false);
77
+ });
78
+
79
+ it('should reset focusedIndex when opening', () => {
80
+ service.setData(mockData);
81
+ service.focusFirst();
82
+ expect(service.focusedIndex()).toBe(0);
83
+
84
+ service.open();
85
+ expect(service.focusedIndex()).toBe(-1);
86
+ });
87
+
88
+ it('should clear search query when closing', () => {
89
+ service.setSearchQuery('apple');
90
+ expect(service.searchQuery()).toBe('apple');
91
+
92
+ service.close();
93
+ expect(service.searchQuery()).toBe('');
94
+ });
95
+
96
+ it('should have default placeholder', () => {
97
+ expect(service.placeholder()).toBe('Search...');
98
+ });
99
+
100
+ it('should update placeholder', () => {
101
+ service.placeholder.set('Select an item...');
102
+ expect(service.placeholder()).toBe('Select an item...');
103
+ });
104
+
105
+ it('should initialize as not disabled', () => {
106
+ expect(service.disabled()).toBe(false);
107
+ });
108
+
109
+ it('should update disabled state', () => {
110
+ service.disabled.set(true);
111
+ expect(service.disabled()).toBe(true);
112
+ });
113
+
114
+ it('should have closeOnSelect enabled by default', () => {
115
+ expect(service.closeOnSelect()).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('Trigger Mode', () => {
120
+ it('should initialize with input trigger mode', () => {
121
+ expect(service.triggerMode()).toBe('input');
122
+ });
123
+
124
+ it('should set trigger mode to custom', () => {
125
+ service.setTriggerMode('custom');
126
+ expect(service.triggerMode()).toBe('custom');
127
+ });
128
+
129
+ it('should set trigger mode to input', () => {
130
+ service.setTriggerMode('custom');
131
+ service.setTriggerMode('input');
132
+ expect(service.triggerMode()).toBe('input');
133
+ });
134
+ });
135
+
136
+ describe('Search & Filter', () => {
137
+ beforeEach(() => {
138
+ service.setData(mockData);
139
+ });
140
+
141
+ it('should return all data when search query is empty', () => {
142
+ expect(service.filteredData()).toEqual(mockData);
143
+ });
144
+
145
+ it('should filter by label', () => {
146
+ service.setSearchQuery('apple');
147
+ const filtered = service.filteredData();
148
+ expect(filtered.length).toBe(1);
149
+ expect(filtered[0].label).toBe('Apple');
150
+ });
151
+
152
+ it('should filter case-insensitively', () => {
153
+ service.setSearchQuery('BANANA');
154
+ const filtered = service.filteredData();
155
+ expect(filtered.length).toBe(1);
156
+ expect(filtered[0].label).toBe('Banana');
157
+ });
158
+
159
+ it('should filter by description', () => {
160
+ service.setSearchQuery('citrus');
161
+ const filtered = service.filteredData();
162
+ expect(filtered.length).toBe(1);
163
+ expect(filtered[0].label).toBe('Orange');
164
+ });
165
+
166
+ it('should filter by value', () => {
167
+ service.setSearchQuery('2');
168
+ const filtered = service.filteredData();
169
+ expect(filtered.length).toBe(1);
170
+ expect(filtered[0].value).toBe(2);
171
+ });
172
+
173
+ it('should return empty array when no matches', () => {
174
+ service.setSearchQuery('xyz');
175
+ expect(service.filteredData()).toEqual([]);
176
+ });
177
+
178
+ it('should trim search query', () => {
179
+ service.setSearchQuery(' apple ');
180
+ expect(service.filteredData().length).toBe(1);
181
+ });
182
+
183
+ it('should reset focusedIndex when search query changes', () => {
184
+ service.focusFirst();
185
+ expect(service.focusedIndex()).toBe(0);
186
+
187
+ service.setSearchQuery('test');
188
+ expect(service.focusedIndex()).toBe(-1);
189
+ });
190
+ });
191
+
192
+ describe('Multi-Select', () => {
193
+ beforeEach(() => {
194
+ service.setData(mockData);
195
+ });
196
+
197
+ it('should initialize as single-select', () => {
198
+ expect(service.multiSelect()).toBe(false);
199
+ });
200
+
201
+ it('should enable multi-select', () => {
202
+ service.setMultiSelect(true);
203
+ expect(service.multiSelect()).toBe(true);
204
+ });
205
+
206
+ it('should select multiple items in multi-select mode', () => {
207
+ service.setMultiSelect(true);
208
+ service.selectItem(1);
209
+ service.selectItem(2);
210
+
211
+ expect(service.selectedValues()).toEqual([1, 2]);
212
+ expect(service.selectedItems().length).toBe(2);
213
+ });
214
+
215
+ it('should toggle selection in multi-select mode', () => {
216
+ service.setMultiSelect(true);
217
+ service.selectItem(1);
218
+ expect(service.selectedValues()).toContain(1);
219
+
220
+ service.selectItem(1); // Deselect
221
+ expect(service.selectedValues()).not.toContain(1);
222
+ });
223
+
224
+ it('should keep only first item when switching from multi to single select', () => {
225
+ service.setMultiSelect(true);
226
+ service.selectItem(1);
227
+ service.selectItem(2);
228
+ service.selectItem(3);
229
+
230
+ service.setMultiSelect(false);
231
+ expect(service.selectedValues()).toEqual([1]);
232
+ });
233
+
234
+ it('should replace selection in single-select mode', () => {
235
+ service.selectItem(1);
236
+ expect(service.selectedValues()).toEqual([1]);
237
+
238
+ service.selectItem(2);
239
+ expect(service.selectedValues()).toEqual([2]);
240
+ });
241
+
242
+ it('should close after selection in single-select mode when closeOnSelect is true', () => {
243
+ service.open();
244
+ service.closeOnSelect.set(true);
245
+ service.selectItem(1);
246
+
247
+ expect(service.isOpen()).toBe(false);
248
+ });
249
+
250
+ it('should not close after selection when closeOnSelect is false', () => {
251
+ service.open();
252
+ service.closeOnSelect.set(false);
253
+ service.selectItem(1);
254
+
255
+ expect(service.isOpen()).toBe(true);
256
+ });
257
+
258
+ it('should not close in multi-select mode', () => {
259
+ service.setMultiSelect(true);
260
+ service.open();
261
+ service.selectItem(1);
262
+
263
+ expect(service.isOpen()).toBe(true);
264
+ });
265
+
266
+ it('should deselect item', () => {
267
+ service.selectItem(1);
268
+ service.deselectItem(1);
269
+
270
+ expect(service.selectedValues()).toEqual([]);
271
+ });
272
+
273
+ it('should clear all selections', () => {
274
+ service.setMultiSelect(true);
275
+ service.selectItem(1);
276
+ service.selectItem(2);
277
+
278
+ service.clearSelection();
279
+ expect(service.selectedValues()).toEqual([]);
280
+ });
281
+
282
+ it('should check if item is selected', () => {
283
+ service.selectItem(1);
284
+
285
+ expect(service.isSelected(1)).toBe(true);
286
+ expect(service.isSelected(2)).toBe(false);
287
+ });
288
+
289
+ it('should return selected items as full objects', () => {
290
+ service.selectItem(1);
291
+ service.selectItem(3);
292
+
293
+ const selected = service.selectedItems();
294
+ expect(selected.length).toBe(1); // Single select mode
295
+ expect(selected[0]).toEqual(mockData[0]);
296
+ });
297
+ });
298
+
299
+ describe('Keyboard Navigation', () => {
300
+ beforeEach(() => {
301
+ service.setData(mockData);
302
+ });
303
+
304
+ it('should initialize with no focused index', () => {
305
+ expect(service.focusedIndex()).toBe(-1);
306
+ expect(service.focusedItem()).toBe(null);
307
+ });
308
+
309
+ it('should focus first item', () => {
310
+ service.focusFirst();
311
+ expect(service.focusedIndex()).toBe(0);
312
+ expect(service.focusedItem()).toEqual(mockData[0]);
313
+ });
314
+
315
+ it('should focus last item', () => {
316
+ service.focusLast();
317
+ expect(service.focusedIndex()).toBe(3);
318
+ expect(service.focusedItem()).toEqual(mockData[3]);
319
+ });
320
+
321
+ it('should focus next item', () => {
322
+ service.focusFirst();
323
+ service.focusNext();
324
+ expect(service.focusedIndex()).toBe(1);
325
+ expect(service.focusedItem()).toEqual(mockData[1]);
326
+ });
327
+
328
+ it('should not go beyond last item when focusing next', () => {
329
+ service.focusLast();
330
+ service.focusNext();
331
+ expect(service.focusedIndex()).toBe(3); // Stays at last
332
+ });
333
+
334
+ it('should focus previous item', () => {
335
+ service.focusLast();
336
+ service.focusPrevious();
337
+ expect(service.focusedIndex()).toBe(2);
338
+ });
339
+
340
+ it('should not go below first item when focusing previous', () => {
341
+ service.focusFirst();
342
+ service.focusPrevious();
343
+ expect(service.focusedIndex()).toBe(0); // Stays at first
344
+ });
345
+
346
+ it('should handle empty filtered data', () => {
347
+ service.setSearchQuery('nonexistent');
348
+ service.focusFirst();
349
+ expect(service.focusedIndex()).toBe(-1);
350
+ });
351
+
352
+ it('should select focused item', () => {
353
+ service.focusFirst();
354
+ service.selectFocusedItem();
355
+
356
+ expect(service.selectedValues()).toContain(1);
357
+ });
358
+
359
+ it('should not select when no item is focused', () => {
360
+ service.selectFocusedItem();
361
+ expect(service.selectedValues()).toEqual([]);
362
+ });
363
+
364
+ it('should update focusedItem when filtered data changes', () => {
365
+ service.focusFirst();
366
+ expect(service.focusedItem()?.label).toBe('Apple');
367
+
368
+ service.setSearchQuery('banana');
369
+ service.focusFirst();
370
+ expect(service.focusedItem()?.label).toBe('Banana');
371
+ });
372
+ });
373
+
374
+ describe('Virtual Scrolling', () => {
375
+ it('should initialize with virtual scroll disabled', () => {
376
+ expect(service.virtualScrollEnabled()).toBe(false);
377
+ });
378
+
379
+ it('should have default item height', () => {
380
+ expect(service.itemHeight()).toBe(48);
381
+ });
382
+
383
+ it('should have default visible items count', () => {
384
+ expect(service.visibleItemsCount()).toBe(8);
385
+ });
386
+
387
+ it('should enable virtual scroll', () => {
388
+ service.setVirtualScroll(true);
389
+ expect(service.virtualScrollEnabled()).toBe(true);
390
+ });
391
+
392
+ it('should set custom item height', () => {
393
+ service.setVirtualScroll(true, 60);
394
+ expect(service.itemHeight()).toBe(60);
395
+ });
396
+
397
+ it('should enable virtual scroll without changing item height', () => {
398
+ service.setVirtualScroll(true);
399
+ expect(service.itemHeight()).toBe(48); // Default
400
+ });
401
+ });
402
+
403
+ describe('Grouping', () => {
404
+ beforeEach(() => {
405
+ service.setData(mockGroupedData);
406
+ });
407
+
408
+ it('should initialize without grouping', () => {
409
+ expect(service.groupBy()).toBe(null);
410
+ expect(service.groupedData()).toBe(null);
411
+ });
412
+
413
+ it('should group data by property', () => {
414
+ service.setGroupBy('group');
415
+ const grouped = service.groupedData();
416
+
417
+ expect(grouped).not.toBe(null);
418
+ expect(grouped?.length).toBe(2);
419
+ });
420
+
421
+ it('should create correct groups', () => {
422
+ service.setGroupBy('group');
423
+ const grouped = service.groupedData();
424
+
425
+ const frontendGroup = grouped?.find(g => g.label === 'Frontend');
426
+ const backendGroup = grouped?.find(g => g.label === 'Backend');
427
+
428
+ expect(frontendGroup?.items.length).toBe(2);
429
+ expect(backendGroup?.items.length).toBe(2);
430
+ });
431
+
432
+ it('should place items without group property in Other', () => {
433
+ const mixedData = [
434
+ ...mockGroupedData,
435
+ { value: 5, label: 'NoGroup' }
436
+ ];
437
+ service.setData(mixedData);
438
+ service.setGroupBy('group');
439
+
440
+ const grouped = service.groupedData();
441
+ const otherGroup = grouped?.find(g => g.label === 'Other');
442
+
443
+ expect(otherGroup).toBeDefined();
444
+ expect(otherGroup?.items.length).toBe(1);
445
+ });
446
+
447
+ it('should update grouped data when filtered', () => {
448
+ service.setGroupBy('group');
449
+ service.setSearchQuery('java'); // Matches JavaScript and Java
450
+
451
+ const grouped = service.groupedData();
452
+ const frontendGroup = grouped?.find(g => g.label === 'Frontend');
453
+ const backendGroup = grouped?.find(g => g.label === 'Backend');
454
+
455
+ expect(frontendGroup?.items.length).toBe(1); // JavaScript
456
+ expect(backendGroup?.items.length).toBe(1); // Java
457
+ });
458
+
459
+ it('should return null when groupBy is cleared', () => {
460
+ service.setGroupBy('group');
461
+ expect(service.groupedData()).not.toBe(null);
462
+
463
+ service.setGroupBy(null);
464
+ expect(service.groupedData()).toBe(null);
465
+ });
466
+ });
467
+
468
+ describe('Integration Tests', () => {
469
+ beforeEach(() => {
470
+ service.setData(mockData);
471
+ });
472
+
473
+ it('should handle complete selection workflow', () => {
474
+ // Open combobox
475
+ service.open();
476
+ expect(service.isOpen()).toBe(true);
477
+
478
+ // Search for item
479
+ service.setSearchQuery('banana');
480
+ expect(service.filteredData().length).toBe(1);
481
+
482
+ // Navigate and select
483
+ service.focusFirst();
484
+ service.selectFocusedItem();
485
+
486
+ expect(service.selectedValues()).toContain(2);
487
+ expect(service.isOpen()).toBe(false); // Auto-closed
488
+ });
489
+
490
+ it('should handle multi-select workflow', () => {
491
+ service.setMultiSelect(true);
492
+ service.open();
493
+
494
+ service.selectItem(1);
495
+ service.selectItem(2);
496
+ service.selectItem(3);
497
+
498
+ expect(service.selectedValues().length).toBe(3);
499
+ expect(service.isOpen()).toBe(true); // Stays open
500
+ });
501
+
502
+ it('should maintain selection when filtering', () => {
503
+ service.selectItem(1);
504
+ service.setSearchQuery('banana');
505
+
506
+ expect(service.selectedValues()).toContain(1);
507
+ expect(service.selectedItems()[0].label).toBe('Apple');
508
+ });
509
+
510
+ it('should handle deselection workflow', () => {
511
+ service.selectItem(1);
512
+ expect(service.isSelected(1)).toBe(true);
513
+
514
+ service.deselectItem(1);
515
+ expect(service.isSelected(1)).toBe(false);
516
+ });
517
+
518
+ it('should clear all state when closing', () => {
519
+ service.setSearchQuery('test');
520
+ service.focusFirst();
521
+ service.open();
522
+
523
+ service.close();
524
+
525
+ expect(service.isOpen()).toBe(false);
526
+ expect(service.searchQuery()).toBe('');
527
+ expect(service.focusedIndex()).toBe(-1);
528
+ });
529
+ });
530
+ });
@@ -0,0 +1,191 @@
1
+ import { Injectable, signal, computed } from '@angular/core';
2
+
3
+ import { ComboboxInterface } from '../models/combobox.model';
4
+
5
+ @Injectable()
6
+ export class ComboboxService {
7
+ // ========== DATA ==========
8
+ private _data = signal<ComboboxInterface[]>([]);
9
+ data = this._data.asReadonly();
10
+
11
+ // ========== UI STATE ==========
12
+ isOpen = signal<boolean>(false);
13
+ position = signal<string | null>(null);
14
+ placeholder = signal<string>('Search...');
15
+ disabled = signal<boolean>(false);
16
+ closeOnSelect = signal<boolean>(true);
17
+
18
+ // ========== TRIGGER MODE ==========
19
+ triggerMode = signal<'input' | 'custom'>('input');
20
+
21
+ // ========== SEARCH & FILTER ==========
22
+ searchQuery = signal<string>('');
23
+ filteredData = computed(() => {
24
+ const query = this.searchQuery().toLowerCase().trim();
25
+ if (!query) return this._data();
26
+
27
+ return this._data().filter(item =>
28
+ item.label.toLowerCase().includes(query) ||
29
+ (item.description && item.description.toLowerCase().includes(query)) ||
30
+ item.value.toString().toLowerCase().includes(query)
31
+ );
32
+ });
33
+
34
+ // ========== MULTI-SELECT ==========
35
+ multiSelect = signal<boolean>(false);
36
+ selectedValues = signal<(string | number)[]>([]);
37
+ selectedItems = computed(() =>
38
+ this._data().filter(item => this.selectedValues().includes(item.value))
39
+ );
40
+
41
+ // ========== KEYBOARD NAVIGATION ==========
42
+ focusedIndex = signal<number>(-1);
43
+ focusedItem = computed(() => {
44
+ const filtered = this.filteredData();
45
+ const idx = this.focusedIndex();
46
+ return idx >= 0 && idx < filtered.length ? filtered[idx] : null;
47
+ });
48
+
49
+ // ========== VIRTUAL SCROLLING ==========
50
+ virtualScrollEnabled = signal<boolean>(false);
51
+ itemHeight = signal<number>(48);
52
+ visibleItemsCount = signal<number>(8);
53
+
54
+ // ========== GROUPING ==========
55
+ groupBy = signal<string | null>(null);
56
+ groupedData = computed(() => {
57
+ const key = this.groupBy();
58
+ if (!key) return null;
59
+
60
+ const groups = new Map<string, ComboboxInterface[]>();
61
+ this.filteredData().forEach(item => {
62
+ const groupKey = (item as any)[key] || 'Other';
63
+ if (!groups.has(groupKey)) {
64
+ groups.set(groupKey, []);
65
+ }
66
+ groups.get(groupKey)!.push(item);
67
+ });
68
+
69
+ return Array.from(groups.entries()).map(([label, items]) => ({
70
+ label,
71
+ items
72
+ }));
73
+ });
74
+
75
+ // ========== DATA METHODS ==========
76
+ setData(data: ComboboxInterface[]): void {
77
+ this._data.set(data);
78
+ }
79
+
80
+ // ========== OPEN/CLOSE METHODS ==========
81
+ open(): void {
82
+ this.isOpen.set(true);
83
+ this.focusedIndex.set(-1);
84
+ }
85
+
86
+ close(): void {
87
+ this.isOpen.set(false);
88
+ this.searchQuery.set('');
89
+ this.focusedIndex.set(-1);
90
+ }
91
+
92
+ toggle(): void {
93
+ this.isOpen() ? this.close() : this.open();
94
+ }
95
+
96
+ // ========== SEARCH METHODS ==========
97
+ setSearchQuery(query: string): void {
98
+ this.searchQuery.set(query);
99
+ this.focusedIndex.set(-1);
100
+ }
101
+
102
+ // ========== SELECTION METHODS ==========
103
+ selectItem(value: string | number): void {
104
+ if (this.multiSelect()) {
105
+ const current = this.selectedValues();
106
+ if (current.includes(value)) {
107
+ this.selectedValues.set(current.filter(v => v !== value));
108
+ } else {
109
+ this.selectedValues.set([...current, value]);
110
+ }
111
+ } else {
112
+ this.selectedValues.set([value]);
113
+ if (this.closeOnSelect()) {
114
+ this.close();
115
+ }
116
+ }
117
+ }
118
+
119
+ deselectItem(value: string | number): void {
120
+ this.selectedValues.update(current =>
121
+ current.filter(v => v !== value)
122
+ );
123
+ }
124
+
125
+ clearSelection(): void {
126
+ this.selectedValues.set([]);
127
+ }
128
+
129
+ isSelected(value: string | number): boolean {
130
+ return this.selectedValues().includes(value);
131
+ }
132
+
133
+ // ========== KEYBOARD NAVIGATION METHODS ==========
134
+ focusNext(): void {
135
+ const maxIndex = this.filteredData().length - 1;
136
+ if (maxIndex < 0) return;
137
+
138
+ this.focusedIndex.update(index =>
139
+ index < maxIndex ? index + 1 : index
140
+ );
141
+ }
142
+
143
+ focusPrevious(): void {
144
+ this.focusedIndex.update(index =>
145
+ index > 0 ? index - 1 : index
146
+ );
147
+ }
148
+
149
+ focusFirst(): void {
150
+ if (this.filteredData().length > 0) {
151
+ this.focusedIndex.set(0);
152
+ }
153
+ }
154
+
155
+ focusLast(): void {
156
+ const maxIndex = this.filteredData().length - 1;
157
+ if (maxIndex >= 0) {
158
+ this.focusedIndex.set(maxIndex);
159
+ }
160
+ }
161
+
162
+ selectFocusedItem(): void {
163
+ const focused = this.focusedItem();
164
+ if (focused) {
165
+ this.selectItem(focused.value);
166
+ }
167
+ }
168
+
169
+ // ========== CONFIGURATION METHODS ==========
170
+ setTriggerMode(mode: 'input' | 'custom'): void {
171
+ this.triggerMode.set(mode);
172
+ }
173
+
174
+ setMultiSelect(enabled: boolean): void {
175
+ this.multiSelect.set(enabled);
176
+ if (!enabled && this.selectedValues().length > 1) {
177
+ this.selectedValues.set([this.selectedValues()[0]]);
178
+ }
179
+ }
180
+
181
+ setVirtualScroll(enabled: boolean, itemHeight?: number): void {
182
+ this.virtualScrollEnabled.set(enabled);
183
+ if (itemHeight) {
184
+ this.itemHeight.set(itemHeight);
185
+ }
186
+ }
187
+
188
+ setGroupBy(property: string | null): void {
189
+ this.groupBy.set(property);
190
+ }
191
+ }