From 8784ec397ca13049cbfbabc733db912b021883be Mon Sep 17 00:00:00 2001 From: Thomas Pink Date: Mon, 29 Jun 2020 13:16:33 +0000 Subject: [PATCH] feat(timepicker): Added experimental timepicker and timeinput components. --- angular.json | 22 ++ apps/demos/src/app-routing.module.ts | 4 + apps/demos/src/nav-items.ts | 13 + apps/dev/src/app.module.ts | 11 +- .../datepicker/datepicker-demo.component.html | 17 + .../datepicker/datepicker-demo.component.scss | 7 + .../datepicker/datepicker-demo.component.ts | 27 ++ apps/dev/src/devapp-routing.module.ts | 2 + apps/dev/src/devapp.component.ts | 1 + apps/dev/src/dt-components.module.ts | 2 + libs/barista-components/config.bzl | 1 + libs/barista-components/core/index.ts | 1 + .../core/src/date/date-adapter.ts | 130 ++++++++ .../core/src/date/date-module.ts | 24 ++ .../barista-components/core/src/date/index.ts | 19 ++ .../core/src/date/native-date-adapter.ts | 233 ++++++++++++++ .../core/src/overlay/index.ts | 1 + .../overlay/overlay-theming-configuration.ts | 44 +++ .../experimental/datepicker/BUILD.bazel | 112 +++++++ .../experimental/datepicker/README.md | 48 +++ .../experimental/datepicker/barista.json | 32 ++ .../experimental/datepicker/index.ts | 19 ++ .../experimental/datepicker/jest.config.json | 8 + .../experimental/datepicker/package.json | 7 + .../datepicker/src/_calendar-body-theme.scss | 16 + .../src/_calendar-header-theme.scss | 20 ++ .../datepicker/src/_timeinput-theme.scss | 16 + .../datepicker/src/datepicker-module.ts | 43 +++ .../src/datepicker-utils/util.spec.ts | 158 ++++++++++ .../datepicker/src/datepicker-utils/util.ts | 87 ++++++ .../experimental/datepicker/src/test-setup.ts | 17 + .../datepicker/src/timeinput.html | 51 +++ .../datepicker/src/timeinput.scss | 73 +++++ .../datepicker/src/timeinput.spec.ts | 291 ++++++++++++++++++ .../experimental/datepicker/src/timeinput.ts | 227 ++++++++++++++ .../datepicker/src/timepicker.html | 20 ++ .../datepicker/src/timepicker.scss | 20 ++ .../datepicker/src/timepicker.spec.ts | 100 ++++++ .../experimental/datepicker/src/timepicker.ts | 89 ++++++ .../experimental/datepicker/tsconfig.json | 16 + .../experimental/datepicker/tsconfig.lib.json | 13 + .../datepicker/tsconfig.spec.json | 10 + .../experimental/datepicker/tslint.json | 7 + libs/barista-components/tsconfig.json | 3 + .../datepicker-dark-example.html | 5 + .../datepicker-dark-example.scss | 7 + .../datepicker-dark-example.ts | 26 ++ .../datepicker-default-example.html | 5 + .../datepicker-default-example.scss | 3 + .../datepicker-default-example.ts | 26 ++ .../datepicker/datepicker-examples.module.ts | 45 +++ libs/examples/src/datepicker/index.ts | 19 ++ libs/examples/src/examples.module.ts | 2 + libs/examples/src/index.ts | 8 + .../inline-editor-examples.module.ts | 1 - nx.json | 4 + tsconfig.base.json | 3 + 57 files changed, 2214 insertions(+), 2 deletions(-) create mode 100644 apps/dev/src/datepicker/datepicker-demo.component.html create mode 100644 apps/dev/src/datepicker/datepicker-demo.component.scss create mode 100644 apps/dev/src/datepicker/datepicker-demo.component.ts create mode 100644 libs/barista-components/core/src/date/date-adapter.ts create mode 100644 libs/barista-components/core/src/date/date-module.ts create mode 100644 libs/barista-components/core/src/date/index.ts create mode 100644 libs/barista-components/core/src/date/native-date-adapter.ts create mode 100644 libs/barista-components/core/src/overlay/overlay-theming-configuration.ts create mode 100644 libs/barista-components/experimental/datepicker/BUILD.bazel create mode 100644 libs/barista-components/experimental/datepicker/README.md create mode 100644 libs/barista-components/experimental/datepicker/barista.json create mode 100644 libs/barista-components/experimental/datepicker/index.ts create mode 100644 libs/barista-components/experimental/datepicker/jest.config.json create mode 100644 libs/barista-components/experimental/datepicker/package.json create mode 100644 libs/barista-components/experimental/datepicker/src/_calendar-body-theme.scss create mode 100644 libs/barista-components/experimental/datepicker/src/_calendar-header-theme.scss create mode 100644 libs/barista-components/experimental/datepicker/src/_timeinput-theme.scss create mode 100644 libs/barista-components/experimental/datepicker/src/datepicker-module.ts create mode 100644 libs/barista-components/experimental/datepicker/src/datepicker-utils/util.spec.ts create mode 100644 libs/barista-components/experimental/datepicker/src/datepicker-utils/util.ts create mode 100644 libs/barista-components/experimental/datepicker/src/test-setup.ts create mode 100644 libs/barista-components/experimental/datepicker/src/timeinput.html create mode 100644 libs/barista-components/experimental/datepicker/src/timeinput.scss create mode 100644 libs/barista-components/experimental/datepicker/src/timeinput.spec.ts create mode 100644 libs/barista-components/experimental/datepicker/src/timeinput.ts create mode 100644 libs/barista-components/experimental/datepicker/src/timepicker.html create mode 100644 libs/barista-components/experimental/datepicker/src/timepicker.scss create mode 100644 libs/barista-components/experimental/datepicker/src/timepicker.spec.ts create mode 100644 libs/barista-components/experimental/datepicker/src/timepicker.ts create mode 100644 libs/barista-components/experimental/datepicker/tsconfig.json create mode 100644 libs/barista-components/experimental/datepicker/tsconfig.lib.json create mode 100644 libs/barista-components/experimental/datepicker/tsconfig.spec.json create mode 100644 libs/barista-components/experimental/datepicker/tslint.json create mode 100644 libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.html create mode 100644 libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.scss create mode 100644 libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.ts create mode 100644 libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.html create mode 100644 libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.scss create mode 100644 libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.ts create mode 100644 libs/examples/src/datepicker/datepicker-examples.module.ts create mode 100644 libs/examples/src/datepicker/index.ts diff --git a/angular.json b/angular.json index 16ddd1fbe9..1c44dfa171 100644 --- a/angular.json +++ b/angular.json @@ -1437,6 +1437,28 @@ }, "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/**" + ] + } + } + }, + "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..8b3d0f82f2 100644 --- a/apps/demos/src/app-routing.module.ts +++ b/apps/demos/src/app-routing.module.ts @@ -324,6 +324,8 @@ import { DtExampleComboboxCustomOptionHeight, DtExampleFilterFieldInfiniteDataDepth, DtExampleSelectCustomValueTemplate, + DtExampleDatepickerDark, + DtExampleDatepickerDefault } from '@dynatrace/examples'; // The Routing Module replaces the routing configuration in the root or feature module. @@ -581,6 +583,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..78b71cf9c7 --- /dev/null +++ b/apps/dev/src/datepicker/datepicker-demo.component.html @@ -0,0 +1,17 @@ +

