diff --git a/angular.json b/angular.json index 16ddd1fbe9..ad42396ec9 100644 --- a/angular.json +++ b/angular.json @@ -1437,6 +1437,39 @@ }, "schematics": {} }, + "datepicker": { + "projectType": "library", + "root": "libs/barista-components/experimental/datepicker", + "sourceRoot": "libs/barista-components/experimental/datepicker/src", + "prefix": "dt", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "libs/barista-components/experimental/datepicker/tsconfig.lib.json", + "libs/barista-components/experimental/datepicker/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**", + "!libs/barista-components/experimantal/datepicker/**" + ] + } + }, + "lint-styles": { + "builder": "./dist/libs/workspace:stylelint", + "options": { + "stylelintConfig": ".stylelintrc", + "reportFile": "dist/stylelint/report.xml", + "exclude": ["**/node_modules/**"], + "files": [ + "libs/barista-components/experimental/datepicker/**/*.scss" + ] + } + } + }, + "schematics": {} + }, "drawer": { "projectType": "library", "root": "libs/barista-components/drawer", diff --git a/apps/demos/src/app-routing.module.ts b/apps/demos/src/app-routing.module.ts index ca280d1385..0f61dedef7 100644 --- a/apps/demos/src/app-routing.module.ts +++ b/apps/demos/src/app-routing.module.ts @@ -323,7 +323,12 @@ import { DtExampleTreeTableSimple, DtExampleComboboxCustomOptionHeight, DtExampleFilterFieldInfiniteDataDepth, +<<<<<<< HEAD DtExampleSelectCustomValueTemplate, +======= + DtExampleDatepickerDark, + DtExampleDatepickerDefault +>>>>>>> d2067af36... feat(datepicker): Added datepicker component. } from '@dynatrace/examples'; // The Routing Module replaces the routing configuration in the root or feature module. @@ -581,6 +586,8 @@ const ROUTES: Routes = [ { path: 'drawer-dynamic-example', component: DtExampleDrawerDynamic }, { path: 'drawer-nested-example', component: DtExampleDrawerNested }, { path: 'drawer-over-example', component: DtExampleDrawerOver }, + { path: 'datepicker-dark-example', component: DtExampleDatepickerDark}, + { path: 'datepicker-default-example', component: DtExampleDatepickerDefault}, { path: 'drawer-table-default-example', component: DtExampleDrawerTableDefault, diff --git a/apps/demos/src/nav-items.ts b/apps/demos/src/nav-items.ts index 954183f11d..eb7ead0787 100644 --- a/apps/demos/src/nav-items.ts +++ b/apps/demos/src/nav-items.ts @@ -438,6 +438,19 @@ export const DT_DEMOS_EXAMPLE_NAV_ITEMS = [ }, ], }, + { + name: 'datepicker', + examples: [ + { + name: 'datepicker-dark-example', + route: '/datepicker-dark-example', + }, + { + name: 'datepicker-default-example', + route: '/datepicker-default-example', + }, + ], + }, { name: 'drawer', examples: [ diff --git a/apps/dev/src/app.module.ts b/apps/dev/src/app.module.ts index fc4da8590c..1db1e063e3 100644 --- a/apps/dev/src/app.module.ts +++ b/apps/dev/src/app.module.ts @@ -43,6 +43,7 @@ import { ConsumptionDemo } from './consumption/consumption-demo.component'; import { ContainerBreakpointObserverDemo } from './container-breakpoint-observer/container-breakpoint-observer-demo.component'; import { ContextDialogDemo } from './context-dialog/context-dialog-demo.component'; import { CopyToClipboardDemo } from './copy-to-clipboard/copy-to-clipboard-demo.component'; +import { DatepickerDemo } from './datepicker/datepicker-demo.component'; import { DrawerDemo } from './drawer/drawer-demo.component'; import { DrawerTableDemo } from './drawer-table/drawer-table-demo.component'; import { EmptyStateDemo } from './empty-state/empty-state-demo'; @@ -94,6 +95,9 @@ import { DtIconModule } from '@dynatrace/barista-components/icon'; import { DT_DEFAULT_UI_TEST_CONFIG, DT_UI_TEST_CONFIG, + DtNativeDateModule, + DT_OVERLAY_THEMING_CONFIG, + DT_DEFAULT_DARK_THEMING_CONFIG, } from '@dynatrace/barista-components/core'; import { ComboboxDemo } from './combobox/combobox-demo.component'; @@ -111,6 +115,7 @@ export class NoopRouteComponent {} DtIconModule.forRoot({ svgIconLocation: '/assets/icons/{{name}}.svg' }), DevAppDynatraceModule, DragDropModule, + DtNativeDateModule, ], declarations: [ DevApp, @@ -127,7 +132,7 @@ export class NoopRouteComponent {} ConfirmationDialogDemo, ContextDialogDemo, CopyToClipboardDemo, - + DatepickerDemo, DrawerDemo, DrawerTableDemo, ExpandablePanelDemo, @@ -184,6 +189,10 @@ export class NoopRouteComponent {} Location, { provide: LocationStrategy, useClass: PathLocationStrategy }, { provide: DT_UI_TEST_CONFIG, useValue: DT_DEFAULT_UI_TEST_CONFIG }, + { + provide: DT_OVERLAY_THEMING_CONFIG, + useValue: DT_DEFAULT_DARK_THEMING_CONFIG, + }, { provide: STEPPER_GLOBAL_OPTIONS, useValue: { showError: true } }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/dev/src/datepicker/datepicker-demo.component.html b/apps/dev/src/datepicker/datepicker-demo.component.html new file mode 100644 index 0000000000..502fbc13da --- /dev/null +++ b/apps/dev/src/datepicker/datepicker-demo.component.html @@ -0,0 +1,58 @@ +

Datepicker

+ +

Full datepicker

+ + + +Disabled +Enabled Time Mode + +

Calendar

+ + + +

Calendar body only

+ + + +

Timepicker

+ + + +Disabled + +
+

Full Dark Mode Datepicker

+ + + Disabled + Enabled Time Mode + +

Dark Mode Calendar

+ + + +

Dark Mode Calendar Body Only

+ + + +

Dark Mode Timepicker

+ + + Disabled +
diff --git a/apps/dev/src/datepicker/datepicker-demo.component.scss b/apps/dev/src/datepicker/datepicker-demo.component.scss new file mode 100644 index 0000000000..2fca28ddd8 --- /dev/null +++ b/apps/dev/src/datepicker/datepicker-demo.component.scss @@ -0,0 +1,7 @@ +.dt-checkbox { + margin-top: 10px; +} + +.dt-example-dark h2 { + color: white; +} diff --git a/apps/dev/src/datepicker/datepicker-demo.component.ts b/apps/dev/src/datepicker/datepicker-demo.component.ts new file mode 100644 index 0000000000..f00d97d0be --- /dev/null +++ b/apps/dev/src/datepicker/datepicker-demo.component.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'demo-component', + templateUrl: 'datepicker-demo.component.html', + styleUrls: ['datepicker-demo.component.scss'], +}) +export class DatepickerDemo { + startAt = new Date(2020, 7, 31); + isDatepickerDisabled = false; + isTimepickerDisabled = false; + isDarkDatepickerDisabled = false; + isDarkTimepickerDisabled = false; + isDatepickerTimeEnabled = true; + isDarkDatepickerTimeEnabled = true; +} diff --git a/apps/dev/src/devapp-routing.module.ts b/apps/dev/src/devapp-routing.module.ts index 3c24581c39..9549a23cca 100644 --- a/apps/dev/src/devapp-routing.module.ts +++ b/apps/dev/src/devapp-routing.module.ts @@ -31,6 +31,7 @@ import { ConsumptionDemo } from './consumption/consumption-demo.component'; import { ContainerBreakpointObserverDemo } from './container-breakpoint-observer/container-breakpoint-observer-demo.component'; import { ContextDialogDemo } from './context-dialog/context-dialog-demo.component'; import { CopyToClipboardDemo } from './copy-to-clipboard/copy-to-clipboard-demo.component'; +import { DatepickerDemo } from './datepicker/datepicker-demo.component'; import { DrawerDemo } from './drawer/drawer-demo.component'; import { DrawerTableDemo } from './drawer-table/drawer-table-demo.component'; import { EmptyStateDemo } from './empty-state/empty-state-demo'; @@ -95,6 +96,7 @@ const routes: Routes = [ { path: 'context-dialog', component: ContextDialogDemo }, { path: 'confirmation-dialog', component: ConfirmationDialogDemo }, { path: 'copy-to-clipboard', component: CopyToClipboardDemo }, + { path: 'datepicker', component: DatepickerDemo }, { path: 'drawer', component: DrawerDemo }, { path: 'drawer-table', component: DrawerTableDemo }, { path: 'empty-state', component: EmptyStateDemo }, diff --git a/apps/dev/src/devapp.component.ts b/apps/dev/src/devapp.component.ts index bee35f665b..03db9d8d13 100644 --- a/apps/dev/src/devapp.component.ts +++ b/apps/dev/src/devapp.component.ts @@ -58,6 +58,7 @@ export class DevApp implements AfterContentInit, OnDestroy { }, { name: 'Context-dialog', route: '/context-dialog' }, { name: 'Copy-to-clipboard', route: '/copy-to-clipboard' }, + { name: 'Datepicker', route: '/datepicker' }, { name: 'Drawer', route: '/drawer' }, { name: 'Drawer-table', route: '/drawer-table' }, { name: 'Empty-state', route: '/empty-state' }, diff --git a/apps/dev/src/dt-components.module.ts b/apps/dev/src/dt-components.module.ts index 4bc8cb3996..22ca853c82 100644 --- a/apps/dev/src/dt-components.module.ts +++ b/apps/dev/src/dt-components.module.ts @@ -30,6 +30,7 @@ import { DtConsumptionModule } from '@dynatrace/barista-components/consumption'; import { DtContainerBreakpointObserverModule } from '@dynatrace/barista-components/container-breakpoint-observer'; import { DtContextDialogModule } from '@dynatrace/barista-components/context-dialog'; import { DtCopyToClipboardModule } from '@dynatrace/barista-components/copy-to-clipboard'; +import { DtDatepickerModule } from '@dynatrace/barista-components/experimental/datepicker'; import { DtDrawerModule } from '@dynatrace/barista-components/drawer'; import { DtDrawerTableModule } from '@dynatrace/barista-components/experimental/drawer-table'; import { DtEmptyStateModule } from '@dynatrace/barista-components/empty-state'; @@ -95,6 +96,7 @@ import { DtComboboxModule } from '@dynatrace/barista-components/experimental/com DtConfirmationDialogModule, DtContextDialogModule, DtCopyToClipboardModule, + DtDatepickerModule, DtDrawerModule, DtDrawerTableModule, DtExpandablePanelModule, diff --git a/libs/barista-components/config.bzl b/libs/barista-components/config.bzl index b183ead056..37965916b8 100644 --- a/libs/barista-components/config.bzl +++ b/libs/barista-components/config.bzl @@ -22,6 +22,7 @@ COMPONENTS = [ "expandable-section", "expandable-text", "experimental/combobox", + "experimental/datepicker", "experimental/drawer-table", "quick-filter", "filter-field", diff --git a/libs/barista-components/core/index.ts b/libs/barista-components/core/index.ts index e3e82bd027..7ed12f398c 100644 --- a/libs/barista-components/core/index.ts +++ b/libs/barista-components/core/index.ts @@ -24,3 +24,4 @@ export * from './src/tree/index'; export * from './src/animations/index'; export * from './src/overlay/index'; export * from './src/testing/index'; +export * from './src/date/index'; diff --git a/libs/barista-components/core/src/date/date-adapter.ts b/libs/barista-components/core/src/date/date-adapter.ts new file mode 100644 index 0000000000..46497f446c --- /dev/null +++ b/libs/barista-components/core/src/date/date-adapter.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { inject, InjectionToken, LOCALE_ID } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +/** InjectionToken for datepicker that can be used to override default locale code. */ +export const DT_DATE_LOCALE = new InjectionToken('DT_DATE_LOCALE', { + providedIn: 'root', + factory: DT_DATE_LOCALE_FACTORY, +}); + +/** @docs-private */ +export function DT_DATE_LOCALE_FACTORY(): string { + return inject(LOCALE_ID); +} + +export abstract class DtDateAdapter { + /** The locale to use for all dates. */ + protected locale: any; + + /** A stream that emits when the locale changes. */ + get localeChanges(): Observable { + return this._localeChanges.asObservable(); + } + protected _localeChanges = new Subject(); + + /** Sets the locale used for all dates. */ + setLocale(locale: any): void { + this.locale = locale; + this._localeChanges.next(); + } + + /** + * Creates a date with the given year, month, and date. + * Does not allow over/under-flow of the month and date. + */ + abstract createDate(year: number, month: number, date: number): D; + + /** Gets today's date. */ + abstract today(): D; + + /** Gets the year component of the given date. */ + abstract getYear(date: D): number; + + /** Gets the month component of the given date. */ + abstract getMonth(date: D): number; + + /** Gets the date of the month component of the given date. */ + abstract getDate(date: D): number; + + /** Gets the day of the week component of the given date. */ + abstract getDayOfWeek(date: D): number; + + /** Gets the first day of the week. */ + abstract getFirstDayOfWeek(): number; + + /** Gets a list of names for the days of the week. */ + abstract getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[]; + + /** Gets the number of days in the month of the given date. */ + abstract getNumDaysInMonth(date: D): number; + + /** Gets a list of names for the dates of the month. */ + abstract getDateNames(): string[]; + + /** Checks whether the given object is considered a date instance by this DateAdapter. */ + abstract isDateInstance(obj: any): obj is D; + + /** Checks whether the given date is valid. */ + abstract isValid(date: D): boolean; + + /** Formats a date as a string according to the given format. */ + abstract format(date: D, displayFormat: Object): string; + + /** + * Adds the given number of years to the date. Years are counted as if flipping 12 pages on the + * calendar for each year and then finding the closest date in the new month. For example when + * adding 1 year to Feb 29, 2016, the resulting date will be Feb 28, 2017. + */ + abstract addCalendarYears(date: D, years: number): D; + + /** + * Adds the given number of months to the date. Months are counted as if flipping a page on the + * calendar for each month and then finding the closest date in the new month. For example when + * adding 1 month to Jan 31, 2017, the resulting date will be Feb 28, 2017. + */ + abstract addCalendarMonths(date: D, months: number): D; + + /** Adds the given number of days to the date. */ + abstract addCalendarDays(date: D, days: number): D; + + /** + * Compares two dates. + * Returns 0 if the dates are equal, + * a number less than 0 if the first date is earlier, + * a number greater than 0 if the first date is later + */ + compareDate(first: D, second: D): number { + return ( + this.getYear(first) - this.getYear(second) || + this.getMonth(first) - this.getMonth(second) || + this.getDate(first) - this.getDate(second) + ); + } + + /** Clamp the given date between min and max dates. */ + clampDate(date: D, min?: D | null, max?: D | null): D { + if (min && this.compareDate(date, min) < 0) { + return min; + } + if (max && this.compareDate(date, max) > 0) { + return max; + } + return date; + } +} diff --git a/libs/barista-components/core/src/date/date-module.ts b/libs/barista-components/core/src/date/date-module.ts new file mode 100644 index 0000000000..6b1ddbabaa --- /dev/null +++ b/libs/barista-components/core/src/date/date-module.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { DtDateAdapter } from './date-adapter'; +import { DtNativeDateAdapter } from './native-date-adapter'; + +@NgModule({ + providers: [{ provide: DtDateAdapter, useClass: DtNativeDateAdapter }], +}) +export class DtNativeDateModule {} diff --git a/libs/barista-components/core/src/date/index.ts b/libs/barista-components/core/src/date/index.ts new file mode 100644 index 0000000000..554460aba5 --- /dev/null +++ b/libs/barista-components/core/src/date/index.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './date-adapter'; +export * from './native-date-adapter'; +export * from './date-module'; diff --git a/libs/barista-components/core/src/date/native-date-adapter.ts b/libs/barista-components/core/src/date/native-date-adapter.ts new file mode 100644 index 0000000000..ee190c1821 --- /dev/null +++ b/libs/barista-components/core/src/date/native-date-adapter.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DtDateAdapter, DT_DATE_LOCALE } from './date-adapter'; +import { Optional, Inject, LOCALE_ID, Injectable } from '@angular/core'; + +/** The default day of the week names to use if Intl API is not available. */ +const DEFAULT_DAY_OF_WEEK_NAMES = { + long: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + narrow: ['S', 'M', 'T', 'W', 'T', 'F', 'S'], +}; + +const DEFAULT_DATE_NAMES = fillArray(31, (i) => String(i + 1)); + +let SUPPORTS_INTL_API: boolean; +try { + SUPPORTS_INTL_API = typeof Intl != 'undefined'; +} catch { + SUPPORTS_INTL_API = false; +} + +/** + * Simple version of the Angular Material's NativeDateAdapter. + * This class can be replaced by Angular's adapter if it is moved to the CDK. + */ +@Injectable() +export class DtNativeDateAdapter extends DtDateAdapter { + constructor( + @Optional() @Inject(DT_DATE_LOCALE) dtDateLocale?: string, + @Optional() @Inject(LOCALE_ID) locale?: string, + ) { + super(); + super.setLocale(dtDateLocale ?? locale ?? 'en-US'); + } + + createDate(year: number, month: number, date: number): Date { + if (month < 0 || month > 11) { + throw Error( + `Invalid month index "${month}". Month index has to be between 0 and 11.`, + ); + } + + if (date < 1) { + throw Error(`Invalid date "${date}". Date has to be greater than 0.`); + } + + const result = createDateWithOverflow(year, month, date); + + if (result.getMonth() != month) { + throw Error(`Invalid date "${date}" for month with index "${month}".`); + } + + return result; + } + + today(): Date { + return new Date(); + } + + getYear(date: Date): number { + return date.getFullYear(); + } + + getMonth(date: Date): number { + return date.getMonth(); + } + + getDate(date: Date): number { + return date.getDate(); + } + + getDayOfWeek(date: Date): number { + return date.getDay(); + } + + getFirstDayOfWeek(): number { + return 1; // Monday + } + + getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { + return DEFAULT_DAY_OF_WEEK_NAMES[style]; + } + + getNumDaysInMonth(date: Date): number { + return this.getDate( + createDateWithOverflow(this.getYear(date), this.getMonth(date) + 1, 0), + ); + } + + getDateNames(): string[] { + if (SUPPORTS_INTL_API) { + const dtf = new Intl.DateTimeFormat(this.locale, { + day: 'numeric', + timeZone: 'utc', + }); + return fillArray(31, (i) => + stripDirectionalityCharacters( + formatDate(dtf, new Date(2017, 0, i + 1)), + ), + ); + } + return DEFAULT_DATE_NAMES; + } + + format(date: Date, displayFormat: Object): string { + displayFormat = { ...displayFormat, timeZone: 'utc' }; + const dtf = new Intl.DateTimeFormat(this.locale, displayFormat); + return stripDirectionalityCharacters(formatDate(dtf, date)); + } + + isValid(date: Date): boolean { + return !isNaN(date.getTime()); + } + + isDateInstance(obj: any): obj is Date { + return obj instanceof Date; + } + + addCalendarYears(date: Date, years: number): Date { + return this.addCalendarMonths(date, years * 12); + } + + addCalendarMonths(date: Date, months: number): Date { + let newDate = createDateWithOverflow( + this.getYear(date), + this.getMonth(date) + months, + this.getDate(date), + ); + + // It's possible to wind up in the wrong month if the original month has more days than the new + // month. In this case we want to go to the last day of the desired month. + // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't + // guarantee this. + if ( + this.getMonth(newDate) != + (((this.getMonth(date) + months) % 12) + 12) % 12 + ) { + newDate = createDateWithOverflow( + this.getYear(newDate), + this.getMonth(newDate), + 0, + ); + } + + return newDate; + } + + addCalendarDays(date: Date, days: number): Date { + return createDateWithOverflow( + this.getYear(date), + this.getMonth(date), + this.getDate(date) + days, + ); + } +} + +function fillArray(length: number, fillFn: (index: number) => T): T[] { + return new Array(length).fill(null).map((_, i) => fillFn(i)); +} + +/** + * Strip out unicode LTR and RTL characters. Edge and IE insert these into formatted dates while + * other browsers do not. We remove them to make output consistent and because they interfere with + * date parsing. + */ +function stripDirectionalityCharacters(str: string): string { + return str.replace(/[\u200e\u200f]/g, ''); +} + +/** + * When converting Date object to string, javascript built-in functions may return wrong + * results because it applies its internal DST rules. The DST rules around the world change + * very frequently, and the current valid rule is not always valid in previous years though. + * We work around this problem building a new Date object which has its internal UTC + * representation with the local date and time. + */ +function formatDate(dtf: Intl.DateTimeFormat, date: Date): string { + const d = new Date( + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ), + ); + return dtf.format(d); +} + +/** Creates a date but allows the month and date to overflow. */ +function createDateWithOverflow( + year: number, + month: number, + date: number, +): Date { + const result = new Date(year, month, date); + + // We need to correct for the fact that JS native Date treats years in range [0, 99] as + // abbreviations for 19xx. + if (year >= 0 && year < 100) { + result.setFullYear(this.getYear(result) - 1900); + } + return result; +} diff --git a/libs/barista-components/core/src/overlay/index.ts b/libs/barista-components/core/src/overlay/index.ts index 45345f97f7..ee4074c640 100644 --- a/libs/barista-components/core/src/overlay/index.ts +++ b/libs/barista-components/core/src/overlay/index.ts @@ -15,3 +15,4 @@ */ export * from './flexible-connected-position-strategy'; +export * from './overlay-theming-configuration'; diff --git a/libs/barista-components/core/src/overlay/overlay-theming-configuration.ts b/libs/barista-components/core/src/overlay/overlay-theming-configuration.ts new file mode 100644 index 0000000000..ad689f2f3c --- /dev/null +++ b/libs/barista-components/core/src/overlay/overlay-theming-configuration.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { coerceElement } from '@angular/cdk/coercion'; +import { ElementRef, InjectionToken } from '@angular/core'; + +export interface DtOverlayThemingConfiguration { + className: string; +} + +export const DT_DEFAULT_DARK_THEMING_CONFIG: DtOverlayThemingConfiguration = { + className: 'dt-theme-dark', +}; + +export const DT_OVERLAY_THEMING_CONFIG = new InjectionToken< + DtOverlayThemingConfiguration +>('DT_OVERLAY_THEMING_CONFIGURATION'); + +export function dtSetOverlayThemeAttribute( + overlayElement: Element, + componentElement: ElementRef | Element, + config: DtOverlayThemingConfiguration, +): void { + const element = coerceElement(componentElement) as Element; + + if (!element) { + return; + } + + overlayElement.classList.add(config.className); +} diff --git a/libs/barista-components/experimental/datepicker/BUILD.bazel b/libs/barista-components/experimental/datepicker/BUILD.bazel new file mode 100644 index 0000000000..08d9a57b69 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/BUILD.bazel @@ -0,0 +1,118 @@ +load("@io_bazel_rules_sass//:defs.bzl", "multi_sass_binary", "sass_library") +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("//tools/bazel_rules:index.bzl", "jest", "ng_module_view_engine", "stylelint") + +package(default_visibility = ["//visibility:public"]) + +ng_module_view_engine( + name = "compile", + srcs = glob( + include = ["**/*.ts"], + exclude = [ + "**/*.spec.ts", + "src/test-setup.ts", + ], + ), + angular_assets = [ + ":styles", + "src/datepicker.html", + "src/calendar.html", + "src/calendar-body.html", + "src/timepicker.html", + "src/timeinput.html", + ], + module_name = "@dynatrace/barista-components/experimental/datepicker", + tsconfig = "tsconfig_lib", + deps = [ + "//libs/barista-components/core:compile", + "//libs/barista-components/form-field:compile", + "//libs/barista-components/theming:compile", + "//libs/barista-components/button:compile", + "//libs/barista-components/icon:compile", + "//libs/barista-components/input:compile", + "//libs/barista-components/overlay:compile", + "@npm//@angular/core", + "@npm//@angular/common", + "@npm//@angular/cdk", + "@npm//@angular/forms", + "@npm//rxjs", + ], +) + +filegroup( + name = "datepicker", + srcs = glob( + include = ["**/*.ts"], + exclude = [ + "**/*.spec.ts", + "src/test-setup.ts", + ], + ) + glob([ + "**/*.html", + "**/*.scss", + ]), +) + +sass_library( + name = "theme", + srcs = ["src/_calendar-body-theme.scss", "src/_calendar-header-theme.scss", "src/_timeinput-theme.scss"] +) + +multi_sass_binary( + name = "styles", + srcs = [ + "src/datepicker.scss", + "src/calendar-body.scss", + "src/calendar.scss", + "src/timepicker.scss", + "src/timeinput.scss", + ":theme" + ] +) + +stylelint( + name = "stylelint", + srcs = glob(["**/*.scss"]), +) + +jest( + name = "test", + srcs = glob(include = ["**/*.spec.ts"]), + jest_config = ":jest.config.json", + setup_file = ":src/test-setup.ts", + ts_config = ":tsconfig_test", + deps = [ + ":compile", + "//libs/testing/browser", + "//libs/barista-components/core:compile", + "//libs/barista-components/form-field:compile", + "//libs/barista-components/theming:compile", + "//libs/barista-components/button:compile", + "//libs/barista-components/icon:compile", + "//libs/barista-components/input:compile", + "//libs/barista-components/overlay:compile", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/platform-browser", + "@npm//@angular/cdk", + "@npm//@angular/forms", + ], +) + +ts_config( + name = "tsconfig_lib", + src = "tsconfig.lib.json", + deps = [ + "tsconfig.json", + "//libs/barista-components:tsconfig", + ], +) + +ts_config( + name = "tsconfig_test", + src = "tsconfig.spec.json", + deps = [ + "tsconfig.json", + "//libs/barista-components:tsconfig", + ], +) diff --git a/libs/barista-components/experimental/datepicker/README.md b/libs/barista-components/experimental/datepicker/README.md new file mode 100644 index 0000000000..a37e269f83 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/README.md @@ -0,0 +1,44 @@ +# Datepicker (experimental) + +## Imports + +You have to import the `DtDatepickerModule` and `DtNativeDateModule` (in case +you would like to use the native date adapter) to use the `dt-datepicker`. The +DtNativeDateModule is based off the functionality available in JavaScript's +native Date object, which is limited when it comes to setting the parse format. +Therefore, if necessary, a custom DateAdapter can be implemented in order to +handle the formatting/parsing library of your choice. + +```typescript +import { NgModule } from '@angular/core'; +import { DtDatepickerModule } from '@dynatrace/barista-components/experimental/datepicker'; +import { DtNativeDateModule } from '@dynatrace/barista-components/core'; + +@NgModule({ + imports: [DtDatepickerModule, DtNativeDateModule], +}) +class MyModule {} +``` + +## Inputs + +| Name | Type | Default | Description | +| -------------- | --------- | ------- | ----------------------------------------- | +| value | `D | null` | `null` | The selected date. | +| startAt | `D | null` | `null` | The date to open the calendar to initially. Is ignored if `selected` is set. Defaults to today's date internally for display only. | +| min | `D | null` | `null` | The minimum valid date. | +| max | `D | null` | `null` | The maximum valid date. | +| disabled | `boolean` | `false` | Whether the datepicker is disabled. | +| isTimeEnabled | `boolean` | `false` | Whether or not the time mode is enabled. | +| isRangeEnabled | `boolean` | `false` | Whether or not the range mode is enabled. | + +#### Methods + +The following methods are on the `DtDatepicker` class: + +| Name | Description | Return value | +| ----------- | --------------------------------------- | ------------ | +| `open` | Opens the datepicker | `void` | +| `close` | Closes the datepicker | `void` | +| `toggle` | Toggles the datepicker | `void` | +| `panelOpen` | Returns the open or closed panel state. | `boolean` | diff --git a/libs/barista-components/experimental/datepicker/barista.json b/libs/barista-components/experimental/datepicker/barista.json new file mode 100644 index 0000000000..17f6933b19 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/barista.json @@ -0,0 +1,24 @@ +{ + "title": "Datepicker", + "description": "ToDo", + "postid": "datepicker", + "identifier": "Da", + "category": "components", + "public": true, + "contributors": { + "dev": [ + { + "name": "Thomas Pink", + "githubuser": "thomaspink" + } + ], + "ux": [ + { + "name": "Ursula Wieshofer", + "githubuser": "ursula-wieshofer" + } + ] + }, + "properties": ["work in progress", "experimental"], + "tags": ["datepicker", "component", "angular"] +} diff --git a/libs/barista-components/experimental/datepicker/index.ts b/libs/barista-components/experimental/datepicker/index.ts new file mode 100644 index 0000000000..1d7841f2e3 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/index.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './src/calendar-body'; +export * from './src/calendar'; +export * from './src/timeinput'; +export * from './src/timepicker'; +export * from './src/timepicker'; +export * from './src/datepicker'; +export * from './src/datepicker-module'; diff --git a/libs/barista-components/experimental/datepicker/jest.config.json b/libs/barista-components/experimental/datepicker/jest.config.json new file mode 100644 index 0000000000..5227d233fe --- /dev/null +++ b/libs/barista-components/experimental/datepicker/jest.config.json @@ -0,0 +1,8 @@ +{ + "name": "datepicker", + "snapshotSerializers": [ + "jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js", + "jest-preset-angular/build/AngularSnapshotSerializer.js", + "jest-preset-angular/build/HTMLCommentSerializer.js" + ] +} diff --git a/libs/barista-components/experimental/datepicker/package.json b/libs/barista-components/experimental/datepicker/package.json new file mode 100644 index 0000000000..dedb72ce9c --- /dev/null +++ b/libs/barista-components/experimental/datepicker/package.json @@ -0,0 +1,7 @@ +{ + "ngPackage": { + "lib": { + "entryFile": "index.ts" + } + } +} diff --git a/libs/barista-components/experimental/datepicker/src/_calendar-body-theme.scss b/libs/barista-components/experimental/datepicker/src/_calendar-body-theme.scss new file mode 100644 index 0000000000..74eb31c3c3 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/_calendar-body-theme.scss @@ -0,0 +1,16 @@ +:host-context(.dt-theme-dark), +:host(.dt-theme-dark) { + background-color: $gray-700; + color: $white; + + .dt-calendar-table-cell:not(.dt-calendar-active) { + .dt-calendar-table-cell-inner { + border-color: $gray-700; + } + } + + .dt-calendar-table-cell:not(.dt-calendar-selected):hover + .dt-calendar-table-cell-inner { + background: $gray-500; + } +} diff --git a/libs/barista-components/experimental/datepicker/src/_calendar-header-theme.scss b/libs/barista-components/experimental/datepicker/src/_calendar-header-theme.scss new file mode 100644 index 0000000000..0cc7d46cc1 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/_calendar-header-theme.scss @@ -0,0 +1,20 @@ +:host-context(.dt-theme-dark), +:host(.dt-theme-dark) { + background-color: $gray-700; + color: $white; + + .dt-button.dt-button-nested:hover:not([disabled]), + .dt-button.dt-button-nested:active:not([disabled]) { + background: $gray-500; + } + + .dt-button-nested.dt-calendar-header-button ::ng-deep svg, + .dt-button-nested.dt-calendar-header-button:hover:not([disabled]) + ::ng-deep + svg, + .dt-button-nested.dt-calendar-header-button:active:not([disabled]) + ::ng-deep + svg { + fill: var(--dt-button-default-color); + } +} diff --git a/libs/barista-components/experimental/datepicker/src/_timeinput-theme.scss b/libs/barista-components/experimental/datepicker/src/_timeinput-theme.scss new file mode 100644 index 0000000000..a6d08ad100 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/_timeinput-theme.scss @@ -0,0 +1,16 @@ +:host-context(.dt-theme-dark) { + border: none; + + .dt-timeinput-input input, + .dt-timeinput-input input, + .dt-timeinput-separator { + background-color: $gray-800; + color: $white; + } + + .dt-timeinput-separator-disabled, + .dt-timeinput-input input[disabled] { + background-color: var(--timeinput-separator-disabled-dark); + color: $disabledcolor; + } +} diff --git a/libs/barista-components/experimental/datepicker/src/calendar-body.html b/libs/barista-components/experimental/datepicker/src/calendar-body.html new file mode 100644 index 0000000000..32ee2ed58a --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar-body.html @@ -0,0 +1,53 @@ +{{ _label }} + + + + + + + + + + + + + +
+ {{ day.short }} +
+
+ {{ cell.displayValue }} +
+
diff --git a/libs/barista-components/experimental/datepicker/src/calendar-body.scss b/libs/barista-components/experimental/datepicker/src/calendar-body.scss new file mode 100644 index 0000000000..cc61ca9938 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar-body.scss @@ -0,0 +1,54 @@ +@import '../../../core/src/style/variables'; +@import './calendar-body-theme'; + +:host { + display: block; + outline: none; +} + +.dt-calendar-table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; +} + +.dt-calendar-table-header th { + text-align: center; + padding: 0 0 8px; + width: 14%; +} + +.dt-calendar-table-cell { + position: relative; + text-align: center; + outline: none; + cursor: pointer; +} + +.dt-calendar-table-cell:not(.dt-calendar-selected):hover + .dt-calendar-table-cell-inner { + background: $gray-200; +} + +.dt-calendar-table-cell-inner { + padding: 2px 0; + border: 1px solid white; + border-radius: 3px; +} + +.dt-calendar-active .dt-calendar-table-cell-inner { + border-color: $disabledcolor; +} + +:host:focus .dt-calendar-active .dt-calendar-table-cell-inner { + border-color: $turquoise-600; +} + +.dt-calendar-selected .dt-calendar-table-cell-inner { + background-color: $turquoise-600; + color: white; +} + +.dt-calendar-body-header-label { + display: none; +} diff --git a/libs/barista-components/experimental/datepicker/src/calendar-body.ts b/libs/barista-components/experimental/datepicker/src/calendar-body.ts new file mode 100644 index 0000000000..120ce89ffa --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar-body.ts @@ -0,0 +1,308 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DOWN_ARROW, + ENTER, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + SPACE, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewEncapsulation, +} from '@angular/core'; +import { + DtDateAdapter, + _readKeyCode, +} from '@dynatrace/barista-components/core'; +import { getValidDateOrNull } from './datepicker-utils/util'; + +const DAYS_PER_WEEK = 7; +let uniqueId = 0; + +interface DtCalendarCell { + displayValue: string; + value: number; + rawValue: D; + ariaLabel: string; +} + +@Component({ + selector: 'dt-calendar-body', + templateUrl: 'calendar-body.html', + styleUrls: ['calendar-body.scss'], + host: { + class: 'dt-calendar-body', + tabIndex: '0', + '(keyup)': '_onHostKeyup($event)', + }, + encapsulation: ViewEncapsulation.Emulated, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DtCalendarBody { + /** + * The date to display in this month view + * (everything other than the month and year is ignored). + */ + @Input() + get activeDate(): D { + return this._activeDate; + } + set activeDate(value: D) { + const validDate = + getValidDateOrNull(this._dateAdapter, value) || this._dateAdapter.today(); + this._activeDate = this._dateAdapter.clampDate( + validDate, + this.minDate, + this.maxDate, + ); + this._init(); + this._label = this._dateAdapter.format(value, { + year: 'numeric', + month: 'short', + }); + this._changeDetectorRef.markForCheck(); + } + private _activeDate: D; + + /** The currently selected date. */ + @Input() + get selected(): D | null { + return this._selected; + } + set selected(value: D | null) { + this._selected = getValidDateOrNull(this._dateAdapter, value); + } + private _selected: D | null = null; + + /** The minimum selectable date. */ + @Input() + get minDate(): D | null { + return this._minDate; + } + set minDate(value: D | null) { + this._minDate = getValidDateOrNull(this._dateAdapter, value); + } + private _minDate: D | null = null; + + /** The maximum selectable date. */ + @Input() + get maxDate(): D | null { + return this._maxDate; + } + set maxDate(value: D | null) { + this._maxDate = getValidDateOrNull(this._dateAdapter, value); + } + private _maxDate: D | null = null; + + /** Function used to filter whether a date is selectable or not. */ + @Input() dateFilter: (date: D) => boolean; + + @Input('aria-labelledby') ariaLabelledby: string | null; + + /** Emits when a new value is selected. */ + @Output() readonly selectedChange = new EventEmitter(); + + /** Emits when any date is activated. */ + @Output() readonly activeDateChange = new EventEmitter(); + + /** The names of the weekdays. */ + _weekdays: { long: string; short: string }[]; + + /** Grid of calendar cells representing the dates of the month. */ + _weeks: DtCalendarCell[][]; + + /** The number of blank cells to put at the beginning for the first row. */ + _firstRowOffset: number; + + /** Unique id used for the aria-label. */ + _labelid = `dt-calendar-body-label-${uniqueId++}`; + + _label = ''; + + constructor( + private _dateAdapter: DtDateAdapter, + private _changeDetectorRef: ChangeDetectorRef, + private _elementRef: ElementRef, + ) { + this._activeDate = this._dateAdapter.today(); + } + + focus(): void { + this._elementRef.nativeElement.focus(); + } + + /** Checks whether the provided date cell has the same value as the provided compare value. */ + _isSame(cell: DtCalendarCell, compareValue: D): boolean { + return ( + compareValue !== null && + cell.rawValue !== null && + this._dateAdapter.compareDate(cell.rawValue, compareValue) === 0 + ); + } + + _cellClicked(cell: DtCalendarCell): void { + this._setActiveDateAndEmit(cell.rawValue); + this._selectActiveDate(); + this._changeDetectorRef.markForCheck(); + } + + _onHostKeyup(event: KeyboardEvent): void { + const keyCode = _readKeyCode(event); + + switch (keyCode) { + case UP_ARROW: + // Goto previous week + this._setActiveDateAndEmit( + this._dateAdapter.addCalendarDays(this._activeDate, -7), + ); + break; + case DOWN_ARROW: + // Goto next week + this._setActiveDateAndEmit( + this._dateAdapter.addCalendarDays(this._activeDate, 7), + ); + break; + case LEFT_ARROW: + // Goto previous day + this._setActiveDateAndEmit( + this._dateAdapter.addCalendarDays(this._activeDate, -1), + ); + break; + case RIGHT_ARROW: + // Goto next day + this._setActiveDateAndEmit( + this._dateAdapter.addCalendarDays(this._activeDate, 1), + ); + break; + case PAGE_UP: + // Goto previous month. If ALT key is pressed goto previous year instead + this._setActiveDateAndEmit( + event.altKey + ? this._dateAdapter.addCalendarYears(this._activeDate, -1) + : this._dateAdapter.addCalendarMonths(this._activeDate, -1), + ); + break; + case PAGE_DOWN: + // Goto next month. If ALT key is pressed goto next year instead + this._setActiveDateAndEmit( + event.altKey + ? this._dateAdapter.addCalendarYears(this._activeDate, 1) + : this._dateAdapter.addCalendarMonths(this._activeDate, 1), + ); + break; + case ENTER: + case SPACE: + // Select the active date + this._selectActiveDate(); + break; + } + + // Prevent unexpected default actions such as form submission. + event.preventDefault(); + + this._changeDetectorRef.markForCheck(); + } + + private _init(): void { + this._initWeekdays(); + this._initWeeks(); + + this._changeDetectorRef.markForCheck(); + } + + private _initWeekdays(): void { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const shortWeekdays = this._dateAdapter.getDayOfWeekNames('short'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => ({ + long, + short: shortWeekdays[i], + })); + this._weekdays = weekdays + .slice(firstDayOfWeek) + .concat(weekdays.slice(0, firstDayOfWeek)); + } + + private _initWeeks(): void { + const daysInMonth = this._dateAdapter.getNumDaysInMonth(this.activeDate); + const dateNames = this._dateAdapter.getDateNames(); + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), + 1, + ); + const firstWeekOffset = + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK; + + let weeks: DtCalendarCell[][] = [[]]; + for (let i = 0, cell = firstWeekOffset; i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), + i + 1, + ); + + weeks[weeks.length - 1].push({ + value: i + 1, + displayValue: dateNames[i], + rawValue: date, + ariaLabel: this._dateAdapter.format(date, { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + }); + } + this._weeks = weeks; + this._firstRowOffset = + weeks && weeks.length && weeks[0].length + ? DAYS_PER_WEEK - weeks[0].length + : 0; + } + + private _selectActiveDate(): void { + if (!this.dateFilter || this.dateFilter(this._activeDate)) { + this.selectedChange.emit(this._activeDate); + } + } + + private _setActiveDateAndEmit(date: D): void { + if (this._dateAdapter.compareDate(date, this.activeDate)) { + this._activeDate = date; + this.activeDateChange.emit(this.activeDate); + } + } +} diff --git a/libs/barista-components/experimental/datepicker/src/calendar.html b/libs/barista-components/experimental/datepicker/src/calendar.html new file mode 100644 index 0000000000..e13889a068 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar.html @@ -0,0 +1,57 @@ +
+ + + + {{ _label }} + + + +
+ + + + diff --git a/libs/barista-components/experimental/datepicker/src/calendar.scss b/libs/barista-components/experimental/datepicker/src/calendar.scss new file mode 100644 index 0000000000..341919be05 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar.scss @@ -0,0 +1,40 @@ +@import '../../../core/src/style/colors'; +@import './calendar-header-theme'; + +:host { + display: block; +} + +.dt-calendar-header { + display: flex; +} + +.dt-calendar-header-button { + flex-grow: 0; + flex-shrink: 0; +} + +.dt-calendar-header-label { + flex-grow: 1; + flex-shrink: 1; + text-align: center; + padding-top: 4px; +} + +// Until we have the correct icons we need to rotate the arrows +// of the buttons individually to get the correct appearance +.dt-calendar-header-button-prev-year ::ng-deep svg { + transform: rotate(90deg); +} + +.dt-calendar-header-button-prev-month ::ng-deep svg { + transform: rotate(180deg); +} + +.dt-calendar-header-button-next-year ::ng-deep svg { + transform: rotate(-90deg); +} + +.dt-calendar-body + .dt-button { + margin-top: 10px; +} diff --git a/libs/barista-components/experimental/datepicker/src/calendar.ts b/libs/barista-components/experimental/datepicker/src/calendar.ts new file mode 100644 index 0000000000..1f593b2d95 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/calendar.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + Input, + ChangeDetectorRef, + AfterContentInit, + Output, + EventEmitter, + ViewChild, +} from '@angular/core'; +import { DtDateAdapter } from '@dynatrace/barista-components/core'; +import { getValidDateOrNull } from './datepicker-utils/util'; +import { DtCalendarBody } from './calendar-body'; + +let uniqueId = 0; + +@Component({ + selector: 'dt-calendar', + templateUrl: 'calendar.html', + styleUrls: ['calendar.scss'], + host: { + class: 'dt-calendar', + }, + encapsulation: ViewEncapsulation.Emulated, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DtCalendar implements AfterContentInit { + /** A date representing the period (month or year) to start the calendar in. */ + @Input() + get startAt(): D | null { + return this._startAt; + } + set startAt(value: D | null) { + this._startAt = getValidDateOrNull(this._dateAdapter, value); + } + private _startAt: D | null = null; + + /** The currently selected date. */ + @Input() + get selected(): D | null { + return this._selected; + } + set selected(value: D | null) { + this._selected = getValidDateOrNull(this._dateAdapter, value); + } + private _selected: D | null = null; + + /** The minimum selectable date. */ + @Input() + get minDate(): D | null { + return this._minDate; + } + set minDate(value: D | null) { + this._minDate = getValidDateOrNull(this._dateAdapter, value); + } + private _minDate: D | null = null; + + /** The maximum selectable date. */ + @Input() + get maxDate(): D | null { + return this._maxDate; + } + set maxDate(value: D | null) { + this._maxDate = getValidDateOrNull(this._dateAdapter, value); + } + private _maxDate: D | null = null; + + /** Emits when the currently selected date changes. */ + @Output() readonly selectedChange = new EventEmitter(); + + get activeDate(): D { + return this._activeDate; + } + set activeDate(value: D) { + this._activeDate = this._dateAdapter.clampDate( + value, + this.minDate, + this.maxDate, + ); + this._label = this._dateAdapter.format(value, { + year: 'numeric', + month: 'short', + }); + this._changeDetectorRef.markForCheck(); + } + private _activeDate: D; + + _label = ''; + + /** Unique id used for the aria-label. */ + _labelid = `dt-calendar-label-${uniqueId++}`; + + @ViewChild(DtCalendarBody) _calendarBody: DtCalendarBody; + + constructor( + private _dateAdapter: DtDateAdapter, + private _changeDetectorRef: ChangeDetectorRef, + ) {} + + ngAfterContentInit(): void { + this.activeDate = this.startAt || this._dateAdapter.today(); + } + + focus(): void { + if (this._calendarBody) { + this._calendarBody.focus(); + } + } + + _addMonths(months: number): void { + this.activeDate = this._dateAdapter.addCalendarMonths( + this.activeDate, + months, + ); + this._changeDetectorRef.markForCheck(); + } + + _selectedValueChanged(value: D): void { + this.selectedChange.emit(value); + } + + _setTodayDate(): void { + this.selected = this._dateAdapter.today(); + this.activeDate = this.selected; + this._selectedValueChanged(this.selected); + this._changeDetectorRef.markForCheck(); + } +} diff --git a/libs/barista-components/experimental/datepicker/src/datepicker-module.ts b/libs/barista-components/experimental/datepicker/src/datepicker-module.ts new file mode 100644 index 0000000000..0186536c46 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-module.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { A11yModule } from '@angular/cdk/a11y'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DtButtonModule } from '@dynatrace/barista-components/button'; +import { DtIconModule } from '@dynatrace/barista-components/icon'; +import { DtInputModule } from '@dynatrace/barista-components/input'; +import { DtCalendar } from './calendar'; +import { DtCalendarBody } from './calendar-body'; +import { DtDatePicker } from './datepicker'; +import { DtTimeInput } from './timeinput'; +import { DtTimepicker } from './timepicker'; + +const COMPONENTS = [ + DtDatePicker, + DtCalendar, + DtCalendarBody, + DtTimepicker, + DtTimeInput, +]; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + OverlayModule, + DtButtonModule, + DtIconModule, + DtInputModule, + A11yModule, + ], + exports: COMPONENTS, + declarations: COMPONENTS, +}) +export class DtDatepickerModule {} diff --git a/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.spec.ts b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.spec.ts new file mode 100644 index 0000000000..4745d0f3ea --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.spec.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + valueTo2DigitString, + isValidHour, + isValidMinute, + isValid, +} from './util'; + +describe('timeinput', () => { + describe('valueTo2DigitString', () => { + it('should cast a number value to string', () => { + expect(valueTo2DigitString(0)).toBe('00'); + expect(valueTo2DigitString(15)).toBe('15'); + expect(valueTo2DigitString(20)).toBe('20'); + }); + it('should prepend zeros for numbers smaller than 10', () => { + expect(valueTo2DigitString(8)).toBe('08'); + expect(valueTo2DigitString(0)).toBe('00'); + }); + }); + + describe('isValidHour', () => { + it('should return true with an integer between 0 and 23', () => { + expect(isValidHour(0)).toBeTruthy(); + expect(isValidHour(10)).toBeTruthy(); + expect(isValidHour(12)).toBeTruthy(); + expect(isValidHour(23)).toBeTruthy(); + }); + it('should return true with a string representing an integer between 0 and 23', () => { + expect(isValidHour('0')).toBeTruthy(); + expect(isValidHour('00')).toBeTruthy(); + expect(isValidHour('10')).toBeTruthy(); + expect(isValidHour('12')).toBeTruthy(); + expect(isValidHour('23')).toBeTruthy(); + }); + it('should return false with a float', () => { + expect(isValidHour(0.3)).toBeFalsy(); + expect(isValidHour(5.1)).toBeFalsy(); + expect(isValidHour(20.1)).toBeFalsy(); + expect(isValidHour(-5.1)).toBeFalsy(); + expect(isValidHour(-5.0)).toBeFalsy(); + expect(isValidHour(35.0)).toBeFalsy(); + expect(isValidHour(35.1)).toBeFalsy(); + }); + it('should return false with a string representing a float', () => { + expect(isValidHour('0.3')).toBeFalsy(); + expect(isValidHour('-5.1')).toBeFalsy(); + expect(isValidHour('5.0')).toBeFalsy(); + }); + it('should return false with an integer outside the valid range', () => { + expect(isValidHour(-1)).toBeFalsy(); + expect(isValidHour(24)).toBeFalsy(); + expect(isValidHour(25)).toBeFalsy(); + }); + it('should return false with a string representing an integer outside the valid range or with invalid leading zeros', () => { + expect(isValidHour('0000008')).toBeFalsy(); + expect(isValidHour('005')).toBeFalsy(); + expect(isValidHour('25')).toBeFalsy(); + expect(isValidHour('-1')).toBeFalsy(); + }); + }); + + describe('isValidMinute', () => { + it('should return true with an integer between 0 and 59', () => { + expect(isValidMinute(0)).toBeTruthy(); + expect(isValidMinute(10)).toBeTruthy(); + expect(isValidMinute(12)).toBeTruthy(); + expect(isValidMinute(23)).toBeTruthy(); + expect(isValidMinute(59)).toBeTruthy(); + }); + it('should return true with a string representing an integer between 0 and 59', () => { + expect(isValidMinute('0')).toBeTruthy(); + expect(isValidMinute('00')).toBeTruthy(); + expect(isValidMinute('20')).toBeTruthy(); + expect(isValidMinute('45')).toBeTruthy(); + expect(isValidMinute('59')).toBeTruthy(); + }); + it('should return false with a float', () => { + expect(isValidMinute(-5.1)).toBeFalsy(); + expect(isValidMinute(-5.0)).toBeFalsy(); + expect(isValidMinute(0.3)).toBeFalsy(); + expect(isValidMinute(5.1)).toBeFalsy(); + expect(isValidMinute(20.1)).toBeFalsy(); + expect(isValidMinute(50.1)).toBeFalsy(); + expect(isValidMinute(65.1)).toBeFalsy(); + }); + it('should return false with a string representing a float', () => { + expect(isValidMinute('0.3')).toBeFalsy(); + expect(isValidMinute('5.0')).toBeFalsy(); + expect(isValidMinute('15.0')).toBeFalsy(); + expect(isValidMinute('25.0')).toBeFalsy(); + expect(isValidMinute('66.0')).toBeFalsy(); + }); + it('should return false with an integer outside the valid range', () => { + expect(isValidMinute(-1)).toBeFalsy(); + expect(isValidMinute(60)).toBeFalsy(); + expect(isValidMinute(75)).toBeFalsy(); + }); + it('should return false with a string representing an integer outside the valid range or with invalid leading zeros', () => { + expect(isValidMinute('-1')).toBeFalsy(); + expect(isValidMinute('0000008')).toBeFalsy(); + expect(isValidMinute('005')).toBeFalsy(); + expect(isValidMinute('65')).toBeFalsy(); + expect(isValidMinute('75')).toBeFalsy(); + }); + }); + + describe('isValid', () => { + it('should return false with an empty input', () => { + expect(isValid('', -Infinity, Infinity)).toBeFalsy(); + expect(isValid(' ', -Infinity, Infinity)).toBeFalsy(); + expect(isValid('NaN', -Infinity, Infinity)).toBeFalsy(); + expect(isValid(null, -Infinity, Infinity)).toBeFalsy(); + expect(isValid(undefined, -Infinity, Infinity)).toBeFalsy(); + }); + it('should return false with invalid inputs containing special characters - or +', () => { + expect(isValid('-1', -Infinity, Infinity)).toBeFalsy(); + expect(isValid('+1', -Infinity, Infinity)).toBeFalsy(); + expect(isValid('1+1', -Infinity, Infinity)).toBeFalsy(); + expect(isValid('0-0', -Infinity, Infinity)).toBeFalsy(); + }); + }); +}); diff --git a/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.ts b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.ts new file mode 100644 index 0000000000..0752bd98d2 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DtDateAdapter, + isEmpty, + isNumberLike, + isString, +} from '@dynatrace/barista-components/core'; + +const MAX_HOURS = 23; +const MAX_MINUTES = 59; +const MIN_HOURS = 0; +const MIN_MINUTES = 0; +const INVALID_TIME_REGEX = /[0]{3,}|[.+-]|[0]{2}[0-9]/g; + +/** Checks whether the provided object is a valid date an returns it; null otherwise. */ +export function getValidDateOrNull( + dateAdapter: DtDateAdapter, + obj: any, +): D | null { + return dateAdapter.isDateInstance(obj) && dateAdapter.isValid(obj) + ? obj + : null; +} + +/** Check is the hour value is valid. */ +export function isValidHour(value: any): boolean { + return isValid(value, MIN_HOURS, MAX_HOURS); +} + +/** Check if the minute value is valid. */ +export function isValidMinute(value: any): boolean { + return isValid(value, MIN_MINUTES, MAX_MINUTES); +} + +/** + * Check if a value if a valid hour/minute number in the range + * Note that if a number is passed directly in with the format 'n.0', such as 5.0, it will be truncated to 5 and validation will fail. + * However, this cannot happen with the input event, since it will be passed as a string. Also, typing '.' is prevented on keydown. + */ +export function isValid(value: any, min: number, max: number): boolean { + if (isEmpty(value) || !isNumberLike(value)) { + return false; + } + + // the regex is necessary for invalidating chars like '-' or '.', as well as multiple leading 0s. + const stringifiedVal = isString(value) ? value : value.toString(); + if (stringifiedVal.match(INVALID_TIME_REGEX)) { + return false; + } + + const parsedValue = parseInt(value, 10); + return parsedValue >= min && parsedValue <= max; +} + +/** Check if a number has at least two digits or is null. */ +export function hasMininmumTwoDigits(input: number | null): boolean { + return input !== null && input >= 10; +} + +/** + * Format a number with max two digits to always display two digits + * (with a leading 0 in case it is a single digit or convert it to string otherwise). + */ +export function valueTo2DigitString(value: number): string { + return value < 10 ? `0${value}` : value.toString(); +} diff --git a/libs/barista-components/experimental/datepicker/src/datepicker.html b/libs/barista-components/experimental/datepicker/src/datepicker.html new file mode 100644 index 0000000000..72d570ec6a --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker.html @@ -0,0 +1,56 @@ + + + + + diff --git a/libs/barista-components/experimental/datepicker/src/datepicker.scss b/libs/barista-components/experimental/datepicker/src/datepicker.scss new file mode 100644 index 0000000000..04a1a10009 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker.scss @@ -0,0 +1,39 @@ +@import '../../../core/src/style/variables'; + +$dt-datepicker-panel-min-width: 300px; +$dt-datepicker-panel-max-width: 350px; + +:host { + display: block; +} + +.dt-theme-dark.dt-datepicker-panel { + background-color: $gray-700; +} + +.dt-datepicker-panel { + min-width: $dt-datepicker-panel-min-width; + max-width: $dt-datepicker-panel-max-width; + background: $white; + box-sizing: border-box; + border: 1px solid $disabledcolor; + border-radius: 3px; + will-change: transform; + + // Prevents the content from repainting on scroll. + backface-visibility: hidden; + + // Makes sure the opening scale animation starts from the top + transform-origin: left top; + overflow: auto; + -webkit-overflow-scrolling: touch; // for momentum scroll on mobile +} + +.dt-datepicker-content { + overflow: hidden; + padding: 8px; +} + +.dt-calendar + .dt-timepicker { + margin-top: 10px; +} diff --git a/libs/barista-components/experimental/datepicker/src/datepicker.ts b/libs/barista-components/experimental/datepicker/src/datepicker.ts new file mode 100644 index 0000000000..f22cbbee74 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker.ts @@ -0,0 +1,457 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + animate, + animateChild, + group, + query, + state, + style, + transition, + trigger, +} from '@angular/animations'; +import { CdkConnectedOverlay } from '@angular/cdk/overlay'; +import { + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Input, + OnDestroy, + Optional, + Self, + SkipSelf, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { + ControlValueAccessor, + FormGroupDirective, + NgControl, + NgForm, +} from '@angular/forms'; +import { + CanDisable, + DtDateAdapter, + DtOverlayThemingConfiguration, + dtSetOverlayThemeAttribute, + dtSetUiTestAttribute, + DtUiTestConfiguration, + DT_OVERLAY_THEMING_CONFIG, + DT_UI_TEST_CONFIG, + ErrorStateMatcher, + HasTabIndex, + mixinDisabled, + mixinErrorState, + mixinTabIndex, +} from '@dynatrace/barista-components/core'; +import { DtTheme } from '@dynatrace/barista-components/theming'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { DtCalendar } from './calendar'; +import { DtTimeChangeEvent } from './timeinput'; +import { DtTimepicker } from './timepicker'; +import { getValidDateOrNull } from './datepicker-utils/util'; + +/** + * This position config ensures that the top "start" corner of the overlay + * is aligned with with the top "start" of the origin by default (overlapping + * the trigger completely). If the panel cannot fit below the trigger, it + * will fall back to a position above the trigger. + */ +const OVERLAY_POSITIONS = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetX: 2, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetX: 2, + }, +]; + +let uniqueId = 0; + +// Boilerplate for applying mixins to DtDatePicker. +export class DtDatepickerBase { + constructor( + public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl, + ) {} +} +export const _DtDatepickerBase = mixinTabIndex( + mixinDisabled(mixinErrorState(DtDatepickerBase)), +); + +@Component({ + selector: 'dt-datepicker', + templateUrl: 'datepicker.html', + styleUrls: ['datepicker.scss'], + host: { + class: 'dt-datepicker', + '[class.dt-select-invalid]': 'errorState', + '[attr.id]': 'id', + '[attr.aria-invalid]': 'errorState', + }, + inputs: ['disabled', 'tabIndex'], + encapsulation: ViewEncapsulation.Emulated, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('transformPanel', [ + state( + 'void', + style({ + transform: 'scaleY(0) translateX(-1px)', + opacity: 0, + }), + ), + state( + 'showing', + style({ + opacity: 1, + transform: 'scaleY(1) translateX(-1px)', + }), + ), + transition( + 'void => *', + group([ + query('@fadeInContent', animateChild()), + animate('150ms cubic-bezier(0.25, 0.8, 0.25, 1)'), + ]), + ), + transition('* => void', [ + animate('250ms 100ms linear', style({ opacity: 0 })), + ]), + ]), + trigger('fadeInContent', [ + state('showing', style({ opacity: 1 })), + transition('void => showing', [ + style({ opacity: 0 }), + animate('150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)'), + ]), + ]), + ], +}) +export class DtDatePicker + extends _DtDatepickerBase + implements ControlValueAccessor, CanDisable, HasTabIndex, OnDestroy { + /** Unique id of the element. */ + @Input() + get id(): string { + return this._id; + } + set id(value: string) { + this._id = value || this._uid; + this.stateChanges.next(); + } + private _id: string; + private _uid = `dt-datepicker-${uniqueId++}`; + + /** Value of the datepicker control. */ + @Input() + get value(): D | null { + return this._value; + } + set value(newValue: D | null) { + if (newValue !== this._value) { + this._value = newValue; + this._changeDetectorRef.markForCheck(); + } + } + private _value: D | null = null; + + /** The date to open the calendar to initially. */ + @Input() + get startAt(): D | null { + return this._startAt || this._value; + } + set startAt(value: D | null) { + this._startAt = getValidDateOrNull(this._dateAdapter, value); + } + private _startAt: D | null; + + /** Object used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcher; + + /** Classes to be passed to the select panel. Supports the same syntax as `ngClass`. */ + // tslint:disable-next-line:no-any + @Input() panelClass: string | string[] | Set | { [key: string]: any }; + + /** Property that enables the timepicker, so that a time can be entered as well. */ + @Input() isTimeEnabled: boolean; + + /** Property that enables the range mode. */ + @Input() isRangeEnabled: boolean; + + /** Whether or not the overlay panel is open. */ + get panelOpen(): boolean { + return this._panelOpen; + } + private _panelOpen = false; + + /** Overlay pane containing the options. */ + @ViewChild(CdkConnectedOverlay) _overlayDir: CdkConnectedOverlay; + + @ViewChild(DtCalendar) _calendar: DtCalendar; + + @ViewChild(DtTimepicker) _timePicker: DtTimepicker; + + @ViewChild('panel') _panel: ElementRef; + + /** @internal Defines the positions the overlay can take relative to the button element. */ + _positions = OVERLAY_POSITIONS; + + /** @internal Whether the panel's animation is done. */ + _panelDoneAnimating = false; + + /** @internal Hour */ + get hour(): number { + return this._hour === 0 ? null : this._hour; + } + + private _hour; + + /** @internal Minute */ + get minute(): number { + return this._minute === 0 ? null : this._minute; + } + + private _minute; + + /** @internal `View -> model callback called when value changes` */ + _onChange: (value: Date) => void = () => {}; + + /** @internal `View -> model callback called when select has been touched` */ + _onTouched = () => {}; + + /** + * @internal Label used for displaying the date. + */ + get valueLabel(): string { + return this._valueLabel || 'Select date'; + } + set valueLabel(value: string) { + this._valueLabel = value; + } + private _valueLabel = ''; + + /** + * @internal Label used for displaying the time. + */ + _timeLabel = ''; + + private _destroy$ = new Subject(); + + constructor( + private _dateAdapter: DtDateAdapter, + private readonly _changeDetectorRef: ChangeDetectorRef, + private readonly _elementRef: ElementRef, + readonly defaultErrorStateMatcher: ErrorStateMatcher, + @Optional() readonly parentForm: NgForm, + @Optional() readonly parentFormGroup: FormGroupDirective, + @Optional() @SkipSelf() private _theme: DtTheme, + @Self() @Optional() readonly ngControl: NgControl, + @Attribute('tabindex') tabIndex: string, + @Inject(DT_OVERLAY_THEMING_CONFIG) + private readonly _themeConfig: DtOverlayThemingConfiguration, + @Optional() + @Inject(DT_UI_TEST_CONFIG) + private readonly _config?: DtUiTestConfiguration, + ) { + super(defaultErrorStateMatcher, parentForm, parentFormGroup, ngControl); + + this.tabIndex = parseInt(tabIndex, 10) || 0; + + // Force setter to be called in case id was not specified. + this.id = this.id; + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + /** Opens or closes the overlay panel. */ + toggle(): void { + if (this.panelOpen) { + this.close(); + } else { + this.open(); + } + } + + /** Opens the overlay panel. */ + open(): void { + if (!this.disabled && !this._panelOpen) { + this._panelOpen = true; + this._changeDetectorRef.markForCheck(); + } + } + + /** Closes the overlay panel and focuses the host element. */ + close(): void { + if (this._panelOpen) { + this._panelOpen = false; + this._changeDetectorRef.markForCheck(); + } + } + + /** Sets the datepicker's value. Part of the ControlValueAccessor. */ + writeValue(value: D): void { + this.value = value; + } + + /** + * Saves a callback function to be invoked when the select's value + * changes from user input. Part of the ControlValueAccessor. + */ + registerOnChange(fn: (value: Date) => void): void { + this._onChange = fn; + } + + /** + * Saves a callback function to be invoked when the select is blurred + * by the user. Part of the ControlValueAccessor. + */ + registerOnTouched(fn: () => {}): void { + this._onTouched = fn; + } + + /** Disables the datepicker. Part of the ControlValueAccessor. */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this._changeDetectorRef.markForCheck(); + this.stateChanges.next(); + } + + /** @internal Callback that is invoked when the overlay panel has been attached. */ + _onAttached(): void { + dtSetUiTestAttribute( + this._overlayDir.overlayRef.overlayElement, + this._overlayDir.overlayRef.overlayElement.id, + this._elementRef, + this._config, + ); + } + + /** + * @internal + * When the panel content is done fading in, the _panelDoneAnimating property is + * set so the proper class can be added to the panel. + */ + _onFadeInDone(): void { + this._panelDoneAnimating = this.panelOpen; + + if (this.panelOpen) { + this._calendar.focus(); + + if (this.isTimeEnabled) { + this._handleTimepickerValues(); + } + } + + this._changeDetectorRef.markForCheck(); + } + + /** + * @internal Handle timepicker hour and minute values. + */ + _handleTimepickerValues(): void { + this._timePicker.timeChange + .pipe(takeUntil(this._destroy$)) + .subscribe((changed) => { + this._handleTimeInputChange(changed); + }); + + this._timePicker._timeInput.hour = this.hour; + this._timePicker._timeInput.minute = this.minute; + } + + _getTimepickerVisibility(): boolean { + return this.isTimeEnabled && this._panelDoneAnimating; + } + + /** + * @internal Add a theming class to the overlay only when dark mode is enabled + */ + _onFadeInStart(): void { + if (this.panelOpen && this._theme.variant === 'dark') + dtSetOverlayThemeAttribute( + this._panel.nativeElement, + this._elementRef.nativeElement, + this._themeConfig, + ); + } + + /** + * @internal Set the selected date. + */ + _setSelectedValue(value: D): void { + this._value = value; + this._valueLabel = value + ? this._dateAdapter.format(value, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) + : ''; + this._changeDetectorRef.markForCheck(); + } + + /** + * @internal Handle the new values when there are time changes. + */ + _handleTimeInputChange(event: DtTimeChangeEvent): void { + if (!event) { + return; + } + + this._hour = event?.hour || 0; + this._minute = event?.minute || 0; + this._timeLabel = event?.format(); + } + + /** + * @internal Handle the new values when thre are time changes. + */ + _isTimeLabelAvailable(): boolean { + return this.isTimeEnabled && (this._hour !== 0 || this._minute !== 0); + } +} diff --git a/libs/barista-components/experimental/datepicker/src/test-setup.ts b/libs/barista-components/experimental/datepicker/src/test-setup.ts new file mode 100644 index 0000000000..3c66e43d72 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/test-setup.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'jest-preset-angular'; diff --git a/libs/barista-components/experimental/datepicker/src/timeinput.html b/libs/barista-components/experimental/datepicker/src/timeinput.html new file mode 100644 index 0000000000..19a487e4e9 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.html @@ -0,0 +1,50 @@ +
+
+ +
+ : +
+ +
+
diff --git a/libs/barista-components/experimental/datepicker/src/timeinput.scss b/libs/barista-components/experimental/datepicker/src/timeinput.scss new file mode 100644 index 0000000000..d6b2b2695d --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.scss @@ -0,0 +1,73 @@ +@import '../../../style/font-mixins'; +@import '../../../core/src/style/form-control'; +@import '../../../core/src/style/interactive-common'; +@import './timeinput-theme'; + +:host { + display: block; +} + +.dt-timeinput-wrapper { + display: flex; + width: 100%; + @include dt-main-font(); + @include dt-form-control(); + --timeinput-separator-disabled: #{$gray-130}; + --timeinput-separator-disabled-dark: #{$gray-500}; +} + +.dt-timeinput-input { + display: block; + flex-grow: 1; + flex-shrink: 1; + flex-basis: 100%; + + input { + display: block; + width: 100%; + height: 100%; + @include dt-main-font(); + appearance: none; + outline: none; + border: 0; + border-radius: 0; + text-align: center; + + &[disabled] { + color: $disabledcolor; + } + + &:focus { + @include dt-no-focus-style(); + } + } + + input[type='number'] { + appearance: textfield; + } + + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + appearance: none; + } +} + +.dt-timeinput-input::-webkit-inner-spin-button, +.dt-timeinput-input::-webkit-outer-spin-button { + appearance: none; + margin: 0; +} + +.dt-timeinput-separator { + display: block; + text-align: center; + flex-grow: 0; + flex-shrink: 0; + padding: 0 4px; +} + +.dt-timeinput-separator-disabled { + background-color: var(--timeinput-separator-disabled); + color: $disabledcolor; + pointer-events: none; +} diff --git a/libs/barista-components/experimental/datepicker/src/timeinput.spec.ts b/libs/barista-components/experimental/datepicker/src/timeinput.spec.ts new file mode 100644 index 0000000000..685bca058a --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.spec.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + NUMPAD_MINUS, + NUMPAD_ONE, + NUMPAD_PERIOD, + NUMPAD_PLUS, +} from '@angular/cdk/keycodes'; +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + DtDatepickerModule, + DtTimeInput, +} from '@dynatrace/barista-components/experimental/datepicker'; +import { + createComponent, + dispatchFakeEvent, + dispatchKeyboardEvent, + typeInElement, +} from '@dynatrace/testing/browser'; + +describe('DtTimeInput', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DtDatepickerModule], + declarations: [SimpleTimeInputTestApp], + }); + + TestBed.compileComponents(); + }), + ); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + let component: SimpleTimeInputTestApp; + + beforeEach(() => { + fixture = createComponent(SimpleTimeInputTestApp); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('disabled input property', () => { + it('should not be usable when disabled', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + component.disabled = true; + fixture.detectChanges(); + tick(); + expect(hourEl.disabled).toBeTruthy(); + expect(hourEl.getAttribute('aria-disabled')).toBeTruthy(); + })); + }); + + describe('timeChange event', () => { + it('should emit a timechange event when the hour and minute inputs are changed', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const changeSpy = jest.fn(); + component.timeInput.timeChange.subscribe(changeSpy); + fixture.detectChanges(); + expect(changeSpy).not.toHaveBeenCalled(); + component.timeInput.hour = 23; + component.timeInput.minute = 55; + fixture.detectChanges(); + dispatchFakeEvent(hourEl, 'blur'); + expect(changeSpy).toHaveBeenCalledTimes(1); + })); + it('should emit a timechange event when the hour and minute inputs are reset to empty', fakeAsync(() => { + const minuteEl = component.timeInput._minuteInput.nativeElement; + const changeSpy = jest.fn(); + component.timeInput.timeChange.subscribe(changeSpy); + fixture.detectChanges(); + expect(changeSpy).not.toHaveBeenCalled(); + component.timeInput.hour = null; + component.timeInput.minute = null; + fixture.detectChanges(); + dispatchFakeEvent(minuteEl, 'blur'); + expect(changeSpy).toHaveBeenCalledTimes(1); + })); + }); + + describe('Focus switch', () => { + it('should switch focus from the hour to the minute input when typing in 2 digits in the hour input', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + + component.timeInput.minute = null; + component.timeInput.hour = 14; + fixture.detectChanges(); + + dispatchKeyboardEvent(hourEl, 'keyup', NUMPAD_ONE); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(minuteEl); + })); + it('should not switch focus from the hour to the minute input when typing in only one digit in the hour input', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + + component.timeInput.minute = null; + component.timeInput.hour = 1; + fixture.detectChanges(); + + dispatchKeyboardEvent(hourEl, 'keyup', NUMPAD_ONE); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).not.toBe(minuteEl); + })); + it('should not switch focus from the hour to the minute input when typing in 2 digits in the hour input, but the minute input already had a valid value', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + + component.timeInput.hour = 15; + fixture.detectChanges(); + + dispatchKeyboardEvent(hourEl, 'keyup', NUMPAD_ONE); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).not.toBe(minuteEl); + })); + }); + + describe('Unwanted characters', () => { + it("should not allow typing in the '+' character", fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + dispatchKeyboardEvent(hourEl, 'keydown', NUMPAD_PLUS); + dispatchKeyboardEvent(minuteEl, 'keydown', NUMPAD_PLUS); + flush(); + fixture.detectChanges(); + tick(); + expect(hourEl.value).not.toBe('+'); + expect(minuteEl.value).not.toBe('+'); + })); + + it("should not allow typing in the '-' character", fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + dispatchKeyboardEvent(hourEl, 'keydown', NUMPAD_MINUS); + dispatchKeyboardEvent(minuteEl, 'keydown', NUMPAD_MINUS); + flush(); + fixture.detectChanges(); + tick(); + expect(hourEl.value).not.toBe('-'); + expect(minuteEl.value).not.toBe('-'); + })); + + it("should not allow typing in the '.' character", fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + dispatchKeyboardEvent(hourEl, 'keydown', NUMPAD_PERIOD); + dispatchKeyboardEvent(minuteEl, 'keydown', NUMPAD_PERIOD); + flush(); + fixture.detectChanges(); + tick(); + expect(hourEl.value).not.toBe('.'); + expect(minuteEl.value).not.toBe('.'); + })); + + it('should allow typing numbers/number-like strings', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + const minuteEl = component.timeInput._minuteInput.nativeElement; + typeInElement('4', hourEl); + typeInElement('5', minuteEl); + fixture.detectChanges(); + tick(); + expect(hourEl.value).toBe('4'); + expect(component.timeInput.hour).toBe(4); + expect(minuteEl.value).toBe('5'); + expect(component.timeInput.minute).toBe(5); + })); + }); + + describe('Time validity', () => { + it('should fall back to the previously set value if the newly typed hour is greater than 23', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + typeInElement('112', hourEl); + fixture.detectChanges(); + tick(); + expect(hourEl.value).toBe('11'); + expect(component.timeInput.hour).toBe(11); + })); + it('should be empty if there is no previously entered value and the newly entered one is an invalid hour', fakeAsync(() => { + component.timeInput.hour = null; + fixture.detectChanges(); + const hourEl = component.timeInput._hourInput.nativeElement; + typeInElement('a', hourEl); + fixture.detectChanges(); + tick(); + expect(hourEl.value).toBe(''); + expect(component.timeInput.hour).toBe(null); + })); + it('should set the new value if it is a valid hour (between 0 and 23)', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + typeInElement('20', hourEl); + fixture.detectChanges(); + expect(hourEl.value).toBe('20'); + expect(component.timeInput.hour).toBe(20); + })); + it('should be empty if there the new value is reset to a null or empty value (eg: user deletes the whole value)', fakeAsync(() => { + const hourEl = component.timeInput._hourInput.nativeElement; + typeInElement('', hourEl); + fixture.detectChanges(); + tick(); + expect(hourEl.value).toBe(''); + expect(component.timeInput.hour).toBe(null); + + const minuteEl = component.timeInput._minuteInput.nativeElement; + typeInElement('', minuteEl); + fixture.detectChanges(); + tick(); + expect(minuteEl.value).toBe(''); + expect(component.timeInput.minute).toBe(null); + })); + it('should fall back to the previously set value if the newly typed minute is greater than 59', fakeAsync(() => { + const minuteEl = component.timeInput._minuteInput.nativeElement; + typeInElement('533', minuteEl); + fixture.detectChanges(); + expect(minuteEl.value).toBe('53'); + expect(component.timeInput.minute).toBe(53); + })); + it('should be empty if there is no previously entered value and the newly entered one is an invalid minute', fakeAsync(() => { + component.timeInput.minute = null; + fixture.detectChanges(); + const minuteEl = component.timeInput._minuteInput.nativeElement; + typeInElement('a', minuteEl); + fixture.detectChanges(); + tick(); + expect(minuteEl.value).toBe(''); + expect(component.timeInput.minute).toBe(null); + })); + it('should set the new value if it is a valid minute (between 0 and 59)', fakeAsync(() => { + const minuteEl = component.timeInput._minuteInput.nativeElement; + typeInElement('45', minuteEl); + component.timeInput.minute = 45; + fixture.detectChanges(); + tick(); + expect(minuteEl.value).toBe('45'); + expect(component.timeInput.minute).toBe(45); + })); + }); + }); +}); + +@Component({ + selector: 'dt-test-app', + template: ` + + `, +}) +class SimpleTimeInputTestApp { + hour: number | null = 11; + minute: number | null = 53; + disabled = false; + + @ViewChild(DtTimeInput) timeInput: DtTimeInput; + + @ViewChild('hours', { read: ElementRef }) _hourInput: ElementRef< + HTMLInputElement + >; + + @ViewChild('minutes', { read: ElementRef }) _minuteInput: ElementRef< + HTMLInputElement + >; +} diff --git a/libs/barista-components/experimental/datepicker/src/timeinput.ts b/libs/barista-components/experimental/datepicker/src/timeinput.ts new file mode 100644 index 0000000000..26cbdb4be2 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FocusOrigin } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { isDefined, isEmpty } from '@dynatrace/barista-components/core'; +import { + hasMininmumTwoDigits, + isValidHour, + isValidMinute, + valueTo2DigitString, +} from './datepicker-utils/util'; + +export class DtTimeChangeEvent { + format(): string { + return `${valueTo2DigitString(this.hour)} + : ${valueTo2DigitString(this.minute)}`; + } + constructor(public hour: number, public minute: number) {} +} + +@Component({ + selector: 'dt-timeinput', + templateUrl: 'timeinput.html', + styleUrls: ['timeinput.scss'], + host: { + class: 'dt-timeinput', + }, + encapsulation: ViewEncapsulation.Emulated, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DtTimeInput { + /** Represents the hour value in the hour input element */ + @Input() + get hour(): number | null { + return this._hour; + } + set hour(value: number | null) { + if (value === this._hour) { + return; + } + + this._hour = value; + this._changeDetectorRef.markForCheck(); + } + private _hour: number | null = null; + + /** Represents the minute value in the minute input element */ + @Input() + get minute(): number | null { + return this._minute; + } + set minute(value: number | null) { + if (value === this._minute) { + return; + } + + this._minute = value; + this._changeDetectorRef.markForCheck(); + } + private _minute: number | null = null; + + /** Binding for the disabled state. */ + @Input() + get disabled(): boolean { + return this._isDisabled; + } + set disabled(disabled: boolean) { + this._isDisabled = coerceBooleanProperty(disabled); + this._changeDetectorRef.markForCheck(); + } + private _isDisabled: boolean = false; + + /** Emits when the hour or minute value changed and the focus is not on the time input elements anymore. */ + @Output() timeChange = new EventEmitter(); + + /** @internal Reference of the hour input element */ + @ViewChild('hours', { read: ElementRef }) _hourInput: ElementRef< + HTMLInputElement + >; + + /** @internal Reference of the minute input element */ + @ViewChild('minutes', { read: ElementRef }) _minuteInput: ElementRef< + HTMLInputElement + >; + + constructor(private _changeDetectorRef: ChangeDetectorRef) {} + + /** + * @internal + * Emits the `time change` event. + */ + _emitTimeChangeEvent(): void { + const event = new DtTimeChangeEvent(this._hour || 0, this._minute || 0); + this.timeChange.emit(event); + } + + // Add the focus switch from the hour input to the minute input when the user typed in 2 digits. + _onHourKeyUp(): void { + if ( + hasMininmumTwoDigits(this._hour) && + !hasMininmumTwoDigits(this._minute) + ) { + this._minuteInput.nativeElement.focus(); + } + } + + /** Called on blur and emits the timeChange event if the time inputs contain valid values. */ + _onInputBlur(origin: FocusOrigin): void { + if (origin === null) { + this._emitTimeChangeEvent(); + } + } + + /** + * @internal Handler for the user's hour input events. + * NOTE: If keydown event is used to prevent adding invalid input, + * we cannot access the whole value, just the last typed character, hence why we use the input event on the input elements + */ + _handleHourInput(event: InputEvent): void { + const value = (event.currentTarget as HTMLInputElement).value; + + if (isValidHour(value)) { + this._hour = parseInt(value, 10); + } else { + // reset the value to something valid - use fallback value if it exists and the new value is not empty, otherwise reset to empty + if (isEmpty(value)) { + this._hour = null; + } + + this._hourInput.nativeElement.value = isDefined(this._hour) + ? `${this._hour}` + : ''; + } + + this._changeDetectorRef.markForCheck(); + } + + /** @internal Handler for the user's minute input events. */ + _handleMinuteInput(event: InputEvent): void { + const value = (event.currentTarget as HTMLInputElement).value; + + if (isValidMinute(value)) { + this._minute = parseInt(value, 10); + } else { + if (isEmpty(value)) { + this._minute = null; + } + this._minuteInput.nativeElement.value = isDefined(this._minute) + ? `${this._minute}` + : ''; + } + + this._changeDetectorRef.markForCheck(); + } + + /** + * @internal Prevent typing in '.', '+, and "-", since the input value will not reflect it on the change event + * (the event target value does not include trailing '.' or "-" -> or it does not trigger any event in case the user types in "-" for example) + */ + _handleKeydown(event: KeyboardEvent): boolean { + return event.key !== '.' && event.key !== '-' && event.key !== '+'; + } +} diff --git a/libs/barista-components/experimental/datepicker/src/timepicker.html b/libs/barista-components/experimental/datepicker/src/timepicker.html new file mode 100644 index 0000000000..84dee4ab94 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.html @@ -0,0 +1,22 @@ +
+ +
+
{{ valueLabel }}
+
+ + +
+ + +
+
to
+ +
+
{{ valueLabel }}
+ +
+
diff --git a/libs/barista-components/experimental/datepicker/src/timepicker.scss b/libs/barista-components/experimental/datepicker/src/timepicker.scss new file mode 100644 index 0000000000..8347ff5581 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.scss @@ -0,0 +1,20 @@ +:host { + display: flex; +} + +.dt-timepicker-part { + display: block; + width: 100%; + flex-grow: 1; + flex-shrink: 1; +} + +.dt-timepicker-separator { + flex-grow: 0; + flex-shrink: 0; + padding: 0 16px; +} + +.dt-timepicker-date-label { + text-align: center; +} diff --git a/libs/barista-components/experimental/datepicker/src/timepicker.spec.ts b/libs/barista-components/experimental/datepicker/src/timepicker.spec.ts new file mode 100644 index 0000000000..cbdac7c010 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.spec.ts @@ -0,0 +1,69 @@ +import { fakeAsync } from '@angular/core/testing'; +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { createComponent } from '@dynatrace/testing/browser'; +import { DtDatepickerModule, DtTimepicker } from '..'; + +describe('DtTimePicker', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DtDatepickerModule], + declarations: [SimpleTimePickerTestApp], + }); + + TestBed.compileComponents(); + }), + ); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + // let component: SimpleTimePickerTestApp; + + beforeEach(() => { + fixture = createComponent(SimpleTimePickerTestApp); + // component = fixture.componentInstance; + fixture.detectChanges(); + }); + + /** + * Add tests for the range mode which will be added in a later version. + */ + it('dummy test', fakeAsync(() => {})); + }); +}); + +@Component({ + selector: 'dt-test-app', + template: ` + + `, +}) +class SimpleTimePickerTestApp { + hour = 11; + minute = 53; + disabled = false; + + @ViewChild(DtTimepicker) timePicker: DtTimepicker; +} diff --git a/libs/barista-components/experimental/datepicker/src/timepicker.ts b/libs/barista-components/experimental/datepicker/src/timepicker.ts new file mode 100644 index 0000000000..c184fa1e65 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + NgZone, + Output, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { switchMap, take } from 'rxjs/operators'; +import { DtTimeChangeEvent, DtTimeInput } from './timeinput'; + +@Component({ + selector: 'dt-timepicker', + templateUrl: 'timepicker.html', + styleUrls: ['timepicker.scss'], + host: { + class: 'dt-timepicker', + }, + encapsulation: ViewEncapsulation.Emulated, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DtTimepicker { + /** Label used for displaying the date in range mode. */ + @Input() + valueLabel: any; + + /** Contains the hour value that is depicted in the timeInput component */ + @Input() + hour: number | null; + + /** Contains the minute value that is depicted in the timeInput component */ + @Input() + minute: number | null; + + /** Property used for enabling the time range mode. */ + @Input() + isTimeRangeEnabled: boolean; + + /** Binding for the disabled state. */ + @Input() + get disabled(): boolean { + return this._isDisabled; + } + set disabled(disabled: boolean) { + this._isDisabled = coerceBooleanProperty(disabled); + this._changeDetectorRef.markForCheck(); + } + private _isDisabled: boolean = false; + + /** Reference to the timeInput component */ + @ViewChild(DtTimeInput) _timeInput: DtTimeInput; + + /** Provides an event when the time input has changed */ + @Output() + timeChange: Observable; + + constructor( + private _zone: NgZone, + private _changeDetectorRef: ChangeDetectorRef, + ) { + this.timeChange = this._zone.onMicrotaskEmpty.pipe( + take(1), + switchMap(() => this._timeInput.timeChange.asObservable()), + ); + } +} diff --git a/libs/barista-components/experimental/datepicker/tsconfig.json b/libs/barista-components/experimental/datepicker/tsconfig.json new file mode 100644 index 0000000000..13b323d35d --- /dev/null +++ b/libs/barista-components/experimental/datepicker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/barista-components/experimental/datepicker/tsconfig.lib.json b/libs/barista-components/experimental/datepicker/tsconfig.lib.json new file mode 100644 index 0000000000..82c2a167cb --- /dev/null +++ b/libs/barista-components/experimental/datepicker/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/barista-components/experimental/datepicker/tsconfig.spec.json b/libs/barista-components/experimental/datepicker/tsconfig.spec.json new file mode 100644 index 0000000000..aed68bc6bf --- /dev/null +++ b/libs/barista-components/experimental/datepicker/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/barista-components/experimental/datepicker/tslint.json b/libs/barista-components/experimental/datepicker/tslint.json new file mode 100644 index 0000000000..e9dbc536a6 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tslint.json", + "rules": {}, + "linterOptions": { + "exclude": ["!**/*"] + } +} diff --git a/libs/barista-components/tsconfig.json b/libs/barista-components/tsconfig.json index 3e1d7b2e48..c49752cf7d 100644 --- a/libs/barista-components/tsconfig.json +++ b/libs/barista-components/tsconfig.json @@ -47,6 +47,9 @@ { "path": "./core/tsconfig.json" }, + { + "path": "./datepicker/tsconfig.json" + }, { "path": "./form-field/tsconfig.json" }, diff --git a/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.html b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.html new file mode 100644 index 0000000000..9c9158258d --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.html @@ -0,0 +1,25 @@ +
+

Full Dark Mode Datepicker

+ + + Disabled + Enabled Time Mode + +

Dark Mode Calendar

+ + + +

Dark Mode Calendar Body Only

+ + + +

Dark Mode Timepicker

+ + Disabled +
diff --git a/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.scss b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.scss new file mode 100644 index 0000000000..2fca28ddd8 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.scss @@ -0,0 +1,7 @@ +.dt-checkbox { + margin-top: 10px; +} + +.dt-example-dark h2 { + color: white; +} diff --git a/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.ts b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.ts new file mode 100644 index 0000000000..b9c02a0dac --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'dt-example-datepicker-dark', + templateUrl: 'datepicker-dark-example.html', + styleUrls: ['datepicker-dark-example.scss'], +}) +export class DtExampleDatepickerDark { + startAt = new Date(2020, 7, 31); + isDatepickerDisabled = false; + isTimepickerDisabled = false; + isDatepickerTimeEnabled = true; +} diff --git a/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.css b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.css new file mode 100644 index 0000000000..02a048fd5d --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.css @@ -0,0 +1,3 @@ +.dt-checkbox { + margin-top: 10px; +} diff --git a/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.html b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.html new file mode 100644 index 0000000000..40a5f2caa2 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.html @@ -0,0 +1,26 @@ +

