wally-ui 1.15.0 → 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/src/app/app.routes.server.ts +4 -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/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.routes.ts +4 -0
package/dist/cli.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -41,6 +41,10 @@ export const serverRoutes: ServerRoute[] = [
|
|
|
41
41
|
path: 'documentation/components/audio-waveform',
|
|
42
42
|
renderMode: RenderMode.Prerender,
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
path: 'documentation/components/combobox',
|
|
46
|
+
renderMode: RenderMode.Prerender,
|
|
47
|
+
},
|
|
44
48
|
{
|
|
45
49
|
path: 'documentation/chat-sdk',
|
|
46
50
|
renderMode: RenderMode.Prerender,
|
|
File without changes
|
package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@if (comboboxService.isOpen()) {
|
|
2
|
+
<div
|
|
3
|
+
[class]="'absolute bg-white dark:bg-[#1b1b1b] dark:border-neutral-700 rounded-xl shadow-2xl border border-neutral-300 w-full z-50 transition-all duration-200 ease-out ' + positionClasses()"
|
|
4
|
+
id="combobox-listbox"
|
|
5
|
+
role="listbox"
|
|
6
|
+
[attr.aria-multiselectable]="comboboxService.multiSelect()"
|
|
7
|
+
>
|
|
8
|
+
<!-- Search (só no custom trigger mode) -->
|
|
9
|
+
@if (comboboxService.triggerMode() === 'custom') {
|
|
10
|
+
<wally-combobox-search></wally-combobox-search>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
<div class="p-1">
|
|
14
|
+
@if (comboboxService.filteredData().length === 0) {
|
|
15
|
+
<wally-combobox-empty></wally-combobox-empty>
|
|
16
|
+
} @else if (comboboxService.groupedData(); as groups) {
|
|
17
|
+
<!-- Itens agrupados -->
|
|
18
|
+
@for (group of groups; track group.label; let groupIdx = $index) {
|
|
19
|
+
<wally-combobox-group [label]="group.label">
|
|
20
|
+
@for (item of group.items; track item.value; let itemIdx = $index) {
|
|
21
|
+
<wally-combobox-item
|
|
22
|
+
[item]="item"
|
|
23
|
+
[focused]="comboboxService.focusedIndex() === getGlobalIndex(groupIdx, itemIdx)"
|
|
24
|
+
[selected]="comboboxService.isSelected(item.value)"
|
|
25
|
+
></wally-combobox-item>
|
|
26
|
+
}
|
|
27
|
+
</wally-combobox-group>
|
|
28
|
+
}
|
|
29
|
+
} @else {
|
|
30
|
+
<!-- Itens normais -->
|
|
31
|
+
@for (item of comboboxService.filteredData(); track item.value; let idx = $index) {
|
|
32
|
+
<wally-combobox-item
|
|
33
|
+
[item]="item"
|
|
34
|
+
[focused]="comboboxService.focusedIndex() === idx"
|
|
35
|
+
[selected]="comboboxService.isSelected(item.value)"
|
|
36
|
+
></wally-combobox-item>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
}
|
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
|