Skip to content

Commit 2d2186d

Browse files
authored
Merge branch 'ng-select:master' into master
2 parents 8fe8b11 + 897e5a5 commit 2d2186d

File tree

9 files changed

+208
-3
lines changed

9 files changed

+208
-3
lines changed

src/demo/app/examples/examples.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { SearchEditableExampleComponent } from './search-editable-example/search
4444
import { TemplatePlaceholderExampleComponent } from './template-placeholder-example/template-placeholder-example.component';
4545
import { FixedPlaceholderExampleComponent } from './fixed-placeholder-example/fixed-placeholder-example.component';
4646
import { TemplateClearExampleComponent } from './template-clear-example/template-clear-example.component';
47+
import { PopoverExampleComponent } from './popover-example/popover-example.component';
4748

4849
export interface Example {
4950
component: any;
@@ -235,4 +236,8 @@ export const EXAMPLE_COMPONENTS: { [key: string]: Example } = {
235236
component: GroupChildrenExampleComponent,
236237
title: 'Items with already grouped children array',
237238
},
239+
'popover-example': {
240+
component: PopoverExampleComponent,
241+
title: 'Popover top layer',
242+
},
238243
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<p>
2+
By default dropdown panel is rendered in the normal document flow, not in the browser's native
3+
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Popover_API" target="_blank">top layer</a>.
4+
</p>
5+
6+
<p>
7+
If ng-select is placed inside an element with <code>overflow: hidden</code>, or inside a component that is already rendered in a
8+
top-layer, the dropdown panel can be clipped or appear behind that container.
9+
</p>
10+
11+
<p>It can be fixed by setting <b>[popover]="true"</b>, which forces the dropdown panel to be displayed in a top-layer.</p>
12+
13+
<h6>Without popover - dropdown stays in normal flow</h6>
14+
<div class="clipped-box">
15+
<ng-select [items]="people()" bindLabel="name" [popover]="false" placeholder="Select a person" [(ngModel)]="selected1"> </ng-select>
16+
</div>
17+
18+
<br />
19+
20+
<h6>With popover - dropdown is rendered in a top-layer</h6>
21+
<div class="clipped-box">
22+
<ng-select [items]="people()" bindLabel="name" [popover]="true" placeholder="Select a person" [(ngModel)]="selected2"> </ng-select>
23+
</div>
24+
25+
<br />
26+
27+
<div class="alert alert-info">
28+
<strong>Use <code>[popover]="true"</code> when:</strong>
29+
<ul class="mb-0 mt-1">
30+
<li>
31+
The select is inside a container with <code>overflow: hidden</code>, <code>overflow: auto</code> or <code>overflow: clip</code>
32+
</li>
33+
<li>The select is rendered inside a modal, dialog or any other component already using the HTML top-layer</li>
34+
<li>The dropdown appears behind another element because of stacking context or z-index constraints</li>
35+
</ul>
36+
<br />
37+
<strong>Avoid it when:</strong>
38+
<ul class="mb-0 mt-1">
39+
<li>You are already using <code>appendTo</code>; both options solve similar positioning problems and should not be combined</li>
40+
<li>
41+
You need to support browsers without the
42+
<a href="https://caniuse.com/mdn-api_htmlelement_showpopover" target="_blank">Popover API</a> (Chrome 114+, Firefox 125+, Safari
43+
17+)
44+
</li>
45+
</ul>
46+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.clipped-box {
2+
padding: 10px;
3+
height: 80px;
4+
border: 1px solid #999;
5+
overflow: hidden;
6+
}
7+
8+
.alert-info {
9+
background-color: #d1ecf1;
10+
border-color: #bee5eb;
11+
color: #0c5460;
12+
padding: 0.75rem 1.25rem;
13+
border-radius: 4px;
14+
15+
ul {
16+
padding-left: 1.2rem;
17+
}
18+
19+
a {
20+
color: #0c5460;
21+
}
22+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Component, inject } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { NgSelectComponent } from '@ng-select/ng-select';
4+
import { DataService, Person } from '../data.service';
5+
import { toSignal } from '@angular/core/rxjs-interop';
6+
7+
@Component({
8+
selector: 'ng-popover-example',
9+
templateUrl: './popover-example.component.html',
10+
styleUrls: ['./popover-example.component.scss'],
11+
imports: [NgSelectComponent, FormsModule],
12+
})
13+
export class PopoverExampleComponent {
14+
private dataService = inject(DataService);
15+
16+
people = toSignal<Person[]>(this.dataService.getPeople());
17+
selected1: Person;
18+
selected2: Person;
19+
}

src/demo/app/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,10 @@ export const appRoutes: Routes = [
4040
component: RouteViewerComponent,
4141
data: { title: 'Append to element', examples: 'append-to' },
4242
},
43+
{
44+
path: 'popover',
45+
component: RouteViewerComponent,
46+
data: { title: 'Popover', examples: 'popover' },
47+
},
4348
{ path: 'grouping', component: RouteViewerComponent, data: { title: 'Grouping', examples: 'group' } },
4449
];

