feat(select): add md-optgroup component (#4432)

* feat(select): add md-optgroup component

Adds the `md-optgroup` component, which can be used to group options inside of `md-select`, in a similar way to the native `optgroup`.

Fixes #3182.

* fix: address feedback

* fix: address feedback
This commit is contained in:
Kristiyan Kostadinov
2017-06-08 20:22:46 +02:00
committed by Andrew Seguin
parent d0d79fd93a
commit d09aa896b6
15 changed files with 443 additions and 45 deletions
+18 -1
View File
@@ -1,5 +1,7 @@
<div style="height: 1000px">This div is for testing scrolled selects.</div>
Space above cards: <input type="number" [formControl]="topHeightCtrl">
<button md-button (click)="showSelect=!showSelect">SHOW SELECT</button>
<div [style.height.px]="topHeightCtrl.value"></div>
<div class="demo-select">
<md-card>
<md-card-subtitle>ngModel</md-card-subtitle>
@@ -63,6 +65,21 @@
</md-card-content>
</md-card>
<md-card>
<md-card-subtitle>Option groups</md-card-subtitle>
<md-card-content>
<md-select placeholder="Pokemon" [(ngModel)]="currentPokemonFromGroup">
<md-optgroup *ngFor="let group of pokemonGroups" [label]="group.name"
[disabled]="group.disabled">
<md-option *ngFor="let creature of group.pokemon" [value]="creature.value">
{{ creature.viewValue }}
</md-option>
</md-optgroup>
</md-select>
</md-card-content>
</md-card>
<div *ngIf="showSelect">
<md-card>
<md-card-subtitle>formControl</md-card-subtitle>
+1 -1
View File
@@ -7,4 +7,4 @@
margin: 24px;
}
}
}
+37
View File
@@ -16,9 +16,11 @@ export class SelectDemo {
showSelect = false;
currentDrink: string;
currentPokemon: string[];
currentPokemonFromGroup: string;
latestChangeEvent: MdSelectChange;
floatPlaceholder: string = 'auto';
foodControl = new FormControl('pizza-1');
topHeightCtrl = new FormControl(0);
drinksTheme = 'primary';
pokemonTheme = 'primary';
@@ -57,6 +59,41 @@ export class SelectDemo {
{value: 'warn', name: 'Warn' }
];
pokemonGroups = [
{
name: 'Grass',
pokemon: [
{ value: 'bulbasaur-0', viewValue: 'Bulbasaur' },
{ value: 'oddish-1', viewValue: 'Oddish' },
{ value: 'bellsprout-2', viewValue: 'Bellsprout' }
]
},
{
name: 'Water',
pokemon: [
{ value: 'squirtle-3', viewValue: 'Squirtle' },
{ value: 'psyduck-4', viewValue: 'Psyduck' },
{ value: 'horsea-5', viewValue: 'Horsea' }
]
},
{
name: 'Fire',
disabled: true,
pokemon: [
{ value: 'charmander-6', viewValue: 'Charmander' },
{ value: 'vulpix-7', viewValue: 'Vulpix' },
{ value: 'flareon-8', viewValue: 'Flareon' }
]
},
{
name: 'Psychic',
pokemon: [
{ value: 'mew-9', viewValue: 'Mew' },
{ value: 'mewtwo-10', viewValue: 'Mewtwo' },
]
}
];
toggleDisabled() {
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
}
+4
View File
@@ -5,6 +5,8 @@
@import 'ripple/ripple';
@import 'option/option';
@import 'option/option-theme';
@import 'option/optgroup';
@import 'option/optgroup-theme';
@import 'selection/pseudo-checkbox/pseudo-checkbox-theme';
@import 'typography/all-typography';
@@ -22,6 +24,7 @@
@include angular-material-typography();
@include mat-ripple();
@include mat-option();
@include mat-optgroup();
@include cdk-a11y();
@include cdk-overlay();
}
@@ -30,6 +33,7 @@
@mixin mat-core-theme($theme) {
@include mat-ripple-theme($theme);
@include mat-option-theme($theme);
@include mat-optgroup-theme($theme);
@include mat-pseudo-checkbox-theme($theme);
// Wrapper element that provides the theme background when the
+2 -2
View File
@@ -2,7 +2,7 @@ import {NgModule} from '@angular/core';
import {MdLineModule} from './line/line';
import {RtlModule} from './rtl/dir';
import {ObserveContentModule} from './observe-content/observe-content';
import {MdOptionModule} from './option/option';
import {MdOptionModule} from './option/index';
import {PortalModule} from './portal/portal-directives';
import {OverlayModule} from './overlay/overlay-directives';
import {A11yModule} from './a11y/index';
@@ -16,7 +16,7 @@ export {Dir, LayoutDirection, RtlModule} from './rtl/dir';
// Mutation Observer
export {ObserveContentModule, ObserveContent} from './observe-content/observe-content';
export {MdOptionModule, MdOption, MdOptionSelectionChange} from './option/option';
export * from './option/index';
// Portals
export {
+14
View File
@@ -0,0 +1,14 @@
@import '../theming/palette';
@import '../theming/theming';
@mixin mat-optgroup-theme($theme) {
$foreground: map-get($theme, foreground);
.mat-optgroup-label {
color: mat-color($foreground, secondary-text);
}
.mat-optgroup-disabled .mat-optgroup-label {
color: mat-color($foreground, hint-text);
}
}
+14
View File
@@ -0,0 +1,14 @@
@import '../style/menu-common';
@import '../style/vendor-prefixes';
@mixin mat-optgroup() {
.mat-optgroup-label {
@include mat-menu-item-base();
@include user-select(none);
cursor: default;
// TODO(crisbeto): should use the typography functions once #4375 is in.
font-weight: bold;
font-size: 14px;
}
}
+10 -1
View File
@@ -17,6 +17,15 @@
@include user-select(none);
cursor: default;
}
.mat-optgroup &:not(.mat-option-multiple) {
padding-left: $mat-menu-side-padding * 2;
[dir='rtl'] & {
padding-left: $mat-menu-side-padding;
padding-right: $mat-menu-side-padding * 2;
}
}
}
.mat-option-ripple {
@@ -31,7 +40,7 @@
// Pointer events can be safely disabled because the ripple trigger element is the host element.
pointer-events: none;
// In high contrast mode this completely covers the text.
// Prevents the ripple from completely covering the option in high contrast mode.
@include cdk-high-contrast {
opacity: 0.5;
}
+18
View File
@@ -0,0 +1,18 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MdRippleModule} from '../ripple/index';
import {MdSelectionModule} from '../selection/index';
import {MdOption} from './option';
import {MdOptgroup} from './optgroup';
@NgModule({
imports: [MdRippleModule, CommonModule, MdSelectionModule],
exports: [MdOption, MdOptgroup],
declarations: [MdOption, MdOptgroup]
})
export class MdOptionModule {}
export * from './option';
export * from './optgroup';
+2
View File
@@ -0,0 +1,2 @@
<label class="mat-optgroup-label" [id]="_labelId">{{ label }}</label>
<ng-content select="md-option, mat-option"></ng-content>
+34
View File
@@ -0,0 +1,34 @@
import {Component, ViewEncapsulation, ContentChildren, QueryList, Input} from '@angular/core';
import {mixinDisabled, CanDisable} from '../common-behaviors/disabled';
// Boilerplate for applying mixins to MdOptgroup.
export class MdOptgroupBase { }
export const _MdOptgroupMixinBase = mixinDisabled(MdOptgroupBase);
// Counter for unique group ids.
let _uniqueOptgroupIdCounter = 0;
/**
* Component that is used to group instances of `md-option`.
*/
@Component({
moduleId: module.id,
selector: 'md-optgroup, mat-optgroup',
templateUrl: 'optgroup.html',
encapsulation: ViewEncapsulation.None,
inputs: ['disabled'],
host: {
'class': 'mat-optgroup',
'role': 'group',
'[class.mat-optgroup-disabled]': 'disabled',
'[attr.aria-disabled]': 'disabled.toString()',
'[attr.aria-labelledby]': '_labelId',
}
})
export class MdOptgroup extends _MdOptgroupMixinBase implements CanDisable {
/** Label for the option group. */
@Input() label: string;
/** Unique id for the underlying label. */
_labelId: string = `mat-optgroup-label-${_uniqueOptgroupIdCounter++}`;
}
+3 -11
View File
@@ -9,12 +9,10 @@ import {
Inject,
Optional,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ENTER, SPACE} from '../keyboard/keycodes';
import {coerceBooleanProperty} from '../coercion/boolean-property';
import {MdRippleModule} from '../ripple/index';
import {MdSelectionModule} from '../selection/index';
import {MATERIAL_COMPATIBILITY_MODE} from '../../core/compatibility/compatibility';
import {MdOptgroup} from './optgroup';
/**
* Option IDs need to be unique across components, so this counter exists outside of
@@ -74,7 +72,7 @@ export class MdOption {
/** Whether the option is disabled. */
@Input()
get disabled() { return this._disabled; }
get disabled() { return (this.group && this.group.disabled) || this._disabled; }
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
/** Event emitted when the option is selected or deselected. */
@@ -82,6 +80,7 @@ export class MdOption {
constructor(
private _element: ElementRef,
@Optional() public readonly group: MdOptgroup,
@Optional() @Inject(MATERIAL_COMPATIBILITY_MODE) public _isCompatibilityMode: boolean) {}
/**
@@ -172,10 +171,3 @@ export class MdOption {
}
}
@NgModule({
imports: [MdRippleModule, CommonModule, MdSelectionModule],
exports: [MdOption],
declarations: [MdOption]
})
export class MdOptionModule {}
+1 -2
View File
@@ -1,8 +1,7 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MdSelect} from './select';
import {MdOptionModule} from '../core/option/option';
import {MdCommonModule, OverlayModule} from '../core';
import {MdCommonModule, OverlayModule, MdOptionModule} from '../core';
@NgModule({
+215 -4
View File
@@ -61,7 +61,8 @@ describe('MdSelect', () => {
BasicSelectNoPlaceholder,
BasicSelectWithTheming,
ResetValuesSelect,
FalsyValueSelect
FalsyValueSelect,
SelectWithGroups
],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -461,9 +462,28 @@ describe('MdSelect', () => {
expect(fixture.componentInstance.select.panelOpen).toBe(true);
expect(options[2].classList).not.toContain('mat-selected');
expect(fixture.componentInstance.select.selected).not.toBeDefined();
expect(fixture.componentInstance.select.selected).toBeUndefined();
});
it('should not select options inside a disabled group', async(() => {
fixture.destroy();
const groupFixture = TestBed.createComponent(SelectWithGroups);
groupFixture.detectChanges();
groupFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement.click();
groupFixture.detectChanges();
const disabledGroup = overlayContainerElement.querySelectorAll('md-optgroup')[1];
const options = disabledGroup.querySelectorAll('md-option');
(options[0] as HTMLElement).click();
groupFixture.detectChanges();
expect(groupFixture.componentInstance.select.panelOpen).toBe(true);
expect(options[0].classList).not.toContain('mat-selected');
expect(groupFixture.componentInstance.select.selected).toBeUndefined();
}));
});
describe('forms integration', () => {
@@ -828,8 +848,11 @@ describe('MdSelect', () => {
/**
* Asserts that the given option is aligned with the trigger.
* @param index The index of the option.
* @param selectInstance Instance of the `md-select` component to check against.
*/
function checkTriggerAlignedWithOption(index: number): void {
function checkTriggerAlignedWithOption(index: number, selectInstance =
fixture.componentInstance.select): void {
const overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane');
const triggerTop = trigger.getBoundingClientRect().top;
const overlayTop = overlayPane.getBoundingClientRect().top;
@@ -844,7 +867,7 @@ describe('MdSelect', () => {
// For the animation to start at the option's center, its origin must be the distance
// from the top of the overlay to the option top + half the option height (48/2 = 24).
const expectedOrigin = Math.floor(optionTop - overlayTop + 24);
const rawYOrigin = fixture.componentInstance.select._transformOrigin.split(' ')[1].trim();
const rawYOrigin = selectInstance._transformOrigin.split(' ')[1].trim();
const origin = Math.floor(parseInt(rawYOrigin));
expect(origin).toBe(expectedOrigin,
@@ -929,6 +952,37 @@ describe('MdSelect', () => {
checkTriggerAlignedWithOption(7);
});
it('should account for preceding label groups when aligning the option', () => {
fixture.destroy();
let groupFixture = TestBed.createComponent(SelectWithGroups);
groupFixture.detectChanges();
trigger = groupFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
select = groupFixture.debugElement.query(By.css('md-select')).nativeElement;
select.style.position = 'fixed';
select.style.top = '200px';
select.style.left = '100px';
// Select an option in the third group, which has a couple of group labels before it.
groupFixture.componentInstance.control.setValue('vulpix-7');
groupFixture.detectChanges();
trigger.click();
groupFixture.detectChanges();
const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
// The selected option should be scrolled to the center of the panel.
// This will be its original offset from the scrollTop - half the panel height + half the
// option height. 10 (option index + 3 group labels before it) * 48 (option height) = 480px.
// 480 (offset from scrollTop) - 256/2 + 48/2 = 376px
expect(Math.floor(scrollContainer.scrollTop))
.toBe(376, `Expected overlay panel to be scrolled to center the selected option.`);
checkTriggerAlignedWithOption(7, groupFixture.componentInstance.select);
});
});
describe('limited space to open vertically', () => {
@@ -1338,6 +1392,72 @@ describe('MdSelect', () => {
}));
});
describe('x-axis positioning with groups', () => {
let groupFixture: ComponentFixture<SelectWithGroups>;
beforeEach(() => {
groupFixture = TestBed.createComponent(SelectWithGroups);
groupFixture.detectChanges();
trigger = groupFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
select = groupFixture.debugElement.query(By.css('md-select')).nativeElement;
select.style.position = 'fixed';
select.style.left = '60px';
});
it('should adjust for the group padding in ltr', fakeAsync(() => {
groupFixture.componentInstance.control.setValue('oddish-1');
groupFixture.detectChanges();
trigger.click();
groupFixture.detectChanges();
const group = document.querySelector('.cdk-overlay-pane md-optgroup');
const triggerLeft = trigger.getBoundingClientRect().left;
const selectedOptionLeft = group.querySelector('md-option.mat-selected')
.getBoundingClientRect().left;
// 32px is the 16px default padding plus 16px of padding when an option is in a group.
expect(Math.floor(selectedOptionLeft)).toEqual(Math.floor(triggerLeft - 32),
`Expected trigger label to align along x-axis, accounting for the padding in ltr.`);
}));
it('should adjust for the group padding in rtl', fakeAsync(() => {
dir.value = 'rtl';
groupFixture.componentInstance.control.setValue('oddish-1');
groupFixture.detectChanges();
trigger.click();
groupFixture.detectChanges();
const group = document.querySelector('.cdk-overlay-pane md-optgroup');
const triggerRight = trigger.getBoundingClientRect().right;
const selectedOptionRight = group.querySelector('md-option.mat-selected')
.getBoundingClientRect().right;
// 32px is the 16px default padding plus 16px of padding when an option is in a group.
expect(Math.floor(selectedOptionRight)).toEqual(Math.floor(triggerRight + 32),
`Expected trigger label to align along x-axis, accounting for the padding in rtl.`);
}));
it('should not adjust if all options are within a group, except the selected one',
fakeAsync(() => {
groupFixture.componentInstance.control.setValue('mime-11');
groupFixture.detectChanges();
trigger.click();
groupFixture.detectChanges();
const selected = document.querySelector('.cdk-overlay-pane md-option.mat-selected');
const selectedOptionLeft = selected.getBoundingClientRect().left;
const triggerLeft = trigger.getBoundingClientRect().left;
// 16px is the default option padding
expect(Math.floor(selectedOptionLeft)).toEqual(Math.floor(triggerLeft - 16));
}));
});
});
describe('accessibility', () => {
@@ -1617,6 +1737,39 @@ describe('MdSelect', () => {
});
describe('for option groups', () => {
let fixture: ComponentFixture<SelectWithGroups>;
let trigger: HTMLElement;
let groups: NodeListOf<HTMLElement>;
beforeEach(() => {
fixture = TestBed.createComponent(SelectWithGroups);
fixture.detectChanges();
trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
trigger.click();
fixture.detectChanges();
groups = overlayContainerElement.querySelectorAll('md-optgroup') as NodeListOf<HTMLElement>;
});
it('should set the appropriate role', () => {
expect(groups[0].getAttribute('role')).toBe('group');
});
it('should set the `aria-labelledby` attribute', () => {
let group = groups[0];
let label = group.querySelector('label');
expect(label.getAttribute('id')).toBeTruthy('Expected label to have an id.');
expect(group.getAttribute('aria-labelledby'))
.toBe(label.getAttribute('id'), 'Expected `aria-labelledby` to match the label id.');
});
it('should set the `aria-disabled` attribute if the group is disabled', () => {
expect(groups[1].getAttribute('aria-disabled')).toBe('true');
});
});
describe('aria-owns', () => {
let fixture: ComponentFixture<ManySelects>;
let triggers: DebugElement[];
@@ -2578,3 +2731,61 @@ class FalsyValueSelect {
control = new FormControl();
@ViewChildren(MdOption) options: QueryList<MdOption>;
}
@Component({
selector: 'select-with-groups',
template: `
<md-select placeholder="Pokemon" [formControl]="control">
<md-optgroup *ngFor="let group of pokemonTypes" [label]="group.name"
[disabled]="group.disabled">
<md-option *ngFor="let pokemon of group.pokemon" [value]="pokemon.value">
{{ pokemon.viewValue }}
</md-option>
</md-optgroup>
<md-option value="mime-11">Mr. Mime</md-option>
</md-select>
`
})
class SelectWithGroups {
control = new FormControl();
pokemonTypes = [
{
name: 'Grass',
pokemon: [
{ value: 'bulbasaur-0', viewValue: 'Bulbasaur' },
{ value: 'oddish-1', viewValue: 'Oddish' },
{ value: 'bellsprout-2', viewValue: 'Bellsprout' }
]
},
{
name: 'Water',
disabled: true,
pokemon: [
{ value: 'squirtle-3', viewValue: 'Squirtle' },
{ value: 'psyduck-4', viewValue: 'Psyduck' },
{ value: 'horsea-5', viewValue: 'Horsea' }
]
},
{
name: 'Fire',
pokemon: [
{ value: 'charmander-6', viewValue: 'Charmander' },
{ value: 'vulpix-7', viewValue: 'Vulpix' },
{ value: 'flareon-8', viewValue: 'Flareon' }
]
},
{
name: 'Psychic',
pokemon: [
{ value: 'mew-9', viewValue: 'Mew' },
{ value: 'mewtwo-10', viewValue: 'Mewtwo' },
]
}
];
@ViewChild(MdSelect) select: MdSelect;
@ViewChildren(MdOption) options: QueryList<MdOption>;
}
+70 -23
View File
@@ -18,7 +18,7 @@ import {
OnInit,
Inject
} from '@angular/core';
import {MdOption, MdOptionSelectionChange} from '../core/option/option';
import {MdOption, MdOptionSelectionChange, MdOptgroup} from '../core/option/index';
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {Dir} from '../core/rtl/dir';
@@ -49,14 +49,15 @@ from '../core/placeholder/placeholder-options';
* the trigger element.
*/
/** The fixed height of every option element. */
export const SELECT_OPTION_HEIGHT = 48;
/** The fixed height of every option element (option, group header etc.). */
export const SELECT_ITEM_HEIGHT = 48;
/** The max height of the select's overlay panel */
export const SELECT_PANEL_MAX_HEIGHT = 256;
/** The max number of options visible at once in the select panel. */
export const SELECT_MAX_OPTIONS_DISPLAYED = 5;
export const SELECT_MAX_OPTIONS_DISPLAYED =
Math.floor(SELECT_PANEL_MAX_HEIGHT / SELECT_ITEM_HEIGHT);
/** The fixed height of the select's trigger element. */
export const SELECT_TRIGGER_HEIGHT = 30;
@@ -64,13 +65,15 @@ export const SELECT_TRIGGER_HEIGHT = 30;
/**
* Must adjust for the difference in height between the option and the trigger,
* so the text will align on the y axis.
* (SELECT_OPTION_HEIGHT (48) - SELECT_TRIGGER_HEIGHT (30)) / 2 = 9
*/
export const SELECT_OPTION_HEIGHT_ADJUSTMENT = 9;
export const SELECT_OPTION_HEIGHT_ADJUSTMENT = (SELECT_ITEM_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2;
/** The panel's padding on the x-axis */
export const SELECT_PANEL_PADDING_X = 16;
/** The panel's x axis padding if it is indented (e.g. there is an option group). */
export const SELECT_PANEL_INDENT_PADDING_X = SELECT_PANEL_PADDING_X * 2;
/**
* Distance between the panel edge and the option text in
* multi-selection mode.
@@ -239,7 +242,10 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
@ViewChild(ConnectedOverlayDirective) overlayDir: ConnectedOverlayDirective;
/** All of the defined select options. */
@ContentChildren(MdOption) options: QueryList<MdOption>;
@ContentChildren(MdOption, { descendants: true }) options: QueryList<MdOption>;
/** All of the defined groups of options. */
@ContentChildren(MdOptgroup) optionGroups: QueryList<MdOptgroup>;
/** Classes to be passed to the select panel. Supports the same syntax as `ngClass`. */
@Input() panelClass: string|string[]|Set<string>|{[key: string]: any};
@@ -763,26 +769,29 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
private _calculateOverlayPosition(): void {
const panelHeight =
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
const items = this._getItemCount();
const panelHeight = Math.min(items * SELECT_ITEM_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
const scrollContainerHeight = items * SELECT_ITEM_HEIGHT;
// The farthest the panel can be scrolled before it hits the bottom
const maxScroll = scrollContainerHeight - panelHeight;
if (this._selectionModel.hasValue()) {
const selectedIndex = this._getOptionIndex(this._selectionModel.selected[0]);
let selectedOptionOffset = this._getOptionIndex(this._selectionModel.selected[0]);
selectedOptionOffset += this._getLabelCountBeforeOption(selectedOptionOffset);
// We must maintain a scroll buffer so the selected option will be scrolled to the
// center of the overlay panel rather than the top.
const scrollBuffer = panelHeight / 2;
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
this._offsetY = this._calculateOverlayOffsetY(selectedIndex, scrollBuffer, maxScroll);
this._scrollTop = this._calculateOverlayScroll(selectedOptionOffset, scrollBuffer, maxScroll);
this._offsetY = this._calculateOverlayOffsetY(selectedOptionOffset, scrollBuffer, maxScroll);
} else {
// If no option is selected, the panel centers on the first option. In this case,
// we must only adjust for the height difference between the option element
// and the trigger element, then multiply it by -1 to ensure the panel moves
// in the correct direction up the page.
this._offsetY = (SELECT_OPTION_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1;
this._offsetY = (SELECT_ITEM_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1;
}
this._checkOverlayWithinViewport(maxScroll);
@@ -797,8 +806,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
*/
_calculateOverlayScroll(selectedIndex: number, scrollBuffer: number,
maxScroll: number): number {
const optionOffsetFromScrollTop = SELECT_OPTION_HEIGHT * selectedIndex;
const halfOptionHeight = SELECT_OPTION_HEIGHT / 2;
const optionOffsetFromScrollTop = SELECT_ITEM_HEIGHT * selectedIndex;
const halfOptionHeight = SELECT_ITEM_HEIGHT / 2;
// Starts at the optionOffsetFromScrollTop, which scrolls the option to the top of the
// scroll container, then subtracts the scroll buffer to scroll the option down to
@@ -851,17 +860,27 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
const isRtl = this._isRtl();
const paddingWidth = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X + SELECT_PANEL_PADDING_X :
SELECT_PANEL_PADDING_X * 2;
let offsetX: number;
let offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
// Adjust the offset, depending on the option padding.
if (this.multiple) {
offsetX = SELECT_MULTIPLE_PANEL_PADDING_X;
} else {
let selected = this._selectionModel.selected[0];
offsetX = selected && selected.group ? SELECT_PANEL_INDENT_PADDING_X : SELECT_PANEL_PADDING_X;
}
// Invert the offset in LTR.
if (!isRtl) {
offsetX *= -1;
}
// Determine how much the select overflows on each side.
const leftOverflow = 0 - (overlayRect.left + offsetX - (isRtl ? paddingWidth : 0));
const rightOverflow = overlayRect.right + offsetX - viewportRect.width
+ (isRtl ? 0 : paddingWidth);
// If the element overflows on either side, reduce the offset to allow it to fit.
if (leftOverflow > 0) {
offsetX += leftOverflow + SELECT_PANEL_VIEWPORT_PADDING;
} else if (rightOverflow > 0) {
@@ -884,9 +903,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
let optionOffsetFromPanelTop: number;
if (this._scrollTop === 0) {
optionOffsetFromPanelTop = selectedIndex * SELECT_OPTION_HEIGHT;
optionOffsetFromPanelTop = selectedIndex * SELECT_ITEM_HEIGHT;
} else if (this._scrollTop === maxScroll) {
const firstDisplayedIndex = this.options.length - SELECT_MAX_OPTIONS_DISPLAYED;
const firstDisplayedIndex = this._getItemCount() - SELECT_MAX_OPTIONS_DISPLAYED;
const selectedDisplayIndex = selectedIndex - firstDisplayedIndex;
// Because the panel height is longer than the height of the options alone,
@@ -894,12 +913,12 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
// scrolled to the very bottom, this padding is at the top of the panel and
// must be added to the offset.
optionOffsetFromPanelTop =
selectedDisplayIndex * SELECT_OPTION_HEIGHT + SELECT_PANEL_PADDING_Y;
selectedDisplayIndex * SELECT_ITEM_HEIGHT + SELECT_PANEL_PADDING_Y;
} else {
// If the option was scrolled to the middle of the panel using a scroll buffer,
// its offset will be the scroll buffer minus the half height that was added to
// center it.
optionOffsetFromPanelTop = scrollBuffer - SELECT_OPTION_HEIGHT / 2;
optionOffsetFromPanelTop = scrollBuffer - SELECT_ITEM_HEIGHT / 2;
}
// The final offset is the option's offset from the top, adjusted for the height
@@ -924,7 +943,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
const panelHeightTop = Math.abs(this._offsetY);
const totalPanelHeight =
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
Math.min(this._getItemCount() * SELECT_ITEM_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
const panelHeightBottom = totalPanelHeight - panelHeightTop - triggerRect.height;
if (panelHeightBottom > bottomSpaceAvailable) {
@@ -981,7 +1000,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Sets the transform origin point based on the selected option. */
private _getOriginBasedOnOption(): string {
const originY =
Math.abs(this._offsetY) - SELECT_OPTION_HEIGHT_ADJUSTMENT + SELECT_OPTION_HEIGHT / 2;
Math.abs(this._offsetY) - SELECT_OPTION_HEIGHT_ADJUSTMENT + SELECT_ITEM_HEIGHT / 2;
return `50% ${originY}px 0px`;
}
@@ -1014,6 +1033,34 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
}
}
/** Calculates the amount of items in the select. This includes options and group labels. */
private _getItemCount(): number {
return this.options.length + this.optionGroups.length;
}
/**
* Calculates the amount of option group labels that precede the specified option.
* Useful when positioning the panel, because the labels will offset the index of the
* currently-selected option.
*/
private _getLabelCountBeforeOption(optionIndex: number): number {
if (this.optionGroups.length) {
let options = this.options.toArray();
let groups = this.optionGroups.toArray();
let groupCounter = 0;
for (let i = 0; i < optionIndex + 1; i++) {
if (options[i].group && options[i].group === groups[groupCounter]) {
groupCounter++;
}
}
return groupCounter;
}
return 0;
}
}
/** Clamps a value n between min and max values. */