Skip to content

Commit

Permalink
feat(autocomplete): support variable option height (#20324)
Browse files Browse the repository at this point in the history
Historically `mat-select` and `mat-autocomplete` have behaved very similarly, because they were written around the same time and they share some logic by depending on `mat-option`. `mat-select` has to know all the option heights ahead of time so that it can position its panel correctly over the trigger. The limitation made its way into `mat-autocomplete`, even though there's no reason for it to be there.

While implementing the MDC-based autocomplete, I refactored some code that makes it easier to support variable-height options so there changes enable the functionality for the non-MDC autocomplete too.

DEPRECATED:
* `AUTOCOMPLETE_OPTION_HEIGHT` is deprecated, because it isn't being used anymore.
* `AUTOCOMPLETE_PANEL_HEIGHT` is deprecated, because it isn't being used anymore.

Fixes #18030.
  • Loading branch information
crisbeto committed Aug 20, 2020
1 parent 578d4ef commit 2058f71
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 76 deletions.
34 changes: 0 additions & 34 deletions src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts
Expand Up @@ -44,38 +44,4 @@ export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any = {
})
export class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase {
protected _aboveClass = 'mat-mdc-autocomplete-panel-above';

protected _scrollToOption(index: number): void {
// Given that we are not actually focusing active options, we must manually adjust scroll
// to reveal options below the fold. First, we find the offset of the option from the top
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
// the panel height + the option height, so the active option will be just visible at the
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
// will become the offset. If that offset is visible within the panel already, the scrollTop is
// not adjusted.
const autocomplete = this.autocomplete;
const labelCount = _countGroupLabelsBeforeOption(index,
autocomplete.options, autocomplete.optionGroups);

if (index === 0 && labelCount === 1) {
// If we've got one group label before the option and we're at the top option,
// scroll the list to the top. This is better UX than scrolling the list to the
// top of the option, because it allows the user to read the top group's label.
autocomplete._setScrollTop(0);
} else {
const option = autocomplete.options.toArray()[index];

if (option) {
const element = option._getHostElement();
const newScrollPosition = _getOptionScrollPosition(
element.offsetTop,
element.offsetHeight,
autocomplete._getScrollTop(),
autocomplete.panel.nativeElement.offsetHeight
);

autocomplete._setScrollTop(newScrollPosition);
}
}
}
}
32 changes: 30 additions & 2 deletions src/material-experimental/mdc-autocomplete/autocomplete.spec.ts
Expand Up @@ -1080,6 +1080,31 @@ describe('MDC-based MatAutocomplete', () => {
.toEqual(40, `Expected panel to reveal the sixth option.`);
});

it('should scroll to active options below if the option height is variable', () => {
// Make every other option a bit taller than the base of 48.
fixture.componentInstance.states.forEach((state, index) => {
if (index % 2 === 0) {
state.height = 64;
}
});
fixture.detectChanges();

const trigger = fixture.componentInstance.trigger;
const scrollContainer =
document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!;

trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`);

// These down arrows will set the 6th option active, below the fold.
[1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT));

// Expect option bottom minus the panel height (336 - 256 + 8 = 88)
expect(scrollContainer.scrollTop)
.toEqual(88, `Expected panel to reveal the sixth option.`);
});

it('should scroll to active options on UP arrow', () => {
const scrollContainer =
document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!;
Expand Down Expand Up @@ -2617,7 +2642,10 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
<mat-option *ngFor="let state of filteredStates" [value]="state">
<mat-option
*ngFor="let state of filteredStates"
[value]="state"
[style.height.px]="state.height">
<span>{{ state.code }}: {{ state.name }}</span>
</mat-option>
</mat-autocomplete>
Expand All @@ -2642,7 +2670,7 @@ class SimpleAutocomplete implements OnDestroy {
@ViewChild(MatFormField) formField: MatFormField;
@ViewChildren(MatOption) options: QueryList<MatOption>;

states = [
states: {code: string, name: string, height?: number}[] = [
{code: 'AL', name: 'Alabama'},
{code: 'CA', name: 'California'},
{code: 'FL', name: 'Florida'},
Expand Down
78 changes: 45 additions & 33 deletions src/material/autocomplete/autocomplete-trigger.ts
Expand Up @@ -59,10 +59,18 @@ import {_MatAutocompleteOriginBase} from './autocomplete-origin';
* actually focusing the active item, scroll must be handled manually.
*/

/** The height of each autocomplete option. */
/**
* The height of each autocomplete option.
* @deprecated No longer being used. To be removed.
* @breaking-change 11.0.0
*/
export const AUTOCOMPLETE_OPTION_HEIGHT = 48;

/** The total height of the autocomplete panel. */
/**
* The total height of the autocomplete panel.
* @deprecated No longer being used. To be removed.
* @breaking-change 11.0.0
*/
export const AUTOCOMPLETE_PANEL_HEIGHT = 256;

/** Injection token that determines the scroll handling while the autocomplete panel is open. */
Expand Down Expand Up @@ -204,9 +212,6 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
this._scrollStrategy = scrollStrategy;
}

/** Scrolls to an option at a particular index. */
protected abstract _scrollToOption(index: number): void;

/** Class to apply to the panel when it's above the input. */
protected abstract _aboveClass: string;

Expand Down Expand Up @@ -715,6 +720,41 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
return this._document?.defaultView || window;
}

/** Scrolls to a particular option in the list. */
private _scrollToOption(index: number): void {
// Given that we are not actually focusing active options, we must manually adjust scroll
// to reveal options below the fold. First, we find the offset of the option from the top
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
// the panel height + the option height, so the active option will be just visible at the
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
// will become the offset. If that offset is visible within the panel already, the scrollTop is
// not adjusted.
const autocomplete = this.autocomplete;
const labelCount = _countGroupLabelsBeforeOption(index,
autocomplete.options, autocomplete.optionGroups);

if (index === 0 && labelCount === 1) {
// If we've got one group label before the option and we're at the top option,
// scroll the list to the top. This is better UX than scrolling the list to the
// top of the option, because it allows the user to read the top group's label.
autocomplete._setScrollTop(0);
} else {
const option = autocomplete.options.toArray()[index];

if (option) {
const element = option._getHostElement();
const newScrollPosition = _getOptionScrollPosition(
element.offsetTop,
element.offsetHeight,
autocomplete._getScrollTop(),
autocomplete.panel.nativeElement.offsetHeight
);

autocomplete._setScrollTop(newScrollPosition);
}
}
}

static ngAcceptInputType_autocompleteDisabled: BooleanInput;
}

Expand Down Expand Up @@ -742,32 +782,4 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
})
export class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase {
protected _aboveClass = 'mat-autocomplete-panel-above';

protected _scrollToOption(index: number): void {
// Given that we are not actually focusing active options, we must manually adjust scroll
// to reveal options below the fold. First, we find the offset of the option from the top
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
// the panel height + the option height, so the active option will be just visible at the
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
// will become the offset. If that offset is visible within the panel already, the scrollTop is
// not adjusted.
const labelCount = _countGroupLabelsBeforeOption(index,
this.autocomplete.options, this.autocomplete.optionGroups);

if (index === 0 && labelCount === 1) {
// If we've got one group label before the option and we're at the top option,
// scroll the list to the top. This is better UX than scrolling the list to the
// top of the option, because it allows the user to read the top group's label.
this.autocomplete._setScrollTop(0);
} else {
const newScrollPosition = _getOptionScrollPosition(
(index + labelCount) * AUTOCOMPLETE_OPTION_HEIGHT,
AUTOCOMPLETE_OPTION_HEIGHT,
this.autocomplete._getScrollTop(),
AUTOCOMPLETE_PANEL_HEIGHT
);

this.autocomplete._setScrollTop(newScrollPosition);
}
}
}
38 changes: 33 additions & 5 deletions src/material/autocomplete/autocomplete.spec.ts
Expand Up @@ -1080,6 +1080,31 @@ describe('MatAutocomplete', () => {
.toEqual(32, `Expected panel to reveal the sixth option.`);
});

it('should scroll to active options below if the option height is variable', () => {
// Make every other option a bit taller than the base of 48.
fixture.componentInstance.states.forEach((state, index) => {
if (index % 2 === 0) {
state.height = 64;
}
});
fixture.detectChanges();

const trigger = fixture.componentInstance.trigger;
const scrollContainer =
document.querySelector('.cdk-overlay-pane .mat-autocomplete-panel')!;

trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`);

// These down arrows will set the 6th option active, below the fold.
[1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT));

