Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ import { KbqAutocompleteOrigin } from './autocomplete-origin.directive';
import { KbqAutocomplete } from './autocomplete.component';

/**
* The following style constants are necessary to save here in order
* to properly calculate the scrollTop of the panel. Because we are not
* actually focusing the active item, scroll must be handled manually.
* The total height of the autocomplete panel.
*
* @deprecated Unused — the panel is capped by `--kbq-autocomplete-size-panel-max-height` and reveals its
* active option through `KbqOption.focus`, so nothing computes a scroll offset from this. Will be removed
* in the next major release.
*/

/** The total height of the autocomplete panel. */
export const AUTOCOMPLETE_PANEL_HEIGHT = 256;

/**
Expand Down Expand Up @@ -289,7 +289,8 @@ export class KbqAutocompleteTrigger

if (this.panelOpen) {
this.scrollActiveOptionIntoView();
this.elementRef.nativeElement.focus();
// Focus returns to the input on every arrow key; it must not drag the scroll with it.
this.elementRef.nativeElement.focus({ preventScroll: true });
} else if (!this.panelOpen && autocompleteValue.keyManager.activeItem) {
autocompleteValue.keyManager.activeItem?.selectViaInteraction();
}
Expand Down
117 changes: 117 additions & 0 deletions packages/components/autocomplete/e2e.webkit.playwright-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { expect, Page, test } from '@playwright/test';
import {
e2eIsFullyInView,
e2eReadOptionFocusOptions,
e2eRecordOptionFocusOptions,
e2eScrollTopOf,
e2eSettleFrames,
e2eWaitForScrollEnd
} from '../../e2e/utils';

/*
* WebKit-only guard for panel scrolling (DS-3299).
*
* Focus scrolls its target into view implicitly. Blink does that synchronously; WebKit defers it to a
* later rendering update, where it lands after — and undoes — whatever the reader scrolled in between.
* Components therefore focus with `preventScroll` and reveal the option themselves.
*
* These assert on scroll offsets rather than screenshots, so they need no baselines and no Docker.
*/

test.use({ browserName: 'webkit' });

test.describe('KbqAutocomplete panel scrolling', () => {
const getPort = (page: Page) => page.locator('.kbq-autocomplete-panel__content');

test.beforeEach(async ({ page }) => {
// Installed before navigation so the focus calls the panel makes while opening are recorded too.
await e2eRecordOptionFocusOptions(page);
await page.goto('/E2eAutocompleteScrollbar');
await page.getByTestId('e2eAutocompleteInput').focus();
// The routes fit the viewport, so without this the page-scroll assertion below could never fail.
await page.addStyleTag({ content: 'body { min-height: 3000px; }' });
await expect(getPort(page)).toBeVisible();
await expect(getPort(page).locator('.kbq-option').first()).toBeVisible();
});

test('has a panel that actually overflows', async ({ page }) => {
// Guards the premise of the other tests: without overflow they would all pass vacuously.
await expect.poll(() => getPort(page).evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
});

test('never asks the browser to scroll on focus, including while opening', async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.keyboard.press('ArrowDown');
}

const preventScrollFlags = await e2eReadOptionFocusOptions(page);

// A non-empty recording proves focus was exercised, so the assertion cannot pass vacuously.
expect(preventScrollFlags.length).toBeGreaterThan(0);
expect(preventScrollFlags).not.toContain(false);
});

test('scrolls with the wheel and stays where the reader left it', async ({ page }) => {
const port = getPort(page);

const start = await e2eScrollTopOf(port);

await port.hover();
await page.mouse.wheel(0, 200);

// The gesture is applied over several frames; wait for the offset to move and then settle.
const scrolled = await e2eWaitForScrollEnd(port, start);

expect(scrolled).toBeGreaterThan(0);

// Nothing may move the panel afterwards — a deferred focus scroll used to land right here.
await e2eSettleFrames(page);

// A deferred focus scroll moves the panel by at least a row; a pixel of settling is not movement.
expect(Math.abs((await e2eScrollTopOf(port)) - scrolled)).toBeLessThanOrEqual(1);
});

test('does not move the list when the pointer lands on a partially clipped option', async ({ page }) => {
const port = getPort(page);

const offsets = await port.evaluate(async (element, sel) => {
// Land on a fractional offset so a row straddles the top edge of the scrollport.
element.scrollTop = 50;
await new Promise((resolve) => requestAnimationFrame(resolve));

const before = element.scrollTop;
const port = element.getBoundingClientRect();
const clipped = [...element.querySelectorAll<HTMLElement>(sel)].find((item) => {
const box = item.getBoundingClientRect();

return box.top < port.top && box.bottom > port.top;
});

if (!clipped) return null;

clipped.dispatchEvent(new MouseEvent('mouseenter'));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));

return { before, after: element.scrollTop };
}, '.kbq-option');

expect(offsets).not.toBeNull();
expect(offsets!.after).toBe(offsets!.before);
});

