diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html
index 6175ceae5ef..fbb317a33f8 100644
--- a/src/demo-app/select/select-demo.html
+++ b/src/demo-app/select/select-demo.html
@@ -1,5 +1,7 @@
-
This div is for testing scrolled selects.
+Space above cards:
ngModel
@@ -63,6 +65,21 @@
+
+ Option groups
+
+
+
+
+
+ {{ creature.viewValue }}
+
+
+
+
+
+
formControl
diff --git a/src/demo-app/select/select-demo.scss b/src/demo-app/select/select-demo.scss
index e7e0649c3a2..8380da8d2be 100644
--- a/src/demo-app/select/select-demo.scss
+++ b/src/demo-app/select/select-demo.scss
@@ -7,4 +7,4 @@
margin: 24px;
}
-}
\ No newline at end of file
+}
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts
index 089fd9a4eb3..6318b2a6272 100644
--- a/src/demo-app/select/select-demo.ts
+++ b/src/demo-app/select/select-demo.ts
@@ -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();
}
diff --git a/src/lib/core/_core.scss b/src/lib/core/_core.scss
index d4a98b1e2c3..cec85a1e74d 100644
--- a/src/lib/core/_core.scss
+++ b/src/lib/core/_core.scss
@@ -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
diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts
index 5a885b1bb95..cce133d1e2d 100644
--- a/src/lib/core/core.ts
+++ b/src/lib/core/core.ts
@@ -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 {
diff --git a/src/lib/core/option/_optgroup-theme.scss b/src/lib/core/option/_optgroup-theme.scss
new file mode 100644
index 00000000000..df2ee44725e
--- /dev/null
+++ b/src/lib/core/option/_optgroup-theme.scss
@@ -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);
+ }
+}
diff --git a/src/lib/core/option/_optgroup.scss b/src/lib/core/option/_optgroup.scss
new file mode 100644
index 00000000000..8203ba4a856
--- /dev/null
+++ b/src/lib/core/option/_optgroup.scss
@@ -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;
+ }
+}
diff --git a/src/lib/core/option/_option.scss b/src/lib/core/option/_option.scss
index fdea4a6037c..f99adae708d 100644
--- a/src/lib/core/option/_option.scss
+++ b/src/lib/core/option/_option.scss
@@ -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;
}
diff --git a/src/lib/core/option/index.ts b/src/lib/core/option/index.ts
new file mode 100644
index 00000000000..d67e0382941
--- /dev/null
+++ b/src/lib/core/option/index.ts
@@ -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';
diff --git a/src/lib/core/option/optgroup.html b/src/lib/core/option/optgroup.html
new file mode 100644
index 00000000000..62e7c987b44
--- /dev/null
+++ b/src/lib/core/option/optgroup.html
@@ -0,0 +1,2 @@
+
+
diff --git a/src/lib/core/option/optgroup.ts b/src/lib/core/option/optgroup.ts
new file mode 100644
index 00000000000..d73b34c1ed5
--- /dev/null
+++ b/src/lib/core/option/optgroup.ts
@@ -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++}`;
+}
diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts
index 286467a3567..3ad394fdc65 100644
--- a/src/lib/core/option/option.ts
+++ b/src/lib/core/option/option.ts
@@ -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 {}
diff --git a/src/lib/select/index.ts b/src/lib/select/index.ts
index 49b7f44f3fd..5fd63dc2b9f 100644
--- a/src/lib/select/index.ts
+++ b/src/lib/select/index.ts
@@ -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({
diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts
index cf6d515a255..e833a67c590 100644
--- a/src/lib/select/select.spec.ts
+++ b/src/lib/select/select.spec.ts
@@ -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;
+
+ 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;
+ let trigger: HTMLElement;
+ let groups: NodeListOf;
+
+ 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;
+ });
+
+ 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;
let triggers: DebugElement[];
@@ -2578,3 +2731,61 @@ class FalsyValueSelect {
control = new FormControl();
@ViewChildren(MdOption) options: QueryList;
}
+
+
+@Component({
+ selector: 'select-with-groups',
+ template: `
+
+
+
+
+ {{ pokemon.viewValue }}
+
+
+
+ Mr. Mime
+
+ `
+})
+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;
+}
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index 7cbef2ad83e..3dee28b89c0 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -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;
+ @ContentChildren(MdOption, { descendants: true }) options: QueryList;
+
+ /** All of the defined groups of options. */
+ @ContentChildren(MdOptgroup) optionGroups: QueryList;
/** Classes to be passed to the select panel. Supports the same syntax as `ngClass`. */
@Input() panelClass: string|string[]|Set|{[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. */