Skip to content

Commit 8c57673

Browse files
committed
feat(autocomplete): support variable option height
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. Fixes #18030.
1 parent 23d3c21 commit 8c57673

File tree

5 files changed

+108
-76
lines changed

5 files changed

+108
-76
lines changed

src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,38 +44,4 @@ export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any = {
4444
})
4545
export class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase {
4646
protected _aboveClass = 'mat-mdc-autocomplete-panel-above';
47-
48-
protected _scrollToOption(index: number): void {
49-
// Given that we are not actually focusing active options, we must manually adjust scroll
50-
// to reveal options below the fold. First, we find the offset of the option from the top
51-
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
52-
// the panel height + the option height, so the active option will be just visible at the
53-
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
54-
// will become the offset. If that offset is visible within the panel already, the scrollTop is
55-
// not adjusted.
56-
const autocomplete = this.autocomplete;
57-
const labelCount = _countGroupLabelsBeforeOption(index,
58-
autocomplete.options, autocomplete.optionGroups);
59-
60-
if (index === 0 && labelCount === 1) {
61-
// If we've got one group label before the option and we're at the top option,
62-
// scroll the list to the top. This is better UX than scrolling the list to the
63-
// top of the option, because it allows the user to read the top group's label.
64-
autocomplete._setScrollTop(0);
65-
} else {
66-
const option = autocomplete.options.toArray()[index];
67-
68-
if (option) {
69-
const element = option._getHostElement();
70-
const newScrollPosition = _getOptionScrollPosition(
71-
element.offsetTop,
72-
element.offsetHeight,
73-
autocomplete._getScrollTop(),
74-
autocomplete.panel.nativeElement.offsetHeight
75-
);
76-
77-
autocomplete._setScrollTop(newScrollPosition);
78-
}
79-
}
80-
}
8147
}

src/material-experimental/mdc-autocomplete/autocomplete.spec.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,31 @@ describe('MDC-based MatAutocomplete', () => {
10821082
.toEqual(40, `Expected panel to reveal the sixth option.`);
10831083
});
10841084