test('brings the active option into view on keyboard navigation without scrolling the page', async ({ page }) => {
const port = getPort(page);
const itemCount = await port.locator('.kbq-option').count();

for (let i = 0; i < itemCount; i++) {
await page.keyboard.press('ArrowDown');
}

await e2eSettleFrames(page);

expect(await e2eScrollTopOf(port)).toBeGreaterThan(0);
expect(await e2eIsFullyInView(port, '.kbq-option.kbq-active')).toBe(true);
// The page is deliberately made scrollable in beforeEach, so this can actually fail.
expect(await page.evaluate(() => window.scrollY)).toBe(0);
});
});
53 changes: 53 additions & 0 deletions packages/components/core/option/option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,59 @@ describe('KbqOption component', () => {
subscription.unsubscribe();
});

it('should reveal the option on focus without letting the browser scroll from focus itself', () => {
const fixture = TestBed.createComponent(OptionWithDisable);

fixture.detectChanges();

const option: KbqOption = fixture.debugElement.query(By.directive(KbqOption)).componentInstance;
const host = option.getHostElement();
const focusSpy = jest.spyOn(host, 'focus');
const scrollSpy = jest.spyOn(host, 'scrollIntoView');

option.focus();

expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});

it('should focus but not reveal while the pointer is over the option', () => {
const fixture = TestBed.createComponent(OptionWithDisable);

fixture.detectChanges();

const option: KbqOption = fixture.debugElement.query(By.directive(KbqOption)).componentInstance;
const host = option.getHostElement();
const focusSpy = jest.spyOn(host, 'focus');
const scrollSpy = jest.spyOn(host, 'scrollIntoView');

host.dispatchEvent(new MouseEvent('mouseenter'));
option.focus();

// Focus must still move, otherwise type-ahead and aria-activedescendant break.
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
// The option is already under the cursor; revealing it would shift the list out from under it.
expect(scrollSpy).not.toHaveBeenCalled();
});

it('should reveal again once the pointer has left, even if no focus happened in between', () => {
const fixture = TestBed.createComponent(OptionWithDisable);

fixture.detectChanges();

const option: KbqOption = fixture.debugElement.query(By.directive(KbqOption)).componentInstance;
const host = option.getHostElement();
const scrollSpy = jest.spyOn(host, 'scrollIntoView');

// Hovering the option that is already active never reaches focus(), so a flag cleared only
// there would stay armed and silently swallow every later reveal.
host.dispatchEvent(new MouseEvent('mouseenter'));
host.dispatchEvent(new MouseEvent('mouseleave'));
option.focus();

expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});

it('should not emit to `onSelectionChange` if selecting an already-selected option', () => {
const fixture = TestBed.createComponent(OptionWithDisable);

Expand Down
32 changes: 17 additions & 15 deletions packages/components/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ActiveDescendantKeyManager } from '../a11y';
import { ENTER, hasModifierKey, SPACE } from '../keycodes';
import { KbqPseudoCheckboxModule } from '../selection';
import { KBQ_TITLE_TEXT_REF, KbqTitleTextRef } from '../title';
import { kbqFocusAndReveal } from '../utils';
import { KbqOptgroup } from './optgroup';

/**
Expand Down Expand Up @@ -157,6 +158,7 @@ export class KbqVirtualOption extends KbqOptionBase {

'(click)': 'handleClick($event)',
'(mouseenter)': 'onMouseenter()',
'(mouseleave)': 'onMouseleave()',
'(keydown)': 'handleKeydown($event)'
},
exportAs: 'kbqOption'
Expand Down Expand Up @@ -259,14 +261,11 @@ export class KbqOption extends KbqOptionBase implements AfterViewChecked, OnDest
private mostRecentViewValue = '';

/**
* Flag that indicates whether the component is currently focused by a mouse interaction.
*
* When set to `true`, the component has focus resulting from a mouse click or
* other pointer event. It is automatically cleared when the component loses
* focus or if focus is obtained through keyboard navigation or programmatic means.
* Whether the pointer is currently over this option. Cleared on `mouseleave` rather than when the
* option is focused: hovering the option that is already active does not move the key manager's
* index, so no focus follows and a flag cleared only there would stay armed indefinitely.
*/
private isFocusedByMouse: boolean = false;

private hoveredByPointer = false;
ngAfterViewChecked() {
// Since parent components could be using the option's label to display the selected values
// (e.g. `kbq-select`) and they don't have a way of knowing if the option's label has changed
Expand Down Expand Up @@ -310,14 +309,9 @@ export class KbqOption extends KbqOptionBase implements AfterViewChecked, OnDest
}
}

