Skip to content

feat(autocomplete): support variable option height #20324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 0 additions & 34 deletions src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,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 @@ -2619,7 +2644,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 @@ -2644,7 +2672,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
Original file line number Diff line number Diff line change
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 @@ -716,6 +721,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 @@ -743,32 +783,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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>;
}