Skip to content

Commit

Permalink
fix(dropdown): fix arrow navigation inside shadow dom (#4548)
Browse files Browse the repository at this point in the history
Fixes #4540
  • Loading branch information
maxokorokov committed Aug 7, 2023
1 parent 9cb3486 commit 995d22d
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 1 deletion.
2 changes: 2 additions & 0 deletions e2e-app/src/app/app.routes.ts
Expand Up @@ -26,6 +26,7 @@ import { OffcanvasAutoCloseComponent } from './offcanvas/autoclose/offcanvas-aut
import { OffcanvasFocusComponent } from './offcanvas/focus/offcanvas-focus.component';
import { OffcanvasNestingComponent } from './offcanvas/nesting/offcanvas-nesting.component';
import { OffcanvasStackConfirmationComponent } from './offcanvas/stack-confirmation/offcanvas-stack-confirmation.component';
import { DropdownShadowComponent } from './dropdown/shadow/dropdown-shadow.component';

export const ROUTES: Routes = [
{
Expand Down Expand Up @@ -63,6 +64,7 @@ export const ROUTES: Routes = [
{ path: 'click', component: DropdownClickComponent },
{ path: 'focus', component: DropdownFocusComponent },
{ path: 'position', component: DropdownPositionComponent },
{ path: 'shadow', component: DropdownShadowComponent },
],
},
{ path: 'popover', children: [{ path: 'autoclose', component: PopoverAutocloseComponent }] },
Expand Down
15 changes: 15 additions & 0 deletions e2e-app/src/app/dropdown/shadow/dropdown-shadow.component.html
@@ -0,0 +1,15 @@
<h3>Dropdown shadow tests</h3>

<div class="input-group">
<div ngbDropdown class="d-inline-block me-3">
<button class="btn btn-outline-secondary" id="dropdown" ngbDropdownToggle>Choose and option</button>
<div ngbDropdownMenu aria-labelledby="dropdown">
<button ngbDropdownItem id="item-1">One</button>
<hr />
<button ngbDropdownItem disabled>Disabled</button>
<button class="dropdown-item">Not arrow-focusable</button>
<hr />
<button ngbDropdownItem id="item-2">Two</button>
</div>
</div>
</div>
12 changes: 12 additions & 0 deletions e2e-app/src/app/dropdown/shadow/dropdown-shadow.component.ts
@@ -0,0 +1,12 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIf } from '@angular/common';

@Component({
standalone: true,
imports: [FormsModule, NgbModule, NgIf],
templateUrl: './dropdown-shadow.component.html',
encapsulation: ViewEncapsulation.ShadowDom,
})
export class DropdownShadowComponent {}
47 changes: 47 additions & 0 deletions e2e-app/src/app/dropdown/shadow/dropdown-shadow.e2e-spec.ts
@@ -0,0 +1,47 @@
import { expect } from '@playwright/test';
import { openDropdown } from '../dropdown.po';
import { getPage, setPage, test } from '../../../../baseTest';
import { sendKey } from '../../tools.po';

const SELECTOR_DROPDOWN = '[ngbDropdown]';
const SELECTOR_DROPDOWN_TOGGLE = '[ngbDropdownToggle]';
const SELECTOR_DROPDOWN_ITEM = (item: number) => `#item-${item}`;

const focusToggle = async () => {
await getPage().focus(SELECTOR_DROPDOWN_TOGGLE);
await expect(getPage().locator(SELECTOR_DROPDOWN_TOGGLE), `dropdown toggle should be focused`).toBeFocused();
};

test.use({ testURL: 'dropdown/shadow', testSelector: 'h3:text("Dropdown shadow")' });
test.beforeEach(async ({ page }) => setPage(page));

test.describe(`Dropdown shadow`, () => {
test(`should focus dropdown items with 'ArrowUp' / 'ArrowDown'`, async () => {
// Open
await openDropdown('Dropdown should be opened', SELECTOR_DROPDOWN);
await expect(getPage().locator(SELECTOR_DROPDOWN_TOGGLE), `Toggling element should be focused`).toBeFocused();

// Down -> first
await sendKey('ArrowDown');
await expect(getPage().locator(SELECTOR_DROPDOWN_ITEM(1)), `first dropdown item should be focused`).toBeFocused();

// Down -> second
await sendKey('ArrowDown');
await expect(getPage().locator(SELECTOR_DROPDOWN_ITEM(2)), `second dropdown item should be focused`).toBeFocused();

// Down -> second
await sendKey('ArrowDown');
await expect(
getPage().locator(SELECTOR_DROPDOWN_ITEM(2)),
`second dropdown item should stay focused`,
).toBeFocused();

// Up -> first
await sendKey('ArrowUp');
await expect(getPage().locator(SELECTOR_DROPDOWN_ITEM(1)), `first dropdown item should be focused`).toBeFocused();

// Up -> first
await sendKey('ArrowUp');
await expect(getPage().locator(SELECTOR_DROPDOWN_ITEM(1)), `first dropdown item should stay focused`).toBeFocused();
});
});
3 changes: 2 additions & 1 deletion src/dropdown/dropdown.ts
Expand Up @@ -30,6 +30,7 @@ import { Key } from '../util/key';

import { NgbDropdownConfig } from './dropdown-config';
import { FOCUSABLE_ELEMENTS_SELECTOR } from '../util/focus-trap';
import { getActiveElement } from '../util/util';

/**
* @deprecated 14.2.0 this directive isn't useful anymore. You can remove it from your imports
Expand Down Expand Up @@ -401,7 +402,7 @@ export class NgbDropdown implements OnInit, AfterContentInit, OnChanges, OnDestr
if (item.contains(event.target as HTMLElement)) {
itemElement = item;
}
if (item === this._document.activeElement) {
if (item === getActiveElement(this._document)) {
position = index;
}
});
Expand Down
31 changes: 31 additions & 0 deletions src/util/util.spec.ts
Expand Up @@ -8,6 +8,7 @@ import {
removeAccents,
closest,
isPromise,
getActiveElement,
} from './util';

describe('util', () => {
Expand Down Expand Up @@ -170,4 +171,34 @@ describe('util', () => {
});
});
}

describe('getActiveElement', () => {
it('should return nothing for a wrong element', () => {
expect(getActiveElement(null as any)).toEqual(null);
expect(getActiveElement(document.createElement('div') as any)).toEqual(null);
});

it('should return focused element', () => {
expect(getActiveElement(document)).toEqual(document.body);

const input = document.createElement('input');
document.body.appendChild(input);
input.focus();

expect(getActiveElement(document)).toEqual(input);
document.body.removeChild(input);
});

it('should return focused element from the shadow dom', () => {
const div = document.createElement('div');
div.attachShadow({ mode: 'open' });
const input = document.createElement('input');
div.shadowRoot!.appendChild(input);
document.body.appendChild(div);
input.focus();

expect(getActiveElement(document)).toEqual(input);
document.body.removeChild(div);
});
});
});
14 changes: 14 additions & 0 deletions src/util/util.ts
Expand Up @@ -99,3 +99,17 @@ export function runInZone<T>(zone: NgZone): OperatorFunction<T, T> {
export function removeAccents(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/**
* Returns the active element in the given root.
* If the active element is inside a shadow root, it is searched recursively.
*/
export function getActiveElement(root: Document | ShadowRoot = document): Element | null {
const activeEl = root?.activeElement;

if (!activeEl) {
return null;
}

return activeEl.shadowRoot ? getActiveElement(activeEl.shadowRoot) : activeEl;
}

0 comments on commit 995d22d

Please sign in to comment.