From d09aa896b6cb7b03ad65b85803b160613baba87b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 8 Jun 2017 20:22:46 +0200 Subject: [PATCH] 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 --- src/demo-app/select/select-demo.html | 19 +- src/demo-app/select/select-demo.scss | 2 +- src/demo-app/select/select-demo.ts | 37 ++++ src/lib/core/_core.scss | 4 + src/lib/core/core.ts | 4 +- src/lib/core/option/_optgroup-theme.scss | 14 ++ src/lib/core/option/_optgroup.scss | 14 ++ src/lib/core/option/_option.scss | 11 +- src/lib/core/option/index.ts | 18 ++ src/lib/core/option/optgroup.html | 2 + src/lib/core/option/optgroup.ts | 34 ++++ src/lib/core/option/option.ts | 14 +- src/lib/select/index.ts | 3 +- src/lib/select/select.spec.ts | 219 ++++++++++++++++++++++- src/lib/select/select.ts | 93 +++++++--- 15 files changed, 443 insertions(+), 45 deletions(-) create mode 100644 src/lib/core/option/_optgroup-theme.scss create mode 100644 src/lib/core/option/_optgroup.scss create mode 100644 src/lib/core/option/index.ts create mode 100644 src/lib/core/option/optgroup.html create mode 100644 src/lib/core/option/optgroup.ts 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. */