Skip to content

Commit 2058f71

Browse files
authored
feat(autocomplete): support variable option height (#20324)
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.
1 parent 578d4ef commit 2058f71

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
@@ -1080,6 +1080,31 @@ describe('MDC-based MatAutocomplete', () => {
10801080
.toEqual(40, `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-mdc-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 + 8 = 88)
1104+
expect(scrollContainer.scrollTop)
1105+
.toEqual(88, `Expected panel to reveal the sixth option.`);
1106+
});
1107+
10831108
it('should scroll to active options on UP arrow', () => {
10841109
const scrollContainer =
10851110
document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!;
@@ -2617,7 +2642,10 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
26172642
26182643
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
26192644
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2620-
<mat-option *ngFor="let state of filteredStates" [value]="state">
2645+
<mat-option
2646+
*ngFor="let state of filteredStates"
2647+
[value]="state"
2648+
[style.height.px]="state.height">
26212649
<span>{{ state.code }}: {{ state.name }}</span>
26222650
</mat-option>
26232651
</mat-autocomplete>
@@ -2642,7 +2670,7 @@ class SimpleAutocomplete implements OnDestroy {
26422670
@ViewChild(MatFormField) formField: MatFormField;
26432671
@ViewChildren(MatOption) options: QueryList<MatOption>;
26442672

2645-
states = [
2673+
states: {code: string, name: string, height?: number}[] = [
26462674
{code: 'AL', name: 'Alabama'},
26472675
{code: 'CA', name: 'California'},
26482676
{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

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

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

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

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)