Full datepicker

+ + + +Disabled +Enabled Time Mode + +

Calendar

+ + + +

Calendar body only

+ + + +

Timepicker

+ + + +Disabled diff --git a/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.ts b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.ts new file mode 100644 index 0000000000..5a4a20b07c --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'dt-example-datepicker-default', + templateUrl: 'datepicker-default-example.html', + styleUrls: ['datepicker-default-example.css'], +}) +export class DtExampleDatepickerDefault { + startAt = new Date(2020, 7, 31); + isDatepickerDisabled = false; + isTimepickerDisabled = false; + isDatepickerTimeEnabled = true; +} diff --git a/libs/examples/src/datepicker/datepicker-examples.module.ts b/libs/examples/src/datepicker/datepicker-examples.module.ts new file mode 100644 index 0000000000..1500653e16 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-examples.module.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DtCheckboxModule } from '@dynatrace/barista-components/checkbox'; +import { DtNativeDateModule } from '@dynatrace/barista-components/core'; +import { DtDatepickerModule } from '@dynatrace/barista-components/experimental/datepicker'; +import { DtThemingModule } from '@dynatrace/barista-components/theming'; +import { + DT_DEFAULT_DARK_THEMING_CONFIG, + DT_OVERLAY_THEMING_CONFIG, +} from './../../../barista-components/core/src/overlay/overlay-theming-configuration'; +import { DtExampleDatepickerDark } from './datepicker-dark-example/datepicker-dark-example'; +import { DtExampleDatepickerDefault } from './datepicker-default-example/datepicker-default-example'; + +@NgModule({ + imports: [ + FormsModule, + DtDatepickerModule, + DtThemingModule, + DtCheckboxModule, + DtNativeDateModule, + ], + declarations: [DtExampleDatepickerDark, DtExampleDatepickerDefault], + providers: [ + { + provide: DT_OVERLAY_THEMING_CONFIG, + useValue: DT_DEFAULT_DARK_THEMING_CONFIG, + }, + ], +}) +export class DtExamplesDatepickerModule {} diff --git a/libs/examples/src/datepicker/index.ts b/libs/examples/src/datepicker/index.ts new file mode 100644 index 0000000000..40f78badd6 --- /dev/null +++ b/libs/examples/src/datepicker/index.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './datepicker-dark-example/datepicker-dark-example'; +export * from './datepicker-default-example/datepicker-default-example'; +export * from './datepicker-examples.module'; diff --git a/libs/examples/src/examples.module.ts b/libs/examples/src/examples.module.ts index 95499ef038..2503e2c69b 100644 --- a/libs/examples/src/examples.module.ts +++ b/libs/examples/src/examples.module.ts @@ -83,6 +83,7 @@ import { DtToastExamplesModule } from './toast/toast-examples.module'; import { DtToggleButtonGroupExamplesModule } from './toggle-button-group/toggle-button-group-examples.module'; import { DtExamplesTopBarNavigationModule } from './top-bar-navigation/top-bar-navigation-examples.module'; import { DtExamplesTreeTableModule } from './tree-table/tree-table-examples.module'; +import { DtExamplesDatepickerModule } from './datepicker/datepicker-examples.module'; /** * NgModule that includes all example components @@ -151,6 +152,7 @@ import { DtExamplesTreeTableModule } from './tree-table/tree-table-examples.modu DtToggleButtonGroupExamplesModule, DtExamplesTopBarNavigationModule, DtExamplesTreeTableModule, + DtExamplesDatepickerModule, ], }) export class DtExamplesModule {} diff --git a/libs/examples/src/index.ts b/libs/examples/src/index.ts index c134e67ef2..7d33ecc71c 100644 --- a/libs/examples/src/index.ts +++ b/libs/examples/src/index.ts @@ -333,6 +333,9 @@ import { DtExampleTreeTableProblemIndicator } from './tree-table/tree-table-prob import { DtExampleTreeTableSimple } from './tree-table/tree-table-simple-example/tree-table-simple-example'; import { DtExampleComboboxCustomOptionHeight } from './combobox/combobox-custom-option-height-example/combobox-custom-option-height-example'; import { DtExampleFilterFieldInfiniteDataDepth } from './filter-field/filter-field-infinite-data-depth-example/filter-field-infinite-data-depth-example'; +import { DtExampleDatepickerDark } from './datepicker/datepicker-dark-example/datepicker-dark-example'; +import { DtExampleDatepickerDefault } from './datepicker/datepicker-default-example/datepicker-default-example'; + export { DtExamplesModule } from './examples.module'; export { DtAlertExamplesModule } from './alert/alert-examples.module'; export { DtAutocompleteExamplesModule } from './autocomplete/autocomplete-examples.module'; @@ -396,6 +399,7 @@ export { DtToastExamplesModule } from './toast/toast-examples.module'; export { DtToggleButtonGroupExamplesModule } from './toggle-button-group/toggle-button-group-examples.module'; export { DtExamplesTopBarNavigationModule } from './top-bar-navigation/top-bar-navigation-examples.module'; export { DtExamplesTreeTableModule } from './tree-table/tree-table-examples.module'; +export { DtExamplesDatepickerModule } from './datepicker/datepicker-examples.module'; export { DtExampleAlertDarkError, DtExampleAlertDark, @@ -705,7 +709,12 @@ export { DtExampleTreeTableProblemIndicator, DtExampleTreeTableSimple, DtExampleFilterFieldInfiniteDataDepth, +<<<<<<< HEAD DtExampleSelectCustomValueTemplate, +======= + DtExampleDatepickerDark, + DtExampleDatepickerDefault, +>>>>>>> d2067af36... feat(datepicker): Added datepicker component. }; export const EXAMPLES_MAP = new Map>([ @@ -822,6 +831,8 @@ export const EXAMPLES_MAP = new Map>([ ['DtExampleDrawerNested', DtExampleDrawerNested], ['DtExampleDrawerOver', DtExampleDrawerOver], ['DtExampleDrawerTableDefault', DtExampleDrawerTableDefault], + ['DtExampleDatepickerDark', DtExampleDatepickerDark], + ['DtExampleDatepickerDefault', DtExampleDatepickerDefault], ['DtExampleCustomEmptyStateTable', DtExampleCustomEmptyStateTable], ['DtExampleCustomEmptyState', DtExampleCustomEmptyState], ['DtExampleEmptyStateDefault', DtExampleEmptyStateDefault], diff --git a/libs/examples/src/inline-editor/inline-editor-examples.module.ts b/libs/examples/src/inline-editor/inline-editor-examples.module.ts index aba18d9df8..f69e02334a 100644 --- a/libs/examples/src/inline-editor/inline-editor-examples.module.ts +++ b/libs/examples/src/inline-editor/inline-editor-examples.module.ts @@ -36,7 +36,6 @@ import { DtExampleInlineEditorRequired } from './inline-editor-required-example/ DtExampleInlineEditorApi, DtExampleInlineEditorDefault, DtExampleInlineEditorFailing, - DtExampleInlineEditorFailing, DtExampleInlineEditorRequired, DtExampleInlineEditorSuccessful, DtExampleInlineEditorValidation, diff --git a/nx.json b/nx.json index d64d30d913..87e001a604 100644 --- a/nx.json +++ b/nx.json @@ -75,6 +75,7 @@ "context-dialog", "copy-to-clipboard", "core", + "datepicker", "drawer", "drawer-table", "empty-state", @@ -181,6 +182,9 @@ "core": { "tags": ["scope:components", "type:library"] }, + "datepicker": { + "tags": ["scope:components", "type:library"] + }, "drawer": { "tags": ["scope:components", "type:library"] }, diff --git a/tsconfig.base.json b/tsconfig.base.json index f51d6ac6e0..ea69cb4204 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -68,6 +68,9 @@ "@dynatrace/barista-components/core": [ "libs/barista-components/core/index.ts" ], + "@dynatrace/barista-components/experimental/datepicker": [ + "libs/barista-components/experimental/datepicker/index.ts" + ], "@dynatrace/barista-components/drawer": [ "libs/barista-components/drawer/index.ts" ],