// Expect option bottom minus the panel height (336 - 256 = 80)
expect(scrollContainer.scrollTop)
.toEqual(80, `Expected panel to reveal the sixth option.`);
});

it('should scroll to active options on UP arrow', () => {
const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-autocomplete-panel')!;

Expand Down Expand Up @@ -1266,7 +1291,7 @@ describe('MatAutocomplete', () => {

fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
tick();
zone.simulateZoneExit();
fixture.detectChanges();
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;

Expand All @@ -1293,7 +1318,7 @@ describe('MatAutocomplete', () => {

fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
tick();
zone.simulateZoneExit();
fixture.detectChanges();
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;

Expand Down Expand Up @@ -1376,7 +1401,7 @@ describe('MatAutocomplete', () => {

fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
tick();
zone.simulateZoneExit();
fixture.detectChanges();
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;

Expand Down Expand Up @@ -2626,7 +2651,10 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
<mat-option *ngFor="let state of filteredStates" [value]="state">
<mat-option
*ngFor="let state of filteredStates"
[value]="state"
[style.height.px]="state.height">
<span>{{ state.code }}: {{ state.name }}</span>
</mat-option>
</mat-autocomplete>
Expand All @@ -2651,7 +2679,7 @@ class SimpleAutocomplete implements OnDestroy {
@ViewChild(MatFormField) formField: MatFormField;
@ViewChildren(MatOption) options: QueryList<MatOption>;

states = [
states: {code: string, name: string, height?: number}[] = [
{code: 'AL', name: 'Alabama'},
{code: 'CA', name: 'California'},
{code: 'FL', name: 'Florida'},
Expand Down
2 changes: 0 additions & 2 deletions tools/public_api_guard/material/autocomplete.d.ts
Expand Up @@ -61,7 +61,6 @@ export declare abstract class _MatAutocompleteTriggerBase implements ControlValu
_handleFocus(): void;
_handleInput(event: KeyboardEvent): void;
_handleKeydown(event: KeyboardEvent): void;
protected abstract _scrollToOption(index: number): void;
closePanel(): void;
ngAfterViewInit(): void;
ngOnChanges(changes: SimpleChanges): void;
Expand Down Expand Up @@ -137,7 +136,6 @@ export declare class MatAutocompleteSelectedEvent {

export declare class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase {
protected _aboveClass: string;
protected _scrollToOption(index: number): void;
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatAutocompleteTrigger, "input[matAutocomplete], textarea[matAutocomplete]", ["matAutocompleteTrigger"], {}, {}, never>;
static ɵfac: i0.ɵɵFactoryDef<MatAutocompleteTrigger, never>;
}

0 comments on commit 2058f71

Please sign in to comment.