Datepicker

+ +

Timepicker

+ + + +Disabled + +
+

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..bcf8d88859 --- /dev/null +++ b/apps/dev/src/datepicker/datepicker-demo.component.ts @@ -0,0 +1,27 @@ +/** + * @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 { + isTimepickerDisabled = false; + isDarkTimepickerDisabled = false; +} 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..0c3f4eab61 --- /dev/null +++ b/libs/barista-components/core/src/date/native-date-adapter.ts @@ -0,0 +1,233 @@ +/** + * @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. + */ + +/** + * This code has been implemented based on the NativeDateAdapter from Angular Material + * https://github.com/angular/components/blob/master/src/material/core/datetime/native-date-adapter.ts + */ + +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..d6f0cb4221 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/BUILD.bazel @@ -0,0 +1,112 @@ +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/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/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..298eecbde4 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/README.md @@ -0,0 +1,48 @@ +# 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..6901701790 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/barista.json @@ -0,0 +1,32 @@ +{ + "title": "Datepicker", + "description": "ToDo", + "postid": "datepicker", + "identifier": "Da", + "category": "components", + "public": true, + "contributors": { + "dev": [ + { + "name": "Thomas Pink", + "githubuser": "thomaspink" + }, + { + "name": "Alexandra Râpeanu", + "githubuser": "nimrod13" + }, + { + "name": "Rowa Audil", + "githubuser": "rowa-audil" + } + ], + "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..f0ecb881c4 --- /dev/null +++ b/libs/barista-components/experimental/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 './src/timeinput'; +export * from './src/timepicker'; +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/datepicker-module.ts b/libs/barista-components/experimental/datepicker/src/datepicker-module.ts new file mode 100644 index 0000000000..a833e91701 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-module.ts @@ -0,0 +1,43 @@ +/** + * @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 { DtTimeInput } from './timeinput'; +import { DtTimepicker } from './timepicker'; + +const COMPONENTS = [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..c123069710 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.spec.ts @@ -0,0 +1,158 @@ +/** + * @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, + isPastedTimeValid, +} from './util'; + +describe('timeinput', () => { + describe('valueTo2DigitString', () => { + it('should cast a number value to string', () => { + expect(valueTo2DigitString(15)).toBe('15'); + expect(valueTo2DigitString(20)).toBe('20'); + }); + it('should prepend zeros for numbers smaller than 10', () => { + expect(valueTo2DigitString(0)).toBe('00'); + expect(valueTo2DigitString(8)).toBe('08'); + }); + }); + + 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(); + }); + }); + + describe('isPastedTimeValid', () => { + it('should return true if a valid hh:mm value is passed', () => { + expect(isPastedTimeValid('00:00')).toBeTruthy(); + expect(isPastedTimeValid('00:23')).toBeTruthy(); + expect(isPastedTimeValid('01:02')).toBeTruthy(); + expect(isPastedTimeValid('1:2')).toBeTruthy(); + expect(isPastedTimeValid('15:23')).toBeTruthy(); + expect(isPastedTimeValid('20:59')).toBeTruthy(); + }); + it('should return false if an invalid hh:mm value is passed', () => { + expect(isPastedTimeValid('29:23')).toBeFalsy(); + expect(isPastedTimeValid('10:66')).toBeFalsy(); + expect(isPastedTimeValid('24:66')).toBeFalsy(); + expect(isPastedTimeValid('02:60')).toBeFalsy(); + expect(isPastedTimeValid('12;23')).toBeFalsy(); + expect(isPastedTimeValid('12-23')).toBeFalsy(); + expect(isPastedTimeValid('12.23')).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..b186d53d5e --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/datepicker-utils/util.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 { + 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_FORMAT_REGEX = /[0]{3,}|[.+-]|[0]{2}[0-9]/g; +const HOURMIN_REGEX = /^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$/g; + +/** Checks whether the provided object is a valid date and returns it; null otherwise. */ +export function getValidDateOrNull( + dateAdapter: DtDateAdapter, + obj: any, +): D | null { + return dateAdapter.isDateInstance(obj) && dateAdapter.isValid(obj) + ? obj + : null; +} + +/** Check if 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 of a valid hour/minute number is 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_FORMAT_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(); +} + +/** Check if a pasted value is a valid hour and minute value. */ +export function isPastedTimeValid(value: string): boolean { + return Boolean(value.match(HOURMIN_REGEX)); +} 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..c9a1652d75 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.html @@ -0,0 +1,51 @@ +
+
+ +
+ : +
+ +
+
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..de8ea514dd --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.spec.ts @@ -0,0 +1,291 @@ +/** + * @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; + let hourEl: HTMLInputElement; + let minuteEl: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleTimeInputTestApp); + component = fixture.componentInstance; + hourEl = component.timeInput._hourInput.nativeElement; + minuteEl = component.timeInput._minuteInput.nativeElement; + fixture.detectChanges(); + }); + + describe('disabled input property', () => { + it('should not be usable when disabled', fakeAsync(() => { + component.disabled = true; + fixture.detectChanges(); + tick(); + expect(hourEl.disabled).toBeTruthy(); + expect(hourEl.getAttribute('aria-disabled')).toBeTruthy(); + expect(minuteEl.disabled).toBeTruthy(); + expect(minuteEl.getAttribute('aria-disabled')).toBeTruthy(); + })); + }); + + describe('timeChange event', () => { + it('should emit a timechange event when the hour and minute inputs are changed', fakeAsync(() => { + 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 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(() => { + 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 switch focus from the hour to the minute input when typing in 2 digits in the hour input, but the minute input only had a valid 1 digit value', fakeAsync(() => { + component.timeInput.hour = 15; + component.timeInput.minute = 3; + 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(() => { + 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 2 digit value', fakeAsync(() => { + 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(() => { + 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(() => { + 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(() => { + 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(() => { + 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(() => { + 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(); + 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(() => { + typeInElement('20', hourEl); + fixture.detectChanges(); + expect(hourEl.value).toBe('20'); + expect(component.timeInput.hour).toBe(20); + })); + + it('should be null or empty if the new value is set to null or empty (e.g.: user types in a digit, then deletes it)', fakeAsync(() => { + typeInElement('', hourEl); + fixture.detectChanges(); + tick(); + expect(hourEl.value).toBe(''); + expect(component.timeInput.hour).toBe(null); + 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(() => { + 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(); + 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(() => { + 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..ace6d699b5 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timeinput.ts @@ -0,0 +1,227 @@ +/** + * @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 { SHIFT, TAB } from '@angular/cdk/keycodes'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { + isDefined, + isEmpty, + _readKeyCode, +} from '@dynatrace/barista-components/core'; +import { + hasMininmumTwoDigits, + isPastedTimeValid, + 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); + } + + /** + * @internal + * Add the focus switch from the hour input to the minute input when the user typed in 2 digits. + */ + _onHourKeyUp(event: KeyboardEvent): void { + const keyCode = _readKeyCode(event); + if ( + keyCode !== SHIFT && + keyCode !== TAB && + hasMininmumTwoDigits(this._hour) && + !hasMininmumTwoDigits(this._minute) + ) { + this._minuteInput.nativeElement.focus(); + } + } + + /** + * @internal + * 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; + this._handleHourInputValidation(value); + } + + /** + * @internal Check if the hour input is valid and set the new value accordingly. + */ + _handleHourInputValidation(value: string): void { + 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; + this._handleMinuteInputValidation(value); + } + + /** + * @internal Check if the minute input is valid and set the new value accordingly. + */ + _handleMinuteInputValidation(value: string): void { + 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 !== '+'; + } + + /** + * @internal Handle pasted values in the hour input, so that the user can paste a valid 'hh:mm' string (validated by regex) + */ + _onTimePaste(event: ClipboardEvent): void { + const pastedValue = event?.clipboardData?.getData('text'); + if (pastedValue && isPastedTimeValid(pastedValue)) { + const [pastedHour, pastedMinute] = pastedValue.split(':'); + this._handleHourInputValidation(pastedHour); + this._handleMinuteInputValidation(pastedMinute); + } + } +} 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..4fb4da3585 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.html @@ -0,0 +1,20 @@ +
+
+
{{ 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..ce951087ca --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.spec.ts @@ -0,0 +1,100 @@ +/** + * @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, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { createComponent, dispatchFakeEvent } 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; + let hourEl: HTMLInputElement; + let minuteEl: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleTimePickerTestApp); + component = fixture.componentInstance; + hourEl = component.timePicker._timeInput._hourInput.nativeElement; + minuteEl = component.timePicker._timeInput._minuteInput.nativeElement; + fixture.detectChanges(); + }); + + /** + * Add tests for the range mode which will be added in a later version. + */ + describe('timeChange event', () => { + it('should emit a timechange event when the hour and minute inputs are changed', fakeAsync(() => { + const changeSpy = jest.fn(); + component.timePicker.timeChange.subscribe(changeSpy); + fixture.detectChanges(); + expect(changeSpy).not.toHaveBeenCalled(); + component.timePicker._timeInput.hour = 23; + component.timePicker._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 changeSpy = jest.fn(); + component.timePicker.timeChange.subscribe(changeSpy); + fixture.detectChanges(); + expect(changeSpy).not.toHaveBeenCalled(); + component.timePicker._timeInput.hour = null; + component.timePicker._timeInput.minute = null; + fixture.detectChanges(); + dispatchFakeEvent(minuteEl, 'blur'); + expect(changeSpy).toHaveBeenCalledTimes(1); + })); + }); + }); +}); + +@Component({ + selector: 'dt-test-app', + template: ` + + `, +}) +class SimpleTimePickerTestApp { + hour: number | null = 11; + minute: number | null = 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..cb32a2e749 --- /dev/null +++ b/libs/barista-components/experimental/datepicker/src/timepicker.ts @@ -0,0 +1,89 @@ +/** + * @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: string; + + /** 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; + + /** + * @internal + * Property used for enabling the time range mode. + */ + _isTimeRangeEnabled = false; + + /** 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; + + /** @internal 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..6bf2fa908f --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.html @@ -0,0 +1,5 @@ +
+

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..840db3c5a2 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-dark-example/datepicker-dark-example.ts @@ -0,0 +1,26 @@ +/** + * @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 { + isTimepickerDisabled = false; +} 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..eb7d0d8601 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.html @@ -0,0 +1,5 @@ +

Timepicker

+ + + +Disabled diff --git a/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.scss b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.scss new file mode 100644 index 0000000000..02a048fd5d --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.scss @@ -0,0 +1,3 @@ +.dt-checkbox { + margin-top: 10px; +} 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..a69a9566a0 --- /dev/null +++ b/libs/examples/src/datepicker/datepicker-default-example/datepicker-default-example.ts @@ -0,0 +1,26 @@ +/** + * @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.scss'], +}) +export class DtExampleDatepickerDefault { + isTimepickerDisabled = false; +} 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..3e0af066ab --- /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, + DT_DEFAULT_DARK_THEMING_CONFIG, + DT_OVERLAY_THEMING_CONFIG, +} from '@dynatrace/barista-components/core'; +import { DtDatepickerModule } from '@dynatrace/barista-components/experimental/datepicker'; +import { DtThemingModule } from '@dynatrace/barista-components/theming'; +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..78d8061bff 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, @@ -706,6 +710,8 @@ export { DtExampleTreeTableSimple, DtExampleFilterFieldInfiniteDataDepth, DtExampleSelectCustomValueTemplate, + DtExampleDatepickerDark, + DtExampleDatepickerDefault, }; export const EXAMPLES_MAP = new Map>([ @@ -822,6 +828,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" ],