-
-
-
+
+
+
+
Modal Dialog
+
+ Dialog Body
+
+
+
no mdcDialogTrigger
+
+
+
-
-
+
+
`;
+const tickTime = Math.max(numbers.DIALOG_ANIMATION_CLOSE_TIME_MS, numbers.DIALOG_ANIMATION_OPEN_TIME_MS);
+
describe('MdcDialogDirective', () => {
@Component({
template: templateWithDialog
@@ -37,105 +42,206 @@ describe('MdcDialogDirective', () => {
function setup() {
const fixture = TestBed.configureTestingModule({
- declarations: [...DIALOG_DIRECTIVES, ...FOCUS_TRAP_DIRECTIVES, MdcButtonDirective, TestComponent]
+ declarations: [...DIALOG_DIRECTIVES, ...FOCUS_TRAP_DIRECTIVES, ...BUTTON_DIRECTIVES, TestComponent]
}).createComponent(TestComponent);
fixture.detectChanges();
return { fixture };
}
- it('should only display the dialog when opened', (() => {
+ it('accessibility and structure', fakeAsync(() => {
+ const { fixture } = setup();
+ validateDom(fixture.nativeElement.querySelector('#dialog'));
+ }));
+
+ it('should only display the dialog when opened', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
const dialog = fixture.nativeElement.querySelector('#dialog');
+ const mdcDialog = fixture.debugElement.query(By.directive(MdcDialogDirective)).injector.get(MdcDialogDirective);
const cancel = fixture.nativeElement.querySelector('#cancel');
const accept = fixture.nativeElement.querySelector('#accept');
+ // open/close by button:
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
+ button.click(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
+ cancel.click(); tick(tickTime); flush();
expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
- button.click();
+ button.click(); tick(tickTime); flush();
expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
- cancel.click();
+ accept.click(); tick(tickTime); flush();
expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
- button.click();
+
+ // open/close with function calls:
+ mdcDialog.open(); tick(tickTime); flush();
expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
- accept.click();
+ mdcDialog.close('accept'); tick(tickTime); flush();
expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
}));
- it('should trap focus to the dialog when opened', (() => {
+ it('should trap focus to the dialog when opened', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
const dialog = fixture.nativeElement.querySelector('#dialog');
const accept = fixture.nativeElement.querySelector('#accept');
+
expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
- button.click();
- // focusTrap is activated on animation 'transitionend', so simulate that event
- // (as tick() and friends won't wait for it):
- fixture.nativeElement.querySelector('#surface').dispatchEvent(new TransitionEvent('transitionend', {}));
- // clicks on the button should now be cancelled:
- expect(cancelledClick(button)).toBe(true);
- // clicks on buttons inside the dialog should not be cancelled:
- expect(cancelledClick(accept)).toBe(false);
+ // no sentinels means no focus trapping:
+ expect([...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')].length).toBe(0);
+
+ button.click(); tick(tickTime); flush();
+ // should now have focus trap sentinels:
+ expect([...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')].length).toBe(2);
+
+ accept.click(); tick(tickTime); flush();
+ // focus trap should be cleaned up:
+ expect([...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')].length).toBe(0);
}));
- it('should apply dialog button styling to buttons dynamically added', (() => {
+ it('should initially focus mdcDialogDefault', fakeAsync(() => {
+ const { fixture } = setup();
+ const button = fixture.nativeElement.querySelector('#open');
+ const accept = fixture.nativeElement.querySelector('#accept');
+
+ button.click(); tick(tickTime); flush();
+ expect(document.activeElement).toBe(accept);
+ }));
+
+ it('should apply dialog button styling to buttons dynamically added', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
- const dialog = fixture.nativeElement.querySelector('#dialog');
const testComponent = fixture.debugElement.injector.get(TestComponent);
testComponent.cancelButton = false;
testComponent.acceptButton = false;
fixture.detectChanges();
- button.click();
+ button.click(); tick(tickTime); flush();
expect(fixture.nativeElement.querySelector('#cancel')).toBeNull();
testComponent.cancelButton = true;
testComponent.acceptButton = true;
fixture.detectChanges();
const cancel = fixture.nativeElement.querySelector('#cancel');
- expect(cancel.classList).toContain('mdc-dialog__footer__button');
+ expect(cancel.classList).toContain('mdc-dialog__button');
const accept = fixture.nativeElement.querySelector('#accept');
- expect(accept.classList).toContain('mdc-dialog__footer__button');
- expect(accept.classList).toContain('mdc-dialog__footer__button--accept');
+ expect(accept.classList).toContain('mdc-dialog__button');
+ expect(cancel.classList).toContain('mdc-dialog__button');
}));
- it('should emit the accept event', (() => {
+ it('should emit the accept event', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
const mdcDialog = fixture.debugElement.query(By.directive(MdcDialogDirective)).injector.get(MdcDialogDirective);
const accept = fixture.nativeElement.querySelector('#accept');
- button.click();
+ button.click(); tick(tickTime); flush();
let accepted = false;
mdcDialog.accept.subscribe(() => { accepted = true; });
- accept.click();
+ accept.click(); tick(tickTime); flush();
expect(accepted).toBe(true);
}));
- it('should emit the cancel event', (() => {
+ it('should emit the cancel event', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
const mdcDialog = fixture.debugElement.query(By.directive(MdcDialogDirective)).injector.get(MdcDialogDirective);
const cancel = fixture.nativeElement.querySelector('#cancel');
- button.click();
+ button.click(); tick(tickTime); flush();
let canceled = false;
mdcDialog.cancel.subscribe(() => { canceled = true; });
- cancel.click();
+ cancel.click(); tick(tickTime); flush();
expect(canceled).toBe(true);
}));
- it('should style the body according to the scrollable property', (() => {
+ it('should style the body according to the scrollable property', fakeAsync(() => {
const { fixture } = setup();
const button = fixture.nativeElement.querySelector('#open');
const dialog = fixture.nativeElement.querySelector('#dialog');
const testComponent = fixture.debugElement.injector.get(TestComponent);
- const mdcDialogBody = fixture.debugElement.query(By.directive(MdcDialogBodyDirective)).injector.get(MdcDialogBodyDirective);
-
- button.click();
- booleanAttributeStyleTest(
- fixture,
- testComponent,
- mdcDialogBody,
- 'scrollable',
- 'scrollable',
- 'mdc-dialog__body--scrollable');
+
+ testComponent.scrollable = true;
+ fixture.detectChanges();
+ button.click(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--scrollable')).toBe(true, 'dialog content must be scrollable');
+ }));
+
+ it('button without mdcDialogTrigger should not close the dialog', fakeAsync(() => {
+ const { fixture } = setup();
+ const button = fixture.nativeElement.querySelector('#open');
+ const dialog = fixture.nativeElement.querySelector('#dialog');
+ const noTrigger = fixture.nativeElement.querySelector('#noTrigger');
+
+ button.click(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
+ noTrigger.click(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
}));
+
+ it('enter should trigger mdcDialogDefault', fakeAsync(() => {
+ const { fixture } = setup();
+ const button = fixture.nativeElement.querySelector('#open');
+ const dialog = fixture.nativeElement.querySelector('#dialog');
+ const mdcDialog = fixture.debugElement.query(By.directive(MdcDialogDirective)).injector.get(MdcDialogDirective);
+ const input = fixture.nativeElement.querySelector('#someInput');
+ let accepted = false;
+ mdcDialog.accept.subscribe(() => { accepted = true; });
+
+ button.click(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
+ input.focus();
+ expect(document.activeElement).toBe(input);
+ input.dispatchEvent(newKeydownEvent('Enter')); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
+ expect(accepted).toBe(true);
+ }));
+
+ it('escape should trigger cancel', fakeAsync(() => {
+ const { fixture } = setup();
+ const dialog = fixture.nativeElement.querySelector('#dialog');
+ const mdcDialog = fixture.debugElement.query(By.directive(MdcDialogDirective)).injector.get(MdcDialogDirective);
+ let canceled = false;
+ mdcDialog.cancel.subscribe(() => { canceled = true; });
+
+ mdcDialog.open(); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(true, 'dialog must be in opened state');
+ document.body.dispatchEvent(newKeydownEvent('Escape')); tick(tickTime); flush();
+ expect(dialog.classList.contains('mdc-dialog--open')).toBe(false, 'dialog must be in closed state');
+ expect(canceled).toBe(true);
+ }));
+
+ function validateDom(dialog) {
+ expect(dialog.classList).toContain('mdc-dialog');
+ expect(dialog.children.length).toBe(2);
+ const container = dialog.children[0];
+ const scrim = dialog.children[1];
+ expect(container.classList).toContain('mdc-dialog__container');
+ expect(container.children.length).toBe(1);
+ const surface = container.children[0];
+ expect(surface.classList).toContain('mdc-dialog__surface');
+ expect(surface.getAttribute('role')).toBe('alertdialog');
+ expect(surface.getAttribute('aria-modal')).toBe('true');
+ const labelledBy = surface.getAttribute('aria-labelledby');
+ expect(labelledBy).toMatch(/[a-zA-Z0-9_-]+/);
+ const describedBy = surface.getAttribute('aria-describedby');
+ expect(describedBy).toMatch(/[a-zA-Z0-9_-]+/);
+ expect(surface.children.length).toBe(3);
+ const title = surface.children[0];
+ const content = surface.children[1];
+ const footer: Element = surface.children[2];
+ expect(title.classList).toContain('mdc-dialog__title');
+ expect(title.id).toBe(labelledBy);
+ expect(content.classList).toContain('mdc-dialog__content');
+ expect(content.id).toBe(describedBy);
+ expect(footer.classList).toContain('mdc-dialog__actions');
+ const buttons = [].slice.call(footer.children);
+ for (let button of buttons) {
+ expect(button.classList).toContain('mdc-button');
+ expect(button.classList).toContain('mdc-dialog__button');
+ }
+ expect(scrim.classList).toContain('mdc-dialog__scrim');
+ }
+
+ function newKeydownEvent(key: string) {
+ let event = new KeyboardEvent('keydown', {key});
+ event.initEvent('keydown', true, true);
+ return event;
+ }
});
diff --git a/bundle/src/components/dialog/mdc.dialog.directive.ts b/bundle/src/components/dialog/mdc.dialog.directive.ts
index ba0c2e4..d880215 100644
--- a/bundle/src/components/dialog/mdc.dialog.directive.ts
+++ b/bundle/src/components/dialog/mdc.dialog.directive.ts
@@ -1,167 +1,206 @@
import { AfterContentInit, ContentChild, ContentChildren, Directive, ElementRef, EventEmitter, HostBinding, Input,
- OnDestroy, Optional, Output, QueryList, Renderer2, Self, forwardRef } from '@angular/core';
-import { MDCDialogFoundation } from '@material/dialog';
+ OnDestroy, OnInit, Optional, Output, QueryList, Renderer2, Self, Inject, HostListener, forwardRef } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { MDCDialogFoundation, MDCDialogAdapter, util, cssClasses } from '@material/dialog';
+import { ponyfill } from '@material/dom'
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-import { asBoolean } from '../../utils/value.utils';
import { MdcButtonDirective } from '../button/mdc.button.directive';
import { AbstractMdcFocusInitial, AbstractMdcFocusTrap, FocusTrapHandle } from '../focus-trap/abstract.mdc.focus-trap';
-import { MdcDialogAdapter } from './mdc.dialog.adapter';
-
-// Note: class mdc-dialog__action not used, since it currently doesn't do anything in MCW anyway:
-// the CSS-rules for this in dialog.scss have lower specifity than those of mdc_button itself,
-// so the secondary color never gets applied.
+import { HasId } from '../abstract/mixin.mdc.hasid';
+import { applyMixins } from '../../utils/mixins';
+@Directive()
+class MdcDialogTitleDirectiveBase {}
+interface MdcDialogTitleDirectiveBase extends HasId {}
+applyMixins(MdcDialogTitleDirectiveBase, [HasId]);
/**
- * Directive for the title of a dialog (
mdcDialog
).
- * This should be used for the child of an element with the
mdcDialogHeader
- * directive.
+ * Directive for the title of an `mdcDialog`.
+ * A title is optional. If used, it should be the first child of an `mdcDialogSurface`.
+ * Please note that there should be no whitespace separating the start/end tag and the title
+ * itself. (The easiest way to achieve this is to *not* set the `preserveWhitespaces` option to
+ * `true` the `angularCompilerOptions`).
*/
@Directive({
- selector: '[mdcDialogHeaderTitle]'
+ selector: '[mdcDialogTitle]'
})
-export class MdcDialogHeaderTitleDirective {
- @HostBinding('class.mdc-dialog__header__title') _cls = true;
-}
+export class MdcDialogTitleDirective extends MdcDialogTitleDirectiveBase implements OnInit {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__title') readonly _cls = true;
-/**
- * Directive for the header of a dialog (
mdcDialog
).
- * This should be used on the first child element of an
mdcDialogSurface
- * directive. Add the title of the dialog in a child element with the
- *
mdcDialogHeaderTitle
directive.
- */
-@Directive({
- selector: '[mdcDialogHeader]'
-})
-export class MdcDialogHeaderDirective {
- @HostBinding('class.mdc-dialog__header') _cls = true;
+ ngOnInit() {
+ this.initId();
+ }
}
+@Directive()
+class MdcDialogContentDirectiveBase {}
+interface MdcDialogContentDirectiveBase extends HasId {}
+applyMixins(MdcDialogContentDirectiveBase, [HasId]);
/**
- * Directive for the body part of a dialog (
mdcDialog
).
- * This should be added to a child element of an
mdcDialogSurface
- * directive.
+ * Directive for the content part of an `mdcDialog`.
+ * This should be added as a child element of an `mdcDialogSurface`.
*/
@Directive({
- selector: '[mdcDialogBody]'
+ selector: '[mdcDialogContent]'
})
-export class MdcDialogBodyDirective {
- @HostBinding('class.mdc-dialog__body') _cls = true;
- private _scrollable = false;
+export class MdcDialogContentDirective extends MdcDialogContentDirectiveBase implements OnInit {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__content') readonly _cls = true;
constructor(public _elm: ElementRef) {
+ super();
}
- /**
- * Set this property to true for dialog content that won't be able to fit the screen
- * without scrolling. It will give the body a max-height, and thus (when necessary) will
- * make the content scrollable.
- * The
max-height
value that is applied can be overridden via the
- *
.mdc-dialog__body--scrollable
selector in CSS.
- */
- @HostBinding('class.mdc-dialog__body--scrollable') @Input()
- get scrollable() {
- return this._scrollable;
- }
-
- set scrollable(val: any) {
- this._scrollable = asBoolean(val);
+ ngOnInit() {
+ this.initId();
}
}
/**
- * Directive for footer of a dialog (
mdcDialog
).
- * This should be added to a child element of an
mdcDialogSurface
- * directive.
- * The footer typically contains buttons, for which the
mdcButton
- * directive should be used. Cancel and accept buttons should also be marked
- * with an
mdcDialogCancel
or
mdcDialogAccept
+ * Directive for the actions (footer) of an `mdcDialog`.
+ * This is an (optional) last child of the `mdcDialogSurface` directive.
+ * This directive should contain buttons, for that should use the `mdcButton`
* directive.
+ *
+ * Action buttons should typically close the dialog, and should therefore
+ * also set a value for the `mdcDialogTrigger` directive.
*/
@Directive({
- selector: '[mdcDialogFooter]',
+ selector: '[mdcDialogActions]',
})
-export class MdcDialogFooterDirective implements AfterContentInit {
- @HostBinding('class.mdc-dialog__footer') _cls = true;
- @ContentChildren(MdcButtonDirective, {descendants: true}) _buttons: QueryList
;
+export class MdcDialogActionsDirective implements AfterContentInit {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__actions') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcButtonDirective, {descendants: true}) _buttons?: QueryList;
constructor(private _rndr: Renderer2) {
}
ngAfterContentInit() {
this.initButtons();
- this._buttons.changes.subscribe(() => {
+ this._buttons!.changes.subscribe(() => {
this.initButtons();
});
}
private initButtons() {
- this._buttons.forEach(btn => {
- this._rndr.addClass(btn._elm.nativeElement, 'mdc-dialog__footer__button');
+ this._buttons!.forEach(btn => {
+ this._rndr.addClass(btn._elm.nativeElement, 'mdc-dialog__button');
});
}
}
/**
- * Directive to mark the accept button of a mdcDialog
. This directive should
- * be used in combination with the mdcButton
directive. Accept button presses
- * trigger the accept
event on the dialog.
+ * Any element within a dialog may include this directive (and assigne a non empty value to it)
+ * to indicate that interacting with it should close the dialog with the specified action.
+ *
+ * This action is then reflected via the `action` field in the `closing` and `closed` events of
+ * the dialog. A value of `close` will also trigger the `cancel` event of the dialog, and a value of
+ * `accept` will trigger the `accept` event.
*
- * When the dialog is marked with the mdcFocusTrap
directive, the focus trap will
- * focus this button when activated. If you want to focus another element in the dialog
- * instead, add the mdcFocusInitial
directive to that element.
+ * Any action buttons within the dialog that equate to a dismissal with no further action should
+ * use set `mdcDialogTrigger="close"`. This will make it easy to handle all such interactions consistently
+ * (via either the `cancel`, `closing`, or `closed` events), while separately handling other actions.
*/
@Directive({
- selector: '[mdcDialogAccept]',
- providers: [{provide: AbstractMdcFocusInitial, useExisting: forwardRef(() => MdcDialogAcceptDirective) }]
+ selector: '[mdcDialogTrigger]'
})
-export class MdcDialogAcceptDirective extends AbstractMdcFocusInitial {
- /** @docs-private */ readonly priority = 1;
- @HostBinding('class.mdc-dialog__footer__button--accept') _cls = true;
-
+export class MdcDialogTriggerDirective {
constructor(public _elm: ElementRef) {
- super();
}
+
+ /**
+ * Set the `action` value that should be send to `closing` and `closed` events when a user
+ * interacts with this element. (When set to an empty string the button/element will not be wired
+ * to close the dialog).
+ */
+ @Input() mdcDialogTrigger: string | null = null;
}
/**
- * Directive to mark the cancel button of a mdcDialog
. This directive should
- * be used in combination with the mdcButton
directive. Cancel button presses
- * trigger the cancel
event on the dialog.
+ * This directive can be used to mark one of the dialogs action buttons as the default action.
+ * This action will then be triggered by pressing the enter key while the dialog has focus.
+ * The default action also will receive focus when the dialog is opened. Unless another
+ * element within the dialog has the `mdcFocusInitial` directive.
*/
@Directive({
- selector: '[mdcDialogCancel]'
+ selector: '[mdcDialogDefault]',
+ providers: [{provide: AbstractMdcFocusInitial, useExisting: forwardRef(() => MdcDialogDefaultDirective) }]
})
-export class MdcDialogCancelDirective {
- @HostBinding('class.mdc-dialog__footer__button--cancel') _cls = true;
+export class MdcDialogDefaultDirective extends AbstractMdcFocusInitial {
+ /** @internal */ readonly priority = 0; // must be lower than prio of MdcFocusInitialDirective
constructor(public _elm: ElementRef) {
+ super();
}
}
/**
- * Directive for the backdrop of a dialog. The backdrop provides the styles for overlaying the
- * page content when the dialog is opened. This guides user attention to the dialog.
+ * Directive for the surface of a dialog. The surface contains the actual content of a dialog,
+ * wrapped in elements with the `mdcDialogHeader`, `mdcDialogContent`, and `mdcDialogActions`
+ * directives.
+ *
+ * # Accessibility
+ * * The role attribute will be set to `alertdialog` by default
+ * * The `aria-modal` attribute will be set to `true` by default
+ * * If there is an `mdcDialogTitle`, the `aria-labelledBy` attribute will be set to the id
+ * of that element (and a unique id will be assigned to it, if none was provided)
+ * * If there is an `mdcDialogContent`, the `aria-describedby` attribute will be set to the
+ * id of that element (and a unique id will be assigned to it, if none was provided)
*/
@Directive({
- selector: '[mdcDialogBackdrop]'
+ selector: '[mdcDialogSurface]'
})
-export class MdcDialogBackdropDirective {
- @HostBinding('class.mdc-dialog__backdrop') _cls = true;
+export class MdcDialogSurfaceDirective {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__surface') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') _role = 'alertdialog';
+ /** @internal */
+ @HostBinding('attr.aria-modal') _modal = 'true';
+ /** @internal */
+ @HostBinding('attr.aria-labelledby') _labelledBy: string | null = null;
+ /** @internal */
+ @HostBinding('attr.aria-describedby') _describedBy: string | null = null;
+ /** @internal */
+ @ContentChildren(MdcDialogTitleDirective) _titles?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcDialogContentDirective) _contents?: QueryList;
+
+ ngAfterContentInit() {
+ this._titles!.changes.subscribe(() => this.setAriaLabels());
+ this._contents!.changes.subscribe(() => this.setAriaLabels());
+ this.setAriaLabels();
+ }
+
+ private setAriaLabels() {
+ this._labelledBy = this._titles!.first?.id;
+ this._describedBy = this._contents!.first?.id;
+ }
}
/**
- * Directive for the surface of a dialog. The surface contains the actual comtent of a dialog,
- * wrapped in elements with the mdcDialogHeader
, mdcDialogBody
,
- * and mdcDialogFooter
directives.
+ * This directive should be the first child of an `mdcDialog`, and contains the `mdcDialogSurface`.
*/
@Directive({
- selector: '[mdcDialogSurface]'
+ selector: '[mdcDialogContainer]'
})
-export class MdcDialogSurfaceDirective {
- @HostBinding('class.mdc-dialog__surface') _cls = true;
+export class MdcDialogContainerDirective {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__container') readonly _cls = true;
+}
- constructor(public _elm: ElementRef) {
- }
+/**
+ * Directive for the backdrop of a dialog. The backdrop provides the styles for overlaying the
+ * page content when the dialog is opened. This guides user attention to the dialog.
+ */
+@Directive({
+ selector: '[mdcDialogScrim]'
+})
+export class MdcDialogScrimDirective {
+ /** @internal */
+ @HostBinding('class.mdc-dialog__scrim') readonly _cls = true;
}
/**
@@ -169,93 +208,141 @@ export class MdcDialogSurfaceDirective {
* child elements: a surface (marked with the mdcDialogSurface
directive), and a
* backdrop (marked with the mdcDialogBackdrop
directive).
*
- * For an accessible user experience, the surface behind the dialog should not be accessible.
- * This can be achieved by adding the mdcFocusTrap
directive to the dialog element
- * as well.
+ * When the dialog is opened, it will activate a focus trap on the elements within the dialog,
+ * so that the surface behind the dialog is not accessible. See `mdcFocusTrap` for more information.
*/
@Directive({
selector: '[mdcDialog]',
exportAs: 'mdcDialog'
})
export class MdcDialogDirective implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-dialog') _cls = true;
- @ContentChild(MdcDialogSurfaceDirective) _surface: MdcDialogSurfaceDirective;
- @ContentChildren(MdcDialogAcceptDirective, {descendants: true}) _accept: QueryList;
- @ContentChildren(MdcDialogCancelDirective, {descendants: true}) _cancel: QueryList;
+ /** @internal */
+ @HostBinding('class.mdc-dialog') readonly _cls = true;
+ /** @internal */
+ @ContentChild(MdcDialogSurfaceDirective) _surface: MdcDialogSurfaceDirective | null = null;
+ /** @internal */
+ @ContentChildren(MdcDialogTriggerDirective, {descendants: true}) _triggers?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcDialogContentDirective, {descendants: true}) _contents?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcDialogActionsDirective, {descendants: true}) _footers?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcDialogDefaultDirective, {descendants: true}) _defaultActions?: QueryList;
+ /**
+ * Event emitted when the user accepts the dialog, e.g. by pressing enter or clicking the button
+ * with `mdcDialogTrigger="accept"`.
+ */
+ @Output() readonly accept: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the user cancels the dialog, e.g. by clicking outside the dialog, pressing the escape key,
+ * or clicking an element with `mdcDialogTrigger="close"`.
+ */
+ @Output() readonly cancel: EventEmitter = new EventEmitter();
/**
- * Event emitted when the user accepts the dialog, e.g. by clicking the accept (mdcDialogAccept
)
- * button.
+ * Event emitted when the dialog starts opening.
*/
- @Output() accept: EventEmitter = new EventEmitter();
+ @Output() readonly opening: EventEmitter = new EventEmitter();
/**
- * Event emitted when the user cancels the dialog, e.g. by clicking the cancel (mdcDialogCancel
)
- * button, or by pressing the Escape key (for dialogs with a focus-trap that is configured to close on Escape)
+ * Event emitted when the dialog is opened.
*/
- @Output() cancel: EventEmitter = new EventEmitter();
- private _initialized = false;
- private focusTrapHandle: FocusTrapHandle;
- private mdcAdapter: MdcDialogAdapter = {
+ @Output() readonly opened: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the dialog starts closing. The 'action' field contains the reason for closing, see
+ * `mdcDialogTrigger` for more information.
+ */
+ @Output() readonly closing: EventEmitter<{action: string}> = new EventEmitter();
+ /**
+ * Event emitted when the dialog is closed. The 'action' field contains the reason for closing, see
+ * `mdcDialogTrigger` for more information.
+ */
+ @Output() readonly closed: EventEmitter<{action: string}> = new EventEmitter();
+ private _onDocumentKeydown = (event: KeyboardEvent) => this.onDocumentKeydown(event);
+ private focusTrapHandle: FocusTrapHandle | null = null;
+ private mdcAdapter: MDCDialogAdapter = {
addClass: (className: string) => this._rndr.addClass(this._elm.nativeElement, className),
removeClass: (className: string) => this._rndr.removeClass(this._elm.nativeElement, className),
- addBodyClass: (className: string) => this._rndr.addClass(document.body, className),
- removeBodyClass: (className: string) => this._rndr.removeClass(document.body, className),
- eventTargetHasClass: (target: HTMLElement, className: string) => {
- if (className === 'mdc-dialog__footer__button--accept')
- return this._accept.find((e) => e._elm.nativeElement === target) != null;
- else if (className === 'mdc-dialog__footer__button--cancel')
- return this._cancel.find((e) => e._elm.nativeElement === target) != null;
- return target.classList.contains(className);
+ addBodyClass: (className: string) => this._rndr.addClass(this.document.body, className),
+ removeBodyClass: (className: string) => this._rndr.removeClass(this.document.body, className),
+ areButtonsStacked: () => this._footers?.first ? util.areTopsMisaligned(this._footers.first?._buttons!.map(b => b._elm.nativeElement)) : false,
+ clickDefaultButton: () => this._defaultActions?.first?._elm.nativeElement.click(),
+ eventTargetMatches: (target, selector) => target ? ponyfill.matches(target as Element, selector) : false,
+ getActionFromEvent: (evt: Event) => {
+ const action = this.closest(evt.target as Element, this._triggers!.toArray());
+ return action?.mdcDialogTrigger || null;
},
- registerInteractionHandler: (evt: string, handler: EventListener) => this._registry.listen(this._rndr, evt, handler, this._elm),
- deregisterInteractionHandler: (evt: string, handler: EventListener) => this._registry.unlisten(evt, handler),
- registerSurfaceInteractionHandler: (evt: string, handler: EventListener) => {
- if (this._surface)
- this._registry.listen(this._rndr, evt, handler, this._surface._elm);
-
+ getInitialFocusEl: () => null, // ignored in our implementation. mdcFocusTrap determines this by itself
+ hasClass: (className) => {
+ if (className === cssClasses.STACKED)
+ return false; // currently not supporting (auto-)stacking of buttons
+ return this._elm.nativeElement.classList.contains(className);
},
- deregisterSurfaceInteractionHandler: (evt: string, handler: EventListener) => this._registry.unlisten(evt, handler),
- registerDocumentKeydownHandler: (handler: EventListener) => this._registry.listenElm(this._rndr, 'keydown', handler, document.body),
- deregisterDocumentKeydownHandler: (handler: EventListener) => this._registry.unlisten('keydown', handler),
- registerTransitionEndHandler: (handler: EventListener) => {
- if (this._surface)
- this._registry.listen(this._rndr, 'transitionend', handler, this._surface._elm);
+ isContentScrollable: () => util.isScrollable(this._content?._elm.nativeElement),
+ notifyClosed: (action) => {
+ this.closed.emit({action});
},
- deregisterTransitionEndHandler: (handler: EventListener) => this._registry.unlisten('transitionend', handler),
- notifyAccept: () => this.accept.emit(),
- notifyCancel: () => this.cancel.emit(),
- trapFocusOnSurface: () => this.trapFocus(),
- untrapFocusOnSurface: () => this.untrapFocus(),
- isDialog: (el: Element) => this._surface && el === this._surface._elm.nativeElement
+ notifyClosing: (action) => {
+ this.document.removeEventListener('keydown', this._onDocumentKeydown);
+ this.closing.emit({action});
+ if (action === 'accept')
+ this.accept.emit();
+ else if (action === 'close')
+ this.cancel.emit();
+ },
+ notifyOpened: () => {
+ this.opened.emit();
+ },
+ notifyOpening: () => {
+ this.document.addEventListener('keydown', this._onDocumentKeydown);
+ this.opening.emit();
+ },
+ releaseFocus: () => this.untrapFocus(),
+ // we're currently not supporting auto-stacking, cause we can't just reverse buttons in the dom
+ // and expect that to not break stuff in angular:
+ reverseButtons: () => undefined,
+ trapFocus: () => this.trapFocus()
};
- private foundation: {
- init: Function,
- destroy: Function,
- open: Function,
- close: Function,
- isOpen: () => boolean,
- accept(shouldNotify: boolean),
- cancel(shouldNotify: boolean)
- } = new MDCDialogFoundation(this.mdcAdapter);
+ private foundation: MDCDialogFoundation | null = null;
+ private document: Document;
constructor(private _elm: ElementRef, private _rndr: Renderer2, private _registry: MdcEventRegistry,
- @Optional() @Self() private _focusTrap: AbstractMdcFocusTrap) {
+ @Optional() @Self() private _focusTrap: AbstractMdcFocusTrap,
+ @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
}
ngAfterContentInit() {
+ this.foundation = new MDCDialogFoundation(this.mdcAdapter);
this.foundation.init();
- this._initialized = true;
+ this.foundation.setAutoStackButtons(false); // currently not supported
}
ngOnDestroy() {
- this._initialized = false;
- this.foundation.destroy();
+ this.document.removeEventListener('keydown', this._onDocumentKeydown);
+ this.foundation?.destroy();
+ this.foundation = null;
}
/**
* Call this method to open the dialog.
*/
open() {
- this.foundation.open();
+ this.foundation!.open();
+ }
+
+ /**
+ * Call this method to close the dialog with the specified action, e.g. `accept` to indicate an acceptance action
+ * (and trigger the `accept` event), or `close` to indicate dismissal (and trigger the `cancel` event).
+ */
+ close(action = 'close') {
+ this.foundation!.close(action);
+ }
+
+ /**
+ * Recalculates layout and automatically adds/removes modifier classes (for instance to detect if the dialog content
+ * should be scrollable)
+ */
+ layout() {
+ this.foundation!.layout();
}
private trapFocus() {
@@ -270,9 +357,40 @@ export class MdcDialogDirective implements AfterContentInit, OnDestroy {
this.focusTrapHandle = null;
}
}
+
+ /** @internal */
+ @HostListener('click', ['$event']) onClick(event: MouseEvent) {
+ this.foundation?.handleClick(event);
+ }
+
+ /** @internal */
+ @HostListener('keydown', ['$event']) onKeydown(event: KeyboardEvent) {
+ this.foundation?.handleKeydown(event);
+ }
+
+ /** @internal */
+ onDocumentKeydown(event: KeyboardEvent) {
+ this.foundation?.handleDocumentKeydown(event);
+ }
+
+ private get _content() {
+ return this._contents!.first;
+ }
+
+ private closest(elm: Element, choices: MdcDialogTriggerDirective[]) {
+ let match: Element | null = elm;
+ while (match && match !== this._elm.nativeElement) {
+ for (let i = 0; i != choices.length; ++i) {
+ if (choices[i]._elm.nativeElement === match)
+ return choices[i];
+ }
+ match = match.parentElement;
+ }
+ return null;
+ }
}
export const DIALOG_DIRECTIVES = [
- MdcDialogDirective, MdcDialogHeaderTitleDirective, MdcDialogHeaderDirective, MdcDialogBodyDirective,
- MdcDialogFooterDirective, MdcDialogAcceptDirective, MdcDialogCancelDirective, MdcDialogBackdropDirective, MdcDialogSurfaceDirective,
+ MdcDialogDirective, MdcDialogTitleDirective, MdcDialogContentDirective, MdcDialogSurfaceDirective, MdcDialogContainerDirective,
+ MdcDialogActionsDirective, MdcDialogTriggerDirective, MdcDialogDefaultDirective, MdcDialogScrimDirective
];
diff --git a/bundle/src/components/drawer/mdc.drawer.directive.spec.ts b/bundle/src/components/drawer/mdc.drawer.directive.spec.ts
new file mode 100644
index 0000000..62dde48
--- /dev/null
+++ b/bundle/src/components/drawer/mdc.drawer.directive.spec.ts
@@ -0,0 +1,285 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { FOCUS_TRAP_DIRECTIVES } from '../focus-trap/mdc.focus-trap.directive';
+import { DRAWER_DIRECTIVES } from './mdc.drawer.directive';
+import { LIST_DIRECTIVES } from '../list/mdc.list.directive';
+import { simulateKey } from '../../testutils/page.test';
+
+const templateWithDrawer = `
+
+
+app content
+`;
+
+describe('MdcDrawerDirective', () => {
+ @Component({
+ template: templateWithDrawer
+ })
+ class TestComponent {
+ notifications = [];
+ open = false;
+ type: 'permanent' | 'dismissible' | 'modal' = 'permanent';
+ items = [
+ {icon: 'inbox', text: 'Inbox'},
+ {icon: 'send', text: 'Outgoing'},
+ {icon: 'drafts', text: 'Drafts'},
+ ];
+ notify(name: string, value: boolean) {
+ let notification = {};
+ notification[name] = value;
+ this.notifications.push(notification);
+ }
+ }
+
+ function setup(type: 'permanent' | 'dismissible' | 'modal', open = false) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...DRAWER_DIRECTIVES, ...FOCUS_TRAP_DIRECTIVES, ...LIST_DIRECTIVES, TestComponent]
+ }).createComponent(TestComponent);
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ testComponent.type = type;
+ testComponent.open = open;
+ fixture.detectChanges();
+ const drawer: HTMLElement = fixture.nativeElement.querySelector('#drawer');
+ const appContent: HTMLElement = fixture.nativeElement.querySelector('#appContent');
+ if (open)
+ animationCycle(drawer);
+ //const mdcDrawer = fixture.debugElement.query(By.directive(MdcDrawerDirective)).injector.get(MdcDrawerDirective);
+ return { fixture, testComponent, drawer, appContent };
+ }
+
+ it('dismissible: structure', fakeAsync(() => {
+ const { fixture, testComponent, drawer, appContent } = setup('dismissible');
+ validateDom(drawer, {
+ type: 'dismissible',
+ open: false
+ });
+
+ testComponent.open = true;
+ fixture.detectChanges();
+ animationCycle(drawer);
+ validateDom(drawer, {
+ type: 'dismissible'
+ });
+ expect(appContent.classList).toContain('mdc-drawer-app-content');
+ }));
+
+ it('modal: structure', fakeAsync(() => {
+ const { fixture, testComponent, drawer, appContent } = setup('modal');
+ validateDom(drawer, {
+ type: 'modal',
+ open: false
+ });
+
+ testComponent.open = true;
+ fixture.detectChanges();
+ animationCycle(drawer);
+ validateDom(drawer, {
+ type: 'modal'
+ });
+ expect(appContent.classList).not.toContain('mdc-drawer-app-content');
+ }));
+
+ it('permanent: structure', fakeAsync(() => {
+ const { fixture, drawer, appContent } = setup('permanent');
+ fixture.detectChanges();
+ validateDom(drawer, {
+ type: 'permanent',
+ open: true
+ });
+ expect(appContent.classList).not.toContain('mdc-drawer-app-content');
+ }));
+
+ it('close while opening is handled correctly', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('modal', true);
+ testComponent.open = false;
+ animationCycle(drawer);
+ // the first animationCycle completes the opening transition:
+ validateDom(drawer, {
+ type: 'modal',
+ open: true
+ });
+ fixture.detectChanges();
+ animationCycle(drawer);
+ // the next animationCycle completes the closing transition:
+ validateDom(drawer, {
+ type: 'modal',
+ open: false
+ });
+ }));
+
+ it('modal: should trap focus to the drawer when opened', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('modal', true);
+ // when open: should have focus trap sentinels:
+ expect([...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')].length).toBe(2);
+ testComponent.open = false;
+ fixture.detectChanges();
+ animationCycle(drawer);
+ // focus trap should be cleaned up:
+ expect([...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')].length).toBe(0);
+ }));
+
+ it('modal: clicking scrim closes the modal', fakeAsync(() => {
+ const { fixture, drawer } = setup('modal', true);
+ validateDom(drawer, {type: 'modal', open: true});
+ const scrim = fixture.nativeElement.querySelector('#scrim');
+ scrim.click();
+ animationCycle(drawer);
+ validateDom(drawer, {type: 'modal', open: false});
+ }));
+
+ it('modal: ESCAPE closes the modal', fakeAsync(() => {
+ const { drawer } = setup('modal', true);
+ validateDom(drawer, {type: 'modal', open: true});
+ simulateKey(drawer, 'Escape');
+ animationCycle(drawer);
+ validateDom(drawer, {type: 'modal', open: false});
+ }));
+
+ it('modal: should emit the afterOpened/afterClosed/openChange events', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('modal', false);
+ expect(testComponent.notifications).toEqual([]);
+ testComponent.open = true;
+ fixture.detectChanges(); animationCycle(drawer);
+ expect(testComponent.notifications).toEqual([
+ {open: true},
+ {afterOpened: true}
+ ]);
+ testComponent.notifications = [];
+ testComponent.open = false;
+ fixture.detectChanges(); animationCycle(drawer);
+ expect(testComponent.notifications).toEqual([
+ {open: false},
+ {afterClosed: true}
+ ]);
+ }));
+
+ it('dismissible: should emit the afterOpened/afterClosed/openChange events', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('dismissible', false);
+ expect(testComponent.notifications).toEqual([]);
+ testComponent.open = true;
+ fixture.detectChanges(); animationCycle(drawer);
+ expect(testComponent.notifications).toEqual([
+ {open: true},
+ {afterOpened: true}
+ ]);
+ testComponent.notifications = [];
+ testComponent.open = false;
+ fixture.detectChanges(); animationCycle(drawer);
+ expect(testComponent.notifications).toEqual([
+ {open: false},
+ {afterClosed: true}
+ ]);
+ }));
+
+ it('change type from dismissible to modal when open', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('dismissible', true);
+ validateDom(drawer, {
+ type: 'dismissible'
+ });
+ document.body.focus(); // make sure the drawer is not focused (trapFocus must be called and focus it when changin type)
+ expect(document.activeElement).toBe(document.body);
+ testComponent.type = 'modal';
+ fixture.detectChanges(); animationCycle(drawer);
+ validateDom(drawer, {
+ type: 'modal'
+ });
+ }));
+
+ it('change type from permanent to modal when open is set', fakeAsync(() => {
+ const { fixture, testComponent, drawer } = setup('permanent', true);
+ validateDom(drawer, {
+ type: 'permanent'
+ });
+ document.body.focus(); // make sure the drawer is not focused (trapFocus must be called and focus it when changin type)
+ expect(document.activeElement).toBe(document.body);
+ testComponent.type = 'modal';
+ fixture.detectChanges(); animationCycle(drawer);
+ validateDom(drawer, {
+ type: 'modal',
+ open: true
+ });
+ }));
+
+ function validateDom(drawer, options: Partial<{
+ type: 'permanent' | 'dismissible' | 'modal',
+ open: boolean,
+ list: boolean
+ }> = {}) {
+ options = {...{
+ type: 'permanent',
+ open: true,
+ list: true
+ }, ...options};
+
+ expect(drawer.classList).toContain('mdc-drawer');
+ expect(drawer.classList).not.toContain('mdc-drawer--animate');
+ expect(drawer.classList).not.toContain('mdc-drawer--opening');
+ expect(drawer.classList).not.toContain('mdc-drawer--closing');
+ switch (options.type) {
+ case 'dismissible':
+ expect(drawer.classList).toContain('mdc-drawer--dismissible');
+ expect(drawer.classList).not.toContain('mdc-drawer--modal');
+ break;
+ case 'modal':
+ expect(drawer.classList).toContain('mdc-drawer--modal');
+ expect(drawer.classList).not.toContain('mdc-drawer--dismissible');
+ break;
+ default:
+ expect(drawer.classList).not.toContain('mdc-drawer--modal');
+ expect(drawer.classList).not.toContain('mdc-drawer--dismissible');
+ }
+ if (options.open && options.type !== 'permanent')
+ expect(drawer.classList).toContain('mdc-drawer--open');
+ else
+ expect(drawer.classList).not.toContain('mdc-drawer--open');
+ // when modal and open, there are focus-trap sentinel children:
+ expect(drawer.children.length).toBe(options.open && options.type === 'modal' ? 3 : 1);
+ const content = drawer.children[options.open && options.type === 'modal' ? 1 : 0];
+ expect(content.classList).toContain('mdc-drawer__content');
+ if (options.list) {
+ expect(content.children.length).toBe(1);
+ const list = content.children[0];
+ expect(list.classList).toContain('mdc-list');
+ }
+ if (options.open && options.type === 'modal') {
+ let drawerIsActive = false;
+ let active = document.activeElement;
+ while (active) {
+ if (active === drawer) {
+ drawerIsActive = true;
+ break;
+ }
+ active = active.parentElement;
+ }
+ expect(drawerIsActive).toBeTrue();
+ }
+ }
+
+ function animationCycle(drawer: HTMLElement) {
+ tick(20);
+ if (drawer.classList.contains('mdc-drawer--dismissible') || drawer.classList.contains('mdc-drawer--modal')) {
+ let event = new TransitionEvent('transitionend');
+ event.initEvent('transitionend', true, true);
+ drawer.dispatchEvent(event);
+ }
+ }
+
+ function newKeydownEvent(key: string) {
+ let event = new KeyboardEvent('keydown', {key});
+ event.initEvent('keydown', true, true);
+ return event;
+ }
+});
diff --git a/bundle/src/components/drawer/mdc.drawer.directive.ts b/bundle/src/components/drawer/mdc.drawer.directive.ts
index 5e9ec19..2af018f 100644
--- a/bundle/src/components/drawer/mdc.drawer.directive.ts
+++ b/bundle/src/components/drawer/mdc.drawer.directive.ts
@@ -1,339 +1,358 @@
-import { AfterContentInit, ContentChildren, Directive, ElementRef, EventEmitter, forwardRef,
- HostBinding, Input, OnDestroy, Output, QueryList, Renderer2 } from '@angular/core';
-import { FOCUSABLE_ELEMENTS } from '@material/drawer/slidable';
-import { MDCPersistentDrawerFoundation, MDCTemporaryDrawerFoundation, util } from '@material/drawer';
-import { MdcSlidableDrawerAdapter } from './mdc.slidable.drawer.adapter';
-import { MdcPersistentDrawerAdapter } from './mdc.persistent.drawer.adapter';
-import { MdcTemporaryDrawerAdapter } from './mdc.temporary.drawer.adapter';
-import { AbstractDrawerElement, MdcDrawerType } from '../abstract/abstract.mdc.drawer.element';
-import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-/**
- * A toolbar spacer is an optional first child of an mdcDrawer
.
- * A toolbar spacer adds space to the drawer in the same amount of the space that the toolbar takes up in your application.
- * This is useful for visual alignment and consistency. Note that you can place content inside the toolbar spacer.
- */
-@Directive({
- selector: '[mdcDrawerToolbarSpacer]',
- providers: [{provide: AbstractDrawerElement, useExisting: forwardRef(() => MdcDrawerToolbarSpacerDirective) }]
-})
-export class MdcDrawerToolbarSpacerDirective extends AbstractDrawerElement {
- @HostBinding('class.mdc-drawer__toolbar-spacer') _cls = true;
-
- constructor() {
- super();
- }
-}
-
-/**
- * A toolbar header is an optional first child of an mdcDrawer
.
- * A toolbar header adds space to create a 16:9 drawer header.
- * It's often used for user account selection or profile information.
- *
- * To place content inside a toolbar header, add a child element with the
- * mdcDrawerHeaderContent
directive.
- */
-@Directive({
- selector: '[mdcDrawerHeader]',
- providers: [{provide: AbstractDrawerElement, useExisting: forwardRef(() => MdcDrawerHeaderDirective) }]
-})
-export class MdcDrawerHeaderDirective extends AbstractDrawerElement {
- @HostBinding('class.mdc-drawer__header') _cls = true;
-
- constructor() {
- super();
- }
-}
-
-/**
- * Directive for the content of a drawer header. This should be the child of an
- * mdcDrawerHeader
directive. The content of the header will be bottom
- * aligned.
- */
-@Directive({
- selector: '[mdcDrawerHeaderContent]',
- providers: [{provide: AbstractDrawerElement, useExisting: forwardRef(() => MdcDrawerHeaderContentDirective) }]
-})
-export class MdcDrawerHeaderContentDirective extends AbstractDrawerElement {
- @HostBinding('class.mdc-drawer__header-content') _cls = true;
-
- constructor() {
- super();
- }
-}
-
-/**
- * Directive for the drawer content. You would typically also apply the mdcList
- * or mdcListGroup
directive to the drawer content (see the examples).
- */
-@Directive({
- selector: '[mdcDrawerContent]',
- providers: [{provide: AbstractDrawerElement, useExisting: forwardRef(() => MdcDrawerContentDirective) }]
-})
-export class MdcDrawerContentDirective extends AbstractDrawerElement {
- @HostBinding('class.mdc-drawer__content') _cls = true;
-
- constructor() {
- super();
- }
-}
-
-/**
- * A standalone mdcDrawer
is a permanent drawer. A permanent
- * drawer is always open, sitting to the side of the content. It is appropriate for any
- * display size larger than mobile.
- *
- * To make a drawer that can be opened/closed, wrap the mdcDrawer
inside an
- * mdcDrawerContainer
. That makes the drawer a persistent or
- * temporary drawer. See MdcDrawerContainerDirective
for more information.
- */
-@Directive({
- selector: '[mdcDrawer]',
- providers: [{provide: AbstractDrawerElement, useExisting: forwardRef(() => MdcDrawerDirective) }]
-})
-export class MdcDrawerDirective implements AfterContentInit {
- private initialized = false;
- private type: MdcDrawerType = 'permanent';
- @ContentChildren(AbstractDrawerElement, {descendants: true}) _children: QueryList;
-
- constructor(public _elm: ElementRef) {
- }
-
- ngAfterContentInit() {
- this.initialized = true;
- this.updateTypeForChildren();
- this._children.changes.subscribe(() => {
- this.updateTypeForChildren();
- });
- }
-
- private updateTypeForChildren() {
- if (this.initialized) {
- this._children.forEach(child => {
- child._drawerType = this.type;
- });
- }
- }
-
- _setType(drawerType: MdcDrawerType) {
- this.type = drawerType;
- this.updateTypeForChildren();
- }
-
- @HostBinding('class.mdc-drawer--permanent') get _isPermanent() {
- return this.type === 'permanent';
- }
-
- @HostBinding('class.mdc-drawer__drawer') get _isContainedDrawer() {
- return this.type === 'persistent' || this.type === 'temporary';
- }
-}
-
-/**
- * Wrap an mdcDrawer
inside a mdcDrawerContainer
to make it a
- * persistent or temporary drawer. Persistent and temporary drawers are slideable: they
- * can be opened or closed by the user, or by code.
- */
-@Directive({
- selector: '[mdcDrawerContainer]'
-})
-export class MdcDrawerContainerDirective implements AfterContentInit, OnDestroy {
- private initialized = false;
- private openMem: boolean;
- private mdcAdapter: MdcSlidableDrawerAdapter;
- @ContentChildren(MdcDrawerDirective) _drawers: QueryList;
- /**
- * Event emitted when the drawer is opened or closed. The event value will be
- * true
when the drawer is opened, and false
when the
- * drawer is closed.
- */
- @Output() openChange: EventEmitter = new EventEmitter();
- private type: 'persistent' | 'temporary' = 'persistent';
- private foundation: {
- init(),
- destroy(),
- open(),
- close(),
- isOpen(): boolean
- };
-
- constructor(protected _elm: ElementRef, protected _rndr: Renderer2, protected _registry: MdcEventRegistry) {}
-
- ngAfterContentInit() {
- this.initialized = true;
- this.updateType();
- this.initDrawer();
- this._drawers.changes.subscribe(() => {
- this.updateType();
- });
- }
-
- ngOnDestroy() {
- this.destroyDrawer();
- }
-
- private updateType() {
- if (this.initialized)
- this.forDrawer((d) => {d._setType(this.type); });
- }
-
- private destroyDrawer() {
- if (this.foundation) {
- this.openMem = this.foundation.isOpen();
- this.foundation.destroy();
- this.foundation = null;
- }
- }
-
- private initDrawer() {
- if (this.initialized) {
- this.destroyDrawer();
- if (this.hasNecessaryDom()) {
- this.createAdapter();
- let newFoundation = this.type === 'temporary' ?
- new MDCTemporaryDrawerFoundation(this.mdcAdapter) :
- new MDCPersistentDrawerFoundation(this.mdcAdapter);
- // first init, then assign to this.foundation, so that
- // this.openMem is used to detect the open state, instead
- // of the new foundation (which would never be opened otherwise):
- newFoundation.init();
- this.open = this.openMem;
- this.foundation = newFoundation;
- } else
- console.error('mdcDrawerContainer can\'t be constructed because of missing DOM elements');
- }
- }
-
- private createAdapter() {
- let adapter: MdcPersistentDrawerAdapter | MdcTemporaryDrawerAdapter = {
- addClass: (className) => {
- if ('mdc-drawer--open' !== className) // *--open is tracked by HostBinding
- this._rndr.addClass(this._elm.nativeElement, className);
- },
- removeClass: (className) => {
- if ('mdc-drawer--open' !== className) // *--open is tracked by HostBinding
- this._rndr.removeClass(this._elm.nativeElement, className);
- },
- hasClass: (className) => {
- if ('mdc-drawer--persistent' === className)
- return this.type === 'persistent';
- else if ('mdc-drawer--temporary' === className)
- return this.type === 'temporary';
- else if ('mdc-drawer--open' === className)
- return this.open;
- else
- return this._elm.nativeElement.classList.contains(className);
- },
- hasNecessaryDom: () => this.hasNecessaryDom(),
- registerInteractionHandler: (evt, handler) => this._registry.listen(this._rndr, util.remapEvent(evt), handler, this._elm, util.applyPassive()),
- deregisterInteractionHandler: (evt, handler) => this._registry.unlisten(util.remapEvent(evt), handler),
- registerDrawerInteractionHandler: (evt, handler) => this._registry.listen(this._rndr, util.remapEvent(evt), handler, this.drawer._elm),
- deregisterDrawerInteractionHandler: (evt, handler) => this._registry.unlisten(util.remapEvent(evt), handler),
- registerTransitionEndHandler: (handler) => this._registry.listen(this._rndr, 'transitionend', handler, this._elm),
- deregisterTransitionEndHandler: (handler) => this._registry.unlisten('transitionend', handler),
- registerDocumentKeydownHandler: (handler) => this._registry.listenElm(this._rndr, 'keydown', handler, document),
- deregisterDocumentKeydownHandler: (handler) => this._registry.unlisten('keydown', handler),
- setTranslateX: (value) => this.forDrawer((d) => {
- return d._elm.nativeElement.style.setProperty(util.getTransformPropertyName(),
- value === null ? null : `translateX(${value}px)`);
- }),
- getFocusableElements: () => this.forDrawer((d) => {
- return d._elm.nativeElement.querySelectorAll(FOCUSABLE_ELEMENTS);
- }),
- saveElementTabState: (el) => util.saveElementTabState(el),
- restoreElementTabState: (el) => util.restoreElementTabState(el),
- makeElementUntabbable: (el: HTMLElement) => el.tabIndex = -1,
- notifyOpen: () => this.openChange.emit(true),
- notifyClose: () => this.openChange.emit(false),
- isRtl: () => getComputedStyle(this._elm.nativeElement).getPropertyValue('direction') === 'rtl',
- getDrawerWidth: () => this.forDrawer((d) => d._elm.nativeElement.offsetWidth, 0),
- isDrawer: (el: Element) => (this.drawer && this.drawer._elm.nativeElement === el),
-
- // for the temporary drawer:
- addBodyClass: (className: string) => {this._rndr.addClass(document.body, className); },
- removeBodyClass: (className: string) => {this._rndr.removeClass(document.body, className); },
- updateCssVariable: (value: string) => {
- if (util.supportsCssCustomProperties())
- this._elm.nativeElement.style.setProperty(MDCTemporaryDrawerFoundation.strings.OPACITY_VAR_NAME, value);
- },
- eventTargetHasClass: (target: HTMLElement, className: string) => {
- if (target === this._elm.nativeElement && className === 'mdc-drawer--temporary')
- // make sure this returns true even if class HostBinding is not effectuated yet:
- return this.type === 'temporary';
- return target.classList.contains(className);
- }
- };
- this.mdcAdapter = adapter;
- }
-
- private hasNecessaryDom() {
- return this.drawer != null;
- }
-
- private get drawer(): MdcDrawerDirective {
- if (this._drawers && this._drawers.length > 0)
- return this._drawers.first;
- return null;
- }
-
- private forDrawer(func: (drawer: MdcDrawerDirective) => T, defaultVal: T = null) {
- let theDrawer = this.drawer;
- return theDrawer ? func(theDrawer) : defaultVal;
- }
-
- /**
- * Set the type of drawer. Either persistent
or temporary
.
- * The default (when no value given) is persistent
. Please note that
- * a third type of drawer exists: the permanent
drawer. But a permanent
- * drawer is created by not wrapping your mdcDrawer
in a
- * mdcDrawerContainer
.
- */
- @Input() get mdcDrawerContainer(): 'persistent' | 'temporary' | null {
- return this.type;
- }
-
- set mdcDrawerContainer(value: 'persistent' | 'temporary' | null) {
- if (value !== 'persistent' && value !== 'temporary')
- value = 'persistent';
- if (value !== this.type) {
- this.type = value;
- this.updateType();
- this.initDrawer();
- }
- }
-
- @HostBinding('class.mdc-drawer--persistent') get _isPersistent() {
- return this.type === 'persistent';
- }
-
- @HostBinding('class.mdc-drawer--temporary') get _isTemporary() {
- return this.type === 'temporary';
- }
-
- @HostBinding('class.mdc-drawer--open') get _isOpenCls() {
- return (this.type === 'persistent' || this.type === 'temporary') && this.open;
- }
-
- /**
- * Input to open (assign value true
) or close (assign value false
)
- * the drawer.
- */
- @Input() get open() {
- return this.foundation ? this.foundation.isOpen() : this.openMem;
- }
-
- set open(value: any) {
- let newValue = asBoolean(value);
- if (newValue !== this.open) {
- this.openMem = newValue;
- if (this.foundation) {
- if (newValue)
- this.foundation.open();
- else
- this.foundation.close();
- } else
- this.openChange.emit(newValue);
- }
- }
-}
+import { AfterContentInit, ContentChildren, Directive, ElementRef, EventEmitter, HostBinding,
+ Input, OnDestroy, Output, QueryList, Renderer2, Inject, Optional, Self, HostListener } from '@angular/core';
+import { MDCDismissibleDrawerFoundation, MDCModalDrawerFoundation, MDCDrawerAdapter } from '@material/drawer';
+import { asBoolean } from '../../utils/value.utils';
+import { DOCUMENT } from '@angular/common';
+import { AbstractMdcFocusTrap, FocusTrapHandle } from '../focus-trap/abstract.mdc.focus-trap';
+import { MdcListItemDirective } from '../list/mdc.list.directive';
+
+/**
+ * Directive for the title of a drawer. The use of this directive is optional.
+ * If used, it should be placed as first element inside an `mdcDrawerHeader`
+ */
+@Directive({
+ selector: '[mdcDrawerTitle]'
+})
+export class MdcDrawerTitleDirective {
+ /** @internal */
+ @HostBinding('class.mdc-drawer__title') readonly _cls = true;
+}
+
+/**
+ * Directive for the subtitle of a drawer. The use of this directive is optional.
+ * If used, it should be placed as a sibling element of `mdcDrawerTitle`
+ * inside an `mdcDrawerHeader`
+ */
+@Directive({
+ selector: '[mdcDrawerSubtitle]'
+})
+export class MdcDrawerSubtitleDirective {
+ /** @internal */
+ @HostBinding('class.mdc-drawer__subtitle') readonly _cls = true;
+}
+
+/**
+ * A toolbar header is an optional first child of an `mdcDrawer`.
+ * The header will not scroll with the rest of the drawer content, so is a
+ * good place to place titles and account switchers.
+ *
+ * Directives that are typically used inside an `mdcDrawerHeader`:
+ * `mdcDrawerTitle`, and `mdcDrawerSubTitle`
+ */
+@Directive({
+ selector: '[mdcDrawerHeader]'
+})
+export class MdcDrawerHeaderDirective {
+ /** @internal */
+ @HostBinding('class.mdc-drawer__header') readonly _cls = true;
+}
+
+/**
+ * Directive for the drawer content. You would typically also apply the `mdcList`
+ * or `mdcListGroup` directive to the drawer content (see the examples).
+ */
+@Directive({
+ selector: '[mdcDrawerContent]'
+})
+export class MdcDrawerContentDirective {
+ /** @internal */
+ @HostBinding('class.mdc-drawer__content') readonly _cls = true;
+}
+
+@Directive({
+ selector: '[mdcDrawerScrim]'
+})
+export class MdcDrawerScrimDirective {
+ /** @internal */
+ @HostBinding('class.mdc-drawer-scrim') readonly _cls = true;
+}
+
+/**
+ * Directive for a (navigation) drawer. The following drawer types are
+ * supported:
+ * * `permanent`: the default type if none was specified.
+ * * `dismissible`: the drawer is hidden by default, and can slide into view.
+ * Typically used when navigation is not common, and the main app content is
+ * prioritized.
+ * * `modal`: the drawer is hidden by default. When activated, the drawer is elevated
+ * above the UI of the app. It uses a scrim to block interaction with the rest of
+ * the app with a scrim.
+ *
+ * Drawers may contain an `mdcDrawerHeader`, and should contain an `mdcDrawerContent`
+ * directive.
+ */
+@Directive({
+ selector: '[mdcDrawer]'
+})
+export class MdcDrawerDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-drawer') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcListItemDirective, {descendants: true}) _items?: QueryList;
+ private _onDocumentClick = (event: MouseEvent) => this.onDocumentClick(event);
+ private focusTrapHandle: FocusTrapHandle | null = null;
+ private type: 'permanent' | 'dismissible' | 'modal' = 'permanent';
+ private previousFocus: Element | HTMLOrSVGElement | null = null;
+ private _open: boolean | null = null;
+ private document: Document;
+ private mdcAdapter: MDCDrawerAdapter = {
+ addClass: (className) => this._rndr.addClass(this._elm.nativeElement, className),
+ removeClass: (className) => this._rndr.removeClass(this._elm.nativeElement, className),
+ hasClass: (className) => this._elm.nativeElement.classList.contains(className),
+ elementHasClass: (element, className) => element.classList.contains(className),
+ saveFocus: () => this.previousFocus = this.document.activeElement,
+ restoreFocus: () => {
+ const prev = this.previousFocus as HTMLOrSVGElement | null;
+ if (prev && prev.focus && this._elm.nativeElement.contains(this.document.activeElement))
+ prev.focus();
+ },
+ focusActiveNavigationItem: () => {
+ const active = this._items!.find(item => item.active);
+ active?._elm.nativeElement.focus();
+ },
+ notifyClose: () => {
+ this.fixOpenClose(false);
+ this.afterClosed.emit();
+ this.document.removeEventListener('click', this._onDocumentClick);
+ },
+ notifyOpen: () => {
+ this.fixOpenClose(true);
+ this.afterOpened.emit();
+ if (this.type === 'modal')
+ this.document.addEventListener('click', this._onDocumentClick);
+ },
+ trapFocus: () => this.trapFocus(),
+ releaseFocus: () => this.untrapFocus()
+ };
+ private foundation: MDCDismissibleDrawerFoundation | null = null; // MDCModalDrawerFoundation extends MDCDismissibleDrawerFoundation
+ /**
+ * Event emitted when the drawer is opened or closed. The event value will be
+ * `true` when the drawer is opened, and `false` when the
+ * drawer is closed. (When this event is triggered, the drawer is starting to open/close,
+ * but the animation may not have fully completed yet)
+ */
+ @Output() readonly openChange: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted after the drawer has fully opened. When this event is emitted the full
+ * opening animation has completed, and the drawer is visible.
+ */
+ @Output() readonly afterOpened: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted after the drawer has fully closed. When this event is emitted the full
+ * closing animation has completed, and the drawer is not visible anymore.
+ */
+ @Output() readonly afterClosed: EventEmitter = new EventEmitter();
+
+ constructor(public _elm: ElementRef, protected _rndr: Renderer2, @Inject(DOCUMENT) doc: any,
+ @Optional() @Self() private _focusTrap: AbstractMdcFocusTrap) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
+ }
+
+ ngAfterContentInit() {
+ this.initDrawer();
+ }
+
+ ngOnDestroy() {
+ this.destroyDrawer();
+ }
+
+ private destroyDrawer() {
+ // when foundation is reconstructed and then .open() is called,
+ // if these classes are still available the foundation assumes open was already called,
+ // and it won't do anything:
+ this._rndr.removeClass(this._elm.nativeElement, 'mdc-drawer--animate');
+ this._rndr.removeClass(this._elm.nativeElement, 'mdc-drawer--closing');
+ this._rndr.removeClass(this._elm.nativeElement, 'mdc-drawer--open');
+ this._rndr.removeClass(this._elm.nativeElement, 'mdc-drawer--opening');
+ if (this.foundation) {
+ this.document.removeEventListener('click', this._onDocumentClick);
+ this.foundation.destroy();
+ this.foundation = null;
+ }
+ }
+
+ private initDrawer() {
+ this.destroyDrawer();
+ let newFoundation: MDCDismissibleDrawerFoundation | null = null;
+ const thiz = this;
+ if (this.type === 'dismissible')
+ newFoundation = new class extends MDCDismissibleDrawerFoundation{
+ close() {
+ const emit = thiz._open;
+ thiz._open = false;
+ super.close();
+ emit ? thiz.openChange.emit(thiz._open) : undefined;
+ }
+ open() {
+ const emit = !thiz._open;
+ thiz._open = true;
+ super.open();
+ emit ? thiz.openChange.emit(thiz._open) : undefined;
+ }
+ }(this.mdcAdapter);
+ else if (this.type === 'modal')
+ newFoundation = new class extends MDCModalDrawerFoundation{
+ close() {
+ const emit = thiz._open;
+ thiz._open = false;
+ super.close();
+ emit ? thiz.openChange.emit(thiz._open) : undefined;
+ }
+ open() {
+ const emit = !thiz._open;
+ thiz._open = true;
+ super.open();
+ emit ? thiz.openChange.emit(thiz._open) : undefined;
+ }
+ }(this.mdcAdapter);
+ // else: permanent drawer -> doesn't need a foundation, just styling
+ if (newFoundation) {
+ this.foundation = newFoundation;
+ newFoundation.init();
+ if (this._open)
+ newFoundation.open();
+ }
+ }
+
+ /** @internal */
+ @HostBinding('class.mdc-drawer--modal') get _isModal() {
+ return this.type === 'modal';
+ }
+
+ /** @internal */
+ @HostBinding('class.mdc-drawer--dismissible') get _isDismisible() {
+ return this.type === 'dismissible';
+ }
+
+ /**
+ * Set the type of drawer. Either `permanent`, `dismissible`, or `modal`.
+ * The default type is `permanent`.
+ */
+ @Input() get mdcDrawer(): 'permanent' | 'dismissible' | 'modal' {
+ return this.type;
+ }
+
+ set mdcDrawer(value: 'permanent' | 'dismissible' | 'modal') {
+ if (value !== 'dismissible' && value !== 'modal')
+ value = 'permanent';
+ if (value !== this.type) {
+ this.type = value;
+ this.initDrawer();
+ }
+ }
+
+ static ngAcceptInputType_mdcDrawer: 'permanent' | 'dismissible' | 'modal' | '';
+
+ /**
+ * Input to open (assign value `true`) or close (assign value `false`)
+ * the drawer.
+ */
+ @Input() get open() {
+ return !!this._open;
+ }
+
+ set open(value: boolean) {
+ let newValue = asBoolean(value);
+ if (newValue !== this._open) {
+ if (this.foundation) {
+ newValue ? this.foundation.open() : this.foundation.close();
+ } else {
+ this._open = newValue;
+ this.openChange.emit(newValue);
+ }
+ }
+ }
+
+ static ngAcceptInputType_open: boolean | '';
+
+ private fixOpenClose(open: boolean) {
+ // the foundation ignores calls to open/close while an opening/closing animation is running.
+ // so when the animation ends, we're just going to try again
+ // (needs to be done in the next micro cycle, because otherwise foundation will still think it's
+ // running the opening/closing animation):
+ Promise.resolve().then(() => {
+ if (this._open !== open) {
+ if (this._open)
+ this.foundation!.open();
+ else
+ this.foundation!.close();
+ }
+ });
+ }
+
+ private trapFocus() {
+ this.untrapFocus();
+ if (this._focusTrap)
+ this.focusTrapHandle = this._focusTrap.trapFocus();
+ }
+
+ private untrapFocus() {
+ if (this.focusTrapHandle && this.focusTrapHandle.active) {
+ this.focusTrapHandle.untrap();
+ this.focusTrapHandle = null;
+ }
+ }
+
+ /** @internal */
+ @HostListener('keydown', ['$event']) onKeydown(event: KeyboardEvent) {
+ this.foundation?.handleKeydown(event);
+ }
+
+ /** @internal */
+ @HostListener('transitionend', ['$event']) handleTransitionEnd(event: TransitionEvent) {
+ this.foundation?.handleTransitionEnd(event);
+ }
+
+ /** @internal */
+ onDocumentClick(event: MouseEvent) {
+ if (this.type === 'modal') {
+ // instead of listening to click event on mdcDrawerScrim (which would require wiring between
+ // mdcDrawerScrim and mdcDrawer), we just listen to document clicks.
+ let el: Element | null = event.target as Element;
+ while (el) {
+ if (el === this._elm.nativeElement)
+ return;
+ el = el.parentElement;
+ }
+ (this.foundation as MDCModalDrawerFoundation)?.handleScrimClick();
+ }
+ }
+}
+
+/**
+ * Use this directive for marking the sibling element after a dismissible `mdcDrawer`.
+ * This will apply styling so that the open/close animations work correctly.
+ */
+@Directive({
+ selector: '[mdcDrawerAppContent]'
+})
+export class MdcDrawerAppContent {
+ /** @internal */
+ @HostBinding('class.mdc-drawer-app-content') _cls = true;
+
+ /**
+ * Set this to false to disable the styling for sibbling app content of a dismissible drawer.
+ * This is typically only used when your `mdcDrawer` type is dynamic. In those cases you can
+ * disable the `mdcDrawerAppContent` when you set your drawer type to anything other than
+ * `dismissible`.
+ */
+ @Input() get mdcDrawerAppContent() {
+ return this._cls;
+ }
+
+ set mdcDrawerAppContent(value: boolean) {
+ this._cls = asBoolean(value);
+ }
+
+ static ngAcceptInputType_mdcDrawerAppContent: boolean | '';
+}
+
+export const DRAWER_DIRECTIVES = [
+ MdcDrawerTitleDirective,
+ MdcDrawerSubtitleDirective,
+ MdcDrawerHeaderDirective,
+ MdcDrawerContentDirective,
+ MdcDrawerScrimDirective,
+ MdcDrawerDirective,
+ MdcDrawerAppContent
+];
diff --git a/bundle/src/components/drawer/mdc.persistent.drawer.adapter.ts b/bundle/src/components/drawer/mdc.persistent.drawer.adapter.ts
deleted file mode 100644
index a3aa88b..0000000
--- a/bundle/src/components/drawer/mdc.persistent.drawer.adapter.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { MdcSlidableDrawerAdapter } from './mdc.slidable.drawer.adapter';
-
-/** @docs-private */
-export interface MdcPersistentDrawerAdapter extends MdcSlidableDrawerAdapter {
-}
diff --git a/bundle/src/components/drawer/mdc.slidable.drawer.adapter.ts b/bundle/src/components/drawer/mdc.slidable.drawer.adapter.ts
deleted file mode 100644
index 774a0a2..0000000
--- a/bundle/src/components/drawer/mdc.slidable.drawer.adapter.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/** @docs-private */
-export interface MdcSlidableDrawerAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- hasClass: (className: string) => boolean;
- hasNecessaryDom: () => boolean;
- registerInteractionHandler: (evt: string, handler: EventListener) => void;
- deregisterInteractionHandler: (evt: string, handler: EventListener) => void;
- registerDrawerInteractionHandler: (evt: string, handler: EventListener) => void;
- deregisterDrawerInteractionHandler: (evt: string, handler: EventListener) => void;
- registerTransitionEndHandler: (handler: EventListener) => void;
- deregisterTransitionEndHandler: (handler: EventListener) => void;
- registerDocumentKeydownHandler: (handler: EventListener) => void;
- deregisterDocumentKeydownHandler: (handler: EventListener) => void;
- setTranslateX: (value: number) => void;
- getFocusableElements: () => NodeListOf;
- saveElementTabState: (el: Element) => void;
- restoreElementTabState: (el: Element) => void;
- makeElementUntabbable: (el: Element) => void;
- notifyOpen: () => void;
- notifyClose: () => void;
- isRtl: () => boolean;
- getDrawerWidth: () => number;
- // allthough in the MDC code this is not listed as member for the slidable/temporary
- // drawer, the code still calls it, and the implementation returns false for temporary
- // drawer:
- isDrawer: (el: Element) => boolean;
-}
diff --git a/bundle/src/components/drawer/mdc.temporary.drawer.adapter.ts b/bundle/src/components/drawer/mdc.temporary.drawer.adapter.ts
deleted file mode 100644
index f483e63..0000000
--- a/bundle/src/components/drawer/mdc.temporary.drawer.adapter.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { MdcSlidableDrawerAdapter } from './mdc.slidable.drawer.adapter';
-
-/** @docs-private */
-export interface MdcTemporaryDrawerAdapter extends MdcSlidableDrawerAdapter {
- addBodyClass: (className: string) => void;
- removeBodyClass: (className: string) => void;
- updateCssVariable: (value: string) => void;
- eventTargetHasClass: (target: HTMLElement, className: string) => boolean;
-}
diff --git a/bundle/src/components/elevation/mdc.elevation.directive.spec.ts b/bundle/src/components/elevation/mdc.elevation.directive.spec.ts
index 9cfe6b3..e6f2491 100644
--- a/bundle/src/components/elevation/mdc.elevation.directive.spec.ts
+++ b/bundle/src/components/elevation/mdc.elevation.directive.spec.ts
@@ -42,7 +42,6 @@ describe('MdcElevationDirective', () => {
it('should change elevation when property changes with numeric values', (() => {
const { fixture } = setup();
- const testComponent = fixture.debugElement.injector.get(TestComponent);
verifyElevationChange(0, 0, fixture);
verifyElevationChange(-1, 0, fixture);
@@ -53,7 +52,6 @@ describe('MdcElevationDirective', () => {
it('should change elevation when property changes with string values', (() => {
const { fixture } = setup();
- const testComponent = fixture.debugElement.injector.get(TestComponent);
verifyElevationChange('0', 0, fixture);
verifyElevationChange('-1', 0, fixture);
@@ -64,7 +62,6 @@ describe('MdcElevationDirective', () => {
it('should change elevation when property changes with invalid type values', (() => {
const { fixture } = setup();
- const testComponent = fixture.debugElement.injector.get(TestComponent);
verifyElevationChange(true, 1, fixture);
verifyElevationChange(false, 0, fixture);
diff --git a/bundle/src/components/elevation/mdc.elevation.directive.ts b/bundle/src/components/elevation/mdc.elevation.directive.ts
index 3128a84..5c07300 100644
--- a/bundle/src/components/elevation/mdc.elevation.directive.ts
+++ b/bundle/src/components/elevation/mdc.elevation.directive.ts
@@ -1,8 +1,5 @@
-import { Directive, ElementRef, HostBinding, Input, Renderer2 } from '@angular/core';
-import { MDCRipple } from '@material/ripple';
-import { MDCRippleFoundation } from '@material/ripple';
-import { asBoolean, asBooleanOrNull } from '../../utils/value.utils';
-import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
+import { Directive, ElementRef, HostBinding, Input, OnInit, Renderer2 } from '@angular/core';
+import { asBoolean } from '../../utils/value.utils';
/**
* Directive for elevating an element above its surface.
@@ -11,8 +8,8 @@ import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
selector: '[mdcElevation]'
})
export class MdcElevationDirective {
- private _z: number = null;
- private _transition;
+ private _z: number | null = null;
+ private _transition: boolean = false;
constructor(private rndr: Renderer2, private _elm: ElementRef) {
}
@@ -22,11 +19,11 @@ export class MdcElevationDirective {
* When set to 0, the element will not be elevated! The default value is 1.
*/
@Input() get mdcElevation() {
- return this._z;
+ return this._z == null ? 1 : this._z;
}
- set mdcElevation(value: string | number) {
- let newValue = (value == null || value === '') ? 1 : +value;
+ set mdcElevation(value: number) {
+ let newValue = (value == null || value === '') ? 1 : +value;
if (newValue < 0)
newValue = 0;
if (newValue > 24)
@@ -41,6 +38,8 @@ export class MdcElevationDirective {
this._z = newValue;
}
+ static ngAcceptInputType_mdcElevation: string | number;
+
/**
* When this input is defined and does not have value false, changes of the elevation
* will be animated.
@@ -50,7 +49,9 @@ export class MdcElevationDirective {
return this._transition;
}
- set animateTransition(value: any) {
+ set animateTransition(value: boolean) {
this._transition = asBoolean(value);
}
+
+ static ngAcceptInputType_animateTransition: boolean | '';
}
diff --git a/bundle/src/components/fab/mdc.fab.directive.spec.ts b/bundle/src/components/fab/mdc.fab.directive.spec.ts
index cc50ce3..6f18863 100644
--- a/bundle/src/components/fab/mdc.fab.directive.spec.ts
+++ b/bundle/src/components/fab/mdc.fab.directive.spec.ts
@@ -1,13 +1,13 @@
-import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
-import { By } from '@angular/platform-browser';
+import { TestBed, fakeAsync, flush } from '@angular/core/testing';
import { Component } from '@angular/core';
-import { MdcFabDirective, MdcFabIconDirective } from './mdc.fab.directive';
-import { booleanAttributeStyleTest, hasRipple } from '../../testutils/page.test';
+import { MdcFabDirective, MdcFabIconDirective, MdcFabLabelDirective } from './mdc.fab.directive';
+import { testStyle, hasRipple } from '../../testutils/page.test';
+import { asBoolean } from '../../utils/value.utils';
describe('MdcFabDirective', () => {
@Component({
template: `
-
+
favorite_border
Like
@@ -16,12 +16,18 @@ describe('MdcFabDirective', () => {
class TestComponent {
mini: any = null;
exited: any = null;
- extended: any = null;
+ _extended = false;
+ get extended() {
+ return this._extended;
+ }
+ set extended(value: any) {
+ this._extended = asBoolean(value);
+ }
}
function setup() {
const fixture = TestBed.configureTestingModule({
- declarations: [MdcFabDirective, MdcFabIconDirective, TestComponent]
+ declarations: [MdcFabDirective, MdcFabLabelDirective, MdcFabIconDirective, TestComponent]
}).createComponent(TestComponent);
fixture.detectChanges();
return { fixture };
@@ -42,26 +48,16 @@ describe('MdcFabDirective', () => {
it('should style according to the value of the mini property', (() => {
const { fixture } = setup();
- testStyle(fixture, 'mini', 'mdc-fab--mini');
+ testStyle(fixture, 'mini', 'mini', 'mdc-fab--mini', MdcFabDirective, TestComponent);
}));
it('should style according to the value of the exited property', (() => {
const { fixture } = setup();
- testStyle(fixture, 'exited', 'mdc-fab--exited');
+ testStyle(fixture, 'exited', 'exited', 'mdc-fab--exited', MdcFabDirective, TestComponent);
}));
- it('should style according to the value of the extended property', (() => {
+ it('should set extended styling for fabs with labels', fakeAsync(() => {
const { fixture } = setup();
- testStyle(fixture, 'extended', 'mdc-fab--extended');
+ testStyle(fixture, 'extended', 'extended', 'mdc-fab--extended', MdcFabDirective, TestComponent);
}));
-
- const testStyle = (fixture: ComponentFixture, property: string, style: string) => {
- const fab = fixture.debugElement.query(By.directive(MdcFabDirective)).injector.get(MdcFabDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
- // initial the styles are not set:
- expect(fab[property]).toBe(false);
- expect(fab._elm.nativeElement.classList.contains(style)).toBe(false);
- // test various ways to set the property value, and the result of having the class or not:
- booleanAttributeStyleTest(fixture, testComponent, fab, property, property, style);
- }
});
diff --git a/bundle/src/components/fab/mdc.fab.directive.ts b/bundle/src/components/fab/mdc.fab.directive.ts
index 6ad1d43..d343c00 100644
--- a/bundle/src/components/fab/mdc.fab.directive.ts
+++ b/bundle/src/components/fab/mdc.fab.directive.ts
@@ -1,24 +1,23 @@
-import { AfterContentInit, Directive, ElementRef, HostBinding, Input, OnDestroy, Renderer2, forwardRef } from '@angular/core';
-import { MDCRipple } from '@material/ripple';
+import { AfterContentInit, Directive, ElementRef, HostBinding, Input, OnDestroy, Renderer2, forwardRef, ContentChild, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
import { asBoolean } from '../../utils/value.utils';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
/**
* Directive for the icon of a Floating Action Button
- * (mdcFab
). The icon of a Floating Action Button is
- * optional when the extended
property is set.
+ * (`mdcFab`).
*/
@Directive({
selector: '[mdcFabIcon]'
})
export class MdcFabIconDirective {
- @HostBinding('class.mdc-fab__icon') _cls = true;
+ @HostBinding('class.mdc-fab__icon') readonly _cls = true;
}
/**
* Directive for the label of an extended Floating Action Button
- * (mdcFab
). The label may be placed before or after the icon.
+ * (`mdcFab`). The label may be placed before or after the icon.
* It is also possible to only have a label for an extended Floating Action
* Button.
*/
@@ -26,25 +25,30 @@ export class MdcFabIconDirective {
selector: '[mdcFabLabel]'
})
export class MdcFabLabelDirective {
- @HostBinding('class.mdc-fab__label') _cls = true;
+ @HostBinding('class.mdc-fab__label') readonly _cls = true;
}
/**
* Material design Floating Action Button. The element should embed
- * an icon element with the MdcFabIconDirective
.
+ * an icon element with the `mdcFabIcon`, or (to make it an extended floating action button)
+ * a label with the `mdcFabLabel` directive. Extended floating actions button may (in addition
+ * to the label) also add an `mdcFabIcon` before or after the label.
*/
@Directive({
selector: '[mdcFab]',
providers: [{provide: AbstractMdcRipple, useExisting: forwardRef(() => MdcFabDirective) }]
})
export class MdcFabDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-fab') _cls = true;
+ /** @internal */
+ @HostBinding('class.mdc-fab') readonly _cls = true;
+ /** @internal */
+ @ContentChild(MdcFabLabelDirective) _label?: MdcFabLabelDirective;
private _mini = false;
- private _extended = false;
private _exited = false;
- constructor(public _elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry) {
- super(_elm, renderer, registry);
+ constructor(public _elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, renderer, registry, doc as Document);
+ this.addRippleSurface('mdc-fab__ripple');
}
ngAfterContentInit() {
@@ -64,22 +68,15 @@ export class MdcFabDirective extends AbstractMdcRipple implements AfterContentIn
return this._mini;
}
- set mini(val: any) {
+ set mini(val: boolean) {
this._mini = asBoolean(val);
}
- /**
- * When this input is defined and does not have value false, the FAB will
- * be extended to a wider size which includes a text label. Use directive
- * mdcFabLabel
for the text label.
- */
- @HostBinding('class.mdc-fab--extended') @Input()
- get extended() {
- return this._extended;
- }
+ static ngAcceptInputType_mini: boolean | '';
- set extended(val: any) {
- this._extended = asBoolean(val);
+ /** @docs-private */
+ @HostBinding('class.mdc-fab--extended') get extended() {
+ return !!this._label;
}
/**
@@ -91,9 +88,11 @@ export class MdcFabDirective extends AbstractMdcRipple implements AfterContentIn
return this._exited;
}
- set exited(val: any) {
+ set exited(val: boolean) {
this._exited = asBoolean(val);
}
+
+ static ngAcceptInputType_exited: boolean | '';
}
export const FAB_DIRECTIVES = [
diff --git a/bundle/src/components/floating-label/mdc.floating-label.adapter.ts b/bundle/src/components/floating-label/mdc.floating-label.adapter.ts
deleted file mode 100644
index abf37fa..0000000
--- a/bundle/src/components/floating-label/mdc.floating-label.adapter.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { MDCTextFieldHelperTextFoundation } from '@material/textfield/helper-text';
-
-/** @docs-private */
-export interface MdcFloatingLabelAdapter {
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- getWidth: () => number,
- registerInteractionHandler: (evtType: string, handler: EventListener) => void,
- deregisterInteractionHandler: (evtType: string, handler: EventListener) => void,
-}
diff --git a/bundle/src/components/floating-label/mdc.floating-label.directive.ts b/bundle/src/components/floating-label/mdc.floating-label.directive.ts
index 1db0c51..496a633 100644
--- a/bundle/src/components/floating-label/mdc.floating-label.directive.ts
+++ b/bundle/src/components/floating-label/mdc.floating-label.directive.ts
@@ -1,13 +1,18 @@
import { AfterContentInit, Directive, ElementRef, forwardRef, HostBinding,
- OnDestroy, Renderer2 } from '@angular/core';
-import { MDCFloatingLabelFoundation } from '@material/floating-label';
-import { MdcFloatingLabelAdapter } from './mdc.floating-label.adapter';
+ OnDestroy, Renderer2, OnInit } from '@angular/core';
+import { MDCFloatingLabelFoundation, MDCFloatingLabelAdapter } from '@material/floating-label';
+import { ponyfill } from '@material/dom';
import { AbstractMdcLabel } from '../abstract/abstract.mdc.label';
-import { asBoolean } from '../../utils/value.utils';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+import { HasId } from '../abstract/mixin.mdc.hasid';
+import { applyMixins } from '../../utils/mixins';
+@Directive()
+class MdcFloatingLabelDirectiveBase {}
+interface MdcFloatingLabelDirectiveBase extends HasId {}
+applyMixins(MdcFloatingLabelDirectiveBase, [HasId]);
/**
- * Directive for the floating label of input fields. Flaoting labels are used by
+ * Directive for the floating label of input fields. Floating labels are used by
* mdcTextField
and mdcSelect
to display the type of input
* the field requires. Floating labels are resting when the field is inactive, and
* float when the field is active.
@@ -18,48 +23,60 @@ import { MdcEventRegistry } from '../../utils/mdc.event.registry';
* to the id of the parent mdcInput
.
*/
@Directive({
- selector: 'label[mdcFloatingLabel]',
+ selector: '[mdcFloatingLabel]',
providers: [{provide: AbstractMdcLabel, useExisting: forwardRef(() => MdcFloatingLabelDirective) }]
})
-export class MdcFloatingLabelDirective extends AbstractMdcLabel implements AfterContentInit, OnDestroy {
- _initialized = false;
- /** @docs-private */
- @HostBinding() for: string;
- @HostBinding('class.mdc-floating-label') _cls = true;
- _mdcAdapter: MdcFloatingLabelAdapter = {
+export class MdcFloatingLabelDirective extends MdcFloatingLabelDirectiveBase implements AfterContentInit, OnDestroy, OnInit {
+ /** @internal */
+ @HostBinding('attr.for') for: string | null = null;
+ /** @internal */
+ @HostBinding('class.mdc-floating-label') readonly _cls = true;
+ private _mdcAdapter: MDCFloatingLabelAdapter = {
addClass: (className: string) => {
this._rndr.addClass(this._elm.nativeElement, className);
},
removeClass: (className: string) => {
this._rndr.removeClass(this._elm.nativeElement, className);
},
- getWidth:() => this._elm.nativeElement.offsetWidth,
- registerInteractionHandler: (type: string, handler: EventListener) => {
+ getWidth:() => ponyfill.estimateScrollWidth(this._elm.nativeElement),
+ registerInteractionHandler: (type, handler) => {
this.registry.listen(this._rndr, type, handler, this._elm);
},
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
+ deregisterInteractionHandler: (type, handler) => {
this.registry.unlisten(type, handler);
}
};
- _foundation: {
- init: Function,
- destroy: Function,
- float: (should: boolean) => void,
- shake: (should: boolean) => void,
- getWidth: () => number
- } = new MDCFloatingLabelFoundation(this._mdcAdapter);
+ private _foundation = new MDCFloatingLabelFoundation(this._mdcAdapter);
constructor(private _rndr: Renderer2, public _elm: ElementRef, private registry: MdcEventRegistry) {
super();
}
+ ngOnInit() {
+ this.initId();
+ }
+
ngAfterContentInit() {
this._foundation.init();
- this._initialized = true;
}
ngOnDestroy() {
- this._foundation.init();
- this._initialized = false;
+ this._foundation.destroy();
+ }
+
+ shake(shouldShake: boolean) {
+ this._foundation.shake(shouldShake);
+ }
+
+ float(shouldFloat: boolean) {
+ this._foundation.float(shouldFloat);
+ }
+
+ getWidth(): number {
+ return this._foundation.getWidth();
+ }
+
+ isLabelElement() {
+ return this._elm.nativeElement.nodeName.toLowerCase() === 'label';
}
}
diff --git a/bundle/src/components/focus-trap/abstract.mdc.focus-trap.ts b/bundle/src/components/focus-trap/abstract.mdc.focus-trap.ts
index c065ede..d4e5abe 100644
--- a/bundle/src/components/focus-trap/abstract.mdc.focus-trap.ts
+++ b/bundle/src/components/focus-trap/abstract.mdc.focus-trap.ts
@@ -3,13 +3,13 @@ import { ElementRef } from '@angular/core';
/** @docs-private */
export interface FocusTrapHandle {
readonly active: boolean;
- untrap();
+ untrap(): void;
}
/** @docs-private */
export abstract class AbstractMdcFocusInitial {
- /** @docs-private */ readonly priority: number;
- readonly _elm: ElementRef;
+ /** @internal */ readonly priority: number | null = null;
+ /** @internal */ readonly _elm: ElementRef | null = null;
}
/** @docs-private */
diff --git a/bundle/src/components/focus-trap/mdc.focus-trap.directive.spec.ts b/bundle/src/components/focus-trap/mdc.focus-trap.directive.spec.ts
index f36db6d..d812344 100644
--- a/bundle/src/components/focus-trap/mdc.focus-trap.directive.spec.ts
+++ b/bundle/src/components/focus-trap/mdc.focus-trap.directive.spec.ts
@@ -1,10 +1,9 @@
import { Component } from '@angular/core';
-import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { TestBed, fakeAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { FOCUS_TRAP_DIRECTIVES, MdcFocusInitialDirective, MdcFocusTrapDirective } from './mdc.focus-trap.directive';
-import { cancelledClick, simulateEscape } from '../../testutils/page.test';
-describe('MdcFocusTrapDirective', () => {
+describe('mdcFocusTrap', () => {
@Component({
template: `
outside 1
@@ -23,91 +22,45 @@ describe('MdcFocusTrapDirective', () => {
declarations: [...FOCUS_TRAP_DIRECTIVES, TestComponent]
}).createComponent(TestComponent);
fixture.detectChanges();
- return { fixture };
+ const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
+ const anchors = [...fixture.nativeElement.querySelectorAll('a')];
+ expect(anchors.length).toBe(4);
+ return { fixture, mdcFocusTrap, anchors };
}
it('should not trap the focus when not activated', (() => {
const { fixture } = setup();
- const anchors = fixture.nativeElement.querySelectorAll('a');
- expect(anchors.length).toBe(4);
-
- // check that clicks on the anchors are not cancelled:
- for (let i = 0; i != anchors.length; ++i) {
- expect(cancelledClick(anchors[i])).toBe(false);
- }
+ const sentinels = [...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')];
+ expect(sentinels.length).toBe(0);
}));
- it('should trap the focus when activated', fakeAsync(() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
- const anchors = fixture.nativeElement.querySelectorAll('a');
- expect(anchors.length).toBe(4);
-
- anchors[3].focus();
+ it('should trap focus when activated', (() => {
+ const { fixture, mdcFocusTrap, anchors } = setup();
+ anchors[0].focus();
let trap = mdcFocusTrap.trapFocus();
- tick(); // member element is focused with a 0ms delay.
- expect(document.activeElement).toBe(anchors[1]); // first element of trap focused
- expect(cancelledClick(anchors[0])).toBe(true); // outside focus trap
- expect(cancelledClick(anchors[1])).toBe(false);
- expect(cancelledClick(anchors[2])).toBe(false);
- expect(cancelledClick(anchors[3])).toBe(true); // outside focus trap
- // none of this should have affected the trap:
expect(trap.active).toBe(true);
- trap.untrap();
- expect(trap.active).toBe(false);
- tick(); // restoring old focus is async
- expect(document.activeElement).toBe(anchors[3]); // focus returns to previously focused element
- // no more canceling of clicks:
- expect(cancelledClick(anchors[0])).toBe(false);
- expect(cancelledClick(anchors[1])).toBe(false);
- expect(cancelledClick(anchors[2])).toBe(false);
- expect(cancelledClick(anchors[3])).toBe(false);
- }));
-
- it('should deactivate on outside click when untrapOnOutsideClick is set', fakeAsync(() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
- mdcFocusTrap.untrapOnOutsideClick = true;
- const anchors = fixture.nativeElement.querySelectorAll('a');
- expect(anchors.length).toBe(4);
-
- anchors[3].focus();
- let trap = mdcFocusTrap.trapFocus();
- tick(); // member element is focused with a 0ms delay.
- expect(document.activeElement).toBe(anchors[1]); // first element of trap focused
- // clicks outside trap are not cancelled, but deactivate the trap:
- expect(cancelledClick(anchors[0])).toBe(false);
- expect(trap.active).toBe(false);
- }));
- it('should honor the ignoreEscape setting', (() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
- const anchors = fixture.nativeElement.querySelectorAll('a');
+ // should have moved focus to first element:
+ expect(document.activeElement).toBe(anchors[1]);
+ // sentinels are added to trap the focus:
+ const sentinels = [...fixture.nativeElement.querySelectorAll('.mdc-dom-focus-sentinel')];
+ expect(sentinels.length).toBe(2);
+ // when trying to focus before the region, the trap focuses the last element:
+ sentinels[0].dispatchEvent(new Event('focus'));
+ expect(document.activeElement).toBe(anchors[2]);
+ // when trying to focus after the region, the trap focuses the last element:
+ sentinels[1].dispatchEvent(new Event('focus'));
+ expect(document.activeElement).toBe(anchors[1]);
- let trap = mdcFocusTrap.trapFocus();
- expect(trap.active).toBe(true);
- simulateEscape();
- expect(trap.active).toBe(false);
trap.untrap();
+ expect(trap.active).toBe(false);
+ // element from before tarp should have gotten focus back:
+ expect(document.activeElement).toBe(anchors[0]);
- mdcFocusTrap.ignoreEscape = true;
- trap = mdcFocusTrap.trapFocus();
- expect(trap.active).toBe(true);
- simulateEscape();
- expect(trap.active).toBe(true);
- }));
-
- it('should be initialized with the correct defaults', (() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
- expect(mdcFocusTrap.ignoreEscape).toBe(false);
- expect(mdcFocusTrap.untrapOnOutsideClick).toBe(false);
}));
- it('stacking of traps is not yet supported', (() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
+ it('can not activate when a trap is already active', (() => {
+ const { mdcFocusTrap } = setup();
let trap1 = mdcFocusTrap.trapFocus();
let error: Error = null;
@@ -132,8 +85,7 @@ describe('MdcFocusTrapDirective', () => {
let leftActiveTrap = null;
it('should deactivate on destroy', (() => {
- const { fixture } = setup();
- const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
+ const { mdcFocusTrap } = setup();
leftActiveTrap = mdcFocusTrap.trapFocus();
expect(leftActiveTrap.active).toBe(true);
@@ -150,7 +102,7 @@ describe('MdcFocusTrapDirective', () => {
});
});
-describe('MdcFocusInitialDirective', () => {
+describe('mdcFocusInitial', () => {
@Component({
template: `
outside 1
@@ -169,21 +121,19 @@ describe('MdcFocusInitialDirective', () => {
declarations: [MdcFocusTrapDirective, MdcFocusInitialDirective, TestComponent]
}).createComponent(TestComponent);
fixture.detectChanges();
- return { fixture };
- }
-
- it('should get focus when trap activates', fakeAsync(() => {
- const { fixture } = setup();
const mdcFocusTrap = fixture.debugElement.query(By.directive(MdcFocusTrapDirective)).injector.get(MdcFocusTrapDirective);
const anchors = fixture.nativeElement.querySelectorAll('a');
expect(anchors.length).toBe(4);
-
+ return { fixture, mdcFocusTrap, anchors };
+ }
+
+ it('should get focus when trap activates', fakeAsync(() => {
+ const { mdcFocusTrap, anchors } = setup();
+
anchors[3].focus();
let trap = mdcFocusTrap.trapFocus();
- tick(); // member element is focused with a 0ms delay.
expect(document.activeElement).toBe(anchors[2]); // mdcFocusInitial
trap.untrap();
- tick(); // restoring old focus is async
expect(document.activeElement).toBe(anchors[3]); // focus returns to previously focused element
}));
});
diff --git a/bundle/src/components/focus-trap/mdc.focus-trap.directive.ts b/bundle/src/components/focus-trap/mdc.focus-trap.directive.ts
index c061d92..cac7007 100644
--- a/bundle/src/components/focus-trap/mdc.focus-trap.directive.ts
+++ b/bundle/src/components/focus-trap/mdc.focus-trap.directive.ts
@@ -1,7 +1,5 @@
import { ContentChildren, Directive, ElementRef, Input, OnDestroy, QueryList, forwardRef } from '@angular/core';
-import createFocusTrap from 'focus-trap';
-import { Options, FocusTrap } from "focus-trap";
-import { asBoolean } from '../../utils/value.utils';
+import { focusTrap } from '@material/dom';
import { AbstractMdcFocusTrap, AbstractMdcFocusInitial, FocusTrapHandle } from './abstract.mdc.focus-trap';
/**
@@ -13,27 +11,39 @@ import { AbstractMdcFocusTrap, AbstractMdcFocusInitial, FocusTrapHandle } from '
providers: [{provide: AbstractMdcFocusInitial, useExisting: forwardRef(() => MdcFocusInitialDirective) }]
})
export class MdcFocusInitialDirective extends AbstractMdcFocusInitial {
- /** @docs-private */ readonly priority = 100;
+ /** @internal */ readonly priority = 100;
constructor(public _elm: ElementRef) {
super();
}
}
+let activeTrap: FocusTrapHandleImpl | null = null;
+
/** @docs-private */
class FocusTrapHandleImpl implements FocusTrapHandle {
private _active = true;
- private trap: FocusTrap;
+ private trap: focusTrap.FocusTrap | null = null;
- constructor(public _elm: ElementRef, options: Options) {
- options.onActivate = () => { this._active = true; activeTrap = this; };
- options.onDeactivate = () => { this._active = false; activeTrap = null; };
- this.trap = createFocusTrap(_elm.nativeElement, options);
- this.trap.activate();
+ constructor(public _elm: ElementRef, focusElm: HTMLElement | null, skipFocus: boolean) {
+ if (activeTrap)
+ // Stacking focus tracks (i.e. changing to another focus trap, and returning
+ // to the previous on deactivation) is not supported:
+ throw new Error('An mdcFocusTrap is already active.');
+ this.trap = new focusTrap.FocusTrap(_elm.nativeElement, {
+ initialFocusEl: focusElm || undefined,
+ skipInitialFocus: skipFocus
+ });
+ this.trap.trapFocus();
+ activeTrap = this;
}
untrap() {
- this.trap.deactivate();
+ this._active = false;
+ if (activeTrap === this) {
+ activeTrap = null;
+ this.trap!.releaseFocus();
+ }
}
get active() {
@@ -41,24 +51,27 @@ class FocusTrapHandleImpl implements FocusTrapHandle {
}
}
-let activeTrap: FocusTrapHandleImpl = null;
-
/**
- * Directive for trapping focus (by key and/or mouse input) inside an element. To be used
+ * Directive for trapping the tab key focus within an element. To be used
* for e.g. modal dialogs, where focus must be constrained for an accesssible experience.
- * Use mdcFocusInitial
on a child element if a specific element needs to get
- * focus upon activation of the trap. In the absense of an mdcFocusInitial
,
+ *
+ * This will only trap the keyboard focus (when using tab or shift+tab). It will not prevent the focus from moving
+ * out of the trapped region due to mouse interaction. You can use a background scrim element that overlays
+ * the window to achieve that. (Like `mdcDialog` does).
+ *
+ * Use `mdcFocusInitial` on a child element if a specific element needs to get
+ * focus upon activation of the trap. In the absense of an `mdcFocusInitial`,
* or when that element can't be focused, the focus trap will activate the first tabbable
* child element of the focus trap.
*/
@Directive({
- selector: '[mdcFocusTrap]',
+ selector: '[mdcFocusTrap],[mdcDialog],[mdcDrawer]',
providers: [{provide: AbstractMdcFocusTrap, useExisting: forwardRef(() => MdcFocusTrapDirective) }]
})
export class MdcFocusTrapDirective extends AbstractMdcFocusTrap implements OnDestroy {
- private _untrapOnOutsideClick = false;
- private _ignoreEscape = false;
- @ContentChildren(AbstractMdcFocusInitial, {descendants: true}) _focusInitial: QueryList;
+ /** @internal */
+ @ContentChildren(AbstractMdcFocusInitial, {descendants: true}) _focusInitials?: QueryList;
+ private trap: FocusTrapHandle | null = null;
constructor(private _elm: ElementRef) {
super();
@@ -66,54 +79,17 @@ export class MdcFocusTrapDirective extends AbstractMdcFocusTrap implements OnDes
ngOnDestroy() {
// if this element is destroyed, it must not leave the trap in activated state:
- if (activeTrap && activeTrap._elm.nativeElement === this._elm.nativeElement)
- activeTrap.untrap();
+ if (this.trap)
+ this.trap.untrap();
+ this.trap = null;
}
/** @docs-private */
trapFocus(): FocusTrapHandle {
- if (activeTrap)
- // Currently stacking focus tracks (i.e. changing to another focus trap, and returning
- // to the previous on deactivation) is not yet supported. Will be in a future release:
- throw new Error('An mdcFocusTrap is already active.');
- let options: Options = {
- clickOutsideDeactivates: this._untrapOnOutsideClick,
- escapeDeactivates: !this._ignoreEscape,
- };
- if (this._focusInitial.length > 0) {
- let fi: AbstractMdcFocusInitial = null;
- this._focusInitial.forEach(focus => fi = (fi == null || fi.priority <= focus.priority) ? focus : fi);
- if (fi)
- options.initialFocus = fi._elm.nativeElement;
- }
- return new FocusTrapHandleImpl(this._elm, options);
- }
-
- /**
- * Set this property to have clicks outside the focus area untrap the focus.
- * The value is taken when the trap is activated. Thus changing the value
- * while a focus trap is active does not affect the behavior of that focus trap.
- */
- @Input() get untrapOnOutsideClick() {
- return this._untrapOnOutsideClick;
- }
-
- set untrapOnOutsideClick(value: any) {
- this._untrapOnOutsideClick = asBoolean(value);
- }
-
- /**
- * Set this property to ignore the escape key. The default is to deactivate the
- * trap when a user presses the escape key.
- * The value is taken when the trap is activated. Thus changing the value
- * while a focus trap is active does not affect the behavior of that focus trap.
- */
- @Input() get ignoreEscape() {
- return this._ignoreEscape;
- }
-
- set ignoreEscape(value: any) {
- this._ignoreEscape = asBoolean(value);
+ let focusInitial: AbstractMdcFocusInitial | null = null;
+ this._focusInitials!.forEach(focus => focusInitial = (focusInitial == null || focusInitial.priority! <= focus.priority!) ? focus : focusInitial);
+ this.trap = new FocusTrapHandleImpl(this._elm, (focusInitial)?._elm.nativeElement, false);
+ return this.trap;
}
}
diff --git a/bundle/src/components/form-field/mdc.form-field.adapter.ts b/bundle/src/components/form-field/mdc.form-field.adapter.ts
deleted file mode 100644
index f0a103e..0000000
--- a/bundle/src/components/form-field/mdc.form-field.adapter.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/** @docs-private */
-export interface MdcFormFieldAdapter {
- registerInteractionHandler: (type: string, handler: EventListener) => void;
- deregisterInteractionHandler: (type: string, handler: EventListener) => void;
- activateInputRipple: () => void;
- deactivateInputRipple: () => void;
-}
diff --git a/bundle/src/components/form-field/mdc.form-field.directive.ts b/bundle/src/components/form-field/mdc.form-field.directive.ts
index 4f4d30d..db51202 100644
--- a/bundle/src/components/form-field/mdc.form-field.directive.ts
+++ b/bundle/src/components/form-field/mdc.form-field.directive.ts
@@ -1,8 +1,7 @@
import { AfterContentInit, ContentChild, ContentChildren, forwardRef, Directive, ElementRef, HostBinding, HostListener,
Input, OnDestroy, Optional, Renderer2, Self } from '@angular/core';
import { NgControl } from '@angular/forms';
-import { MDCFormFieldFoundation } from '@material/form-field';
-import { MdcFormFieldAdapter } from './mdc.form-field.adapter';
+import { MDCFormFieldFoundation, MDCFormFieldAdapter } from '@material/form-field';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
import { AbstractMdcInput } from '../abstract/abstract.mdc.input';
import { AbstractMdcLabel } from '../abstract/abstract.mdc.label';
@@ -16,7 +15,7 @@ let nextId = 1;
providers: [{provide: AbstractMdcInput, useExisting: forwardRef(() => MdcFormFieldInputDirective) }]
})
export class MdcFormFieldInputDirective extends AbstractMdcInput {
- private _id: string;
+ private _id: string | null = null;
private _disabled = false;
constructor(public _elm: ElementRef, @Optional() @Self() public _cntr: NgControl) {
@@ -28,18 +27,20 @@ export class MdcFormFieldInputDirective extends AbstractMdcInput {
return this._id;
}
- set id(value: string) {
+ set id(value: string | null) {
this._id = value;
}
@HostBinding()
@Input() get disabled() {
- return this._cntr ? this._cntr.disabled : this._disabled;
+ return this._cntr ? !!this._cntr.disabled : this._disabled;
}
- set disabled(value: any) {
+ set disabled(value: boolean) {
this._disabled = asBoolean(value);
}
+
+ static ngAcceptInputType_disabled: boolean | '';
}
@Directive({
@@ -47,7 +48,7 @@ export class MdcFormFieldInputDirective extends AbstractMdcInput {
providers: [{provide: AbstractMdcLabel, useExisting: forwardRef(() => MdcFormFieldLabelDirective) }]
})
export class MdcFormFieldLabelDirective extends AbstractMdcLabel {
- @HostBinding() @Input() for: string;
+ @HostBinding() @Input() for: string | null = null;
constructor(public _elm: ElementRef) {
super();
@@ -58,17 +59,21 @@ export class MdcFormFieldLabelDirective extends AbstractMdcLabel {
selector: '[mdcFormField]'
})
export class MdcFormFieldDirective implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-form-field') _cls = true;
+ /** @internal */
+ @HostBinding('class.mdc-form-field') readonly _cls = true;
private _alignEnd = false;
- @ContentChild(AbstractMdcRipple) rippleChild: AbstractMdcRipple;
- @ContentChild(AbstractMdcInput) mdcInput: AbstractMdcInput;
- @ContentChild(AbstractMdcLabel) mdcLabel: AbstractMdcLabel;
+ /** @internal */
+ @ContentChild(AbstractMdcRipple) rippleChild?: AbstractMdcRipple;
+ /** @internal */
+ @ContentChild(AbstractMdcInput) mdcInput?: AbstractMdcInput;
+ /** @internal */
+ @ContentChild(AbstractMdcLabel) mdcLabel?: AbstractMdcLabel;
- private mdcAdapter: MdcFormFieldAdapter = {
- registerInteractionHandler: (type: string, handler: EventListener) => {
+ private mdcAdapter: MDCFormFieldAdapter = {
+ registerInteractionHandler: (type, handler) => {
this.registry.listen(this.renderer, type, handler, this.root);
},
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
+ deregisterInteractionHandler: (type, handler) => {
this.registry.unlisten(type, handler);
},
activateInputRipple: () => {
@@ -80,7 +85,7 @@ export class MdcFormFieldDirective implements AfterContentInit, OnDestroy {
this.rippleChild.deactivateRipple();
}
};
- private foundation: { init: Function, destroy: Function } = new MDCFormFieldFoundation(this.mdcAdapter);
+ private foundation: MDCFormFieldFoundation | null = null;
constructor(private renderer: Renderer2, private root: ElementRef, private registry: MdcEventRegistry) {
}
@@ -94,18 +99,28 @@ export class MdcFormFieldDirective implements AfterContentInit, OnDestroy {
else if (this.mdcLabel.for == null)
this.mdcLabel.for = this.mdcInput.id;
}
+ this.foundation = new MDCFormFieldFoundation(this.mdcAdapter);
this.foundation.init();
}
ngOnDestroy() {
- this.foundation.destroy();
+ this.foundation?.destroy();
+ this.foundation = null;
}
@Input() @HostBinding('class.mdc-form-field--align-end') get alignEnd() {
return this._alignEnd;
}
- set alignEnd(val: any) {
+ set alignEnd(val: boolean) {
this._alignEnd = asBoolean(val);
}
+
+ static ngAcceptInputType_alignEnd: boolean | '';
}
+
+export const FORM_FIELD_DIRECTIVES = [
+ MdcFormFieldInputDirective,
+ MdcFormFieldLabelDirective,
+ MdcFormFieldDirective
+];
diff --git a/bundle/src/components/icon-button/abstract.mdc.icon.ts b/bundle/src/components/icon-button/abstract.mdc.icon.ts
index 21cbcbf..c830f1e 100644
--- a/bundle/src/components/icon-button/abstract.mdc.icon.ts
+++ b/bundle/src/components/icon-button/abstract.mdc.icon.ts
@@ -1,10 +1,12 @@
-import { ElementRef, Renderer2 } from '@angular/core';
+import { Directive, ElementRef, Inject, Renderer2 } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
/** @docs-private */
+@Directive()
export abstract class AbstractMdcIcon extends AbstractMdcRipple {
- constructor(public _elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry) {
- super(_elm, renderer, registry);
+ constructor(public _elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, renderer, registry, doc as Document);
}
}
diff --git a/bundle/src/components/icon-button/mdc.icon-button.adapter.ts b/bundle/src/components/icon-button/mdc.icon-button.adapter.ts
deleted file mode 100644
index e245bc0..0000000
--- a/bundle/src/components/icon-button/mdc.icon-button.adapter.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/** @docs-private */
-export interface MdcIconButtonToggleAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- registerInteractionHandler: (type: string, handler: EventListener) => void;
- deregisterInteractionHandler: (type: string, handler: EventListener) => void;
- setText: (text: string) => void;
- // getTabIndex/setTabIndex: not used (foundation calls getTabIndex but does nothing with it)
- // also, since this is supposed to be a button element, the tabIndex doesn't need tinkering
- // with from the foundation, so left out:
- // getTabIndex: () => number;
- // setTabIndex: (tabIndex: number) => void;
- getAttr: (name: string) => string;
- setAttr: (name: string, value: string) => void;
- // removeAttr is never called by the foundation, left out:
- // removeAttr: (name: string) => void;
- notifyChange: (evtData: { isOn: boolean }) => void;
-}
\ No newline at end of file
diff --git a/bundle/src/components/icon-button/mdc.icon-button.directive.spec.ts b/bundle/src/components/icon-button/mdc.icon-button.directive.spec.ts
index cd7a0f3..89eadda 100644
--- a/bundle/src/components/icon-button/mdc.icon-button.directive.spec.ts
+++ b/bundle/src/components/icon-button/mdc.icon-button.directive.spec.ts
@@ -1,11 +1,11 @@
-import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
+import { TestBed, fakeAsync, tick, flush, ComponentFixture } from '@angular/core/testing';
import { FormsModule, NgModel } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Component, ViewChild } from '@angular/core';
-import { MdcIconButtonIconDirective, MdcIconButtonDirective, MdcIconButtonToggleDirective, MdcFormsIconButtonDirective } from './mdc.icon-button.directive';
-import { booleanAttributeStyleTest, hasRipple } from '../../testutils/page.test';
+import { MdcIconButtonDirective, MdcIconToggleDirective, MdcIconDirective, MdcFormsIconButtonDirective } from './mdc.icon-button.directive';
+import { hasRipple } from '../../testutils/page.test';
-describe('mdcIconButton as action button', () => {
+describe('mdcIconButton', () => {
@Component({
template: `
@@ -34,12 +34,6 @@ describe('mdcIconButton as action button', () => {
expect(hasRipple(iconButton)).toBe(true);
}));
- it('should read behavioral properties from inputs', (() => {
- const { fixture } = setup();
- const iconButton = fixture.debugElement.query(By.directive(MdcIconButtonDirective)).injector.get(MdcIconButtonDirective);
- expect(iconButton.disabled).toBeFalsy();
- }));
-
it('should be styled differently when disabled', (() => {
const { fixture } = setup();
const iconButton = fixture.nativeElement.querySelector('button');
@@ -55,24 +49,20 @@ describe('mdcIconButton as action button', () => {
const iconButton = fixture.nativeElement.querySelector('button');
const testComponent = fixture.debugElement.injector.get(TestComponent);
+ expect(testComponent.clickCount).toBe(0);
iconButton.click();
expect(testComponent.clickCount).toBe(1);
}));
});
-describe('mdcIconButton as toggle', () => {
+describe('mdcIconToggle', () => {
@Component({
template: `
-
+
+ favorite
+ favorite_border
+
`
})
class TestComponent {
@@ -91,49 +81,23 @@ describe('mdcIconButton as toggle', () => {
function setup(testComponentType: any = TestComponent) {
const fixture = TestBed.configureTestingModule({
- declarations: [MdcIconButtonToggleDirective, testComponentType]
+ declarations: [MdcIconToggleDirective, MdcIconDirective, testComponentType]
}).createComponent(testComponentType);
fixture.detectChanges();
- return { fixture };
+ const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
+ const element: HTMLButtonElement = fixture.nativeElement.querySelector('.mdc-icon-button');
+ const testComponent = fixture.debugElement.injector.get(testComponentType);
+ return { fixture, iconToggle, element, testComponent };
}
it('should render the icon toggles with icon and ripple styles', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.nativeElement.querySelector('button');
- expect(iconToggle.classList).toContain('mdc-icon-button');
- expect(hasRipple(iconToggle)).toBe(true);
- }));
-
- it('should read behavioral properties from inputs', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- expect(iconToggle.iconIsClass).toBeFalsy();
- expect(iconToggle.labelOn).toBe('Remove from favorites');
- expect(iconToggle.labelOff).toBe('Add to favorites');
- expect(iconToggle.iconOn).toBe('favorite');
- expect(iconToggle.iconOff).toBe('favorite_border');
- expect(iconToggle.disabled).toBeFalsy();
- }));
-
- it('should change appearance when behavioral properties are changed', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
-
- iconToggle.labelOn = 'Do not like';
- iconToggle.labelOff = 'Like';
- iconToggle.iconOn = 'thumb_up';
- iconToggle.iconOff = 'thumb_down';
-
- fixture.detectChanges();
-
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Like');
- expect(iconToggle._elm.nativeElement.textContent).toBe('thumb_down');
+ const { element } = setup();
+ expect(element.classList).toContain('mdc-icon-button');
+ expect(hasRipple(element)).toBe(true);
}));
it('should be styled differently when disabled', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const { fixture, iconToggle, testComponent } = setup();
expect(iconToggle.disabled).toBe(false);
testComponent.disabled = true;
fixture.detectChanges();
@@ -141,39 +105,78 @@ describe('mdcIconButton as toggle', () => {
}));
it('should toggle state when clicked', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const { iconToggle, testComponent, element } = setup();
expect(iconToggle.on).toBe(false); // initial value from 'favorite' property
- expect(testComponent.favorite).toBeFalsy(); // not yet initialized, may be undefined or false
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite_border');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Add to favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('false');
-
- iconToggle._elm.nativeElement.click();
- fixture.detectChanges();
+ expect(testComponent.favorite).toBeUndefined(); // not yet initialized, so undefined (coerced to false on button)
+ expect(element.classList).not.toContain('mdc-icon-button--on');
+
+ clickAndCheck(true);
+ clickAndCheck(false);
+ clickAndCheck(true);
+
+ function clickAndCheck(expected) {
+ element.click();
+ expect(iconToggle.on).toBe(expected);
+ expect(testComponent.favorite).toBe(expected);
+ if (expected)
+ expect(element.classList).toContain('mdc-icon-button--on');
+ else
+ expect(element.classList).not.toContain('mdc-icon-button--on');
+ }
+
- expect(iconToggle.on).toBe(true);
- expect(testComponent.favorite).toBe(true);
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Remove from favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('true');
+ }));
+
+ it('aria-pressed should reflect state of toggle', (() => {
+ const { fixture, testComponent, element } = setup();
- iconToggle._elm.nativeElement.click();
+ expect(testComponent.favorite).toBeUndefined(); // not yet initialized, so undefined (coerced to false on button)
+ expect(element.getAttribute('aria-pressed')).toBe('false');
+ element.click(); // user change
+ expect(element.getAttribute('aria-pressed')).toBe('true');
+ testComponent.favorite = false; //programmatic change
fixture.detectChanges();
+ expect(element.getAttribute('aria-pressed')).toBe('false');
+ }));
- expect(iconToggle.on).toBe(false);
- expect(testComponent.favorite).toBe(false);
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite_border');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Add to favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('false');
+ it('label is reflected as aria-label', (() => {
+ const { element } = setup();
+ expect(element.getAttribute('aria-label')).toBe('Add to favorites');
+ }));
+
+ it('labelOn and labelOff are reflected as aria-label', (() => {
+ const { fixture, element, testComponent } = setup(TestLabelOnOffComponent);
+ expect(element.getAttribute('aria-label')).toBe('Add to favorites');
+ testComponent.favorite = true;
+ fixture.detectChanges();
+ expect(element.getAttribute('aria-label')).toBe('Remove from favorites');
+ }));
+
+ it('should toggle state when clicked', (() => {
+ const { iconToggle, testComponent, element } = setup();
+
+ expect(iconToggle.on).toBe(false); // initial value from 'favorite' property
+ expect(testComponent.favorite).toBeUndefined(); // not yet initialized, so undefined (coerced to false on button)
+ expect(element.classList).not.toContain('mdc-icon-button--on');
+
+ clickAndCheck(true);
+ clickAndCheck(false);
+ clickAndCheck(true);
+
+ function clickAndCheck(expected) {
+ element.click();
+ expect(iconToggle.on).toBe(expected);
+ expect(testComponent.favorite).toBe(expected);
+ if (expected)
+ expect(element.classList).toContain('mdc-icon-button--on');
+ else
+ expect(element.classList).not.toContain('mdc-icon-button--on');
+ }
}));
it('value changes must be emitted via onChange', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const { fixture, iconToggle, testComponent } = setup();
iconToggle._elm.nativeElement.click(); fixture.detectChanges();
expect(testComponent.changes).toEqual([true]);
@@ -181,67 +184,54 @@ describe('mdcIconButton as toggle', () => {
expect(testComponent.changes).toEqual([true, false]);
}));
- it("programmatic changes of 'on' should not trigger 'onChange' events", (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ it("programmatic value changes must be emitted via onChange", (() => {
+ const { fixture, testComponent } = setup();
testComponent.favorite = true; fixture.detectChanges();
testComponent.favorite = false; fixture.detectChanges();
testComponent.favorite = true; fixture.detectChanges();
- expect(testComponent.changes).toEqual([]);
+ expect(testComponent.changes).toEqual([true, false, true]);
}));
@Component({
template: `
-
- `
+
+
+
+ `
})
class TestIconIsClassComponent {
}
- it("iconIsClass property", (() => {
- const { fixture } = setup(TestIconIsClassComponent);
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
-
- expect(iconToggle._elm.nativeElement.classList).toContain('fa-heart-o');
- expect(iconToggle._elm.nativeElement.textContent.trim()).toBe('');
-
- iconToggle.iconIsClass = true; // setting to existing value should be allowed
- // change value:
- expect(() => {iconToggle.iconIsClass = false; }).toThrowError(/iconIsClass property.*changed.*/);
+ it("iconIsClass property", fakeAsync(() => {
+ const { element } = setup(TestIconIsClassComponent);
+ expect(element.classList).toContain('mdc-icon-button');
+ expect(hasRipple(element)).toBe(true);
}));
-
- it("iconIsClass property can not be changed after initialization", (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
-
- // change value (default is undefined, so both true and false as value should be rejected after init):
- expect(() => {iconToggle.iconIsClass = false; }).toThrowError(/iconIsClass property.*changed.*/);
- expect(() => {iconToggle.iconIsClass = true; }).toThrowError(/iconIsClass property.*changed.*/);
- }));
+ @Component({
+ template: `
+
+ favorite
+ favorite_border
+
+ `
+ })
+ class TestLabelOnOffComponent {
+ favorite = false;
+ }
});
-describe('mdcIconButton with FormsModule', () => {
+describe('mdcIconToggle with FormsModule', () => {
@Component({
template: `
-
+
+ favorite
+ favorite_border
+
`
})
class TestComponent {
@@ -262,16 +252,16 @@ describe('mdcIconButton with FormsModule', () => {
function setup() {
const fixture = TestBed.configureTestingModule({
imports: [FormsModule],
- declarations: [MdcIconButtonToggleDirective, MdcFormsIconButtonDirective, TestComponent]
+ declarations: [MdcIconToggleDirective, MdcIconDirective, MdcFormsIconButtonDirective, TestComponent]
}).createComponent(TestComponent);
fixture.detectChanges();
- return { fixture };
+ const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ return { fixture, iconToggle, testComponent };
}
it('value changes must be emitted via ngModelChange', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const { fixture, iconToggle, testComponent } = setup();
iconToggle._elm.nativeElement.click(); fixture.detectChanges();
expect(testComponent.changes).toEqual([true]);
@@ -280,21 +270,19 @@ describe('mdcIconButton with FormsModule', () => {
expect(testComponent.changes).toEqual([true, false]);
}));
- it("programmatic changes of 'ngModel' should not trigger 'ngModelChange' events", (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
+ it("programmatic changes of 'ngModel don't trigger ngModelChange events", fakeAsync(() => {
+ const { fixture, testComponent } = setup();
- testComponent.favorite = true; fixture.detectChanges();
- testComponent.favorite = false; fixture.detectChanges();
- testComponent.favorite = true; fixture.detectChanges();
+ testComponent.favorite = true; fixture.detectChanges(); flush();
+ testComponent.favorite = false; fixture.detectChanges(); flush();
+ testComponent.favorite = true; fixture.detectChanges(); flush();
expect(testComponent.changes).toEqual([]);
}));
it("the disabled property should disable the button", fakeAsync(() => {
const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
+ const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
const testComponent = fixture.debugElement.injector.get(TestComponent);
tick();
expect(iconToggle._elm.nativeElement.disabled).toBe(false);
@@ -306,80 +294,3 @@ describe('mdcIconButton with FormsModule', () => {
expect(testComponent.ngModel.disabled).toBe(true);
}));
});
-
-describe('MdcIconButton with nested MdcIconButtonIcon', () => {
- @Component({
- template: `
-
-
-
- `
- })
- class TestComponent {
- disabled: boolean = false;
- like: boolean = true;
- }
-
- function setup() {
- const fixture = TestBed.configureTestingModule({
- declarations: [MdcIconButtonToggleDirective, MdcIconButtonIconDirective, TestComponent]
- }).createComponent(TestComponent);
- fixture.detectChanges();
- return { fixture };
- }
-
- it('should render iconOn/iconOff styles on the nested element, but ripples on the button', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.nativeElement.querySelector('button#icon');
- expect(iconToggle.classList).toContain('mdc-icon-button');
- expect(hasRipple(iconToggle)).toBe(true);
- const icon = iconToggle.querySelector('i.fa');
- expect(icon).toBeDefined();
- expect(icon.classList).toContain('fa-heart');
- expect(icon.classList).toContain('fa');
- }));
-
- it('should change appearance when behavioral properties are changed', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const icon = fixture.nativeElement.querySelector('i.fa');
- expect(icon.classList).toContain('fa-heart');
-
- iconToggle.labelOn = 'Open envelope';
- iconToggle.labelOff = 'Close envelope';
- iconToggle.iconOn = 'fa-envelope';
- iconToggle.iconOff = 'fa-envelope-open-o';
-
- fixture.detectChanges();
-
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Open envelope');
- expect(icon.classList.contains('fa-heart')).toBe(false, 'actual classes: ' + icon.classList);
- expect(icon.classList).toContain('fa-envelope');
- expect(icon.classList).toContain('fa');
- }));
-
- it('should toggle state when clicked', (() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconButtonToggleDirective)).injector.get(MdcIconButtonToggleDirective);
- const icon = fixture.nativeElement.querySelector('i.fa');
-
- expect(icon.classList.contains('fa-heart')).toBe(true, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(false, icon.classList);
-
- icon.click(); fixture.detectChanges();
-
- expect(icon.classList.contains('fa-heart')).toBe(false, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(true, icon.classList);
-
- icon.click(); fixture.detectChanges();
-
- expect(icon.classList.contains('fa-heart')).toBe(true, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(false, icon.classList);
- }));
-});
diff --git a/bundle/src/components/icon-button/mdc.icon-button.directive.ts b/bundle/src/components/icon-button/mdc.icon-button.directive.ts
index c601b26..deeea4a 100644
--- a/bundle/src/components/icon-button/mdc.icon-button.directive.ts
+++ b/bundle/src/components/icon-button/mdc.icon-button.directive.ts
@@ -1,83 +1,68 @@
-import { AfterContentInit, Directive, ContentChild, ElementRef, EventEmitter, forwardRef, HostBinding,
- HostListener, Input, OnDestroy, Output, Renderer2, Self } from '@angular/core';
+import { AfterContentInit, Directive, ElementRef, EventEmitter, forwardRef, HostBinding,
+ HostListener, Inject, Input, OnDestroy, Output, Renderer2, Self } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { MDCIconButtonToggleFoundation } from '@material/icon-button';
-import { MdcIconButtonToggleAdapter } from './mdc.icon-button.adapter';
-import { asBoolean, asBooleanOrNull } from '../../utils/value.utils';
+import { MDCIconButtonToggleFoundation, MDCIconButtonToggleAdapter, MDCIconButtonToggleEventDetail } from '@material/icon-button';
+import { asBoolean } from '../../utils/value.utils';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
import { AbstractMdcIcon } from './abstract.mdc.icon';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-/**
- * Directive for an icon nested inside a MdcIconButtonDirective
.
- * This directive is only required when the icon font for an mdcIconButton
- * uses CSS pseudo-elements in order to provide the icon. This is how Font Awesome, and many
- * other icon font libraries provide their icons. These pseudo elements would interfere
- * with the pseudo elements that mdcIconButton
uses to provide a ripple
- * effect. This can be solved by having a child element in your mdcIconButton
- * and set this directive on it. The icon classes will then be applied to the child
- * element, and won't interfere with the icon button pseudo elements anymore.
- *
- * For icon fonts that don't use pseudo elements (such as the Material
- * Design Icons from Google), this directive is not necessary.
- */
-@Directive({
- selector: '[mdcIconButtonIcon]'
-})
-export class MdcIconButtonIconDirective {
-}
-
/**
* Directive for an icon button. Icon buttons can be used with a font icon library such as
- * Google Material Icons , or
- * svg elements. They provide material styling and a ripple to the icon. Use it on anchor and
- * button tags. For toggling icon buttons, see MdcIconButtonToggleDirective
.
- * When the applied icon font uses CSS pseudo elements, make the icon a child element of the
- * mdcIconButton
, and give it the mdcIconButtonIcon
directive.
+ * Google Material Icons , SVG
+ * elements or images. They provide material styling and a ripple to the icon. Use it on anchor and
+ * button tags. For toggling icon buttons, see `MdcIconToggleDirective`.
*/
@Directive({
- selector: '[mdcIconButton]:not([iconOn])',
+ selector: 'button[mdcIconButton],a[mdcIconButton]',
providers: [
{provide: AbstractMdcRipple, useExisting: forwardRef(() => MdcIconButtonDirective) },
{provide: AbstractMdcIcon, useExisting: forwardRef(() => MdcIconButtonDirective) }
]
})
export class MdcIconButtonDirective extends AbstractMdcIcon implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-icon-button') _hostClass = true;
- private _disabled = false;
+ /** @internal */
+ @HostBinding('class.mdc-icon-button') readonly _cls = true;
- constructor(_elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry) {
- super(_elm, renderer, registry);
+ constructor(_elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, renderer, registry, doc as Document);
}
ngAfterContentInit() {
- this.initRipple();
+ this.initRipple(true);
}
ngOnDestroy() {
this.destroyRipple();
}
+}
+
+/**
+ * Directive for the icon to display on one of the toggle states of an `mdcIconToggle`. See
+ * `MdcIconToggleDirective` for more information.
+ */
+@Directive({
+ selector: '[mdcIcon]'
+})
+export class MdcIconDirective {
+ /** @internal */
+ @HostBinding('class.mdc-icon-button__icon') readonly _cls = true;
+ /** @internal */
+ @HostBinding('class.mdc-icon-button__icon--on') _on = false;
/**
- * To disable the icon, set this input to true.
+ * Set this input to false to remove the ripple effect from the surface.
*/
- @Input()
- @HostBinding()
- get disabled() {
- return this._disabled;
- }
-
- set disabled(value: any) {
- this._disabled = asBoolean(value);
+ @Input() get mdcIcon() {
+ return this._on ? 'on' : '';
}
- /** @docs-private */
- protected isRippleUnbounded() {
- return true;
+ set mdcIcon(value: 'on' | '') {
+ this._on = value === 'on';
}
}
-
/**
* Directive for creating a Material Design icon toggle button: a button that toggles state, and
* switches the icon based on the value of the toggle.
@@ -87,224 +72,119 @@ export class MdcIconButtonDirective extends AbstractMdcIcon implements AfterCont
* then update the child element with the correct icon if it is toggled.
*/
@Directive({
- selector: '[mdcIconButton][iconOn]',
+ selector: '[mdcIconToggle]',
providers: [
- {provide: AbstractMdcRipple, useExisting: forwardRef(() => MdcIconButtonToggleDirective) },
- {provide: AbstractMdcIcon, useExisting: forwardRef(() => MdcIconButtonToggleDirective) }
+ {provide: AbstractMdcRipple, useExisting: forwardRef(() => MdcIconToggleDirective) },
+ {provide: AbstractMdcIcon, useExisting: forwardRef(() => MdcIconToggleDirective) }
]
})
-export class MdcIconButtonToggleDirective extends AbstractMdcIcon implements AfterContentInit {
- @HostBinding('class.mdc-icon-button') _hostClass = true;
- @ContentChild(MdcIconButtonIconDirective, {read: ElementRef}) _innerIcon: ElementRef;
+export class MdcIconToggleDirective extends AbstractMdcIcon implements AfterContentInit {
+ /** @internal */
+ @HostBinding('class.mdc-icon-button') readonly _cls = true;
+ /**
+ * The aria-label to assign to the icon toggle. You can override the value for the
+ * on respectively off state by assigning to property `labelOn` or `labelOff`.
+ */
+ @Input() label: string | null = null;
+ /**
+ * The aria-label to assign to the icon toggle when it is on. If this input has no value,
+ * the aria-label will default to the value of the `label` input.
+ */
+ @Input() labelOn: string | null = null;
+ /**
+ * The aria-label to assign to the icon toggle when it is off. If this input has no value,
+ * the aria-label will default to the value of the `label` input.
+ */
+ @Input() labelOff: string | null = null;
/**
* Event emitted when the state of the icon toggle changes (for example when a user clicks
* the icon).
*/
- @Output() onChange: EventEmitter = new EventEmitter();
- private _onChange: (value: any) => void = (value) => {};
+ @Output() readonly onChange: EventEmitter = new EventEmitter();
+ private _onChange: (value: any) => void = () => {};
private _onTouched: () => any = () => {};
- private _initialized = false;
private _on = false;
- private _labelOn: string;
- private _labelOff: string;
- private _iconOn: string;
- private _iconOff: string;
- private _iconIsClass: boolean;
- private _disabled: boolean;
- private toggleAdapter: MdcIconButtonToggleAdapter = {
- addClass: (className: string) => this._renderer.addClass(this.iconElm, className),
- removeClass: (className: string) => this._renderer.removeClass(this.iconElm, className),
- registerInteractionHandler: (type: string, handler: EventListener) => this._registry.listen(this._renderer, type, handler, this._elm),
- deregisterInteractionHandler: (type: string, handler: EventListener) => this._registry.unlisten(type, handler),
- setText: (text: string) => this.iconElm.textContent = text,
- getAttr: (name: string) => {
- if (name === 'data-toggle-on-label') return this._labelOn;
- else if (name === 'data-toggle-off-label') return this._labelOff;
- else if (name === 'data-toggle-on-content') return this.iconIsClass ? null : this._iconOn;
- else if (name === 'data-toggle-off-content') return this.iconIsClass ? null : this._iconOff;
- else if (name === 'data-toggle-on-class') return this.iconIsClass ? this._iconOn : null;
- else if (name === 'data-toggle-off-class') return this.iconIsClass ? this._iconOff : null;
- return this._elm.nativeElement.getAttribute(name);
- },
+ private _disabled = false;
+ private toggleAdapter: MDCIconButtonToggleAdapter = {
+ addClass: (className: string) => this._renderer.addClass(this._elm.nativeElement, className),
+ removeClass: (className: string) => this._renderer.removeClass(this._elm.nativeElement, className),
+ // TODO return mdc-icon-button__icon--on for on...
+ hasClass: (className: string) => this._elm.nativeElement.classList.contains(className),
setAttr: (name: string, value: string) => this._renderer.setAttribute(this._elm.nativeElement, name, value),
- notifyChange: (evtData: {isOn: boolean}) => {
+ notifyChange: (evtData: MDCIconButtonToggleEventDetail) => {
this._on = evtData.isOn;
- this.notifyChange();
+ this._onChange(this._on);
+ this.onChange.emit(this._on);
}
};
- private toggleFoundation: {
- init(),
- destroy(),
- isOn(): boolean,
- toggle(isOn?: boolean)
- refreshToggleData()
- };
+ private toggleFoundation: MDCIconButtonToggleFoundation | null = null;
- constructor(_elm: ElementRef, rndr: Renderer2, registry: MdcEventRegistry) {
- super(_elm, rndr, registry);
+ constructor(_elm: ElementRef, rndr: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, rndr, registry, doc as Document);
}
ngAfterContentInit() {
- this.initRipple();
+ this.initRipple(true);
this.toggleFoundation = new MDCIconButtonToggleFoundation(this.toggleAdapter);
this.toggleFoundation.init();
- // the foundation doesn't initialize the iconOn/iconOff and labelOn/labelOff until
- // toggle is called for the first time,
- // also, this will ensure 'aria-pressed' and 'aria-label' attributes are initialized:
- this.toggleFoundation.toggle(this._on);
- this._initialized = true;
}
ngOnDestroy() {
this.destroyRipple();
- this.toggleFoundation.destroy();
- }
-
- private refreshToggleData() {
- if (this._initialized) {
- this.toggleFoundation.refreshToggleData();
- // refreshToggleData does not actually apply the new config to the icon:
- this.toggleFoundation.toggle(this._on);
- }
- }
-
- private get iconElm() {
- return this._innerIcon ? this._innerIcon.nativeElement : this._elm.nativeElement;
- }
-
- private notifyChange() {
- this._onChange(this._on);
- this.onChange.emit(this._on);
+ this.toggleFoundation?.destroy();
+ this.toggleFoundation = null;
}
- /** @docs-private */
+ /** @internal */
writeValue(obj: any) {
- let old = this._on;
- this._on = !!obj;
- if (this._initialized)
- this.toggleFoundation.toggle(this._on);
+ this.on = !!obj;
}
- /** @docs-private */
+ /** @internal */
registerOnChange(onChange: (value: any) => void) {
this._onChange = onChange;
}
- /** @docs-private */
+ /** @internal */
registerOnTouched(onTouched: () => any) {
this._onTouched = onTouched;
}
- /** @docs-private */
+ /** @internal */
setDisabledState(disabled: boolean) {
this._disabled = disabled;
}
- /** @docs-private */
- protected isRippleUnbounded() {
- return true;
- }
-
/**
* The current state of the icon (true for on/pressed, false for off/unpressed).
*/
@Input() get on() {
- return this._on;
- }
-
- set on(value: any) {
- let newValue = asBoolean(value);
- if (newValue !== this._on) {
- this._on = newValue;
- if (this._initialized)
- this.toggleFoundation.toggle(this._on);
- }
- }
-
- /**
- * The aria-label to use for the on/pressed state of the icon.
- */
- @Input() get labelOn() {
- return this._labelOn;
+ return this.toggleFoundation ? this.toggleFoundation.isOn() : this._on;
}
- set labelOn(value: string) {
- this._labelOn = value;
- this.refreshToggleData();
- }
-
- /**
- * The aria-label to use for the off/unpressed state of the icon.
- */
- @Input() get labelOff() {
- return this._labelOff;
- }
-
- set labelOff(value: string) {
- this._labelOff = value;
- this.refreshToggleData();
- }
-
- /**
- * The icon to use for the on/pressed state of the icon.
- */
- @Input() get iconOn() {
- return this._iconOn;
- }
-
- set iconOn(value: string) {
- if (value !== this._iconOn) {
- if (this.iconIsClass)
- // the adapter doesn't clean up old classes; this class may be set,
- // in which case after it's changed the foundation won't be able to remove it anymore:
- this.toggleAdapter.removeClass(this._iconOn);
- this._iconOn = value;
- this.refreshToggleData();
- }
- }
-
- /**
- * The icon to use for the off/unpressed state of the icon.
- */
- @Input() get iconOff() {
- return this._iconOff;
+ set on(value: boolean) {
+ const old = this.toggleFoundation ? this.toggleFoundation.isOn() : this._on;
+ this._on = asBoolean(value);
+ if (this.toggleFoundation)
+ this.toggleFoundation.toggle(this._on);
+ if (this._on !== old)
+ this.onChange.emit(this._on);
}
- set iconOff(value: string) {
- if (value !== this._iconOff) {
- if (this.iconIsClass)
- // the adapter doesn't clean up old classes; this class may be set,
- // in which case after it's changed the foundation won't be able to remove it anymore:
- this.toggleAdapter.removeClass(this._iconOff);
- this._iconOff = value;
- this.refreshToggleData();
- }
- }
+ static ngAcceptInputType_on: boolean | '';
- /**
- * Some icon fonts (such as Font Awesome) use CSS class names to select the icon to show.
- * Others, such as the Material Design Icons from Google use ligatures (allowing selection of
- * the icon by using their textual name). When iconIsClass
is true, the directive
- * assumes iconOn
, and iconOff
represent class names. When
- * iconIsClass
is false, the directive assumes the use of ligatures.
- * When iconIsClass is not set, the value depends on the availability of a nested
- * mdcIconButtonIcon
directive: when that exists, iconOn
and iconOff
- * are expected to be classnames, otherwise they are expected to be ligatures. This is usually
- * the intended behaviour, so in most cases you don't need to initialize the iconIsClass
- * property.
- */
- @Input() get iconIsClass() {
- return this._iconIsClass == null ? this._innerIcon != null : this._iconIsClass;
+ /** @internal */
+ @HostBinding('attr.aria-label') get _label() {
+ return this._on ? (this.labelOn || this.label) : (this.labelOff || this.label);
}
-
- set iconIsClass(value: any) {
- let newValue = asBooleanOrNull(value);
- if (this._initialized && this._iconIsClass !== newValue)
- throw new Error('iconIsClass property should not be changed after the mdcIconButton is initialized');
- this._iconIsClass = newValue;
+
+ /** @internal */
+ @HostListener('click') _onClick() {
+ this.toggleFoundation?.handleClick();
}
- @HostListener('(blur') _onBlur() {
+ /** @internal */
+ @HostListener('blur') _onBlur() {
this._onTouched();
}
@@ -317,24 +197,26 @@ export class MdcIconButtonToggleDirective extends AbstractMdcIcon implements Aft
return this._disabled;
}
- set disabled(value: any) {
+ set disabled(value: boolean) {
this._disabled = asBoolean(value);
}
+
+ static ngAcceptInputType_disabled: boolean | '';
}
/**
* Directive for adding Angular Forms (ControlValueAccessor
) behavior to an
- * MdcIconButtonDirective
. Allows the use of the Angular Forms API with
+ * MdcIconToggleDirective
. Allows the use of the Angular Forms API with
* icon toggle buttons, e.g. binding to [(ngModel)]
, form validation, etc.
*/
@Directive({
- selector: '[mdcIconButton][iconOn][formControlName],[mdcIconButton][iconOn][formControl],[mdcIconButton][iconOn][ngModel]',
+ selector: '[mdcIconToggle][formControlName],[mdcIconToggle][formControl],[mdcIconToggle][ngModel]',
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MdcFormsIconButtonDirective), multi: true}
]
})
export class MdcFormsIconButtonDirective implements ControlValueAccessor {
- constructor(@Self() private mdcIconButton: MdcIconButtonToggleDirective) {
+ constructor(@Self() private mdcIconButton: MdcIconToggleDirective) {
}
/** @docs-private */
@@ -359,5 +241,5 @@ export class MdcFormsIconButtonDirective implements ControlValueAccessor {
}
export const ICON_BUTTON_DIRECTIVES = [
- MdcIconButtonIconDirective, MdcIconButtonDirective, MdcIconButtonToggleDirective, MdcFormsIconButtonDirective
+ MdcIconDirective, MdcIconButtonDirective, MdcIconToggleDirective, MdcFormsIconButtonDirective
];
diff --git a/bundle/src/components/icon-toggle/mdc.icon-toggle.adapter.ts b/bundle/src/components/icon-toggle/mdc.icon-toggle.adapter.ts
deleted file mode 100644
index 46ac03b..0000000
--- a/bundle/src/components/icon-toggle/mdc.icon-toggle.adapter.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/** @docs-private */
-export interface MdcIconToggleAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- registerInteractionHandler: (type: string, handler: EventListener) => void;
- deregisterInteractionHandler: (type: string, handler: EventListener) => void;
- setText: (text: string) => void;
- getTabIndex: () => number;
- setTabIndex: (tabIndex: number) => void;
- getAttr: (name: string) => string;
- setAttr: (name: string, value: string) => void;
- rmAttr: (name: string) => void;
- notifyChange: (evtData: { isOn: boolean }) => void;
-}
\ No newline at end of file
diff --git a/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.spec.ts b/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.spec.ts
deleted file mode 100644
index 8e3eea1..0000000
--- a/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.spec.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
-import { By } from '@angular/platform-browser';
-import { Component } from '@angular/core';
-import { MdcIconToggleDirective, MdcIconToggleIconDirective } from './mdc.icon-toggle.directive';
-import { booleanAttributeStyleTest, hasRipple } from '../../testutils/page.test';
-
-describe('MdcIconToggleDirective standalone', () => {
- @Component({
- template: `
-
- `
- })
- class TestComponent {
- disabled: any;
- favorite: any;
- action() {}
- }
-
- function setup() {
- const fixture = TestBed.configureTestingModule({
- declarations: [MdcIconToggleDirective, MdcIconToggleIconDirective, TestComponent]
- }).createComponent(TestComponent);
- fixture.detectChanges();
- return { fixture };
- }
-
- it('should render the icon toggles with icon and ripple styles', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.nativeElement.querySelector('i');
- expect(iconToggle.classList).toContain('mdc-icon-toggle');
- expect(hasRipple(iconToggle)).toBe(true);
- }));
-
- it('should read behavioral properties from inputs', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
- expect(iconToggle.iconIsClass).toBeFalsy();
- expect(iconToggle.labelOn).toBe('Remove from favorites');
- expect(iconToggle.labelOff).toBe('Add to favorites');
- expect(iconToggle.iconOn).toBe('favorite');
- expect(iconToggle.iconOff).toBe('favorite_border');
- expect(iconToggle.disabled).toBeFalsy();
- }));
-
- it('should change appearance when behavioral properties are changed', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
-
- iconToggle.labelOn = 'Do not like';
- iconToggle.labelOff = 'Like';
- iconToggle.iconOn = 'thumb_up';
- iconToggle.iconOff = 'thumb_down';
-
- fixture.detectChanges();
- tick();
-
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Like');
- expect(iconToggle._elm.nativeElement.textContent).toBe('thumb_down');
- }));
-
- it('should toggle state when clicked', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
- const testComponent = fixture.debugElement.injector.get(TestComponent);
-
- expect(iconToggle.on).toBe(false); // initial value from 'favorite' property
- expect(testComponent.favorite).toBeFalsy(); // not yet initialized, may be undefined or false
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite_border');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Add to favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('false');
-
- iconToggle._elm.nativeElement.click(); tick(); fixture.detectChanges();
-
- expect(iconToggle.on).toBe(true);
- expect(testComponent.favorite).toBe(true);
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Remove from favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('true');
-
- iconToggle._elm.nativeElement.click(); tick(); fixture.detectChanges();
-
- expect(iconToggle.on).toBe(false);
- expect(testComponent.favorite).toBe(false);
- expect(iconToggle._elm.nativeElement.textContent).toBe('favorite_border');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Add to favorites');
- expect(iconToggle._elm.nativeElement.getAttribute('aria-pressed')).toBe('false');
- }));
-});
-
-describe('MdcIconToggleDirective with MdcIconToggleIconDirective', () => {
- @Component({
- template: `
-
-
-
- `
- })
- class TestComponent {
- disabled: boolean = false;
- like: boolean = true;
- action() {}
- }
-
- function setup() {
- const fixture = TestBed.configureTestingModule({
- declarations: [MdcIconToggleDirective, MdcIconToggleIconDirective, TestComponent]
- }).createComponent(TestComponent);
- fixture.detectChanges();
- return { fixture };
- }
-
- it('should render the icon toggles with icon and ripple styles', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.nativeElement.querySelector('span#icon');
- expect(iconToggle.classList).toContain('mdc-icon-toggle');
- expect(hasRipple(iconToggle)).toBe(true);
- const icon = iconToggle.querySelector('i.fa');
- expect(icon).toBeDefined();
- expect(icon.classList).toContain('fa-heart');
- expect(icon.classList).toContain('fa');
- }));
-
- it('should change appearance when behavioral properties are changed', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
- const icon = fixture.nativeElement.querySelector('i.fa');
- expect(icon.classList).toContain('fa-heart');
-
- iconToggle.labelOn = 'Open envelope';
- iconToggle.labelOff = 'Close envelope';
- iconToggle.iconOn = 'fa-envelope';
- iconToggle.iconOff = 'fa-envelope-open-o';
-
- fixture.detectChanges();
- tick();
-
- expect(iconToggle._elm.nativeElement.getAttribute('aria-label')).toBe('Open envelope');
- expect(icon.classList.contains('fa-heart')).toBe(false, 'actual classes: ' + icon.classList);
- expect(icon.classList).toContain('fa-envelope');
- expect(icon.classList).toContain('fa');
- }));
-
- it('should toggle state when clicked', fakeAsync(() => {
- const { fixture } = setup();
- const iconToggle = fixture.debugElement.query(By.directive(MdcIconToggleDirective)).injector.get(MdcIconToggleDirective);
- const icon = fixture.nativeElement.querySelector('i.fa');
-
- expect(icon.classList.contains('fa-heart')).toBe(true, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(false, icon.classList);
-
- icon.click(); tick(); fixture.detectChanges();
-
- expect(icon.classList.contains('fa-heart')).toBe(false, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(true, icon.classList);
-
- icon.click(); tick(); fixture.detectChanges();
-
- expect(icon.classList.contains('fa-heart')).toBe(true, icon.classList);
- expect(icon.classList.contains('fa-heart-o')).toBe(false, icon.classList);
- }));
-});
diff --git a/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.ts b/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.ts
deleted file mode 100644
index c631f70..0000000
--- a/bundle/src/components/icon-toggle/mdc.icon-toggle.directive.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-import { AfterContentInit, AfterViewInit, Component, ContentChild, ContentChildren, Directive, ElementRef, EventEmitter, forwardRef, HostBinding,
- HostListener, Input, OnDestroy, OnInit, Output, Provider, Renderer2, Self, ViewChild,
- ViewEncapsulation } from '@angular/core';
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { MDCRipple } from '@material/ripple';
-import { MDCIconToggleFoundation } from '@material/icon-toggle';
-import { MdcIconToggleAdapter } from './mdc.icon-toggle.adapter';
-import { asBoolean, asBooleanOrNull } from '../../utils/value.utils';
-import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-import { AbstractMdcIcon } from '../icon-button/abstract.mdc.icon';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-/**
- * Directive for an icon nested inside a MdcIconToggleDirective
.
- * This directive is only needed when the icon font uses CSS pseudo-elements in order
- * to provide the icon. This is how Font Awesome, and many other icon font libraries
- * provide the icons.
- * For icon fonts that don't use pseudo elements (such as the Material Design Icons from Google),
- * this directive is not necessary.
- */
-@Directive({
- selector: '[mdcIconToggleIcon]'
-})
-export class MdcIconToggleIconDirective {
-}
-
-/**
- * Directive for creating a Material Design icon toggle button.
- * The icon toggle is fully accessible, and works with any icon font.
- * When the icon font uses CSS pseudo-elements in order to display the icon,
- * embed an MdcIconToggleIconDirective
inside this directive for
- * the actual icon. (Otherwise the pseudo-elements used for showing the icon
- * will interfere with the pseudo-elements this directive uses for showing
- * ripple styles).
- */
-@Directive({
- selector: '[mdcIconToggle]',
- providers: [{provide: AbstractMdcIcon, useExisting: forwardRef(() => MdcIconToggleDirective) }]
-})
-export class MdcIconToggleDirective extends AbstractMdcIcon implements AfterContentInit {
- @HostBinding('class.mdc-icon-toggle') _hostClass = true;
- @HostBinding('attr.role') _role: string = 'button';
- @ContentChild(MdcIconToggleIconDirective, {read: ElementRef}) _innerIcon: ElementRef;
- /**
- * Event emitted when the state of the icon changes (for example when a user clicks
- * the icon).
- */
- @Output() onChange: EventEmitter = new EventEmitter();
- private _onChange: (value: any) => void = (value) => {};
- private _onTouched: () => any = () => {};
- private _beforeInitQueu: Array<() => any> = [];
- private _initialized = false;
- private _labelOn: string;
- private _labelOff: string;
- private _iconOn: string;
- private _iconOff: string;
- private _iconIsClass: boolean;
- private mdcAdapter: MdcIconToggleAdapter = {
- addClass: (className: string) => {
- let inner = this._innerIcon && this._iconIsClass !== false && (className === this._iconOn || className === this._iconOff);
- this.renderer.addClass(inner ? this._innerIcon.nativeElement : this._elm.nativeElement, className);
- },
- removeClass: (className: string) => {
- let inner = this._innerIcon && this._iconIsClass !== false && (className === this._iconOn || className === this._iconOff);
- this.renderer.removeClass(inner ? this._innerIcon.nativeElement : this._elm.nativeElement, className);
- },
- registerInteractionHandler: (type: string, handler: EventListener) => {
- this.registry.listen(this.renderer, type, handler, this._elm);
- },
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
- this.registry.unlisten(type, handler);
- },
- setText: (text: string) => {
- if (this._innerIcon)
- this._innerIcon.nativeElement.textContent = text;
- else
- this._elm.nativeElement.textContent = text;
- },
- getTabIndex: () => this._elm.nativeElement.tabIndex,
- setTabIndex: (tabIndex: number) => { this._elm.nativeElement.tabIndex = tabIndex; },
- getAttr: (name: string) => this._elm.nativeElement.getAttribute(name),
- setAttr: (name: string, value: string) => { this.renderer.setAttribute(this._elm.nativeElement, name, value); },
- rmAttr: (name: string) => { this.renderer.removeAttribute(this._elm.nativeElement, name); },
- notifyChange: (evtData: {isOn: boolean}) => {
- this._onChange(evtData.isOn);
- this.onChange.emit(evtData.isOn);
- }
- };
- private foundation: {
- init(),
- destroy(),
- setDisabled(disabled: boolean),
- isDisabled(): boolean,
- isOn(): boolean,
- toggle(isOn?: boolean)
- refreshToggleData(),
- isKeyboardActivated(): boolean
- } = new MDCIconToggleFoundation(this.mdcAdapter);
-
- constructor(_elm: ElementRef, private renderer: Renderer2, private registry: MdcEventRegistry) {
- super(_elm, renderer, registry);
- }
-
- ngAfterContentInit() {
- this.initDefaultAttributes();
- this.initializeData();
- this.foundation.init();
- // run all deferred foundation interactions:
- for (let fun of this._beforeInitQueu)
- fun();
- this._beforeInitQueu = [];
- // the foundation doesn't initialize the iconOn/iconOff and labelOn/labelOff until
- // toggle is called for the first time,
- // also, this will ensure 'aria-pressed' and 'aria-label' attributes are initialized:
- this.foundation.toggle(this.foundation.isOn());
- this.initRipple();
- this._initialized = true;
- }
-
- ngOnDestroy() {
- this.destroyRipple();
- this.foundation.destroy();
- }
-
- private execAfterInit(fun: () => any) {
- if (this._initialized)
- fun();
- else
- this._beforeInitQueu.push(fun);
- }
-
- private refreshData() {
- if (this._initialized) {
- this.initializeData();
- this.foundation.refreshToggleData();
- // refreshToggleData does not actually apply the new config to the icon:
- this.foundation.toggle(this.foundation.isOn());
- }
- }
-
- private initDefaultAttributes() {
- if (!this._elm.nativeElement.hasAttribute('tabindex'))
- // unless overridden by another tabIndex, we want icon-toggles to
- // participate in tabbing (the foundation will remove the tabIndex
- // when the icon-toggle is disabled):
- this._elm.nativeElement.tabIndex = 0;
- }
-
- private initializeData() {
- // iconOn/iconOff are classes when the iconIsClass is true, or when iconIsClass is not set,
- // and _innerIcon is used (because _innerIcon is specifically for cases where icons are set via pseudo elements
- // by using classes):
- let iconIsClass = this.renderIconAsClass;
- this.renderer.setAttribute(this._elm.nativeElement, 'data-toggle-on',
- this.createDataAttrForToggle(this._labelOn, this._iconOn, iconIsClass));
- this.renderer.setAttribute(this._elm.nativeElement, 'data-toggle-off',
- this.createDataAttrForToggle(this._labelOff, this._iconOff, iconIsClass));
- }
-
- private get renderIconAsClass() {
- return this._iconIsClass == null ? this._innerIcon != null : this._iconIsClass;
- }
-
- private createDataAttrForToggle(label: string, icon: string, iconIsClass: boolean) {
- let data = {
- label: label
- };
- data[iconIsClass ? 'cssClass' : 'content'] = icon;
- return JSON.stringify(data);
- }
-
- /** @docs-private */
- writeValue(obj: any) {
- this.execAfterInit(() => this.foundation.toggle(!!obj));
- }
-
- /** @docs-private */
- registerOnChange(onChange: (value: any) => void) {
- this._onChange = onChange;
- }
-
- /** @docs-private */
- registerOnTouched(onTouched: () => any) {
- this._onTouched = onTouched;
- }
-
- /** @docs-private */
- setDisabledState(disabled: boolean) {
- this.disabled = disabled;
- }
-
- /** @docs-private */
- protected isRippleUnbounded() {
- return true;
- }
-
- /** @docs-private */
- protected isRippleSurfaceActive() {
- return this.foundation.isKeyboardActivated();
- }
-
- /**
- * The current state of the icon (true for on/pressed, false for off/unpressed).
- */
- @Input() get on() {
- return this.foundation.isOn();
- }
-
- set on(value: any) {
- this.execAfterInit(() => this.foundation.toggle(asBoolean(value)));
- }
-
- /**
- * The aria-label to use for the on/pressed state of the icon.
- */
- @Input() get labelOn() {
- return this._labelOn;
- }
-
- set labelOn(value: string) {
- this._labelOn = value;
- this.refreshData();
- }
-
- /**
- * The aria-label to use for the off/unpressed state of the icon.
- */
- @Input() get labelOff() {
- return this._labelOff;
- }
-
- set labelOff(value: string) {
- this._labelOff = value;
- this.refreshData();
- }
-
- /**
- * The icon to use for the on/pressed state of the icon.
- */
- @Input() get iconOn() {
- return this._iconOn;
- }
-
- set iconOn(value: string) {
- if (value !== this._iconOn) {
- if (this.renderIconAsClass)
- // the adapter doesn't clean up old classes; this class may be set,
- // in which case after it's changed the foundation won't be able to remove it anymore:
- this.mdcAdapter.removeClass(this._iconOn);
- this._iconOn = value;
- this.refreshData();
- }
- }
-
- /**
- * The icon to use for the off/unpressed state of the icon.
- */
- @Input() get iconOff() {
- return this._iconOff;
- }
-
- set iconOff(value: string) {
- if (value !== this._iconOff) {
- if (this.renderIconAsClass)
- // the adapter doesn't clean up old classes; this class may be set,
- // in which case after it's changed the foundation won't be able to remove it anymore:
- this.mdcAdapter.removeClass(this._iconOff);
- this._iconOff = value;
- this.refreshData();
- }
- }
-
- /**
- * Some icon fonts (such as Font Awesome) use CSS class names to select the icon to show.
- * Others, such as the Material Design Icons from Google use ligatures (allowing selection of
- * the icon by using their textual name). When iconIsClass
is true, the directive
- * assumes iconOn
, and iconOff
represent class names. When
- * iconIsClass
is false, the directive assumes the use of ligatures.
- *
- * When iconIsClass
is not assigned, the directive bases its decision on whether
- * or not an embedded MdcIconToggleIconDirective
is used.
- * In most cases you won't need to set this input, as the default based on an embedded
- * MdcIconToggleIconDirective
is typically what you need.
- */
- @Input() get iconIsClass() {
- return this._iconIsClass;
- }
-
- set iconIsClass(value: any) {
- this._iconIsClass = asBooleanOrNull(value);
- this.refreshData();
- }
-
- /**
- * To disable the icon toggle, set this input to true.
- */
- @Input() get disabled() {
- return this.foundation.isDisabled();
- }
-
- set disabled(value: any) {
- this.execAfterInit(() => {
- let newValue = asBoolean(value);
- // we only set the disabled state if it changes from the current value.
- // if we don't do that, then calling setDisabled(false) after initialization
- // will clear the tabIndex. So this works around a bug in @material/icon-toggle:
- if (this.foundation.isDisabled() != newValue)
- this.foundation.setDisabled(asBoolean(value));
- });
- }
-
- @HostListener('(blur') _onBlur() {
- this._onTouched();
- }
-}
-
-/**
- * Directive for adding Angular Forms (ControlValueAccessor
) behavior to an
- * MdcIconToggleDirective
. Allows the use of the Angular Forms API with
- * icon toggles, e.g. binding to [(ngModel)]
, form validation, etc.
- */
-@Directive({
- selector: '[mdcIconToggle][formControlName],[mdcIconToggle][formControl],[mdcIconToggle][ngModel]',
- providers: [
- {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MdcFormsIconToggleDirective), multi: true}
- ]
-})
-export class MdcFormsIconToggleDirective implements ControlValueAccessor {
- constructor(@Self() private mdcIconToggle: MdcIconToggleDirective) {
- }
-
- /** @docs-private */
- writeValue(obj: any) {
- this.mdcIconToggle.writeValue(obj);
- }
-
- /** @docs-private */
- registerOnChange(onChange: (value: any) => void) {
- this.mdcIconToggle.registerOnChange(onChange);
- }
-
- /** @docs-private */
- registerOnTouched(onTouched: () => any) {
- this.mdcIconToggle.registerOnTouched(onTouched);
- }
-
- /** @docs-private */
- setDisabledState(disabled: boolean) {
- this.mdcIconToggle.setDisabledState(disabled);
- }
-}
diff --git a/bundle/src/components/line-ripple/mdc.line-ripple.adapter.ts b/bundle/src/components/line-ripple/mdc.line-ripple.adapter.ts
deleted file mode 100644
index 27e632b..0000000
--- a/bundle/src/components/line-ripple/mdc.line-ripple.adapter.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { MDCLineRippleFoundation } from '@material/line-ripple';
-
-/** @docs-private */
-export interface MdcLineRippleAdapter {
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- hasClass: (className) => boolean,
- setStyle: (name: string, value: string) => void,
- registerEventHandler: (evtType: string, handler: EventListener) => void,
- deregisterEventHandler: (evtType: string, handler: EventListener) => void
-}
\ No newline at end of file
diff --git a/bundle/src/components/linear-progress/mdc.linear-progress.adapter.ts b/bundle/src/components/linear-progress/mdc.linear-progress.adapter.ts
deleted file mode 100644
index 76ae67d..0000000
--- a/bundle/src/components/linear-progress/mdc.linear-progress.adapter.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/** @docs-private */
-export interface MdcLinearProgressAdapter {
- addClass: (className: string) => void;
- getPrimaryBar: () => Element;
- getBuffer: () => Element;
- hasClass: (className: string) => boolean;
- removeClass: (className: string) => void;
- setStyle: (el: Element, styleProperty: string, value: number) => void;
-}
\ No newline at end of file
diff --git a/bundle/src/components/linear-progress/mdc.linear-progress.directive.spec.ts b/bundle/src/components/linear-progress/mdc.linear-progress.directive.spec.ts
new file mode 100644
index 0000000..e6711d2
--- /dev/null
+++ b/bundle/src/components/linear-progress/mdc.linear-progress.directive.spec.ts
@@ -0,0 +1,119 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Component } from '@angular/core';
+import { MdcLinearProgressDirective } from './mdc.linear-progress.directive';
+
+describe('mdcLinearProgress', () => {
+ @Component({
+ template: `
+
+ `
+ })
+ class TestComponent {
+ progress = 0;
+ buffer = 0;
+ closed = false;
+ indeterminate = false;
+ reversed = false;
+ }
+
+ function setup() {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [MdcLinearProgressDirective, TestComponent]
+ }).createComponent(TestComponent);
+ fixture.detectChanges(); tick();
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const linearProgress = fixture.debugElement.query(By.directive(MdcLinearProgressDirective)).injector.get(MdcLinearProgressDirective);
+ const element = fixture.nativeElement.querySelector('.mdc-linear-progress');
+ return { fixture, linearProgress, element, testComponent };
+ }
+
+ it('should render with default values, styles, and attributes', fakeAsync(() => {
+ const { element } = setup();
+ expect(element).toBeDefined();
+ expect(element.classList).not.toContain('mdc-linear-progress--indeterminate');
+ expect(element.getAttribute('role')).toBe('progressbar');
+ expect(element.getAttribute('aria-valuemin')).toBe('0');
+ expect(element.getAttribute('aria-valuemax')).toBe('1');
+ expect(element.getAttribute('aria-valuenow')).toBe('0');
+ expect(element.getAttribute('aria-label')).toBe('My Progress');
+ }));
+
+ it('should show buffer and progress changes', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+ const primaryBar = fixture.nativeElement.querySelector('.mdc-linear-progress__primary-bar');
+ const buffer = fixture.nativeElement.querySelector('.mdc-linear-progress__buffer');
+
+ expect(element.getAttribute('aria-valuenow')).toBe('0');
+ expect(primaryBar.style.transform).toBe('scaleX(0)');
+ expect(buffer.style.transform).toBe('scaleX(0)');
+ testComponent.progress = 0.2;
+ testComponent.buffer = 0.9;
+ fixture.detectChanges(); tick();
+ expect(element.getAttribute('aria-valuenow')).toBe('0.2');
+ expect(primaryBar.style.transform).toBe('scaleX(0.2)');
+ expect(buffer.style.transform).toBe('scaleX(0.9)');
+ }));
+
+ it('can be shown reversed', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+
+ expect(element.classList).not.toContain('mdc-linear-progress--reversed');
+ testComponent.progress = 0.3;
+ testComponent.buffer = 0.6;
+ testComponent.reversed = true;
+ fixture.detectChanges(); tick();
+ expect(element.classList).toContain('mdc-linear-progress--reversed');
+ expect(element.getAttribute('aria-valuenow')).toBe('0.3');
+
+ testComponent.reversed = false;
+ fixture.detectChanges(); tick();
+ expect(element.classList).not.toContain('mdc-linear-progress--reversed');
+ }));
+
+ it('can be indeterminate', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+ const primaryBar = fixture.nativeElement.querySelector('.mdc-linear-progress__primary-bar');
+ const buffer = fixture.nativeElement.querySelector('.mdc-linear-progress__buffer');
+
+ expect(element.classList).not.toContain('mdc-linear-progress--indeterminate');
+ testComponent.progress = 0.3;
+ testComponent.buffer = 0.6;
+ testComponent.indeterminate = true;
+ fixture.detectChanges(); tick();
+ expect(element.classList).toContain('mdc-linear-progress--indeterminate');
+ expect(element.getAttribute('aria-valuenow')).toBeNull();
+ expect(primaryBar.style.transform).toBe('scaleX(1)');
+ expect(buffer.style.transform).toBe('scaleX(1)');
+
+ testComponent.indeterminate = false;
+ fixture.detectChanges(); tick();
+ expect(element.classList).not.toContain('mdc-linear-progress--indeterminate');
+ expect(element.getAttribute('aria-valuenow')).toBe('0.3');
+ expect(primaryBar.style.transform).toBe('scaleX(0.3)');
+ expect(buffer.style.transform).toBe('scaleX(0.6)');
+ }));
+
+ it('can be closed', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+ const primaryBar = fixture.nativeElement.querySelector('.mdc-linear-progress__primary-bar');
+ const buffer = fixture.nativeElement.querySelector('.mdc-linear-progress__buffer');
+
+ expect(element.classList).not.toContain('mdc-linear-progress--indeterminate');
+ testComponent.progress = 0.3;
+ testComponent.buffer = 0.6;
+ testComponent.closed = true;
+ fixture.detectChanges(); tick();
+ expect(element.classList).toContain('mdc-linear-progress--closed');
+ expect(element.getAttribute('aria-valuenow')).toBe('0.3');
+ expect(primaryBar.style.transform).toBe('scaleX(0.3)');
+ expect(buffer.style.transform).toBe('scaleX(0.6)');
+ // TODO-UPSTREAM: shouldn't this be aria-hidden?
+
+ testComponent.closed = false;
+ fixture.detectChanges(); tick();
+ expect(element.classList).not.toContain('mdc-linear-progress--closed');
+ expect(element.getAttribute('aria-valuenow')).toBe('0.3');
+ }));
+});
diff --git a/bundle/src/components/linear-progress/mdc.linear-progress.directive.ts b/bundle/src/components/linear-progress/mdc.linear-progress.directive.ts
index 38eea68..bea7d5d 100644
--- a/bundle/src/components/linear-progress/mdc.linear-progress.directive.ts
+++ b/bundle/src/components/linear-progress/mdc.linear-progress.directive.ts
@@ -1,23 +1,10 @@
import { AfterContentInit, Directive, ElementRef, HostBinding, Input, OnDestroy, Renderer2 } from '@angular/core';
-import { MDCLinearProgressFoundation, strings } from '@material/linear-progress';
-import { MdcLinearProgressAdapter } from './mdc.linear-progress.adapter';
+import { MDCLinearProgressFoundation, MDCLinearProgressAdapter } from '@material/linear-progress';
import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
const CLASS_INDETERMINATE = 'mdc-linear-progress--indeterminate';
const CLASS_REVERSED = 'mdc-linear-progress--reversed';
-interface MdcLinearProgressFoundationInterface {
- init();
- destroy();
- setDeterminate(isDeterminate: boolean);
- setProgress(value: number);
- setBuffer(value: number);
- setReverse(isReversed: boolean);
- open();
- close();
-}
-
/**
* Directive for creating a Material Design linear progress indicator.
* The current implementation will add and manage all DOM child elements that
@@ -29,18 +16,30 @@ interface MdcLinearProgressFoundationInterface {
selector: '[mdcLinearProgress]'
})
export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-linear-progress') _cls = true;
+ /** @internal */
+ @HostBinding('class.mdc-linear-progress') readonly _cls = true;
+ /** @internal */
@HostBinding('attr.role') _role: string = 'progressbar';
- private _initialized = false;
+ /** @internal */
+ @HostBinding('attr.aria-valuemin') _min = 0;
+ /** @internal */
+ @HostBinding('attr.aria-valuemax') _max = 1;
+ /** @internal */
@HostBinding('class.' + CLASS_INDETERMINATE) _indeterminate = false;
+ /** @internal */
@HostBinding('class.' + CLASS_REVERSED) _reverse = false;
private _progress = 0;
private _buffer = 1;
private _closed = false;
- private _elmBuffer: HTMLElement;
- private _elmPrimaryBar: HTMLElement;
+ private _elmBuffer: HTMLElement | null = null;
+ private _elmPrimaryBar: HTMLElement | null = null;
+ /**
+ * Label indicationg how the progress bar should be announced to the user.
+ * Determines the à ria-label` attribute value.
+ */
+ @HostBinding('attr.aria-label') @Input() label: string | null = null;
- private mdcAdapter: MdcLinearProgressAdapter = {
+ private mdcAdapter: MDCLinearProgressAdapter = {
addClass: (className: string) => {
if (className !== CLASS_INDETERMINATE && className != CLASS_REVERSED)
this._rndr.addClass(this._root.nativeElement, className);
@@ -58,19 +57,22 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
if (className !== CLASS_INDETERMINATE && className != CLASS_REVERSED)
this._rndr.removeClass(this._root.nativeElement, className);
},
- setStyle: (el: Element, styleProperty: string, value: number) => {
+ setStyle: (el, styleProperty, value) => {
this._rndr.setStyle(el, styleProperty, value);
- }
+ },
+ forceLayout: () => this._root.nativeElement.offsetWidth,
+ removeAttribute: (name) => this._rndr.removeAttribute(this._root.nativeElement, name),
+ setAttribute: (name, value) => this._rndr.setAttribute(this._root.nativeElement, name, value)
};
- private foundation: MdcLinearProgressFoundationInterface = new MDCLinearProgressFoundation(this.mdcAdapter);
+ private foundation: MDCLinearProgressFoundation | null = null;
- constructor(private _rndr: Renderer2, private _root: ElementRef, private _registry: MdcEventRegistry) {
+ constructor(private _rndr: Renderer2, private _root: ElementRef) {
}
ngAfterContentInit() {
this.initElements();
+ this.foundation = new MDCLinearProgressFoundation(this.mdcAdapter);
this.foundation.init();
- this._initialized = true;
this.foundation.setProgress(this._progress);
this.foundation.setBuffer(this._buffer);
if (this._closed)
@@ -78,16 +80,18 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
}
ngOnDestroy() {
- this.foundation.destroy();
+ this.foundation?.destroy();
+ this._elmPrimaryBar = null;
+ this._elmBuffer = null;
}
private initElements() {
- const elmBufferingDots = this.addElement(this._root.nativeElement, 'div', ['mdc-linear-progress__buffering-dots']);
+ this.addElement(this._root.nativeElement, 'div', ['mdc-linear-progress__buffering-dots']);
this._elmBuffer = this.addElement(this._root.nativeElement, 'div', ['mdc-linear-progress__buffer']);
this._elmPrimaryBar = this.addElement(this._root.nativeElement, 'div', ['mdc-linear-progress__bar', 'mdc-linear-progress__primary-bar']);
- this.addElement(this._elmPrimaryBar, 'span', ['mdc-linear-progress__bar-inner']);
+ this.addElement(this._elmPrimaryBar!, 'span', ['mdc-linear-progress__bar-inner']);
const secondaryBar = this.addElement(this._root.nativeElement, 'div', ['mdc-linear-progress__bar', 'mdc-linear-progress__secondary-bar']);
- this.addElement(this._elmPrimaryBar, 'span', ['mdc-linear-progress__bar-inner']);
+ this.addElement(secondaryBar, 'span', ['mdc-linear-progress__bar-inner']);
}
private addElement(parent: HTMLElement, element: string, classNames: string[]) {
@@ -103,16 +107,17 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
* Puts the progress indicator in 'indeterminate' state, signaling
* that the exact progress on a measured task is not known.
*/
- @Input() @HostBinding('class.' + CLASS_INDETERMINATE)
+ @Input()
+ @HostBinding('class.' + CLASS_INDETERMINATE)
get indeterminate() {
return this._indeterminate;
}
- set indeterminate(value: any) {
+ set indeterminate(value: boolean) {
let newValue = asBoolean(value);
if (newValue !== this._indeterminate) {
this._indeterminate = newValue;
- if (this._initialized) {
+ if (this.foundation) {
this.foundation.setDeterminate(!this._indeterminate);
if (!this._indeterminate) {
this.foundation.setProgress(this._progress);
@@ -122,6 +127,8 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
}
}
+ static ngAcceptInputType_indeterminate: boolean | '';
+
/**
* Reverses the direction of the linear progress indicator.
*/
@@ -130,12 +137,13 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
return this._reverse;
}
- set reversed(value: any) {
+ set reversed(value: boolean) {
this._reverse = asBoolean(value);
- if (this._initialized)
- this.foundation.setReverse(this._reverse);
+ this.foundation?.setReverse(this._reverse);
}
+ static ngAcceptInputType_reversed: boolean | '';
+
/**
* Set the progress, the value should be between [0, 1].
*/
@@ -144,12 +152,13 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
return this._progress;
}
- set progressValue(value: number | string) {
+ set progressValue(value: number) {
this._progress = +value;
- if (this._initialized)
- this.foundation.setProgress(this._progress);
+ this.foundation?.setProgress(this._progress);
}
+ static ngAcceptInputType_progressValue: number | string;
+
/**
* Set the buffer progress, the value should be between [0, 1].
*/
@@ -158,12 +167,13 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
return this._buffer;
}
- set bufferValue(value: number | string) {
+ set bufferValue(value: number) {
this._buffer = +value;
- if (this._initialized)
- this.foundation.setBuffer(this._buffer);
+ this.foundation?.setBuffer(this._buffer);
}
+ static ngAcceptInputType_bufferValue: number | string;
+
/**
* When set to true this closes (animates away) the progress bar,
* when set to false this opens (animates into view) the progress bar.
@@ -173,16 +183,20 @@ export class MdcLinearProgressDirective implements AfterContentInit, OnDestroy {
return this._closed;
}
- set closed(value: any) {
+ set closed(value: boolean) {
let newValue = asBoolean(value);
if (newValue !== this._closed) {
this._closed = newValue;
- if (this._initialized) {
- if (newValue)
- this.foundation.close();
- else
- this.foundation.open();
- }
+ if (newValue)
+ this.foundation?.close();
+ else
+ this.foundation?.open();
}
}
+
+ static ngAcceptInputType_closed: boolean | '';
}
+
+export const LINEAR_PROGRESS_DIRECTIVES = [
+ MdcLinearProgressDirective
+];
diff --git a/bundle/src/components/list/mdc.list.directive.spec.ts b/bundle/src/components/list/mdc.list.directive.spec.ts
new file mode 100644
index 0000000..2bdbf07
--- /dev/null
+++ b/bundle/src/components/list/mdc.list.directive.spec.ts
@@ -0,0 +1,370 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { LIST_DIRECTIVES } from './mdc.list.directive';
+import { hasRipple } from '../../testutils/page.test';
+
+// TODO tests for checbox/radio input controlled list items
+
+describe('mdcList', () => {
+ @Component({
+ template: `
+
+ `
+ })
+ class TestComponent {
+ actions: number[] = [];
+ selectedChange: {index: number, value: boolean}[] = [];
+ items = ['item 1', 'item 2', 'item 3'];
+ nonInteractive = false;
+ selectionMode: string = null;
+ disabled: number = null;
+ action(i: number) {
+ this.actions.push(i);
+ }
+ active(value: boolean, index: number) {
+ this.selectedChange.push({index, value});
+ }
+ }
+
+ it('should render the list and items with correct styles and attributes', fakeAsync(() => {
+ const { list, items } = setup();
+ expect(list).toBeDefined();
+ expect(list.getAttribute('role')).toBeNull();
+ expect(list.getAttribute('tabindex')).toBeNull();
+ expect(items.length).toBe(3);
+ // by default items are set to be focusable, but only the first item is tabbable (tabindex=-1):
+ expectTabbable(items, 0);
+ expectRoles(items, null);
+ expect(items.map(it => it.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']);
+ items.forEach(item => expect(hasRipple(item)).toBe(true));
+ }));
+
+ it('clicking an item affects tabindexes', fakeAsync(() => {
+ const { fixture, items } = setup();
+
+ // focus by clicking:
+ focusItem(fixture, items, 1);
+ expectTabbable(items, 1);
+
+ // remove focus should restore tabindex=0 on first item:
+ blurItem(fixture, items, 1);
+ expectTabbable(items, 0);
+ }));
+
+ it('keyboard navigation affects tabindexes', fakeAsync(() => {
+ const { fixture, items } = setup();
+
+ // tabbing will focus the element with tabindex=0 (default first elm):
+ items[0].focus();
+ items[0].dispatchEvent(newFocusEvent('focusin'));
+ tick(); fixture.detectChanges();
+ expectTabbable(items, 0); // tabindexes on items should not have been changed
+ // Down key:
+ items[0].dispatchEvent(newKeydownEvent('ArrowDown'));
+ tick(); fixture.detectChanges();
+ expectTabbable(items, 1);
+ // focusOut should make first element tabbable again:
+ blurItem(fixture, items, 1);
+ expectTabbable(items, 0);
+ }));
+
+ it('adding items will not change focus', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup();
+
+ focusItem(fixture, items, 1);
+ testComponent.items = ['new text', ...testComponent.items];
+ fixture.detectChanges(); tick();
+ const currentItems: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-list-item')];
+ expect(currentItems.length).toBe(4);
+ expectTabbable(currentItems, 2); // next item, because one was inserted before
+ blurItem(fixture, currentItems, 2);
+ expectTabbable(currentItems, 0);
+ }));
+
+ it('nonInteractive lists ignore interaction and are not focusable', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup();
+ testComponent.nonInteractive = true;
+ fixture.detectChanges();
+
+ expectTabbable(items, -1); // not focusable
+ focusItem(fixture, items, 1); // will not focus:
+ expectTabbable(items, -1); // not focusable
+ // not listening to keyboard input:
+ items[0].dispatchEvent(newKeydownEvent('ArrowDown'));
+ tick(); fixture.detectChanges();
+ expectTabbable(items, -1);
+ // no action events have been emitted:
+ expect(testComponent.actions).toEqual([]);
+ expect(testComponent.selectedChange).toEqual([]);
+ }));
+
+ it('disabled items are correctly styled, not actionable, and not selectable', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup();
+ testComponent.disabled = 1;
+ fixture.detectChanges(); tick();
+ expectDisabled(items, 1);
+
+ testComponent.selectionMode = 'single';
+ fixture.detectChanges(); tick();
+ // try to focus and activate disabled item:
+ focusItem(fixture, items, 1);
+ expectTabbable(items, 1); // is focusable
+ expectActive(items, -1, 'selected'); // can not be activated
+ // no action events have been emitted:
+ expect(testComponent.actions).toEqual([]);
+ expect(testComponent.selectedChange).toEqual([]);
+ }));
+
+ it('selectionMode=single/current', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup();
+ testComponent.selectionMode = 'single';
+ fixture.detectChanges(); tick();
+ validateSelectionMode('selected', -1);
+ testComponent.selectionMode = 'active';
+ fixture.detectChanges(); tick();
+ testComponent.actions.length = 0;
+ testComponent.selectedChange.length = 0;
+ validateSelectionMode('current', 2);
+
+ function validateSelectionMode(type, initialActive) {
+ expectActive(items, initialActive, type);
+
+ // activate on click:
+ focusItem(fixture, items, 1);
+ expectTabbable(items, 1);
+ expectActive(items, 1, type);
+ // should also emit action event:
+ expect(testComponent.actions).toEqual([1]);
+ if (initialActive !== -1)
+ expect(testComponent.selectedChange).toEqual([
+ {index: initialActive, value: false},
+ {index: 1, value: true}
+ ]);
+ else
+ expect(testComponent.selectedChange).toEqual([
+ {index: 1, value: true}
+ ]);
+ testComponent.selectedChange.length = 0;
+
+ // active on keyboard on input:
+ items[1].dispatchEvent(newKeydownEvent('ArrowDown'));
+ tick(); fixture.detectChanges();
+ expectTabbable(items, 2);
+ expectActive(items, 1,type);
+ items[2].dispatchEvent(newKeydownEvent('Enter'));
+ tick(); fixture.detectChanges();
+ expectActive(items, 2, type);
+ // should also emit action event:
+ expect(testComponent.actions).toEqual([1, 2]);
+ expect(testComponent.selectedChange).toEqual([
+ {index: 1, value: false},
+ {index: 2, value: true}
+ ]);
+ }
+ }));
+
+ @Component({
+ template: `
+
+
Header
+
+
+
+
+ primary
+ secondary
+
+
+
+
+
+
+
+ `
+ })
+ class TestOptionalDirectivesComponent {
+ inset = null;
+ padded = null;
+ }
+ it('should render optional directives correctly', fakeAsync(() => {
+ const { fixture, list, testComponent } = setup(TestOptionalDirectivesComponent);
+
+ expect(fixture.nativeElement.querySelector('div.mdc-list-group')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('h3.mdc-list-group__subheader')).not.toBeNull();
+ expect(list.classList).toContain('mdc-list--two-line');
+ expect(fixture.nativeElement.querySelector('li.mdc-list-item')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('span.mdc-list-item__text')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('span.mdc-list-item__primary-text')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('span.mdc-list-item__secondary-text')).not.toBeNull();
+ const itemDivider = fixture.nativeElement.querySelector('li.mdc-list-divider');
+ expect(itemDivider.getAttribute('role')).toBe('separator');
+ expect(itemDivider.classList).not.toContain('mdc-list-divider--inset');
+ expect(itemDivider.classList).not.toContain('mdc-list-divider--padded');
+ const listDivider = fixture.nativeElement.querySelector('hr.mdc-list-divider');
+ expect(listDivider.getAttribute('role')).toBeNull();
+ expect(fixture.nativeElement.querySelector('span.mdc-list-item__graphic')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('span.mdc-list-item__meta')).not.toBeNull();
+
+ testComponent.inset = true;
+ testComponent.padded = true;
+ fixture.detectChanges();
+ expect(itemDivider.classList).toContain('mdc-list-divider--inset');
+ expect(itemDivider.classList).toContain('mdc-list-divider--padded');
+ }));
+
+ @Component({
+ template: `
+
+ `
+ })
+ class TestProgrammaticActivationComponent {
+ selectedChange: {value: string, active: boolean}[] = [];
+ items = [
+ {value: 'item1', active: true},
+ {value: 'item2', active: false},
+ {value: 'item3', active: false}
+ ];
+ nonInteractive = false;
+ selectionMode: string = null;
+ active(active: boolean, value: string) {
+ this.selectedChange.push({value, active});
+ }
+ }
+ it('single selection list: programmatic change of active/selected items', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup(TestProgrammaticActivationComponent);
+ expectActive(items, 0, 'selected', true); // first item selected, no aria-selected, because plain list should not have aria-selected attributes
+ expect(testComponent.selectedChange).toEqual([{active: true, value: 'item1'}]);
+ // switch to single selection mode:
+ testComponent.selectionMode = 'single';
+ testComponent.selectedChange = [];
+ fixture.detectChanges(); tick();
+ expectActive(items, 0, 'selected');
+ testComponent.items[0].active = false;
+ testComponent.items[2].active = true;
+ fixture.detectChanges(); tick();
+ expectActive(items, 2, 'selected');
+ expect(testComponent.selectedChange).toEqual([
+ {active: false, value: 'item1'},
+ {active: true, value: 'item3'},
+ ]);
+
+ testComponent.selectionMode = 'active';
+ fixture.detectChanges(); tick();
+ expectActive(items, 2, 'current');
+ testComponent.items[1].active = true;
+ fixture.detectChanges(); tick();
+ expectActive(items, 1, 'current'); // only the first of the two 'active' items selected
+ }));
+
+ it('simple list: programmatic change of active/selected items', fakeAsync(() => {
+ const { fixture, items, testComponent } = setup(TestProgrammaticActivationComponent);
+ expectActive(items, 0, 'selected', true);
+ expect(testComponent.selectedChange).toEqual([{active: true, value: 'item1'}]);
+ testComponent.selectedChange = [];
+ // activate all items:
+ testComponent.items[1].active = true;
+ testComponent.items[2].active = true;
+ fixture.detectChanges(); tick();
+ expectActive(items, [0, 1, 2], 'selected', true);
+ expect(testComponent.selectedChange).toEqual([
+ {active: true, value: 'item2'},
+ {active: true, value: 'item3'},
+ ]);
+
+ testComponent.nonInteractive = true;
+ testComponent.items[0].active = false;
+ fixture.detectChanges(); tick();
+ expectActive(items, [1, 2], 'selected', true);
+ }));
+
+ function expectTabbable(items: HTMLElement[], index: number) {
+ const expected = Array.apply(null, Array(items.length)).map((_, i) => i === index ? '0' : '-1');
+ expect(items.map(it => it.getAttribute('tabindex'))).toEqual(expected);
+ }
+
+ function expectRoles(items: HTMLElement[], role: string) {
+ items.forEach(item => expect(item.getAttribute('role')).toBe(role));
+ }
+
+ function expectActive(items: HTMLElement[], index: number | number[], type: 'current' | 'selected' | 'checked' | null, noAria = false) {
+ const indexes = typeof index === 'number' ? [index] : index;
+ const expectSelected = Array.apply(null, Array(items.length)).map((_, i) => indexes.indexOf(i) !== -1 && type === 'selected');
+ const expectActived = Array.apply(null, Array(items.length)).map((_, i) => indexes.indexOf(i) !== -1 && type === 'current');
+ const expectAriaSelected = Array.apply(null, Array(items.length)).map((_, i) => ariaValueForType('selected', i, noAria));
+ const expectAriaCurrent = Array.apply(null, Array(items.length)).map((_, i) => ariaValueForType('current', i, noAria));
+ const expectAriaChecked = Array.apply(null, Array(items.length)).map((_, i) => ariaValueForType('checked', i, noAria));
+ expect(items.map(it => it.classList.contains('mdc-list-item--selected'))).toEqual(expectSelected);
+ expect(items.map(it => it.classList.contains('mdc-list-item--activated'))).toEqual(expectActived);
+ expect(items.map(it => it.getAttribute('aria-selected'))).toEqual(expectAriaSelected);
+ expect(items.map(it => it.getAttribute('aria-current'))).toEqual(expectAriaCurrent);
+ expect(items.map(it => it.getAttribute('aria-checked'))).toEqual(expectAriaChecked);
+
+ function ariaValueForType(forType: 'current' | 'selected' | 'checked', i, noAria) {
+ if (!noAria && type === forType)
+ return indexes.indexOf(i) !== -1 ? 'true' : 'false';
+ return null;
+ }
+ }
+
+ function expectDisabled(items: HTMLElement[], index: number) {
+ const expected = Array.apply(null, Array(items.length)).map((_, i) => i === index);
+ expect(items.map(it => it.classList.contains('mdc-list-item--disabled'))).toEqual(expected);
+ expect(items.map(it => !!it.getAttribute('aria-disabled'))).toEqual(expected);
+ expect(items.map(it => it.getAttribute('aria-disabled') === 'true')).toEqual(expected);
+ }
+
+ function newFocusEvent(name: string) {
+ const event = document.createEvent('FocusEvent');
+ event.initEvent(name, true, true);
+ return event;
+ }
+
+ function newKeydownEvent(key: string) {
+ let event = new KeyboardEvent('keydown', {key});
+ event.initEvent('keydown', true, true);
+ return event;
+ }
+
+ function newClickEvent() {
+ let event = document.createEvent('MouseEvent');
+ event.initEvent('click', true, true);
+ return event;
+ }
+
+ function blurActive() {
+ (document.activeElement).blur();
+ }
+
+ function focusItem(fixture, items: HTMLElement[], index: number) {
+ items[index].focus();
+ items[index].dispatchEvent(newFocusEvent('focusin'));
+ items[index].dispatchEvent(newClickEvent());
+ tick(); fixture.detectChanges();
+ }
+
+ function blurItem(fixture, items, focusedIndex) {
+ blurActive();
+ items[focusedIndex].dispatchEvent(newFocusEvent('focusout'));
+ tick(); fixture.detectChanges();
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...LIST_DIRECTIVES, compType]
+ }).createComponent(compType);
+ fixture.detectChanges();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const list: HTMLElement = fixture.nativeElement.querySelector('.mdc-list');
+ const items: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-list-item')];
+ return { fixture, list, items, testComponent };
+ }
+});
diff --git a/bundle/src/components/list/mdc.list.directive.ts b/bundle/src/components/list/mdc.list.directive.ts
index 6ed7c1a..3124f8c 100644
--- a/bundle/src/components/list/mdc.list.directive.ts
+++ b/bundle/src/components/list/mdc.list.directive.ts
@@ -1,29 +1,38 @@
-import { AfterContentInit, ContentChildren, Directive, ElementRef, HostBinding,
- Input, OnDestroy, QueryList, Renderer2 } from '@angular/core';
+import { AfterContentInit, ContentChildren, Directive, ElementRef, HostBinding, Input, OnDestroy,
+ QueryList, Renderer2, Output, EventEmitter, HostListener, ChangeDetectorRef, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { MDCListFoundation, MDCListAdapter, strings, cssClasses } from '@material/list';
import { asBoolean } from '../../utils/value.utils';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';;
+import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+import { MdcRadioDirective } from '../radio/mdc.radio.directive';
+import { MdcCheckboxDirective } from '../checkbox/mdc.checkbox.directive';
+import { Subject, merge, ReplaySubject } from 'rxjs';
+import { takeUntil, debounceTime } from 'rxjs/operators';
/**
- * Directive for a separator in a list (between list items), or as a separator between lists.
- * This directive also adds a "role" attribute to its element (depending on the context
- * where the divider is used).
+ * Directive for a separator in a list (between list items), or as a separator in a
+ * list group (between lists).
+ *
+ * # Accessibility
+ * This directive adds a `role=separator` attribute when it is used as a separator
+ * between list items.
*/
@Directive({
- selector: '[mdcListDivider]',
+ selector: '[mdcListDivider]'
})
export class MdcListDividerDirective {
- @HostBinding('class.mdc-list-divider') _cls = true;
- @HostBinding('attr.role') _role = 'separator';
+ /** @internal */
+ @HostBinding('class.mdc-list-divider') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') _role: string | null = 'separator';
+ /** @internal */
@HostBinding('attr.disabled') _disabled = false;
private _inset = false;
private _padded = false;
- constructor(private _elm: ElementRef) {
- if (_elm.nativeElement.nodeName === 'OPTION') {
- this._role = 'presentation';
- this._disabled = true;
- } else if (_elm.nativeElement.nodeName === 'HR')
+ constructor(_elm: ElementRef) {
+ if (_elm.nativeElement.nodeName.toUpperCase() !== 'LI')
this._role = null;
}
@@ -36,10 +45,12 @@ export class MdcListDividerDirective {
return this._inset;
}
- set inset(val: any) {
+ set inset(val: boolean) {
this._inset = asBoolean(val);
}
+ static ngAcceptInputType_inset: boolean | '';
+
/**
* When this input is defined and does not have value false, the divider leaves
* gaps on each site to match the padding of mdcListItemMeta
.
@@ -49,34 +60,92 @@ export class MdcListDividerDirective {
return this._padded;
}
- set padded(val: any) {
+ set padded(val: boolean) {
this._padded = asBoolean(val);
}
+
+ static ngAcceptInputType_padded: boolean | '';
}
/**
* Directive for the items of a material list.
- * This directive should be used for the direct children of a MdcListDirective
.
+ * This directive should be used for the direct children (list items) of an
+ * `mdcList`.
+ *
+ * # Children
+ * * Use `mdcListItemText` for the text content of the list. One line and two line
+ * lists are supported. See `mdcListItemText` for more info.
+ * * Optional: `mdcListItemGraphic` for a starting detail (typically icon or image).
+ * * Optional: `mdcListItemMeta` for the end detail (typically icon or image).
+ *
+ * # Accessibility
+ * * All items in a list will get a `tabindex=-1` attribute to make them focusable,
+ * but not tabbable. The focused, active/current, or first (in that preference) item will
+ * get `tabindex=0`, so that the list can be tabbed into. Keyboard navigation
+ * between list items is done with arrow, home, and end keys. Keyboard based selection of
+ * an item (when items are selectable), can be done with the enter or space key.
+ * * The `role` attribute with be set to `option` for single selection lists,
+ * `checkbox` for list items that can be selected with embedded checkbox inputs, `radio`
+ * for for list items that can be selected with embedded radio inputs, `menuitem` when the
+ * list is part of an `mdcMenu`. Otherwise there will be no `role` attribute, so the default
+ * role for a standard list item (`role=listitem`) will apply.
+ * * Single selection lists set the `aria-selected` or `aria-current` attributes, based on the
+ * chosen `selectionMode` of the list. Please see [WAI-ARIA aria-current](https://www.w3.org/TR/wai-aria-1.1/#aria-current)
+ * for recommendations.
+ * * `aria-checked` will be set for lists with embedded checkbox or radio inputs.
+ * * Disabled list items will be included in the keyboard navigation. This follows
+ * [focusability of disabled controls](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_disabled_controls)
+ * recommendations in the ARIA practices article. Exception: when the list is part of an `mdcMenu` or `mdcSelect`,
+ * disabled items are not included in the keyboard navigation.
+ * * As the user navigates through the list, any button and anchor elements within list items that are not focused
+ * will receive `tabindex=-1`. When the list item receives focus, those elements will receive `tabindex=0`.
+ * This allows for the user to tab through list item elements and then tab to the first element after the list.
+ * * Lists are interactive by default (unless `nonInteractive` is set on the `mdcList`). List items will
+ * show ripples when interacted with.
+ * * `aria-disabled` will be set for disabled list items. When the list uses checkbox or radio inputs to control
+ * the checked state, the disabled state will mirror the state of those inputs.
*/
@Directive({
selector: '[mdcListItem]'
})
export class MdcListItemDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-list-item') _cls = true;
- @HostBinding('attr.role') public _role = null;
+ /** @internal */
+ @HostBinding('class.mdc-list-item') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') public _role: string | null = null;
+ /** @internal */
+ @ContentChildren(MdcRadioDirective, {descendants: true}) _radios?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcCheckboxDirective, {descendants: true}) _checkBoxes?: QueryList;
+ /** @internal */
+ _ariaActive: 'current' | 'selected' | 'checked' | null = null;
private _initialized = false;
- private _interactive = false;
+ private _interactive = true;
private _disabled = false;
- private _selected = false;
- private _activated = false;
+ private _active = false;
+ /** @internal (called valueChanged instead of valueChange so that library consumers cannot by accident use
+ * this for two-way binding) */
+ @Output() readonly valueChanged: EventEmitter = new EventEmitter();
+ /** @internal */
+ _activationRequest: Subject = new ReplaySubject(1);
/**
- * When a list is used inside an mdcMenu
, or mdcSelect
,
- * this property can be used to assign a value to this choice/selection item.
+ * Event emitted for user action on the list item, including keyboard and mouse actions.
+ * This will not emit when the `mdcList` has `nonInteractive` set.
*/
- @Input() value;
+ @Output() readonly action: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the active state of a list item in a single-selection list
+ * (`selectionMode` is `single` or `active`) is changed. This event does not emit
+ * for lists that do not have the mentioned `selectionMode`, and therefore does also
+ * not emit for lists where the active/selected state is controlled by embedded checkbox
+ * or radio inputs. (Note that for lists controlled by an `mdcSelect`, the `selectionMode`
+ * will be either `single` or `active`).
+ */
+ @Output() readonly selectedChange: EventEmitter = new EventEmitter();
+ private _value: string | null = null;
- constructor(public _elm: ElementRef, rndr: Renderer2, registry: MdcEventRegistry) {
- super(_elm, rndr, registry)
+ constructor(public _elm: ElementRef, rndr: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, rndr, registry, doc as Document);
}
ngAfterContentInit() {
@@ -89,6 +158,7 @@ export class MdcListItemDirective extends AbstractMdcRipple implements AfterCont
this.destroyRipple();
}
+ /** @internal */
_setInteractive(interactive: boolean) {
if (this._interactive !== interactive) {
this._interactive = interactive;
@@ -102,178 +172,545 @@ export class MdcListItemDirective extends AbstractMdcRipple implements AfterCont
}
/**
- * When a list is used inside an mdcMenu
, or mdcSelect
,
- * this property can be used to disable the item. When disabled, the list-item will have
- * the aria-disabled
attribute, and for mdcMenu
,
- * or mdcSelect
will set the tabindex
to -1
.
+ * If set to a value other than false, the item will be disabled. This affects styling
+ * and selectability, and may affect keyboard navigation.
+ * This input is ignored for lists where the selection is controlled by embedded checkbox
+ * or radio inputs. In those cases the disabled state of the input will be used instead.
*/
- @Input()
+ @HostBinding('class.mdc-list-item--disabled') @Input()
get disabled() {
+ if (this._ariaActive === 'checked') {
+ const input = this._getInput();
+ return input ? input._elm.nativeElement.disabled : false;
+ }
return this._disabled;
}
- set disabled(val: any) {
+ set disabled(val: boolean) {
this._disabled = asBoolean(val);
}
+ static ngAcceptInputType_disabled: boolean | '';
+
/**
- * When this value is set to a value other than false, the list item will be
- * styled in a selected state. Note: selection and activation are different things.
- * Multiple items in a list can be selected, only one can be activated.
- * Selection is likely to change soon, activation is more permanent than selection.
+ * Assign this field with a value that should be reflected in the `value` property of
+ * a `selectionMode=single|active` or and `mdcMenu` or `mdcSelect` for the active property.
+ * Ignored for lists that don't offer a selection, and for lists that use checkbox/radio
+ * inputs for selection.
*/
- @Input() @HostBinding('class.mdc-list-item--selected')
- get selected() {
- return this._selected;
+ @Input() get value() {
+ return this._value;
}
- set selected(val: any) {
- this._selected = asBoolean(val);
+ set value(newValue: string | null) {
+ if (this._value !== newValue) {
+ this._value = newValue;
+ this.valueChanged.emit(newValue);
+ }
}
/**
- * When this value is set to a value other than false, the list item will be
- * styled in an activated state. Note: selection and activation are different things.
- * Multiple items in a list can be selected, only one can be activated.
- * Selection is likely to change soon, activation is more permanent than selection.
+ * This input can be used to change the active or selected state of the item. This should *not* be used for lists
+ * inside an `mdcSelect`/`mdcMenu`, or for lists that use checkbox/radio inputs for selection.
+ * Depending on the `selectionMode` of the list this will update the `selected` or `active` state of the item.
*/
- @Input() @HostBinding('class.mdc-list-item--activated')
- get activated() {
- return this._activated;
+ @Input() set selected(val: boolean) {
+ let newValue = asBoolean(val);
+ if (newValue !== this._active)
+ this._activationRequest.next(val);
}
- set activated(val: any) {
- this._activated = asBoolean(val);
+ static ngAcceptInputType_selected: boolean | '';
+
+ /** @internal */
+ @HostBinding('class.mdc-list-item--selected')
+ get _selected() {
+ return (this._ariaActive === 'selected' && this._active)
+ || (!this._role && this._active);
}
- @HostBinding('attr.tabindex') get _tabIndex() {
- if (this._role === 'menuitem' || this._role === 'option')
- return this._disabled ? -1 : 0;
- return null;
+ /** @internal */
+ @HostBinding('class.mdc-list-item--activated')
+ get _activated() {
+ return this._ariaActive === 'current' && this._active;
}
+ /** @internal */
@HostBinding('attr.aria-disabled') get _ariaDisabled() {
- if (this._disabled)
+ if (this.disabled) // checks checkbox/radio disabled state when appropriate
return 'true';
return null;
}
+
+ /** @internal */
+ @HostBinding('attr.aria-current') get _ariaCurrent() {
+ if (this._ariaActive === 'current')
+ return this._active ? 'true' : 'false';
+ return null;
+ }
+
+ /** @internal */
+ @HostBinding('attr.aria-selected') get _ariaSelected() {
+ if (this._ariaActive === 'selected')
+ return this._active ? 'true' : 'false';
+ return null;
+ }
+
+ /** @internal */
+ @HostBinding('attr.aria-checked') get _ariaChecked() {
+ if (this._ariaActive === 'checked')
+ // (this.active: returns checked value of embedded input if appropriate)
+ return this.active ? 'true' : 'false';
+ return null;
+ }
+
+ /** @internal */
+ get active() {
+ if (this._ariaActive === 'checked') {
+ const input = this._getInput();
+ return input ? input._elm.nativeElement.checked : false;
+ }
+ return this._active;
+ }
+
+ /** @internal */
+ set active(value: boolean) {
+ if (value !== this._active) {
+ this._active = value;
+ this.selectedChange.emit(value);
+ }
+ }
+
+ /** @internal */
+ _getRadio() {
+ return this._radios?.first;
+ }
+
+ /** @internal */
+ _getCheckbox() {
+ return this._checkBoxes?.first;
+ }
+
+ /** @internal */
+ _getInput() {
+ return (this._getCheckbox() || this._getRadio())?._input;
+ }
}
/**
- * Directive to mark the first line of an item with "two line list" styling
- * according to the Material Design spec.
- * This directive, if used, should be the child of an MdcListItemDirective
.
- * Using this directive inside any mdcListItem
will put the list
- * "two line" mode.
+ * Directive to mark the text portion(s) of an `mdcListItem`. This directive should be the child of an `mdcListItem`.
+ * For single line lists, the text can be added directly to this directive.
+ * For two line lists, add `mdcListItemPrimaryText` and `mdcListItemSecondaryText` children.
*/
@Directive({
selector: '[mdcListItemText]'
})
export class MdcListItemTextDirective {
- @HostBinding('class.mdc-list-item__text') _cls = true;
+ /** @internal */
+ @HostBinding('class.mdc-list-item__text') readonly _cls = true;
+}
- constructor() {}
+/**
+ * Directive to mark the first line of an item with "two line list" styling.
+ * This directive, if used, should be the child of an `mdcListItemText`.
+ * Using this directive will put the list "two line" mode.
+ */
+@Directive({
+ selector: '[mdcListItemPrimaryText]'
+})
+export class MdcListItemPrimaryTextDirective {
+ /** @internal */
+ @HostBinding('class.mdc-list-item__primary-text') readonly _cls = true;
}
/**
* Directive for the secondary text of an item with "two line list" styling.
+ * This directive, if used, should be the child of an `mdcListItemText`, and
+ * come after the `mdcListItemPrimaryText`.
*/
@Directive({
selector: '[mdcListItemSecondaryText]',
})
export class MdcListItemSecondaryTextDirective {
- @HostBinding('class.mdc-list-item__secondary-text') _cls = true;
-
- constructor() {}
+ /** @internal */
+ @HostBinding('class.mdc-list-item__secondary-text') readonly _cls = true;
}
/**
* Directive for the start detail item of a list item.
- * This directive, if used, should be the child of an MdcListItemDirective
.
+ * This directive, if used, should be the child of an`mdcListItem`.
*/
@Directive({
selector: '[mdcListItemGraphic]',
})
export class MdcListItemGraphicDirective {
- @HostBinding('class.mdc-list-item__graphic') _cls = true;
-
- constructor() {}
+ /** @internal */
+ @HostBinding('class.mdc-list-item__graphic') readonly _cls = true;
}
/**
* Directive for the end detail item of a list item.
- * This directive, if used, should be the child of an MdcListItemDirective
.
+ * This directive, if used, should be the child of an `mdcListItem`.
*/
@Directive({
selector: '[mdcListItemMeta]',
})
export class MdcListItemMetaDirective {
- @HostBinding('class.mdc-list-item__meta') _cls = true;
-
- constructor() {}
+ /** @internal */
+ @HostBinding('class.mdc-list-item__meta') readonly _cls = true;
}
+/** @docs-private */
export enum MdcListFunction {
- plain, menu
+ plain, menu, select
};
+// attributes on list-items that we maintain ourselves, so should be ignored
+// in the adapter:
+const ANGULAR_ITEM_ATTRIBUTES = [
+ strings.ARIA_CHECKED, strings.ARIA_SELECTED, strings.ARIA_CURRENT, strings.ARIA_DISABLED
+];
+// classes on list-items that we maintain ourselves, so should be ignored
+// in the adapter:
+const ANGULAR_ITEM_CLASSES = [
+ cssClasses.LIST_ITEM_DISABLED_CLASS, cssClasses.LIST_ITEM_ACTIVATED_CLASS, cssClasses.LIST_ITEM_SELECTED_CLASS
+];
+
/**
- * Directive for a material list.
- * The children of this directive should either be MdcListItemDirective
,
- * or MdcListDividerDirective
elements.
- * This directive can optionally be contained in a MdcListGroupDirective
, in a
- * MdcMenuDirective
, or in a MdcSelectDirective
.
+ * Lists are continuous, vertical indexes of text or images. They can be interactive, and may support
+ * selaction/activation of list of items. Single-line and Two-line lists are supported, as well as
+ * starting and end details (images or controls) on a list. A list contains `mdcListItem` children,
+ * and may also contain `mdcListDivider` children.
+ *
+ * A list can be used by itself, or contained inside `mdcListGroup`, `mdcMenu`, or `mdcSelect`.
+ *
+ * # Accessibility
+ * * See Accessibility section of `mdcListItem` for navigation, focus, and tab(index) affordances.
+ * * The `role` attribute will be set to `listbox` for single selection lists (`selectionMode` is `single`
+ * or `active`), to `radiogroup` when selection is triggered by embedded radio inputs, to
+ * `checkbox` when selection is triggered by embedded checkbox inputs, to `menu` when used inside
+ * `mdcMenu`. Otherwise there will be no `role` attribute, so the default role for a standard list
+ * (`role=list`) will apply.
+ * * You should set an appropriate `label` for checkbox based selection lists. The
+ * `label` will be reflected to the `aria-label` attribute.
*/
@Directive({
selector: '[mdcList]',
})
-export class MdcListDirective implements AfterContentInit {
- @HostBinding('class.mdc-list') _cls = true;
- @ContentChildren(MdcListItemDirective) _items: QueryList;
- @ContentChildren(MdcListItemTextDirective, {descendants: true}) _texts: QueryList;
+export class MdcListDirective implements AfterContentInit, OnDestroy {
+ private onDestroy$: Subject = new Subject();
+ private document: Document;
+ /** @internal */
+ @HostBinding('class.mdc-list') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcListItemDirective) _items?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcListItemPrimaryTextDirective, {descendants: true}) _primaryTexts?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcCheckboxDirective, {descendants: true}) _checkboxes?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcRadioDirective, {descendants: true}) _radios?: QueryList;
+ /** @internal */
+ @Output() readonly itemsChanged: EventEmitter = new EventEmitter();
+ /** @internal */
+ @Output() readonly itemValuesChanged: EventEmitter = new EventEmitter();
+ /** @internal */
+ @Output() readonly itemAction: EventEmitter<{index: number, value: string | null}> = new EventEmitter();
+ /** @internal */
@HostBinding('class.mdc-list--two-line') _twoLine = false;
+ /**
+ * Label announcing the purpose of the list. Should be set for lists that embed checkbox inputs
+ * for activation/selection. The label is reflected in the `aria-label` attribute value.
+ *
+ * @internal
+ */
+ @HostBinding('attr.aria-label') @Input() label: string | null = null;
+ /**
+ * Link to the id of an element that announces the purpose of the list. This will be set automatically
+ * to the id of the `mdcFloatingLabel` when the list is part of an `mdcSelect`.
+ *
+ * @internal
+ */
+ @HostBinding('attr.aria-labelledBy') @Input() labelledBy: string | null = null;
private _function: MdcListFunction = MdcListFunction.plain;
+ /** @internal */
_hidden = false;
private _dense = false;
private _avatar = false;
private _nonInteractive = false;
+ private _selectionMode: 'single' | 'active' | null = null;
+ private _wrapFocus = false;
+ private mdcAdapter: MDCListAdapter = {
+ getAttributeForElementIndex: (index, attr) => {
+ if (attr === strings.ARIA_CURRENT)
+ return this.getItem(index)?._ariaCurrent;
+ return this.getItem(index)?._elm.nativeElement.getAttribute(attr);
+ },
+ getListItemCount: () => this._items!.length,
+ getFocusedElementIndex: () => this._items!.toArray().findIndex(i => i._elm.nativeElement === this.document.activeElement!),
+ setAttributeForElementIndex: (index, attribute, value) => {
+ // ignore attributes we maintain ourselves
+ if (!ANGULAR_ITEM_ATTRIBUTES.find(a => a === attribute)) {
+ const elm = this.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.setAttribute(elm, attribute, value);
+ }
+ },
+ addClassForElementIndex: (index, className) => {
+ if (!ANGULAR_ITEM_CLASSES.find(c => c === className)) {
+ const elm = this.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.addClass(elm, className);
+ }
+ },
+ removeClassForElementIndex: (index, className) => {
+ if (!ANGULAR_ITEM_CLASSES.find(c => c === className)) {
+ const elm = this.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.addClass(elm, className);
+ }
+ },
+ focusItemAtIndex: (index: number) => this.getItem(index)?._elm.nativeElement.focus(),
+ setTabIndexForListItemChildren: (index, tabIndexValue) => {
+ // TODO check this plays nice with our own components (mdcButton etc.)
+ // TODO build this in an abstract class for our own elements?
+ // TODO limit to our own elements/custom directive?
+ const elm = this.getItem(index)?._elm.nativeElement;
+ const listItemChildren: Element[] = [].slice.call(elm.querySelectorAll(strings.CHILD_ELEMENTS_TO_TOGGLE_TABINDEX));
+ listItemChildren.forEach((el) => this.rndr.setAttribute(el, 'tabindex', tabIndexValue));
+ },
+ hasRadioAtIndex: () => this._role === 'radiogroup',
+ hasCheckboxAtIndex: () => this._role === 'group',
+ isCheckboxCheckedAtIndex: (index) => !!this.getItem(index)?._getCheckbox()?._input?.checked,
+ isRootFocused: () => this.document.activeElement === this._elm.nativeElement,
+ listItemAtIndexHasClass: (index, className) => {
+ if (className === cssClasses.LIST_ITEM_DISABLED_CLASS)
+ return !!this.getItem(index)?.disabled;
+ return !!this.getItem(index)?._elm.nativeElement.classList.contains(className);
+ },
+ setCheckedCheckboxOrRadioAtIndex: (index, isChecked) => {
+ const item = this.getItem(index);
+ const input = (item?._getRadio() || item?._getCheckbox())?._input?._elm.nativeElement;
+ if (input) {
+ input.checked = isChecked;
+ // simulate user interaction, as this is triggered from a user interaction:
+ const event = this.document.createEvent('Event');
+ event.initEvent('change', true, true);
+ input.dispatchEvent(event);
+ // checkbox input listens to clicks, not changed events, so let it know about the change:
+ item?._getCheckbox()?._input?._onChange();
+ }
+ },
+ notifyAction: (index) => {
+ const item = this.getItem(index);
+ if (item && !item?.disabled) {
+ item.action.emit();
+ this.itemAction.emit({index, value: item.value});
+ }
+ },
+ isFocusInsideList: () => {
+ return this._elm.nativeElement.contains(this.document.activeElement);
+ },
+ };
+ /** @internal */
+ foundation?: MDCListFoundation | null;
- constructor(public _elm: ElementRef) {}
+ constructor(public _elm: ElementRef, private rndr: Renderer2, private cdRef: ChangeDetectorRef, @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
+ }
ngAfterContentInit() {
- this.updateItems();
- this._items.changes.subscribe(() => {
+ merge(
+ this._checkboxes!.changes,
+ this._radios!.changes
+ ).pipe(takeUntil(this.onDestroy$)).subscribe(() => {
this.updateItems();
+ this.updateLayout();
+ this.updateFoundationSelections();
+ });
+ this._items!.changes.subscribe(() => {
+ // when number of items changes, we have to reinitialize the foundation, because
+ // the focusused item index that the foundation keeps may be invalidated:
+ this.destroyFoundation();
+ this.updateItems();
+ this.initFoundation();
+ this.itemsChanged.emit();
+ this.itemValuesChanged.emit();
+ merge(this._items!.map(item => item.valueChanged.asObservable())).pipe(
+ takeUntil(this.onDestroy$),
+ takeUntil(this.itemsChanged),
+ debounceTime(1)
+ ).subscribe(() => {
+ this.itemValuesChanged.emit();
+ });
+ this.subscribeItemActivationRequests();
+ });
+ this._primaryTexts!.changes.subscribe(_ => this._twoLine = this._primaryTexts!.length > 0);
+ this.updateItems();
+ this._twoLine = this._primaryTexts!.length > 0;
+ this.initFoundation();
+ this.subscribeItemActivationRequests();
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.destroyFoundation();
+ }
+
+ private initFoundation() {
+ this.foundation = new MDCListFoundation(this.mdcAdapter);
+ this.foundation.init();
+ this.updateLayout();
+ const focus = this.getListItemIndex({target: this.document.activeElement as EventTarget});
+ if (focus !== -1) // only way to restore focus when a list item already had focus:
+ (this.foundation)['focusedItemIndex_'] = focus;
+ this.updateFoundationSelections();
+ this.foundation.setWrapFocus(this._wrapFocus);
+ }
+
+ private destroyFoundation() {
+ this.foundation?.destroy();
+ this.foundation = null;
+ }
+
+ private subscribeItemActivationRequests() {
+ this._items!.map(item => {
+ item._activationRequest.asObservable().pipe(
+ takeUntil(this.onDestroy$),
+ takeUntil(this.itemsChanged)
+ ).subscribe(active => this.activateOrSelectItem(item, active));
});
- this._texts.changes.subscribe(_ => this._twoLine = this._texts.length > 0);
- this._twoLine = this._texts.length > 0;
}
private updateItems() {
- let itemRole = null;
- if (this._function === MdcListFunction.menu)
- itemRole = 'menuitem';
- if (this._items)
+ let itemRole = {
+ 'menu': 'menuitem',
+ 'listbox': 'option',
+ 'group': 'checkbox',
+ 'radiogroup': 'radio'
+ }[this._role!] || null;
+ let ariaActive = {
+ 'menu': null,
+ 'listbox': this._selectionMode === 'active' ? 'current' : 'selected',
+ 'group': 'checked',
+ 'radiogroup': 'checked'
+ }[this._role!] || null;
+ if (this._items) {
+ const firstTabbable = this._nonInteractive ? null :
+ this._items.find(item => item._elm.nativeElement.tabIndex === 0) ||
+ this._items.find(item => item.active) ||
+ this._items.first;
this._items.forEach(item => {
item._role = itemRole;
+ item._ariaActive = ariaActive;
item._setInteractive(!this._nonInteractive);
+ if (this._nonInteractive)
+ // not focusable if not interactive:
+ this.rndr.removeAttribute(item._elm.nativeElement, 'tabindex');
+ this.rndr.setAttribute(item._elm.nativeElement, 'tabindex', item === firstTabbable ? '0' : '-1');
});
+ // child components were updated (in updateItems above)
+ // let angular know to prevent ExpressionChangedAfterItHasBeenCheckedError:
+ this.cdRef.detectChanges();
+ }
}
+ private updateLayout() {
+ this.foundation?.layout();
+ }
+
+ private updateFoundationSelections() {
+ this.foundation?.setSingleSelection(this._role === 'listbox');
+ this.foundation?.setSelectedIndex(this.getSelection());
+ }
+
+ private updateItemSelections(active: number | number[]) {
+ const activeIndexes = typeof active === 'number' ? [active] : active;
+ // first deactivate, then activate
+ this._items!.toArray().forEach((item, idx) => {
+ if (activeIndexes.indexOf(idx) === -1)
+ item.active = false;
+ });
+ this._items!.toArray().forEach((item, idx) => {
+ if (activeIndexes.indexOf(idx) !== -1)
+ item.active = true;
+ });
+ }
+
+ private activateOrSelectItem(item: MdcListItemDirective, active: boolean) {
+ let activeIndexes: number | number[] = -1;
+ if (!active) {
+ if (this._role === 'group' || !this._role)
+ activeIndexes = this._items!.toArray().map((v, i) => v.active && v !== item ? i : null).filter(i => i != null);
+ else if (this._role === 'listbox' || this._role === 'radiogroup' || this._role === 'menu')
+ activeIndexes = this._items!.toArray().findIndex(i => i.active && i !== item);
+ } else {
+ if (this._role === 'group' || !this._role)
+ activeIndexes = this._items!.toArray().map((v, i) => v.active || v === item ? i : null).filter(i => i != null);
+ else if (this._role === 'listbox' || this._role === 'radiogroup' || this._role === 'menu')
+ activeIndexes = this._items!.toArray().findIndex(i => i === item);
+ }
+ if (this._role === 'group' || this._role === 'listbox' || this._role === 'radiogroup' || this._role === 'menu')
+ this.foundation?.setSelectedIndex(activeIndexes);
+ this.updateItemSelections(activeIndexes);
+ this.cdRef.detectChanges();
+ }
+
+ private getSelection(forFoundation = true): number | number[] {
+ if (this._role === 'listbox' || this._role === 'radiogroup' || this._role === 'menu')
+ return this._items!.toArray().findIndex(i => i.active);
+ if (this._role === 'group')
+ return this._items!.toArray().map((v, i) => v.active ? i : null).filter(i => i != null);
+ return forFoundation ? -1 : this._items!.toArray().map((v, i) => v.active ? i : null).filter(i => i != null);
+ }
+
+ /** @internal */
+ getSelectedItem() {
+ if (this._role === 'listbox' || this._role === 'radiogroup' || this._role === 'menu')
+ return this._items!.find(i => i.active);
+ return null;
+ }
+
+ /** @internal */
@HostBinding('attr.role') get _role() {
- return (this._function === MdcListFunction.menu) ? 'menu' : null;
+ if (this._function === MdcListFunction.menu)
+ return 'menu';
+ if (this._function === MdcListFunction.select)
+ return 'listbox';
+ if (this._selectionMode === 'single' || this._selectionMode === 'active')
+ return 'listbox';
+ if (this._checkboxes && this._checkboxes.length > 0)
+ return 'group';
+ if (this._radios && this._radios.length > 0)
+ return 'radiogroup';
+ return null;
}
+ /** @internal */
@HostBinding('attr.aria-hidden') get _ariaHidden() {
return (this._hidden && this._function === MdcListFunction.menu) ? 'true' : null;
}
+ /** @internal */
+ @HostBinding('attr.aria-orientation') get _ariaOrientation() {
+ return this._function === MdcListFunction.menu ? 'vertical' : null;
+ }
+
+ /** @internal */
@HostBinding('class.mdc-menu__items') get _isMenu() {
return this._function === MdcListFunction.menu;
}
+ /** @internal */
+ @HostBinding('attr.tabindex') get _tabindex() {
+ // the root of a menu should be focusable
+ return this._function === MdcListFunction.menu ? "-1" : null;
+ }
+
+ /** @internal */
_setFunction(val: MdcListFunction) {
this._function = val;
+ this.foundation?.setSingleSelection(this._role === 'listbox');
this.updateItems();
}
@@ -286,20 +723,59 @@ export class MdcListDirective implements AfterContentInit {
return this._dense;
}
- set dense(val: any) {
+ set dense(val: boolean) {
this._dense = asBoolean(val);
}
+ static ngAcceptInputType_dense: boolean | '';
+
/**
- * When this input is defined and does not have value false, interactivity affordances for
- * the list will be disabled.
+ * When set to `single` or `active`, the list will act as a single-selection-list.
+ * This enables the enter and space keys for selecting/deselecting a list item,
+ * and sets the appropriate accessibility options.
+ * When not set, the list will not act as a single-selection-list.
+ *
+ * When using `single`, the active selection is announced with `aria-selected`
+ * attributes on the list elements. When using `active`, the active selection
+ * is announced with `aria-current`. See [WAI-ARIA aria-current](https://www.w3.org/TR/wai-aria-1.1/#aria-current)
+ * article for recommendations on usage.
+ *
+ * The selectionMode is ignored when there are embedded checkbox or radio inputs inside the list, in which case
+ * those inputs will trigger selection of list items.
+ */
+ @Input()
+ get selectionMode() {
+ return this._selectionMode;
+ }
+
+ set selectionMode(val: 'single' | 'active' | null) {
+ if (val !== this._selectionMode) {
+ if (val === 'single' || val === 'active')
+ this._selectionMode = val;
+ else
+ this._selectionMode = null;
+ this.updateItems();
+ if (this.foundation) {
+ this.foundation.setSingleSelection(this._role === 'listbox');
+ this.foundation.setSelectedIndex(this.getSelection());
+ this.updateItemSelections(this.getSelection(false));
+ }
+ }
+ }
+
+ static ngAcceptInputType_selectionMode: 'single' | 'active' | '' | null;
+
+ /**
+ * When this input is defined and does not have value false, the list will be made
+ * non-interactive. Users will not be able to interact with list items, and the styling will
+ * reflect this (e.g. by not adding ripples to the items).
*/
@Input() @HostBinding('class.mdc-list--non-interactive')
get nonInteractive() {
return this._nonInteractive;
}
- set nonInteractive(val: any) {
+ set nonInteractive(val: boolean) {
let newValue = asBoolean(val);
if (newValue !== this._nonInteractive) {
this._nonInteractive = newValue;
@@ -307,6 +783,24 @@ export class MdcListDirective implements AfterContentInit {
}
}
+ static ngAcceptInputType_nonInteractive: boolean | '';
+
+ /**
+ * When this input is defined and does not have value false, focus will wrap from last to
+ * first and vice versa when using keyboard navigation through list items.
+ */
+ @Input()
+ get wrapFocus() {
+ return this._wrapFocus;
+ }
+
+ set wrapFocus(val: boolean) {
+ this._wrapFocus = asBoolean(val);
+ this.foundation?.setWrapFocus(this._wrapFocus);
+ }
+
+ static ngAcceptInputType_wrapFocus: boolean | '';
+
/**
* When this input is defined and does not have value false, elements with directive mdcListItemGraphic
* will be styled for avatars: large, circular elements that lend themselves well to contact images, profile pictures, etc.
@@ -316,9 +810,85 @@ export class MdcListDirective implements AfterContentInit {
return this._avatar;
}
- set avatarList(val: any) {
+ set avatarList(val: boolean) {
this._avatar = asBoolean(val);
}
+
+ static ngAcceptInputType_avatarList: boolean | '';
+
+ /** @internal */
+ @HostListener('focusin', ['$event']) _onFocusIn(event: FocusEvent) {
+ if (this.foundation && !this._nonInteractive) {
+ this.foundation.setSelectedIndex(this.getSelection());
+ const index = this.getListItemIndex(event as {target: EventTarget});
+ this.foundation.handleFocusIn(event, index);
+ }
+ }
+
+ /** @internal */
+ @HostListener('focusout', ['$event']) _onFocusOut(event: FocusEvent) {
+ if (this.foundation && !this._nonInteractive) {
+ this.foundation.setSelectedIndex(this.getSelection());
+ const index = this.getListItemIndex(event as {target: EventTarget});
+ this.foundation.handleFocusOut(event, index);
+ }
+ }
+
+ /** @internal */
+ @HostListener('keydown', ['$event']) _onKeydown(event: KeyboardEvent) {
+ if (this.foundation && !this._nonInteractive) {
+ this.foundation.setSelectedIndex(this.getSelection());
+ const index = this.getListItemIndex(event as {target: EventTarget});
+ const onRoot = this.getItem(index)?._elm.nativeElement === event.target;
+ this.foundation.handleKeydown(event, onRoot, index);
+ if (this._role === 'listbox')
+ this.updateItemSelections(this.foundation!.getSelectedIndex());
+ }
+ }
+
+ /** @internal */
+ @HostListener('click', ['$event']) _onClick(event: MouseEvent) {
+ if (this.foundation && !this._nonInteractive) {
+ this.foundation.setSelectedIndex(this.getSelection());
+ const index = this.getListItemIndex(event as {target: EventTarget});
+ // only toggle radio/checkbox input if it's not already toggled from the event:
+ const inputElement = this.getItem(index)?._getCheckbox()?._input!._elm.nativeElement ||
+ this.getItem(index)?._getRadio()?._input!._elm.nativeElement;
+ const toggleInput = event.target !== inputElement;
+ this.foundation.handleClick(index, toggleInput);
+ if (this._role === 'listbox')
+ this.updateItemSelections(this.foundation!.getSelectedIndex());
+ }
+ }
+
+ /** @internal */
+ getItem(index: number): MdcListItemDirective | null {
+ if (index >= 0 && index < this._items!.length)
+ return this._items!.toArray()[index];
+ return null;
+ }
+
+ /** @internal */
+ getItems(): MdcListItemDirective[] {
+ return this._items?.toArray() || [];
+ }
+
+ /** @internal */
+ getItemByElement(element: Element): MdcListItemDirective | null {
+ return this._items?.find(i => i._elm.nativeElement === element) || null;
+ }
+
+ private getListItemIndex(evt: {target: EventTarget}) {
+ let eventTarget: Element | null = evt.target as Element;
+ const itemElements = this._items!.map(item => item._elm.nativeElement);
+ while (eventTarget && eventTarget !== this._elm.nativeElement) {
+ const index = itemElements.findIndex(e => e === eventTarget);
+ if (index !== -1)
+ return index;
+ eventTarget = eventTarget.parentElement;
+ }
+ return -1;
+ }
}
/**
@@ -328,23 +898,33 @@ export class MdcListDirective implements AfterContentInit {
selector: '[mdcListGroupSubHeader]'
})
export class MdcListGroupSubHeaderDirective {
- @HostBinding('class.mdc-list-group__subheader') _cls = true;
-
- constructor() {}
+ /** @internal */
+ @HostBinding('class.mdc-list-group__subheader') readonly _cls = true;
}
/**
- * Directive for a material designed list group, grouping several
- * mdcList
lists.
- * List groups should contain elements with mdcListGroupSubHeader
,
- * and mdcList
directives.
+ * Directive for a material designed list group, grouping several `mdcList` lists.
+ * List groups should contain elements with `mdcListGroupSubHeader`,
+ * and `mdcList` directives. Lists may be separated by `mdcListSeparator` directives.
*/
@Directive({
selector: '[mdcListGroup]'
})
export class MdcListGroupDirective {
- @HostBinding('class.mdc-list-group') _cls = true;
-
- constructor() {}
+ /** @internal */
+ @HostBinding('class.mdc-list-group') readonly _cls = true;
}
+
+export const LIST_DIRECTIVES = [
+ MdcListDividerDirective,
+ MdcListItemDirective,
+ MdcListItemTextDirective,
+ MdcListItemPrimaryTextDirective,
+ MdcListItemSecondaryTextDirective,
+ MdcListItemGraphicDirective,
+ MdcListItemMetaDirective,
+ MdcListDirective,
+ MdcListGroupSubHeaderDirective,
+ MdcListGroupDirective
+];
diff --git a/bundle/src/components/menu-surface/mdc.menu-surface.directive.spec.ts b/bundle/src/components/menu-surface/mdc.menu-surface.directive.spec.ts
new file mode 100644
index 0000000..fc13736
--- /dev/null
+++ b/bundle/src/components/menu-surface/mdc.menu-surface.directive.spec.ts
@@ -0,0 +1,199 @@
+import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { numbers } from '@material/menu-surface';
+import { MENU_SURFACE_DIRECTIVES } from './mdc.menu-surface.directive';
+import { simulateKey } from '../../testutils/page.test';
+
+describe('mdcMenuSurface', () => {
+ @Component({
+ template: `
+
+ `,
+ styles: [`
+ #anchor {
+ left: 150px;
+ top: 150px;
+ width: 80px;
+ height: 20px;
+ background-color: red;
+ }
+ #surface {
+ width: 150px;
+ height: 300px;
+ }`
+ ]
+ })
+ class TestComponent {
+ notifications = [];
+ open = null;
+ openFrom = null;
+ fixed = null;
+ notify(name: string, value: boolean) {
+ let notification = {};
+ notification[name] = value;
+ this.notifications.push(notification);
+ }
+ }
+
+ it('open and close', fakeAsync(() => {
+ const { fixture, anchor, surface, testComponent } = setup();
+ expect(anchor).not.toBeNull();
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ testComponent.open = true;
+ animationCycle(fixture, () => expect(testComponent.notifications).toEqual([{open: true}]));
+ expect(testComponent.notifications).toEqual([{open: true}, {afterOpened: true}]);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+ const position = surface.style['transform-origin'].split(' '); // left,bottom or left,top depending on size of window
+ expect(position[0]).toBe('left');
+ expect(surface.style['transform-origin']).toBe(position.join(' '));
+ expect(surface.style[position[0]]).toBe('0px', position[0]);
+ expect(surface.style[position[1]]).toBe('0px', position[1]);
+ testComponent.notifications = [];
+ testComponent.open = false;
+ animationCycle(fixture, () => expect(testComponent.notifications).toEqual([{open: false}]));
+ expect(testComponent.notifications).toEqual([{open: false}, {afterClosed: true}]);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+
+ testComponent.open = true;
+ testComponent.openFrom = 'tr';
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+ expect(surface.style['transform-origin']).toBe(position.join(' '));
+ expect(surface.style[position[0]]).toBe('80px', position[0]);
+ expect(surface.style[position[1]]).toBe('0px', position[1]);
+
+ testComponent.open = false;
+ animationCycle(fixture);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+
+ testComponent.open = true;
+ testComponent.openFrom = 'br';
+ animationCycle(fixture);
+ expect(surface.style['transform-origin']).toBe(position.join(' '));
+ expect(surface.style[position[0]]).toBe('80px', position[0]);
+ expect(surface.style[position[1]]).toBe('20px', position[1]);
+ }));
+
+ it('focus restore', fakeAsync(() => {
+ const { fixture, surface, anchor, testComponent } = setup();
+ anchor.focus();
+ expect(document.activeElement).toBe(anchor);
+ testComponent.open = true;
+ animationCycle(fixture);
+ surface.focus();
+ expect(document.activeElement).toBe(surface);
+ testComponent.open = false;
+ animationCycle(fixture);
+ expect(document.activeElement).toBe(anchor);
+ }));
+
+ it('fixed positioning', fakeAsync(() => {
+ const { fixture, surface, testComponent } = setup();
+ testComponent.fixed = true;
+ testComponent.open = true;
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--fixed');
+ }));
+
+ it('hoisted positioning', fakeAsync(() => {
+ const { fixture, anchor, surface, testComponent } = setup();
+ expect(anchor).not.toBeNull();
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ testComponent.hoisted = true;
+ testComponent.open = true;
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+ const position = surface.style['transform-origin'].split(' '); // left,bottom or left,top depending on size of window
+ expect(position[0]).toBe('left');
+ expect(surface.style['transform-origin']).toBe(position.join(' '));
+ expect(surface.style[position[0]]).not.toBe('0px', position[0]);
+ expect(surface.style[position[1]]).not.toBe('0px', position[1]);
+ }));
+
+ it('close by outside bodyclick', fakeAsync(() => {
+ const { fixture, surface, testComponent } = setup();
+ testComponent.open = true;
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+
+ // clicking on surface itself does nothing:
+ testComponent.notifications = [];
+ surface.click();
+ animationCycle(fixture);
+ expect(testComponent.notifications).toEqual([]);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+
+ document.body.click();
+ animationCycle(fixture, () => expect(testComponent.notifications).toEqual([{open: false}]));
+ expect(testComponent.notifications).toEqual([{open: false}, {afterClosed: true}]);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ }));
+
+ it('close by outside ESC key', fakeAsync(() => {
+ const { fixture, surface, testComponent } = setup();
+ testComponent.open = true;
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+
+ // TAB key does nothing
+ testComponent.notifications = [];
+ simulateKey(surface, 'Enter');
+ animationCycle(fixture);
+ expect(testComponent.notifications).toEqual([]);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+
+ simulateKey(surface, 'Escape');
+ animationCycle(fixture, () => expect(testComponent.notifications).toEqual([{open: false}]));
+ expect(testComponent.notifications).toEqual([{open: false}, {afterClosed: true}]);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ }));
+
+ @Component({
+ template: `
`,
+ styles: [`
+ #surface {
+ width: 150px;
+ height: 300px;
+ }`
+ ]
+ })
+ class TestWithoutAnchorComponent {
+ open = null;
+ }
+
+ it('no anchor', fakeAsync(() => {
+ const { fixture, surface, testComponent } = setup(TestWithoutAnchorComponent);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ testComponent.open = true;
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+ testComponent.open = false;
+ animationCycle(fixture);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ }));
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...MENU_SURFACE_DIRECTIVES, compType]
+ }).createComponent(compType);
+ fixture.detectChanges();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const anchor: HTMLElement = fixture.nativeElement.querySelector('.mdc-menu-surface--anchor');
+ const surface: HTMLElement = fixture.nativeElement.querySelector('.mdc-menu-surface');
+ return { fixture, anchor, surface, testComponent };
+ }
+
+ function animationCycle(fixture, checksBeforeAnimation: () => void = () => {}) {
+ fixture.detectChanges();
+ checksBeforeAnimation();
+ tick(Math.max(numbers.TRANSITION_CLOSE_DURATION, numbers.TRANSITION_OPEN_DURATION));
+ flush();
+ }
+});
diff --git a/bundle/src/components/menu-surface/mdc.menu-surface.directive.ts b/bundle/src/components/menu-surface/mdc.menu-surface.directive.ts
new file mode 100644
index 0000000..2a7423f
--- /dev/null
+++ b/bundle/src/components/menu-surface/mdc.menu-surface.directive.ts
@@ -0,0 +1,329 @@
+import { AfterContentInit, ContentChildren, Directive, ElementRef, HostBinding,
+ Input, OnDestroy, QueryList, Renderer2, Output, EventEmitter, HostListener, Inject } from '@angular/core';
+import { MDCMenuSurfaceFoundation, MDCMenuSurfaceAdapter, util, cssClasses, Corner } from '@material/menu-surface';
+import { asBoolean } from '../../utils/value.utils';
+import { DOCUMENT } from '@angular/common';
+
+/**
+ * The `mdcMenuSurface` is a reusable surface that appears above the content of the page
+ * and can be positioned adjacent to an element. It is required as the surface for an `mdcMenu`
+ * but can also be used by itself.
+ */
+@Directive({
+ selector: '[mdcMenuSurface],[mdcMenu],[mdcSelectMenu]'
+})
+export class MdcMenuSurfaceDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-menu-surface') readonly _cls = true;
+ private _open = false;
+ private _openFrom: 'tl' | 'tr' | 'bl' | 'br' | 'ts' | 'te' | 'bs' | 'be' = 'ts';
+ // the anchor to use if no menuAnchor is provided (a direct parent MdcMenuAnchor if available):
+ /** @internal */
+ _parentAnchor: MdcMenuAnchorDirective | null = null;
+ /**
+ * Assign an (optional) element or `mdcMenuAnchor`. If set the menu
+ * will position itself relative to this anchor element. Assigning this property is not needed
+ * if you wrap your surface inside an `mdcMenuAnchor`.
+ */
+ @Input() menuAnchor: MdcMenuAnchorDirective | Element | null = null;
+ /**
+ * Assign any `HTMLElement` to this property to use as the viewport instead of
+ * the window object. The menu will choose to open from the top or bottom, and
+ * from the left or right, based on the space available inside the viewport.
+ *
+ * You should probably not use this property. We only use it to keep the documentation
+ * snippets on our demo website contained in their window.
+ */
+ @Input() viewport: HTMLElement | null = null;
+ /**
+ * Event emitted when the menu is opened or closed. (When this event is triggered, the
+ * surface is starting to open/close, but the animation may not have fully completed
+ * yet).
+ */
+ @Output() readonly openChange: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted after the menu has fully opened. When this event is emitted the full
+ * opening animation has completed, and the menu is visible.
+ */
+ @Output() readonly afterOpened: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted after the menu has fully closed. When this event is emitted the full
+ * closing animation has completed, and the menu is not visible anymore.
+ */
+ @Output() readonly afterClosed: EventEmitter = new EventEmitter();
+ private _prevFocus: Element | null = null;
+ private _hoisted = false;
+ private _fixed = false;
+ private _handleBodyClick = (event: MouseEvent) => this.handleBodyClick(event);
+
+ private mdcAdapter: MDCMenuSurfaceAdapter = {
+ addClass: (className: string) => this.rndr.addClass(this._elm.nativeElement, className),
+ removeClass: (className: string) => this.rndr.removeClass(this._elm.nativeElement, className),
+ hasClass: (className: string) => {
+ if (className === cssClasses.ROOT)
+ return true;
+ if (className === cssClasses.OPEN)
+ return this._open;
+ return this._elm.nativeElement.classList.contains(className);
+ },
+ hasAnchor: () => !!this._parentAnchor || !!this.menuAnchor,
+ isElementInContainer: (el: Element) => this._elm.nativeElement.contains(el),
+ isFocused: () => this.document.activeElement === this._elm.nativeElement,
+ isRtl: () => getComputedStyle(this._elm.nativeElement).getPropertyValue('direction') === 'rtl',
+ getInnerDimensions: () => ({width: this._elm.nativeElement.offsetWidth, height: this._elm.nativeElement.offsetHeight}),
+ getAnchorDimensions: () => {
+ const anchor = this.menuAnchor || this._parentAnchor;
+ if (!anchor)
+ return null;
+ if (!this.viewport)
+ return anchor.getBoundingClientRect();
+ let viewportRect = this.viewport.getBoundingClientRect();
+ let anchorRect = anchor.getBoundingClientRect();
+ return {
+ bottom: anchorRect.bottom - viewportRect.top,
+ left: anchorRect.left - viewportRect.left,
+ right: anchorRect.right - viewportRect.left,
+ top: anchorRect.top - viewportRect.top,
+ width: anchorRect.width,
+ height: anchorRect.height
+ };
+ },
+ getWindowDimensions: () => ({
+ width: this.viewport ? this.viewport.clientWidth : this.document.defaultView!.innerWidth,
+ height: this.viewport ? this.viewport.clientHeight : this.document.defaultView!.innerHeight
+ }),
+ getBodyDimensions: () => ({
+ width: this.viewport ? this.viewport.scrollWidth : this.document.body.clientWidth,
+ height: this.viewport ? this.viewport.scrollHeight : this.document.body.clientHeight}),
+ getWindowScroll: () => ({
+ x: this.viewport ? this.viewport.scrollLeft : this.document.defaultView!.pageXOffset,
+ y: this.viewport ? this.viewport.scrollTop : this.document.defaultView!.pageYOffset
+ }),
+ setPosition: (position) => {
+ let el = this._elm.nativeElement;
+ this.rndr.setStyle(el, 'left', 'left' in position ? `${position.left}px` : '');
+ this.rndr.setStyle(el, 'right', 'right' in position ? `${position.right}px` : '');
+ this.rndr.setStyle(el, 'top', 'top' in position ? `${position.top}px` : '');
+ this.rndr.setStyle(el, 'bottom', 'bottom' in position ? `${position.bottom}px` : '');
+ },
+ setMaxHeight: (height: string) => this._elm.nativeElement.style.maxHeight = height,
+ setTransformOrigin: (origin: string) => this.rndr.setStyle(this._elm.nativeElement,
+ `${util.getTransformPropertyName(this.document.defaultView!)}-origin`, origin),
+ saveFocus: () => this._prevFocus = this.document.activeElement,
+ restoreFocus: () => this._elm.nativeElement.contains(this.document.activeElement) && this._prevFocus
+ && (this._prevFocus as any)['focus'] && (this._prevFocus as any)['focus'](),
+ notifyClose: () => {
+ this.afterClosed.emit();
+ this.document.removeEventListener('click', this._handleBodyClick);
+ },
+ notifyOpen: () => {
+ this.afterOpened.emit();
+ this.document.addEventListener('click', this._handleBodyClick);
+ }
+ };
+ /** @docs-private */
+ private foundation: MDCMenuSurfaceFoundation | null = null;
+ private document: Document;
+
+ constructor(private _elm: ElementRef, private rndr: Renderer2, @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
+ }
+
+ ngAfterContentInit() {
+ this.foundation = new MDCMenuSurfaceFoundation(this.mdcAdapter);
+ this.foundation.init();
+ this.foundation.setFixedPosition(this._fixed);
+ this.foundation.setIsHoisted(this._hoisted);
+ this.updateFoundationCorner();
+ if (this._open)
+ this.foundation.open();
+ }
+
+ ngOnDestroy() {
+ // when we're destroying a closing surface, the event listener may not be removed yet:
+ this.document.removeEventListener('click', this._handleBodyClick);
+ this.foundation?.destroy();
+ this.foundation = null;
+ }
+
+ /**
+ * When this input is defined and does not have value false, the menu will be opened,
+ * otherwise the menu will be closed.
+ */
+ @Input() @HostBinding('class.mdc-menu-surface--open')
+ get open() {
+ return this._open;
+ }
+
+ set open(val: boolean) {
+ let newValue = asBoolean(val);
+ if (newValue !== this._open) {
+ this._open = newValue;
+ if (newValue)
+ this.foundation?.open();
+ else
+ this.foundation?.close();
+ this.openChange.emit(newValue);
+ }
+ }
+
+ static ngAcceptInputType_open: boolean | '';
+
+ /** @internal */
+ closeWithoutFocusRestore() {
+ if (this._open) {
+ this._open = false;
+ this.foundation?.close(true);
+ this.openChange.emit(false);
+ }
+ }
+
+ /**
+ * Set this value if you want to customize the direction from which the menu will be opened.
+ * Use `tl` for top-left, `br` for bottom-right, etc.
+ * When the left/right position depends on the text directionality, use `ts` for top-start,
+ * `te` for top-end, etc. Start will map to left in left-to-right text directionality, and to
+ * to right in right-to-left text directionality. End maps the other way around.
+ * The default value is 'ts'.
+ */
+ @Input()
+ get openFrom(): 'tl' | 'tr' | 'bl' | 'br' | 'ts' | 'te' | 'bs' | 'be' {
+ return this._openFrom;
+ }
+
+ set openFrom(val: 'tl' | 'tr' | 'bl' | 'br' | 'ts' | 'te' | 'bs' | 'be') {
+ if (val !== this.openFrom) {
+ if (['tl', 'tr', 'bl', 'br', 'ts', 'te', 'bs', 'be'].indexOf(val) !== -1)
+ this._openFrom = val;
+ else
+ this._openFrom = 'ts';
+ this.updateFoundationCorner();
+ }
+ }
+
+ private updateFoundationCorner() {
+ const corner: Corner = {
+ 'tl': Corner.TOP_LEFT,
+ 'tr': Corner.TOP_RIGHT,
+ 'bl': Corner.BOTTOM_LEFT,
+ 'br': Corner.BOTTOM_RIGHT,
+ 'ts': Corner.TOP_START,
+ 'te': Corner.TOP_END,
+ 'bs': Corner.BOTTOM_START,
+ 'be': Corner.BOTTOM_END
+ }[this._openFrom];
+ this.foundation?.setAnchorCorner(corner);
+ }
+
+ /** @internal */
+ setFoundationAnchorCorner(corner: Corner) {
+ this.foundation?.setAnchorCorner(corner);
+ }
+
+ /**
+ * Set to a value other then false to hoist the menu surface to the body so that the position offsets
+ * are calculated relative to the page and not the anchor. (When a `viewport` is set, hoisting is done to
+ * the viewport instead of the body).
+ */
+ @Input()
+ get hoisted() {
+ return this._hoisted;
+ }
+
+ set hoisted(val: boolean) {
+ let newValue = asBoolean(val);
+ if (newValue !== this._hoisted) {
+ this._hoisted = newValue;
+ this.foundation?.setIsHoisted(newValue);
+ }
+ }
+
+ static ngAcceptInputType_hoisted: boolean | '';
+
+ /**
+ * Set to a value other then false use fixed positioning, so that the menu stays in the
+ * same place on the window (or viewport) even if the page (or viewport) is
+ * scrolled.
+ */
+ @Input() @HostBinding('class.mdc-menu-surface--fixed')
+ get fixed() {
+ return this._fixed;
+ }
+
+ set fixed(val: boolean) {
+ let newValue = asBoolean(val);
+ if (newValue !== this._fixed) {
+ this._fixed = newValue;
+ this.foundation?.setFixedPosition(newValue);
+ }
+ }
+
+ static ngAcceptInputType_fixed: boolean | '';
+
+ // listened after notifyOpen, listening stopped after notifyClose
+ /** @internal */
+ handleBodyClick(event: MouseEvent) {
+ if (this.foundation) {
+ this.foundation.handleBodyClick(event);
+ if (this._open && this._open !== this.foundation.isOpen()) {// if just closed:
+ this._open = false;
+ this.openChange.emit(false);
+ }
+ }
+ }
+
+ /** @internal */
+ @HostListener('keydown', ['$event'])
+ handleKeydow(event: KeyboardEvent) {
+ if (this.foundation) {
+ this.foundation.handleKeydown(event);
+ if (this._open && this._open !== this.foundation.isOpen()) {// if just closed:
+ this._open = false;
+ this.openChange.emit(false);
+ }
+ }
+ }
+}
+
+/**
+ * Defines an anchor to position an `mdcMenuSurface` to. If this directive is used as the direct parent of an `mdcMenuSurface`,
+ * it will automatically be used as the anchor point. (Unless de `mdcMenuSurface` sets another anchor via its `menuAnchor`property).
+ */
+@Directive({
+ selector: '[mdcMenuAnchor]'
+})
+export class MdcMenuAnchorDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-menu-surface--anchor') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcMenuSurfaceDirective) private surfaces?: QueryList;
+
+ constructor(public _elm: ElementRef) {}
+
+ ngAfterContentInit() {
+ this.surfaces!.changes.subscribe(_ => {
+ this.setSurfaces(this);
+ });
+ this.setSurfaces(this);
+ }
+
+ ngOnDestroy() {
+ this.setSurfaces(null);
+ }
+
+ private setSurfaces(anchor: MdcMenuAnchorDirective | null) {
+ this.surfaces?.toArray().forEach(surface => {
+ surface._parentAnchor = anchor;
+ });
+ }
+
+ /** @internal */
+ public getBoundingClientRect() {
+ return this._elm.nativeElement.getBoundingClientRect();
+ }
+}
+
+export const MENU_SURFACE_DIRECTIVES = [
+ MdcMenuAnchorDirective,
+ MdcMenuSurfaceDirective
+];
diff --git a/bundle/src/components/menu/mdc.menu.adapter.ts b/bundle/src/components/menu/mdc.menu.adapter.ts
deleted file mode 100644
index 3b15eb0..0000000
--- a/bundle/src/components/menu/mdc.menu.adapter.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/** @docs-private */
-export interface MdcMenuAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- hasClass: (className: string) => boolean,
- hasNecessaryDom: () => boolean,
- getAttributeForEventTarget: (target: Element, attrName: string) => string,
- getInnerDimensions: () => {width: number, height: number},
- hasAnchor: () => boolean,
- getAnchorDimensions: () => {width: number, height: number, top: number, right: number, bottom: number, left: number},
- getWindowDimensions: () => {width: number, height: number},
- getNumberOfItems: () => number,
- registerInteractionHandler: (type: string, handler: EventListener) => void,
- deregisterInteractionHandler: (type: string, handler: EventListener) => void,
- registerBodyClickHandler: (handler: EventListener) => void,
- deregisterBodyClickHandler: (handler: EventListener) => void,
- getIndexForEventTarget: (target: EventTarget) => number,
- notifySelected: (evtData: {index: number}) => void,
- notifyCancel: () => void,
- saveFocus: () => void,
- restoreFocus: () => void,
- isFocused: () => boolean,
- focus: () => void,
- getFocusedItemIndex: () => number,
- focusItemAtIndex: (index: number) => void,
- isRtl: () => boolean,
- setTransformOrigin: (origin: string) => void,
- setPosition: (position: {top: string | undefined, right: string | undefined, bottom: string | undefined, left: string | undefined}) => void,
- setMaxHeight: (value: string) => void
-}
diff --git a/bundle/src/components/menu/mdc.menu.directive.spec.ts b/bundle/src/components/menu/mdc.menu.directive.spec.ts
new file mode 100644
index 0000000..0149e30
--- /dev/null
+++ b/bundle/src/components/menu/mdc.menu.directive.spec.ts
@@ -0,0 +1,375 @@
+import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { DefaultFocusState } from '@material/menu';
+import { LIST_DIRECTIVES } from '../list/mdc.list.directive';
+import { MENU_SURFACE_DIRECTIVES } from '../menu-surface/mdc.menu-surface.directive';
+import { MENU_DIRECTIVES, MdcMenuDirective } from '../menu/mdc.menu.directive';
+import { simulateKey } from '../../testutils/page.test';
+import { By } from '@angular/platform-browser';
+
+describe('mdcMenu', () => {
+ @Component({
+ template: `
+ before
+
+
Open Menu
+
+
+ A Menu Item
+ Another Menu Item
+
+
+
+ after
+ `,
+ styles: [`
+ #anchor {
+ left: 150px;
+ top: 150px;
+ width: 80px;
+ height: 20px;
+ }`
+ ]
+ })
+ class TestComponent {
+ notifications = [];
+ open = null;
+ openFrom = null;
+ fixed = null;
+ item2disabled = null;
+ notify(name: string, value: any) {
+ let notification = {};
+ notification[name] = value;
+ this.notifications.push(notification);
+ }
+ }
+
+ it('open with ArrowDown', fakeAsync(() => {
+ const { fixture, trigger } = setup();
+ validateOpenBy(fixture, () => {
+ simulateKey(trigger, 'ArrowDown');
+ });
+ expect(document.activeElement).toBe(listElement(fixture, 0));
+ }));
+
+ it('open with ArrowUp', fakeAsync(() => {
+ const { fixture, trigger } = setup();
+ validateOpenBy(fixture, () => {
+ simulateKey(trigger, 'ArrowUp');
+ });
+ expect(document.activeElement).toBe(listElement(fixture, 1));
+ }));
+
+ it('open with Enter', fakeAsync(() => {
+ const { fixture, trigger } = setup();
+ validateOpenBy(fixture, () => {
+ simulateKey(trigger, 'Enter');
+ trigger.click();
+ simulateKey(trigger, 'Enter', 'keyup');
+ });
+ expect(document.activeElement).toBe(listElement(fixture, 0));
+ }));
+
+ it('open with Space', fakeAsync(() => {
+ const { fixture, trigger } = setup();
+ validateOpenBy(fixture, () => {
+ simulateKey(trigger, 'Space');
+ trigger.click();
+ simulateKey(trigger, 'Space', 'keyup');
+ });
+ expect(document.activeElement).toBe(listElement(fixture, 0));
+ }));
+
+ it('open with click', fakeAsync(() => {
+ const { fixture, trigger, list } = setup();
+ validateOpenBy(fixture, () => {
+ trigger.click();
+ });
+ expect(document.activeElement).toBe(list);
+ }));
+
+ it('close restores focus', fakeAsync(() => {
+ const { fixture, surface, before, trigger, list } = setup();
+ before.focus();
+ validateOpenBy(fixture, () => trigger.click());
+ expect(document.activeElement).toBe(list);
+ validateCloseBy(fixture, () => simulateKey(surface, 'Escape'));
+ expect(document.activeElement).toBe(before);
+ }));
+
+ it('close with tab key does not restore focus', fakeAsync(() => {
+ const { fixture, surface, list, before, trigger, testComponent } = setup();
+ before.focus();
+ validateOpenBy(fixture, () => trigger.click());
+ expect(document.activeElement).toBe(list);
+ validateCloseBy(fixture, () => simulateKey(surface, 'Tab'));
+ // focus is unchanged from open state - (when a user presses the Tab key, focus will be changed to the next element)
+ expect(document.activeElement).toBe(list);
+ // no menu item picked:
+ expect(testComponent.notifications).toEqual([]);
+ }));
+
+ it('close with picking a menu item restores focus', fakeAsync(() => {
+ const { fixture, before, testComponent } = setup();
+ before.focus();
+ validateCloseBy(fixture, () => listElement(fixture, 1).click());
+ expect(document.activeElement).toBe(before);
+ expect(testComponent.notifications).toEqual([
+ {pick: {index: 1, value: null}}
+ ]);
+ }));
+
+ it('menu list has role=menu; items have role=menuitem', fakeAsync(() => {
+ const { list, items } = setup();
+ expect(list.getAttribute('role')).toBe('menu');
+ items.forEach(item => expect(item.getAttribute('role')).toBe('menuitem'));
+ }));
+
+ it('menu list aria attributes and tabindex', fakeAsync(() => {
+ const { fixture, surface, list, trigger } = setup();
+ expect(list.getAttribute('tabindex')).toBe('-1');
+ expect(list.getAttribute('aria-hidden')).toBe('true');
+ expect(list.getAttribute('aria-orientation')).toBe('vertical');
+
+ validateOpenBy(fixture, () => trigger.click());
+ expect(list.hasAttribute('aria-hidden')).toBeFalse();
+
+ validateCloseBy(fixture, () => simulateKey(surface, 'Escape'));
+ expect(list.getAttribute('aria-orientation')).toBe('vertical');
+ }));
+
+ it('disabled menu item', fakeAsync(() => {
+ const { fixture, testComponent } = setup();
+ testComponent.item2disabled = true;
+ fixture.detectChanges();
+
+ const items = [...fixture.nativeElement.querySelectorAll('li')];
+ expect(items[0].classList).not.toContain('mdc-list-item--disabled');
+ expect(items[0].hasAttribute('aria-disabled')).toBeFalse();
+ expect(items[1].classList).toContain('mdc-list-item--disabled');
+ expect(items[1].getAttribute('aria-disabled')).toBe('true');
+ }));
+
+ @Component({
+ template: `
+
+ after
+ `
+ })
+ class TestChangeListComponent {
+ firstList = true;
+ }
+
+ it('underlying list can be changed', fakeAsync(() => {
+ let { fixture, list, items, trigger, testComponent } = setup(TestChangeListComponent);
+
+ expect(list.id).toBe('list1');
+ expect(list.getAttribute('role')).toBe('menu');
+ items.forEach(item => expect(item.getAttribute('role')).toBe('menuitem'));
+ expect(list.getAttribute('tabindex')).toBe('-1');
+
+ testComponent.firstList = false;
+ fixture.detectChanges(); tick();
+
+ list = fixture.nativeElement.querySelector('ul');
+ items = [...fixture.nativeElement.querySelectorAll('li')];
+ expect(list.id).toBe('list2');
+ expect(list.getAttribute('role')).toBe('menu');
+ items.forEach(item => expect(item.getAttribute('role')).toBe('menuitem'));
+ expect(list.getAttribute('tabindex')).toBe('-1');
+
+ validateOpenBy(fixture, () => trigger.click(), TestChangeListComponent);
+ }));
+
+ @Component({
+ template: `
+
+ `
+ })
+ class TestListItemNgForComponent {
+ items = [
+ {value: 'item1', text: 'First Item'},
+ {value: 'item2', text: 'Second Item'}
+ ];
+ }
+ it('menu items in ngFor', fakeAsync(() => {
+ let { fixture, list, items, trigger, testComponent } = setup(TestListItemNgForComponent);
+
+ expect(list.getAttribute('role')).toBe('menu');
+ expect(items.length).toBe(2);
+ items.forEach(item => expect(item.getAttribute('role')).toBe('menuitem'));
+ expect(list.getAttribute('tabindex')).toBe('-1');
+
+ testComponent.items.push({value: 'item3', text: 'Third Item'});
+ // this would previously throw ExpressionChangedAfterItHasBeenCheckedError;
+ // fixed by calling ChangeDetectorRef.detectChanges after child components are
+ // updated in MdcListDirective.ngAfterContentInit:
+ fixture.detectChanges(); tick();
+
+ list = fixture.nativeElement.querySelector('ul');
+ items = [...fixture.nativeElement.querySelectorAll('li')];
+ expect(list.getAttribute('role')).toBe('menu');
+ expect(items.length).toBe(3);
+ items.forEach(item => expect(item.getAttribute('role')).toBe('menuitem'));
+ expect(list.getAttribute('tabindex')).toBe('-1');
+
+ validateOpenBy(fixture, () => trigger.click(), TestListItemNgForComponent);
+ }));
+
+ function validateOpenBy(fixture, doOpen: () => void, compType: Type = TestComponent) {
+ const { surface } = getElements(fixture, compType);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ doOpen();
+ animationCycle(fixture);
+ expect(surface.classList).toContain('mdc-menu-surface--open');
+ validateDefaultFocusState(fixture);
+ }
+
+ function validateCloseBy(fixture, doClose: () => void, compType: Type = TestComponent) {
+ const { surface } = getElements(fixture, compType);
+ doClose();
+ animationCycle(fixture);
+ expect(surface.classList).not.toContain('mdc-menu-surface--open');
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...LIST_DIRECTIVES, ...MENU_SURFACE_DIRECTIVES, ...MENU_DIRECTIVES, ...LIST_DIRECTIVES, compType]
+ }).createComponent(compType);
+ fixture.detectChanges(); tick();
+ return getElements(fixture, compType);
+ }
+
+ function getElements(fixture, compType: Type = TestComponent) {
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const menuDirective = fixture.debugElement.query(By.directive(MdcMenuDirective)).injector.get(MdcMenuDirective);
+ const anchor: HTMLElement = fixture.nativeElement.querySelector('.mdc-menu-surface--anchor');
+ const surface: HTMLElement = fixture.nativeElement.querySelector('.mdc-menu-surface');
+ const trigger: HTMLElement = fixture.nativeElement.querySelector('button');
+ const list: HTMLElement = fixture.nativeElement.querySelector('ul');
+ const before: HTMLAnchorElement = fixture.nativeElement.querySelectorAll('a').item(0);
+ const after: HTMLAnchorElement = fixture.nativeElement.querySelectorAll('a').item(1);
+ const items: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('li')];
+ return { fixture, anchor, surface, trigger, list, testComponent, menuDirective, before, after, items };
+ }
+
+ function listElement(fixture, index) {
+ return fixture.nativeElement.querySelectorAll('li').item(index);
+ }
+
+ function validateDefaultFocusState(fixture) {
+ const menuDirective = fixture.debugElement.query(By.directive(MdcMenuDirective)).injector.get(MdcMenuDirective);
+ // default focus state should always NONE when not currently handling an open/close:
+ expect(menuDirective['foundation']['defaultFocusState_']).toBe(DefaultFocusState.NONE);
+ }
+});
+
+describe('mdcMenuTrigger', () => {
+ @Component({
+ template: `
+
+
Open Menu1
+
+
+ A Menu Item
+ Another Menu Item
+
+
+
+
+
Open Menu2
+
+
+ A Menu Item
+ Another Menu Item
+
+
+
+ Whatever
+ `,
+ styles: [`
+ #anchor {
+ left: 150px;
+ top: 150px;
+ width: 80px;
+ height: 20px;
+ }`
+ ]
+ })
+ class TestComponent {
+ open = [false, false, false];
+ }
+
+ it('accessibility attributes mdcMenuTrigger', fakeAsync(() => {
+ const { fixture, triggers, surfaces } = setup();
+ // anchor element as menuTrigger; template assigned id:
+ expect(surfaces[0].id).toBe('surface1');
+ expect(triggers[0].getAttribute('role')).toBe('button');
+ expect(triggers[0].getAttribute('aria-haspopup')).toBe('menu');
+ expect(triggers[0].getAttribute('aria-controls')).toBe('surface1');
+ expect(triggers[0].hasAttribute('aria-expanded')).toBeFalse();
+ // button as menuTrigger, unique id assigned by menu:
+ expect(surfaces[1].id).toMatch(/mdc-menu-.*/);
+ expect(triggers[1].hasAttribute('role')).toBeFalse();
+ expect(triggers[1].getAttribute('aria-haspopup')).toBe('menu');
+ expect(triggers[1].getAttribute('aria-controls')).toBe(surfaces[1].id);
+ expect(triggers[1].hasAttribute('aria-expanded')).toBeFalse();
+ // not attached to a menu:
+ expect(triggers[2].hasAttribute('role')).toBeFalse();
+ expect(triggers[2].hasAttribute('aria-haspopup')).toBeFalse();
+ expect(triggers[2].hasAttribute('aria-controls')).toBeFalse();
+ expect(triggers[2].hasAttribute('aria-expanded')).toBeFalse();
+
+ // open:
+ triggers[0].click();
+ animationCycle(fixture);
+ expect(triggers[0].getAttribute('aria-expanded')).toBe('true');
+
+ // close:
+ simulateKey(surfaces[0], 'Escape');
+ animationCycle(fixture);
+ expect(triggers[0].hasAttribute('aria-expanded')).toBeFalse();
+ }));
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...LIST_DIRECTIVES, ...MENU_SURFACE_DIRECTIVES, ...MENU_DIRECTIVES, ...LIST_DIRECTIVES, compType]
+ }).createComponent(compType);
+ fixture.detectChanges();
+ return getElements(fixture);
+ }
+
+ function getElements(fixture, compType: Type = TestComponent) {
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const surfaces: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-menu-surface')];
+ const triggers: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('a,button')];
+ return { fixture, surfaces, triggers, testComponent };
+ }
+});
+
+function animationCycle(fixture) {
+ fixture.detectChanges(); tick(300); flush();
+}
+
diff --git a/bundle/src/components/menu/mdc.menu.directive.ts b/bundle/src/components/menu/mdc.menu.directive.ts
index 672065f..5cc92eb 100644
--- a/bundle/src/components/menu/mdc.menu.directive.ts
+++ b/bundle/src/components/menu/mdc.menu.directive.ts
@@ -1,18 +1,11 @@
import { AfterContentInit, ContentChildren, Directive, ElementRef, EventEmitter, HostBinding,
- Input, OnDestroy, Output, QueryList, Renderer2 } from '@angular/core';
-import { MDCMenuFoundation, MDCMenu } from '@material/menu';
-import { getTransformPropertyName } from '@material/menu/util';
-import { MdcMenuAdapter } from './mdc.menu.adapter';
+ Input, OnDestroy, Output, QueryList, Renderer2, Self, HostListener, OnInit } from '@angular/core';
+import { cssClasses as listCssClasses } from '@material/list';
+import { MDCMenuFoundation, MDCMenuAdapter, cssClasses, strings, DefaultFocusState } from '@material/menu';
+import { MdcMenuSurfaceDirective } from '../menu-surface/mdc.menu-surface.directive';
import { MdcListDirective, MdcListFunction } from '../list/mdc.list.directive';
-import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-const CLASS_MENU = 'mdc-menu';
-const CLASS_MENU_OPEN = 'mdc-menu--open';
-const CLASS_TOP_LEFT = 'mdc-menu--open-from-top-left';
-const CLASS_TOP_RIGHT = 'mdc-menu--open-from-top-right';
-const CLASS_BOTTOM_LEFT = 'mdc-menu--open-from-bottom-left';
-const CLASS_BOTTOM_RIGHT = 'mdc-menu--open-from-bottom-right';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
/**
* Data send by the pick
event of MdcMenuDirective
.
@@ -28,275 +21,343 @@ export interface MdcMenuSelection {
index: number
}
-/**
- * Directive for an optional anchor to which a menu can position itself.
- * Use the menuAnchor
input of MdcMenuDirective
- * to bind the menu to the anchor. The anchor must be a direct parent of the menu.
- * It will get the following styles to make the positioning work:
- * position: relative; overflow: visible;
.
- */
-@Directive({
- selector: '[mdcMenuAnchor]',
- exportAs: 'mdcMenuAnchor'
-})
-export class MdcMenuAnchorDirective {
- @HostBinding('class.mdc-menu-anchor') _cls = true;
+// attributes on list-items that we maintain ourselves, so should be ignored
+// in the adapter:
+const ANGULAR_ITEM_ATTRIBUTES = [
+ strings.ARIA_CHECKED_ATTR, strings.ARIA_DISABLED_ATTR
+];
+// classes on list-items that we maintain ourselves, so should be ignored
+// in the adapter:
+const ANGULAR_ITEM_CLASSES = [
+ listCssClasses.LIST_ITEM_DISABLED_CLASS, cssClasses.MENU_SELECTED_LIST_ITEM
+];
- constructor(public _elm: ElementRef) {
- }
-}
+export enum FocusOnOpen {first = 0, last = 1, root = -1};
+let nextId = 1;
/**
* Directive for a spec aligned material design Menu.
- * This directive should wrap an MdcListDirective
. The mdcList
- * contains the menu items (and possible separators).
+ * This directive should wrap an `mdcList`. The `mdcList` contains the menu items (and possible separators).
+ *
+ * An `mdcMenu` element will also match with the selector of the menu surface directive, documented
+ * here: mdcMenuSurface . The
+ * mdcMenuAnchor API is documented on the same page.
+ *
+ * # Accessibility
+ *
+ * * For `role` and `aria-*` attributes on the list, see documentation for `mdcList`.
+ * * The best way to open the menu by user interaction is to use the `mdcMenuTrigger` directive
+ * on the interaction element (e.g. button). This takes care of following ARIA recommended practices
+ * for focusing the correct element, and maintaining proper `aria-*` and `role` attributes on the
+ * interaction element, menu, and list.
+ * * When opening the `mdcMenuSurface` programmatic, the program is responsible for all of this.
+ * (including focusing an element of the menu or the menu itself).
+ * * The `mdcList` will be made focusable by setting a `"tabindex"="-1"` attribute.
+ * * The `mdcList` will get an `aria-orientation=vertical` attribute.
+ * * The `mdcList` will get an `aria-hidden=true` attribute when the menu surface is closed.
*/
@Directive({
- selector: '[mdcMenu]'
+ selector: '[mdcMenu],[mdcSelectMenu]',
+ exportAs: 'mdcMenu'
})
-export class MdcMenuDirective implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-menu') _cls = true;
+export class MdcMenuDirective implements AfterContentInit, OnInit, OnDestroy {
+ private onDestroy$: Subject = new Subject();
+ private onListChange$: Subject = new Subject();
+ /** @internal */
+ @Output() readonly itemsChanged: EventEmitter = new EventEmitter();
+ /** @internal */
+ @Output() readonly itemValuesChanged: EventEmitter = new EventEmitter();
+ /** @internal */
+ @HostBinding('class.mdc-menu') readonly _cls = true;
+ private _id: string | null = null;
+ private cachedId: string | null = null;
private _function = MdcListFunction.menu;
- private _openMemory = false;
- private _openFrom: 'tl' | 'tr' | 'bl' | 'br' | null = null;
- /**
- * Assign an (optional) MdcMenuAnchorDirective
. If set the menu
- * will position itself relative to this anchor element. The anchor should be
- * a direct parent of this menu.
- */
- @Input() menuAnchor: MdcMenuAnchorDirective;
+ private _lastList: MdcListDirective | null= null;
+
/**
* Event emitted when the user selects a value. The passed object contains a value
* (set to the value
of the selected list item), and an index
* (set to the index of the selected list item).
*/
- @Output() pick: EventEmitter = new EventEmitter();
- /**
- * Event emitted when the menu is closed without any selection being made.
- */
- @Output() cancel: EventEmitter = new EventEmitter();
- /**
- * Event emitted when the menu is opened or closed.
- */
- @Output() openChange: EventEmitter = new EventEmitter();
- private _lastList: MdcListDirective;
- @ContentChildren(MdcListDirective) _listQuery: QueryList;
- private _prevFocus: Element;
- private mdcAdapter: MdcMenuAdapter = {
- addClass: (className: string) => {
- this._rndr.addClass(this._elm.nativeElement, className);
- },
- removeClass: (className: string) => {
- this._rndr.removeClass(this._elm.nativeElement, className);
- },
- hasClass: (className: string) => {
- if (CLASS_MENU === className)
- return true;
- if (CLASS_MENU_OPEN === className)
- return this.open;
- if (CLASS_TOP_LEFT === className)
- return this._openFrom === 'tl';
- if (CLASS_TOP_RIGHT === className)
- return this._openFrom === 'tr';
- if (CLASS_BOTTOM_LEFT === className)
- return this._openFrom === 'bl';
- if (CLASS_BOTTOM_RIGHT === className)
- return this._openFrom === 'br';
- return this._elm.nativeElement.classList.contains(className);
- },
- hasNecessaryDom: () => this._listQuery.length != 0,
- getAttributeForEventTarget: (target: Element, attrName: string) => target.getAttribute(attrName),
- getInnerDimensions: () => {
- let elm = this._list._elm.nativeElement;
- return {width: elm.offsetWidth, height: elm.offsetHeight};
- },
- hasAnchor: () => this.menuAnchor != null,
- getAnchorDimensions: () => {
- if (!this.viewport)
- return this.menuAnchor._elm.nativeElement.getBoundingClientRect();
- let viewportRect = this.viewport.getBoundingClientRect();
- let anchorRect = this.menuAnchor._elm.nativeElement.getBoundingClientRect();
- return {
- bottom: anchorRect.bottom - viewportRect.top,
- left: anchorRect.left - viewportRect.left,
- right: anchorRect.right - viewportRect.left,
- top: anchorRect.top - viewportRect.top,
- width: anchorRect.width,
- height: anchorRect.height
- };
- },
- getWindowDimensions: () => ({
- width: this.viewport ? this.viewport.clientWidth : window.innerWidth,
- height: this.viewport ? this.viewport.clientHeight : window.innerHeight
- }),
- getNumberOfItems: () => this._list._items.length,
- registerInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.listen(this._rndr, type, handler, this._elm);
- },
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.unlisten(type, handler);
- },
- registerBodyClickHandler: (handler: EventListener) => {
- this._registry.listenElm(this._rndr, 'click', handler, document.body);
- },
- deregisterBodyClickHandler: (handler: EventListener) => {
- this._registry.unlisten('click', handler);
- },
- getIndexForEventTarget: (target: EventTarget) => this._list._items.toArray().map(i => i._elm.nativeElement).indexOf(target),
- notifySelected: (evtData: {index: number}) => {
- this.pick.emit({index: evtData.index, value: this._list._items.toArray()[evtData.index].value});
- // timeout so that the correct open/close value is reported, even if MDCMenu changes it after the event:
- window.setTimeout(() => this._onOpenClose(), 0);
- },
- notifyCancel: () => {
- this.cancel.emit();
- // timeout so that the correct open/close value is reported, even if MDCMenu changes it after the event:
- window.setTimeout(() => this._onOpenClose(), 0);
- },
- saveFocus: () => {
- this._prevFocus = document.activeElement;
+ @Output() readonly pick: EventEmitter = new EventEmitter();
+ /** @internal */
+ @ContentChildren(MdcListDirective) _listQuery?: QueryList;
+ private mdcAdapter: MDCMenuAdapter = {
+ addClassToElementAtIndex: (index, className) => {
+ // ignore classes we maintain ourselves
+ if (!ANGULAR_ITEM_CLASSES.find(c => c === className)) {
+ const elm = this._list?.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.addClass(elm, className);
+ }
},
- restoreFocus: () => {
- if (this._prevFocus)
- (this._prevFocus).focus();
+ removeClassFromElementAtIndex: (index, className) => {
+ // ignore classes we maintain ourselves
+ if (!ANGULAR_ITEM_CLASSES.find(c => c === className)) {
+ const elm = this._list?.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.addClass(elm, className);
+ }
},
- isFocused: () => document.activeElement === this._elm.nativeElement,
- focus: () => {
- this._elm.nativeElement.focus();
+ addAttributeToElementAtIndex: (index, attr, value) => {
+ // ignore attributes we maintain ourselves
+ if (!ANGULAR_ITEM_ATTRIBUTES.find(a => a === attr)) {
+ const elm = this._list?.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.setAttribute(elm, attr, value);
+ }
},
- getFocusedItemIndex: () => this._list._items.toArray().map(i => i._elm.nativeElement).indexOf(document.activeElement),
- focusItemAtIndex: (index: number) => {
- this._list._items.toArray()[index]._elm.nativeElement.focus();
+ removeAttributeFromElementAtIndex: (index, attr) => {
+ // ignore attributes we maintain ourselves
+ if (!ANGULAR_ITEM_ATTRIBUTES.find(a => a === attr)) {
+ const elm = this._list?.getItem(index)?._elm.nativeElement;
+ if (elm)
+ this.rndr.removeAttribute(elm, attr);
+ }
},
- isRtl: () => getComputedStyle(this._elm.nativeElement).getPropertyValue('direction') === 'rtl',
- setTransformOrigin: (origin: string) => {
- this._elm.nativeElement.style[`${getTransformPropertyName(window)}-origin`] = origin;
+ elementContainsClass: (element, className) => element.classList.contains(className),
+ closeSurface: (skipRestoreFocus) => {
+ if (skipRestoreFocus)
+ this.surface.closeWithoutFocusRestore();
+ else
+ this.surface.open = false;
},
- setPosition: (position: {top: string | undefined, right: string | undefined, bottom: string | undefined, left: string | undefined}) => {
- let el = this._elm.nativeElement;
- this._rndr.setStyle(el, 'left', 'left' in position ? position.left : null);
- this._rndr.setStyle(el, 'right', 'right' in position ? position.right : null);
- this._rndr.setStyle(el, 'top', 'top' in position ? position.top : null);
- this._rndr.setStyle(el, 'bottom', 'bottom' in position ? position.bottom : null);
+ getElementIndex: (element) => this._list?._items!.toArray().findIndex(i => i._elm.nativeElement === element),
+ notifySelected: (evtData) => {
+ this.pick.emit({index: evtData.index, value: this._list._items!.toArray()[evtData.index].value});
},
- setMaxHeight: (value: string) => {
- this._elm.nativeElement.style.maxHeight = value;
- }
+ getMenuItemCount: () => this._list?._items!.length || 0,
+ focusItemAtIndex: (index) => this._list.getItem(index)?._elm.nativeElement.focus(),
+ focusListRoot: () => this._list?._elm.nativeElement.focus(),
+ getSelectedSiblingOfItemAtIndex: () => -1, // menuSelectionGroup not yet supported
+ isSelectableItemAtIndex: () => false // menuSelectionGroup not yet supported
};
- private foundation: {
- open(arg?: {focusIndex?: number}),
- close(event?: Event),
- isOpen(): boolean
- } = new MDCMenuFoundation(this.mdcAdapter);
- // we need an MDCMenu for menus contained inside mdc-select:
- public _component: MDCMenu;
-
- constructor(public _elm: ElementRef, private _rndr: Renderer2, private _registry: MdcEventRegistry) {
+ private foundation: MDCMenuFoundation | null = null;
+
+ constructor(public _elm: ElementRef, private rndr: Renderer2, @Self() private surface: MdcMenuSurfaceDirective) {
+ }
+
+ ngOnInit() {
+ // Force setter to be called in case id was not specified.
+ this.id = this.id;
}
ngAfterContentInit() {
- this._lastList = this._listQuery.first;
- if (this._lastList) {
- this._lastList._setFunction(MdcListFunction.menu);
- this._onOpenClose(false);
- }
- this._listQuery.changes.subscribe(() => {
- if (this._lastList !== this._listQuery.first) {
- this._lastList._setFunction(MdcListFunction.plain);
- this._lastList = this._listQuery.first;
- if (this._lastList) {
- this._lastList._setFunction(MdcListFunction.menu);
- this._onOpenClose(false);
- if (this._component == null) {
- this._component = new MDCMenu(this._elm.nativeElement, this.foundation);
- this._component.open = this._openMemory;
- }
- } else if (this._component) {
- this._openMemory = this._component.open;
- this._component.destroy();
- this._component = null;
- this.foundation = new MDCMenuFoundation(this.mdcAdapter);
- }
+ this._lastList = this._listQuery!.first;
+ this._listQuery!.changes.subscribe(() => {
+ if (this._lastList !== this._listQuery!.first) {
+ this.onListChange$.next();
+ this._lastList?._setFunction(MdcListFunction.plain);
+ this._lastList = this._listQuery!.first;
+ this.destroyFoundation();
+ if (this._lastList)
+ this.initAll();
}
});
+ this.surface.afterOpened.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
+ this.foundation?.handleMenuSurfaceOpened();
+ // reset default focus state for programmatic opening of menu;
+ // interactive opening sets the default when the open is triggered
+ // (see openAndFocus)
+ this.foundation?.setDefaultFocusState(DefaultFocusState.NONE);
+ });
+ this.surface.openChange.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
+ if (this._list)
+ this._list._hidden = !this.surface.open;
+ });
if (this._lastList)
- // constructing the MDCMenu also initializes the foundation:
- this._component = new MDCMenu(this._elm.nativeElement, this.foundation);
+ this.initAll();
}
ngOnDestroy() {
- if (this._component)
- this._component.destroy();
+ this.onListChange$.next(); this.onListChange$.complete();
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.destroyFoundation();
+ }
+
+ private initAll() {
+ Promise.resolve().then(() => this._lastList!._setFunction(this._function));
+ this.initFoundation();
+ this.subscribeItemActions();
+ this._lastList?.itemsChanged.pipe(takeUntil(this.onListChange$)).subscribe(() => this.itemsChanged.emit());
+ this._lastList?.itemValuesChanged.pipe(takeUntil(this.onListChange$)).subscribe(() => this.itemValuesChanged.emit());
}
- private _onOpenClose(emit = true) {
+ private initFoundation() {
+ this.foundation = new MDCMenuFoundation(this.mdcAdapter);
+ this.foundation.init();
+ // suitable for programmatic opening, program can focus whatever element it wants:
+ this.foundation.setDefaultFocusState(DefaultFocusState.NONE);
if (this._list)
- this._list._hidden = !this.open;
- if (emit)
- this.openChange.emit(this.open);
+ this._list._hidden = !this.surface.open;
}
+ private destroyFoundation() {
+ if (this.foundation) {
+ this.foundation.destroy();
+ this.foundation = null;
+ }
+ }
+
+ private subscribeItemActions() {
+ this._lastList?.itemAction.pipe(takeUntil(this.onListChange$)).subscribe(data => {
+ this.foundation?.handleItemAction(this._list.getItem(data.index)!._elm.nativeElement);
+ });
+ }
+
+ /** @docs-private */
+ @HostBinding()
+ @Input() get id() {
+ return this._id;
+ }
+
+ set id(value: string | null) {
+ this._id = value || this._newId();
+ }
+
+ /** @internal */
+ _newId(): string {
+ this.cachedId = this.cachedId || `mdc-menu-${nextId++}`;
+ return this.cachedId;
+ }
+
+ /** @docs-private */
+ get open() {
+ return this.surface.open;
+ }
+
+ /** @docs-private */
+ openAndFocus(focus: FocusOnOpen) {
+ switch (focus) {
+ case FocusOnOpen.first:
+ this.foundation?.setDefaultFocusState(DefaultFocusState.FIRST_ITEM);
+ break;
+ case FocusOnOpen.last:
+ this.foundation?.setDefaultFocusState(DefaultFocusState.LAST_ITEM);
+ break;
+ case FocusOnOpen.root:
+ default:
+ this.foundation?.setDefaultFocusState(DefaultFocusState.LIST_ROOT);
+ }
+ this.surface.open = true;
+ }
+
+ /** @internal */
+ doClose() {
+ this.surface.open = false;
+ }
+
+ /** @internal */
set _listFunction(val: MdcListFunction) {
this._function = val;
if (this._lastList) // otherwise this will happen in ngAfterContentInit
this._list._setFunction(val);
}
+ /** @internal */
get _list(): MdcListDirective {
- return this._listQuery.first;
+ return this._listQuery!.first;
}
-
- /**
- * When this input is defined and does not have value false, the menu will be opened,
- * otherwise the menu will be closed.
- */
- @Input() @HostBinding('class.mdc-menu--open')
- get open() {
- return this._component ? this.foundation.isOpen() : this._openMemory;
+
+ /** @internal */
+ @HostListener('keydown', ['$event']) _onKeydown(event: KeyboardEvent) {
+ this.foundation?.handleKeydown(event);
}
-
- set open(val: any) {
- let newValue = asBoolean(val);
- if (newValue !== this.open) {
- this._openMemory = newValue;
- if (this._component != null) {
- if (newValue)
- this.foundation.open();
- else
- this.foundation.close();
- }
- this._onOpenClose(false);
- }
+}
+
+/**
+ *
+ * # Accessibility
+ *
+ * * `Enter`, `Space`, and `Down Arrow` keys open the menu and place focus on the first item.
+ * * `Up Arrow` opens the menu and places focus on the last item
+ * * Click/Touch events set focus to the mdcList root element
+ *
+ * * Attribute `role=button` will be set if the element is not already a button element.
+ * * Attribute `aria-haspopup=menu` will be set if an `mdcMenu` is attached.
+ * * Attribute `aria-expanded` will be set while the attached menu is open
+ * * Attribute `aria-controls` will be set to the id of the attached menu. (And a unique id will be generated,
+ * if none was set on the menu).
+ * * `Enter`, `Space`, and `Down-Arrow` will open the menu with the first menu item focused.
+ * * `Up-Arrow` will open the menu with the last menu item focused.
+ * * Mouse/Touch events will open the menu with the list root element focused. The list root element
+ * will handle keyboard navigation once it receives focus.
+ */
+@Directive({
+ selector: '[mdcMenuTrigger]',
+})
+export class MdcMenuTriggerDirective {
+ /** @internal */
+ @HostBinding('attr.role') _role: string | null = 'button';
+ private _mdcMenuTrigger: MdcMenuDirective | null = null;
+ private down = {
+ enter: false,
+ space: false
}
- /**
- * Set this value if you want to customize the direction from which the menu will be opened.
- * Note that without this setting the menu will base the direction upon its position in the viewport,
- * which is normally the right behavior. Use 'tl'
for top-left, 'br'
- * for bottom-right, etc.
- */
- @Input()
- get openFrom(): 'tl' | 'tr' | 'bl' | 'br' | null {
- return this._openFrom;
+ constructor(elm: ElementRef) {
+ if (elm.nativeElement.nodeName.toUpperCase() === 'BUTTON')
+ this._role = null;
}
- set openFrom(val: 'tl' | 'tr' | 'bl' | 'br' | null) {
- if (val === 'br' || val === 'bl' || val === 'tr' || val === 'tl')
- this._openFrom = val;
+ /** @internal */
+ @HostListener('click') onClick() {
+ if (this.down.enter || this.down.space)
+ this._mdcMenuTrigger?.openAndFocus(FocusOnOpen.first);
else
- this._openFrom = null;
+ this._mdcMenuTrigger?.openAndFocus(FocusOnOpen.root);
}
- @HostBinding('class.mdc-menu--open-from-top-left') get _tl() { return this._openFrom === 'tl'; }
- @HostBinding('class.mdc-menu--open-from-top-right') get _tr() { return this._openFrom === 'tr'; }
- @HostBinding('class.mdc-menu--open-from-bottom-left') get _bl() { return this._openFrom === 'bl'; }
- @HostBinding('class.mdc-menu--open-from-bottom-right') get _br() { return this._openFrom === 'br'; }
+ /** @internal */
+ @HostListener('keydown', ['$event']) onKeydown(event: KeyboardEvent) {
+ this.setDown(event, true);
+ const {key, keyCode} = event;
+ if (key === 'ArrowUp' || keyCode === 38)
+ this._mdcMenuTrigger?.openAndFocus(FocusOnOpen.last);
+ else if (key === 'ArrowDown' || keyCode === 40)
+ this._mdcMenuTrigger?.openAndFocus(FocusOnOpen.first);
+ }
- /**
- * Assign any HTMLElement
to this property to use as the viewport instead of
- * the window object. The menu will choose to open the menu from the top or bottom, and
- * from the left or right, based on the space available inside the viewport.
- * It's normally not needed to set this, and mainly added for the demos and examples.
- */
- @Input() viewport: HTMLElement;
+ /** @internal */
+ @HostListener('keyup', ['$event']) onKeyup(event: KeyboardEvent) {
+ this.setDown(event, false);
+ }
+
+ /** @internal */
+ @HostBinding('attr.aria-haspopup') get _hasPopup() {
+ return this._mdcMenuTrigger ? 'menu' : null;
+ }
+
+ /** @internal */
+ @HostBinding('attr.aria-expanded') get _expanded() {
+ return this._mdcMenuTrigger?.open ? 'true' : null;
+ }
+
+ /** @internal */
+ @HostBinding('attr.aria-controls') get _ariaControls() {
+ return this._mdcMenuTrigger?.id;
+ }
+
+ @Input() get mdcMenuTrigger() {
+ return this._mdcMenuTrigger;
+ }
+
+ set mdcMenuTrigger(value: MdcMenuDirective | null) {
+ if (value && value.openAndFocus)
+ this._mdcMenuTrigger = value;
+ else
+ this._mdcMenuTrigger = null;
+ }
+
+ private setDown(event: KeyboardEvent, isDown: boolean) {
+ const {key, keyCode} = event;
+ if (key === 'Enter' || keyCode === 13)
+ this.down.enter = isDown;
+ else if (key === 'Space' || keyCode === 32)
+ this.down.space = isDown;
+ }
}
+
+export const MENU_DIRECTIVES = [
+ MdcMenuDirective, MdcMenuTriggerDirective
+];
diff --git a/bundle/src/components/notched-outline/mdc.notched-outline.adapter.ts b/bundle/src/components/notched-outline/mdc.notched-outline.adapter.ts
deleted file mode 100644
index a30d328..0000000
--- a/bundle/src/components/notched-outline/mdc.notched-outline.adapter.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/** @docs-private */
-export interface MdcNotchedOutlineAdapter {
- getWidth: () => number;
- getHeight: () => number;
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- setOutlinePathAttr: (value: string) => void,
- getIdleOutlineStyleValue: (propertyName: string) => string
-}
diff --git a/bundle/src/components/notched-outline/mdc.notched-outline.directive.ts b/bundle/src/components/notched-outline/mdc.notched-outline.directive.ts
new file mode 100644
index 0000000..a16db07
--- /dev/null
+++ b/bundle/src/components/notched-outline/mdc.notched-outline.directive.ts
@@ -0,0 +1,117 @@
+import { AfterContentInit, Directive, ElementRef, HostBinding, OnDestroy, Renderer2, ContentChildren, QueryList } from '@angular/core';
+import { MDCNotchedOutlineFoundation, MDCNotchedOutlineAdapter } from '@material/notched-outline';
+
+/**
+ * This directive styles the notch of an `mdcNotchedOutline`. It should wrap the (floating)
+ * label of an input like `mdcTextField` or `mdcSelect`.
+ */
+@Directive({
+ selector: '[mdcNotchedOutlineNotch]'
+})
+export class MdcNotchedOutlineNotchDirective {
+ /** @internal */
+ @HostBinding('class.mdc-notched-outline__notch') readonly _cls = true;
+
+ constructor(public _elm: ElementRef) {
+ }
+}
+
+/**
+ * The notched outline is a border around all sides of either an `mdcTextField` or an
+ * `mdcSelect`. It should only be used for the outlined variant of these inputs.
+ * This directive should wrap an `mdcNotchedOutlineNotch`, which in turn wraps the
+ * actual label.
+ */
+@Directive({
+ selector: '[mdcNotchedOutline]'
+})
+export class MdcNotchedOutlineDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-notched-outline') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcNotchedOutlineNotchDirective) _notches?: QueryList;
+ private notchWidth: number | null = null;
+ private mdcAdapter: MDCNotchedOutlineAdapter = {
+ addClass: (name) => this.rndr.addClass(this.root.nativeElement, name),
+ removeClass: (name) => this.rndr.removeClass(this.root.nativeElement, name),
+ setNotchWidthProperty: (width) => this.rndr.setStyle(this.notch!._elm.nativeElement, 'width', `${width}px`),
+ removeNotchWidthProperty: () => this.rndr.removeStyle(this.notch!._elm.nativeElement, 'width')
+ };
+ private foundation: MDCNotchedOutlineFoundation | null = null;
+
+ constructor(private rndr: Renderer2, private root: ElementRef) {
+ this.addSurround('mdc-notched-outline__leading')
+ }
+
+ ngAfterContentInit() {
+ this.addSurround('mdc-notched-outline__trailing');
+ if (this.notch)
+ this.initFoundation();
+ this._notches!.changes.subscribe(() => {
+ this.destroyFoundation();
+ if (this._notches!.length > 0)
+ this.initFoundation();
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroyFoundation();
+ }
+
+ private initFoundation() {
+ this.foundation = new MDCNotchedOutlineFoundation(this.mdcAdapter);
+ this.foundation.init();
+ if (this.notchWidth)
+ this.foundation.notch(this.notchWidth);
+ else
+ this.foundation.closeNotch();
+ }
+
+ private destroyFoundation() {
+ if (this.foundation) {
+ this.foundation.destroy();
+ this.foundation = null;
+ }
+ }
+
+ private addSurround(clazz: string) {
+ let surround = this.rndr.createElement('span');
+ this.rndr.addClass(surround,clazz);
+ this.rndr.appendChild(this.root.nativeElement, surround);
+ }
+
+ private get notch() {
+ return this._notches?.first;
+ }
+
+ /**
+ * Opens the notched outline.
+ *
+ * @param width The width of the notch.
+ */
+ open(width: number) {
+ // TODO we actually want to compare the size here as well as the open/closed state (by dropping !! on both sides)
+ // but this reduces the width of the label when the input has a non-empty value. Needs investigation.
+ if (!!this.notchWidth !== !!width) {
+ this.notchWidth = width;
+ if (this.foundation)
+ this.foundation.notch(width);
+ }
+ }
+
+ /**
+ * Closes the notched outline.
+ */
+ close() {
+ if (this.notchWidth != null) {
+ this.notchWidth = null;
+ if (this.foundation)
+ this.foundation.closeNotch();
+ }
+ }
+}
+
+export const NOTCHED_OUTLINE_DIRECTIVES = [
+ MdcNotchedOutlineNotchDirective,
+ MdcNotchedOutlineDirective
+];
diff --git a/bundle/src/components/notched-outline/notched-outline.support.ts b/bundle/src/components/notched-outline/notched-outline.support.ts
deleted file mode 100644
index 73f100a..0000000
--- a/bundle/src/components/notched-outline/notched-outline.support.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { AfterContentInit, Directive, ElementRef, HostBinding, OnDestroy, Renderer2 } from '@angular/core';
-import { MDCNotchedOutlineFoundation } from '@material/notched-outline';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-import { MdcNotchedOutlineAdapter } from './mdc.notched-outline.adapter';
-import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-
-/** @docs-private */
-export class NotchedOutlineSupport {
- private _initialized = false;
- private path: SVGPathElement;
- private _outline: HTMLElement;
- private _outlineIdle: HTMLElement;
- private _adapter: MdcNotchedOutlineAdapter = {
- getWidth: () => this._outline.offsetWidth,
- getHeight: () => this._outline.offsetHeight,
- addClass: (className: string) => this._rndr.addClass(this._outline, className),
- removeClass: (className: string) => this._rndr.removeClass(this._outline, className),
- setOutlinePathAttr: (value: string) => this._rndr.setAttribute(this.path, 'd', value), //, 'svg'),
- getIdleOutlineStyleValue: (propertyName: string) => window.getComputedStyle(this._outlineIdle).getPropertyValue(propertyName)
- }
- private _foundation: {
- init(): void,
- destroy(): void,
- notch(notchWidth: number, isRtl: boolean): void,
- closeNotch(): void,
- updateSvgPath(notchWidth: number, isRtl: boolean): void
- };
-
- constructor(private _elm: ElementRef, private _rndr: Renderer2) {
- }
-
- get foundation() {
- return this._foundation;
- }
-
- init() {
- if (this._foundation == null) {
- let path = this._rndr.createElement('path', 'svg');
- this._rndr.addClass(path, 'mdc-notched-outline__path');
- let svg = this._rndr.createElement('svg', 'svg');
- this._rndr.appendChild(svg, path);
- this._rndr.appendChild(this._elm.nativeElement, svg);
- let outline = this._rndr.createElement('div');
- this._rndr.addClass(outline, 'mdc-notched-outline');
- this._rndr.appendChild(outline, svg);
-
- let outlineIdle = this._rndr.createElement('div');
- this._rndr.addClass(outlineIdle, 'mdc-notched-outline__idle');
-
- this._rndr.appendChild(this._elm.nativeElement, outline);
- this._rndr.appendChild(this._elm.nativeElement, outlineIdle);
-
- this._outline = outline;
- this._outlineIdle = outlineIdle;
- this.path = path;
- this._foundation = new MDCNotchedOutlineFoundation(this._adapter);
- this._foundation.init();
- }
- }
-
- destroy() {
- if (this._foundation != null) {
- try {
- this._foundation.destroy();
- this._rndr.removeChild(this._elm.nativeElement, this._outlineIdle);
- this._rndr.removeChild(this._elm.nativeElement, this._outline);
- } finally {
- this._outline = null;
- this._outlineIdle = null;
- this.path = null;
- this._foundation = null;
- }
- }
- }
-}
diff --git a/bundle/src/components/radio/mdc.radio.adapter.ts b/bundle/src/components/radio/mdc.radio.adapter.ts
deleted file mode 100644
index cba9b0f..0000000
--- a/bundle/src/components/radio/mdc.radio.adapter.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/** @docs-private */
-export interface MdcRadioAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- getNativeControl: () => HTMLInputElement;
-}
diff --git a/bundle/src/components/radio/mdc.radio.directive.spec.ts b/bundle/src/components/radio/mdc.radio.directive.spec.ts
new file mode 100644
index 0000000..c754d73
--- /dev/null
+++ b/bundle/src/components/radio/mdc.radio.directive.spec.ts
@@ -0,0 +1,229 @@
+import { TestBed, fakeAsync, ComponentFixture, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { MdcRadioInputDirective, MdcRadioDirective } from './mdc.radio.directive';
+import { hasRipple } from '../../testutils/page.test';
+import { By } from '@angular/platform-browser';
+import { FormsModule } from '@angular/forms';
+
+describe('MdcRadioDirective', () => {
+ it('should render the mdcRadio with ripple and label', fakeAsync(() => {
+ const { fixture } = setup();
+ const root = fixture.nativeElement.querySelector('.mdc-radio');
+ expect(root.children.length).toBe(3);
+ expect(root.children[0].classList).toContain('mdc-radio__native-control');
+ expect(root.children[1].classList).toContain('mdc-radio__background');
+ expect(root.children[2].classList).toContain('mdc-radio__ripple');
+ expect(hasRipple(root)).toBe(true, 'the ripple element should be attached');
+ }));
+
+ it('checked can be set programmatically', fakeAsync(() => {
+ const { fixture, testComponent, elements } = setup();
+ expect(testComponent.value).toBe(null);
+ for (let i in [0, 1, 2])
+ expect(elements[i].checked).toBe(false);
+ setAndCheck(fixture, 'r1', [true, false, false]);
+ setAndCheck(fixture, 'r2', [false, true, false]);
+ setAndCheck(fixture, 'r3', [false, false, true]);
+ setAndCheck(fixture, 'doesnotexist', [false, false, false]);
+ setAndCheck(fixture, null, [false, false, false]);
+ setAndCheck(fixture, '', [false, false, false]);
+ }));
+
+ it('checked can be set by user', fakeAsync(() => {
+ const { fixture, testComponent, elements, inputs } = setup();
+
+ elements[1].click();
+ tick(); fixture.detectChanges();
+ expect(elements.map(e => e.checked)).toEqual([false, true, false]);
+ expect(testComponent.value).toBe('r2');
+ }));
+
+ it('can be disabled', fakeAsync(() => {
+ const { fixture, testComponent, elements, inputs } = setup();
+
+ testComponent.disabled = true;
+ fixture.detectChanges();
+ for (let i in [0, 1, 2]) {
+ expect(elements[i].disabled).toBe(true);
+ expect(inputs[i].disabled).toBe(true);
+ }
+ expect(testComponent.disabled).toBe(true);
+ const radio = fixture.debugElement.query(By.directive(MdcRadioDirective)).injector.get(MdcRadioDirective);
+ expect(radio['isRippleSurfaceDisabled']()).toBe(true);
+ expect(radio['root'].nativeElement.classList).toContain('mdc-radio--disabled');
+
+ testComponent.disabled = false;
+ fixture.detectChanges();
+ for (let i in [0, 1, 2]) {
+ expect(elements[i].disabled).toBe(false);
+ expect(inputs[i].disabled).toBe(false);
+ }
+ expect(testComponent.disabled).toBe(false);
+ expect(radio['isRippleSurfaceDisabled']()).toBe(false);
+ expect(radio['root'].nativeElement.classList).not.toContain('mdc-radio--disabled');
+ }));
+
+ it('native input can be changed dynamically', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestComponentDynamicInput);
+
+ let elements = fixture.nativeElement.querySelectorAll('.mdc-radio__native-control');
+ // when no input is present the mdcRadio renders without an initialized foundation:
+ expect(elements.length).toBe(0);
+
+ let check = false;
+ for (let i = 0; i != 3; ++i) {
+ // render/include one of the inputs:
+ testComponent.input = i;
+ fixture.detectChanges();
+ // the input should be recognized, the foundation is (re)initialized,
+ // so we have a fully functional mdcRadio now:
+ elements = fixture.nativeElement.querySelectorAll('.mdc-radio__native-control');
+ expect(elements.length).toBe(1);
+ expect(elements[0].classList).toContain('mdc-radio__native-control');
+ expect(elements[0].id).toBe(`i${i}`);
+ // the value of the native input is correctly synced with the testcomponent:
+ expect(elements[0].checked).toBe(check);
+ // change the value for the next iteration:
+ check = !check;
+ testComponent.checked = check;
+ fixture.detectChanges();
+ expect(elements[0].checked).toBe(check);
+ }
+
+ // removing input should also work:
+ testComponent.input = null;
+ fixture.detectChanges();
+ elements = fixture.nativeElement.querySelectorAll('.mdc-radio__native-control');
+ // when no input is present the mdcRadio renders without an initialized foundation:
+ expect(elements.length).toBe(0);
+ expect(testComponent.checked).toBe(check);
+ }));
+
+ it('user interactions are registered in the absence of template bindings', fakeAsync(() => {
+ const { fixture, elements, inputs } = setup(TestComponentNoBinding);
+
+ expect(elements.map(e => e.checked)).toEqual([false, false, false]);
+ elements[1].click();
+ fixture.detectChanges();
+ expect(elements.map(e => e.checked)).toEqual([false, true, false]);
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any, expected: boolean[]) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const elements: HTMLInputElement[] = Array.from(fixture.nativeElement.querySelectorAll('.mdc-radio__native-control'));
+ testComponent.value = value;
+ fixture.detectChanges();
+ expect(elements.map(e => e.checked)).toEqual(expected);
+ }
+
+ @Component({
+ template: `
+
+
+
+ `
+ })
+ class TestComponent {
+ value: any = null;
+ disabled: any = null;
+ }
+
+ @Component({
+ template: `
+
+
+
+ `
+ })
+ class TestComponentNoBinding {
+ }
+
+ @Component({
+ template: `
+
+
+
+
+
+ `
+ })
+ class TestComponentDynamicInput {
+ input: number = null;
+ checked: any = null;
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [MdcRadioInputDirective, MdcRadioDirective, compType]
+ }).createComponent(compType);
+ fixture.detectChanges();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const inputs = fixture.debugElement.queryAll(By.directive(MdcRadioInputDirective)).map(i => i.injector.get(MdcRadioInputDirective));
+ const elements: HTMLInputElement[] = Array.from(fixture.nativeElement.querySelectorAll('.mdc-radio__native-control'));
+ return { fixture, testComponent, inputs, elements };
+ }
+});
+
+describe('MdcRadioDirective with FormsModule', () => {
+ it('ngModel can be set programmatically', fakeAsync(() => {
+ const { fixture, testComponent, elements } = setup();
+ expect(testComponent.value).toBe(null);
+ for (let i in [0, 1, 2])
+ expect(elements[i].checked).toBe(false);
+ setAndCheck(fixture, 'r1', [true, false, false]);
+ setAndCheck(fixture, 'r2', [false, true, false]);
+ setAndCheck(fixture, 'r3', [false, false, true]);
+ setAndCheck(fixture, 'doesnotexist', [false, false, false]);
+ setAndCheck(fixture, null, [false, false, false]);
+ setAndCheck(fixture, '', [false, false, false]);
+ }));
+
+ it('ngModel can be changed by user', fakeAsync(() => {
+ const { fixture, testComponent, elements, inputs } = setup();
+
+ expect(elements.map(e => e.checked)).toEqual([false, false, false]);
+ expect(testComponent.value).toBe(null);
+
+ elements[0].click();
+ fixture.detectChanges();
+ expect(elements.map(e => e.checked)).toEqual([true, false, false]);
+ expect(testComponent.value).toBe('r1');
+
+ elements[2].click();
+ fixture.detectChanges();
+ expect(elements.map(e => e.checked)).toEqual([false, false, true]);
+ expect(testComponent.value).toBe('r3');
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any, expected: boolean[]) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const elements: HTMLInputElement[] = Array.from(fixture.nativeElement.querySelectorAll('.mdc-radio__native-control'));
+ testComponent.value = value;
+ fixture.detectChanges(); flush();
+ expect(elements.map(e => e.checked)).toEqual(expected);
+ }
+
+ @Component({
+ template: `
+
+
+
+ `
+ })
+ class TestComponent {
+ value: any = null;
+ }
+
+ function setup() {
+ const fixture = TestBed.configureTestingModule({
+ imports: [FormsModule],
+ declarations: [MdcRadioInputDirective, MdcRadioDirective, TestComponent]
+ }).createComponent(TestComponent);
+ fixture.detectChanges();
+ tick();
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const inputs = fixture.debugElement.queryAll(By.directive(MdcRadioInputDirective)).map(i => i.injector.get(MdcRadioInputDirective));
+ const elements: HTMLInputElement[] = Array.from(fixture.nativeElement.querySelectorAll('.mdc-radio__native-control'));
+ return { fixture, testComponent, inputs, elements };
+ }
+});
diff --git a/bundle/src/components/radio/mdc.radio.directive.ts b/bundle/src/components/radio/mdc.radio.directive.ts
index 864b011..5c28fb2 100644
--- a/bundle/src/components/radio/mdc.radio.directive.ts
+++ b/bundle/src/components/radio/mdc.radio.directive.ts
@@ -1,9 +1,9 @@
-import { AfterContentInit, Component, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, HostListener,
- Input, OnDestroy, OnInit, Optional, Output, Provider, Renderer2, Self, ViewChild, ViewEncapsulation, forwardRef } from '@angular/core';
+import { AfterContentInit, Directive, ElementRef, HostBinding,
+ Input, OnDestroy, Optional, Renderer2, Self, forwardRef, ContentChildren, QueryList, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
import { NgControl } from '@angular/forms';
-import { MDCRadioFoundation } from '@material/radio';
+import { MDCRadioFoundation, MDCRadioAdapter } from '@material/radio';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-import { MdcRadioAdapter } from './mdc.radio.adapter';
import { AbstractMdcInput } from '../abstract/abstract.mdc.input';
import { asBoolean } from '../../utils/value.utils';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
@@ -16,8 +16,9 @@ import { MdcEventRegistry } from '../../utils/mdc.event.registry';
providers: [{provide: AbstractMdcInput, useExisting: forwardRef(() => MdcRadioInputDirective) }]
})
export class MdcRadioInputDirective extends AbstractMdcInput {
- @HostBinding('class.mdc-radio__native-control') _cls = true;
- private _id: string;
+ /** @internal */
+ @HostBinding('class.mdc-radio__native-control') readonly _cls = true;
+ private _id: string | null = null;
private _disabled = false;
constructor(public _elm: ElementRef, @Optional() @Self() public _cntr: NgControl) {
@@ -30,19 +31,21 @@ export class MdcRadioInputDirective extends AbstractMdcInput {
return this._id;
}
- set id(value: string) {
+ set id(value: string | null) {
this._id = value;
}
/** @docs-private */
@HostBinding()
@Input() get disabled() {
- return this._cntr ? this._cntr.disabled : this._disabled;
+ return this._cntr ? !!this._cntr.disabled : this._disabled;
}
- set disabled(value: any) {
+ set disabled(value: boolean) {
this._disabled = asBoolean(value);
}
+
+ static ngAcceptInputType_disabled: boolean | '';
}
/**
@@ -56,38 +59,43 @@ export class MdcRadioInputDirective extends AbstractMdcInput {
*
* This directive can be used together with an mdcFormField
to
* easily position radio buttons and their labels, see
- * mdcFormField .
+ * mdcFormField .
*/
@Directive({
selector: '[mdcRadio]'
})
export class MdcRadioDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
- @HostBinding('class.mdc-radio') _cls = true;
- @ContentChild(MdcRadioInputDirective) _input: MdcRadioInputDirective;
- private mdcAdapter: MdcRadioAdapter = {
- addClass: (className: string) => {
- this.renderer.addClass(this.root.nativeElement, className);
- },
- removeClass: (className: string) => {
- this.renderer.removeClass(this.root.nativeElement, className);
- },
- getNativeControl: () => this._input ? this._input._elm.nativeElement : null
+ /** @internal */
+ @HostBinding('class.mdc-radio') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcRadioInputDirective) _inputs?: QueryList;
+ private mdcAdapter: MDCRadioAdapter = {
+ // We can just ignore all adapter calls, since we have a HostBinding for the
+ // disabled classes, and never call foundation.setDisabled
+ addClass: () => undefined,
+ removeClass: () => undefined,
+ setNativeControlDisabled: () => undefined
};
- private foundation: { init: Function, destroy: Function } = new MDCRadioFoundation(this.mdcAdapter);
+ private foundation: MDCRadioFoundation | null = new MDCRadioFoundation(this.mdcAdapter);
- constructor(private renderer: Renderer2, private root: ElementRef, private registry: MdcEventRegistry) {
- super(root, renderer, registry);
+ constructor(private renderer: Renderer2, private root: ElementRef, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(root, renderer, registry, doc as Document);
}
ngAfterContentInit() {
this.addBackground();
- this.initRipple();
- this.foundation.init();
+ this.addRippleSurface('mdc-radio__ripple');
+ this.initRipple(true);
+ this.foundation!.init();
+ this._inputs!.changes.subscribe(() => {
+ this.reinitRipple();
+ });
}
ngOnDestroy() {
this.destroyRipple();
- this.foundation.destroy();
+ this.foundation?.destroy();
+ this.foundation = null;
}
private addBackground() {
@@ -102,17 +110,12 @@ export class MdcRadioDirective extends AbstractMdcRipple implements AfterContent
this.renderer.appendChild(this.root.nativeElement, bg);
}
- /** @docs-private */
+ /** @internal */
protected getRippleInteractionElement() {
- return this._input ? this._input._elm : null;
- }
-
- /** @docs-private */
- isRippleUnbounded() {
- return true;
+ return this._input?._elm;
}
- /** @docs-private */
+ /** @internal */
isRippleSurfaceActive() {
// This is what the @material/radio MDCRadio component does, with the following comment:
// "Radio buttons technically go 'active' whenever there is *any* keyboard interaction.
@@ -120,7 +123,20 @@ export class MdcRadioDirective extends AbstractMdcRipple implements AfterContent
return false;
}
+ // instead of calling foundation.setDisabled on disabled state changes, we just
+ // bind the class to the property:
+ /** @internal */
@HostBinding('class.mdc-radio--disabled') get _disabled() {
return this._input == null || this._input.disabled;
}
+
+ /** @internal */
+ get _input() {
+ return this._inputs && this._inputs.length > 0 ? this._inputs.first : null;
+ }
}
+
+export const RADIO_DIRECTIVES = [
+ MdcRadioInputDirective,
+ MdcRadioDirective
+];
diff --git a/bundle/src/components/ripple/abstract.mdc.ripple.ts b/bundle/src/components/ripple/abstract.mdc.ripple.ts
index 9338e70..1247190 100644
--- a/bundle/src/components/ripple/abstract.mdc.ripple.ts
+++ b/bundle/src/components/ripple/abstract.mdc.ripple.ts
@@ -1,60 +1,62 @@
-import {
- ElementRef,
- Renderer2
-} from '@angular/core';
-import { MDCRippleFoundation, util } from '@material/ripple';
-import { asBoolean, asBooleanOrNull } from '../../utils/value.utils';
-import { MdcRippleAdapter } from './mdc.ripple.adapter';
+import { ElementRef, Renderer2, HostListener, Directive } from '@angular/core';
+import { MDCRippleFoundation, MDCRippleAdapter, util } from '@material/ripple';
+import { events } from '@material/dom';
+import { ponyfill } from '@material/dom';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-// cast to correct type (string); getMatchesProperty is annotated as returning string[], but it does actually return a string:
-const matchesProperty: string = util.getMatchesProperty(HTMLElement.prototype);
-
/** @docs-private */
+@Directive()
export abstract class AbstractMdcRipple {
- private mdcRippleAdapter: MdcRippleAdapter = {
- browserSupportsCssVars: () => util.supportsCssVariables(window),
- isUnbounded: () => this.isRippleUnbounded(),
+ private mdcRippleAdapter: MDCRippleAdapter = {
+ browserSupportsCssVars: () => util.supportsCssVariables(this.document.defaultView!),
+ isUnbounded: () => this._unbounded,
isSurfaceActive: () => this.isRippleSurfaceActive(),
isSurfaceDisabled: () => this.isRippleSurfaceDisabled(),
- addClass: (className: string) => this.addClassToRipple(className),
- removeClass: (className: string) => this.removeClassFromRipple(className),
- containsEventTarget: (target: EventTarget) => this._rippleElm.nativeElement.contains(target),
- registerInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.listenElm(this._renderer, type, handler, this.getRippleInteractionElement().nativeElement, util.applyPassive());
+ addClass: (className) => this.addClassToRipple(className),
+ removeClass: (className) => this.removeClassFromRipple(className),
+ containsEventTarget: (target) => this._rippleElm.nativeElement.contains(target),
+ registerInteractionHandler: (type, handler) => {
+ if (this.getRippleInteractionElement())
+ this._registry.listenElm(this._renderer, type, handler, this.getRippleInteractionElement()!.nativeElement, events.applyPassive());
},
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
+ deregisterInteractionHandler: (type, handler) => {
this._registry.unlisten(type, handler);
},
- registerDocumentInteractionHandler: (type: string, handler: EventListener) => this._registry.listenElm(this._renderer, type, handler, document),
- deregisterDocumentInteractionHandler: (type: string, handler: EventListener) => this._registry.unlisten(type, handler),
- registerResizeHandler: (handler: EventListener) => {
- this._registry.listenElm(this._renderer, 'resize', handler, window);
+ registerDocumentInteractionHandler: (type, handler) => this._registry.listenElm(this._renderer, type, handler, this.document, events.applyPassive()),
+ deregisterDocumentInteractionHandler: (type, handler) => this._registry.unlisten(type, handler),
+ registerResizeHandler: (handler) => {
+ this._registry.listenElm(this._renderer, 'resize', handler, this.document.defaultView!);
},
- deregisterResizeHandler: (handler: EventListener) => {
+ deregisterResizeHandler: (handler) => {
this._registry.unlisten('resize', handler);
},
- updateCssVariable: (name: string, value: string) => { this._rippleElm.nativeElement.style.setProperty(name, value); },
+ updateCssVariable: (name, value) => { this.getRippleStylingElement().nativeElement.style.setProperty(name, value); },
computeBoundingRect: () => this.computeRippleBoundingRect(),
- getWindowPageOffset: () => ({x: window.pageXOffset, y: window.pageYOffset})
+ getWindowPageOffset: () => ({x: this.document.defaultView!.pageXOffset, y: this.document.defaultView!.pageYOffset})
}
- protected _rippleFoundation: {
- init(),
- destroy(),
- activate(event?: Event),
- deactivate(event?: Event),
- layout()
- };
- constructor(protected _rippleElm: ElementRef, protected _renderer: Renderer2, protected _registry: MdcEventRegistry) {}
+ /** @internal */
+ protected _rippleFoundation: MDCRippleFoundation | null = null;
+ private _unbounded = false;
+ private _rippleSurface: HTMLElement | null = null;
+ protected document: Document;
+
+ constructor(protected _rippleElm: ElementRef, protected _renderer: Renderer2, protected _registry: MdcEventRegistry,
+ doc: any) {
+ // workaround compiler bug when using ViewEngine. Type Document fails compilation
+ this.document = doc as Document
+ }
- protected initRipple() {
+ /** @internal */
+ protected initRipple(unbounded = false) {
if (this._rippleFoundation)
throw new Error('initRipple() is called multiple times');
+ this._unbounded = unbounded;
this._rippleFoundation = new MDCRippleFoundation(this.mdcRippleAdapter);
this._rippleFoundation.init();
}
+ /** @internal */
protected destroyRipple() {
if (this._rippleFoundation) {
this._rippleFoundation.destroy();
@@ -62,10 +64,40 @@ export abstract class AbstractMdcRipple {
}
}
+ /** @internal */
+ protected reinitRipple() {
+ if (this._rippleFoundation) {
+ this.destroyRipple();
+ this.initRipple(this._unbounded);
+ }
+ }
+
+ /** @internal */
protected isRippleInitialized() {
return this._rippleFoundation != null;
}
+ /** @internal */
+ protected addRippleSurface(clazz: string, firstElement = false) {
+ this.destroyRippleSurface();
+ this._rippleSurface = this._renderer.createElement('div');
+ this._renderer.addClass(this._rippleSurface, clazz);
+ if (firstElement && this._rippleElm.nativeElement.children.length > 0) {
+ const firstChild = this._rippleElm.nativeElement.children.item(0);
+ this._renderer.insertBefore(this._rippleElm.nativeElement, this._rippleSurface, firstChild);
+ } else
+ this._renderer.appendChild(this._rippleElm.nativeElement, this._rippleSurface);
+ return this._rippleSurface;
+ }
+
+ /** @internal */
+ protected destroyRippleSurface() {
+ if (this._rippleSurface) {
+ this._renderer.removeChild(this._rippleElm.nativeElement, this._rippleSurface);
+ this._rippleSurface = null;
+ }
+ }
+
activateRipple() {
if (this._rippleFoundation)
this._rippleFoundation.activate();
@@ -76,41 +108,77 @@ export abstract class AbstractMdcRipple {
this._rippleFoundation.deactivate();
}
- protected getRippleInteractionElement() {
+ layout() {
+ if (this._rippleFoundation)
+ this._rippleFoundation.layout();
+ }
+
+ protected get rippleSurface() {
+ return new ElementRef(this._rippleSurface);
+ }
+
+ protected getRippleInteractionElement(): ElementRef | undefined {
return this._rippleElm;
}
- protected isRippleUnbounded() {
- return false;
+ protected getRippleStylingElement() {
+ return this._rippleElm;
+ }
+
+ protected isRippleUnbounded(): boolean {
+ return this._unbounded;
+ }
+
+ /** @internal */
+ protected setRippleUnbounded(value: boolean) {
+ if (!!value !== this._unbounded) {
+ this._unbounded = !!value;
+ // despite what the documentation seems to indicate, you can't
+ // just change the unbounded property of an already initialized
+ // ripple. The initialization registers different handlers, and won't
+ // change those registrations when you change the unbounded property.
+ // Hence we destroy and re-init the whole thing:
+ this.reinitRipple();
+ }
}
protected isRippleSurfaceActive() {
let interactionElm = this.getRippleInteractionElement();
- if (interactionElm == null)
- return false;
- return this.isActiveElement(interactionElm.nativeElement);
+ return !!interactionElm && this.isActiveElement(interactionElm.nativeElement);
}
protected isActiveElement(element: HTMLElement) {
- return element == null ? false : element[matchesProperty](':active');
+ return element == null ? false : ponyfill.matches(element, ':active');
}
protected isRippleSurfaceDisabled() {
let interactionElm = this.getRippleInteractionElement();
- if (interactionElm == null)
- return true;
- return !!interactionElm.nativeElement.attributes.getNamedItem('disabled');
+ return !!interactionElm && !!interactionElm.nativeElement.attributes.getNamedItem('disabled');
}
+ /** @internal */
protected addClassToRipple(name: string) {
- this._renderer.addClass(this._rippleElm.nativeElement, name);
+ this._renderer.addClass(this.getRippleStylingElement().nativeElement, name);
}
+ /** @internal */
protected removeClassFromRipple(name: string) {
- this._renderer.removeClass(this._rippleElm.nativeElement, name);
+ this._renderer.removeClass(this.getRippleStylingElement().nativeElement, name);
}
protected computeRippleBoundingRect() {
return this._rippleElm.nativeElement.getBoundingClientRect();
}
+
+ /** @internal */
+ @HostListener('focusin') onFocus() {
+ if (this._rippleFoundation)
+ this._rippleFoundation.handleFocus();
+ }
+
+ /** @internal */
+ @HostListener('focusout') onBlur() {
+ if (this._rippleFoundation)
+ this._rippleFoundation.handleBlur();
+ }
}
diff --git a/bundle/src/components/ripple/mdc.ripple.adapter.ts b/bundle/src/components/ripple/mdc.ripple.adapter.ts
deleted file mode 100644
index 807b6d0..0000000
--- a/bundle/src/components/ripple/mdc.ripple.adapter.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/** @docs-private */
-export interface MdcRippleAdapter {
- browserSupportsCssVars: () => boolean;
- isUnbounded: () => boolean;
- isSurfaceActive: () => boolean;
- isSurfaceDisabled: () => boolean;
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- containsEventTarget: (target: EventTarget) => boolean;
- registerInteractionHandler: (type: string, handler: EventListener) => void;
- deregisterInteractionHandler: (type: string, handler: EventListener) => void;
- registerDocumentInteractionHandler: (type: string, handler: EventListener) => void;
- deregisterDocumentInteractionHandler: (type: string, handler: EventListener) => void;
- registerResizeHandler: (handler: EventListener) => void;
- deregisterResizeHandler: (handler: EventListener) => void;
- updateCssVariable: (name: string, value: number | string) => void;
- computeBoundingRect: () => ClientRect;
- getWindowPageOffset: () => {x: number, y: number};
-}
diff --git a/bundle/src/components/ripple/mdc.ripple.directive.spec.ts b/bundle/src/components/ripple/mdc.ripple.directive.spec.ts
new file mode 100644
index 0000000..d810879
--- /dev/null
+++ b/bundle/src/components/ripple/mdc.ripple.directive.spec.ts
@@ -0,0 +1,207 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Component } from '@angular/core';
+import { MDCRippleFoundation, MDCRippleAdapter } from '@material/ripple';
+import { MdcRippleDirective } from './mdc.ripple.directive';
+import { testStyle, hasRipple } from '../../testutils/page.test';
+import { spyOnAll } from '../../testutils/util';
+
+describe('MdcRippleDirective', () => {
+ it('should attach the ripple effect', fakeAsync(() => {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [MdcRippleDirective, SimpleTestComponent]
+ }).createComponent(SimpleTestComponent);
+ fixture.detectChanges();
+ const div = fixture.nativeElement.querySelector('div');
+ expect(hasRipple(div)).toBe(true, 'mdcRipple should attach the mdc-ripple-upgraded class');
+ }));
+
+ it('on/off can be set programmatically', fakeAsync(() => {
+ const { fixture } = setup();
+ testStyle(fixture, 'ripple', 'mdcRipple', 'mdc-ripple-upgraded', MdcRippleDirective, TestComponent, () => {tick(20); });
+ }));
+
+ it('can be disabled programmatically', fakeAsync(() => {
+ const { fixture, button, testComponent, ripple } = setupEnabled();
+ expect(ripple['isRippleSurfaceDisabled']()).toBe(false);
+ button.disabled = true;
+ expect(ripple['isRippleSurfaceDisabled']()).toBe(true);
+ testComponent.disabled = false;
+ fixture.detectChanges();
+ expect(ripple['isRippleSurfaceDisabled']()).toBe(false);
+ button.disabled = false;
+ testComponent.disabled = true;
+ fixture.detectChanges();
+ expect(ripple['isRippleSurfaceDisabled']()).toBe(true);
+ testComponent.disabled = null;
+ fixture.detectChanges();
+ expect(ripple['isRippleSurfaceDisabled']()).toBe(false);
+ }));
+
+ it('can be made (un)bounded programmatically', fakeAsync(() => {
+ const { fixture } = setupEnabled();
+ testStyle(fixture, 'unbounded', 'unbounded', 'mdc-ripple-upgraded--unbounded', MdcRippleDirective, TestComponent, () => {tick(20); });
+ }));
+
+ it('dimension can be changed', fakeAsync(() => {
+ const { fixture, button, testComponent, ripple } = setupEnabled();
+
+ testComponent.dimension = "150";
+ fixture.detectChanges();
+ expect(ripple.dimension).toBe(150);
+ const {left, top} = button.getBoundingClientRect();
+ expect(ripple['computeRippleBoundingRect']()).toEqual({
+ left,
+ top,
+ width: 150,
+ height: 150,
+ right: left + 150,
+ bottom: top + 150
+ });
+ }));
+
+ it('dimension can be changed', fakeAsync(() => {
+ const { fixture, button, testComponent, ripple } = setupEnabled();
+
+ testComponent.dimension = "150";
+ fixture.detectChanges();
+ expect(ripple.dimension).toBe(150);
+ const {left, top} = button.getBoundingClientRect();
+ expect(ripple['computeRippleBoundingRect']()).toEqual({
+ left,
+ top,
+ width: 150,
+ height: 150,
+ right: left + 150,
+ bottom: top + 150
+ });
+ }));
+
+ it('surface can be anabled, disabled, or set to primary or accent', fakeAsync(() => {
+ const { fixture, button, testComponent, ripple } = setupEnabled();
+
+ expect(button.classList).toContain('mdc-ripple-surface');
+ expect(button.classList).not.toContain('mdc-ripple-surface--primary');
+ expect(button.classList).not.toContain('mdc-ripple-surface--accent');
+
+ testComponent.surface = 'primary';
+ fixture.detectChanges();
+ expect(button.classList).toContain('mdc-ripple-surface');
+ expect(button.classList).toContain('mdc-ripple-surface--primary');
+ expect(button.classList).not.toContain('mdc-ripple-surface--accent');
+
+ testComponent.surface = false;
+ fixture.detectChanges();
+ expect(button.classList).not.toContain('mdc-ripple-surface');
+ expect(button.classList).not.toContain('mdc-ripple-surface--primary');
+ expect(button.classList).not.toContain('mdc-ripple-surface--accent');
+ }));
+
+ it('focus and blur events are passed to foundation', fakeAsync(() => {
+ const { button, foundation } = setupEnabled();
+ expect(foundation.handleBlur).not.toHaveBeenCalled();
+ expect(foundation.handleFocus).not.toHaveBeenCalled();
+ button.dispatchEvent(new Event('focus'));
+ tick();
+ expect(foundation.handleBlur).not.toHaveBeenCalled();
+ expect(foundation.handleFocus).toHaveBeenCalled();
+ button.dispatchEvent(new Event('blur'));
+ tick();
+ expect(foundation.handleBlur).toHaveBeenCalled();
+ expect(foundation.handleFocus).toHaveBeenCalled();
+ }));
+
+ it('can be activated', fakeAsync(() => {
+ const { button, adapter } = setupEnabled();
+ (adapter.computeBoundingRect).calls.reset();
+ (adapter.addClass).calls.reset();
+ (adapter.removeClass).calls.reset();
+ button.dispatchEvent(new Event('mousedown'));
+ tick(5);
+ expect(adapter.computeBoundingRect).toHaveBeenCalled();
+ expect(adapter.addClass).toHaveBeenCalledWith('mdc-ripple-upgraded--foreground-activation');
+ expect(adapter.removeClass).toHaveBeenCalledWith('mdc-ripple-upgraded--foreground-activation');
+ tick(300); // wait for all animation frames / queued timers
+ }));
+
+ it('can be programmatically activated/deactivated', fakeAsync(() => {
+ const { ripple, foundation, adapter } = setupEnabled();
+ (adapter.computeBoundingRect).calls.reset();
+ (adapter.addClass).calls.reset();
+ (adapter.removeClass).calls.reset();
+ ripple.activateRipple();
+ tick(5);
+ expect(foundation.activate).toHaveBeenCalled();
+ expect(adapter.computeBoundingRect).toHaveBeenCalled();
+ expect(adapter.addClass).toHaveBeenCalledWith('mdc-ripple-upgraded--foreground-activation');
+ expect(adapter.removeClass).toHaveBeenCalledWith('mdc-ripple-upgraded--foreground-activation');
+ (adapter.addClass).calls.reset();
+ (adapter.removeClass).calls.reset();
+ ripple.deactivateRipple();
+ expect(foundation.deactivate).toHaveBeenCalled();
+ tick(400); // wait for all animation frames / queued timers
+ }));
+
+ it('initRipple must not be called when already initialized', fakeAsync(() => {
+ const { ripple } = setupEnabled();
+ expect(ripple['isRippleInitialized']()).toBeTrue();
+ expect(() => {
+ ripple['initRipple']();
+ }).toThrowError('initRipple() is called multiple times');
+ }));
+
+ it('isRippleSurfaceActive default implementation', fakeAsync(() => {
+ const { button, ripple } = setupEnabled();
+ expect(ripple['isRippleSurfaceActive']()).toBeFalse();
+ button.matches = (selector) => selector === ':active';
+ expect(ripple['isRippleSurfaceActive']()).toBeTrue();
+ }));
+
+ @Component({
+ template: `
+ ripple
+ `
+ })
+ class SimpleTestComponent {
+ }
+
+ @Component({
+ template: `
+ ripple
+ `
+ })
+ class TestComponent {
+ ripple: any = null;
+ disabled: any = null;
+ unbounded: any = null;
+ dimension: any = null;
+ surface: any = true;
+ }
+
+ function setup() {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [MdcRippleDirective, TestComponent]
+ }).createComponent(TestComponent);
+ fixture.detectChanges();
+ return { fixture };
+ }
+
+ function setupEnabled() {
+ const { fixture } = setup();
+ const button = fixture.nativeElement.querySelector('button');
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const ripple = fixture.debugElement.query(By.directive(MdcRippleDirective)).injector.get(MdcRippleDirective);
+ // spy-on adapter, before it's used to initialize the foundation (otherwise spies will never be called):
+ let adapter: MDCRippleAdapter = ripple['mdcRippleAdapter'];
+ spyOnAll(adapter);
+ // enable the ripple:
+ testComponent.ripple = true;
+ fixture.detectChanges();
+ tick(20);
+ // attach some spies:
+ let foundation: MDCRippleFoundation = ripple['_rippleFoundation'];
+ spyOnAll(foundation);
+
+ return { fixture, button, testComponent, ripple, foundation, adapter };
+ }
+});
diff --git a/bundle/src/components/ripple/mdc.ripple.directive.ts b/bundle/src/components/ripple/mdc.ripple.directive.ts
index 11d70e4..4a3de33 100644
--- a/bundle/src/components/ripple/mdc.ripple.directive.ts
+++ b/bundle/src/components/ripple/mdc.ripple.directive.ts
@@ -1,7 +1,5 @@
-import { AfterContentInit, Directive, ElementRef, HostBinding,
- Input, OnDestroy, Renderer2 } from '@angular/core';
-import { MDCRipple } from '@material/ripple';
-import { MDCRippleFoundation } from '@material/ripple';
+import { AfterContentInit, Directive, ElementRef, HostBinding, Input, OnDestroy, Renderer2, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
import { asBoolean, asBooleanOrNull } from '../../utils/value.utils';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
@@ -19,13 +17,12 @@ import { MdcEventRegistry } from '../../utils/mdc.event.registry';
export class MdcRippleDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
private _initialized = false;
private _on = false;
- private _disabled: boolean = null;
- private _unbounded = false;
- private _surface = false;
- private _dim = null;
+ private _disabled: boolean | null = null;
+ private _surface: boolean | 'primary' | 'accent' = false;
+ private _dim: number | null = null;
- constructor(private elm: ElementRef, private renderer: Renderer2, private registry: MdcEventRegistry) {
- super(elm, renderer, registry);
+ constructor(public _elm: ElementRef, renderer: Renderer2, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_elm, renderer, registry, doc as Document);
}
ngAfterContentInit() {
@@ -38,28 +35,23 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
this.destroyRipple();
}
- /** @docs-private */
- protected isRippleUnbounded() {
- return this._unbounded;
- }
-
- /** @docs-private */
+ /** @internal */
protected isRippleSurfaceDisabled() {
return this._disabled == null ? super.isRippleSurfaceDisabled() : this._disabled;
}
- /** @docs-private */
+ /** @internal */
protected computeRippleBoundingRect() {
if (this._dim == null)
return super.computeRippleBoundingRect();
- const {left, top} = this.elm.nativeElement.getBoundingClientRect();
+ const {left, top} = this._elm.nativeElement.getBoundingClientRect();
return {
left,
top,
width: this._dim,
height: this._dim,
right: left + this._dim,
- bottom: left + this._dim,
+ bottom: top + this._dim,
};
}
@@ -67,11 +59,11 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
* Set this input to false to remove the ripple effect from the surface.
*/
@Input() get mdcRipple() {
- return !this._on;
+ return this._on;
}
- set mdcRipple(value: any) {
- let newValue = asBoolean(value);
+ set mdcRipple(value: boolean) {
+ const newValue = asBoolean(value);
if (newValue !== this._on) {
this._on = newValue;
if (this._initialized) {
@@ -83,25 +75,26 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
}
}
+ static ngAcceptInputType_mdcRipple: boolean | '';
+
/**
* When this input has a value other than false, the ripple is unbounded.
* Surfaces for bounded ripples should have overflow
set to hidden,
* while surfaces for unbounded ripples should have it set to visible
.
*/
@Input() get unbounded() {
- return this._unbounded;
+ return this.isRippleUnbounded();
}
- set unbounded(value: any) {
- let newValue = asBoolean(value);
- if (newValue !== this._unbounded) {
- this._unbounded = newValue;
- this.reInit();
- }
+ set unbounded(value: boolean) {
+ this.setRippleUnbounded(asBoolean(value));
}
+ static ngAcceptInputType_unbounded: boolean | '';
+
+ /** @internal */
@HostBinding('attr.data-mdc-ripple-is-unbounded') get _attrUnbounded() {
- return this._unbounded ? "" : null;
+ return this.unbounded ? "" : null;
}
/**
@@ -113,11 +106,13 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
return this._dim;
}
- set dimension(value: string | number) {
+ set dimension(value: number | null) {
this._dim = value == null ? null : +value;
this.layout();
}
+ static ngAcceptInputType_dimension: string | number | null;
+
/**
* This input can be used to programmatically enable/disable the ripple.
* When true, the ripple effect will be disabled, when false the ripple
@@ -129,10 +124,12 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
return this._disabled;
}
- set disabled(value: any) {
+ set disabled(value: boolean | null) {
this._disabled = asBooleanOrNull(value);
}
+ static ngAcceptInputType_boolean: boolean | null | '';
+
/**
* When this input has a value other than false, the ripple element will get the
* "mdc-ripple-surface" class. That class has styling for bounded and unbounded
@@ -140,25 +137,28 @@ export class MdcRippleDirective extends AbstractMdcRipple implements AfterConten
* you have to supply your own ripple styles, using the provided
* Sass Mixins .
+ *
+ * To apply a standard surface ripple, set the value to `true`, `"primary"`, or `"accent"`.
+ * The values primary and accent set the ripple color to the theme primary or secondary color.
*/
@Input() @HostBinding('class.mdc-ripple-surface') get surface() {
- return this._surface;
+ return !!this._surface;
}
- set surface(value: any) {
- this._surface = asBoolean(value);
+ set surface(value: boolean | 'primary' | 'accent') {
+ if (value === 'primary' || value === 'accent')
+ this._surface = value;
+ else
+ this._surface = asBoolean(value);
}
- private reInit() {
- if (this._initialized && this.isRippleInitialized()) {
- this.destroyRipple();
- this.initRipple();
- }
+ static ngAcceptInputType_surface: boolean | 'primary' | 'accent' | '';
+
+ @HostBinding('class.mdc-ripple-surface--primary') get _surfacePrimary() {
+ return this._surface === 'primary';
}
- private layout() {
- if (this._initialized && this.isRippleInitialized()) {
- this._rippleFoundation.layout();
- }
+ @HostBinding('class.mdc-ripple-surface--accent') get _surfaceAccent() {
+ return this._surface === 'accent';
}
}
diff --git a/bundle/src/components/select/mdc.select.adapter.ts b/bundle/src/components/select/mdc.select.adapter.ts
deleted file mode 100644
index 7e68a11..0000000
--- a/bundle/src/components/select/mdc.select.adapter.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/** @docs-private */
-export interface MdcSelectAdapter {
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- floatLabel: (value: boolean) => void,
- activateBottomLine: () => void,
- deactivateBottomLine: () => void,
- registerInteractionHandler: (type: string, handler: EventListener) => void,
- deregisterInteractionHandler: (type: string, handler: EventListener) => void,
- getSelectedIndex: () => number,
- setSelectedIndex: (index: number) => void,
- setDisabled: (disabled: boolean) => void,
- getValue: () => string,
- setValue: (value: string) => void,
- isRtl: () => boolean,
- hasLabel: () => boolean,
- getLabelWidth: () => number,
- hasOutline: () => {},
- notchOutline: (labelWidth: number, isRtl: boolean) => void,
- closeOutline: () => void
-}
diff --git a/bundle/src/components/select/mdc.select.directive.spec.ts b/bundle/src/components/select/mdc.select.directive.spec.ts
new file mode 100644
index 0000000..86f0ce5
--- /dev/null
+++ b/bundle/src/components/select/mdc.select.directive.spec.ts
@@ -0,0 +1,394 @@
+import { TestBed, fakeAsync, ComponentFixture, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { MdcFloatingLabelDirective } from '../floating-label/mdc.floating-label.directive';
+import { SELECT_DIRECTIVES, MdcSelectDirective } from './mdc.select.directive';
+import { MENU_DIRECTIVES } from '../menu/mdc.menu.directive';
+import { LIST_DIRECTIVES } from '../list/mdc.list.directive';
+import { MENU_SURFACE_DIRECTIVES } from '../menu-surface/mdc.menu-surface.directive';
+import { NOTCHED_OUTLINE_DIRECTIVES } from '../notched-outline/mdc.notched-outline.directive';
+import { hasRipple, simulateKey } from '../../testutils/page.test';
+import { By } from '@angular/platform-browser';
+import { FormsModule } from '@angular/forms';
+
+// TODO: test disabled options
+// TODO: test structure when surface open
+// TODO: test structure when required
+// TODO: test mdcSelect with FormsModule
+
+describe('MdcSelectDirective', () => {
+ it('filled: test DOM, aria properties, and ripple', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestStaticComponent);
+ const root = fixture.nativeElement.querySelector('.mdc-select');
+ const { anchor } = validateDom(root);
+ expect(hasRipple(anchor)).toBe(true, 'the ripple element should be attached to the anchor');
+
+ testComponent.disabled = true;
+ fixture.detectChanges(); tick(5);
+ validateDom(root, {disabled: true});
+
+ testComponent.labeled = false; testComponent.disabled = false;
+ fixture.detectChanges(); tick(5);
+ validateDom(root, {labeled: false});
+ }));
+
+ it('outlined: test DOM, aria properties, and ripple', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestStaticOutlinedComponent);
+ const root = fixture.nativeElement.querySelector('.mdc-select');
+ const { anchor } = validateDom(root, {outlined: true});
+ expect(hasRipple(anchor)).toBe(false, 'no ripple allowed for outlined variant');
+
+ testComponent.disabled = true;
+ fixture.detectChanges(); tick(5);
+ validateDom(root, {outlined: true, disabled: true});
+
+ testComponent.labeled = false; testComponent.disabled = false;
+ fixture.detectChanges(); tick(5);
+ validateDom(root, {outlined: true, labeled: false});
+ }));
+
+ it('filled: floating label must float when input has focus', fakeAsync(() => {
+ const { fixture } = setup(TestStaticComponent);
+ validateFloatOnFocus(fixture);
+ }));
+
+ it('outlined: floating label must float when input has focus', fakeAsync(() => {
+ const { fixture } = setup(TestStaticOutlinedComponent);
+ validateFloatOnFocus(fixture);
+ }));
+
+ it('value can be changed programmatically', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestStaticComponent);
+
+ expect(testComponent.value).toBe(null);
+ setAndCheck(fixture, 'vegetables', TestStaticComponent);
+ setAndCheck(fixture, '', TestStaticComponent);
+ setAndCheck(fixture, 'fruit', TestStaticComponent);
+ setAndCheck(fixture, null, TestStaticComponent);
+ setAndCheck(fixture, 'invalid', TestStaticComponent);
+ }));
+
+ it('value can be changed by user', fakeAsync(() => {
+ const { fixture, testComponent } = setup();
+
+ expect(testComponent.value).toBe(null);
+ selectAndCheck(fixture, 0, 2, 'vegetables');
+ selectAndCheck(fixture, 2, 3, 'fruit');
+ selectAndCheck(fixture, 3, 0, '');
+ }));
+
+ it('label remains floating when switching between outlined and without outline', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestSwitchOutlinedComponent);
+ const root = fixture.nativeElement.querySelector('.mdc-select');
+ checkFloating(fixture, false);
+ setAndCheck(fixture, 'vegetables', TestSwitchOutlinedComponent);
+ checkFloating(fixture, true);
+ validateDom(root, {outlined: true, selected: 2});
+ testComponent.outlined = false;
+ fixture.detectChanges(); tick(20);
+ validateDom(root, {outlined: false, selected: 2});
+ checkFloating(fixture, true);
+ testComponent.outlined = true;
+ fixture.detectChanges(); tick(20);
+ validateDom(root, {outlined: true, selected: 2});
+ checkFloating(fixture, true);
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any, type = TestComponent) {
+ const testComponent = fixture.debugElement.injector.get(type);
+ const mdcSelect = fixture.debugElement.query(By.directive(MdcSelectDirective))?.injector.get(MdcSelectDirective);
+ testComponent.value = value;
+ fixture.detectChanges(); flush(); fixture.detectChanges(); flush(); fixture.detectChanges(); flush();
+ if (value === 'invalid')
+ value = '';
+ expect(mdcSelect.value).toBe(value || '');
+ expect(testComponent.value).toBe(value || '');
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ function selectAndCheck(fixture: ComponentFixture, focusIndex: number, selectIndex: number, value: string, type: Type = TestComponent) {
+ const testComponent = fixture.debugElement.injector.get(type);
+ const text = fixture.nativeElement.querySelector('.mdc-select__selected-text');
+ const items = [...fixture.nativeElement.querySelectorAll('.mdc-list-item')];
+ const mdcSelect = fixture.debugElement.query(By.directive(MdcSelectDirective))?.injector.get(MdcSelectDirective);
+ text.dispatchEvent(new Event('focus'));
+ simulateKey(text, 'ArrowDown');
+ animationCycle(fixture);
+ expect(document.activeElement).toBe(items[focusIndex]);
+ let selected = focusIndex;
+ while (selected < selectIndex) {
+ simulateKey(items[selected], 'ArrowDown');
+ fixture.detectChanges(); flush();
+ expect(document.activeElement).toBe(items[++selected]);
+ }
+ while (selected > selectIndex) {
+ simulateKey(items[selected], 'ArrowUp');
+ fixture.detectChanges(); flush();
+ expect(document.activeElement).toBe(items[--selected]);
+ }
+ simulateKey(items[selected], 'Enter');
+ animationCycle(fixture);
+
+ expect(mdcSelect.value).toBe(value);
+ expect(testComponent.value).toBe(value);
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ @Component({
+ template: `
+
+
+
{{value}}
+
Pick a Food Group
+
+
+
+
+ Bread, Cereal, Rice, and Pasta
+ Vegetables
+ Fruit
+
+
+
+ selected: {{value}}
+ `
+ })
+ class TestComponent {
+ value: any = null;
+ }
+
+ @Component({
+ template: `
+
+
+
{{value}}
+
Pick a Food Group
+
+
+
+
+ Bread, Cereal, Rice, and Pasta
+ Vegetables
+ Fruit
+
+
+
+ selected: {{value}}
+ `
+ })
+ class TestStaticComponent {
+ value: any = null;
+ labeled = true;
+ disabled = false;
+ }
+
+ @Component({
+ template: `
+
+
+
{{value}}
+
+
+ Floating Label
+
+
+
+
+
+
+ Bread, Cereal, Rice, and Pasta
+ Vegetables
+ Fruit
+
+
+
+ selected: {{value}}
+ `
+ })
+ class TestStaticOutlinedComponent {
+ value: any = null;
+ labeled = true;
+ disabled = false;
+ }
+
+ @Component({
+ template: `
+
+
+
{{value}}
+
+
+ Pick a Food Group
+
+
+
+ Pick a Food Group
+
+
+
+
+
+ Bread, Cereal, Rice, and Pasta
+ Vegetables
+ Fruit
+
+
+
+ selected: {{value}}
+ `
+ })
+ class TestSwitchOutlinedComponent {
+ value: any = null;
+ outlined = true;
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [
+ ...SELECT_DIRECTIVES,
+ ...MENU_DIRECTIVES,
+ ...MENU_SURFACE_DIRECTIVES,
+ ...LIST_DIRECTIVES,
+ MdcFloatingLabelDirective,
+ ...NOTCHED_OUTLINE_DIRECTIVES,
+ compType]
+ }).createComponent(compType);
+ fixture.detectChanges(); flush();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const select = fixture.nativeElement.querySelector('.mdc-select');
+ return { fixture, testComponent, select };
+ }
+});
+
+function validateDom(select, options: Partial<{
+ outlined: boolean,
+ expanded: boolean,
+ disabled: boolean,
+ required: boolean,
+ labeled: boolean,
+ selected: number,
+ values: boolean
+ }> = {}) {
+ options = {...{
+ outlined: false,
+ expanded: false,
+ disabled: false,
+ required: false,
+ labeled: true,
+ selected: -1,
+ values: true
+ }, ...options};
+
+ expect(select.classList).toContain('mdc-select');
+ if (!options.labeled)
+ expect(select.classList).toContain('mdc-select--no-label');
+ else
+ expect(select.classList).not.toContain('mdc-select--no-label');
+ if (options.disabled)
+ expect(select.classList).toContain('mdc-select--disabled');
+ else
+ expect(select.classList).not.toContain('mdc-select--disabled');
+ if (options.required)
+ expect(select.classList).toContain('mdc-select--required');
+ else
+ expect(select.classList).not.toContain('mdc-select--required');
+ expect(select.children.length).toBe(2);
+
+ const anchor = select.children[0];
+ if (options.outlined)
+ expect(anchor.children.length).toBe(3);
+ else
+ expect(anchor.children.length).toBe(options.labeled ? 4 : 3);
+ const dropDownIcon = anchor.children[0];
+ const selectedText = anchor.children[1];
+ expect(selectedText.id).toMatch(/mdc-u-id-.*/);
+ const floatingLabel = anchor.querySelector('.mdc-floating-label');
+ expect(!!floatingLabel).toBe(options.labeled);
+ if (floatingLabel) {
+ expect(floatingLabel.id).toMatch(/mdc-u-id-.*/);
+ expect(floatingLabel.classList).toContain('mdc-floating-label');
+ }
+ if (options.outlined) {
+ const notchedOutline = anchor.children[2];
+ expect(notchedOutline.classList).toContain('mdc-notched-outline');
+ expect(notchedOutline.children.length).toBe(options.labeled ? 3 : 2);
+ expect(notchedOutline.children[0].classList).toContain('mdc-notched-outline__leading');
+ expect(notchedOutline.children[notchedOutline.children.length - 1].classList).toContain('mdc-notched-outline__trailing');
+ if (floatingLabel) {
+ expect(notchedOutline.children[1].classList).toContain('mdc-notched-outline__notch');
+ const notch = notchedOutline.children[1];
+ expect(notch.children.length).toBe(1);
+ expect(notch.children[0]).toBe(floatingLabel);
+ }
+ } else {
+ const lineRipple = anchor.children[anchor.children.length - 1];
+ expect(lineRipple.classList).toContain('mdc-line-ripple');
+ if (floatingLabel)
+ expect(anchor.children[2]).toBe(floatingLabel);
+ }
+ expect(dropDownIcon.classList).toContain('mdc-select__dropdown-icon');
+ expect(selectedText.classList).toContain('mdc-select__selected-text');
+ expect(selectedText.getAttribute('tabindex')).toBe(options.disabled ? '-1': '0');
+ expect(selectedText.getAttribute('aria-disabled')).toBe(`${options.disabled}`);
+ expect(selectedText.getAttribute('aria-required')).toBe(`${options.required}`);
+ expect(selectedText.getAttribute('role')).toBe('button');
+ expect(selectedText.getAttribute('aria-haspopup')).toBe('listbox');
+ expect(selectedText.getAttribute('aria-labelledBy')).toBe(`${floatingLabel ? floatingLabel.id + ' ' : ''}${selectedText.id}`);
+ expect(selectedText.getAttribute('aria-expanded')).toBe(options.expanded ? 'true' : 'false');
+
+ expect(anchor.classList).toContain('mdc-select__anchor');
+
+ const menu = select.children[1];
+ expect(menu.classList).toContain('mdc-select__menu');
+ expect(menu.classList).toContain('mdc-menu');
+ expect(menu.classList).toContain('mdc-menu-surface');
+ expect(menu.children.length).toBe(1);
+
+ const list = menu.children[0];
+ expect(list.classList).toContain('mdc-list');
+ expect(list.getAttribute('role')).toBe('listbox');
+ expect(list.getAttribute('aria-labelledBy')).toBe(floatingLabel ? floatingLabel.id : null);
+ expect(list.getAttribute('tabindex')).toBeNull();
+ const items = [...list.querySelectorAll('li')];
+ let index = 0;
+ items.forEach(item => {
+ expect(item.classList).toContain('mdc-list-item');
+ expect(item.getAttribute('role')).toBe('option');
+ expect(item.getAttribute('tabindex')).toMatch(/0|-1/);
+ const selected = options.selected === index
+ expect(item.getAttribute('aria-selected')).toBe(selected ? 'true' : 'false');
+ if (selected)
+ expect(item.classList).toContain('mdc-list-item--selected');
+ else
+ expect(item.classList).not.toContain('mdc-list-item--selected');
+ expect(item.hasAttribute('value')).toBe(options.values);
+ ++index;
+ });
+ return { anchor, menu, list, items };
+}
+
+function validateFloatOnFocus(fixture) {
+ const floatingLabelElm = fixture.nativeElement.querySelector('.mdc-floating-label');
+ const text = fixture.nativeElement.querySelector('.mdc-select__selected-text');
+ expect(floatingLabelElm.classList).not.toContain('mdc-floating-label--float-above');
+ text.dispatchEvent(new Event('focus')); tick();
+ expect(floatingLabelElm.classList).toContain('mdc-floating-label--float-above');
+ text.dispatchEvent(new Event('blur')); tick();
+ expect(floatingLabelElm.classList).not.toContain('mdc-floating-label--float-above');
+}
+
+function checkFloating(fixture: ComponentFixture, expected: boolean) {
+ // when not empty, the label must be floating:
+ const floatingLabelElm = fixture.nativeElement.querySelector('.mdc-floating-label');
+ if (floatingLabelElm) {
+ if (expected)
+ expect(floatingLabelElm.classList).toContain('mdc-floating-label--float-above');
+ else
+ expect(floatingLabelElm.classList).not.toContain('mdc-floating-label--float-above');
+ }
+}
+
+function animationCycle(fixture) {
+ fixture.detectChanges(); tick(300); flush();
+}
diff --git a/bundle/src/components/select/mdc.select.directive.ts b/bundle/src/components/select/mdc.select.directive.ts
index 81768a8..18289f2 100644
--- a/bundle/src/components/select/mdc.select.directive.ts
+++ b/bundle/src/components/select/mdc.select.directive.ts
@@ -1,284 +1,646 @@
-import { AfterContentInit, ContentChild, Directive, ElementRef, forwardRef, HostBinding,
- Input, OnDestroy, OnInit, Optional, Renderer2, Self } from '@angular/core';
-import { NgControl } from '@angular/forms';
-import { MDCSelectFoundation } from '@material/select';
-import { MDCFloatingLabelFoundation } from '@material/floating-label';
-import { MDCLineRippleFoundation } from '@material/line-ripple';
-import { MdcSelectAdapter } from './mdc.select.adapter';
+import { AfterContentInit, Directive, ElementRef, forwardRef, HostBinding,
+ Input, OnDestroy, OnInit, Renderer2, Self, ContentChildren, QueryList, Host, SkipSelf,
+ HostListener, Inject, Output, EventEmitter } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { MDCLineRippleFoundation, MDCLineRippleAdapter } from '@material/line-ripple';
+import { MDCSelectFoundation, MDCSelectAdapter, cssClasses, strings } from '@material/select';
+import { Subject, merge } from 'rxjs';
+import { takeUntil, debounceTime } from 'rxjs/operators';
import { MdcFloatingLabelDirective } from '../floating-label/mdc.floating-label.directive';
-import { MdcLineRippleAdapter } from '../line-ripple/mdc.line-ripple.adapter';
-import { AbstractMdcInput } from '../abstract/abstract.mdc.input';
-import { asBoolean } from '../../utils/value.utils';
import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-import { NotchedOutlineSupport } from '../notched-outline/notched-outline.support';
import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+import { MdcNotchedOutlineDirective } from '../notched-outline/mdc.notched-outline.directive';
+import { MdcMenuDirective } from '../menu/mdc.menu.directive';
+import { MdcMenuSurfaceDirective } from '../menu-surface/mdc.menu-surface.directive';
+import { MdcListFunction, MdcListDirective } from '../list/mdc.list.directive';
+import { HasId } from '../abstract/mixin.mdc.hasid';
+import { applyMixins } from '../../utils/mixins';
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
+import { asBoolean } from '../../utils/value.utils';
-const CLASS_SELECT = 'mdc-select';
-const CLASS_SELECT_CONTROL = 'mdc-select__native-control';
-const CLASS_LINE_RIPPLE = 'mdc-line-ripple';
-
-let nextId = 1;
+@Directive()
+class MdcSelectTextDirectiveBase {}
+interface MdcSelectTextDirectiveBase extends HasId {}
+applyMixins(MdcSelectTextDirectiveBase, [HasId]);
/**
- * Directive for the select control of an mdcSelect
directive.
- * Should be used as the first child element of the mdcSelect
.
+ * Directive for showing the text of the currently selected `mdcSelect` item. It is the responsibility
+ * of the host component to set the actual text (see examples). This makes the `mdcSelect` more flexible,
+ * so that e.g. the text of options can also contain markup to style parts of it differently.
+ * When no value is selected, the embedded text must be empty.
+ *
+ * # Accessibility
+ * * When no `id` is assigned, the component will generate a unique `id`, so that the `mdcSelectAnchor`
+ * and `mdcList` for this select can be labelled (with `aria-labelledBy`) appropriately.
+ * * The element will be made focusable and tabbable (with `tabindex=0`), unless disabled.
+ * * The `aria-disabled` will get a value based on the `disabled` property of the `mdcSelect`.
+ * * The `aria-required` will get a value based on the `required` property of the `mdcSelect`.
+ * * The `role` attribute will be set to `button`.
+ * * The `aria-haspopup` attribute will be set to `listbox`.
+ * * The `aria-labelledBy` attribute will list the ids of the `mdcFloatinglabel` and the `mdcSelectText` self.
+ * * The `aria-expanded` attribute will reflect whether this element is focused (the menu-surface is open).
*/
@Directive({
- selector: 'select[mdcSelectControl]',
- providers: [{provide: AbstractMdcInput, useExisting: forwardRef(() => MdcSelectControlDirective) }]
+ selector: '[mdcSelectText]'
})
-export class MdcSelectControlDirective extends AbstractMdcInput implements OnInit, OnDestroy {
- @HostBinding('class.' + CLASS_SELECT_CONTROL) _cls = true;
- _onChange = (value) => {};
- private _id: string;
- private _disabled = false;
- private cachedId: string;
+export class MdcSelectTextDirective extends MdcSelectTextDirectiveBase implements OnInit {
+ /** @internal */
+ @HostBinding('class.mdc-select__selected-text') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') _role = 'button';
+ /** @internal */
+ @HostBinding('attr.aria-haspopup') _haspop = 'listbox';
+ /** @internal */
+ @HostBinding('attr.aria-labelledby') _labelledBy: string | null = null;
- constructor(public _elm: ElementRef, private renderer: Renderer2,
- public _registry: MdcEventRegistry, @Optional() @Self() public _cntr: NgControl) {
+ constructor(public _elm: ElementRef, @Host() @SkipSelf() @Inject(forwardRef(() => MdcSelectDirective)) private select: MdcSelectDirective) {
super();
}
ngOnInit() {
- // Force setter to be called in case id was not specified.
- this.id = this.id;
- // Listen to changes, so that the label style can be updated when a value is
- // set or cleared:
- if (this._cntr)
- this._cntr.valueChanges.subscribe(value => {
- this._onChange(this._elm.nativeElement.selectedIndex);
- });
+ this.initId();
}
- ngOnDestroy() {
+ /** @internal */
+ @HostListener('focus') _onFocus() {
+ this.select.foundation?.handleFocus();
}
- /**
- * Mirrors the id
attribute. If no id is assigned, this directive will
- * assign a unique id by itself. If an mdcFloatingLabel
for this select control
- * is available, the mdcFloatingLabel
will automatically set its for
- * attribute to this id
value.
- */
- @HostBinding()
- @Input() get id() {
- return this._id;
- }
-
- set id(value: string) {
- this._id = value || this._newId();
+ /** @internal */
+ @HostListener('blur') _onBlur() {
+ this.select.onBlur();
}
- /**
- * If set to a value other than false, the mdcSelectControl will be in disabled state.
- */
- @HostBinding()
- @Input() get disabled() {
- return this._cntr ? this._cntr.disabled : this._disabled;
+ /** @internal */
+ @HostListener('keydown', ['$event']) _onKeydown(event: KeyboardEvent) {
+ this.select.foundation?.handleKeydown(event);
}
- set disabled(value: any) {
- this._disabled = asBoolean(value);
+ /** @internal */
+ @HostListener('click', ['$event']) _onClick(event: MouseEvent | TouchEvent) {
+ this.select.foundation?.handleClick(this.getNormalizedXCoordinate(event));
}
- _newId(): string {
- this.cachedId = this.cachedId || `mdc-select-${nextId++}`;
- return this.cachedId;
+ private getNormalizedXCoordinate(event: MouseEvent | TouchEvent): number {
+ const targetClientRect = (event.target as Element).getBoundingClientRect();
+ const xCoordinate = !!((event as TouchEvent).touches) ? (event as TouchEvent).touches[0].clientX : (event as MouseEvent).clientX;
+ return xCoordinate - targetClientRect.left;
}
}
/**
- * Directive for a spec aligned material design 'Select Control'.
- * This directive should wrap an mdcSelectControl
, and an
- * mdcFloatingLabel
directive.
+ * The `mdcSelectAnchor` should be the first child of an `mdcSelect`. It contains the dropdown-icon,
+ * `mdcSelectText`, `mdcFloatingLabel`, ripples, and may contain an optional `mdcNotchedOutline`.
+ * See the examples for the required structure of these directives.
*/
@Directive({
- selector: '[mdcSelect]'
+ selector: '[mdcSelectAnchor]'
})
-export class MdcSelectDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
- @HostBinding('class.' + CLASS_SELECT) _cls = true;
- @ContentChild(MdcSelectControlDirective) _control: MdcSelectControlDirective;
- @ContentChild(MdcFloatingLabelDirective) _label: MdcFloatingLabelDirective;
- private _outlineSupport: NotchedOutlineSupport;
- private _initialized = false;
- private _box = false;
- private _outlined = false;
- private _bottomLineElm: HTMLElement = null;
- private _lineRippleAdapter: MdcLineRippleAdapter = {
- addClass: (className: string) => this._rndr.addClass(this._bottomLineElm, className),
- removeClass: (className: string) => this._rndr.removeClass(this._bottomLineElm, className),
- hasClass: (className) => this._bottomLineElm.classList.contains(className),
- setStyle: (name: string, value: string) => this._rndr.setStyle(this._bottomLineElm, name, value),
- registerEventHandler: (evtType: string, handler: EventListener) => this._registry.listenElm(this._rndr, evtType, handler, this._bottomLineElm),
- deregisterEventHandler: (evtType: string, handler: EventListener) => this._registry.unlisten(evtType, handler)
- };
- private _lineRippleFoundation: {
- init: Function,
- destroy: Function,
- activate: Function,
- deactivate: Function,
- setRippleCenter: (x: number) => void
- } = new MDCLineRippleFoundation(this._lineRippleAdapter);
- private adapter: MdcSelectAdapter = {
- addClass: (className: string) => this._rndr.addClass(this._elm.nativeElement, className),
- removeClass: (className: string) => this._rndr.removeClass(this._elm.nativeElement, className),
- floatLabel: (value: boolean) => {
- if (this._label) this._label._foundation.float(value);
- },
- activateBottomLine: () => {
- if (this._bottomLineElm) this._lineRippleFoundation.activate();
- },
- deactivateBottomLine: () => {
- if (this._bottomLineElm) this._lineRippleFoundation.deactivate();
- },
- registerInteractionHandler: (type, handler) => this._control._registry.listen(this._rndr, type, handler, this._control._elm),
- deregisterInteractionHandler: (type, handler) => this._control._registry.unlisten(type, handler),
- getSelectedIndex: () => this._control._elm.nativeElement.selectedIndex,
- setSelectedIndex: (index: number) => this._control._elm.nativeElement.selectedIndex = index,
- setDisabled: (disabled: boolean) => this._control._elm.nativeElement.disabled = disabled,
- getValue: () => this._control._elm.nativeElement.value,
- setValue: (value: string) => this._control._elm.nativeElement.value = value,
- isRtl: () => getComputedStyle(this._elm.nativeElement).getPropertyValue('direction') === 'rtl',
- hasLabel: () => !!this._label,
- getLabelWidth: () => this._label._foundation.getWidth(),
- hasOutline: () => this._outlined,
- notchOutline: (labelWidth: number, isRtl: boolean) => this._outlineSupport.foundation.notch(labelWidth, isRtl),
- closeOutline: () => this._outlineSupport.foundation.closeNotch()
- };
- private foundation: {
- init(),
- destroy(),
- setValue(value: string),
- setDisabled(disabled: boolean),
- setSelectedIndex(index: number),
- notchOutline(openNotch: boolean)
+export class MdcSelectAnchorDirective extends AbstractMdcRipple implements AfterContentInit, OnDestroy {
+ private onDestroy$: Subject = new Subject();
+ private onLabelsChange$: Subject = new Subject();
+ /** @internal */
+ @HostBinding('class.mdc-select__anchor') readonly _cls = true;
+ /** @internal */
+ @ContentChildren(MdcFloatingLabelDirective, {descendants: true}) _floatingLabels?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcNotchedOutlineDirective) _outlines?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcSelectTextDirective) _texts?: QueryList;
+ private _bottomLineElm: HTMLElement | null = null;
+ /** @internal */
+ bottomLineFoundation: MDCLineRippleFoundation | null = null;
+ private mdcLineRippleAdapter: MDCLineRippleAdapter = {
+ addClass: (className) => this.rndr.addClass(this._bottomLineElm, className),
+ removeClass: (className) => this.rndr.removeClass(this._bottomLineElm, className),
+ hasClass: (className) => this._bottomLineElm!.classList.contains(className),
+ setStyle: (name, value) => this.rndr.setStyle(this._bottomLineElm, name, value),
+ registerEventHandler: (evtType, handler) => this._registry.listenElm(this.rndr, evtType, handler, this._bottomLineElm!),
+ deregisterEventHandler: (evtType, handler) => this._registry.unlisten(evtType, handler)
};
-
- constructor(private _elm: ElementRef, private _rndr: Renderer2, _registry: MdcEventRegistry) {
- super(_elm, _rndr, _registry);
- this._outlineSupport = new NotchedOutlineSupport(_elm, _rndr);
+
+ constructor(public _elm: ElementRef, private rndr: Renderer2, registry: MdcEventRegistry,
+ @Host() @SkipSelf() @Inject(forwardRef(() => MdcSelectDirective)) private select: MdcSelectDirective,
+ @Inject(DOCUMENT) doc: any) {
+ super(_elm, rndr, registry, doc as Document);
}
ngAfterContentInit() {
- if (!this._control || !this._label)
- throw new Error('mdcSelect requires an embedded mdcSelectControl and mdcFloatingLabel');
- if (!this._label._initialized)
- throw new Error('mdcFloatingLabel not properly initialized');
- this._initialized = true;
+ merge(
+ this._floatingLabels!.changes,
+ this._outlines!.changes
+ ).pipe(
+ takeUntil(this.onDestroy$),
+ debounceTime(1)
+ ).subscribe(() => {
+ this.reconstructComponent();
+ });
+ merge(this._floatingLabels!.changes, this._texts!.changes).pipe(takeUntil(this.onDestroy$)).subscribe(() => {
+ this.onLabelsChange$.next();
+ this._label?.idChange().pipe(takeUntil(this.onLabelsChange$)).subscribe(() => {
+ this.computeLabelledBy();
+ });
+ this._text?.idChange().pipe(takeUntil(this.onLabelsChange$)).subscribe(() => {
+ this.computeLabelledBy();
+ });
+ this.computeLabelledBy();
+ });
+ this.addIcon();
this.initComponent();
-
- if (this._control)
- this._control._onChange = (value) => this.foundation.setSelectedIndex(value);
+ this.computeLabelledBy();
}
-
+
ngOnDestroy() {
+ this.onLabelsChange$.next(); this.onLabelsChange$.complete();
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.destroyRipple();
this.destroyLineRipple();
- this.foundation.destroy();
- this._control._onChange = (value) => {};
}
private initComponent() {
- this.initLineRipple();
- this.initBox();
- this.initOutline();
- this.foundation = new MDCSelectFoundation(this.adapter);
- this.foundation.init();
+ if (!this._outline) {
+ this.initLineRipple();
+ this.initRipple();
+ }
}
private destroyComponent() {
+ this.destroyRipple();
this.destroyLineRipple();
- this.destroyBox();
- this.destroyOutline();
- this.foundation.destroy();
}
private reconstructComponent() {
- if (this._initialized) {
- this.destroyComponent();
- this.initComponent();
- this.recomputeOutline();
- }
+ this.destroyComponent();
+ this.initComponent();
+ }
+
+ private addIcon() {
+ const icon = this.rndr.createElement('i');
+ this.rndr.addClass(icon, 'mdc-select__dropdown-icon');
+ if (this._elm.nativeElement.children.length > 0)
+ this.rndr.insertBefore(this._elm.nativeElement, icon, this._elm.nativeElement.children.item(0));
+ else
+ this.rndr.appendChild(this._elm.nativeElement, icon);
}
private initLineRipple() {
- if (!this._outlined) {
- this._bottomLineElm = this._rndr.createElement('div');
- this._rndr.addClass(this._bottomLineElm, CLASS_LINE_RIPPLE);
- this._rndr.appendChild(this._elm.nativeElement, this._bottomLineElm);
- this._lineRippleFoundation.init();
+ if (!this._bottomLineElm) {
+ this._bottomLineElm = this.rndr.createElement('div');
+ this.rndr.addClass(this._bottomLineElm, 'mdc-line-ripple');
+ this.rndr.appendChild(this._elm.nativeElement, this._bottomLineElm);
+ this.bottomLineFoundation = new MDCLineRippleFoundation(this.mdcLineRippleAdapter);
+ this.bottomLineFoundation.init();
}
}
private destroyLineRipple() {
if (this._bottomLineElm) {
- this._lineRippleFoundation.destroy();
- this._rndr.removeChild(this._elm.nativeElement, this._bottomLineElm);
+ this.bottomLineFoundation!.destroy();
+ this.bottomLineFoundation = null;
+ this.rndr.removeChild(this._elm.nativeElement, this._bottomLineElm);
this._bottomLineElm = null;
}
}
- private initBox() {
- if (this._box)
- this.initRipple();
+ private computeLabelledBy() {
+ let ids = [];
+ const labelId = this._label?.id;
+ if (labelId)
+ ids.push(labelId)
+ const textId = this._text?.id;
+ if (textId)
+ ids.push(textId);
+ if (this._text)
+ this._text._labelledBy = ids.join(' ');
+ this.select.setListLabelledBy(labelId || null); // the list should only use the id of the label
}
- private destroyBox() {
- this.destroyRipple();
+ /** @internal */
+ get _outline() {
+ return this._outlines?.first;
}
- private initOutline() {
- if (this._outlined)
- this._outlineSupport.init();
+ /** @internal */
+ get _label() {
+ return this._floatingLabels?.first;
}
- private destroyOutline() {
- this._outlineSupport.destroy();
+ /** @internal */
+ get _text() {
+ return this._texts?.first;
}
+}
- private recomputeOutline() {
- if (this._outlined) {
- // the outline may not be valid after re-initialisation, recompute outline when all
- // style/structural changes have been employed:
- let inputValue = this._control._elm.nativeElement.value;
- let shouldFloat = inputValue != null && inputValue.length > 0;
- setTimeout(() => {this.foundation.notchOutline(shouldFloat); }, 0);
- }
- }
+/**
+ * Directive for the options list of an `mdcSelect`. This directive should be the second (last) child
+ * of an `mdcSelect`, and should wrap an `mdcList` with all selection options.
+ * See the examples for the required structure of these directives.
+ *
+ * An `mdcSelectMenu` element will also match with the selector of the menu surface directive, documented
+ * here: mdcMenuSurface API .
+ */
+@Directive({
+ selector: '[mdcSelectMenu]'
+})
+export class MdcSelectMenuDirective {
+ /** @internal */
+ @HostBinding('class.mdc-select__menu') readonly _cls = true;
+
+ constructor(@Self() public _menu: MdcMenuDirective, @Self() public _surface: MdcMenuSurfaceDirective) {}
+}
+/** @docs-private */
+enum ValueSource {
+ control, foundation, program
+};
+
+/**
+ * Directive for a spec aligned material design 'Select Control'. This directive should contain
+ * and `mdcSelectAnchor` and an `mdcSelectMenu`. See the examples for the required structure of
+ * these directives.
+ *
+ * If leaving the select empty should be a valid option, include an `mdcListItem` as first element in the list,
+ * with an ampty string as `value`.
+ *
+ * # Accessibility
+ * * This directive implements the aria practices recommendations for a
+ * [listbox](https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html).
+ * Most `aria-*` and `role` attributes affect the embedded `mdcSelectAnchor`, and `mdcList`, and are
+ * explained in the documentation for these directives.
+ */
+@Directive({
+ selector: '[mdcSelect]'
+})
+export class MdcSelectDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-select') readonly _cls = true;
+ private onDestroy$: Subject = new Subject();
+ private onMenuChange$: Subject = new Subject();
+ private onItemsChange$: Subject = new Subject();
+ private document: Document;
+ private _onChange: (value: any) => void = () => {};
+ private _onTouched: () => any = () => {};
+ private _lastMenu: MdcSelectMenuDirective | null = null;
+ private _value: string | null = null;
+ private _valueSource: ValueSource | null = null;
+ private _disabled = false;
+ private _required = false;
+ private _listLabelledBy: string | null = null;
/**
- * When this input is defined and does not have value false, the select will be styled as a
- * box select.
+ * emits the value of the item when the selected item changes
*/
- @Input() @HostBinding('class.mdc-select--box')
- get box() {
- return this._box;
+ @Output() readonly valueChange: EventEmitter = new EventEmitter();
+ /** @internal */
+ @ContentChildren(MdcSelectAnchorDirective) _anchors?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcNotchedOutlineDirective, {descendants: true}) _outlines?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcSelectMenuDirective) _menus?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcListDirective, {descendants: true}) _lists?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcSelectTextDirective, {descendants: true}) _texts?: QueryList;
+ private mdcAdapter: MDCSelectAdapter = {
+ addClass: (className) => this.rndr.addClass(this.elm.nativeElement, className),
+ removeClass: (className) => this.rndr.removeClass(this.elm.nativeElement, className),
+ hasClass: (className) => this.elm.nativeElement.classList.contains(className),
+ activateBottomLine: () => this.anchor?.bottomLineFoundation?.activate(),
+ deactivateBottomLine: () => this.anchor?.bottomLineFoundation?.deactivate(),
+ getSelectedMenuItem: () => this.getSelectedItem()?._elm.nativeElement,
+ hasLabel: () => !!this.label,
+ floatLabel: (shouldFloat) => this.label?.float(shouldFloat),
+ getLabelWidth: () => this.label?.getWidth() || 0,
+ hasOutline: () => !!this.anchor?._outline,
+ notchOutline: (labelWidth) => this.anchor?._outline!.open(labelWidth),
+ closeOutline: () => this.anchor?._outline!.close(),
+ setRippleCenter: (normalizedX) => this.anchor?.bottomLineFoundation?.setRippleCenter(normalizedX),
+ notifyChange: (value) => this.updateValue(value, ValueSource.foundation),
+ // setSelectedText does nothing, library consumer should set the text; gives them more freedom to e.g. also use markup:
+ setSelectedText: () => undefined,
+ isSelectedTextFocused: () => !!(this.document.activeElement && this.document.activeElement === this.text?._elm.nativeElement),
+ getSelectedTextAttr: (attr) => this.text?._elm.nativeElement.getAttribute(attr),
+ setSelectedTextAttr: (attr, value) => this.text ? this.rndr.setAttribute(this.text._elm.nativeElement, attr, value) : undefined,
+ openMenu: () => this.menu!.openAndFocus(null),
+ closeMenu: () => this.menu!.doClose(),
+ getAnchorElement: () => this.anchor!._elm.nativeElement,
+ setMenuAnchorElement: (anchorEl) => this.surface!.menuAnchor = anchorEl,
+ setMenuAnchorCorner: (anchorCorner) => this.surface!.setFoundationAnchorCorner(anchorCorner),
+ setMenuWrapFocus: () => undefined, // foundation always sets this to false, which is the default anyway - skip
+ setAttributeAtIndex: (index, name, value) => {
+ if (name != strings.ARIA_SELECTED_ATTR) {
+ const item = this.menu?._list?.getItem(index)?._elm.nativeElement;
+ if (item)
+ this.rndr.setAttribute(item, name, value);
+ }
+ },
+ removeAttributeAtIndex: (index, name) => {
+ if (name !== strings.ARIA_SELECTED_ATTR) {
+ const item = this.menu?._list?.getItem(index)?._elm.nativeElement;
+ if (item)
+ this.rndr.removeAttribute(item, name);
+ }
+ },
+ focusMenuItemAtIndex: (index) => {
+ const item = this.menu?._list?.getItem(index)?._elm.nativeElement;
+ if (item)
+ item.focus();
+ },
+ getMenuItemCount: () => this.menu?._list?.getItems().length || 0,
+ getMenuItemValues: () => this.menu?._list?.getItems().map(item => item.value || '') || [],
+ // foundation uses this to 'setSelectedText', but that's ignored in our implementation (see remark on setSelectedText):
+ getMenuItemTextAtIndex: () => '',
+ getMenuItemAttr: (menuItem, attr) => {
+ if (attr === strings.VALUE_ATTR)
+ return this.menu?._list?.getItemByElement(menuItem)?.value || null;
+ return menuItem.getAttribute(attr);
+ },
+ addClassAtIndex: (index, className) => {
+ const item = this.menu?._list?.getItem(index);
+ if (item && className === cssClasses.SELECTED_ITEM_CLASS) {
+ item.active = true;
+ } else if (item)
+ this.rndr.addClass(item._elm.nativeElement, className);
+ },
+ removeClassAtIndex: (index, className) => {
+ const item = this.menu?._list?.getItem(index);
+ if (item && className === cssClasses.SELECTED_ITEM_CLASS) {
+ item.active = false;
+ } else if (item)
+ this.rndr.removeClass(this.menu!._list.getItem(index)!._elm.nativeElement, className);
+ }
+ };
+ /** @internal */
+ foundation: MDCSelectFoundation | null = null;
+
+ constructor(private elm: ElementRef, private rndr: Renderer2, @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document;
}
- set box(val: any) {
- let newVal = asBoolean(val);
- if (newVal !== this._box) {
- this._box = asBoolean(val);
+ ngAfterContentInit() {
+ this._lastMenu = this._menus!.first;
+ this._menus!.changes.subscribe(() => {
+ if (this._lastMenu !== this._menus!.first) {
+ this.onMenuChange$.next();
+ this._lastMenu?._menu.itemValuesChanged.pipe(takeUntil(this.onMenuChange$)).subscribe(() => this.onItemsChange$.next());
+ this._lastMenu = this._menus!.first;
+ this.setupMenuHandlers();
+ }
+ });
+ this._lists!.changes.subscribe(() => this.initListLabel());
+ merge(
+ this.onMenuChange$,
+ // the foundation initializes with the values of the items, so if they change, the foundation must be reconstructed:
+ this.onItemsChange$,
+ // mdcSelectText change needs a complete re-init as well:
+ this._texts!.changes,
+ // when an outline is added/removed, a re-init is needed as well:
+ this._outlines!.changes
+ ).pipe(
+ takeUntil(this.onDestroy$),
+ debounceTime(1)
+ ).subscribe(() => {
this.reconstructComponent();
+ });
+ this.initComponent();
+ this.setupMenuHandlers();
+ this.initListLabel();
+ }
+
+ ngOnDestroy() {
+ this.onMenuChange$.next(); this.onMenuChange$.complete();
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.onItemsChange$.complete();
+ this.destroyComponent();
+ }
+
+ private initComponent() {
+ this.foundation = new class extends MDCSelectFoundation {
+ isValid() {
+ //TODO: required effect on validity in combination with @angular/forms
+ //TODO: setValid/aria-invalid/helpertext validity
+ return super.isValid();
+ }
+ }(this.mdcAdapter, {
+ helperText: undefined,
+ leadingIcon: undefined
+ });
+ this.foundation.init();
+ // foundation needs a call to setDisabled (even when false), because otherwise
+ // tabindex will not be set correctly:
+ this.foundation.setDisabled(this._disabled);
+ this.foundation.setRequired(this._required);
+ // foundation only updates aria-expanded on open/close, not on initialization:
+ this.mdcAdapter.setSelectedTextAttr('aria-expanded', `${this.surface!.open}`);
+ // TODO: it looks like the foundation doesn't update aria-expanded when the surface is
+ // opened programmatically.
+ }
+
+ private destroyComponent() {
+ this.foundation?.destroy();
+ this.foundation = null;
+ }
+
+ private reconstructComponent() {
+ this.destroyComponent();
+ this.initComponent();
+ }
+
+ private setupMenuHandlers() {
+ if (this.menu) {
+ this.menu._listFunction = MdcListFunction.select;
+ this.menu.pick.pipe(takeUntil(this.onMenuChange$)).subscribe((evt) => {
+ this.foundation?.handleMenuItemAction(evt.index);
+ });
+ this.surface!.afterOpened.pipe(takeUntil(this.onMenuChange$)).subscribe(() => {
+ this.foundation?.handleMenuOpened();
+ });
+ this.surface!.afterClosed.pipe(takeUntil(this.onMenuChange$)).subscribe(() => {
+ this.foundation?.handleMenuClosed();
+ });
}
}
+ private initListLabel() {
+ this._lists!.forEach(list => {
+ list.labelledBy = this._listLabelledBy;
+ });
+ }
+
/**
- * When this input is set to a value other than false, the select control will be styled
- * with a notched outline.
+ * The value of the selected item.
*/
- @HostBinding('class.mdc-select--outlined') @Input()
- get outlined() {
- return this._outlined;
+ @Input() get value() {
+ return this._value;
}
- set outlined(val: any) {
- let newVal = asBoolean(val);
- if (newVal !== this._outlined) {
- this._outlined = asBoolean(val);
- this.reconstructComponent();
+ set value(value: string | null) {
+ this.updateValue(value, ValueSource.program);
+ }
+
+ /** @internal */
+ updateValue(value: string | null, source: ValueSource) {
+ const oldSource = this._valueSource;
+ try {
+ if (!this._valueSource)
+ this._valueSource = source;
+ if (source === ValueSource.foundation) {
+ this._value = value;
+ Promise.resolve().then(() => {
+ this.valueChange.emit(value);
+ if (this._valueSource !== ValueSource.control)
+ this._onChange(value);
+ });
+ } else if (value !== this.value) {
+ if (this.foundation) {
+ this.foundation.setValue(value!); // foundation should also accept null value
+ // foundation will do a nested call for this function with source===foundation
+ // there we will handle the value change and emit to observers (see the if block preceding this)
+ } else {
+ this._value = value;
+ Promise.resolve().then(() => {
+ this.valueChange.emit(value);
+ if (this._valueSource !== ValueSource.control)
+ this._onChange(value);
+ });
+ }
+ }
+ } finally {
+ this._valueSource = oldSource;
}
}
- @HostBinding('class.mdc-select--disabled') get _disabled() {
- return !this._control || this._control.disabled;
+ /**
+ * To disable the select, set this input to a value other then false.
+ */
+ @Input()
+ get disabled() {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = asBoolean(value);
+ this.foundation?.setDisabled(this._disabled);
+ }
+
+ static ngAcceptInputType_disabled: boolean | '';
+
+ /**
+ * To make the select a required input, set this input to a value other then false.
+ */
+ @Input()
+ get required() {
+ return this._required;
+ }
+
+ set required(value: boolean) {
+ this._required = asBoolean(value);
+ this.foundation?.setRequired(this._required);
+ }
+
+ static ngAcceptInputType_required: boolean | '';
+
+ /** @internal */
+ @HostBinding('class.mdc-select--outlined') get outlined() {
+ return !!this.anchor?._outline;
+ }
+
+ /** @internal */
+ @HostBinding('class.mdc-select--no-label') get labeled() {
+ return !this.anchor?._label;
+ }
+
+ /** @internal */
+ setListLabelledBy(id: string | null) {
+ this._listLabelledBy = id;
+ this.initListLabel();
+ }
+
+ /** @internal */
+ get expanded() {
+ return !!this.surface?.open;
+ }
+
+ private get menu() {
+ return this._menus?.first?._menu;
+ }
+
+ private get surface() {
+ return this._menus?.first?._surface;
+ }
+
+ private get anchor() {
+ return this._anchors?.first;
+ }
+
+ private get label() {
+ return this.anchor?._label;
+ }
+
+ private get text() {
+ return this._texts?.first;
+ }
+
+ private getSelectedItem() {
+ return this.menu?._list?.getSelectedItem();
+ }
+
+ /** @internal */
+ registerOnChange(onChange: (value: any) => void) {
+ this._onChange = onChange;
+ }
+
+ /** @internal */
+ registerOnTouched(onTouched: () => any) {
+ this._onTouched = onTouched;
+ }
+
+ /** @internal */
+ onBlur() {
+ this.foundation?.handleBlur();
+ this._onTouched();
}
}
+
+/**
+ * Directive for adding Angular Forms (ControlValueAccessor
) behavior to an
+ * `mdcSelect`. Allows the use of the Angular Forms API with select inputs,
+ * e.g. binding to [(ngModel)]
, form validation, etc.
+ */
+@Directive({
+ selector: '[mdcSelect][formControlName],[mdcSelect][formControl],[mdcSelect][ngModel]',
+ providers: [
+ {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MdcFormsSelectDirective), multi: true}
+ ]
+})
+export class MdcFormsSelectDirective implements ControlValueAccessor {
+ constructor(@Self() private mdcSelect: MdcSelectDirective) {
+ }
+
+ /** @docs-private */
+ writeValue(obj: any) {
+ this.mdcSelect.updateValue(obj, ValueSource.control);
+ }
+
+ /** @docs-private */
+ registerOnChange(onChange: (value: any) => void) {
+ this.mdcSelect.registerOnChange(onChange);
+ }
+
+ /** @docs-private */
+ registerOnTouched(onTouched: () => any) {
+ this.mdcSelect.registerOnTouched(onTouched);
+ }
+
+ /** @docs-private */
+ setDisabledState(disabled: boolean) {
+ this.mdcSelect.disabled = disabled;
+ }
+}
+
+export const SELECT_DIRECTIVES = [
+ MdcSelectTextDirective,
+ MdcSelectAnchorDirective,
+ MdcSelectMenuDirective,
+ MdcSelectDirective,
+ MdcFormsSelectDirective
+];
diff --git a/bundle/src/components/slider/mdc.slider.adapter.ts b/bundle/src/components/slider/mdc.slider.adapter.ts
deleted file mode 100644
index 7c1db31..0000000
--- a/bundle/src/components/slider/mdc.slider.adapter.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/** @docs-private */
-export interface MdcSliderAdapter {
- hasClass: (className: string ) => boolean,
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- getAttribute: (name: string) => string | null,
- setAttribute: (name: string, value: string) => void,
- removeAttribute: (name: string) => void,
- computeBoundingRect: () => ClientRect,
- getTabIndex: () => number,
- registerInteractionHandler: (type: string, handler: EventListener) => void,
- deregisterInteractionHandler: (type: string, handler: EventListener) => void,
- registerThumbContainerInteractionHandler: (type: string, handler: EventListener) => void,
- deregisterThumbContainerInteractionHandler: (type: string, handler: EventListener) => void,
- registerBodyInteractionHandler: (type: string, handler: EventListener) => void,
- deregisterBodyInteractionHandler: (type: string, handler: EventListener) => void,
- registerResizeHandler: (handler: EventListener) => void,
- deregisterResizeHandler: (handler: EventListener) => void,
- notifyInput: () => void,
- notifyChange: () => void,
- setThumbContainerStyleProperty: (propertyName: string, value: string) => void,
- setTrackStyleProperty: (propertyName: string, value: string) => void,
- setMarkerValue: (value: number) => void,
- appendTrackMarkers: (numMarkers: number) => void,
- removeTrackMarkers: () => void,
- setLastTrackMarkersStyleProperty: (propertyName: string, value: string) => void,
- isRTL: () => boolean
-}
\ No newline at end of file
diff --git a/bundle/src/components/slider/mdc.slider.directive.spec.ts b/bundle/src/components/slider/mdc.slider.directive.spec.ts
index a8f6aea..32d9c68 100644
--- a/bundle/src/components/slider/mdc.slider.directive.spec.ts
+++ b/bundle/src/components/slider/mdc.slider.directive.spec.ts
@@ -1,4 +1,4 @@
-import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
+import { TestBed, ComponentFixture, fakeAsync, tick, flush } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@@ -107,9 +107,11 @@ describe('MdcSliderDirective', () => {
const slider: HTMLElement = fixture.nativeElement.querySelector('.mdc-slider');
expect(slider).toBeDefined();
expect(slider.getAttribute('tabindex')).toBe('0');
- expect(slider.getAttribute('aria-valuenow')).toBeNull();
- expect(slider.getAttribute('aria-valuemin')).toBeNull();
- expect(slider.getAttribute('aria-valuemax')).toBeNull();
+ //value/min/max will always be assigned:
+ expect(slider.getAttribute('aria-valuenow')).toBe('0');
+ expect(slider.getAttribute('aria-valuemin')).toBe('0');
+ expect(slider.getAttribute('aria-valuemax')).toBe('0');
+
expect(slider.getAttribute('aria-disabled')).toBe('false');
expect(slider.getAttribute('role')).toBe('slider');
}));
@@ -120,7 +122,7 @@ describe('MdcSliderDirective', () => {
const slider: HTMLElement = fixture.nativeElement.querySelector('.mdc-slider');
expect(slider).toBeDefined();
expect(slider.getAttribute('tabindex')).toBe('0');
- expect(slider.getAttribute('aria-valuenow')).toBeNull();
+ expect(slider.getAttribute('aria-valuenow')).toBe('0'); // a value is always assigned, null is coerced to 0 (or min when min > 0)
expect(slider.getAttribute('aria-valuemin')).toBe('0');
expect(slider.getAttribute('aria-valuemax')).toBe('100');
expect(slider.getAttribute('aria-disabled')).toBe('false');
@@ -141,7 +143,7 @@ describe('MdcSliderDirective', () => {
testComponent.clear();
testComponent._value = 90;
- fixture.detectChanges(); tick();
+ fixture.detectChanges(); tick(); fixture.detectChanges();
expect(slider.getAttribute('aria-valuenow')).toBe('90');
expect(slider.getAttribute('aria-valuemin')).toBe('0');
@@ -150,7 +152,7 @@ describe('MdcSliderDirective', () => {
testComponent.clear();
testComponent._max = 50;
- fixture.detectChanges(); tick();
+ fixture.detectChanges(); tick(); fixture.detectChanges();
expect(slider.getAttribute('aria-valuenow')).toBe('50');
expect(slider.getAttribute('aria-valuemin')).toBe('0');
@@ -161,7 +163,7 @@ describe('MdcSliderDirective', () => {
testComponent.clear();
testComponent._value = 60;
- fixture.detectChanges(); tick();
+ fixture.detectChanges(); tick(); fixture.detectChanges();
expect(slider.getAttribute('aria-valuenow')).toBe('50');
expect(slider.getAttribute('aria-valuemin')).toBe('0');
expect(slider.getAttribute('aria-valuemax')).toBe('50');
@@ -171,7 +173,7 @@ describe('MdcSliderDirective', () => {
testComponent.clear();
testComponent._min = 60;
- fixture.detectChanges(); tick();
+ fixture.detectChanges(); tick(); fixture.detectChanges();
expect(slider.getAttribute('aria-valuenow')).toBe('60');
expect(slider.getAttribute('aria-valuemin')).toBe('60');
expect(slider.getAttribute('aria-valuemax')).toBe('60');
@@ -182,7 +184,7 @@ describe('MdcSliderDirective', () => {
testComponent.clear();
testComponent._max = 10;
- fixture.detectChanges(); tick();
+ fixture.detectChanges(); tick(); fixture.detectChanges();
expect(slider.getAttribute('aria-valuenow')).toBe('10');
expect(slider.getAttribute('aria-valuemin')).toBe('10');
expect(slider.getAttribute('aria-valuemax')).toBe('10');
@@ -200,6 +202,24 @@ describe('MdcSliderDirective', () => {
testValueAndRangeChanges(FormsTestComponent, true);
}));
+ it('can be discrete and have markers', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+
+ const slider: HTMLElement = fixture.nativeElement.querySelector('.mdc-slider');
+ // dosn't have pin markers:
+ expect(slider.querySelector('div.mdc-slider__pin')).toBeNull();
+ expect(slider.querySelector('div.mdc-slider__pin-value-marker')).toBeNull();
+
+ testComponent.discrete = true;
+ testComponent.markers = true;
+ fixture.detectChanges(); tick(25);
+
+ // now must have pin markers:
+ expect(slider.querySelector('div.mdc-slider__pin')).not.toBeNull();
+ expect(slider.querySelector('div.mdc-slider__pin-value-marker')).not.toBeNull();
+ }));
+
function testDisabling(testComponentType: any, withForms: boolean) {
const { fixture } = setup(testComponentType, withForms);
const testComponent = fixture.debugElement.injector.get(testComponentType);
diff --git a/bundle/src/components/slider/mdc.slider.directive.ts b/bundle/src/components/slider/mdc.slider.directive.ts
index 58a3e83..5f6aa36 100644
--- a/bundle/src/components/slider/mdc.slider.directive.ts
+++ b/bundle/src/components/slider/mdc.slider.directive.ts
@@ -1,481 +1,478 @@
-import { AfterContentInit, AfterViewInit, Directive, ElementRef, EventEmitter, forwardRef,
- HostBinding, Input, OnChanges, OnDestroy, Output, Renderer2, Self, SimpleChange, SimpleChanges } from '@angular/core';
-import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
-import { MDCSliderFoundation, strings } from '@material/slider';
-import { MdcSliderAdapter } from './mdc.slider.adapter';
-import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-interface MdcSliderFoundationInterface {
- init: () => void,
- destroy: () => void,
- setupTrackMarker(),
- layout(),
- getValue(),
- setValue(value: number),
- getMax(): number,
- setMax(max: number),
- getMin(): number,
- setMin(min: number),
- getStep(): number,
- setStep(step: number),
- isDisabled(): boolean,
- setDisabled(disabled: boolean),
-}
-
-/**
- * Directive for creating a Material Design slider input.
- * (Modelled after the <input type="range"/>
element).
- * The slider is fully accessible. The current implementation
- * will add and manage all DOM child elements that are required for the wrapped
- * mdc-slider
component.
- * Future implementations will also support supplying (customized)
- * DOM children.
- */
-@Directive({
- selector: '[mdcSlider]'
-})
-export class MdcSliderDirective implements AfterContentInit, AfterViewInit, OnChanges, OnDestroy {
- @HostBinding('class.mdc-slider') _cls = true;
- @HostBinding('attr.role') _role: string = 'slider';
- /**
- * Event emitted when the value changes. The value may change because of user input,
- * or as a side affect of setting new min, max, or step values.
- */
- @Output() valueChange: EventEmitter = new EventEmitter();
- /**
- * Event emitted when the min range value changes. This may happen as a side effect
- * of setting a new max value (when the new max is smaller than the old min).
- */
- @Output() minValueChange: EventEmitter = new EventEmitter();
- /**
- * Event emitted when the max range value changes. This may happen as a side effect
- * of setting a new min value (when the new min is larger than the old max).
- */
- @Output() maxValueChange: EventEmitter = new EventEmitter();
- /**
- * Event emitted when the step value changes. This may happen as a side effect
- * of making the slider discrete.
- */
- @Output() stepValueChange: EventEmitter = new EventEmitter();
- private _initialized = false;
- private _elmThumbCntr: HTMLElement;
- private _elmSliderPin: HTMLElement;
- private _elmValueMarker: HTMLElement;
- private _elmTrack: HTMLElement;
- private _elmTrackMarkerCntr: HTMLElement;
- private _reinitTabIndex: number;
- private _onChange: (value: any) => void = (value) => {};
- private _onTouched: () => any = () => {};
- private _discrete = false;
- private _markers = false;
- private _disabled = false;
- private _value = 0;
- private _min = 0;
- private _max = 100;
- private _step = 0;
- private _lastWidth: number;
-
- private mdcAdapter: MdcSliderAdapter = {
- hasClass: (className: string) => {
- if (className === 'mdc-slider--discrete')
- return this._discrete;
- if (className === 'mdc-slider--display-markers')
- return this._markers;
- return this._root.nativeElement.classList.contains(className);
- },
- addClass: (className: string) => {
- this._rndr.addClass(this._root.nativeElement, className);
- },
- removeClass: (className: string) => {
- this._rndr.removeClass(this._root.nativeElement, className);
- },
- getAttribute: (name: string) => this._root.nativeElement.getAttribute(name),
- setAttribute: (name: string, value: string) => {this._rndr.setAttribute(this._root.nativeElement, name, value); },
- removeAttribute: (name: string) => {this._rndr.removeAttribute(this._root.nativeElement, name); },
- computeBoundingRect: () => this._root.nativeElement.getBoundingClientRect(),
- getTabIndex: () => this._root.nativeElement.tabIndex,
- registerInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.listen(this._rndr, type, handler, this._root);
- },
- deregisterInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.unlisten(type, handler);
- },
- registerThumbContainerInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.listenElm(this._rndr, type, handler, this._elmThumbCntr);
- },
- deregisterThumbContainerInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.unlisten(type, handler);
- },
- registerBodyInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.listenElm(this._rndr, type, handler, document.body);
- },
- deregisterBodyInteractionHandler: (type: string, handler: EventListener) => {
- this._registry.unlisten(type, handler);
- },
- registerResizeHandler: (handler: EventListener) => {
- this._registry.listenElm(this._rndr, 'resize', handler, window);
- },
- deregisterResizeHandler: (handler: EventListener) => {
- this._registry.unlisten('resize', handler);
- },
- notifyInput: () => {
- let newValue = this.asNumber(this.foundation.getValue());
- if (newValue !== this._value) {
- this._value = newValue;
- this.notifyValueChanged();
- }
- },
- notifyChange: () => {
- // currently not handling this event, if there is a usecase for this, please
- // create a feature request.
- },
- setThumbContainerStyleProperty: (propertyName: string, value: string) => {
- this._rndr.setStyle(this._elmThumbCntr, propertyName, value);
- },
- setTrackStyleProperty: (propertyName: string, value: string) => {
- this._rndr.setStyle(this._elmTrack, propertyName, value);
- },
- setMarkerValue: (value: number) => {
- if (this._elmValueMarker)
- this._elmValueMarker.innerText = value != null ? value.toString() : null;
- },
- appendTrackMarkers: (numMarkers: number) => {
- if (this._elmTrackMarkerCntr) {
- const frag = document.createDocumentFragment();
- for (let i = 0; i < numMarkers; i++) {
- const marker = document.createElement('div');
- marker.classList.add('mdc-slider__track-marker');
- frag.appendChild(marker);
- }
- this._rndr.appendChild(this._elmTrackMarkerCntr, frag);
- }
- },
- removeTrackMarkers: () => {
- if (this._elmTrackMarkerCntr)
- while (this._elmTrackMarkerCntr.firstChild)
- this._rndr.removeChild(this._elmTrackMarkerCntr, this._elmTrackMarkerCntr.firstChild);
- },
- setLastTrackMarkersStyleProperty: (propertyName: string, value: string) => {
- const lastTrackMarker = this._root.nativeElement.querySelector('.mdc-slider__track-marker:last-child');
- if (lastTrackMarker)
- this._rndr.setStyle(lastTrackMarker, propertyName, value);
- },
- isRTL: () => getComputedStyle(this._root.nativeElement).direction === 'rtl'
- };
- private foundation: MdcSliderFoundationInterface = new MDCSliderFoundation(this.mdcAdapter);
-
- constructor(private _rndr: Renderer2, private _root: ElementRef, private _registry: MdcEventRegistry) {
- }
-
- ngAfterContentInit() {
- this.initElements();
- this.initDefaultAttributes();
- this.foundation.init();
- this._lastWidth = this.mdcAdapter.computeBoundingRect().width;
- this.updateValues({});
- this._initialized = true;
- }
-
- ngAfterViewInit() {
- this.updateLayout();
- }
-
- ngOnDestroy() {
- this.foundation.destroy();
- }
-
- ngOnChanges(changes: SimpleChanges) {
- this._onChanges(changes);
- }
-
- _onChanges(changes: SimpleChanges) {
- if (this._initialized) {
- if (this.isChanged('discrete', changes) || this.isChanged('markers', changes)) {
- this.foundation.destroy();
- this.initElements();
- this.initDefaultAttributes();
- this.foundation = new MDCSliderFoundation(this.mdcAdapter);
- this.foundation.init();
- }
- this.updateValues(changes);
- this.updateLayout();
- }
- }
-
- private isChanged(name: string, changes: SimpleChanges) {
- return changes[name] && changes[name].currentValue !== changes[name].previousValue;
- }
-
- private initElements() {
- // initElements is also called when changes dictate a new Foundation initialization,
- // in which case we create new child elements:
- while (this._root.nativeElement.firstChild)
- this._rndr.removeChild(this._root.nativeElement, this._root.nativeElement.firstChild);
- const elmTrackContainer = this.addElement(this._root.nativeElement, 'div', ['mdc-slider__track-container']);
- this._elmTrack = this.addElement(elmTrackContainer, 'div', ['mdc-slider__track']);
- if (this._discrete && this._markers)
- this._elmTrackMarkerCntr = this.addElement(elmTrackContainer, 'div', ['mdc-slider__track-marker-container']);
- else
- this._elmTrackMarkerCntr = null;
- this._elmThumbCntr = this.addElement(this._root.nativeElement, 'div', ['mdc-slider__thumb-container']);
- if (this._discrete) {
- this._elmSliderPin = this.addElement(this._elmThumbCntr, 'div', ['mdc-slider__pin']);
- this._elmValueMarker = this.addElement(this._elmSliderPin, 'div', ['mdc-slider__pin-value-marker']);
- } else {
- this._elmSliderPin = null;
- this._elmValueMarker = null;
- }
- const svg = this._rndr.createElement('svg', 'svg');
- this._rndr.addClass(svg, 'mdc-slider__thumb');
- this._rndr.setAttribute(svg, 'width', '21');
- this._rndr.setAttribute(svg, 'height', '21');
- this._rndr.appendChild(this._elmThumbCntr, svg);
- const circle = this._rndr.createElement('circle', 'svg');
- this._rndr.setAttribute(circle, 'cx', '10.5');
- this._rndr.setAttribute(circle, 'cy', '10.5');
- this._rndr.setAttribute(circle, 'r', '7.875');
- this._rndr.appendChild(svg, circle);
- this.addElement(this._elmThumbCntr, 'div', ['mdc-slider__focus-ring']);
- }
-
- private addElement(parent: HTMLElement, element: string, classNames: string[]) {
- let child = this._rndr.createElement(element);
- classNames.forEach(name => {
- this._rndr.addClass(child, name);
- });
- this._rndr.appendChild(parent, child);
- return child;
- }
-
- private initDefaultAttributes() {
- if (this._reinitTabIndex)
- // value was set the first time we initialized the foundation,
- // so it should also be set when we reinitialize evrything:
- this._root.nativeElement.tabIndex = this._reinitTabIndex;
- else if (!this._root.nativeElement.hasAttribute('tabindex')) {
- // unless overridden by another tabIndex, we want sliders to
- // participate in tabbing (the foundation will remove the tabIndex
- // when the slider is disabled, reset to the initial value when enabled again):
- this._root.nativeElement.tabIndex = 0;
- this._reinitTabIndex = 0;
- } else {
- this._reinitTabIndex = this._root.nativeElement.tabIndex;
- }
- }
-
- private updateValues(changes: SimpleChanges) {
- if (this._discrete && this._step < 1) {
- // See https://github.com/material-components/material-components-web/issues/1426
- // mdc-slider doesn't allow a discrete step value < 1 currently:
- this._step = 1;
- setTimeout(() => {this.stepValueChange.emit(this._step); }, 0);
- } else if (this._step < 0) {
- this._step = 0;
- setTimeout(() => {this.stepValueChange.emit(this._step); }, 0);
- }
- if (this._min > this._max) {
- if (this.isChanged('maxValue', changes)) {
- this._min = this._max;
- setTimeout(() => {this.minValueChange.emit(this._min); }, 0);
- } else {
- this._max = this._min;
- setTimeout(() => {this.maxValueChange.emit(this._max); }, 0);
- }
- }
- let currValue = changes['value'] ? changes['value'].currentValue : this._value;
- if (this._value < this._min)
- this._value = this._min;
- if (this._value > this._max)
- this._value = this._max;
- // find an order in which the changed values will be accepted by the foundation
- // (since the foundation will throw errors for min > max and other conditions):
- if (this._min < this.foundation.getMax()) {
- this.foundation.setMin(this._min);
- this.foundation.setMax(this._max);
- } else {
- this.foundation.setMax(this._max);
- this.foundation.setMin(this._min);
- }
- this.foundation.setStep(this._step);
- if (this.foundation.isDisabled() !== this._disabled) {
- // without this check, MDCFoundation may remove the tabIndex incorrectly,
- // preventing the slider from getting focus on keyboard commands:
- this.foundation.setDisabled(this._disabled);
- }
- // if we pass null, the foundation will make this a number, since we want to support 'no value',
- // we're passing 'undefined' instead:
- this.foundation.setValue(this._value == null ? undefined : this._value);
- // value may have changed during setValue(), due to step settings:
- this._value = this.asNumber(this.foundation.getValue());
- // compare with '!=' as null and undefined are considered the same (for initialisation sake):
- if (currValue != this._value && !(isNaN(currValue) && isNaN(this._value)))
- setTimeout(() => {this.notifyValueChanged(); }, 0);
- }
-
- private updateLayout() {
- let newWidth = this.mdcAdapter.computeBoundingRect().width;
- if (newWidth !== this._lastWidth) {
- this._lastWidth = newWidth;
- this.foundation.layout();
- }
- }
-
- private notifyValueChanged() {
- this.valueChange.emit(this._value);
- this._onChange(this._value);
- }
-
- /** @docs-private */
- registerOnChange(onChange: (value: any) => void) {
- this._onChange = onChange;
- }
-
- /** @docs-private */
- registerOnTouched(onTouched: () => any) {
- this._onTouched = onTouched;
- }
-
- /**
- * Make the slider discrete. Note from the wrapped mdc-slider
- * component:
- * If a slider contains a step value it does not mean that the slider is a "discrete" slider.
- * "Discrete slider" is a UX treatment, while having a step value is behavioral.
- */
- @Input() @HostBinding('class.mdc-slider--discrete')
- get discrete() {
- return this._discrete;
- }
-
- set discrete(value: any) {
- this._discrete = asBoolean(value);
- }
-
- /**
- * Property to enable/disable the display of track markers. Display markers
- * are only supported for discrete sliders. Thus they are only shown when the values
- * of both markers and discrete equal true.
- */
- @Input() @HostBinding('class.mdc-slider--display-markers')
- get markers() {
- return this._markers;
- }
-
- set markers(value: any) {
- this._markers = asBoolean(value);
- }
-
- /**
- * The current value of the slider.
- */
- @Input() @HostBinding('attr.aria-valuenow')
- get value() {
- return this._value;
- }
-
- set value(value: string | number) {
- this._value = this.asNumber(value);
- }
-
- /**
- * The minumum allowed value of the slider.
- */
- @Input() @HostBinding('attr.aria-valuemin')
- get minValue() {
- return this._min;
- }
-
- set minValue(value: string | number) {
- this._min = this.asNumber(value);
- }
-
- /**
- * The maximum allowed value of the slider.
- */
- @Input() @HostBinding('attr.aria-valuemax')
- get maxValue() {
- return this._max;
- }
-
- set maxValue(value: string | number) {
- this._max = this.asNumber(value);
- }
-
- /**
- * Set the step value (or set to 0 for no step value).
- * The step value can be a floating point value >= 0.
- * The slider will quantize all values to match the step value, except for the minimum and
- * maximum, which can always be set.
- * Discrete sliders are required to have a step value other than 0.
- * Note from the wrapped mdc-slider
component:
- * If a slider contains a step value it does not mean that the slider is a "discrete" slider.
- * "Discrete slider" is a UX treatment, while having a step value is behavioral.
- */
- @Input()
- get stepValue() {
- return this._step;
- }
-
- set stepValue(value: string | number) {
- this._step = this.asNumber(value);
- }
-
- /**
- * A property to disable the slider.
- */
- @Input() @HostBinding('attr.aria-disabled')
- get disabled() {
- return this._disabled;
- }
-
- set disabled(value: any) {
- this._disabled = asBoolean(value);
- }
-
- asNumber(value: number | string): number {
- if (value == null)
- return value;
- let result = +value;
- if (isNaN(result))
- return null;
- return result;
- }
-}
-
-/**
- * Directive for adding Angular Forms (ControlValueAccessor
) behavior to an
- * MdcSliderDirective
. Allows the use of the Angular Forms API with
- * icon toggles, e.g. binding to [(ngModel)]
, form validation, etc.
- */
-@Directive({
- selector: '[mdcSlider][formControlName],[mdcSlider][formControl],[mdcSlider][ngModel]',
- providers: [
- {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MdcFormsSliderDirective), multi: true}
- ]
-})
-export class MdcFormsSliderDirective implements ControlValueAccessor {
- constructor(@Self() private mdcSlider: MdcSliderDirective) {
- }
-
- /** @docs-private */
- writeValue(obj: any) {
- let change = new SimpleChange(this.mdcSlider.value, this.mdcSlider.asNumber(obj), false);
- this.mdcSlider.value = obj;
- this.mdcSlider._onChanges({value: change});
- }
-
- /** @docs-private */
- registerOnChange(onChange: (value: any) => void) {
- this.mdcSlider.registerOnChange(onChange);
- }
-
- /** @docs-private */
- registerOnTouched(onTouched: () => any) {
- this.mdcSlider.registerOnTouched(onTouched);
- }
-
- /** @docs-private */
- setDisabledState(disabled: boolean) {
- this.mdcSlider.disabled = disabled;
- }
-}
+import { AfterContentInit, AfterViewInit, Directive, ElementRef, EventEmitter, forwardRef,
+ HostBinding, HostListener, Inject, Input, OnChanges, OnDestroy, Output, Renderer2, Self, SimpleChange,
+ SimpleChanges } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
+import { MDCSliderFoundation, MDCSliderAdapter } from '@material/slider';
+import { events } from '@material/dom';
+import { asBoolean } from '../../utils/value.utils';
+import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+
+/**
+ * Directive for creating a Material Design slider input.
+ * (Modelled after the <input type="range"/>
element).
+ * The slider is fully accessible. The current implementation
+ * will add and manage all DOM child elements that are required for the wrapped
+ * mdc-slider
component.
+ * Future implementations will also support supplying (customized)
+ * DOM children.
+ */
+@Directive({
+ selector: '[mdcSlider]'
+})
+export class MdcSliderDirective implements AfterContentInit, AfterViewInit, OnChanges, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-slider') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') _role: string = 'slider';
+ /**
+ * Event emitted when the value changes. The value may change because of user input,
+ * or as a side affect of setting new min, max, or step values.
+ */
+ @Output() readonly valueChange: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the min range value changes. This may happen as a side effect
+ * of setting a new max value (when the new max is smaller than the old min).
+ */
+ @Output() readonly minValueChange: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the max range value changes. This may happen as a side effect
+ * of setting a new min value (when the new min is larger than the old max).
+ */
+ @Output() readonly maxValueChange: EventEmitter = new EventEmitter();
+ /**
+ * Event emitted when the step value changes. This may happen as a side effect
+ * of making the slider discrete.
+ */
+ @Output() readonly stepValueChange: EventEmitter = new EventEmitter();
+ private trackCntr: HTMLElement | null = null;
+ private _elmThumbCntr: HTMLElement | null = null;
+ private _elmSliderPin: HTMLElement | null = null;
+ private _elmValueMarker: HTMLElement | null = null;
+ private _elmTrack: HTMLElement | null = null;
+ private _elmTrackMarkerCntr: HTMLElement | null = null;
+ private _reinitTabIndex: number | null = null;
+ private _onChange: (value: any) => void = (value) => {};
+ private _onTouched: () => any = () => {};
+ private _discrete = false;
+ private _markers = false;
+ private _disabled = false;
+ private _value: number = 0;
+ private _min = 0;
+ private _max = 100;
+ private _step = 0;
+ private _lastWidth: number | null = null;
+
+ private mdcAdapter: MDCSliderAdapter = {
+ hasClass: (className: string) => {
+ if (className === 'mdc-slider--discrete')
+ return this._discrete;
+ if (className === 'mdc-slider--display-markers')
+ return this._markers;
+ return this._root.nativeElement.classList.contains(className);
+ },
+ addClass: (className: string) => {
+ this._rndr.addClass(this._root.nativeElement, className);
+ },
+ removeClass: (className: string) => {
+ this._rndr.removeClass(this._root.nativeElement, className);
+ },
+ getAttribute: (name: string) => this._root.nativeElement.getAttribute(name),
+ setAttribute: (name: string, value: string) => {
+ // skip attributes that we control with angular
+ if (!/^aria-(value.*|disabled)$/.test(name))
+ this._rndr.setAttribute(this._root.nativeElement, name, value);
+ },
+ removeAttribute: (name: string) => {this._rndr.removeAttribute(this._root.nativeElement, name); },
+ computeBoundingRect: () => this._root.nativeElement.getBoundingClientRect(),
+ getTabIndex: () => this._root.nativeElement.tabIndex,
+ registerInteractionHandler: (evtType, handler) => this._registry.listen(this._rndr, evtType, handler, this._root, events.applyPassive()),
+ deregisterInteractionHandler: (evtType, handler) => this._registry.unlisten(evtType, handler),
+ registerThumbContainerInteractionHandler: (evtType, handler) => this._registry.listenElm(this._rndr, evtType, handler, this._elmThumbCntr!, events.applyPassive()),
+ deregisterThumbContainerInteractionHandler: (evtType, handler) => this._registry.unlisten(evtType, handler),
+ registerBodyInteractionHandler: (evtType, handler) => this._registry.listenElm(this._rndr, evtType, handler, this.document.body),
+ deregisterBodyInteractionHandler: (evtType, handler) => this._registry.unlisten(evtType, handler),
+ registerResizeHandler: (handler) => this._registry.listenElm(this._rndr, 'resize', handler, this.document.defaultView!),
+ deregisterResizeHandler: (handler) => this._registry.unlisten('resize', handler),
+ notifyInput: () => {
+ let newValue = this.asNumber(this.foundation!.getValue());
+ if (newValue !== this._value) {
+ this._value = newValue!;
+ this.notifyValueChanged();
+ }
+ },
+ notifyChange: () => {
+ // currently not handling this event, if there is a usecase for this, please
+ // create a feature request.
+ },
+ setThumbContainerStyleProperty: (propertyName: string, value: string) => {
+ this._rndr.setStyle(this._elmThumbCntr, propertyName, value);
+ },
+ setTrackStyleProperty: (propertyName: string, value: string) => {
+ this._rndr.setStyle(this._elmTrack, propertyName, value);
+ },
+ setMarkerValue: (value: number) => {
+ if (this._elmValueMarker)
+ this._elmValueMarker.innerText = value != null ? value.toLocaleString() : '';
+ },
+ setTrackMarkers: (step, max, min) => {
+ if (this._elmTrackMarkerCntr) {
+ // from https://github.com/material-components/material-components-web/blob/v5.1.0/packages/mdc-slider/component.ts#L141
+ const stepStr = step.toLocaleString();
+ const maxStr = max.toLocaleString();
+ const minStr = min.toLocaleString();
+ const markerAmount = `((${maxStr} - ${minStr}) / ${stepStr})`;
+ const markerWidth = `2px`;
+ const markerBkgdImage = `linear-gradient(to right, currentColor ${markerWidth}, transparent 0)`;
+ const markerBkgdLayout = `0 center / calc((100% - ${markerWidth}) / ${markerAmount}) 100% repeat-x`;
+ const markerBkgdShorthand = `${markerBkgdImage} ${markerBkgdLayout}`;
+ this._rndr.setStyle(this._elmTrackMarkerCntr, 'background', markerBkgdShorthand);
+ }
+ },
+ isRTL: () => getComputedStyle(this._root.nativeElement).direction === 'rtl'
+
+ };
+ private foundation: MDCSliderFoundation | null = null;
+ private document: Document;
+
+ constructor(private _rndr: Renderer2, private _root: ElementRef, private _registry: MdcEventRegistry,
+ @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
+ }
+
+ ngAfterContentInit() {
+ this.initElements();
+ this.initDefaultAttributes();
+ this.foundation = new MDCSliderFoundation(this.mdcAdapter)
+ this.foundation.init();
+ this._lastWidth = this.mdcAdapter.computeBoundingRect().width;
+ this.updateValues({});
+ }
+
+ ngAfterViewInit() {
+ this.updateLayout();
+ }
+
+ ngOnDestroy() {
+ this.foundation?.destroy();
+ }
+
+ ngOnChanges(changes: SimpleChanges) {
+ this._onChanges(changes);
+ }
+
+ /** @internal */
+ _onChanges(changes: SimpleChanges) {
+ if (this.foundation) {
+ if (this.isChanged('discrete', changes) || this.isChanged('markers', changes)) {
+ this.foundation.destroy();
+ this.initElements();
+ this.initDefaultAttributes();
+ this.foundation = new MDCSliderFoundation(this.mdcAdapter);
+ this.foundation.init();
+ }
+ this.updateValues(changes);
+ this.updateLayout();
+ }
+ }
+
+ private isChanged(name: string, changes: SimpleChanges) {
+ return changes[name] && changes[name].currentValue !== changes[name].previousValue;
+ }
+
+ private initElements() {
+ // initElements is also called when changes dictate a new Foundation initialization,
+ // in which case we create new child elements:
+ if (this.trackCntr) {
+ this._rndr.removeChild(this._root.nativeElement, this.trackCntr);
+ this._rndr.removeChild(this._root.nativeElement, this._elmThumbCntr);
+ }
+ this.trackCntr = this.addElement(this._root.nativeElement, 'div', ['mdc-slider__track-container']);
+ this._elmTrack = this.addElement(this.trackCntr!, 'div', ['mdc-slider__track']);
+ if (this._discrete && this._markers)
+ this._elmTrackMarkerCntr = this.addElement(this.trackCntr!, 'div', ['mdc-slider__track-marker-container']);
+ else
+ this._elmTrackMarkerCntr = null;
+ this._elmThumbCntr = this.addElement(this._root.nativeElement, 'div', ['mdc-slider__thumb-container']);
+ if (this._discrete) {
+ this._elmSliderPin = this.addElement(this._elmThumbCntr!, 'div', ['mdc-slider__pin']);
+ this._elmValueMarker = this.addElement(this._elmSliderPin!, 'div', ['mdc-slider__pin-value-marker']);
+ } else {
+ this._elmSliderPin = null;
+ this._elmValueMarker = null;
+ }
+ const svg = this._rndr.createElement('svg', 'svg');
+ this._rndr.addClass(svg, 'mdc-slider__thumb');
+ this._rndr.setAttribute(svg, 'width', '21');
+ this._rndr.setAttribute(svg, 'height', '21');
+ this._rndr.appendChild(this._elmThumbCntr, svg);
+ const circle = this._rndr.createElement('circle', 'svg');
+ this._rndr.setAttribute(circle, 'cx', '10.5');
+ this._rndr.setAttribute(circle, 'cy', '10.5');
+ this._rndr.setAttribute(circle, 'r', '7.875');
+ this._rndr.appendChild(svg, circle);
+ this.addElement(this._elmThumbCntr!, 'div', ['mdc-slider__focus-ring']);
+ }
+
+ private addElement(parent: HTMLElement, element: string, classNames: string[]) {
+ let child = this._rndr.createElement(element);
+ classNames.forEach(name => {
+ this._rndr.addClass(child, name);
+ });
+ this._rndr.appendChild(parent, child);
+ return child;
+ }
+
+ private initDefaultAttributes() {
+ if (this._reinitTabIndex)
+ // value was set the first time we initialized the foundation,
+ // so it should also be set when we reinitialize evrything:
+ this._root.nativeElement.tabIndex = this._reinitTabIndex;
+ else if (!this._root.nativeElement.hasAttribute('tabindex')) {
+ // unless overridden by another tabIndex, we want sliders to
+ // participate in tabbing (the foundation will remove the tabIndex
+ // when the slider is disabled, reset to the initial value when enabled again):
+ this._root.nativeElement.tabIndex = 0;
+ this._reinitTabIndex = 0;
+ } else {
+ this._reinitTabIndex = this._root.nativeElement.tabIndex;
+ }
+ }
+
+ private updateValues(changes: SimpleChanges) {
+ if (this._discrete && this._step < 1) {
+ // See https://github.com/material-components/material-components-web/issues/1426
+ // mdc-slider doesn't allow a discrete step value < 1 currently:
+ this._step = 1;
+ Promise.resolve().then(() => {this.stepValueChange.emit(this._step); });
+ } else if (this._step < 0) {
+ this._step = 0;
+ Promise.resolve().then(() => {this.stepValueChange.emit(this._step); });
+ }
+ if (this._min > this._max) {
+ if (this.isChanged('maxValue', changes)) {
+ this._min = this._max;
+ Promise.resolve().then(() => {this.minValueChange.emit(this._min); });
+ } else {
+ this._max = this._min;
+ Promise.resolve().then(() => {this.maxValueChange.emit(this._max); });
+ }
+ }
+ let currValue = this.asNumber(changes['value'] ? changes['value'].currentValue : this._value);
+ if (this._value < this._min)
+ this._value = this._min;
+ if (this._value > this._max)
+ this._value = this._max;
+ // find an order in which the changed values will be accepted by the foundation
+ // (since the foundation will throw errors for min > max and other conditions):
+ if (this._min < this.foundation!.getMax()) {
+ this.foundation!.setMin(this._min);
+ this.foundation!.setMax(this._max);
+ } else {
+ this.foundation!.setMax(this._max);
+ this.foundation!.setMin(this._min);
+ }
+ this.foundation!.setStep(this._step);
+ if (this.foundation!.isDisabled() !== this._disabled) {
+ // without this check, MDCFoundation may remove the tabIndex incorrectly,
+ // preventing the slider from getting focus on keyboard commands:
+ this.foundation!.setDisabled(this._disabled);
+ }
+ this.foundation!.setValue(this._value);
+ // value may have changed during setValue(), due to step settings:
+ this._value = this.asNumber(this.foundation!.getValue());
+ // compare with '!=' as null and undefined are considered the same (for initialisation sake):
+ if (currValue !== this._value)
+ Promise.resolve().then(() => {this.notifyValueChanged(); });
+ }
+
+ private updateLayout() {
+ let newWidth = this.mdcAdapter.computeBoundingRect().width;
+ if (newWidth !== this._lastWidth) {
+ this._lastWidth = newWidth;
+ this.foundation!.layout();
+ }
+ }
+
+ private notifyValueChanged() {
+ this.valueChange.emit(this._value);
+ this._onChange(this._value);
+ }
+
+ /** @internal */
+ registerOnChange(onChange: (value: any) => void) {
+ this._onChange = onChange;
+ }
+
+ /** @internal */
+ registerOnTouched(onTouched: () => any) {
+ this._onTouched = onTouched;
+ }
+
+ /**
+ * Make the slider discrete. Note from the wrapped mdc-slider
+ * component:
+ * If a slider contains a step value it does not mean that the slider is a "discrete" slider.
+ * "Discrete slider" is a UX treatment, while having a step value is behavioral.
+ */
+ @Input() @HostBinding('class.mdc-slider--discrete')
+ get discrete() {
+ return this._discrete;
+ }
+
+ set discrete(value: boolean) {
+ this._discrete = asBoolean(value);
+ }
+
+ static ngAcceptInputType_discrete: boolean | '';
+
+ /**
+ * Property to enable/disable the display of track markers. Display markers
+ * are only supported for discrete sliders. Thus they are only shown when the values
+ * of both markers and discrete equal true.
+ */
+ @Input() @HostBinding('class.mdc-slider--display-markers')
+ get markers() {
+ return this._markers;
+ }
+
+ set markers(value: boolean) {
+ this._markers = asBoolean(value);
+ }
+
+ static ngAcceptInputType_markers: boolean | '';
+
+ /**
+ * The current value of the slider.
+ */
+ @Input() @HostBinding('attr.aria-valuenow')
+ get value() {
+ return this._value;
+ }
+
+ set value(value: number) {
+ this._value = this.asNumber(value);
+ }
+
+ static ngAcceptInputType_value: string | number;
+
+ /**
+ * The minumum allowed value of the slider.
+ */
+ @Input() @HostBinding('attr.aria-valuemin')
+ get minValue() {
+ return this._min;
+ }
+
+ set minValue(value: number) {
+ this._min = this.asNumber(value);
+ }
+
+ static ngAcceptInputType_minValue: string | number;
+
+ /**
+ * The maximum allowed value of the slider.
+ */
+ @Input() @HostBinding('attr.aria-valuemax')
+ get maxValue() {
+ return this._max;
+ }
+
+ set maxValue(value: number) {
+ this._max = this.asNumber(value);
+ }
+
+ static ngAcceptInputType_maxValue: string | number;
+
+ /**
+ * Set the step value (or set to 0 for no step value).
+ * The step value can be a floating point value >= 0.
+ * The slider will quantize all values to match the step value, except for the minimum and
+ * maximum, which can always be set.
+ * Discrete sliders are required to have a step value other than 0.
+ * Note from the wrapped mdc-slider
component:
+ * If a slider contains a step value it does not mean that the slider is a "discrete" slider.
+ * "Discrete slider" is a UX treatment, while having a step value is behavioral.
+ */
+ @Input()
+ get stepValue() {
+ return this._step;
+ }
+
+ set stepValue(value: number) {
+ this._step = this.asNumber(value);
+ }
+
+ static ngAcceptInputType_stepValue: string | number;
+
+ /**
+ * A property to disable the slider.
+ */
+ @Input() @HostBinding('attr.aria-disabled')
+ get disabled() {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = asBoolean(value);
+ }
+
+ static ngAcceptInputType_disabled: boolean | '';
+
+ /** @internal */
+ @HostListener('blur') _onBlur() {
+ this._onTouched();
+ }
+
+ /** @internal */
+ asNumber(value: number | string | null): number {
+ if (value == null)
+ return 0;
+ let result = +value;
+ if (isNaN(result))
+ return 0;
+ return result;
+ }
+}
+
+/**
+ * Directive for adding Angular Forms (ControlValueAccessor
) behavior to an
+ * MdcSliderDirective
. Allows the use of the Angular Forms API with
+ * icon toggles, e.g. binding to [(ngModel)]
, form validation, etc.
+ */
+@Directive({
+ selector: '[mdcSlider][formControlName],[mdcSlider][formControl],[mdcSlider][ngModel]',
+ providers: [
+ {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MdcFormsSliderDirective), multi: true}
+ ]
+})
+export class MdcFormsSliderDirective implements ControlValueAccessor {
+ constructor(@Self() private mdcSlider: MdcSliderDirective) {
+ }
+
+ /** @docs-private */
+ writeValue(obj: any) {
+ let change = new SimpleChange(this.mdcSlider.value, this.mdcSlider.asNumber(obj), false);
+ this.mdcSlider.value = obj;
+ this.mdcSlider._onChanges({value: change});
+ }
+
+ /** @docs-private */
+ registerOnChange(onChange: (value: any) => void) {
+ this.mdcSlider.registerOnChange(onChange);
+ }
+
+ /** @docs-private */
+ registerOnTouched(onTouched: () => any) {
+ this.mdcSlider.registerOnTouched(onTouched);
+ }
+
+ /** @docs-private */
+ setDisabledState(disabled: boolean) {
+ this.mdcSlider.disabled = disabled;
+ }
+}
+
+export const SLIDER_DIRECTIVES = [
+ MdcSliderDirective, MdcFormsSliderDirective
+];
diff --git a/bundle/src/components/snackbar/mdc.snackbar.adapter.ts b/bundle/src/components/snackbar/mdc.snackbar.adapter.ts
deleted file mode 100644
index 0c65a2a..0000000
--- a/bundle/src/components/snackbar/mdc.snackbar.adapter.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/** @docs-private */
-export interface MdcSnackbarAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- setAriaHidden: () => void;
- unsetAriaHidden: () => void;
- setActionAriaHidden: () => void;
- unsetActionAriaHidden: () => void;
- setActionText: (actionText: string) => void;
- setMessageText: (message: string) => void;
- setFocus: () => void;
- visibilityIsHidden: () => boolean;
- registerCapturedBlurHandler: (handler: EventListener) => void;
- deregisterCapturedBlurHandler: (handler: EventListener) => void;
- registerVisibilityChangeHandler: (handler: EventListener) => void;
- deregisterVisibilityChangeHandler: (handler: EventListener) => void;
- registerCapturedInteractionHandler: (evtType: string, handler: EventListener) => void;
- deregisterCapturedInteractionHandler: (evtType: string, handler: EventListener) => void;
- registerActionClickHandler: (handler: EventListener) => void;
- deregisterActionClickHandler: (handler: EventListener) => void;
- registerTransitionEndHandler: (handler: EventListener) => void;
- deregisterTransitionEndHandler: (handler: EventListener) => void;
- notifyShow: () => void;
- notifyHide: () => void;
-}
diff --git a/bundle/src/components/snackbar/mdc.snackbar.message.ts b/bundle/src/components/snackbar/mdc.snackbar.message.ts
index 9c009be..dc38ca9 100644
--- a/bundle/src/components/snackbar/mdc.snackbar.message.ts
+++ b/bundle/src/components/snackbar/mdc.snackbar.message.ts
@@ -11,15 +11,13 @@ export interface MdcSnackbarMessage {
*/
actionText?: string,
/**
- * Whether to show the snackbar with space for multiple lines of text (optional, default is false).
+ * Action buttons with long texts should be positioned below the label instead of alongside it.
+ * Set the stacked option to true to accomplish this.
*/
- multiline?: boolean,
+ stacked?: boolean,
/**
- * Whether to show the action below the multiple lines of text (optional, only applies when multiline is true).
+ * The amount of time in milliseconds to show the snackbar (optional, default is 5000ms).
+ * Value must be between 4000 and 10000, or -1 to disable the timeout completely.
*/
- actionOnBottom?: boolean,
- /**
- * The amount of time in milliseconds to show the snackbar (optional, default is 2750ms).
- */
- timeout?: number
+ timeout?: number,
}
diff --git a/bundle/src/components/snackbar/mdc.snackbar.service.spec.ts b/bundle/src/components/snackbar/mdc.snackbar.service.spec.ts
new file mode 100644
index 0000000..63589aa
--- /dev/null
+++ b/bundle/src/components/snackbar/mdc.snackbar.service.spec.ts
@@ -0,0 +1,153 @@
+import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { MdcSnackbarService } from './mdc.snackbar.service';
+import { numbers } from '@material/snackbar/constants';
+import { simulateKey } from '../../testutils/page.test';
+
+const template = `Testing Snackbar
`;
+
+describe('MdcSnackbarService', () => {
+ let service: MdcSnackbarService = null;
+ @Component({
+ template: template
+ })
+ class TestComponent {
+ constructor(public snackbar: MdcSnackbarService) {}
+ }
+
+ function setup(testComponentType: any = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [testComponentType]
+ }).createComponent(testComponentType);
+ fixture.detectChanges();
+ service = fixture.debugElement.injector.get(MdcSnackbarService);
+ return { fixture, service };
+ }
+
+ afterEach(() => {
+ if (service) {
+ service.onDestroy();
+ service = null;
+ }
+ });
+
+ it('not initialized before it is used', fakeAsync(() => {
+ const { service } = setup(TestComponent);
+ // reading the proprties does not initialize anything yet:
+ expect(service.leading).toBe(false);
+ expect(service.closeOnEscape).toBe(true);
+ expect(document.querySelector('.mdc-snackbar')).toBeNull();
+ // setting the leading property forces initialization of elements and foundation:
+ service.leading = true;
+ expect(document.querySelector('.mdc-snackbar')).not.toBeNull();
+ // check the new values of properties:
+ expect(service.leading).toBe(true);
+ expect(service.closeOnEscape).toBe(true);
+ }));
+
+ it('should show and queue messages', fakeAsync(() => {
+ const { service } = setup(TestComponent);
+ expect(document.querySelector('.mdc-snackbar__label')).toBeNull();
+
+ // show first message:
+ const ref1 = service.show({
+ message: 'Hello my old friend'
+ });
+ tick(1);
+ // queue next message:
+ const ref2 = service.show({
+ message: 'People talking without speaking'
+ });
+ tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS);
+ tick(numbers.ARIA_LIVE_DELAY_MS);
+ expect(document.querySelector('.mdc-snackbar__label').textContent).toBe('Hello my old friend');
+ tick(numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS - numbers.ARIA_LIVE_DELAY_MS);
+ tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS);
+ expect(document.querySelector('.mdc-snackbar').classList).toContain('mdc-snackbar--closing');
+ waitForNotClass('mdc-snackbar--closing');
+ expect(document.querySelector('.mdc-snackbar').classList).toContain('mdc-snackbar--opening');
+ tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS + numbers.ARIA_LIVE_DELAY_MS);
+ expect(document.querySelector('.mdc-snackbar__label').textContent).toBe('People talking without speaking');
+ flush();
+ }));
+
+ it('should send afterOpened, and afterClosed events', fakeAsync(() => {
+ const { service } = setup(TestComponent);
+ expect(document.querySelector('.mdc-snackbar__label')).toBeNull();
+ const ref = service.show({
+ message: 'Hello my old friend'
+ });
+ let events = [];
+ ref.afterOpened().subscribe(() => events.push('afterOpened'));
+ ref.afterClosed().subscribe(reason => events.push('afterClosed#' + reason));
+ ref.action().subscribe(() => events.push('action'));
+ tick(1);
+
+ expect(events).toEqual([]);
+ tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS);
+ waitForNotClass('mdc-snackbar--opening');
+ expect(events).toEqual(['afterOpened']);
+ tick(numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS);
+ tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS);
+ waitForNotClass('mdc-snackbar--closing');
+ expect(events).toEqual(['afterOpened', 'afterClosed#dismiss']);
+ flush();
+ }));
+
+ it('action click should trigger event and close snackbar', fakeAsync(() => {
+ const { service } = setup(TestComponent);
+ expect(document.querySelector('.mdc-snackbar__label')).toBeNull();
+ const ref = service.show({
+ message: 'Hello my old friend'
+ });
+ let events = [];
+ ref.afterOpened().subscribe(() => events.push('afterOpened'));
+ ref.afterClosed().subscribe(reason => events.push('afterClosed#' + reason));
+ ref.action().subscribe(() => events.push('action'));
+ tick(1);
+
+ expect(events).toEqual([]);
+ tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS);
+ waitForNotClass('mdc-snackbar--opening');
+ (document.querySelector('.mdc-snackbar__action')).click();
+ expect(events).toEqual(['afterOpened', 'action']);
+ tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS);
+ waitForNotClass('mdc-snackbar--closing');
+ expect(events).toEqual(['afterOpened', 'action', 'afterClosed#action']);
+ flush();
+ }));
+
+ it('escape button only closes when closeOnEscape is true (default)', fakeAsync(() => {
+ const { service } = setup(TestComponent);
+ expect(document.querySelector('.mdc-snackbar__label')).toBeNull();
+
+ service.show({message: 'Hello my old friend'});
+ const snackbar = document.querySelector('.mdc-snackbar');
+ pressEscapeAfterOpened(snackbar);
+ expect(snackbar.className).not.toMatch(/.*open.*/); // closed
+ flush();
+
+ // when closeOnEscape == false, the snackbar should not be closed:
+ service.closeOnEscape = false;
+ service.show({message: 'Hello my old friend'});
+ pressEscapeAfterOpened(snackbar);
+ expect(snackbar.className).toMatch(/.*open.*/); // still open!
+ flush();
+
+ }));
+
+ function pressEscapeAfterOpened(snackbar) {
+ tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS);
+ waitForNotClass('mdc-snackbar--opening');
+ simulateKey(snackbar, 'Escape');
+ tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS);
+ waitForNotClass('mdc-snackbar--closing', 20);
+ }
+
+ function waitForNotClass(clazz: string, ms = 20) {
+ // this should typically take less than 20ms, the rquestAnimationFrame time that we haven't
+ // ticked yet.
+ for (let i = 0; i != ms && document.querySelector('.mdc-snackbar').classList.contains(clazz); ++i)
+ tick(1);
+ }
+});
diff --git a/bundle/src/components/snackbar/mdc.snackbar.service.ts b/bundle/src/components/snackbar/mdc.snackbar.service.ts
index b0fc8d5..c4fc3b4 100644
--- a/bundle/src/components/snackbar/mdc.snackbar.service.ts
+++ b/bundle/src/components/snackbar/mdc.snackbar.service.ts
@@ -1,224 +1,241 @@
-import { Injectable, Optional, Renderer2, SkipSelf } from '@angular/core';
-import { getCorrectEventName } from '@material/animation';
-import { MDCSnackbar, MDCSnackbarFoundation } from '@material/snackbar';
-import { Observable, Subject } from 'rxjs';
-import { filter, take } from 'rxjs/operators';
-import { MdcSnackbarAdapter } from './mdc.snackbar.adapter';
-import { MdcSnackbarMessage } from './mdc.snackbar.message';
-
-const CLASS_ACTIVE = 'mdc-snackbar--active';
-const CLASS_ALIGN_START = 'mdc-snackbar--align-start';
-
-/**
- * This class provides information about a posted snackbar message.
- * It can also be used to subscribe to action clicks.
- */
-export class MdcSnackbarRef {
- constructor(
- private _action: Subject,
- private _show: Subject,
- private _hide: Subject
- ) {}
-
- /**
- * Subscribe to this observable to be informed when a user clicks the action
- * for the shown snackbar. Note that the observable will complete when the snackbar
- * disappears from screen, so there is no need to unsubscribe.
- */
- action(): Observable {
- return this._action.asObservable();
- }
-
- /**
- * Subscribe to this observable to be informed when the message is displayed.
- * Note that the observable will complete when the snackbar disappears from screen,
- * so there is no need to unsubscribe.
- */
- afterShow(): Observable {
- return this._show.asObservable();
- }
-
- /**
- * Subscribe to this observable to be informed when the message disappears.
- * Note that the observable will complete immediately afterwards, so there is
- * no need to unsubscribe.
- */
- afterHide(): Observable {
- return this._hide.asObservable();
- }
-}
-
-/**
- * A service for showing spec-aligned material design snackbar/toast messages.
- */
-@Injectable({
- providedIn: 'root'
-})
-export class MdcSnackbarService {
- private snackbar: MDCSnackbar = null;
- private root: HTMLElement = null;
- private isActive = false;
- private postedMessages = 0;
- private lastActivated = -1;
- private lastDismissed = -1;
-
- private openMessage: Subject = new Subject();
- private closeMessage: Subject = new Subject();
-
- constructor() {
- }
-
- private initHtml() {
- if (!this.snackbar) {
- this.root = document.createElement('div');
- this.root.classList.add('mdc-snackbar');
- this.root.setAttribute('aria-live', 'assertive');
- this.root.setAttribute('aria-atomic', 'true');
- this.root.setAttribute('aria-hidden', 'true');
- let snackbarText = document.createElement('div');
- snackbarText.classList.add('mdc-snackbar__text');
- this.root.appendChild(snackbarText);
- let snackbarAction = document.createElement('div');
- snackbarAction.classList.add('mdc-snackbar__action-wrapper');
- this.root.appendChild(snackbarAction);
- let snackbarActionButton = document.createElement('button');
- snackbarActionButton.classList.add('mdc-snackbar__action-button');
- snackbarActionButton.setAttribute('type', 'button');
- snackbarAction.appendChild(snackbarActionButton);
- document.body.appendChild(this.root);
- this.snackbar = new MDCSnackbar(this.root, this.getFoundation(this.root));
- }
- }
-
- private getFoundation(root: HTMLElement): MDCSnackbarFoundation {
- const textEl = root.querySelector('.mdc-snackbar__text');
- const buttonEl = root.querySelector('.mdc-snackbar__action-button');
- const adapter: MdcSnackbarAdapter = {
- addClass: (className) => { root.classList.add(className); },
- removeClass: (className) => { root.classList.remove(className); },
- setAriaHidden: () => root.setAttribute('aria-hidden', 'true'),
- unsetAriaHidden: () => root.removeAttribute('aria-hidden'),
- setActionAriaHidden: () => buttonEl.setAttribute('aria-hidden', 'true'),
- unsetActionAriaHidden: () => buttonEl.removeAttribute('aria-hidden'),
- setActionText: (text) => { buttonEl.textContent = text; },
- setMessageText: (text) => { textEl.textContent = text; },
- setFocus: () => buttonEl.focus(),
- visibilityIsHidden: () => document.hidden,
- registerCapturedBlurHandler: (handler) => buttonEl.addEventListener('blur', handler, true),
- deregisterCapturedBlurHandler: (handler) => buttonEl.removeEventListener('blur', handler, true),
- registerVisibilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler),
- deregisterVisibilityChangeHandler: (handler) => document.removeEventListener('visibilitychange', handler),
- registerCapturedInteractionHandler: (evt, handler) => document.body.addEventListener(evt, handler, true),
- deregisterCapturedInteractionHandler: (evt, handler) => document.body.removeEventListener(evt, handler, true),
- registerActionClickHandler: (handler) => buttonEl.addEventListener('click', handler),
- deregisterActionClickHandler: (handler) => buttonEl.removeEventListener('click', handler),
- registerTransitionEndHandler: (handler) => root.addEventListener(getCorrectEventName(window, 'transitionend'), handler),
- deregisterTransitionEndHandler: (handler) => root.removeEventListener(getCorrectEventName(window, 'transitionend'), handler),
- notifyShow: () => { this.activateNext(); },
- notifyHide: () => { this.deactivateLast(); }
- }
- return new MDCSnackbarFoundation(adapter);
- }
-
- private activateNext() {
- while (this.lastDismissed < this.lastActivated)
- // since this activates a new message, all messages before will logically be closed:
- this.closeMessage.next(++this.lastDismissed);
- this.openMessage.next(++this.lastActivated);
- this.isActive = true;
- }
-
- private deactivateLast() {
- if (this.isActive) {
- ++this.lastDismissed;
- this.isActive = false;
- this.closeMessage.next(this.lastDismissed);
- }
- }
-
- /**
- * Show a snackbar/toast message. If a snackbar message is already showing, the new
- * message will be queued to show after earlier message have been shown.
- * The returned MdcSnackbarRef
provides methods to subscribe to action clicks.
- *
- * @param message Queue a snackbar message to show.
- */
- show(message: MdcSnackbarMessage): MdcSnackbarRef {
- // make sure data passes precondition checks in foundation,
- // or our counters will not be right after snackbar.show throws exception:
- if (!message)
- throw new Error('snackbar message called with no data');
- if (!message.message)
- throw new Error('snackbar message is missing the actual message text');
-
- this.initHtml();
- let messageNr = this.postedMessages++;
- let data: any = {
- message: message.message,
- actionText: message.actionText,
- multiline: message.multiline,
- actionOnBottom: message.actionOnBottom,
- timeout: message.timeout
- };
-
- // provide a means to subscribe to an action click:
- let action = new Subject();
- let show = new Subject();
- let hide = new Subject();
- if (message.actionText)
- data.actionHandler = function() { action.next(); };
-
- // manage the show subscription
- this.openMessage.asObservable().pipe(
- filter(nr => nr === messageNr),
- take(1)
- ).subscribe(nr => { show.next(); });
- // manage the hide subscription, and close complete all observables when the
- // message is removed:
- this.closeMessage.asObservable().pipe(
- filter(nr => nr === messageNr),
- take(1)
- ).subscribe(nr => {
- hide.next();
- show.complete();
- hide.complete();
- action.complete();
- });
-
- // show the actual snackbar, using setTimeout to give callers
- // a chance to subscribe to all events:
- setTimeout(() => {this.snackbar.show(data); });
-
- return new MdcSnackbarRef(action, show, hide);
- }
-
- /**
- * Set this property to true to show snackbars start-aligned instead of center-aligned. Desktop and tablet only.
- */
- get startAligned(): boolean {
- return this.snackbar ? this.root.classList.contains(CLASS_ALIGN_START) : false;
- }
-
- set startAligned(value: boolean) {
- this.initHtml();
- if (value)
- this.root.classList.add(CLASS_ALIGN_START);
- else
- this.root.classList.remove(CLASS_ALIGN_START);
- }
-
- /**
- * By default the snackbar will be dimissed when the user presses the action button.
- * If you want the snackbar to remain visible until the timeout is reached (regardless of
- * whether the user pressed the action button or not) you can set the dismissesOnAction
- * property to false.
- */
- get dismissesOnAction(): boolean {
- return this.snackbar ? this.snackbar.dismissesOnAction : true;
- }
-
- set dismissesOnAction(value: boolean) {
- this.initHtml();
- this.snackbar.dismissesOnAction = value;
- }
-}
+import { DOCUMENT } from '@angular/common';
+import { Inject, Injectable } from '@angular/core';
+import { MDCSnackbarAdapter, MDCSnackbarFoundation, numbers } from '@material/snackbar';
+import { util } from '@material/snackbar';
+import { Observable, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { MdcSnackbarMessage } from './mdc.snackbar.message';
+
+const CLASS_LEADING = 'mdc-snackbar--leading';
+const CLASS_STACKED = 'mdc-snackbar--stacked';
+
+
+/**
+ * This class provides information about a posted snackbar message.
+ * It can also be used to subscribe to action clicks.
+ */
+export abstract class MdcSnackbarRef {
+ /**
+ * Subscribe to this observable to be informed when a user clicks the action
+ * for the shown snackbar. Note that the observable will complete when the snackbar
+ * disappears from screen, so there is no need to unsubscribe.
+ */
+ abstract action(): Observable;
+
+ /**
+ * Subscribe to this observable to be informed when the message is displayed.
+ * Note that the observable will complete when the snackbar disappears from screen,
+ * so there is no need to unsubscribe.
+ */
+ abstract afterOpened(): Observable;
+
+ /**
+ * Subscribe to this observable to be informed when the message has disappeared.
+ * Note that the observable will complete immediately afterwards, so there is
+ * no need to unsubscribe.
+ * The observed value is the `reason` string that was provided for closing the snackbar.
+ */
+ abstract afterClosed(): Observable;
+}
+
+// internal representation of the snackbar
+class MdcSnackbarInfo extends MdcSnackbarRef {
+ /** @internal */
+ public _action: Subject = new Subject();
+ /** @internal */
+ public _opened: Subject = new Subject();
+ /** @internal */
+ public _closed: Subject = new Subject();
+
+ constructor(public message: MdcSnackbarMessage) {
+ super();
+ }
+
+ action(): Observable {
+ return this._action.asObservable();
+ }
+
+ afterOpened(): Observable {
+ return this._opened.asObservable();
+ }
+
+ afterClosed(): Observable {
+ return this._closed.asObservable();
+ }
+}
+
+/**
+ * A service for showing spec-aligned material design snackbar/toast messages.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class MdcSnackbarService {
+ private onDestroy$: Subject = new Subject();
+ private closed: Subject = new Subject();
+ private root: HTMLElement | null = null;
+ private label: HTMLElement | null = null;
+ private actionButton: HTMLElement | null = null;
+ private actionLabel: HTMLElement | null = null;
+ private adapter: MDCSnackbarAdapter = {
+ addClass: (name) => this.root!.classList.add(name),
+ announce: () => util.announce(this.label!, this.label!),
+ notifyClosed: (reason) => this.closed.next(reason),
+ notifyClosing: () => {},
+ notifyOpened: () => this.current?._opened.next(),
+ notifyOpening: () => {},
+ removeClass: (name) => this.root!.classList.remove(name)
+ };
+ private handleActionClick = (evt: MouseEvent) => {
+ try {
+ (this.queue.length > 0) && this.queue[0]._action.next();
+ } finally {
+ this.foundation!.handleActionButtonClick(evt);
+ }
+ };
+ private handleKeyDown = (evt: KeyboardEvent) => this.foundation!.handleKeyDown(evt);
+ private foundation: MDCSnackbarFoundation | null = null;
+ private queue: MdcSnackbarInfo[] = [];
+ private document: Document;
+
+ constructor(@Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document;
+ }
+
+ private init() {
+ if (!this.foundation) {
+ this.root = this.document.createElement('div');
+ this.root.classList.add('mdc-snackbar');
+ let surface = this.document.createElement('div');
+ surface.classList.add('mdc-snackbar__surface');
+ this.root.appendChild(surface);
+ this.label = this.document.createElement('div');
+ this.label.setAttribute('role', 'status');
+ this.label.setAttribute('aria-live', 'polite');
+ this.label.classList.add('mdc-snackbar__label');
+ surface.appendChild(this.label);
+ let actions = this.document.createElement('div');
+ actions.classList.add('mdc-snackbar__actions');
+ surface.appendChild(actions);
+ this.actionButton = this.document.createElement('button');
+ this.actionButton.classList.add('mdc-button');
+ this.actionButton.classList.add('mdc-snackbar__action');
+ this.actionButton.setAttribute('type', 'button');
+ actions.appendChild(this.actionButton);
+ let ripple = this.document.createElement('div');
+ ripple.classList.add('mdc-button__ripple');
+ this.actionButton.appendChild(ripple);
+ this.actionLabel = this.document.createElement('span');
+ this.actionLabel.classList.add('mdc-button__label');
+ this.actionButton.appendChild(this.actionLabel);
+ this.document.body.appendChild(this.root);
+ this.foundation = new MDCSnackbarFoundation(this.adapter);
+
+ this.actionButton.addEventListener('click', this.handleActionClick);
+ this.root.addEventListener('keydown', this.handleKeyDown);
+
+ this.closed.pipe(takeUntil(this.onDestroy$)).subscribe(reason => this.closeCurrent(reason));
+ }
+ }
+
+ /** @internal */
+ onDestroy() {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ if (this.foundation) {
+ this.actionButton!.removeEventListener('click', this.handleActionClick);
+ this.root!.removeEventListener('keydown', this.handleKeyDown);
+ this.foundation.destroy();
+ this.root!.parentElement!.removeChild(this.root!);
+ this.root = null;
+ this.label = null;
+ this.actionButton = null;
+ this.actionLabel = null;
+ }
+ }
+
+ /**
+ * Show a snackbar/toast message. If a snackbar message is already showing, the new
+ * message will be queued to show after earlier message have been shown.
+ * The returned `MdcSnackbarRef` provides methods to subscribe to opened, closed, and
+ * action click events.
+ *
+ * @param message Queue a snackbar message to show.
+ */
+ show(message: MdcSnackbarMessage): MdcSnackbarRef {
+ if (!message)
+ throw new Error('message parameter is not set in call to MdcSnackbarService.show');
+ this.init();
+ const ref = new MdcSnackbarInfo(message);
+ this.queue.push(ref);
+ if (this.queue.length === 1) {
+ // showing needs to be triggered after snackbarRef is returned to caller,
+ // so that caller can subscribe to `afterShow` before it is triggered:
+ Promise.resolve().then(() => {
+ this.showNext();
+ });
+ }
+ return ref;
+ }
+
+ private showNext() {
+ if (this.queue.length === 0)
+ return;
+ const info = this.queue[0];
+ this.label!.textContent = info.message.message || '';
+ this.actionLabel!.textContent = info.message.actionText || '';
+ if (info.message.stacked)
+ this.root!.classList.add(CLASS_STACKED);
+ else
+ this.root!.classList.remove(CLASS_STACKED);
+ try {
+ this.foundation!.setTimeoutMs(info.message.timeout || numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS);
+ } catch (error) {
+ console.warn(error.message);
+ this.foundation!.setTimeoutMs(numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS);
+ }
+ this.foundation!.open();
+ }
+
+ private closeCurrent(reason: string) {
+ const info = this.queue.shift();
+ info!._closed.next(reason);
+ info!._opened.complete();
+ info!._action.complete();
+ info!._closed.complete();
+ if (this.queue.length > 0)
+ this.showNext();
+ }
+
+ private get current() {
+ return this.queue.length > 0 ? this.queue[0] : null;
+ }
+
+ /**
+ * Set this property to true to show snackbars start-aligned instead of center-aligned. Desktop and tablet only.
+ */
+ get leading(): boolean {
+ return this.foundation ? this.root!.classList.contains(CLASS_LEADING) : false;
+ }
+
+ set leading(value: boolean) {
+ this.init();
+ if (value)
+ this.root!.classList.add(CLASS_LEADING);
+ else
+ this.root!.classList.remove(CLASS_LEADING);
+ }
+
+ /**
+ * By default the snackbar closes when the user presses ESC, while it's focused. Set this to
+ * false to not close the snackbar when the user presses ESC.
+ */
+ get closeOnEscape(): boolean {
+ return this.foundation ? this.foundation.getCloseOnEscape() : true;
+ }
+
+ set closeOnEscape(value: boolean) {
+ this.init();
+ this.foundation!.setCloseOnEscape(!!value);
+ }
+}
diff --git a/bundle/src/components/switch/mdc.switch.directive.spec.ts b/bundle/src/components/switch/mdc.switch.directive.spec.ts
new file mode 100644
index 0000000..d976c5a
--- /dev/null
+++ b/bundle/src/components/switch/mdc.switch.directive.spec.ts
@@ -0,0 +1,291 @@
+import { TestBed, fakeAsync, ComponentFixture, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { MdcSwitchDirective, MdcSwitchInputDirective, MdcSwitchThumbDirective } from './mdc.switch.directive';
+import { By } from '@angular/platform-browser';
+import { FormsModule } from '@angular/forms';
+
+describe('MdcSwitchDirective', () => {
+ it('should render the switch with correct styling and sub-elements', fakeAsync(() => {
+ const { switchElement } = setup();
+ expect(switchElement.classList).toContain('mdc-switch');
+ expect(switchElement.children.length).toBe(2);
+ expect(switchElement.children[0].classList).toContain('mdc-switch__track');
+ expect(switchElement.children[1].classList).toContain('mdc-switch__thumb-underlay');
+ const thumbUnderlay = switchElement.children[1];
+ expect(thumbUnderlay.children.length).toBe(2);
+ expect(thumbUnderlay.children[0].classList).toContain('mdc-switch__thumb');
+ expect(thumbUnderlay.children[1].classList).toContain('mdc-switch__native-control');
+ }));
+
+ it('checked can be set programmatically', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup();
+ expect(testComponent.checked).toBe(null);
+ expect(element.checked).toBe(false);
+ setAndCheck(fixture, 'yes', true);
+ setAndCheck(fixture, 1, true);
+ setAndCheck(fixture, true, true);
+ setAndCheck(fixture, 'false', false);
+ setAndCheck(fixture, '0', true);
+ setAndCheck(fixture, false, false);
+ setAndCheck(fixture, 0, true);
+ setAndCheck(fixture, null, false);
+ setAndCheck(fixture, '', true);
+ }));
+
+ it('checked can be set by user', fakeAsync(() => {
+ const { fixture, element } = setup();
+
+ expect(element.checked).toBe(false);
+ clickAndCheck(fixture, true, false);
+ clickAndCheck(fixture, false, false);
+ clickAndCheck(fixture, true, false);
+ }));
+
+ it('can be disabled', fakeAsync(() => {
+ const { fixture, testComponent, element, input } = setup();
+
+ testComponent.disabled = true;
+ fixture.detectChanges();
+ expect(element.disabled).toBe(true);
+ expect(input.disabled).toBe(true);
+ expect(testComponent.disabled).toBe(true);
+ const sw = fixture.debugElement.query(By.directive(MdcSwitchDirective)).injector.get(MdcSwitchDirective);
+ expect(sw['root'].nativeElement.classList).toContain('mdc-switch--disabled');
+
+ testComponent.disabled = false;
+ fixture.detectChanges();
+ expect(element.disabled).toBe(false);
+ expect(input.disabled).toBe(false);
+ expect(testComponent.disabled).toBe(false);
+ }));
+
+ it('native input can be changed dynamically', fakeAsync(() => {
+ const { fixture, testComponent } = setup(TestComponentDynamicInput);
+
+ let elements = fixture.nativeElement.querySelectorAll('.mdc-switch__native-control');
+ // when no input is present the mdcSwitch renders without an initialized foundation:
+ expect(elements.length).toBe(0);
+
+ let check = false;
+ for (let i = 0; i != 3; ++i) {
+ // render/include one of the inputs:
+ testComponent.input = i;
+ fixture.detectChanges();
+ // the input should be recognized, the foundation is (re)initialized,
+ // so we have a fully functional mdcSwitch now:
+ elements = fixture.nativeElement.querySelectorAll('.mdc-switch__native-control');
+ expect(elements.length).toBe(1);
+ expect(elements[0].classList).toContain('mdc-switch__native-control');
+ expect(elements[0].id).toBe(`i${i}`);
+ // the value of the native input is correctly synced with the testcomponent:
+ expect(elements[0].checked).toBe(check);
+ // change the value for the next iteration:
+ check = !check;
+ testComponent.checked = check;
+ fixture.detectChanges();
+ expect(elements[0].checked).toBe(check);
+ }
+
+ // removing input should also work:
+ testComponent.input = null;
+ fixture.detectChanges();
+ elements = fixture.nativeElement.querySelectorAll('.mdc-switch__native-control');
+ // when no input is present the mdcSwitch renders without an initialized foundation:
+ expect(elements.length).toBe(0);
+ expect(testComponent.checked).toBe(check);
+ }));
+
+ it('user interactions are registered in the absence of template bindings', fakeAsync(() => {
+ const { fixture, element, input } = setup(TestComponentNoBindings);
+
+ expect(element.checked).toBe(false);
+ expect(input.checked).toBe(false);
+ clickAndCheckNb(true);
+ clickAndCheckNb(false);
+ clickAndCheckNb(true);
+
+ function clickAndCheckNb(expected) {
+ element.click();
+ tick(); fixture.detectChanges(); flush();
+ expect(element.checked).toBe(expected);
+ expect(input.checked).toBe(expected);
+ }
+ }));
+
+ it('aria-checked should reflect state of switch', (() => {
+ const { fixture, testComponent, element } = setup();
+
+ expect(element.getAttribute('aria-checked')).toBe('false');
+ element.click(); // user change
+ expect(element.getAttribute('aria-checked')).toBe('true');
+ testComponent.checked = false; //programmatic change
+ fixture.detectChanges();
+ expect(element.getAttribute('aria-checked')).toBe('false');
+ }));
+
+ it('input should have role=switch attribute', (() => {
+ const { element } = setup();
+ expect(element.getAttribute('role')).toBe('switch');
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any, expected: boolean) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-switch__native-control');
+ const input = fixture.debugElement.query(By.directive(MdcSwitchInputDirective))?.injector.get(MdcSwitchInputDirective);
+ testComponent.checked = value;
+ fixture.detectChanges();
+ expect(element.checked).toBe(expected);
+ expect(input.checked).toBe(expected);
+ }
+
+ function clickAndCheck(fixture: ComponentFixture, expected: boolean, expectIndeterminate: any) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-switch__native-control');
+ const input = fixture.debugElement.query(By.directive(MdcSwitchInputDirective))?.injector.get(MdcSwitchInputDirective);
+ element.click();
+ tick(); fixture.detectChanges();
+ expect(element.checked).toBe(expected);
+ expect(input.checked).toBe(expected);
+ expect(testComponent.checked).toBe(expected);
+ }
+
+ @Component({
+ template: `
+
+ off/on
+ `
+ })
+ class TestComponent {
+ checked: any = null;
+ disabled: any = null;
+ onClick() {
+ this.checked = !this.checked;
+ }
+ }
+
+ @Component({
+ template: `
+
+ `
+ })
+ class TestComponentNoBindings {
+ }
+
+ @Component({
+ template: `
+
+ `
+ })
+ class TestComponentDynamicInput {
+ input: number = null;
+ checked: any = null;
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [MdcSwitchDirective, MdcSwitchInputDirective, MdcSwitchThumbDirective, compType]
+ }).createComponent(compType);
+ fixture.detectChanges();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const input = fixture.debugElement.query(By.directive(MdcSwitchInputDirective))?.injector.get(MdcSwitchInputDirective);
+ const element = fixture.nativeElement.querySelector('.mdc-switch__native-control');
+ const switchElement = fixture.nativeElement.querySelector('.mdc-switch');
+ return { fixture, testComponent, input, element, switchElement };
+ }
+});
+
+describe('MdcSwitchDirective with FormsModule', () => {
+ it('ngModel can be set programmatically', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup();
+ expect(testComponent.value).toBe(null);
+ expect(element.checked).toBe(false);
+
+ // Note that binding to 'ngModel' behaves slightly different from binding to 'checked'
+ // ngModel coerces values the javascript way: it does !!bindedValue
+ // checked coerces the string-safe way: value != null && `${value}` !== 'false'
+ setAndCheck(fixture, 'yes', true);
+ setAndCheck(fixture, false, false);
+ setAndCheck(fixture, 'false', true); // the way it works for ngModel...
+ setAndCheck(fixture, null, false);
+ setAndCheck(fixture, 1, true);
+ setAndCheck(fixture, 0, false);
+ setAndCheck(fixture, '0', true);
+ }));
+
+ it('ngModel can be changed by updating checked property', fakeAsync(() => {
+ const { fixture, testComponent, input } = setup();
+
+ input.checked = true;
+ fixture.detectChanges(); tick();
+ expect(testComponent.value).toBe(true);
+
+ input.checked = false;
+ fixture.detectChanges(); tick();
+ expect(testComponent.value).toBe(false);
+ }));
+
+ it('ngModel can be changed by user', fakeAsync(() => {
+ const { fixture, testComponent, element, input } = setup();
+
+ element.click();
+ tick(); fixture.detectChanges();
+ expect(element.checked).toBe(true);
+ expect(input.checked).toBe(true);
+ expect(testComponent.value).toBe(true);
+
+ element.click();
+ tick(); fixture.detectChanges();
+ expect(element.checked).toBe(false);
+ expect(input.checked).toBe(false);
+ expect(testComponent.value).toBe(false);
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any, expectedValue: boolean) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-switch__native-control');
+ const input = fixture.debugElement.query(By.directive(MdcSwitchInputDirective)).injector.get(MdcSwitchInputDirective);
+ testComponent.value = value;
+ fixture.detectChanges(); tick();
+ expect(input.checked).toBe(expectedValue);
+ expect(element.checked).toBe(expectedValue);
+ expect(testComponent.value).toBe(value);
+ }
+
+ @Component({
+ template: `
+
+
+
+ `
+ })
+ class TestComponent {
+ value: any = null;
+ }
+
+ function setup() {
+ const fixture = TestBed.configureTestingModule({
+ imports: [FormsModule],
+ declarations: [MdcSwitchInputDirective, MdcSwitchDirective, TestComponent]
+ }).createComponent(TestComponent);
+ fixture.detectChanges();
+ tick();
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const input = fixture.debugElement.query(By.directive(MdcSwitchInputDirective)).injector.get(MdcSwitchInputDirective);
+ const element = fixture.nativeElement.querySelector('.mdc-switch__native-control');
+ return { fixture, testComponent, input, element };
+ }
+});
diff --git a/bundle/src/components/switch/mdc.switch.directive.ts b/bundle/src/components/switch/mdc.switch.directive.ts
index 6b529f9..5a19570 100644
--- a/bundle/src/components/switch/mdc.switch.directive.ts
+++ b/bundle/src/components/switch/mdc.switch.directive.ts
@@ -1,83 +1,236 @@
-import { AfterContentInit, Component, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, HostListener,
- Input, Optional, Output, Provider, Renderer2, Self, ViewChild, ViewEncapsulation, forwardRef } from '@angular/core';
-import { NgControl } from '@angular/forms';
-import { AbstractMdcInput } from '../abstract/abstract.mdc.input';
-import { asBoolean } from '../../utils/value.utils';
-
-/**
- * Directive for the input element of an MdcSwitchDirective
.
- */
-@Directive({
- selector: 'input[mdcSwitchInput][type=checkbox]',
- providers: [{provide: AbstractMdcInput, useExisting: forwardRef(() => MdcSwitchInputDirective) }]
-})
-export class MdcSwitchInputDirective extends AbstractMdcInput {
- @HostBinding('class.mdc-switch__native-control') _cls = true;
- private _id: string;
- private _disabled = false;
-
- constructor(public _elm: ElementRef, @Optional() @Self() public _cntr: NgControl) {
- super();
- }
-
- /** @docs-private */
- @HostBinding()
- @Input() get id() {
- return this._id;
- }
-
- set id(value: string) {
- this._id = value;
- }
-
- /** @docs-private */
- @HostBinding()
- @Input() get disabled() {
- return this._cntr ? this._cntr.disabled : this._disabled;
- }
-
- set disabled(value: any) {
- this._disabled = asBoolean(value);
- }
-}
-
-/**
- * Directive for creating a Material Design switch component. The switch is driven by an
- * underlying native checkbox input, which must use the MdcSwitchInputDirective
- * directive.
- * The current implementation will add all other required DOM elements (such as the
- * background).
- * Future implementations will also support supplying (customized) background
- * elements.
- *
- * This directive can be used together with an mdcFormField
to
- * easily position switches and their labels, see
- * mdcFormField .
- */
-@Directive({
- selector: '[mdcSwitch]'
-})
-export class MdcSwitchDirective implements AfterContentInit {
- @HostBinding('class.mdc-switch') _cls = true;
- @ContentChild(MdcSwitchInputDirective) _input: MdcSwitchInputDirective;
-
- constructor(private rndr: Renderer2, private root: ElementRef) {
- }
-
- ngAfterContentInit() {
- this.addBackground();
- }
-
- private addBackground() {
- let knob = this.rndr.createElement('div');
- this.rndr.addClass(knob, 'mdc-switch__knob');
- let bg = this.rndr.createElement('div');
- this.rndr.addClass(bg, 'mdc-switch__background');
- this.rndr.appendChild(bg, knob);
- this.rndr.appendChild(this.root.nativeElement, bg);
- }
-
- @HostBinding('class.mdc-switch--disabled') get _disabled() {
- return this._input == null || this._input.disabled;
- }
-}
+import { Directive, ElementRef, HostBinding, Input, Optional, Renderer2, Self,
+ forwardRef, Output, EventEmitter, OnInit, OnDestroy, ContentChildren, QueryList, HostListener} from '@angular/core';
+import { NgControl } from '@angular/forms';
+import { MDCSwitchFoundation, MDCSwitchAdapter } from '@material/switch';
+import { AbstractMdcInput } from '../abstract/abstract.mdc.input';
+import { asBoolean } from '../../utils/value.utils';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+/**
+ * Directive for the native input element of an MdcSwitchDirective
.
+ */
+@Directive({
+ selector: 'input[mdcSwitchInput][type=checkbox]',
+ providers: [{provide: AbstractMdcInput, useExisting: forwardRef(() => MdcSwitchInputDirective) }]
+})
+export class MdcSwitchInputDirective extends AbstractMdcInput implements OnInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-switch__native-control') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.role') _role = 'switch';
+ private onDestroy$: Subject = new Subject();
+ /** @internal */
+ @Output() readonly _checkedChange: EventEmitter = new EventEmitter();
+ /** @internal */
+ @Output() readonly _disabledChange: EventEmitter = new EventEmitter();
+ /** @internal */
+ @Output() readonly _change: EventEmitter = new EventEmitter();
+ private _id: string | null = null;
+ private _disabled = false;
+ private _checked = false;
+
+ constructor(public _elm: ElementRef, @Optional() @Self() public _cntr: NgControl) {
+ super();
+ }
+
+ ngOnInit() {
+ this._cntr?.valueChanges!.pipe(takeUntil(this.onDestroy$)).subscribe((value) => {
+ this.updateValue(value, true);
+ });
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ }
+
+ /** @docs-private */
+ @HostBinding()
+ @Input() get id() {
+ return this._id;
+ }
+
+ set id(value: string | null) {
+ this._id = value;
+ }
+
+ /** @docs-private */
+ @HostBinding()
+ @Input() get disabled() {
+ return this._cntr ? !!this._cntr.disabled : this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ const newVal = asBoolean(value);
+ if (newVal != this._disabled) {
+ this._disabled = asBoolean(newVal);
+ this._disabledChange.emit(newVal);
+ }
+ }
+
+ static ngAcceptInputType_disabled: boolean | '';
+
+ /** @docs-private */
+ @HostBinding()
+ @Input() get checked(): boolean {
+ return this._checked;
+ }
+
+ set checked(value: boolean) {
+ this.updateValue(value, false);
+ }
+
+ static ngAcceptInputType_checked: boolean | '';
+
+ /** @internal */
+ @HostListener('change', ['$event']) _onChange(event: Event) {
+ // update checked value, but not via this.checked, so we bypass events being sent to:
+ // - _checkedChange -> foundation is already updated via _change
+ // - _cntr.control.setValue -> control is already updated through its own handling of user events
+ this._checked = this._elm.nativeElement.checked; // bypass
+ this._change.emit(event);
+ }
+
+ private updateValue(value: any, fromControl: boolean) {
+ // When the 'checked' property is the source of the change, we want to coerce boolean
+ // values using asBoolean, so that initializing with an attribute with no value works
+ // as expected.
+ // When the NgControl is the source of the change we don't want that. The value should
+ // be interpreted like NgControl/NgForms handles non-boolean values when binding.
+ const newVal = fromControl ? !!value : asBoolean(value);
+ if (newVal !== this._checked) {
+ this._checked = newVal;
+ this._checkedChange.emit(newVal);
+ }
+ if (!fromControl && this._cntr && newVal !== this._cntr.value) {
+ this._cntr.control!.setValue(newVal);
+ }
+ }
+}
+
+/**
+ * Directive for the mandatory thumb element of an `mdcSwitch`. See `mdcSwitch` for more
+ * information.
+ */
+@Directive({
+ selector: '[mdcSwitchThumb]'
+})
+export class MdcSwitchThumbDirective {
+ /** @internal */
+ @HostBinding('class.mdc-switch__thumb-underlay') readonly _cls = true;
+
+ constructor(private elm: ElementRef, private rndr: Renderer2) {
+ this.addThumb();
+ }
+
+ private addThumb() {
+ const thumb = this.rndr.createElement('div');
+ this.rndr.addClass(thumb, 'mdc-switch__thumb');
+ this.rndr.appendChild(this.elm.nativeElement, thumb);
+ }
+}
+
+/**
+ * Directive for creating a Material Design switch component. The switch is driven by an
+ * underlying native checkbox input, which must use the `mdcSwitchInput` directive. The
+ * `mdcSwitchInput` must be wrapped by an `mdcSwitchThumb`, which must be a direct child of this
+ * `mdcSwitch` directive.
+ *
+ * The current implementation will add all other required DOM elements (such as the
+ * switch-track). Future implementations will also support supplying (customized) elements
+ * for those.
+ *
+ * This directive can be used together with an mdcFormField
to
+ * easily position switches and their labels, see
+ * mdcFormField .
+ */
+@Directive({
+ selector: '[mdcSwitch]'
+})
+export class MdcSwitchDirective {
+ /** @internal */
+ @HostBinding('class.mdc-switch') readonly _cls = true;
+ private onDestroy$: Subject = new Subject();
+ private onInputChange$: Subject = new Subject();
+ /** @internal */
+ @ContentChildren(MdcSwitchInputDirective, {descendants: true}) _inputs?: QueryList;
+ private mdcAdapter: MDCSwitchAdapter = {
+ addClass: (className: string) => {
+ this.rndr.addClass(this.root.nativeElement, className);
+ },
+ removeClass: (className: string) => {
+ this.rndr.removeClass(this.root.nativeElement, className);
+ },
+ setNativeControlAttr: (attr: string, value: string) => this.rndr.setAttribute(this._input!._elm.nativeElement, attr, value),
+ setNativeControlChecked: () => undefined, // nothing to do, checking/unchecking is done directly on the input
+ setNativeControlDisabled: () => undefined // nothing to do, enabling/disabling is done directly on the input
+ };
+ private foundation: MDCSwitchFoundation | null = null;
+
+ constructor(private rndr: Renderer2, private root: ElementRef) {
+ this.addTrack();
+ }
+
+ ngAfterContentInit() {
+ if (this._input) {
+ this.initFoundation();
+ }
+ this._inputs!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
+ if (this.foundation)
+ this.foundation.destroy();
+ if (this._input)
+ this.initFoundation();
+ else
+ this.foundation = null;
+ this.subscribeInputChanges();
+ });
+ this.subscribeInputChanges();
+ }
+
+ ngOnDestroy() {
+ this.onInputChange$.next(); this.onInputChange$.complete();
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ if (this.foundation) {
+ this.foundation.destroy();
+ this.foundation = null;
+ }
+ }
+
+ private initFoundation() {
+ this.foundation = new MDCSwitchFoundation(this.mdcAdapter);
+ this.foundation.init();
+ // The foundation doesn't correctly set the aria-checked attribute and the checked/disabled styling
+ // on initialization. So let's help it to not forget that:
+ this.foundation.setChecked(this._input!.checked);
+ this.foundation.setDisabled(this._input!.disabled);
+ }
+
+ private addTrack() {
+ const track = this.rndr.createElement('div');
+ this.rndr.addClass(track, 'mdc-switch__track');
+ this.rndr.appendChild(this.root.nativeElement, track);
+ }
+
+
+ private subscribeInputChanges() {
+ this.onInputChange$.next();
+ this._input?._checkedChange.asObservable().pipe(takeUntil(this.onInputChange$)).subscribe(checked => this.foundation?.setChecked(checked));
+ this._input?._disabledChange.asObservable().pipe(takeUntil(this.onInputChange$)).subscribe(disabled => {
+ this.foundation?.setDisabled(disabled);
+ });
+ this._input?._change.asObservable().pipe(takeUntil(this.onInputChange$)).subscribe((event) => {
+ this.foundation?.handleChange(event);
+ });
+ }
+
+ private get _input() {
+ return this._inputs && this._inputs.length > 0 ? this._inputs.first : null;
+ }
+}
+
+export const SWITCH_DIRECTIVES = [
+ MdcSwitchInputDirective,
+ MdcSwitchThumbDirective,
+ MdcSwitchDirective
+];
diff --git a/bundle/src/components/tab/mdc.tab.bar.directive.spec.ts b/bundle/src/components/tab/mdc.tab.bar.directive.spec.ts
new file mode 100644
index 0000000..5f854c5
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.bar.directive.spec.ts
@@ -0,0 +1,182 @@
+import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { MdcTabBarDirective, TAB_BAR_DIRECTIVES } from './mdc.tab.bar.directive';
+import { TAB_SCROLLER_DIRECTIVES } from './mdc.tab.scroller.directive';
+import { TAB_DIRECTIVES } from './mdc.tab.directive';
+import { TAB_INDICATOR_DIRECTIVES } from './mdc.tab.indicator.directive';
+import { By } from '@angular/platform-browser';
+import { simulateKey } from '../../testutils/page.test';
+
+const template = `
+
+
+
+
+
+
+ {{tab.icon}}
+ {{tab.label}}
+
+
+
+
+
+
+
+
+
+`
+
+const templateDynamic = `
+
+
+
+
+
+
+ {{tab.icon}}
+ {{tab.label}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{tab.icon}}
+ {{tab.label}}
+
+
+
+
+
+
+
+
+
+`;
+
+describe('MdcTabScrollerDirective', () => {
+ abstract class AbstractTestComponent {
+ }
+
+ @Component({
+ template: template
+ })
+ class TestComponent extends AbstractTestComponent {
+ tabs = [
+ {icon: 'access_time', label: 'recents'},
+ {icon: 'near_me', label: 'nearby'},
+ {icon: 'favorite', label: 'favorites'}
+ ];
+ }
+
+ @Component({
+ template: templateDynamic
+ })
+ class DynamicTestComponent extends AbstractTestComponent {
+ scrollerA = true;
+ tabsA = [
+ {icon: 'access_time', label: 'recents'},
+ {icon: 'near_me', label: 'nearby'},
+ {icon: 'favorite', label: 'favorites'}
+ ];
+ tabsB = [
+ {icon: 'access_time', label: 'recents'},
+ {icon: 'favorite', label: 'favorites'}
+ ];
+ }
+
+ function setup(testComponentType: any = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...TAB_INDICATOR_DIRECTIVES, ...TAB_DIRECTIVES, ...TAB_SCROLLER_DIRECTIVES, ...TAB_BAR_DIRECTIVES, testComponentType]
+ }).createComponent(testComponentType);
+ fixture.detectChanges(); tick(100);
+ return { fixture };
+ }
+
+ it('should initialize with defaults', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const bar: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-bar');
+
+ expect(bar).toBeDefined();
+ expect(bar.getAttribute('role')).toBe('tablist');
+ }));
+
+ it('tabs can be activated by interaction', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, false, false]);
+
+ tabs[1].click(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true, false]);
+
+ tabs[2].click(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, false, true]);
+ }));
+
+ it('tabs can be focused with arrow keys', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ const ripples: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab__ripple')];
+ tabs[1].click(); tick(20); fixture.detectChanges();
+ expect(document.activeElement).toBe(tabs[1]);
+ simulateKey(tabs[1], 'ArrowRight');
+ tick(20); fixture.detectChanges(); // (styles are applied via requestAnimationFrame)
+ expect(document.activeElement).toBe(tabs[2]);
+ }));
+
+ it('foundation correctly reinitialized when scroller is changed', fakeAsync(() => {
+ const { fixture } = setup(DynamicTestComponent);
+
+ let tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ const testComponent = fixture.debugElement.injector.get(DynamicTestComponent);
+ const mdcTabBar = fixture.debugElement.query(By.directive(MdcTabBarDirective)).injector.get(MdcTabBarDirective);
+ const foundation = getFoundation(mdcTabBar);
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, false, false]);
+
+ tabs[1].click(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true, false]);
+ // foundation should not have changed:
+ expect(foundation).toBe(getFoundation(mdcTabBar));
+
+ testComponent.scrollerA = false; // switch scroller
+ fixture.detectChanges(); tick(100);
+ tabs = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ // tabs changed, no tab is active anymore:
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, false]);
+ // foundation should have been reconstructed:
+ expect(foundation).not.toBe(getFoundation(mdcTabBar));
+ }));
+
+ it('foundation correctly reinitialized when tabs are added', fakeAsync(() => {
+ const { fixture } = setup(DynamicTestComponent);
+
+ let tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ const testComponent = fixture.debugElement.injector.get(DynamicTestComponent);
+ const mdcTabBar = fixture.debugElement.query(By.directive(MdcTabBarDirective)).injector.get(MdcTabBarDirective);
+ const foundation = getFoundation(mdcTabBar);
+ tabs[1].click(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true, false]);
+
+ testComponent.tabsA.push( {icon: 'explore', label: 'explore'})
+ fixture.detectChanges(); tick(100);
+ tabs = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ // tabs changed, but second tab is still active:
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true, false, false]);
+ // foundation should have been reconstructed:
+ expect(foundation).not.toBe( mdcTabBar['_foundation']);
+ }));
+
+ function getFoundation(mdcTabBar: MdcTabBarDirective) {
+ const foundation = mdcTabBar['_foundation'];
+ expect(foundation.handleKeyDown).toBeDefined();
+ return foundation;
+ }
+});
diff --git a/bundle/src/components/tab/mdc.tab.bar.directive.ts b/bundle/src/components/tab/mdc.tab.bar.directive.ts
new file mode 100644
index 0000000..4207cc4
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.bar.directive.ts
@@ -0,0 +1,147 @@
+import { ContentChildren, EventEmitter, QueryList, Directive, ElementRef, HostBinding, Output, HostListener,
+ AfterContentInit, OnDestroy, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { Subject, Subscription } from 'rxjs';
+import { MDCTabBarFoundation, MDCTabBarAdapter } from '@material/tab-bar';
+import { AbstractMdcTabDirective, MdcTabChange } from './mdc.tab.directive';
+import { MdcTabScrollerDirective } from './mdc.tab.scroller.directive';
+import { takeUntil } from 'rxjs/operators';
+
+/**
+ * Directive for a tab bar. This directive must have an `mdcTabScroller` as only child.
+ */
+@Directive({
+ selector: '[mdcTabBar]'
+})
+export class MdcTabBarDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-tab-bar') readonly _cls = true;
+ private document: Document;
+ /** @internal */
+ @HostBinding('attr.role') _role = 'tablist';
+ private onDestroy$: Subject = new Subject();
+ private onTabsChange$: Subject = new Subject();
+ /** @internal */
+ @ContentChildren(MdcTabScrollerDirective) _scrollers?: QueryList;
+ /**
+ * Event emitted when the active tab changes.
+ */
+ @Output() readonly tabChange: EventEmitter = new EventEmitter();
+ private _adapter: MDCTabBarAdapter = {
+ scrollTo: (scrollX) => this._scroller!._foundation!.scrollTo(scrollX),
+ incrementScroll: (scrollXIncrement) => this._scroller!._foundation!.incrementScroll(scrollXIncrement),
+ getScrollPosition: () => this._scroller!._foundation!.getScrollPosition(),
+ getScrollContentWidth: () => this._scroller!._getScrollContentWidth(),
+ getOffsetWidth: () => (this._el.nativeElement as HTMLElement).offsetWidth,
+ isRTL: () => getComputedStyle(this._el.nativeElement).getPropertyValue('direction') === 'rtl',
+ setActiveTab: (index) => this._foundation!.activateTab(index),
+ activateTabAtIndex: (index, clientRect) => this._tabs!.toArray()[index]._activate(index, clientRect),
+ deactivateTabAtIndex: (index) => this._tabs!.toArray()[index]._deactivate(),
+ focusTabAtIndex: (index) => this._tabs!.toArray()[index]._focus(),
+ getTabIndicatorClientRectAtIndex: (index) => this._tabs!.toArray()[index]._computeIndicatorClientRect()!,
+ getTabDimensionsAtIndex: (index) => this._tabs!.toArray()[index]._computeDimensions()!,
+ getPreviousActiveTabIndex: () => this._tabs!.toArray().findIndex(e => e.isActive()),
+ getFocusedTabIndex: () => this._tabs!.map(t => t._root.nativeElement).indexOf(this.document.activeElement),
+ getIndexOfTabById: () => -1, // we're not using the id's, and nothing should call getIndexOfTabById
+ getTabListLength: () => this._tabs!.length,
+ notifyTabActivated: (tabIndex) => this.tabChange.emit({tab: this._tabs!.toArray()[tabIndex], tabIndex})
+ };
+ private _subscriptions: Subscription[] = [];
+ private _foundation: MDCTabBarFoundation | null = null;
+
+ constructor(public _el: ElementRef, @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document;
+ }
+
+ ngAfterContentInit() {
+ let scrollersObservable$ = this._scrollers!.changes.pipe(takeUntil(this.onDestroy$));
+ const tabChangeInit = () => {
+ if (this._tabs) {
+ this._tabs.changes.pipe(
+ takeUntil(scrollersObservable$), takeUntil(this.onDestroy$)
+ ).subscribe(() => {
+ this.onTabsChange$.next();
+ });
+ }
+ }
+ scrollersObservable$.subscribe(() => {
+ this.onTabsChange$.next();
+ tabChangeInit();
+ });
+ tabChangeInit();
+
+ this.onTabsChange$.pipe(
+ takeUntil(this.onDestroy$)
+ ).subscribe(() => {
+ this.destroyFoundation();
+ if (this._tabs)
+ this.initFoundation();
+ });
+ if (this._tabs)
+ this.initFoundation();
+ }
+
+ ngOnDestroy() {
+ this.onTabsChange$.complete();
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.destroyFoundation();
+ }
+
+ private initFoundation() {
+ this._foundation = new MDCTabBarFoundation(this._adapter);
+ this._foundation.init();
+ this._listenTabSelected();
+ }
+
+ private destroyFoundation() {
+ this._unlistenTabSelected();
+ let destroy = this._foundation != null;
+ if (destroy) {
+ this._foundation!.destroy();
+ }
+ this._foundation = null;
+ return destroy;
+ }
+
+ private _listenTabSelected() {
+ this._unlistenTabSelected();
+ this._subscriptions = new Array();
+ this._tabs?.forEach(tab => {
+ this._subscriptions!.push(tab.activationRequest$.subscribe(activated => {
+ if (activated)
+ this._setActive(tab);
+ }));
+ });
+ }
+
+ private _unlistenTabSelected() {
+ this._subscriptions.forEach(sub => sub.unsubscribe());
+ this._subscriptions = [];
+ }
+
+ private _setActive(tab: AbstractMdcTabDirective) {
+ if (this._foundation && this._tabs) {
+ let index = this._tabs.toArray().indexOf(tab);
+ // This is what foundation.handleTabInteraction would do, but more accessible, without
+ // the need for assigned tabIds:
+ if (index >= 0)
+ this._adapter.setActiveTab(index);
+ }
+ }
+
+ /** @internal */
+ @HostListener('keydown', ['$event']) _handleInteraction(event: KeyboardEvent) {
+ if (this._foundation)
+ this._foundation.handleKeyDown(event);
+ }
+
+ private get _scroller() {
+ return this._scrollers && this._scrollers.length > 0 ? this._scrollers.first : null;
+ }
+
+ private get _tabs() {
+ return this._scroller ? this._scroller._tabs : null;
+ }
+}
+
+export const TAB_BAR_DIRECTIVES = [MdcTabBarDirective];
diff --git a/bundle/src/components/tab/mdc.tab.directive.spec.ts b/bundle/src/components/tab/mdc.tab.directive.spec.ts
new file mode 100644
index 0000000..2900ec4
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.directive.spec.ts
@@ -0,0 +1,174 @@
+import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Component } from '@angular/core';
+import { TAB_DIRECTIVES, MdcTabDirective } from './mdc.tab.directive';
+import { TAB_INDICATOR_DIRECTIVES } from './mdc.tab.indicator.directive';
+import { hasRipple } from '../../testutils/page.test';
+
+const template = `
+
+
+ favorite
+ Favorites
+
+
+
+
+
+`;
+
+const templateIndicatorSpanning = `
+
+
+ favorite
+ Favorites
+
+
+
+
+
+`;
+
+describe('MdcTabDirective', () => {
+ abstract class AbstractTestComponent {
+ events: any[] = [];
+ active: boolean = null;
+ activate(event: any) {
+ this.events.push(event);
+ }
+ }
+
+ @Component({
+ template: template
+ })
+ class TestComponent extends AbstractTestComponent {
+ }
+
+ @Component({
+ template: templateIndicatorSpanning
+ })
+ class SpanningTestComponent extends AbstractTestComponent {
+ }
+
+ function setup(testComponentType: any = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...TAB_INDICATOR_DIRECTIVES, ...TAB_DIRECTIVES, testComponentType]
+ }).createComponent(testComponentType);
+ fixture.detectChanges();
+ return { fixture };
+ }
+
+ it('should initialize with defaults', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tab: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab');
+ expect(tab).toBeDefined();
+ expect(tab.classList).not.toContain('mdc-tab--active');
+ expect(tab.getAttribute('aria-selected')).toBe('false');
+ expect(tab.getAttribute('tabindex')).toBe('-1');
+ expect(fixture.nativeElement.querySelector('.mdc-tab__content').classList).toBeDefined();
+ expect(fixture.nativeElement.querySelector('.mdc-tab__icon').classList).toBeDefined();
+ expect(fixture.nativeElement.querySelector('.mdc-tab__text-label').classList).toBeDefined();
+ expect(fixture.nativeElement.querySelector('.mdc-tab-indicator').classList).toBeDefined();
+ expect(fixture.nativeElement.querySelector('.mdc-tab-indicator__content').classList).toBeDefined();
+ // ripple styling is on the ripple surface element:
+ expect(hasRipple(fixture.nativeElement.querySelector('.mdc-tab__ripple'))).toBe(true, 'the ripple element should be attached');
+ }));
+
+ it('tab can be activated and deactivated', (() => {
+ const { fixture } = setup(TestComponent);
+ validateActivation(fixture, TestComponent);
+ }));
+
+ it('indicator spanning tab activation and deactivation', (() => {
+ const { fixture } = setup(SpanningTestComponent);
+ validateActivation(fixture, SpanningTestComponent);
+ }));
+
+ it('click triggers activationRequest', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tab: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab');
+ const mdcTab = fixture.debugElement.query(By.directive(MdcTabDirective)).injector.get(MdcTabDirective);
+ const events = [];
+ const subscription = mdcTab.activationRequest$.subscribe(activation => events.push(activation));
+ try {
+ expect(events).toEqual([false]);
+ tab.click(); tick(); fixture.detectChanges();
+ expect(events).toEqual([false, true]);
+ } finally {
+ subscription.unsubscribe();
+ }
+ }));
+
+ it('active property triggers activationRequest', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tab: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab');
+ const mdcTab = fixture.debugElement.query(By.directive(MdcTabDirective)).injector.get(MdcTabDirective);
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const events = [];
+ const subscription = mdcTab.activationRequest$.subscribe(activation => events.push(activation));
+ try {
+ expect(events).toEqual([false]);
+
+ testComponent.active = true; tick(); fixture.detectChanges();
+ expect(events).toEqual([false, true]);
+
+ testComponent.active = false; tick(); fixture.detectChanges();
+ expect(events).toEqual([false, true, false]);
+
+ testComponent.active = true; tick(); fixture.detectChanges();
+ expect(events).toEqual([false, true, false, true]);
+
+ testComponent.active = true; tick(); fixture.detectChanges();
+ expect(events).toEqual([false, true, false, true]); // no value change => no new event
+ } finally {
+ subscription.unsubscribe();
+ }
+ }));
+
+ function validateActivation(fixture: ComponentFixture, testComponentType: any = TestComponent) {
+ const tab: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab');
+ const mdcTab = fixture.debugElement.query(By.directive(MdcTabDirective)).injector.get(MdcTabDirective);
+ const testComponent = fixture.debugElement.injector.get(testComponentType);
+ expect(testComponent.events).toEqual([]);
+
+ validateActive(tab, mdcTab, false);
+ expect(testComponent.events).toEqual([]);
+
+ mdcTab._activate(0); fixture.detectChanges();
+ validateActive(tab, mdcTab);
+ expect(testComponent.events).toEqual([{tab: mdcTab, tabIndex: 0}]);
+ testComponent.events = [];
+
+ // changing active property should not do anything (but send a message to the parent,
+ // so that it can deactivate the right tab and activate this one):
+ testComponent.active = false; fixture.detectChanges();
+ validateActive(tab, mdcTab);
+ expect(testComponent.events).toEqual([]);
+
+ mdcTab._deactivate(); fixture.detectChanges();
+ validateActive(tab, mdcTab, false);
+ expect(testComponent.events).toEqual([]);
+
+ testComponent.active = true; fixture.detectChanges();
+ validateActive(tab, mdcTab, false); // as above: active property should not affect state by itself
+ expect(testComponent.events).toEqual([]);
+ }
+
+ function validateActive(tab: HTMLElement, mdcTab: MdcTabDirective, active = true, focusOnActivate = true) {
+ const indicator: HTMLElement = tab.querySelector('.mdc-tab-indicator');
+ expect(mdcTab.active).toBe(active);
+ if (active) {
+ expect(tab.classList).toContain('mdc-tab--active');
+ expect(indicator.classList).toContain('mdc-tab-indicator--active');
+ expect(tab.getAttribute('aria-selected')).toBe('true');
+ expect(tab.getAttribute('tabindex')).toBe('0');
+ if (focusOnActivate)
+ expect(document.activeElement).toBe(tab);
+ } else {
+ expect(tab.classList).not.toContain('mdc-tab--active');
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--active');
+ expect(tab.getAttribute('aria-selected')).toBe('false');
+ expect(tab.getAttribute('tabindex')).toBe('-1');
+ }
+ }
+});
diff --git a/bundle/src/components/tab/mdc.tab.directive.ts b/bundle/src/components/tab/mdc.tab.directive.ts
new file mode 100644
index 0000000..9a302cf
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.directive.ts
@@ -0,0 +1,252 @@
+import { AfterContentInit, ContentChildren, EventEmitter, forwardRef, Directive, ElementRef,
+ HostBinding, Input, OnDestroy, Output, Renderer2, QueryList, HostListener, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { MDCTabFoundation, MDCTabAdapter } from '@material/tab';
+import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
+import { asBoolean } from '../../utils/value.utils';
+import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+import { MdcTabIndicatorDirective } from './mdc.tab.indicator.directive';
+import { takeUntil } from 'rxjs/operators';
+import { ReplaySubject, Subject } from 'rxjs';
+
+/**
+ * The interface for events send by the activate
output of an
+ * `mdcTab` directive, or by the tabChange
output of an mdcTabBar
.
+ */
+export interface MdcTabChange {
+ /**
+ * A reference to the tab that sends the event.
+ */
+ tab: AbstractMdcTabDirective,
+ /**
+ * The index of the tab that sends the event.
+ */
+ tabIndex: number
+}
+
+/**
+ * Directive for an optional icon when having a tab bar with icons.
+ * This directive must be used as a child of an `mdcTabContent`, and as a sibbling
+ * to a following `mdcTabLabel`.
+ */
+@Directive({
+ selector: '[mdcTabIcon]'
+})
+export class MdcTabIconDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab__icon') readonly _cls = true;
+ /** @internal */
+ @HostBinding('attr.aria-hidden') _ariaHidden = true;
+}
+
+/**
+ * Directive for the text label of a tab.
+ * This directive must be used as a child of an `mdcTabContent`.
+ * It can be preceded by an optional `mdcTabIcon`.
+ */
+@Directive({
+ selector: '[mdcTabLabel]'
+})
+export class MdcTabLabelDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab__text-label') readonly _cls = true;
+}
+
+/**
+ * Directive for the content (label and optional icon of the tab).
+ * This directive must be used as a child of an `mdcTab`, and
+ * can contain an (optional) `mdcTabIcon` and an `mdcTabLabel`.
+ */
+@Directive({
+ selector: '[mdcTabContent]'
+})
+export class MdcTabContentDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab__content') readonly _cls = true;
+
+ constructor(public _root: ElementRef) {}
+}
+
+@Directive()
+export abstract class AbstractMdcTabDirective extends AbstractMdcRipple implements OnDestroy, AfterContentInit {
+ /** @internal */
+ @HostBinding('class.mdc-tab') readonly _cls = true;
+ private onDestroy$: Subject = new Subject();
+ /** @internal */
+ protected _active: ClientRect | boolean = false;
+ /** @internal */
+ @HostBinding('attr.role') _role = 'tab';
+ /** @internal */
+ @ContentChildren(MdcTabContentDirective) _contents?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcTabIndicatorDirective, {descendants: true}) _indicators?: QueryList;
+ /**
+ * Event called when the tab is activated.
+ */
+ @Output() readonly activate: EventEmitter = new EventEmitter();
+ private activationRequest: Subject = new ReplaySubject(1);
+ /** @internal */
+ protected _adapter: MDCTabAdapter = {
+ addClass: (className) => this._rndr.addClass(this._root.nativeElement, className),
+ removeClass: (className) => this._rndr.removeClass(this._root.nativeElement, className),
+ hasClass: (className) => this._root.nativeElement.classList.contains(className),
+ setAttr: (attr, value) => this._rndr.setAttribute(this._root.nativeElement, attr, value),
+ activateIndicator: (previousIndicatorClientRect) => this._indicator?.activate(previousIndicatorClientRect),
+ deactivateIndicator: () => this._indicator?.deactivate(),
+ notifyInteracted: () => this.activationRequest.next(true),
+ getOffsetLeft: () => this._root.nativeElement.offsetLeft,
+ getOffsetWidth: () => this._root.nativeElement.offsetWidth,
+ getContentOffsetLeft: () => this._content!._root.nativeElement.offsetLeft,
+ getContentOffsetWidth: () => this._content!._root.nativeElement.offsetWidth,
+ focus: () => this._root.nativeElement.focus()
+ };
+ /** @internal */
+ _foundation: MDCTabFoundation | null = null;
+
+ constructor(protected _rndr: Renderer2, public _root: ElementRef, protected _registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(_root, _rndr, _registry, doc as Document);
+ }
+
+ ngAfterContentInit() {
+ this.addRippleSurface('mdc-tab__ripple');
+ this.initRipple();
+
+ let initializer = () => {
+ this.destroyFoundation();
+ if (this._content && this._indicator)
+ this.initFoundation();
+ };
+ initializer();
+ this._contents!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(initializer);
+ this._indicators!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(initializer);
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ this.destroyRipple();
+ this.destroyFoundation();
+ }
+
+ private destroyFoundation() {
+ let destroy = this._foundation != null;
+ if (destroy)
+ this._foundation!.destroy();
+ this._foundation = null;
+ return destroy;
+ }
+
+ private initFoundation() {
+ this._foundation = new MDCTabFoundation(this._adapter);
+ this._foundation.init();
+ if (this._active) {
+ let clientRect = typeof this._active === 'boolean' ? undefined : this._active;
+ this._foundation.activate(clientRect);
+ } else {
+ // foundation doesn't initialize these attributes:
+ this._rndr.setAttribute(this._root.nativeElement, 'aria-selected', 'false');
+ this._rndr.setAttribute(this._root.nativeElement, 'tabindex', '-1');
+ }
+ }
+
+ /** @internal */
+ protected getRippleStylingElement() {
+ return this.rippleSurface;
+ }
+
+ /** @internal */
+ _activate(tabIndex: number, previousIndicatorClientRect?: ClientRect) {
+ this._active = previousIndicatorClientRect || true;
+ if (this._foundation)
+ this._foundation.activate(previousIndicatorClientRect);
+ this.activate.emit({tab: this, tabIndex});
+ }
+
+ /** @internal */
+ _deactivate() {
+ this._active = false;
+ if (this._foundation)
+ this._foundation.deactivate();
+ }
+
+ /** @internal */
+ _focus() {
+ this._adapter.focus();
+ }
+
+ /** @internal */
+ _computeIndicatorClientRect() {
+ return this._indicator?._computeContentClientRect();
+ }
+
+ /** @internal */
+ _computeDimensions() {
+ return this._foundation?.computeDimensions();
+ }
+
+ /** @internal */
+ isActive() {
+ return !!this._active;
+ }
+
+ /** @internal */
+ triggerActivation(value: boolean = true) {
+ // Note: this should not set the _active property. It just notifies the tab-bar
+ // that it wants to be activated. The tab-bar will deactivate the previous tab, and activate
+ // this one.
+ this.activationRequest.next(value);
+ }
+
+ /** @internal */
+ get activationRequest$() {
+ return this.activationRequest.asObservable();
+ }
+
+ /** @internal */
+ @HostListener('click') _onClick() {
+ if (this._foundation)
+ this._foundation.handleClick();
+ }
+
+ /** @internal */
+ private get _indicator() {
+ return this._indicators && this._indicators.length > 0 ? this._indicators.first : null;
+ }
+
+ /** @internal */
+ private get _content() {
+ return this._contents && this._contents.length > 0 ? this._contents.first : null;
+ }
+}
+
+/**
+ * Directive for a tab. This directive must be used as a child of mdcTabBar
.
+ * When using tabs in combination with angular routes, add a `routerLink` property, so that
+ * the `MdcTabRouterDirective` is selected instead of this directive.
+ */
+@Directive({
+ selector: '[mdcTab]:not([routerLink])',
+ exportAs: 'mdcTab',
+ providers: [{provide: AbstractMdcTabDirective, useExisting: forwardRef(() => MdcTabDirective) }]
+})
+export class MdcTabDirective extends AbstractMdcTabDirective {
+ constructor(rndr: Renderer2, root: ElementRef, registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ super(rndr, root, registry, doc as Document);
+ }
+
+ /**
+ * Input for activating the tab. Assign a truthy value to activate the tab. A falsy value
+ * will have no effect. In order to deactivate the tab, you must activate another tab.
+ */
+ @Input() get active() {
+ return this.isActive();
+ }
+
+ set active(value: boolean) {
+ this.triggerActivation(asBoolean(value));
+ }
+
+ static ngAcceptInputType_active: boolean | '';
+}
+
+export const TAB_DIRECTIVES = [MdcTabIconDirective, MdcTabLabelDirective, MdcTabContentDirective, MdcTabDirective];
diff --git a/bundle/src/components/tab/mdc.tab.indicator.directive.spec.ts b/bundle/src/components/tab/mdc.tab.indicator.directive.spec.ts
new file mode 100644
index 0000000..7164eac
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.indicator.directive.spec.ts
@@ -0,0 +1,98 @@
+import { TestBed, fakeAsync } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Component } from '@angular/core';
+import { MdcTabIndicatorDirective, TAB_INDICATOR_DIRECTIVES } from './mdc.tab.indicator.directive';
+
+const template = `
+
+ {{contentType === 'icon' ? 'favorite' : ''}}
+
+`;
+
+// TODO: change nested content, check correct reinitialization and state of mdc-tab-indicator--active
+
+describe('MdcTabIndicatorDirective', () => {
+ abstract class AbstractTestComponent {
+ type: string = null;
+ contentType: string = null;
+ }
+
+ @Component({
+ template: template
+ })
+ class TestComponent extends AbstractTestComponent {
+ }
+
+ function setup(testComponentType: any = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...TAB_INDICATOR_DIRECTIVES, testComponentType]
+ }).createComponent(testComponentType);
+ fixture.detectChanges();
+ return { fixture };
+ }
+
+ @Component({
+ template: template
+ })
+ class UninitializedTestComponent {
+ }
+
+ it('should initialize with defaults', (() => {
+ const { fixture } = setup(UninitializedTestComponent);
+ const indicator: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-indicator');
+ expect(indicator).toBeDefined();
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--fade');
+ const content: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-indicator__content');
+ expect(content).toBeDefined();
+ expect(content.classList).toContain('mdc-tab-indicator__content--underline');
+ expect(content.classList).not.toContain('mdc-tab-indicator__content--icon');
+ }));
+
+ it('indicator can be activated and deactivated', (() => {
+ const { fixture } = setup(UninitializedTestComponent);
+ const indicator: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-indicator');
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--active');
+ const mdcIndicator = fixture.debugElement.query(By.directive(MdcTabIndicatorDirective)).injector.get(MdcTabIndicatorDirective);
+
+ mdcIndicator.activate(null); fixture.detectChanges();
+ expect(indicator.classList).toContain('mdc-tab-indicator--active');
+
+ mdcIndicator.deactivate(); fixture.detectChanges();
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--active');
+ }));
+
+ it('indicator type can be changed', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const indicator: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-indicator');
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--fade');
+
+ testComponent.type = 'fade'; fixture.detectChanges();
+ expect(indicator.classList).toContain('mdc-tab-indicator--fade');
+
+ testComponent.type = 'underline'; fixture.detectChanges();
+ expect(indicator.classList).not.toContain('mdc-tab-indicator--fade');
+ }));
+
+ it('indicatorContent type can be changed', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const content: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-indicator__content');
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const mdcIndicator = fixture.debugElement.query(By.directive(MdcTabIndicatorDirective)).injector.get(MdcTabIndicatorDirective);
+ expect(content.classList).toContain('mdc-tab-indicator__content--underline');
+ expect(content.classList).not.toContain('mdc-tab-indicator__content--icon');
+
+ testComponent.contentType = 'icon'; fixture.detectChanges();
+ expect(content.classList).not.toContain('mdc-tab-indicator__content--underline');
+ expect(content.classList).toContain('mdc-tab-indicator__content--icon');
+
+ mdcIndicator.activate(null);
+
+ testComponent.contentType = 'underline'; fixture.detectChanges();
+ expect(content.classList).toContain('mdc-tab-indicator__content--underline');
+ expect(content.classList).not.toContain('mdc-tab-indicator__content--icon');
+
+ mdcIndicator.deactivate();
+ }));
+});
diff --git a/bundle/src/components/tab/mdc.tab.indicator.directive.ts b/bundle/src/components/tab/mdc.tab.indicator.directive.ts
new file mode 100644
index 0000000..415221a
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.indicator.directive.ts
@@ -0,0 +1,163 @@
+import { AfterContentInit, ContentChildren, Directive, ElementRef,
+ HostBinding, Input, OnDestroy, Renderer2, QueryList } from '@angular/core';
+import { MDCTabIndicatorFoundation, MDCFadingTabIndicatorFoundation, MDCSlidingTabIndicatorFoundation, MDCTabIndicatorAdapter } from '@material/tab-indicator';
+import { takeUntil } from 'rxjs/operators';
+import { Subject } from 'rxjs';
+
+/**
+ * Child directive for an `mdcTabIndicator`. Must be present, and can be assigned
+ * the value `underline` (default), or `icon`, to set the type of indicator.
+ */
+@Directive({
+ selector: '[mdcTabIndicatorContent]'
+})
+export class MdcTabIndicatorContentDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab-indicator__content') readonly _cls = true;
+ /** @internal */
+ _type: 'underline' | 'icon' = 'underline';
+
+ constructor(public _root: ElementRef) {}
+
+ /**
+ * By default the indicator is represented as an underline. Set this value to
+ * `icon` to have it represented as an icon.
+ * You can use SVG, or font icon libraries to set the content icon.
+ */
+ @Input() get mdcTabIndicatorContent() {
+ return this._type;
+ }
+
+ set mdcTabIndicatorContent(value: 'underline' | 'icon') {
+ this._type = value === 'icon' ? value : 'underline'
+ }
+
+ static ngAcceptInputType_mdcTabIndicatorContent: 'underline' | 'icon' | '';
+
+ @HostBinding('class.mdc-tab-indicator__content--underline') get _underline() {
+ return this._type === 'underline';
+ }
+
+ @HostBinding('class.mdc-tab-indicator__content--icon') get _icon() {
+ return this._type === 'icon';
+ }
+}
+
+/**
+ * Directive for the content (label and optional icon of the tab).
+ * This directive must be used as a child of an `mdcTab`, or `mdcTabRouter`.
+ */
+@Directive({
+ selector: '[mdcTabIndicator]'
+})
+export class MdcTabIndicatorDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-tab-indicator') readonly _cls = true;
+ private onDestroy$: Subject = new Subject();
+ /** @internal */
+ @ContentChildren(MdcTabIndicatorContentDirective) _contents?: QueryList;
+ /** @internal */
+ _type: 'slide' | 'fade' = 'slide';
+ private active: ClientRect | boolean = false;
+
+ private mdcAdapter: MDCTabIndicatorAdapter = {
+ addClass: (className) => {
+ this.rndr.addClass(this.root.nativeElement, className);
+ },
+ removeClass: (className) => {
+ this.rndr.removeClass(this.root.nativeElement, className);
+ },
+ computeContentClientRect: () => this._content!._root.nativeElement.getBoundingClientRect(),
+ setContentStyleProperty: (name, value) => this.rndr.setStyle(this._content!._root.nativeElement, name, value)
+ };
+ private foundation: MDCTabIndicatorFoundation | null = null;
+
+ constructor(private rndr: Renderer2, private root: ElementRef) {}
+
+ ngAfterContentInit() {
+ if (this._content) {
+ this.initFoundation();
+ }
+ this._contents!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
+ this.destroyFoundation();
+ if (this._content)
+ this.initFoundation();
+ });
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next(); this.onDestroy$.complete();
+ this.destroyFoundation();
+ }
+
+ private destroyFoundation() {
+ let destroy = this.foundation != null;
+ if (destroy) {
+ this.foundation!.destroy();
+ this.mdcAdapter.removeClass('mdc-tab-indicator--active');
+ }
+ this.foundation = null;
+ return destroy;
+ }
+
+ private initFoundation() {
+ this.foundation = this._type === 'fade' ?
+ new MDCFadingTabIndicatorFoundation(this.mdcAdapter) :
+ new MDCSlidingTabIndicatorFoundation(this.mdcAdapter);
+ this.foundation.init();
+ if (this.active) {
+ let clientRect = typeof this.active === 'boolean' ? undefined : this.active;
+ this.foundation.activate(clientRect);
+ }
+ }
+
+ /**
+ * By default the indicator is a sliding indicator: when another tab is activated, the indicator
+ * animates a slide to the new tab. Set this property `fade` to have a fading animation
+ * instead.
+ */
+ @Input() get mdcTabIndicator() {
+ return this._type;
+ }
+
+ set mdcTabIndicator(value: 'slide' | 'fade') {
+ let newValue: 'slide' | 'fade' = value === 'fade' ? value : 'slide'
+ if (newValue !== this._type) {
+ this._type = newValue;
+ if (this.destroyFoundation())
+ this.initFoundation();
+ }
+ }
+
+ static ngAcceptInputType_mdcTabIndicator: 'slide' | 'fade' | '';
+
+ /** @internal */
+ activate(previousIndicatorClientRect: ClientRect | undefined) {
+ this.active = previousIndicatorClientRect || true;
+ if (this.foundation)
+ this.foundation.activate(previousIndicatorClientRect);
+ }
+
+ /** @internal */
+ deactivate() {
+ this.active = false;
+ if (this.foundation)
+ this.foundation.deactivate();
+ }
+
+ /** @internal */
+ @HostBinding('class.mdc-tab-indicator--fade') get _slide() {
+ return this._type === 'fade';
+ }
+
+ private get _content() {
+ return this._contents && this._contents.length > 0 ? this._contents.first : null;
+ }
+
+ /** @internal */
+ _computeContentClientRect() {
+ return this.foundation?.computeContentClientRect();
+ }
+}
+
+export const TAB_INDICATOR_DIRECTIVES = [MdcTabIndicatorContentDirective, MdcTabIndicatorDirective];
diff --git a/bundle/src/components/tab/mdc.tab.router.directive.spec.ts b/bundle/src/components/tab/mdc.tab.router.directive.spec.ts
new file mode 100644
index 0000000..f459b9c
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.router.directive.spec.ts
@@ -0,0 +1,183 @@
+import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { Component } from '@angular/core';
+import { TAB_BAR_DIRECTIVES } from './mdc.tab.bar.directive';
+import { TAB_SCROLLER_DIRECTIVES } from './mdc.tab.scroller.directive';
+import { TAB_ROUTER_DIRECTIVES } from './mdc.tab.router.directive';
+import { TAB_DIRECTIVES } from './mdc.tab.directive';
+import { TAB_INDICATOR_DIRECTIVES } from './mdc.tab.indicator.directive';
+
+import { Router, Routes } from '@angular/router';
+
+const template = `
+
+
+
+ home
+
+
+
+ search
+
+
+
+
+
+`;
+const noHomeTemplate = `
+
+
+`;
+
+describe('MdcTabRouterDirective', () => {
+ @Component({
+ template: `Search
`
+ })
+ class SearchComponent {
+ };
+
+ @Component({
+ template: `Search One
`
+ })
+ class SearchOneComponent {
+ };
+
+ @Component({
+ template: `Search Two
`
+ })
+ class SearchTwoComponent {
+ };
+
+ @Component({
+ template: `Home
`
+ })
+ class HomeComponent {
+ }
+
+ @Component({
+ template: `Other
`
+ })
+ class OtherComponent {
+ }
+
+ abstract class AbstractTestComponent {
+ }
+
+ @Component({
+ template: template
+ })
+ class TestComponent extends AbstractTestComponent {
+ }
+
+ @Component({
+ template: noHomeTemplate
+ })
+ class NoHomeTestComponent extends AbstractTestComponent {
+ }
+
+ const testRoutes = [
+ {path: '', redirectTo: 'home', pathMatch: 'full'},
+ {path: 'home', component: HomeComponent},
+ {path: 'search', component: SearchComponent,
+ children: [
+ { path: 'one', component: SearchOneComponent },
+ { path: 'two', component: SearchTwoComponent }
+ ],
+ },
+ {path: 'other', component: OtherComponent}
+ ];
+
+ function setup(testComponentType: any = TestComponent, routes: Routes = testRoutes) {
+ const fixture = TestBed.configureTestingModule({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ declarations: [
+ ...TAB_INDICATOR_DIRECTIVES,
+ ...TAB_DIRECTIVES,
+ ...TAB_ROUTER_DIRECTIVES,
+ ...TAB_SCROLLER_DIRECTIVES,
+ ...TAB_BAR_DIRECTIVES,
+ testComponentType,
+ ...routes.map(r => r.component).filter(c => !!c),
+ SearchOneComponent, SearchTwoComponent
+ ]
+ }).createComponent(testComponentType);
+
+ let router = TestBed.inject(Router);
+ //let location = TestBed.inject(Location);
+
+ fixture.ngZone.run(() => router.initialNavigation());
+ fixture.detectChanges();
+ tick();
+ return { fixture, router }; //, location };
+ }
+
+ it('initial state and route navigation by interaction', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ // initial state has no tab selected, because no tab matches the home route:
+ expect(fixture.nativeElement.querySelector('#home')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('#search')).toBeNull();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([true, false]);
+
+ // changing to search route:
+ tabs[1].click(); tick(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true]);
+ expect(fixture.nativeElement.querySelector('#home')).toBeNull();
+ expect(fixture.nativeElement.querySelector('#search')).not.toBeNull();
+ }));
+
+ it('initial state when no route matches', fakeAsync(() => {
+ const { fixture } = setup(NoHomeTestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ // initial state has no tab selected, because no tab matches the home route:
+ expect(fixture.nativeElement.querySelector('#home')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('#search')).toBeNull();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false]);
+
+ // after clicking search we should be on the search route, with tab-bar reflecting that:
+ tabs[0].click(); tick(); fixture.detectChanges();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([true]);
+ expect(fixture.nativeElement.querySelector('#home')).toBeNull();
+ expect(fixture.nativeElement.querySelector('#search')).not.toBeNull();
+ }));
+
+ it('route change through router', fakeAsync(() => {
+ const { fixture, router } = setup(NoHomeTestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+ // initial state has no tab selected, because no tab matches the home route:
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false]);
+
+ fixture.ngZone.run(() =>router.navigate(['/home']));
+ flush(); fixture.detectChanges();
+ // still no tab selected, because 'home' doesn't match route for a tab:
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false]);
+
+ fixture.ngZone.run(() =>router.navigate(['/search']));
+ flush(); fixture.detectChanges();
+ // search route and tab now active:
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([true]);
+
+ fixture.ngZone.run(() =>router.navigate(['/home']));
+ flush(); fixture.detectChanges();
+ // search tab still selected, because we have no other tab to activate for '/home':
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([true]);
+ }));
+
+ it('activate tab for child route', fakeAsync(() => {
+ const { fixture, router } = setup(TestComponent);
+ const tabs: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.mdc-tab')];
+
+ fixture.ngZone.run(() => router.navigate(['/search/two']));
+ flush(); fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('#search')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('#searchtwo')).not.toBeNull();
+ expect(tabs.map(t => t.classList.contains('mdc-tab--active'))).toEqual([false, true]);
+ }));
+});
diff --git a/bundle/src/components/tab/mdc.tab.router.directive.ts b/bundle/src/components/tab/mdc.tab.router.directive.ts
new file mode 100644
index 0000000..bb68702
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.router.directive.ts
@@ -0,0 +1,62 @@
+import { ContentChildren, forwardRef, QueryList, Directive, ElementRef, Optional, Renderer2, Inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { Router, RouterLink, RouterLinkWithHref } from '@angular/router';
+import { AbstractMdcTabDirective } from './mdc.tab.directive';
+import { RouterActiveDetector } from '../utility/router.active.detector';
+import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+
+/**
+ * Directive for a tab that triggers a route change. This directive must be used as a child of
+ * `mdcTabBar`. For a tab that doesn't use the angular routing module, drop the `routerLink`
+ * attribute.
+ *
+ * Selector `mdcTabRouter` is provided for backward compatibility and will be deprecated in the future.
+ * Use the selector `mdcTab` in combination with a `routerLink` attribute instead.
+ */
+@Directive({
+ selector: '[mdcTab][routerLink],[mdcTabRouter]',
+ exportAs: 'mdcTab',
+ providers: [{provide: AbstractMdcTabDirective, useExisting: forwardRef(() => MdcTabRouterDirective) }]
+})
+export class MdcTabRouterDirective extends AbstractMdcTabDirective {
+ /** @internal */
+ @ContentChildren(RouterLink, {descendants: true}) _links?: QueryList;
+ /** @internal */
+ @ContentChildren(RouterLinkWithHref, {descendants: true}) _linksWithHrefs?: QueryList;
+ private routerActive: RouterActiveDetector | null = null;
+
+ constructor(rndr: Renderer2, root: ElementRef, registry: MdcEventRegistry, private router: Router,
+ @Inject(DOCUMENT) doc: any,
+ @Optional() private link?: RouterLink, @Optional() private linkWithHref?: RouterLinkWithHref) {
+ super(rndr, root, registry, doc as Document);
+ }
+
+ ngOnDestroy() {
+ this.routerActive?.destroy();
+ this.routerActive = null;
+ super.ngOnDestroy();
+ }
+
+ ngAfterContentInit(): void {
+ super.ngAfterContentInit();
+ this.routerActive = new RouterActiveDetector(this, this._links!, this._linksWithHrefs!, this.router,
+ this.link, this.linkWithHref);
+ this.routerActive.init();
+ }
+
+ ngOnChanges(): void {
+ this.routerActive?.update();
+ }
+
+ /** @internal */
+ isRouterActive() {
+ return this.isActive();
+ }
+
+ /** @internal */
+ setRouterActive(activate: boolean) {
+ this.triggerActivation(activate);
+ }
+}
+
+export const TAB_ROUTER_DIRECTIVES = [MdcTabRouterDirective];
diff --git a/bundle/src/components/tab/mdc.tab.scroller.directive.spec.ts b/bundle/src/components/tab/mdc.tab.scroller.directive.spec.ts
new file mode 100644
index 0000000..7561290
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.scroller.directive.spec.ts
@@ -0,0 +1,59 @@
+import { TestBed, fakeAsync } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { TAB_SCROLLER_DIRECTIVES } from './mdc.tab.scroller.directive';
+import { TAB_DIRECTIVES } from './mdc.tab.directive';
+import { TAB_INDICATOR_DIRECTIVES } from './mdc.tab.indicator.directive';
+
+const template = `
+
+
+
+
+
+ {{tab.icon}}
+ {{tab.label}}
+
+
+
+
+
+
+
+
+`;
+
+describe('MdcTabScrollerDirective', () => {
+ abstract class AbstractTestComponent {
+ tabs = [
+ {icon: 'access_time', label: 'recents'},
+ {icon: 'near_me', label: 'nearby'},
+ {icon: 'favorite', label: 'favorites'}
+ ];
+ }
+
+ @Component({
+ template: template
+ })
+ class TestComponent extends AbstractTestComponent {
+ }
+
+ function setup(testComponentType: any = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [...TAB_INDICATOR_DIRECTIVES, ...TAB_DIRECTIVES, ...TAB_SCROLLER_DIRECTIVES, testComponentType]
+ }).createComponent(testComponentType);
+ fixture.detectChanges();
+ return { fixture };
+ }
+
+ it('should initialize with defaults', fakeAsync(() => {
+ const { fixture } = setup(TestComponent);
+ const scroller: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-scroller');
+ const area: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-scroller__scroll-area');
+ const content: HTMLElement = fixture.nativeElement.querySelector('.mdc-tab-scroller__scroll-content');
+
+ expect(scroller.classList).toBeDefined();
+ expect(area.classList).toBeDefined();
+ expect(content.classList).toBeDefined();
+ }));
+});
+
diff --git a/bundle/src/components/tab/mdc.tab.scroller.directive.ts b/bundle/src/components/tab/mdc.tab.scroller.directive.ts
new file mode 100644
index 0000000..d07101a
--- /dev/null
+++ b/bundle/src/components/tab/mdc.tab.scroller.directive.ts
@@ -0,0 +1,142 @@
+import { AfterContentInit, ContentChildren, Directive, ElementRef,
+ HostBinding, OnDestroy, Renderer2, QueryList, Inject } from '@angular/core';
+import { MDCTabScrollerFoundation, MDCTabScrollerAdapter, util } from '@material/tab-scroller';
+import { events, ponyfill } from '@material/dom';
+import { MdcEventRegistry } from '../../utils/mdc.event.registry';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { DOCUMENT } from '@angular/common';
+import { AbstractMdcTabDirective } from './mdc.tab.directive';
+
+/**
+ * Directive for a the scroll content of an `mdcTabScrollerArea`. This directive must wrap the
+ * `mdcTab` directives for each of the tabs.
+ */
+@Directive({
+ selector: '[mdcTabScrollerContent]'
+})
+export class MdcTabScrollerContentDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab-scroller__scroll-content') readonly _cls = true;
+
+ constructor(public _el: ElementRef) {}
+}
+
+/**
+ * Directive for a the scroll area of an `mdcTabScroller`. This directive should have exactly one
+ * `mdcTabScrollerContent` child directive.
+ */
+@Directive({
+ selector: '[mdcTabScrollerArea]'
+})
+export class MdcTabScrollerAreaDirective {
+ /** @internal */
+ @HostBinding('class.mdc-tab-scroller__scroll-area') readonly _cls = true;
+
+ constructor(public _el: ElementRef) {}
+}
+
+/**
+ * Directive for a scrollable tab bar. This directive should have exactly one
+ * `mdcTabScrollerArea` child directive.
+ */
+@Directive({
+ selector: '[mdcTabScroller]'
+})
+export class MdcTabScrollerDirective implements AfterContentInit, OnDestroy {
+ /** @internal */
+ @HostBinding('class.mdc-tab-scroller') readonly _cls = true;
+ private onDestroy$: Subject = new Subject();
+ /** @internal */
+ @ContentChildren(MdcTabScrollerAreaDirective) _areas?: QueryList;
+ /** @internal */
+ @ContentChildren(MdcTabScrollerContentDirective, {descendants: true}) _contents?: QueryList;
+ /** @internal */
+ @ContentChildren(AbstractMdcTabDirective, {descendants: true}) _tabs?: QueryList;
+ private document: Document;
+ private _adapter: MDCTabScrollerAdapter = {
+ eventTargetMatchesSelector: (target, selector) => ponyfill.matches(target as Element, selector),
+ addClass: (name) => this._rndr.addClass(this._el.nativeElement, name),
+ removeClass: (name) => this._rndr.removeClass(this._el.nativeElement, name),
+ addScrollAreaClass: (name) => this._rndr.addClass(this._area!._el.nativeElement, name),
+ setScrollAreaStyleProperty: (name, value) => this._rndr.setStyle(this._area!._el.nativeElement, name, value),
+ setScrollContentStyleProperty: (name, value) => this._rndr.setStyle(this._content!._el.nativeElement, name, value),
+ getScrollContentStyleValue: (name) => getComputedStyle(this._content!._el.nativeElement).getPropertyValue(name),
+ setScrollAreaScrollLeft: (scrollX) => this._area!._el.nativeElement.scrollLeft = scrollX,
+ getScrollAreaScrollLeft: () => this._area!._el.nativeElement.scrollLeft,
+ getScrollContentOffsetWidth: () => this._content!._el.nativeElement.offsetWidth,
+ getScrollAreaOffsetWidth: () => this._area!._el.nativeElement.offsetWidth,
+ computeScrollAreaClientRect: () => this._area!._el.nativeElement.getBoundingClientRect(),
+ computeScrollContentClientRect: () => this._content!._el.nativeElement.getBoundingClientRect(),
+ computeHorizontalScrollbarHeight: () => util.computeHorizontalScrollbarHeight(this.document)
+ };
+ /** @internal */
+ _foundation: MDCTabScrollerFoundation | null = null;
+
+ constructor(private _rndr: Renderer2, private _el: ElementRef, private registry: MdcEventRegistry, @Inject(DOCUMENT) doc: any) {
+ this.document = doc as Document; // work around ngc issue https://github.com/angular/angular/issues/20351
+ }
+
+ ngAfterContentInit() {
+ let initializer = () => {
+ this.destroyFoundation();
+ if (this._content && this._area)
+ this.initFoundation();
+ };
+ initializer();
+ this._contents!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(initializer);
+ this._areas!.changes.pipe(takeUntil(this.onDestroy$)).subscribe(initializer);
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ this.destroyFoundation();
+ }
+
+ private initFoundation() {
+ this._foundation = new MDCTabScrollerFoundation(this._adapter);
+ this._foundation.init();
+ // manual registration of event listeners, because we need applyPassive, which is not (yet)
+ // supported by angular bindings:
+ this.registry.listen(this._rndr, 'wheel', this._handleInteraction, this._area!._el, events.applyPassive());
+ this.registry.listen(this._rndr, 'touchstart', this._handleInteraction, this._area!._el, events.applyPassive());
+ this.registry.listen(this._rndr, 'pointerdown', this._handleInteraction, this._area!._el, events.applyPassive());
+ this.registry.listen(this._rndr, 'mousedown', this._handleInteraction, this._area!._el, events.applyPassive());
+ this.registry.listen(this._rndr, 'keydown', this._handleInteraction, this._area!._el, events.applyPassive());
+ this.registry.listen(this._rndr, 'transitionend', this._handleTransitionEnd, this._content!._el);
+ }
+
+ private destroyFoundation() {
+ let destroy = this._foundation != null;
+ if (destroy) {
+ this.registry.unlisten('wheel', this._handleInteraction);
+ this.registry.unlisten('touchstart', this._handleInteraction);
+ this.registry.unlisten('pointerdown', this._handleInteraction);
+ this.registry.unlisten('mousedown', this._handleInteraction);
+ this.registry.unlisten('keydown', this._handleInteraction);
+ this.registry.unlisten('transitionend', this._handleTransitionEnd);
+ this._foundation!.destroy();
+ }
+ this._foundation = null;
+ return destroy;
+ }
+
+ private _handleInteraction = () => this._foundation!.handleInteraction();
+ private _handleTransitionEnd = (evt: Event) => this._foundation!.handleTransitionEnd(evt);
+
+ /** @internal */
+ _getScrollContentWidth() {
+ return this._adapter.getScrollContentOffsetWidth();
+ }
+
+ private get _area() {
+ return this._areas && this._areas.length > 0 ? this._areas.first : null;
+ }
+
+ private get _content() {
+ return this._contents && this._contents.length > 0 ? this._contents.first : null;
+ }
+}
+
+export const TAB_SCROLLER_DIRECTIVES = [MdcTabScrollerContentDirective, MdcTabScrollerAreaDirective, MdcTabScrollerDirective];
diff --git a/bundle/src/components/tabs/mdc.tab.adapter.ts b/bundle/src/components/tabs/mdc.tab.adapter.ts
deleted file mode 100644
index 64937aa..0000000
--- a/bundle/src/components/tabs/mdc.tab.adapter.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @docs-private */
-export interface MdcTabAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- registerInteractionHandler: (evt: string, handler: EventListener) => void;
- deregisterInteractionHandler: (evt: string, handler: EventListener) => void;
- getOffsetWidth: () => number;
- getOffsetLeft: () => number;
- notifySelected: () => void;
-}
diff --git a/bundle/src/components/tabs/mdc.tab.bar.adapter.ts b/bundle/src/components/tabs/mdc.tab.bar.adapter.ts
deleted file mode 100644
index 02b0be2..0000000
--- a/bundle/src/components/tabs/mdc.tab.bar.adapter.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { MDCTab } from '@material/tabs';
-
-/** @docs-private */
-export interface MdcTabBarAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- bindOnMDCTabSelectedEvent: () => void;
- unbindOnMDCTabSelectedEvent: () => void;
- registerResizeHandler: (handler: EventListener) => void;
- deregisterResizeHandler: (handler: EventListener) => void;
- getOffsetWidth: () => number;
- setStyleForIndicator: (propertyName: string, value: string) => void;
- getOffsetWidthForIndicator: () => number;
- notifyChange: (evtData: {activeTabIndex: number}) => void;
- getNumberOfTabs: () => number;
- isTabActiveAtIndex: (index: number) => boolean;
- setTabActiveAtIndex: (index: number, isActive: boolean) => void;
- isDefaultPreventedOnClickForTabAtIndex: (index: number) => boolean;
- setPreventDefaultOnClickForTabAtIndex: (index: number, preventDefaultOnClick: boolean) => void;
- measureTabAtIndex: (index: number) => void;
- getComputedWidthForTabAtIndex: (index: number) => number;
- getComputedLeftForTabAtIndex: (index: number) => number;
-}
diff --git a/bundle/src/components/tabs/mdc.tab.bar.directive.ts b/bundle/src/components/tabs/mdc.tab.bar.directive.ts
deleted file mode 100644
index da2f662..0000000
--- a/bundle/src/components/tabs/mdc.tab.bar.directive.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { ContentChildren, EventEmitter, QueryList, Directive, ElementRef, HostBinding, Output, Renderer2 } from '@angular/core';
-import { Subscription } from 'rxjs';
-import { MDCTabBarFoundation } from '@material/tabs';
-import { MdcTabBarAdapter } from './mdc.tab.bar.adapter';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-import { AbstractMdcTabDirective, MdcTabChange } from './mdc.tab.directive';
-
-const CLASS_TAB_BAR = 'mdc-tab-bar';
-const CLASS_INDICATOR = 'mdc-tab-bar__indicator';
-const CLASS_ICONS_BAR = 'mdc-tab-bar--icon-tab-bar';
-const CLASS_ICONS_WITH_TEXT_BAR = 'mdc-tab-bar--icons-with-text';
-
-/**
- * Directive for a tab bar. This directive must have mdcTab
,
- * or mdcTabRouter
children. The tab bar can optionally be
- * embedded inside an mdcTabBarScroller
.
- */
-@Directive({
- selector: '[mdcTabBar]'
-})
-export class MdcTabBarDirective {
- @HostBinding('class.' + CLASS_TAB_BAR) _hostClass = true;
- @HostBinding('class.mdc-tab-bar-scroller__scroll-frame__tabs') _insideScrollFrame = false;
- @ContentChildren(AbstractMdcTabDirective, {descendants: false}) _tabs: QueryList;
- /**
- * Event emitted when the actived tab changes.
- */
- @Output() tabChange: EventEmitter = new EventEmitter();
- private _indicator: HTMLElement;
- private _adapter: MdcTabBarAdapter = {
- addClass: (className: string) => this._rndr.addClass(this._el.nativeElement, className),
- removeClass: (className: string) => this._rndr.removeClass(this._el.nativeElement, className),
- bindOnMDCTabSelectedEvent: () => this._listenTabSelected(),
- unbindOnMDCTabSelectedEvent: () => this._unlistenTabSelected(),
- registerResizeHandler: (handler: EventListener) => window.addEventListener('resize', handler),
- deregisterResizeHandler: (handler: EventListener) => window.removeEventListener('resize', handler),
- getOffsetWidth: () => this._el.nativeElement.offsetWidth,
- setStyleForIndicator: (propertyName: string, value: string) => this._rndr.setStyle(this._indicator, propertyName, value),
- getOffsetWidthForIndicator: () => this._indicator.offsetWidth,
- notifyChange: (evtData: {activeTabIndex: number}) => {
- this.tabChange.emit({tab: null, tabIndex: evtData.activeTabIndex});
- },
- getNumberOfTabs: () => this._tabs.length,
- isTabActiveAtIndex: (index: number) => index >= 0 ? this._tabs.toArray()[index]._active : false,
- setTabActiveAtIndex: (index: number, isActive = true) => this._tabs.toArray()[index]._active = isActive,
- isDefaultPreventedOnClickForTabAtIndex: (index: number) => !!this._tabs.toArray()[index]._foundation.preventsDefaultOnClick,
- setPreventDefaultOnClickForTabAtIndex: (index: number, preventDefaultOnClick: boolean) => this._tabs.toArray()[index]._foundation.setPreventDefaultOnClick(preventDefaultOnClick),
- measureTabAtIndex: (index: number) => this._tabs.toArray()[index]._foundation.measureSelf(),
- getComputedWidthForTabAtIndex: (index: number) => this._tabs.toArray()[index]._foundation.getComputedWidth(),
- getComputedLeftForTabAtIndex: (index: number) => this._tabs.toArray()[index]._foundation.getComputedLeft()
- };
- private _subscriptions: Subscription[];
- private _foundation = new MDCTabBarFoundation(this._adapter);
-
- constructor(private _rndr: Renderer2, public _el: ElementRef, private registry: MdcEventRegistry) {
- }
-
- ngAfterContentInit() {
- this._tabs.changes.subscribe(() => {
- if (this._subscriptions)
- // make sure we update the tab change event subscriptions:
- this._listenTabSelected();
- });
- this.addIndicator();
- this._foundation.init();
- }
-
- ngOnDestroy() {
- this._foundation.destroy();
- }
-
- private addIndicator() {
- this._indicator = this._rndr.createElement('span');
- this._rndr.addClass(this._indicator, CLASS_INDICATOR);
- this._rndr.appendChild(this._el.nativeElement, this._indicator);
- }
-
- private _listenTabSelected() {
- if (this._subscriptions)
- this._unlistenTabSelected();
- this._subscriptions = new Array();
- this._tabs.forEach(tab => {
- this._subscriptions.push(tab.activate.subscribe(event => {
- this._setActive(event.tab, true);
- }));
- });
- }
-
- private _unlistenTabSelected() {
- this._subscriptions.forEach(sub => sub.unsubscribe());
- this._subscriptions = null;
- }
-
- private _setActive(tab: AbstractMdcTabDirective, notifyChange: boolean) {
- const index = this._tabs.toArray().indexOf(tab);
- this._foundation.switchToTabAtIndex(index, notifyChange);
- }
-
- @HostBinding('class.' + CLASS_ICONS_BAR)
- get _tabBarWithIcon() {
- return this._tabs.length > 0
- && this._tabs.first._mdcTabIcon != null
- && this._tabs.first._mdcTabIconText == null;
- }
-
- @HostBinding('class.' + CLASS_ICONS_WITH_TEXT_BAR)
- get _tabBarWithIconAndText() {
- return this._tabs.length > 0
- && this._tabs.first._mdcTabIcon != null
- && this._tabs.first._mdcTabIconText != null;
- }
-}
diff --git a/bundle/src/components/tabs/mdc.tab.bar.scroller.adapter.ts b/bundle/src/components/tabs/mdc.tab.bar.scroller.adapter.ts
deleted file mode 100644
index a7480f9..0000000
--- a/bundle/src/components/tabs/mdc.tab.bar.scroller.adapter.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/** @docs-private */
-export interface MdcTabBarScrollerAdapter {
- addClass: (className: string) => void;
- removeClass: (className: string) => void;
- eventTargetHasClass: (target: HTMLElement, className: string) => boolean;
- addClassToForwardIndicator: (className: string) => void;
- removeClassFromForwardIndicator: (className: string) => void;
- addClassToBackIndicator: (className: string) => void;
- removeClassFromBackIndicator: (className: string) => void;
- isRTL: () => boolean;
- registerBackIndicatorClickHandler: (handler: EventListener) => void;
- deregisterBackIndicatorClickHandler: (handler: EventListener) => void;
- registerForwardIndicatorClickHandler: (handler: EventListener) => void;
- deregisterForwardIndicatorClickHandler: (handler: EventListener) => void;
- registerCapturedInteractionHandler: (evt: string, handler: EventListener) => void;
- deregisterCapturedInteractionHandler: (evt: string, handler: EventListener) => void;
- registerWindowResizeHandler: (handler: EventListener) => void;
- deregisterWindowResizeHandler: (handler: EventListener) => void;
- getNumberOfTabs: () => number;
- getComputedWidthForTabAtIndex: (index: number) => number;
- getComputedLeftForTabAtIndex: (index: number) => number;
- getOffsetWidthForScrollFrame: () => number;
- getScrollLeftForScrollFrame: () => number;
- setScrollLeftForScrollFrame: (scrollLeftAmount: number) => void;
- getOffsetWidthForTabBar: () => number;
- setTransformStyleForTabBar: (value: string) => void;
- getOffsetLeftForEventTarget: (target: HTMLElement) => number;
- getOffsetWidthForEventTarget: (target: HTMLElement) => number;
-}
diff --git a/bundle/src/components/tabs/mdc.tab.bar.scroller.directive.ts b/bundle/src/components/tabs/mdc.tab.bar.scroller.directive.ts
deleted file mode 100644
index f3fab1c..0000000
--- a/bundle/src/components/tabs/mdc.tab.bar.scroller.directive.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { AfterContentInit, ContentChild, ContentChildren, forwardRef, Directive, ElementRef,
- HostBinding, HostListener, Input, OnDestroy, Optional, Output, Renderer2, Self } from '@angular/core';
-import { getCorrectPropertyName } from '@material/animation';
-import { MDCTabBarScrollerFoundation } from '@material/tabs';
-import { AbstractMdcTabDirective } from './mdc.tab.directive';
-import { MdcTabBarScrollerAdapter } from './mdc.tab.bar.scroller.adapter';
-import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-import { MdcTabBarDirective } from './mdc.tab.bar.directive';
-
-const CLASS_SCROLLER = 'mdc-tab-bar-scroller';
-const CLASS_INDICATOR = 'mdc-tab-bar-scroller__indicator';
-const CLASS_INDICATOR_INNER = 'mdc-tab-bar-scroller__indicator__inner';
-const CLASS_INDICATOR_BACK = 'mdc-tab-bar-scroller__indicator--back';
-const CLASS_INDICATOR_FORWARD = 'mdc-tab-bar-scroller__indicator--forward';
-const CLASS_SCROLLER_FRAME = 'mdc-tab-bar-scroller__scroll-frame';
-
-/**
- * Directive for the icon of the back or forward button of a tab bar scroller.
- * Use this directive on a child element of mdcTabBarScrollerBack
,
- * and mdcTabBarScrollerForward
.
- */
-@Directive({
- selector: '[mdcTabBarScrollerInner]'
-})
-export class MdcTabBarScrollerInnerDirective {
- @HostBinding('class.' + CLASS_INDICATOR_INNER) _hostClass = true;
-}
-
-/**
- * Directive for the 'back' button of a tab bar scroller. Must be the
- * first child of an mdcTabBarScroller
.
- * Embed an mdcTabBarScrollerInner
inside this directive for the
- * actual icon.
- */
-@Directive({
- selector: '[mdcTabBarScrollerBack]'
-})
-export class MdcTabBarScrollerBackDirective {
- @HostBinding('class.' + CLASS_INDICATOR) _hostClass = true;
- @HostBinding('class.' + CLASS_INDICATOR_BACK) _back = true;
-
- constructor(public _el: ElementRef) {
- }
-}
-
-/**
- * Directive for the 'forward' button of a tab bar scroller. Must be the
- * last child of an mdcTabBarScroller
.
- * Embed an mdcTabBarScrollerInner
inside this directive for the
- * actual icon.
- */
-@Directive({
- selector: '[mdcTabBarScrollerForward]'
-})
-export class MdcTabBarScrollerForwardDirective {
- @HostBinding('class.' + CLASS_INDICATOR) _hostClass = true;
- @HostBinding('class.' + CLASS_INDICATOR_FORWARD) _forward = true;
-
- constructor(public _el: ElementRef) {
- }
-}
-
-/**
- * Directive for the 'frame' part (containing the tab bar) of a tab bar scroller.
- * Must be the child of an mdcTabBarScroller
, and have an
- * mdcTabBar
as child.
- */
-@Directive({
- selector: '[mdcTabBarScrollerFrame]'
-})
-export class MdcTabBarScrollerFrameDirective implements AfterContentInit {
- @HostBinding('class.' + CLASS_SCROLLER_FRAME) _hostClass = true;
- @ContentChild(MdcTabBarDirective) _tabBar: MdcTabBarDirective;
-
- constructor(public _el: ElementRef) {
- }
-
- ngAfterContentInit() {
- if (this._tabBar)
- this._tabBar._insideScrollFrame = true;
- }
-
- _tabAt(index: number) {
- if (this._tabBar) {
- let tabs = this._tabBar._tabs.toArray();
- if (index >= 0 && index < tabs.length)
- return tabs[index];
- }
- return null;
- }
-}
-
-/**
- * Directive for a scrollable tab bar. Add mdcTabBarScrollerBack
,
- * mdcTabBarScrollerFrame
, and mdcTabBarScrollerForward
- * as children for respectively the back button, scrollable tab bar, and forward button.
- */
-@Directive({
- selector: '[mdcTabBarScroller]'
-})
-export class MdcTabBarScrollerDirective implements AfterContentInit, OnDestroy {
- @HostBinding('class.' + CLASS_SCROLLER) _hostClass = true;
- @ContentChild(MdcTabBarScrollerBackDirective) _back: MdcTabBarScrollerBackDirective;
- @ContentChild(MdcTabBarScrollerForwardDirective) _forward: MdcTabBarScrollerForwardDirective;
- @ContentChild(MdcTabBarScrollerFrameDirective) _scrollFrame: MdcTabBarScrollerFrameDirective;
- private _adapter: MdcTabBarScrollerAdapter = {
- addClass: (className: string) => this._rndr.addClass(this._el.nativeElement, className),
- removeClass: (className: string) => this._rndr.removeClass(this._el.nativeElement, className),
- eventTargetHasClass: (target: HTMLElement, className: string) => target.classList.contains(className),
- addClassToForwardIndicator: (className: string) => {
- if (this._forward)
- this._rndr.addClass(this._forward._el.nativeElement, className);
- },
- removeClassFromForwardIndicator: (className: string) => {
- if (this._forward)
- this._rndr.removeClass(this._forward._el.nativeElement, className);
- },
- addClassToBackIndicator: (className: string) => {
- if (this._back)
- this._rndr.addClass(this._back._el.nativeElement, className);
- },
- removeClassFromBackIndicator: (className: string) => {
- if (this._back)
- this._rndr.removeClass(this._back._el.nativeElement, className);
- },
- isRTL: () => getComputedStyle(this._el.nativeElement).getPropertyValue('direction') === 'rtl',
- registerBackIndicatorClickHandler: (handler: EventListener) => {
- if (this._back)
- this.registry.listen(this._rndr, 'click', handler, this._back._el);
- },
- deregisterBackIndicatorClickHandler: (handler: EventListener) => {
- if (this._back)
- this.registry.unlisten('click', handler);
- },
- registerForwardIndicatorClickHandler: (handler: EventListener) => {
- if (this._forward)
- this.registry.listen(this._rndr, 'click', handler, this._forward._el);
- },
- deregisterForwardIndicatorClickHandler: (handler: EventListener) => {
- if (this._forward)
- this.registry.unlisten('click', handler);
- },
- registerCapturedInteractionHandler: (evt: string, handler: EventListener) => {
- this.registry.listen(this._rndr, evt, handler, this._el);
- },
- deregisterCapturedInteractionHandler: (evt: string, handler: EventListener) => {
- this.registry.unlisten(evt, handler);
- },
- registerWindowResizeHandler: (handler: EventListener) => window.addEventListener('resize', handler),
- deregisterWindowResizeHandler: (handler: EventListener) => window.removeEventListener('resize', handler),
- getNumberOfTabs: () => {
- if (this._scrollFrame && this._scrollFrame._tabBar)
- return this._scrollFrame._tabBar._tabs.length;
- return 0;
- },
- getComputedWidthForTabAtIndex: (index: number) => this._tabAt(index)._foundation.getComputedWidth(),
- getComputedLeftForTabAtIndex: (index: number) => this._tabAt(index)._foundation.getComputedLeft(),
- getOffsetWidthForScrollFrame: () => {
- if (this._scrollFrame)
- return this._scrollFrame._el.nativeElement.offsetWidth;
- return 0;
- },
- getScrollLeftForScrollFrame: () => {
- if (this._scrollFrame)
- return this._scrollFrame._el.nativeElement.scrollLeft;
- return 0;
- },
- setScrollLeftForScrollFrame: (scrollLeftAmount: number) => {
- if (this._scrollFrame)
- this._rndr.setProperty(this._scrollFrame._el.nativeElement, 'scrollLeft', scrollLeftAmount);
- },
- getOffsetWidthForTabBar: () => {
- if (this._scrollFrame && this._scrollFrame._tabBar)
- return this._scrollFrame._tabBar._el.nativeElement.offsetWidth;
- return 0;
- },
- setTransformStyleForTabBar: (value: string) => {
- if (this._scrollFrame && this._scrollFrame._tabBar)
- this._rndr.setStyle(this._scrollFrame._tabBar._el.nativeElement, getCorrectPropertyName(window, 'transform'), value);
- },
- getOffsetLeftForEventTarget: (target: HTMLElement) => target.offsetLeft,
- getOffsetWidthForEventTarget: (target: HTMLElement) => target.offsetWidth
- }
- private _foundation = new MDCTabBarScrollerFoundation(this._adapter);
-
- constructor(private _rndr: Renderer2, private _el: ElementRef, private registry: MdcEventRegistry) {
- }
-
- ngAfterContentInit() {
- this._foundation.init();
- }
-
- ngOnDestroy() {
- this._foundation.destroy();
- }
-
- private _tabAt(index: number) {
- if (this._scrollFrame)
- return this._scrollFrame._tabAt(index);
- return null;
- }
-}
diff --git a/bundle/src/components/tabs/mdc.tab.directive.ts b/bundle/src/components/tabs/mdc.tab.directive.ts
deleted file mode 100644
index ea3d811..0000000
--- a/bundle/src/components/tabs/mdc.tab.directive.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { AfterContentInit, ContentChild, ContentChildren, EventEmitter, forwardRef, Directive, ElementRef,
- HostBinding, HostListener, Input, OnDestroy, Optional, Output, Renderer2, Self } from '@angular/core';
-import { NgControl } from '@angular/forms';
-import { MDCTabFoundation } from '@material/tabs';
-import { AbstractMdcRipple } from '../ripple/abstract.mdc.ripple';
-import { MdcTabAdapter } from './mdc.tab.adapter';
-import { asBoolean } from '../../utils/value.utils';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-/**
- * The interface for events send by the activate
output of an
- * mdcTab
or mdcTabRouter
directive, or by
- * the tabChange
event of an mdcTabBar
.
- */
-export interface MdcTabChange {
- /**
- * A reference to the tab that sends the event.
- */
- tab: AbstractMdcTabDirective,
- /**
- * The index of the tab that sends the event.
- */
- tabIndex: number
-}
-
-/**
- * Directive for an icon when having a tab bar with icons.
- * This directive must be used as a child of an mdcTab
,
- * or mdcTabRouter
.
- */
-@Directive({
- selector: '[mdcTabIcon]'
-})
-export class MdcTabIconDirective {
- @HostBinding('class.mdc-tab__icon') _hostClass = true;
-}
-
-/**
- * Directive for the text of tabs, when having a tab bar with icons and text labels.
- * This directive must be used as a child of an mdcTab
, and as a sibbling
- * to a preceding mdcTabIcon
.
- */
-@Directive({
- selector: '[mdcTabIconText]'
-})
-export class MdcTabIconTextDirective {
- @HostBinding('class.mdc-tab__icon-text') _hostClass = true;
-}
-
-export class AbstractMdcTabDirective extends AbstractMdcRipple implements OnDestroy, AfterContentInit {
- @HostBinding('class.mdc-tab') _hostClass = true;
- @ContentChild(MdcTabIconDirective) _mdcTabIcon: MdcTabIconDirective;
- @ContentChild(MdcTabIconTextDirective) _mdcTabIconText: MdcTabIconTextDirective;
- /**
- * Event called when the tab is activated.
- */
- @Output() activate: EventEmitter = new EventEmitter();
- protected _adapter: MdcTabAdapter = {
- addClass: (className: string) => this._rndr.addClass(this._root.nativeElement, className),
- removeClass: (className: string) => this._rndr.removeClass(this._root.nativeElement, className),
- registerInteractionHandler: (type: string, handler: EventListener) => this._registry.listen(this._rndr, type, handler, this._root),
- deregisterInteractionHandler: (type: string, handler: EventListener) => this._registry.unlisten(type, handler),
- getOffsetWidth: () => this._root.nativeElement.offsetWidth,
- getOffsetLeft: () => this._root.nativeElement.offsetLeft,
- notifySelected: () => this.activate.emit({tab: this, tabIndex: null})
- };
- _foundation = new MDCTabFoundation(this._adapter);
-
- constructor(protected _rndr: Renderer2, protected _root: ElementRef, protected _registry: MdcEventRegistry) {
- super(_root, _rndr, _registry);
- }
-
- ngAfterContentInit() {
- this.initRipple();
- this._foundation.init();
- }
-
- ngOnDestroy() {
- this.destroyRipple();
- this._foundation.destroy();
- }
-
- @HostBinding('class.mdc-tab--with-icon-and-text')
- get _tabWithIconAndText() {
- return this._mdcTabIcon != null && this._mdcTabIconText != null;
- }
-
- @HostBinding('class.mdc-tab--active')
- get _active() {
- return this._foundation.isActive();
- }
-
- set _active(value: boolean) {
- this._foundation.setActive(value);
- }
-}
-
-/**
- * Directive for a tab. This directive must be used as a child of mdcTabBar
.
- */
-@Directive({
- selector: '[mdcTab]',
- providers: [{provide: AbstractMdcTabDirective, useExisting: forwardRef(() => MdcTabDirective) }]
-})
-export class MdcTabDirective extends AbstractMdcTabDirective {
- constructor(rndr: Renderer2, root: ElementRef, registry: MdcEventRegistry) {
- super(rndr, root, registry);
- }
-
- /**
- * Input for activating the tab. Assign a value other than false
to activate
- * the tab. Any other value will have no effect: in order to deatcivate the tab, you must
- * activate another tab.
- */
- @Input()
- get active() {
- return this._active;
- }
-
- set active(value: boolean) {
- let activate = asBoolean(value);
- if (activate) {
- this._active = true;
- this._adapter.notifySelected();
- }
- }
-}
diff --git a/bundle/src/components/tabs/mdc.tab.router.directive.ts b/bundle/src/components/tabs/mdc.tab.router.directive.ts
deleted file mode 100644
index 383ba09..0000000
--- a/bundle/src/components/tabs/mdc.tab.router.directive.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { ContentChildren, forwardRef, QueryList, Directive, ElementRef, Optional, Renderer2 } from '@angular/core';
-import { Router, RouterLink, RouterLinkWithHref } from '@angular/router';
-import { AbstractMdcTabDirective } from './mdc.tab.directive';
-import { RouterActiveDetector } from '../utility/router.active.detector';
-import { MdcEventRegistry } from '../../utils/mdc.event.registry';
-
-@Directive({
- selector: '[mdcTabRouter]',
- exportAs: 'mdcTabRouter',
- providers: [{provide: AbstractMdcTabDirective, useExisting: forwardRef(() => MdcTabRouterDirective) }]
-})
-export class MdcTabRouterDirective extends AbstractMdcTabDirective {
- @ContentChildren(RouterLink, {descendants: true}) _links: QueryList;
- @ContentChildren(RouterLinkWithHref, {descendants: true}) _linksWithHrefs: QueryList;
- private routerActive: RouterActiveDetector;
-
- constructor(rndr: Renderer2, root: ElementRef, registry: MdcEventRegistry, private router: Router,
- @Optional() private link?: RouterLink, @Optional() private linkWithHref?: RouterLinkWithHref) {
- super(rndr, root, registry);
- }
-
- ngOnDestroy() {
- this.routerActive.destroy();
- this.routerActive = null;
- super.ngOnDestroy();
- }
-
- ngAfterContentInit(): void {
- super.ngAfterContentInit();
- this.routerActive = new RouterActiveDetector(this, this._links, this._linksWithHrefs, this.router,
- this.link, this.linkWithHref);
- this.routerActive.init();
- }
-
- ngOnChanges(): void {
- if (this.routerActive)
- this.routerActive.update();
- }
-
- /** @docs-private */
- isRouterActive() {
- return this._active;
- }
-
- /** @docs-private */
- setRouterActive(active: boolean) {
- this._active = active;
- if (active)
- this._adapter.notifySelected();
- }
-}
diff --git a/bundle/src/components/text-field/mdc.text-field.adapter.ts b/bundle/src/components/text-field/mdc.text-field.adapter.ts
deleted file mode 100644
index 33dbeb7..0000000
--- a/bundle/src/components/text-field/mdc.text-field.adapter.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { MDCTextFieldHelperTextFoundation } from '@material/textfield/helper-text';
-
-/** @docs-private */
-export interface MdcTextFieldHelperTextAdapter {
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- hasClass: (className: string) => void,
- setAttr: (name: string, value: string) => void,
- removeAttr: (name: string) => void,
- setContent: (content: string) => void
-}
-
-/** @docs-private */
-export interface MdcTextFieldIconAdapter {
- getAttr: (attr: string) => string,
- setAttr: (name: string, value: string) => void,
- removeAttr: (name: string) => void,
- setContent: (content: string) => void,
- registerInteractionHandler: (evtType: string, handler: EventListener) => void,
- deregisterInteractionHandler: (evtType: string, handler: EventListener) => void,
- notifyIconAction: () => void
-}
-
-/** @docs-private */
-export interface MdcTextFieldAdapter {
- addClass: (className: string) => void,
- removeClass: (className: string) => void,
- hasClass: (className: string) => boolean,
- registerTextFieldInteractionHandler: (evtType: string, handler: EventListener) => void,
- deregisterTextFieldInteractionHandler: (evtType: string, handler: EventListener) => void,
- registerInputInteractionHandler: (evtType: string, handler: EventListener) => void,
- deregisterInputInteractionHandler: (evtType: string, handler: EventListener) => void,
- registerValidationAttributeChangeHandler: (handler: (arg: Array) => void) => MutationObserver,
- deregisterValidationAttributeChangeHandler: (observer: MutationObserver) => void,
- getNativeInput: () => {value: string, disabled: boolean, validity: {badInput: boolean, valid: boolean}},
- isFocused: () => boolean,
- isRtl: () => boolean,
- activateLineRipple: () => void,
- deactivateLineRipple: () => void,
- setLineRippleTransformOrigin: (normalizedX: number) => void
- shakeLabel: (shouldShake: boolean) => void,
- floatLabel: (shouldFloat: boolean) => void,
- hasLabel: () => boolean,
- getLabelWidth: () => number,
- hasOutline: () => boolean,
- notchOutline: (labelWidth: number, isRtl: boolean) => void,
- closeOutline: () => void
-}
diff --git a/bundle/src/components/text-field/mdc.text-field.directive.spec.ts b/bundle/src/components/text-field/mdc.text-field.directive.spec.ts
new file mode 100644
index 0000000..ddde9e2
--- /dev/null
+++ b/bundle/src/components/text-field/mdc.text-field.directive.spec.ts
@@ -0,0 +1,477 @@
+import { TestBed, fakeAsync, ComponentFixture, tick, flush } from '@angular/core/testing';
+import { Component, Type } from '@angular/core';
+import { MdcFloatingLabelDirective } from '../floating-label/mdc.floating-label.directive';
+import { MdcNotchedOutlineDirective, MdcNotchedOutlineNotchDirective } from '../notched-outline/mdc.notched-outline.directive';
+import { MdcTextFieldDirective, MdcTextFieldInputDirective, MdcTextFieldIconDirective,
+ MdcTextFieldHelperLineDirective, MdcTextFieldHelperTextDirective } from './mdc.text-field.directive';
+import { hasRipple } from '../../testutils/page.test';
+import { By } from '@angular/platform-browser';
+import { FormsModule } from '@angular/forms';
+
+describe('MdcTextFieldDirective', () => {
+ it('filled: should render the text-field with ripple and label', fakeAsync(() => {
+ const { fixture } = setup();
+ const root = fixture.nativeElement.querySelector('.mdc-text-field');
+ expect(root.children.length).toBe(4);
+ expect(root.children[0].classList).toContain('mdc-text-field__ripple');
+ expect(root.children[1].classList).toContain('mdc-text-field__input');
+ expect(root.children[2].classList).toContain('mdc-floating-label');
+ expect(root.children[3].classList).toContain('mdc-line-ripple');
+ expect(hasRipple(root)).toBe(true, 'the ripple element should be attached');
+
+ // input must be labelled by the floating label:
+ expect(root.children[2].id).toMatch(/mdc-u-id-.*/);
+ expect(root.children[1].getAttribute('aria-labelledby')).toBe(root.children[2].id);
+ }));
+
+ it('filled: floating label must float when input has focus', fakeAsync(() => {
+ const { fixture, element } = setup();
+ const floatingLabelElm = fixture.nativeElement.querySelector('.mdc-floating-label');
+ expect(floatingLabelElm.classList).not.toContain('mdc-floating-label--float-above');
+ element.dispatchEvent(new Event('focus')); tick();
+ expect(floatingLabelElm.classList).toContain('mdc-floating-label--float-above');
+ element.dispatchEvent(new Event('blur')); tick();
+ expect(floatingLabelElm.classList).not.toContain('mdc-floating-label--float-above');
+ }));
+
+ it('outlined: should render the text-field with outline and label', fakeAsync(() => {
+ const { fixture, testComponent } = setup();
+ testComponent.outlined = true;
+ fixture.detectChanges(); tick(5); fixture.detectChanges();
+ const root = fixture.nativeElement.querySelector('.mdc-text-field');
+ expect(root.children.length).toBe(2);
+ expect(root.children[0].classList).toContain('mdc-text-field__input');
+ expect(root.children[1].classList).toContain('mdc-notched-outline');
+ const notchedOutline = root.children[1];
+ expect(notchedOutline.children.length).toBe(3);
+ expect(notchedOutline.children[0].classList).toContain('mdc-notched-outline__leading');
+ expect(notchedOutline.children[1].classList).toContain('mdc-notched-outline__notch');
+ expect(notchedOutline.children[2].classList).toContain('mdc-notched-outline__trailing');
+ const notch = notchedOutline.children[1];
+ expect(notch.children.length).toBe(1);
+ expect(notch.children[0].classList).toContain('mdc-floating-label');
+ const floatingLabel = notch.children[0];
+
+ expect(hasRipple(root)).toBe(false, 'no ripple allowed on outlined inputs');
+
+ // input must be labelled by the floating label:
+ expect(floatingLabel.id).toMatch(/mdc-u-id-.*/);
+ expect(root.children[0].getAttribute('aria-labelledby')).toBe(floatingLabel.id);
+ }));
+
+ it('value can be changed programmatically', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup();
+ expect(testComponent.value).toBe(null);
+ expect(element.value).toBe('');
+ setAndCheck(fixture, 'ab');
+ setAndCheck(fixture, '');
+ setAndCheck(fixture, ' ');
+ setAndCheck(fixture, null);
+ }));
+
+ it('value can be changed by user', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+
+ expect(testComponent.value).toBe(null);
+ expect(element.value).toEqual('');
+ typeAndCheck(fixture, 'abc');
+ typeAndCheck(fixture, '');
+ }));
+
+ it('can be disabled', fakeAsync(() => {
+ const { fixture, testComponent, element, input } = setup();
+
+ testComponent.disabled = true;
+ fixture.detectChanges();
+ expect(element.disabled).toBe(true);
+ expect(input.disabled).toBe(true);
+ expect(testComponent.disabled).toBe(true);
+ const field = fixture.debugElement.query(By.directive(MdcTextFieldDirective)).injector.get(MdcTextFieldDirective);
+ expect(field['isRippleSurfaceDisabled']()).toBe(true);
+ expect(field['root'].nativeElement.classList).toContain('mdc-text-field--disabled');
+
+ testComponent.disabled = false;
+ fixture.detectChanges();
+ expect(element.disabled).toBe(false);
+ expect(input.disabled).toBe(false);
+ expect(testComponent.disabled).toBe(false);
+ expect(field['isRippleSurfaceDisabled']()).toBe(false);
+ expect(field['root'].nativeElement.classList).not.toContain('mdc-text-field--disabled');
+ }));
+
+ it('without label', fakeAsync(() => {
+ const { fixture, testComponent } = setup();
+ const field = fixture.debugElement.query(By.directive(MdcTextFieldDirective)).injector.get(MdcTextFieldDirective);
+ expect(field['root'].nativeElement.classList).not.toContain('mdc-text-field--no-label');
+ testComponent.labeled = false;
+ fixture.detectChanges(); tick(5);
+ expect(field['root'].nativeElement.classList).toContain('mdc-text-field--no-label');
+ }));
+
+ it('textarea', fakeAsync(() => {
+ const { fixture } = setup(TestTextareaComponent);
+ const root = fixture.nativeElement.querySelector('.mdc-text-field');
+ expect(root.classList).toContain('mdc-text-field--textarea');
+ expect(root.children.length).toBe(2);
+ expect(root.children[0].classList).toContain('mdc-text-field__input');
+ expect(root.children[1].classList).toContain('mdc-notched-outline');
+ expect(hasRipple(root)).toBe(false, 'no ripple allowed on outlined/textarea inputs');
+ checkFloating(fixture, false);
+ typeAndCheck(fixture, 'typing text\nin my textarea', TestTextareaComponent);
+ }));
+
+ it('helper text', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup(TestWithHelperTextComponent);
+ testComponent.withHelperText = true;
+ const helperText: HTMLElement = fixture.nativeElement.querySelector('.mdc-text-field-helper-text');
+ expect(helperText.id).toMatch(/mdc-u-id-[0-9]+/);
+ const helperId = helperText.id;
+ expect(helperText.classList).not.toContain('mdc-text-field-helper-text--persistent');
+ expect(helperText.classList).not.toContain('mdc-text-field-helper-text--validation-msg');
+ expect(element.getAttribute('aria-controls')).toBe(helperId);
+ expect(element.getAttribute('aria-describedby')).toBe(helperId);
+ testComponent.persistent = true;
+ fixture.detectChanges(); flush();
+ expect(helperText.classList).toContain('mdc-text-field-helper-text--persistent');
+ expect(helperText.classList).not.toContain('mdc-text-field-helper-text--validation-msg');
+ testComponent.persistent = false;
+ testComponent.validation = true;
+ fixture.detectChanges(); flush();
+ expect(helperText.classList).not.toContain('mdc-text-field-helper-text--persistent');
+ expect(helperText.classList).toContain('mdc-text-field-helper-text--validation-msg');
+ }));
+
+ it('helper text dynamic ids', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup(TestWithHelperTextDynamicIdComponent);
+ testComponent.withHelperText = true;
+ const helperText: HTMLElement = fixture.nativeElement.querySelector('.mdc-text-field-helper-text');
+ expect(helperText.id).toBe('someId');
+ expect(element.getAttribute('aria-controls')).toBe('someId');
+ expect(element.getAttribute('aria-describedby')).toBe('someId');
+ testComponent.helperId = 'otherId';
+ fixture.detectChanges(); flush();
+ expect(helperText.id).toBe('otherId');
+ expect(element.getAttribute('aria-controls')).toBe('otherId');
+ expect(element.getAttribute('aria-describedby')).toBe('otherId');
+ }));
+
+ it('icons', fakeAsync(() => {
+ const { fixture, testComponent } = setup();
+ const root = fixture.nativeElement.querySelector('.mdc-text-field');
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon').length).toBe(0);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--leading').length).toBe(0);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--trailing').length).toBe(0);
+ expect(root.classList).not.toContain('mdc-text-field--with-leading-icon');
+ expect(root.classList).not.toContain('mdc-text-field--with-trailing-icon');
+ testComponent.leadingIcon = true;
+ fixture.detectChanges(); tick(5); fixture.detectChanges();
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon').length).toBe(1);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--leading').length).toBe(1);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--trailing').length).toBe(0);
+ expect(root.classList).toContain('mdc-text-field--with-leading-icon');
+ expect(root.classList).not.toContain('mdc-text-field--with-trailing-icon');
+ testComponent.trailingIcon = true;
+ fixture.detectChanges(); tick(5); fixture.detectChanges();
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon').length).toBe(2);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--leading').length).toBe(1);
+ expect(fixture.nativeElement.querySelectorAll('.mdc-text-field__icon--trailing').length).toBe(1);
+ expect(root.classList).toContain('mdc-text-field--with-leading-icon');
+ expect(root.classList).toContain('mdc-text-field--with-trailing-icon');
+
+ const leadIcon = fixture.nativeElement.querySelector('.mdc-text-field__icon--leading');
+ const trailIcon = fixture.nativeElement.querySelector('.mdc-text-field__icon--trailing');
+ expect(leadIcon.getAttribute('role')).toBeNull(); // no interaction -> no role
+ expect(trailIcon.getAttribute('role')).toBe('button');
+ expect(leadIcon.tabIndex).toBe(-1); // no interaction -> no tabIndex
+ expect(trailIcon.tabIndex).toBe(0);
+ testComponent.disabled = true;
+ fixture.detectChanges(); tick(5); fixture.detectChanges();
+ expect(trailIcon.getAttribute('role')).toBeNull(); // disabled -> no role
+ expect(trailIcon.tabIndex).toBe(-1); // disabled -> no tabIndex
+ testComponent.disabled = false;
+ fixture.detectChanges(); tick(5); fixture.detectChanges();
+
+ // interactions:
+ expect(testComponent.trailingIconInteractions).toBe(0);
+ trailIcon.dispatchEvent(new Event('click'));
+ fixture.detectChanges();
+ expect(testComponent.trailingIconInteractions).toBe(1);
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-text-field__input');
+ const input = fixture.debugElement.query(By.directive(MdcTextFieldInputDirective))?.injector.get(MdcTextFieldInputDirective);
+ testComponent.value = value;
+ fixture.detectChanges(); flush();
+ expect(element.value).toBe(value || '');
+ expect(input.value).toBe(value || '');
+ expect(testComponent.value).toBe(value);
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ function typeAndCheck(fixture: ComponentFixture, value: string, type: Type = TestComponent) {
+ const testComponent = fixture.debugElement.injector.get(type);
+ const element = fixture.nativeElement.querySelector('.mdc-text-field__input');
+ const input = fixture.debugElement.query(By.directive(MdcTextFieldInputDirective))?.injector.get(MdcTextFieldInputDirective);
+ element.value = value;
+ element.dispatchEvent(new Event('focus'));
+ element.dispatchEvent(new Event('input'));
+ element.dispatchEvent(new Event('blur')); // focus/blur events triggered for testing label float depending on value after blur
+ tick(); fixture.detectChanges();
+ expect(element.value).toBe(value);
+ expect(input.value).toBe(value);
+ expect(testComponent.value).toBe(value);
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ @Component({
+ template: `
+
+ phone
+
+ event
+
+
+ Floating Label
+
+
+ Floating Label
+
+ `
+ })
+ class TestComponent {
+ value: any = null;
+ outlined: any = null;
+ disabled: any = null;
+ labeled = true;
+ withHelperText = false;
+ leadingIcon = false;
+ trailingIcon = false;
+ trailingIconInteractions = 0;
+ onInput(e) {
+ this.value = e.target.value;
+ }
+ trailingIconInteract() {
+ ++this.trailingIconInteractions;
+ }
+ }
+
+ @Component({
+ template: `
+
+
+
+
+ Floating Label
+
+
+
+ `
+ })
+ class TestTextareaComponent {
+ value: any = null;
+ onInput(e) {
+ this.value = e.target.value;
+ }
+ }
+
+ @Component({
+ template: `
+
+
+
+
+ Floating Label
+
+
+ Floating Label
+
+
+ `
+ })
+ class TestWithHelperTextComponent {
+ value: any = null;
+ persistent: any = null;
+ validation: any = null;
+ onInput(e) {
+ this.value = e.target.value;
+ }
+ }
+
+ @Component({
+ template: `
+
+
+ Floating Label
+
+
+ `
+ })
+ class TestWithHelperTextDynamicIdComponent {
+ value: any = null;
+ helperId = 'someId';
+ onInput(e) {
+ this.value = e.target.value;
+ }
+ }
+
+ function setup(compType: Type = TestComponent) {
+ const fixture = TestBed.configureTestingModule({
+ declarations: [
+ MdcTextFieldDirective, MdcTextFieldInputDirective, MdcTextFieldIconDirective,
+ MdcTextFieldHelperLineDirective, MdcTextFieldHelperTextDirective,
+ MdcFloatingLabelDirective,
+ MdcNotchedOutlineNotchDirective, MdcNotchedOutlineDirective,
+ compType]
+ }).createComponent(compType);
+ fixture.detectChanges(); flush();
+ const testComponent = fixture.debugElement.injector.get(compType);
+ const input = fixture.debugElement.query(By.directive(MdcTextFieldInputDirective))?.injector.get(MdcTextFieldInputDirective);
+ const element: HTMLInputElement = fixture.nativeElement.querySelector('.mdc-text-field__input');
+ return { fixture, testComponent, input, element };
+ }
+});
+
+describe('MdcTextFieldDirective with FormsModule', () => {
+ it('ngModel can be set programmatically', fakeAsync(() => {
+ const { fixture, testComponent, element } = setup();
+ expect(testComponent.value).toBe(null);
+ expect(element.value).toBe('');
+ setAndCheck(fixture, 'ab');
+ setAndCheck(fixture, '');
+ setAndCheck(fixture, ' ');
+ setAndCheck(fixture, null);
+ }));
+
+ it('ngModel can be changed by updating value property', fakeAsync(() => {
+ const { fixture, testComponent, input } = setup();
+
+ input.value = 'new value';
+ fixture.detectChanges(); tick();
+ expect(testComponent.value).toBe('new value');
+ checkFloating(fixture, true);
+
+ input.value = '';
+ fixture.detectChanges(); tick();
+ expect(testComponent.value).toBe('');
+ checkFloating(fixture, false);
+
+ input.value = null; // the browser will change this to ''
+ fixture.detectChanges(); tick();
+ expect(testComponent.value).toBe('');
+ checkFloating(fixture, false);
+ }));
+
+ it('ngModel can be changed by user', fakeAsync(() => {
+ const { fixture, element, testComponent } = setup();
+
+ expect(testComponent.value).toBe(null);
+ expect(element.value).toEqual('');
+ typeAndCheck(fixture, 'abc');
+ typeAndCheck(fixture, '');
+ }));
+
+ it('can be disabled', fakeAsync(() => {
+ const { fixture, testComponent, element, input } = setup();
+
+ testComponent.disabled = true;
+ fixture.detectChanges(); tick(); fixture.detectChanges();
+ expect(element.disabled).toBe(true);
+ expect(input.disabled).toBe(true);
+ expect(testComponent.disabled).toBe(true);
+ const field = fixture.debugElement.query(By.directive(MdcTextFieldDirective)).injector.get(MdcTextFieldDirective);
+ expect(field['isRippleSurfaceDisabled']()).toBe(true);
+ expect(field['root'].nativeElement.classList).toContain('mdc-text-field--disabled');
+
+ testComponent.disabled = false;
+ fixture.detectChanges(); tick(); fixture.detectChanges();
+ expect(element.disabled).toBe(false);
+ expect(input.disabled).toBe(false);
+ expect(testComponent.disabled).toBe(false);
+ expect(field['isRippleSurfaceDisabled']()).toBe(false);
+ expect(field['root'].nativeElement.classList).not.toContain('mdc-text-field--disabled');
+ }));
+
+ function setAndCheck(fixture: ComponentFixture, value: any) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-text-field__input');
+ const input = fixture.debugElement.query(By.directive(MdcTextFieldInputDirective))?.injector.get(MdcTextFieldInputDirective);
+ testComponent.value = value;
+ fixture.detectChanges(); flush();
+ expect(element.value).toBe(value || '');
+ expect(input.value).toBe(value || '');
+ expect(testComponent.value).toBe(value);
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ function typeAndCheck(fixture: ComponentFixture, value: string) {
+ const testComponent = fixture.debugElement.injector.get(TestComponent);
+ const element = fixture.nativeElement.querySelector('.mdc-text-field__input');
+ const input = fixture.debugElement.query(By.directive(MdcTextFieldInputDirective))?.injector.get(MdcTextFieldInputDirective);
+ element.value = value;
+ element.dispatchEvent(new Event('focus'));
+ element.dispatchEvent(new Event('input'));
+ element.dispatchEvent(new Event('blur')); // focus/blur events triggered for testing label float depending on value after blur
+ tick(); fixture.detectChanges();
+ expect(element.value).toBe(value);
+ expect(input.value).toBe(value);
+ expect(testComponent.value).toBe(value);
+
+ checkFloating(fixture, value != null && value.length > 0);
+ }
+
+ @Component({
+ template: `
+
+