src/ng-select/lib/ng-dropdown-panel.component.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges {
7171
* Which DOM event to listen to for outside click detection
7272
*/
7373
readonly outsideClickEvent = input<'click' | 'mousedown'>('click');
74+
readonly popover = input(false, { transform: booleanAttribute });
7475
readonly update = output<any[]>();
7576
readonly scroll = output<{
7677
start: number;
@@ -141,6 +142,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges {
141142
this._appendDropdown();
142143
this._setupMousedownListener();
143144
this._handleWindowScroll();
145+
this._showPopoverIfNeeded();
144146
}
145147

146148
ngOnChanges(changes: SimpleChanges) {
@@ -196,7 +198,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges {
196198
this._updateDropdownClass('bottom');
197199
}
198200

199-
if (this.appendTo()) {
201+
if (this.appendTo() || this.popover()) {
200202
this._updateYPosition();
201203
}
202204

@@ -463,7 +465,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges {
463465
}
464466

465467
private _handleWindowScroll() {
466-
if (!this.appendTo()) {
468+
if (!this.appendTo() && !this.popover()) {
467469
return;
468470
}
469471
this._zone.runOutsideAngular(() => {
@@ -475,4 +477,17 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges {
475477
});
476478
});
477479
}
480+
481+
private _showPopoverIfNeeded() {
482+
if (!this.popover()) {
483+
return;
484+
}
485+
if (typeof this._dropdown.showPopover === 'function') {
486+
this._renderer.setAttribute(this._dropdown, 'popover', 'manual');
487+
this._dropdown.showPopover();
488+
this._parent = globalThis.document.body;
489+
this._updateXPosition();
490+
this._updateYPosition();
491+
}
492+
}
478493
}

src/ng-select/lib/ng-select.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
[class.ng-select-multiple]="multiple()"
107107
[class]="appendToValue ? (ngClass() ? ngClass() : classes) : null"
108108
[id]="dropdownId"
109-
[ariaLabelDropdown]="ariaLabelDropdown() ?? config.ariaLabelDropdown">
109+
[ariaLabelDropdown]="ariaLabelDropdown() ?? config.ariaLabelDropdown"
110+
[popover]="popover()">
110111
<ng-container>
111112
@for (item of viewPortItems; track trackByOption($index, item)) {
112113
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/mouse-events-have-key-events -->

src/ng-select/lib/ng-select.component.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,96 @@ describe('NgSelectComponent', () => {
19011901
const listboxElement = fixture.debugElement.nativeElement.querySelector('.ng-dropdown-panel-items[role="listbox"]');
19021902
expect(listboxElement.getAttribute('aria-label')).toBe('Template Aria Label');
19031903
}));
1904+
1905+
describe('Popover', () => {
1906+
it('should have popover input with default value false', fakeAsync(() => {
1907+
const fixture = createTestingModule(NgSelectTestComponent, `<ng-select [items]="cities" bindLabel="name"></ng-select>`);
1908+
1909+
const select = fixture.componentInstance.select();
1910+
expect(select.popover()).toBe(false);
1911+
}));
1912+
1913+
it('should pass popover false to dropdown panel by default', fakeAsync(() => {
1914+
const fixture = createTestingModule(NgSelectTestComponent, `<ng-select [items]="cities" bindLabel="name"></ng-select>`);
1915+
1916+
const select = fixture.componentInstance.select();
1917+
select.open();
1918+
tickAndDetectChanges(fixture);
1919+
1920+
const dropdownPanel = select.dropdownPanel();
1921+
expect(dropdownPanel.popover()).toBe(false);
1922+
1923+
const panelElement = fixture.debugElement.nativeElement.querySelector('.ng-dropdown-panel');
1924+
expect(panelElement?.matches(':popover-open')).toBe(false);
1925+
}));
1926+
1927+
it('should pass popover true to dropdown panel when set to true', fakeAsync(() => {
1928+
const fixture = createTestingModule(
1929+
NgSelectTestComponent,
1930+
`<ng-select [items]="cities" bindLabel="name" [popover]="true"></ng-select>`,
1931+
);
1932+
1933+
const select = fixture.componentInstance.select();
1934+
expect(select.popover()).toBe(true);
1935+
1936+
select.open();
1937+
tickAndDetectChanges(fixture);
1938+
1939+
const dropdownPanel = select.dropdownPanel();
1940+
expect(dropdownPanel.popover()).toBe(true);
1941+
1942+
const panelElement = fixture.debugElement.nativeElement.querySelector('.ng-dropdown-panel');
1943+
expect(panelElement?.matches(':popover-open')).toBe(true);
1944+
}));
1945+
1946+
it('should pass popover false to dropdown panel when explicitly set to false', fakeAsync(() => {
1947+
const fixture = createTestingModule(
1948+
NgSelectTestComponent,
1949+
`<ng-select [items]="cities" bindLabel="name" [popover]="false"></ng-select>`,
1950+
);
1951+
1952+
const select = fixture.componentInstance.select();
1953+
expect(select.popover()).toBe(false);
1954+
1955+
select.open();
1956+
tickAndDetectChanges(fixture);
1957+
1958+
const dropdownPanel = select.dropdownPanel();
1959+
expect(dropdownPanel.popover()).toBe(false);
1960+
1961+
const panelElement = fixture.debugElement.nativeElement.querySelector('.ng-dropdown-panel');
1962+
expect(panelElement?.matches(':popover-open')).toBe(false);
1963+
}));
1964+
1965+
it('should toggle popover value dynamically', fakeAsync(() => {
1966+
const fixture = createTestingModule(
1967+
NgSelectTestComponent,
1968+
`<ng-select [items]="cities" bindLabel="name" [popover]="popoverEnabled"></ng-select>`,
1969+
);
1970+
1971+
const component = fixture.componentInstance as any;
1972+
component.popoverEnabled = false;
1973+
fixture.detectChanges();
1974+
1975+
let select = fixture.componentInstance.select();
1976+
expect(select.popover()).toBe(false);
1977+
1978+
component.popoverEnabled = true;
1979+
fixture.detectChanges();
1980+
1981+
select = fixture.componentInstance.select();
1982+
expect(select.popover()).toBe(true);
1983+
1984+
select.open();
1985+
tickAndDetectChanges(fixture);
1986+
1987+
const dropdownPanel = select.dropdownPanel();
1988+
expect(dropdownPanel.popover()).toBe(true);
1989+
1990+
const panelElement = fixture.debugElement.nativeElement.querySelector('.ng-dropdown-panel');
1991+
expect(panelElement?.matches(':popover-open')).toBe(true);
1992+
}));
1993+
});
19041994
});
19051995

19061996
describe('Keyboard events', () => {
@@ -5518,6 +5608,7 @@ class NgSelectTestComponent {
55185608
typeahead = undefined;
55195609
preventToggleOnRightClick = false;
55205610
searchWhileComposing = true;
5611+
popoverEnabled = false;
55215612

55225613
citiesLoading = false;
55235614
selectedCityId: number;

src/ng-select/lib/ng-select.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export class NgSelectComponent implements OnChanges, OnInit, AfterViewInit, Cont
161161
},
162162
});
163163
readonly keyDownFn = input<(_: KeyboardEvent) => boolean>((_: KeyboardEvent) => true);
164+
readonly popover = input(false, { transform: booleanAttribute });
164165

165166
// models
166167
readonly bindLabel = model<string>(undefined);

0 commit comments

Comments
 (0)