diff --git a/package.json b/package.json index e52f58cb6c78..48dca766d811 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "minimatch": "^3.0.4", "minimist": "^1.2.0", "moment": "^2.18.1", + "sass": "^1.24.4", "parse5": "^5.0.0", "protractor": "^5.4.2", "requirejs": "^2.3.6", diff --git a/src/cdk/coercion/public-api.ts b/src/cdk/coercion/public-api.ts index a33a133e56bb..69d9eada7376 100644 --- a/src/cdk/coercion/public-api.ts +++ b/src/cdk/coercion/public-api.ts @@ -11,3 +11,4 @@ export * from './number-property'; export * from './array'; export * from './css-pixel-value'; export * from './element'; +export * from './string-array'; diff --git a/src/cdk/coercion/string-array.spec.ts b/src/cdk/coercion/string-array.spec.ts new file mode 100644 index 000000000000..99bb3b3da5c8 --- /dev/null +++ b/src/cdk/coercion/string-array.spec.ts @@ -0,0 +1,32 @@ +import {coerceStringArray} from '@angular/cdk/coercion/string-array'; + +describe('coerceStringArray', () => { + it('should split a string', () => { + expect(coerceStringArray('x y z 1')).toEqual(['x', 'y', 'z', '1']); + }); + + it('should map values to string in an array', () => { + expect(coerceStringArray(['x', 1, true, null, undefined, ['arr', 'ay'], { data: false }])) + .toEqual(['x', '1', 'true', 'null', 'undefined', 'arr,ay', '[object Object]']); + }); + + it('should work with a custom delimiter', () => { + expect(coerceStringArray('1::2::3::4', '::')).toEqual(['1', '2', '3', '4']); + }); + + it('should trim values and remove empty values', () => { + expect(coerceStringArray(', x, ,, ', ',')).toEqual(['x']); + }); + + it('should map non-string values to string', () => { + expect(coerceStringArray(0)).toEqual(['0']); + }); + + it('should return an empty array for null', () => { + expect(coerceStringArray(null)).toEqual([]); + }); + + it('should return an empty array for undefined', () => { + expect(coerceStringArray(undefined)).toEqual([]); + }); +}); diff --git a/src/cdk/coercion/string-array.ts b/src/cdk/coercion/string-array.ts new file mode 100644 index 000000000000..23c17e5b10dc --- /dev/null +++ b/src/cdk/coercion/string-array.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Coerces a value to an array of trimmed non-empty strings. + * Any input that is not an array, `null` or `undefined` will be turned into a string + * via `toString()` and subsequently split with the given separator. + * `null` and `undefined` will result in an empty array. + * This results in the following outcomes: + * - `null` -> `[]` + * - `[null]` -> `["null"]` + * - `["a", "b ", " "]` -> `["a", "b"]` + * - `[1, [2, 3]]` -> `["1", "2,3"]` + * - `[{ a: 0 }]` -> `["[object Object]"]` + * - `{ a: 0 }` -> `["[object", "Object]"]` + * + * Useful for defining CSS classes or table columns. + * @param value the value to coerce into an array of strings + * @param separator split-separator if value isn't an array + */ +export function coerceStringArray(value: any, separator: string | RegExp = /\s+/): string[] { + const result = []; + + if (value != null) { + const sourceValues = Array.isArray(value) ? value : `${value}`.split(separator); + for (const sourceValue of sourceValues) { + const trimmedString = `${sourceValue}`.trim(); + if (trimmedString) { + result.push(trimmedString); + } + } + } + + return result; +} + diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index 4e96f737c907..434a52db5673 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -7,7 +7,7 @@ */ import {ActiveDescendantKeyManager} from '@angular/cdk/a11y'; -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {BooleanInput, coerceBooleanProperty, coerceStringArray} from '@angular/cdk/coercion'; import { AfterContentInit, ChangeDetectionStrategy, @@ -154,10 +154,10 @@ export class MatAutocomplete extends _MatAutocompleteMixinBase implements AfterC * inside the overlay container to allow for easy styling. */ @Input('class') - set classList(value: string) { + set classList(value: string | string[]) { if (value && value.length) { - this._classList = value.split(' ').reduce((classList, className) => { - classList[className.trim()] = true; + this._classList = coerceStringArray(value).reduce((classList, className) => { + classList[className] = true; return classList; }, {} as {[key: string]: boolean}); } else { diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index 3f104a5c4253..e2ec693f1fab 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -1761,6 +1761,47 @@ describe('MatDatepicker', () => { })); }); + describe('panelClass input', () => { + let fixture: ComponentFixture; + let testComponent: PanelClassDatepicker; + + beforeEach(fakeAsync(() => { + fixture = createComponent(PanelClassDatepicker, [MatNativeDateModule]); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + })); + + afterEach(fakeAsync(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + flush(); + })); + + it('should accept a single class', () => { + testComponent.panelClass = 'foobar'; + fixture.detectChanges(); + expect(testComponent.datepicker.panelClass).toEqual(['foobar']); + }); + + it('should accept multiple classes', () => { + testComponent.panelClass = 'foo bar'; + fixture.detectChanges(); + expect(testComponent.datepicker.panelClass).toEqual(['foo', 'bar']); + }); + + it('should work with ngClass', () => { + testComponent.panelClass = ['foo', 'bar']; + testComponent.datepicker.open(); + fixture.detectChanges(); + + const datepickerContent = testComponent.datepicker['_dialogRef']!!.componentInstance; + const actualClasses = datepickerContent._elementRef.nativeElement.children[1].classList; + expect(actualClasses.contains('foo')).toBe(true); + expect(actualClasses.contains('bar')).toBe(true); + }); + }); + }); @@ -2071,3 +2112,15 @@ class DatepickerToggleWithNoDatepicker {} `, }) class DatepickerInputWithNoDatepicker {} + +@Component({ + template: ` + + + `, +}) +class PanelClassDatepicker { + date = new Date(0); + panelClass: any; + @ViewChild('d') datepicker: MatDatepicker; +} diff --git a/src/material/datepicker/datepicker.ts b/src/material/datepicker/datepicker.ts index e395e4cf9e3e..f9aacd5817d9 100644 --- a/src/material/datepicker/datepicker.ts +++ b/src/material/datepicker/datepicker.ts @@ -7,7 +7,7 @@ */ import {Directionality} from '@angular/cdk/bidi'; -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {BooleanInput, coerceBooleanProperty, coerceStringArray} from '@angular/cdk/coercion'; import {ESCAPE, UP_ARROW} from '@angular/cdk/keycodes'; import { Overlay, @@ -235,8 +235,16 @@ export class MatDatepicker implements OnDestroy, CanColor { */ @Output() readonly monthSelected: EventEmitter = new EventEmitter(); - /** Classes to be passed to the date picker panel. Supports the same syntax as `ngClass`. */ - @Input() panelClass: string | string[]; + /** + * Classes to be passed to the date picker panel. + * Supports string and string array values, similar to `ngClass`. + */ + @Input() + get panelClass(): string | string[] { return this._panelClass; } + set panelClass(value: string | string[]) { + this._panelClass = coerceStringArray(value); + } + private _panelClass: string[]; /** Function that can be used to add custom CSS classes to dates. */ @Input() dateClass: (date: D) => MatCalendarCellCssClasses; diff --git a/tools/public_api_guard/cdk/coercion.d.ts b/tools/public_api_guard/cdk/coercion.d.ts index 1cf0d8e786ec..f1e266b511a4 100644 --- a/tools/public_api_guard/cdk/coercion.d.ts +++ b/tools/public_api_guard/cdk/coercion.d.ts @@ -13,4 +13,6 @@ export declare function coerceElement(elementOrRef: ElementRef | T): T; export declare function coerceNumberProperty(value: any): number; export declare function coerceNumberProperty(value: any, fallback: D): number | D; +export declare function coerceStringArray(value: any, separator?: string | RegExp): string[]; + export declare type NumberInput = string | number | null | undefined; diff --git a/tools/public_api_guard/material/autocomplete.d.ts b/tools/public_api_guard/material/autocomplete.d.ts index 52719b4d4280..a9f9daaecbec 100644 --- a/tools/public_api_guard/material/autocomplete.d.ts +++ b/tools/public_api_guard/material/autocomplete.d.ts @@ -28,7 +28,7 @@ export declare class MatAutocomplete extends _MatAutocompleteMixinBase implement _keyManager: ActiveDescendantKeyManager; get autoActiveFirstOption(): boolean; set autoActiveFirstOption(value: boolean); - set classList(value: string); + set classList(value: string | string[]); readonly closed: EventEmitter; displayWith: ((value: any) => string) | null; id: string; diff --git a/tools/public_api_guard/material/datepicker.d.ts b/tools/public_api_guard/material/datepicker.d.ts index 73ccfa9891a7..815f8928057d 100644 --- a/tools/public_api_guard/material/datepicker.d.ts +++ b/tools/public_api_guard/material/datepicker.d.ts @@ -129,7 +129,8 @@ export declare class MatDatepicker implements OnDestroy, CanColor { get opened(): boolean; set opened(value: boolean); openedStream: EventEmitter; - panelClass: string | string[]; + get panelClass(): string | string[]; + set panelClass(value: string | string[]); get startAt(): D | null; set startAt(value: D | null); startView: 'month' | 'year' | 'multi-year'; diff --git a/yarn.lock b/yarn.lock index 8bdbe4ccb4db..5d4689bab71b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7529,7 +7529,6 @@ matchdep@^2.0.0: micromatch "^3.0.4" resolve "^1.4.0" stack-trace "0.0.10" - material-components-web@5.0.0-canary.29b89dbc1.0: version "5.0.0-canary.29b89dbc1.0" resolved "https://registry.yarnpkg.com/material-components-web/-/material-components-web-5.0.0-canary.29b89dbc1.0.tgz#308a73d88918538b591d7ab0825b6dea88a51b54"