1085+
it('should scroll to active options below if the option height is variable', () => {
1086+
// Make every other option a bit taller than the base of 48.
1087+
fixture.componentInstance.states.forEach((state, index) => {
1088+
if (index % 2 === 0) {
1089+
state.height = 64;
1090+
}
1091+
});
1092+
fixture.detectChanges();
1093+
1094+
const trigger = fixture.componentInstance.trigger;
1095+
const scrollContainer =
1096+
document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!;
1097+
1098+
trigger._handleKeydown(DOWN_ARROW_EVENT);
1099+
fixture.detectChanges();
1100+
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`);
1101+
1102+
// These down arrows will set the 6th option active, below the fold.
1103+
[1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT));
1104+
1105+
// Expect option bottom minus the panel height (336 - 256 + 8 = 88)
1106+
expect(scrollContainer.scrollTop)
1107+
.toEqual(88, `Expected panel to reveal the sixth option.`);
1108+
});
1109+
10851110
it('should scroll to active options on UP arrow', () => {
10861111
const scrollContainer =
10871112
document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!;
@@ -2619,7 +2644,10 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
26192644
26202645
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
26212646
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2622-
<mat-option *ngFor="let state of filteredStates" [value]="state">
2647+
<mat-option
2648+
*ngFor="let state of filteredStates"
2649+
[value]="state"
2650+
[style.height.px]="state.height">
26232651
<span>{{ state.code }}: {{ state.name }}</span>
26242652
</mat-option>
26252653
</mat-autocomplete>
@@ -2644,7 +2672,7 @@ class SimpleAutocomplete implements OnDestroy {
26442672
@ViewChild(MatFormField) formField: MatFormField;
26452673
@ViewChildren(MatOption) options: QueryList<MatOption>;
26462674

2647-
states = [
2675+
states: {code: string, name: string, height?: number}[] = [
26482676
{code: 'AL', name: 'Alabama'},
26492677
{code: 'CA', name: 'California'},
26502678
{code: 'FL', name: 'Florida'},

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,18 @@ import {_MatAutocompleteOriginBase} from './autocomplete-origin';
5959
* actually focusing the active item, scroll must be handled manually.
6060
*/
6161

62-
/** The height of each autocomplete option. */
62+
/**
63+
* The height of each autocomplete option.
64+
* @deprecated No longer being used. To be removed.
65+
* @breaking-change 11.0.0
66+
*/
6367
export const AUTOCOMPLETE_OPTION_HEIGHT = 48;
6468

65-
/** The total height of the autocomplete panel. */
69+
/**
70+
* The total height of the autocomplete panel.
71+
* @deprecated No longer being used. To be removed.
72+
* @breaking-change 11.0.0
73+
*/
6674
export const AUTOCOMPLETE_PANEL_HEIGHT = 256;
6775

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

207-
/** Scrolls to an option at a particular index. */
208-
protected abstract _scrollToOption(index: number): void;
209-
210215
/** Class to apply to the panel when it's above the input. */
211216
protected abstract _aboveClass: string;
212217

@@ -716,6 +721,41 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
716721
return this._document?.defaultView || window;
717722
}
718723

724+
/** Scrolls to a particular option in the list. */
725+
private _scrollToOption(index: number): void {
726+
// Given that we are not actually focusing active options, we must manually adjust scroll
727+
// to reveal options below the fold. First, we find the offset of the option from the top
728+
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
729+
// the panel height + the option height, so the active option will be just visible at the
730+
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
731+
// will become the offset. If that offset is visible within the panel already, the scrollTop is
732+
// not adjusted.
733+
const autocomplete = this.autocomplete;
734+
const labelCount = _countGroupLabelsBeforeOption(index,
735+
autocomplete.options, autocomplete.optionGroups);
736+
737+
if (index === 0 && labelCount === 1) {
738+
// If we've got one group label before the option and we're at the top option,
739+
// scroll the list to the top. This is better UX than scrolling the list to the
740+
// top of the option, because it allows the user to read the top group's label.
741+
autocomplete._setScrollTop(0);
742+
} else {
743+
const option = autocomplete.options.toArray()[index];
744+
745+
if (option) {
746+
const element = option._getHostElement();
747+
const newScrollPosition = _getOptionScrollPosition(
748+
element.offsetTop,
749+
element.offsetHeight,
750+
autocomplete._getScrollTop(),
751+
autocomplete.panel.nativeElement.offsetHeight
752+
);
753+
754+
autocomplete._setScrollTop(newScrollPosition);
755+
}
756+
}
757+
}
758+
719759
static ngAcceptInputType_autocompleteDisabled: BooleanInput;
720760
}
721761

@@ -743,32 +783,4 @@ export abstract class _MatAutocompleteTriggerBase implements ControlValueAccesso
743783
})
744784
export class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase {
745785
protected _aboveClass = 'mat-autocomplete-panel-above';
746-
747-
protected _scrollToOption(index: number): void {
748-
// Given that we are not actually focusing active options, we must manually adjust scroll
749-
// to reveal options below the fold. First, we find the offset of the option from the top
750-
// of the panel. If that offset is below the fold, the new scrollTop will be the offset -
751-
// the panel height + the option height, so the active option will be just visible at the
752-
// bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop
753-
// will become the offset. If that offset is visible within the panel already, the scrollTop is
754-
// not adjusted.
755-
const labelCount = _countGroupLabelsBeforeOption(index,
756-
this.autocomplete.options, this.autocomplete.optionGroups);
757-
758-
if (index === 0 && labelCount === 1) {
759-
// If we've got one group label before the option and we're at the top option,
760-
// scroll the list to the top. This is better UX than scrolling the list to the
761-
// top of the option, because it allows the user to read the top group's label.
762-
this.autocomplete._setScrollTop(0);
763-
} else {
764-
const newScrollPosition = _getOptionScrollPosition(
765-
(index + labelCount) * AUTOCOMPLETE_OPTION_HEIGHT,
766-
AUTOCOMPLETE_OPTION_HEIGHT,
767-
this.autocomplete._getScrollTop(),
768-
AUTOCOMPLETE_PANEL_HEIGHT
769-
);
770-
771-
this.autocomplete._setScrollTop(newScrollPosition);
772-
}
773-
}
774786
}

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,31 @@ describe('MatAutocomplete', () => {
10801080
.toEqual(32, `Expected panel to reveal the sixth option.`);
10811081
});
10821082

1083+
it('should scroll to active options below if the option height is variable', () => {
1084+
// Make every other option a bit taller than the base of 48.
1085+
fixture.componentInstance.states.forEach((state, index) => {
1086+
if (index % 2 === 0) {
1087+
state.height = 64;
1088+
}
1089+
});
1090+
fixture.detectChanges();
1091+
1092+
const trigger = fixture.componentInstance.trigger;
1093+
const scrollContainer =
1094+
document.querySelector('.cdk-overlay-pane .mat-autocomplete-panel')!;
1095+
1096+
trigger._handleKeydown(DOWN_ARROW_EVENT);
1097+
fixture.detectChanges();
1098+
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`);
1099+
1100+
// These down arrows will set the 6th option active, below the fold.
1101+
[1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT));
1102+
1103+
// Expect option bottom minus the panel height (336 - 256 = 80)
1104+
expect(scrollContainer.scrollTop)
1105+
.toEqual(80, `Expected panel to reveal the sixth option.`);
1106+
});
1107+
10831108
it('should scroll to active options on UP arrow', () => {
10841109
const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-autocomplete-panel')!;
10851110

@@ -1266,7 +1291,7 @@ describe('MatAutocomplete', () => {
12661291

12671292
fixture.componentInstance.trigger.openPanel();
12681293
fixture.detectChanges();
1269-
tick();
1294+
zone.simulateZoneExit();
12701295
fixture.detectChanges();
12711296
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;
12721297

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

12941319
fixture.componentInstance.trigger.openPanel();
12951320
fixture.detectChanges();
1296-
tick();
1321+
zone.simulateZoneExit();
12971322
fixture.detectChanges();
12981323
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;
12991324

@@ -1376,7 +1401,7 @@ describe('MatAutocomplete', () => {
13761401

13771402
fixture.componentInstance.trigger.openPanel();
13781403
fixture.detectChanges();
1379-
tick();
1404+
zone.simulateZoneExit();
13801405
fixture.detectChanges();
13811406
const container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;
13821407

@@ -2626,7 +2651,10 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
26262651
26272652
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
26282653
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2629-
<mat-option *ngFor="let state of filteredStates" [value]="state">
2654+
<mat-option
2655+
*ngFor="let state of filteredStates"
2656+
[value]="state"
2657+
[style.height.px]="state.height">
26302658
<span>{{ state.code }}: {{ state.name }}</span>
26312659
</mat-option>
26322660
</mat-autocomplete>
@@ -2651,7 +2679,7 @@ class SimpleAutocomplete implements OnDestroy {
26512679
@ViewChild(MatFormField) formField: MatFormField;
26522680
@ViewChildren(MatOption) options: QueryList<MatOption>;
26532681

2654-
states = [
2682+
states: {code: string, name: string, height?: number}[] = [
26552683
{code: 'AL', name: 'Alabama'},
26562684
{code: 'CA', name: 'California'},
26572685
{code: 'FL', name: 'Florida'},

tools/public_api_guard/material/autocomplete.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ export declare abstract class _MatAutocompleteTriggerBase implements ControlValu
6161
_handleFocus(): void;
6262
_handleInput(event: KeyboardEvent): void;
6363
_handleKeydown(event: KeyboardEvent): void;
64-
protected abstract _scrollToOption(index: number): void;
6564
closePanel(): void;
6665
ngAfterViewInit(): void;
6766
ngOnChanges(changes: SimpleChanges): void;
@@ -137,7 +136,6 @@ export declare class MatAutocompleteSelectedEvent {
137136

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

0 commit comments

Comments
 (0)