/** Moves keyboard focus to this option and reveals it — see {@link kbqFocusAndReveal}. */
focus(): void {
const element = this.getHostElement();

if (typeof element.focus === 'function') {
element.focus({ preventScroll: this.isFocusedByMouse });

this.isFocusedByMouse = false;
}
kbqFocusAndReveal(this.getHostElement(), this.hoveredByPointer);
}

/**
Expand Down Expand Up @@ -400,10 +394,15 @@ export class KbqOption extends KbqOptionBase implements AfterViewChecked, OnDest
protected onMouseenter() {
if (this.disabled) return;

this.isFocusedByMouse = true;
this.hoveredByPointer = true;

this.parent?.keyManager?.setActiveItem(this);
}

/** @docs-private */
protected onMouseleave() {
this.hoveredByPointer = false;
}
}

/**
Expand Down Expand Up @@ -443,6 +442,9 @@ export function countGroupLabelsBeforeOption(
* @param currentScrollPosition Current scroll position of the panel.
* @param panelHeight Height of the panel.
* @docs-private
* @deprecated Unused — an option reveals itself on focus through `KbqOption.focus`, which lets the
* browser resolve the scroll container instead of computing an offset from a uniform row height. Will be
* removed in the next major release.
*/
export function getOptionScrollPosition(
optionIndex: number,
Expand Down
26 changes: 26 additions & 0 deletions packages/components/core/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { _getFocusedElementPierceShadowDom } from '@angular/cdk/platform';
import { ElementRef, inject } from '@angular/core';

/**
Expand All @@ -6,3 +7,28 @@ import { ElementRef, inject } from '@angular/core';
export const kbqInjectNativeElement = <T extends Element = HTMLElement>(): T => {
return inject<ElementRef<T>>(ElementRef<T>).nativeElement;
};

/**
* Focuses `element` and scrolls it into view by the shortest distance, leaving a visible one alone.
*
* The reveal is explicit because the one `focus()` performs implicitly is not portable: WebKit defers it
* to a later rendering update, where it lands after — and undoes — any scrolling the reader did in the
* meantime. Letting the element reveal itself keeps every consumer working, whatever it uses as a scroll
* container, and the browser resolves that container rather than the caller guessing at it.
*
* Pass `skipReveal` when the pointer caused the focus: the element is already under the cursor, so
* revealing it would shift the list out from under it.
*/
export const kbqFocusAndReveal = (element: HTMLElement, skipReveal = false): void => {
if (typeof element.focus !== 'function') return;

// A focus listener calling back into `focus` must not scroll a second time. Read through shadow
// roots, where `document.activeElement` reports the host instead of the element that holds focus.
const wasFocused = _getFocusedElementPierceShadowDom() === element;

element.focus({ preventScroll: true });

if (skipReveal || wasFocused) return;

element.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
};
20 changes: 17 additions & 3 deletions packages/components/dropdown/dropdown-item.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,28 @@ export class KbqDropdownItem implements KbqTitleTextRef, KbqDropdownItemActionHo
this.getHostElement().classList.remove('cdk-keyboard-focused');
}

/** Focuses the dropdown item. */
/**
* Focuses the dropdown item and reveals it.
*
* The reveal is explicit because the one `focus()` performs implicitly is not portable: WebKit defers
* it to a later rendering update, where it lands after — and undoes — any scrolling the reader did in
* the meantime. `preventScroll` is therefore always forced on, overriding `options`.
*/
focus(origin?: FocusOrigin, options?: FocusOptions): void {
if (this.disabled) return;

const element = this.getHostElement();
const focusOptions: FocusOptions = { ...options, preventScroll: true };

if (this.focusMonitor && origin) {
this.focusMonitor.focusVia(this.getHostElement(), origin, options);
this.focusMonitor.focusVia(element, origin, focusOptions);
} else {
this.getHostElement().focus(options);
element.focus(focusOptions);
}

// With the pointer already on this item, revealing it would shift the list out from under it.
if (origin !== 'mouse') {
element.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}

this.focused.next(this);
Expand Down
2 changes: 1 addition & 1 deletion packages/components/dropdown/dropdown.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
// same as `setFirstItemActive` no-ops in that case.
const panel = this.directDescendantItems.first?.getHostElement().closest<HTMLElement>('.kbq-dropdown__panel');

panel?.focus();
panel?.focus({ preventScroll: true });
}

/**
Expand Down
Loading