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,228 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { ComponentRef } from '@angular/core';
3
+
4
+ import { ComboboxContent } from './combobox-content';
5
+ import { ComboboxService } from '../lib/service/combobox.service';
6
+ import { ComboboxPosition } from '../lib/types/combobox-position.type';
7
+
8
+ describe('ComboboxContent', () => {
9
+ let component: ComboboxContent;
10
+ let fixture: ComponentFixture<ComboboxContent>;
11
+ let componentRef: ComponentRef<ComboboxContent>;
12
+ let service: ComboboxService;
13
+
14
+ beforeEach(async () => {
15
+ await TestBed.configureTestingModule({
16
+ imports: [ComboboxContent],
17
+ providers: [ComboboxService]
18
+ })
19
+ .compileComponents();
20
+
21
+ fixture = TestBed.createComponent(ComboboxContent);
22
+ component = fixture.componentInstance;
23
+ componentRef = fixture.componentRef;
24
+ service = TestBed.inject(ComboboxService);
25
+ fixture.detectChanges();
26
+ });
27
+
28
+ it('should create', () => {
29
+ expect(component).toBeTruthy();
30
+ });
31
+
32
+ it('should inject ComboboxService', () => {
33
+ expect(service).toBeTruthy();
34
+ });
35
+
36
+ describe('Input: position', () => {
37
+ it('should default to bottom position', () => {
38
+ expect(component.position()).toBe('bottom');
39
+ });
40
+
41
+ it('should accept top position', () => {
42
+ componentRef.setInput('position', 'top');
43
+ fixture.detectChanges();
44
+
45
+ expect(component.position()).toBe('top');
46
+ });
47
+
48
+ it('should accept all valid positions', () => {
49
+ const positions: ComboboxPosition[] = ['top', 'bottom', 'left', 'right'];
50
+
51
+ positions.forEach(pos => {
52
+ componentRef.setInput('position', pos);
53
+ fixture.detectChanges();
54
+ expect(component.position()).toBe(pos);
55
+ });
56
+ });
57
+ });
58
+
59
+ describe('Position Calculation', () => {
60
+ it('should initialize with bottom calculated position', () => {
61
+ expect(component.calculatedPosition()).toBe('bottom');
62
+ });
63
+
64
+ it('should generate correct position classes for bottom', () => {
65
+ component.calculatedPosition.set('bottom');
66
+ const classes = component.positionClasses();
67
+
68
+ expect(classes).toContain('top-full');
69
+ expect(classes).toContain('mt-2');
70
+ expect(classes).toContain('left-0');
71
+ });
72
+
73
+ it('should generate correct position classes for top', () => {
74
+ component.calculatedPosition.set('top');
75
+ const classes = component.positionClasses();
76
+
77
+ expect(classes).toContain('bottom-full');
78
+ expect(classes).toContain('mb-2');
79
+ expect(classes).toContain('left-0');
80
+ });
81
+
82
+ it('should generate correct position classes for right', () => {
83
+ component.calculatedPosition.set('right');
84
+ const classes = component.positionClasses();
85
+
86
+ expect(classes).toContain('left-full');
87
+ expect(classes).toContain('ml-2');
88
+ expect(classes).toContain('top-0');
89
+ });
90
+
91
+ it('should generate correct position classes for left', () => {
92
+ component.calculatedPosition.set('left');
93
+ const classes = component.positionClasses();
94
+
95
+ expect(classes).toContain('right-full');
96
+ expect(classes).toContain('mr-2');
97
+ expect(classes).toContain('top-0');
98
+ });
99
+
100
+ it('should include scroll classes', () => {
101
+ const classes = component.positionClasses();
102
+
103
+ expect(classes).toContain('max-h-96');
104
+ expect(classes).toContain('overflow-y-auto');
105
+ });
106
+ });
107
+
108
+ describe('Global Index Calculation', () => {
109
+ it('should return item index when no grouping', () => {
110
+ const index = component.getGlobalIndex(0, 5);
111
+ expect(index).toBe(5);
112
+ });
113
+
114
+ it('should calculate global index for grouped items', () => {
115
+ service.setData([
116
+ { value: 1, label: 'JS', group: 'Frontend' },
117
+ { value: 2, label: 'TS', group: 'Frontend' },
118
+ { value: 3, label: 'Python', group: 'Backend' },
119
+ { value: 4, label: 'Java', group: 'Backend' }
120
+ ]);
121
+ service.setGroupBy('group');
122
+
123
+ // First item in second group (Backend)
124
+ const index = component.getGlobalIndex(1, 0);
125
+ expect(index).toBe(2); // After 2 Frontend items
126
+ });
127
+
128
+ it('should handle multiple groups correctly', () => {
129
+ service.setData([
130
+ { value: 1, label: 'Item1', group: 'A' },
131
+ { value: 2, label: 'Item2', group: 'A' },
132
+ { value: 3, label: 'Item3', group: 'B' },
133
+ { value: 4, label: 'Item4', group: 'C' },
134
+ { value: 5, label: 'Item5', group: 'C' }
135
+ ]);
136
+ service.setGroupBy('group');
137
+
138
+ expect(component.getGlobalIndex(0, 0)).toBe(0); // First item in Group A
139
+ expect(component.getGlobalIndex(1, 0)).toBe(2); // First item in Group B
140
+ expect(component.getGlobalIndex(2, 1)).toBe(4); // Second item in Group C
141
+ });
142
+ });
143
+
144
+ describe('Window Resize', () => {
145
+ it('should recalculate position on resize when open', () => {
146
+ spyOn<any>(component, 'calculateBestPosition').and.returnValue('top');
147
+ service.open();
148
+
149
+ component.onResize();
150
+
151
+ expect(component['calculateBestPosition']).toHaveBeenCalled();
152
+ expect(component.calculatedPosition()).toBe('top');
153
+ });
154
+
155
+ it('should not recalculate position on resize when closed', () => {
156
+ spyOn<any>(component, 'calculateBestPosition');
157
+
158
+ component.onResize();
159
+
160
+ expect(component['calculateBestPosition']).not.toHaveBeenCalled();
161
+ });
162
+ });
163
+
164
+ describe('Escape Key', () => {
165
+ it('should close combobox on escape when open', () => {
166
+ service.open();
167
+ expect(service.isOpen()).toBe(true);
168
+
169
+ component.onEscape();
170
+
171
+ expect(service.isOpen()).toBe(false);
172
+ });
173
+
174
+ it('should not error on escape when closed', () => {
175
+ expect(service.isOpen()).toBe(false);
176
+
177
+ expect(() => component.onEscape()).not.toThrow();
178
+ });
179
+ });
180
+
181
+ describe('Click Outside', () => {
182
+ it('should close when clicking outside', () => {
183
+ service.open();
184
+
185
+ const event = new MouseEvent('mousedown');
186
+ Object.defineProperty(event, 'target', { value: document.body, enumerable: true });
187
+
188
+ component.onDocumentClick(event);
189
+
190
+ expect(service.isOpen()).toBe(false);
191
+ });
192
+
193
+ it('should not close when clicking inside content', () => {
194
+ service.open();
195
+
196
+ const event = new MouseEvent('mousedown');
197
+ Object.defineProperty(event, 'target', { value: fixture.nativeElement, enumerable: true });
198
+
199
+ component.onDocumentClick(event);
200
+
201
+ expect(service.isOpen()).toBe(true);
202
+ });
203
+
204
+ it('should not process click when combobox is closed', () => {
205
+ const closeSpy = spyOn(service, 'close');
206
+
207
+ const event = new MouseEvent('mousedown');
208
+ Object.defineProperty(event, 'target', { value: document.body, enumerable: true });
209
+
210
+ component.onDocumentClick(event);
211
+
212
+ expect(closeSpy).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
216
+ describe('Position Recalculation on Open', () => {
217
+ it('should recalculate position when opening', (done) => {
218
+ spyOn<any>(component, 'calculateBestPosition').and.returnValue('top');
219
+
220
+ service.open();
221
+
222
+ setTimeout(() => {
223
+ expect(component['calculateBestPosition']).toHaveBeenCalled();
224
+ done();
225
+ }, 10);
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,217 @@
1
+ import { Component, computed, effect, ElementRef, HostListener, inject, input, signal } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+
4
+ import { ComboboxService } from '../lib/service/combobox.service';
5
+ import { ComboboxPosition } from '../lib/types/combobox-position.type';
6
+ import { ComboboxItem } from '../combobox-item/combobox-item';
7
+ import { ComboboxEmpty } from '../combobox-empty/combobox-empty';
8
+ import { ComboboxGroup } from '../combobox-group/combobox-group';
9
+ import { ComboboxSearch } from '../combobox-search/combobox-search';
10
+
11
+ @Component({
12
+ selector: 'wally-combobox-content',
13
+ imports: [
14
+ CommonModule,
15
+ ComboboxItem,
16
+ ComboboxEmpty,
17
+ ComboboxGroup,
18
+ ComboboxSearch
19
+ ],
20
+ templateUrl: './combobox-content.html',
21
+ styleUrl: './combobox-content.css'
22
+ })
23
+ export class ComboboxContent {
24
+ comboboxService = inject(ComboboxService);
25
+ private elementRef = inject(ElementRef);
26
+
27
+ position = input<ComboboxPosition>('bottom');
28
+
29
+ calculatedPosition = signal<ComboboxPosition>('bottom');
30
+
31
+ positionClasses = computed(() => {
32
+ const position = this.calculatedPosition();
33
+ const scrollClasses = 'max-h-96 overflow-y-auto';
34
+
35
+ const positionMap = {
36
+ bottom: 'top-full mt-2 left-0',
37
+ top: 'bottom-full mb-2 left-0',
38
+ right: 'left-full ml-2 top-0',
39
+ left: 'right-full mr-2 top-0'
40
+ };
41
+
42
+ return `${scrollClasses} ${positionMap[position]}`;
43
+ });
44
+
45
+ constructor() {
46
+ // Recalcular ao abrir
47
+ effect(() => {
48
+ if (this.comboboxService.isOpen()) {
49
+ setTimeout(() => {
50
+ const bestPosition = this.calculateBestPosition();
51
+ this.calculatedPosition.set(bestPosition);
52
+ }, 0);
53
+ }
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Recalculates position on window resize to maintain optimal placement
59
+ */
60
+ @HostListener('window:resize')
61
+ onResize(): void {
62
+ if (this.comboboxService.isOpen()) {
63
+ const bestPosition = this.calculateBestPosition();
64
+ this.calculatedPosition.set(bestPosition);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Close on Escape key
70
+ */
71
+ @HostListener('document:keydown.escape')
72
+ onEscape(): void {
73
+ if (this.comboboxService.isOpen()) {
74
+ this.comboboxService.close();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Close when clicking outside
80
+ */
81
+ @HostListener('document:mousedown', ['$event'])
82
+ onDocumentClick(event: MouseEvent): void {
83
+ if (!this.comboboxService.isOpen()) return;
84
+
85
+ const contentElement = this.elementRef.nativeElement;
86
+ const triggerElement = contentElement.parentElement;
87
+
88
+ const clickedInside = contentElement.contains(event.target as Node);
89
+ const clickedTrigger = triggerElement?.contains(event.target as Node);
90
+
91
+ if (!clickedInside && !clickedTrigger) {
92
+ this.comboboxService.close();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Measures available space around the trigger element in all directions.
98
+ * Pattern from dropdown-menu-content.ts
99
+ */
100
+ private measureAvailableSpace(): {
101
+ triggerRect: DOMRect;
102
+ spaceAbove: number;
103
+ spaceBelow: number;
104
+ spaceLeft: number;
105
+ spaceRight: number;
106
+ } | null {
107
+ const triggerElement = this.elementRef.nativeElement.parentElement;
108
+
109
+ if (!triggerElement) return null;
110
+
111
+ const triggerRect = triggerElement.getBoundingClientRect();
112
+ const viewportWidth = window.innerWidth;
113
+ const viewportHeight = window.innerHeight;
114
+
115
+ return {
116
+ triggerRect,
117
+ spaceAbove: triggerRect.top,
118
+ spaceBelow: viewportHeight - triggerRect.bottom,
119
+ spaceLeft: triggerRect.left,
120
+ spaceRight: viewportWidth - triggerRect.right
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Calculates the best position for the combobox based on available viewport space.
126
+ * Falls back to alternative positions if preferred position doesn't fit.
127
+ * If no position has enough space, chooses the direction with the most available space.
128
+ */
129
+ private calculateBestPosition(): ComboboxPosition {
130
+ const space = this.measureAvailableSpace();
131
+
132
+ if (!space) return this.position();
133
+
134
+ const menuDimensions = this.getMenuDimensions();
135
+ const MENU_MIN_HEIGHT = menuDimensions.height + 20;
136
+ const MENU_MIN_WIDTH = menuDimensions.width + 20;
137
+
138
+ const preferred = this.position();
139
+
140
+ // Try preferred position first, then fallbacks
141
+ switch (preferred) {
142
+ case 'bottom':
143
+ if (space.spaceBelow >= MENU_MIN_HEIGHT) return 'bottom';
144
+ if (space.spaceAbove >= MENU_MIN_HEIGHT) return 'top';
145
+ if (space.spaceRight >= MENU_MIN_WIDTH) return 'right';
146
+ if (space.spaceLeft >= MENU_MIN_WIDTH) return 'left';
147
+ break;
148
+
149
+ case 'top':
150
+ if (space.spaceAbove >= MENU_MIN_HEIGHT) return 'top';
151
+ if (space.spaceBelow >= MENU_MIN_HEIGHT) return 'bottom';
152
+ if (space.spaceRight >= MENU_MIN_WIDTH) return 'right';
153
+ if (space.spaceLeft >= MENU_MIN_WIDTH) return 'left';
154
+ break;
155
+
156
+ case 'right':
157
+ if (space.spaceRight >= MENU_MIN_WIDTH) return 'right';
158
+ if (space.spaceLeft >= MENU_MIN_WIDTH) return 'left';
159
+ if (space.spaceBelow >= MENU_MIN_HEIGHT) return 'bottom';
160
+ if (space.spaceAbove >= MENU_MIN_HEIGHT) return 'top';
161
+ break;
162
+
163
+ case 'left':
164
+ if (space.spaceLeft >= MENU_MIN_WIDTH) return 'left';
165
+ if (space.spaceRight >= MENU_MIN_WIDTH) return 'right';
166
+ if (space.spaceBelow >= MENU_MIN_HEIGHT) return 'bottom';
167
+ if (space.spaceAbove >= MENU_MIN_HEIGHT) return 'top';
168
+ break;
169
+ }
170
+
171
+ // If no position has enough space, choose the one with the most space
172
+ const maxVerticalSpace = Math.max(space.spaceAbove, space.spaceBelow);
173
+ const maxHorizontalSpace = Math.max(space.spaceLeft, space.spaceRight);
174
+
175
+ // Prefer vertical positions (bottom/top) over horizontal
176
+ if (maxVerticalSpace >= maxHorizontalSpace) {
177
+ return space.spaceBelow >= space.spaceAbove ? 'bottom' : 'top';
178
+ } else {
179
+ return space.spaceRight >= space.spaceLeft ? 'right' : 'left';
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Gets actual rendered dimensions of the dropdown menu
185
+ */
186
+ private getMenuDimensions(): { height: number; width: number } {
187
+ const menuElement = this.elementRef.nativeElement.querySelector('[role="listbox"]');
188
+
189
+ if (!menuElement) {
190
+ return {
191
+ height: 384, // max-h-96 = 384px
192
+ width: 288 // min-w-72 = 288px
193
+ };
194
+ }
195
+
196
+ const rect = menuElement.getBoundingClientRect();
197
+
198
+ return {
199
+ height: rect.height || 384,
200
+ width: rect.width || 288
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Calculates global index for grouped items
206
+ */
207
+ getGlobalIndex(groupIndex: number, itemIndex: number): number {
208
+ const groups = this.comboboxService.groupedData();
209
+ if (!groups) return itemIndex;
210
+
211
+ let globalIndex = 0;
212
+ for (let i = 0; i < groupIndex; i++) {
213
+ globalIndex += groups[i].items.length;
214
+ }
215
+ return globalIndex + itemIndex;
216
+ }
217
+ }
@@ -0,0 +1,3 @@
1
+ <div class="py-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
2
+ {{ message() }}
3
+ </div>
@@ -0,0 +1,56 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { ComponentRef } from '@angular/core';
3
+
4
+ import { ComboboxEmpty } from './combobox-empty';
5
+
6
+ describe('ComboboxEmpty', () => {
7
+ let component: ComboboxEmpty;
8
+ let fixture: ComponentFixture<ComboboxEmpty>;
9
+ let componentRef: ComponentRef<ComboboxEmpty>;
10
+
11
+ beforeEach(async () => {
12
+ await TestBed.configureTestingModule({
13
+ imports: [ComboboxEmpty]
14
+ })
15
+ .compileComponents();
16
+
17
+ fixture = TestBed.createComponent(ComboboxEmpty);
18
+ component = fixture.componentInstance;
19
+ componentRef = fixture.componentRef;
20
+ fixture.detectChanges();
21
+ });
22
+
23
+ it('should create', () => {
24
+ expect(component).toBeTruthy();
25
+ });
26
+
27
+ describe('Input: message', () => {
28
+ it('should have default message', () => {
29
+ expect(component.message()).toBe('No results found');
30
+ });
31
+
32
+ it('should accept custom message', () => {
33
+ componentRef.setInput('message', 'No items to display');
34
+ fixture.detectChanges();
35
+
36
+ expect(component.message()).toBe('No items to display');
37
+ });
38
+
39
+ it('should update when changed', () => {
40
+ componentRef.setInput('message', 'Empty list');
41
+ fixture.detectChanges();
42
+ expect(component.message()).toBe('Empty list');
43
+
44
+ componentRef.setInput('message', 'Nothing here');
45
+ fixture.detectChanges();
46
+ expect(component.message()).toBe('Nothing here');
47
+ });
48
+
49
+ it('should accept empty string', () => {
50
+ componentRef.setInput('message', '');
51
+ fixture.detectChanges();
52
+
53
+ expect(component.message()).toBe('');
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,11 @@
1
+ import { Component, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'wally-combobox-empty',
5
+ imports: [],
6
+ templateUrl: './combobox-empty.html',
7
+ styleUrl: './combobox-empty.css'
8
+ })
9
+ export class ComboboxEmpty {
10
+ message = input<string>('No results found');
11
+ }
@@ -0,0 +1,11 @@
1
+ <div class="pt-2 first:pt-0">
2
+ <!-- Group header -->
3
+ <div class="px-3 py-2 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
4
+ {{ label() }}
5
+ </div>
6
+
7
+ <!-- Group items -->
8
+ <div class="space-y-0.5">
9
+ <ng-content></ng-content>
10
+ </div>
11
+ </div>
@@ -0,0 +1,57 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { ComponentRef } from '@angular/core';
3
+
4
+ import { ComboboxGroup } from './combobox-group';
5
+
6
+ describe('ComboboxGroup', () => {
7
+ let component: ComboboxGroup;
8
+ let fixture: ComponentFixture<ComboboxGroup>;
9
+ let componentRef: ComponentRef<ComboboxGroup>;
10
+
11
+ beforeEach(async () => {
12
+ await TestBed.configureTestingModule({
13
+ imports: [ComboboxGroup]
14
+ })
15
+ .compileComponents();
16
+
17
+ fixture = TestBed.createComponent(ComboboxGroup);
18
+ component = fixture.componentInstance;
19
+ componentRef = fixture.componentRef;
20
+ });
21
+
22
+ it('should create', () => {
23
+ componentRef.setInput('label', 'Test Group');
24
+ fixture.detectChanges();
25
+
26
+ expect(component).toBeTruthy();
27
+ });
28
+
29
+ describe('Input: label', () => {
30
+ it('should be required', () => {
31
+ componentRef.setInput('label', 'Frontend');
32
+ fixture.detectChanges();
33
+
34
+ expect(component.label()).toBe('Frontend');
35
+ });
36
+
37
+ it('should update when changed', () => {
38
+ componentRef.setInput('label', 'Backend');
39
+ fixture.detectChanges();
40
+ expect(component.label()).toBe('Backend');
41
+
42
+ componentRef.setInput('label', 'DevOps');
43
+ fixture.detectChanges();
44
+ expect(component.label()).toBe('DevOps');
45
+ });
46
+
47
+ it('should accept any string value', () => {
48
+ const testLabels = ['Group 1', 'Category A', 'Section X', ''];
49
+
50
+ testLabels.forEach(label => {
51
+ componentRef.setInput('label', label);
52
+ fixture.detectChanges();
53
+ expect(component.label()).toBe(label);
54
+ });
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,11 @@
1
+ import { Component, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'wally-combobox-group',
5
+ imports: [],
6
+ templateUrl: './combobox-group.html',
7
+ styleUrl: './combobox-group.css'
8
+ })
9
+ export class ComboboxGroup {
10
+ label = input.required<string>();
11
+ }
@@ -0,0 +1,71 @@
1
+ <div
2
+ class="flex items-center gap-2 w-full px-3 py-2 bg-white dark:bg-[#1b1b1b] border border-neutral-300 dark:border-neutral-700 rounded-lg focus-within:ring-2 focus-within:ring-blue-500 transition-all duration-200 min-h-[42px]"
3
+ [class.opacity-50]="comboboxService.disabled()"
4
+ >
5
+ <!-- Left side: chips + input (can wrap) -->
6
+ <div class="flex flex-wrap gap-1.5 items-center flex-1 min-w-0">
7
+ <!-- Selected chips (multi-select mode) -->
8
+ @if (comboboxService.multiSelect()) {
9
+ @for (item of comboboxService.selectedItems(); track item.value) {
10
+ <div class="flex items-center gap-1 px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-md text-sm">
11
+ <span class="text-[#0a0a0a] dark:text-white">{{ item.label }}</span>
12
+ <button
13
+ type="button"
14
+ class="hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-full p-0.5 transition-colors"
15
+ (click)="removeChip(item.value, $event)"
16
+ [attr.aria-label]="'Remove ' + item.label"
17
+ >
18
+ <svg class="w-3 h-3 text-neutral-600 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
19
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
20
+ </svg>
21
+ </button>
22
+ </div>
23
+ }
24
+ }
25
+
26
+ <!-- Input field -->
27
+ <input
28
+ #inputElement
29
+ type="text"
30
+ class="flex-1 min-w-[120px] outline-none bg-transparent text-[#0a0a0a] dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 text-sm"
31
+ [placeholder]="comboboxService.selectedItems().length === 0 ? comboboxService.placeholder() : ''"
32
+ [value]="comboboxService.searchQuery()"
33
+ [disabled]="comboboxService.disabled()"
34
+ (focus)="onInputFocus()"
35
+ (click)="onInputClick()"
36
+ (input)="onInput($event)"
37
+ role="combobox"
38
+ [attr.aria-expanded]="comboboxService.isOpen()"
39
+ [attr.aria-controls]="'combobox-listbox'"
40
+ autocomplete="off"
41
+ />
42
+ </div>
43
+
44
+ <!-- Right side: icons (always fixed at the end) -->
45
+ <div class="flex items-center gap-1 flex-shrink-0">
46
+ <!-- Clear button -->
47
+ @if (comboboxService.selectedItems().length > 0) {
48
+ <button
49
+ type="button"
50
+ class="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-full transition-colors"
51
+ (click)="clearAll(inputElement)"
52
+ aria-label="Clear all"
53
+ >
54
+ <svg class="w-4 h-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
56
+ </svg>
57
+ </button>
58
+ }
59
+
60
+ <!-- Dropdown indicator -->
61
+ <svg
62
+ class="w-4 h-4 text-neutral-500 transition-transform duration-200"
63
+ [class.rotate-180]="comboboxService.isOpen()"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ viewBox="0 0 24 24"
67
+ >
68
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
69
+ </svg>
70
+ </div>
71
+ </div>