mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Andrew Seguin
parent
d0d79fd93a
commit
d09aa896b6
@@ -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>
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
<label class="mat-optgroup-label" [id]="_labelId">{{ label }}</label>
|
||||
<ng-content select="md-option, mat-option"></ng-content>
|
||||
@@ -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++}`;
|
||||
}
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user