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.
- package/dist/cli.js +0 -0
- package/package.json +1 -1
- package/playground/showcase/public/sitemap.xml +15 -0
- package/playground/showcase/src/app/app.routes.server.ts +8 -0
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.html +41 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.spec.ts +228 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.ts +217 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.html +3 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.spec.ts +56 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.ts +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.html +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.spec.ts +57 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.ts +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.html +71 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.spec.ts +468 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.ts +90 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.html +58 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.spec.ts +173 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.ts +37 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.spec.ts +166 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.ts +36 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.html +8 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.spec.ts +137 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.ts +30 -0
- package/playground/showcase/src/app/components/combobox/combobox.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox.html +3 -0
- package/playground/showcase/src/app/components/combobox/combobox.spec.ts +391 -0
- package/playground/showcase/src/app/components/combobox/combobox.ts +59 -0
- package/playground/showcase/src/app/components/combobox/lib/models/combobox.model.ts +13 -0
- package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.spec.ts +530 -0
- package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.ts +191 -0
- package/playground/showcase/src/app/components/combobox/lib/types/combobox-position.type.ts +1 -0
- package/playground/showcase/src/app/components/combobox/lib/types/combobox-trigger-mode.type.ts +1 -0
- package/playground/showcase/src/app/core/services/seo.service.ts +100 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.css +0 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.html +383 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.spec.ts +23 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.ts +333 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.examples.ts +226 -0
- package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
- package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +8 -0
- package/playground/showcase/src/app/pages/home/home.html +1 -1
package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.spec.ts
ADDED
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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>
|