diff --git a/package-lock.json b/package-lock.json
index a00913dd..b7e6d935 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10169,10 +10169,9 @@
}
},
"date-fns": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz",
- "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==",
- "dev": true
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz",
+ "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ=="
},
"date-format": {
"version": "3.0.0",
@@ -15535,6 +15534,14 @@
"cli-cursor": "^2.1.0",
"date-fns": "^1.27.2",
"figures": "^2.0.0"
+ },
+ "dependencies": {
+ "date-fns": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz",
+ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==",
+ "dev": true
+ }
}
},
"load-json-file": {
diff --git a/package.json b/package.json
index 6ed7eaea..bb86c984 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@angular/router": "~10.0.5",
"ngx-mat-select-search": "^3.0.1",
"nouislider": "^14.0.2",
+ "date-fns": "2.16.1",
"rxjs": "~6.5.5",
"tslib": "^2.0.0",
"zone.js": "~0.10.2"
diff --git a/projects/components/date-time-input/index.ts b/projects/components/date-time-input/index.ts
new file mode 100644
index 00000000..c74f953a
--- /dev/null
+++ b/projects/components/date-time-input/index.ts
@@ -0,0 +1,3 @@
+// export what ./public_api exports so we can import with the lib name like this:
+// import { ModuleA } from 'libname'
+export * from './public_api';
diff --git a/projects/components/date-time-input/package.json b/projects/components/date-time-input/package.json
new file mode 100644
index 00000000..dedb72ce
--- /dev/null
+++ b/projects/components/date-time-input/package.json
@@ -0,0 +1,7 @@
+{
+ "ngPackage": {
+ "lib": {
+ "entryFile": "index.ts"
+ }
+ }
+}
diff --git a/projects/components/date-time-input/public_api.ts b/projects/components/date-time-input/public_api.ts
new file mode 100644
index 00000000..8d7567fe
--- /dev/null
+++ b/projects/components/date-time-input/public_api.ts
@@ -0,0 +1,2 @@
+export { PsDateTimeInputComponent as DateTimeComponent } from './src/date-time-input.component';
+export { PsDateTimeInputModule } from './src/date-time-input.module';
diff --git a/projects/components/date-time-input/src/date-time-input.component.html b/projects/components/date-time-input/src/date-time-input.component.html
new file mode 100644
index 00000000..f81eb13f
--- /dev/null
+++ b/projects/components/date-time-input/src/date-time-input.component.html
@@ -0,0 +1,29 @@
+
+
+
diff --git a/projects/components/date-time-input/src/date-time-input.component.scss b/projects/components/date-time-input/src/date-time-input.component.scss
new file mode 100644
index 00000000..71f3f25e
--- /dev/null
+++ b/projects/components/date-time-input/src/date-time-input.component.scss
@@ -0,0 +1,11 @@
+.ps-date-time__grid-container {
+ display: grid;
+ grid-template-columns: 2fr min-content minmax(min-content, 1fr);
+ grid-gap: 1px;
+ margin-bottom: -0.7em;
+ margin-top: -0.7em;
+}
+
+.mat-form-field-suffix {
+ top: 0;
+}
diff --git a/projects/components/date-time-input/src/date-time-input.component.spec.ts b/projects/components/date-time-input/src/date-time-input.component.spec.ts
new file mode 100644
index 00000000..24cf597d
--- /dev/null
+++ b/projects/components/date-time-input/src/date-time-input.component.spec.ts
@@ -0,0 +1,104 @@
+import { Platform } from '@angular/cdk/platform';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { getLocaleFirstDayOfWeek } from '@angular/common';
+import { Component, Inject, Injectable, LOCALE_ID, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { DateAdapter, MatDateFormats, MAT_DATE_FORMATS, NativeDateAdapter } from '@angular/material/core';
+import { parseHumanInput } from '@prosoft/components/utils';
+import { PsDateTimeInputComponent } from './date-time-input.component';
+import { PsDateTimeInputModule } from './date-time-input.module';
+import { PsDateTimeInputHarness } from './testing/date-time-input.harness';
+
+export const TEST_DATE_FORMATS: MatDateFormats = {
+ parse: {
+ dateInput: null,
+ },
+ display: {
+ dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' },
+ monthYearLabel: { year: 'numeric', month: 'short' },
+ dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
+ monthYearA11yLabel: { year: 'numeric', month: 'long' },
+ },
+};
+
+// extend NativeDateAdapter's format method to specify the date format.
+@Injectable({ providedIn: 'root' })
+export class TestDateTimeAdapter extends NativeDateAdapter {
+ constructor(@Inject(LOCALE_ID) _locale: string, platform: Platform) {
+ super(_locale, platform);
+ }
+
+ public sameDate(a: any, b: any) {
+ return !a && !b ? false : super.sameDate(a, b);
+ }
+
+ public getFirstDayOfWeek(): number {
+ return getLocaleFirstDayOfWeek(this.locale);
+ }
+
+ // If required extend other NativeDateAdapter methods.
+ public parse(value: any): Date | null {
+ return parseHumanInput(value);
+ }
+
+ public getIs24Hours(): boolean {
+ return new Date(79200000).toLocaleTimeString(this.locale).indexOf('11') === -1;
+ }
+}
+
+@Component({
+ selector: 'ps-test-component',
+ template: ` `,
+})
+export class TestDataSourceComponent {
+ public disabled = false;
+ @ViewChild(PsDateTimeInputComponent) public dateTimeInputComponent: PsDateTimeInputComponent;
+}
+
+describe('DateTimeInputComponent', () => {
+ let fixture: ComponentFixture;
+ let component: TestDataSourceComponent;
+ let loader: HarnessLoader;
+ let dateTimeInput: PsDateTimeInputHarness;
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [PsDateTimeInputModule],
+ declarations: [TestDataSourceComponent],
+ providers: [
+ { provide: DateAdapter, useClass: TestDateTimeAdapter },
+ { provide: MAT_DATE_FORMATS, useValue: TEST_DATE_FORMATS },
+ ],
+ });
+ fixture = TestBed.createComponent(TestDataSourceComponent);
+ component = fixture.componentInstance;
+ expect(component).toBeDefined();
+
+ loader = TestbedHarnessEnvironment.loader(fixture);
+ dateTimeInput = await loader.getHarness(PsDateTimeInputHarness);
+ });
+
+ it('Should be disabled', async () => {
+ expect(await dateTimeInput.isDisabled()).toEqual(false);
+ component.disabled = true;
+ expect(await dateTimeInput.isDisabled()).toEqual(true);
+ });
+
+ it('Should return valid date', () => {
+ component.dateTimeInputComponent.datum = new Date();
+ component.dateTimeInputComponent.uhrzeit = '11:11';
+ const value = component.dateTimeInputComponent.convertToDate();
+
+ const expectedValue = new Date();
+ expectedValue.setHours(11, 11);
+ expect(value.getDate()).toEqual(expectedValue.getDate());
+ });
+
+ it('Should return invalid Date', () => {
+ component.dateTimeInputComponent.datum = new Date();
+ component.dateTimeInputComponent.uhrzeit = null;
+ const value = component.dateTimeInputComponent.convertToDate();
+
+ expect(value.getTime()).toEqual(NaN);
+ });
+});
diff --git a/projects/components/date-time-input/src/date-time-input.component.ts b/projects/components/date-time-input/src/date-time-input.component.ts
new file mode 100644
index 00000000..4460ba36
--- /dev/null
+++ b/projects/components/date-time-input/src/date-time-input.component.ts
@@ -0,0 +1,255 @@
+import { coerceBooleanProperty } from '@angular/cdk/coercion';
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ DoCheck,
+ ElementRef,
+ Input,
+ OnDestroy,
+ OnInit,
+ Optional,
+ Self,
+ ViewChild,
+} from '@angular/core';
+import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm } from '@angular/forms';
+import { CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core';
+import { MatFormFieldControl } from '@angular/material/form-field';
+import { isValid } from '@prosoft/components/utils';
+import { Subject } from 'rxjs';
+
+export class PsDateTimeInputBase {
+ constructor(
+ public _defaultErrorStateMatcher: ErrorStateMatcher,
+ public _parentForm: NgForm,
+ public _parentFormGroup: FormGroupDirective,
+ public ngControl: NgControl
+ ) {}
+}
+
+let nextUniqueId = 0;
+
+export const _PsDateTimeMixinBase: CanUpdateErrorStateCtor & typeof PsDateTimeInputBase = mixinErrorState(PsDateTimeInputBase);
+
+@Component({
+ selector: 'ps-date-time-input',
+ templateUrl: './date-time-input.component.html',
+ styleUrls: ['./date-time-input.component.scss'],
+ providers: [{ provide: MatFormFieldControl, useExisting: PsDateTimeInputComponent }],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ // tslint:disable-next-line: no-host-metadata-property
+ host: {
+ // Native input properties that are overwritten by Angular inputs need to be synced with
+ // the native input element. Otherwise property bindings for those don't work.
+ '[attr.id]': 'id',
+ '[attr.placeholder]': 'placeholder',
+ '[attr.disabled]': 'disabled',
+ '[attr.required]': 'required',
+ '[attr.readonly]': 'readonly || null',
+ '[attr.aria-describedby]': '_ariaDescribedby || null',
+ '[attr.aria-invalid]': 'errorState',
+ '[attr.aria-required]': 'required.toString()',
+ },
+})
+export class PsDateTimeInputComponent extends _PsDateTimeMixinBase
+ implements MatFormFieldControl, OnDestroy, ControlValueAccessor, DoCheck, OnInit {
+ @Input()
+ public shouldLabelFloat = true;
+ public stateChanges = new Subject();
+ public errorState: boolean;
+ public controlType = 'ps-date-time-input';
+ public placeholder: string;
+
+ public readonly = false;
+ public get empty(): boolean {
+ return !this.value;
+ }
+ public required = false;
+ /** An object used to control when error messages are shown. */
+ @Input() public errorStateMatcher: ErrorStateMatcher;
+ @Input()
+ public get disabled(): boolean {
+ if (this.ngControl && this.ngControl.disabled !== null) {
+ return this.ngControl.disabled;
+ }
+ return this._disabled;
+ }
+ public set disabled(value: boolean) {
+ this._disabled = coerceBooleanProperty(value);
+ this.dateInputEl.nativeElement.disabled = value;
+ this.timeInputEl.nativeElement.disabled = value;
+ this.stateChanges.next();
+ this.cd.markForCheck();
+ }
+ public autofilled?: boolean;
+
+ public datum: Date;
+ public uhrzeit: string;
+
+ public get focused(): boolean {
+ return this._focused;
+ }
+
+ @Input()
+ public get id(): string {
+ return this._id;
+ }
+ public set id(value: string) {
+ this._id = value || this._uid;
+ }
+
+ public get value(): Date | null {
+ return this._value;
+ }
+ public set value(value: Date | null) {
+ this._value = value;
+ this.stateChanges.next();
+ }
+
+ public _ariaDescribedby: string;
+
+ @ViewChild('dateInput', { static: true }) public dateInputEl: ElementRef;
+ @ViewChild('timeInput', { static: true }) public timeInputEl: ElementRef;
+ protected _id: string;
+ protected _disabled = false;
+
+ protected _uid = `ps-date-time-input-${nextUniqueId++}`;
+
+ private _value: Date | null = null;
+ private _focused = false;
+
+ private _changeFn: (value: Date) => void;
+ private _touchedFn: () => void;
+
+ constructor(
+ @Optional()
+ @Self()
+ public ngControl: NgControl,
+ @Optional() _parentForm: NgForm,
+ @Optional() _parentFormGroup: FormGroupDirective,
+ _defaultErrorStateMatcher: ErrorStateMatcher,
+ private cd: ChangeDetectorRef
+ ) {
+ super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
+ if (this.ngControl != null) {
+ this.ngControl.valueAccessor = this;
+ }
+ }
+ public ngOnInit(): void {
+ // this._id = value || this._uid; wird gesetzt
+ this.id = this.id;
+ }
+ public writeValue(newValue: Date | null): void {
+ this.patchDateValue(newValue);
+ }
+ public setDisabledState?(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+ public registerOnChange(fn: (value: Date) => void): void {
+ this._changeFn = fn;
+ }
+
+ public registerOnTouched(fn: () => void): void {
+ this._touchedFn = fn;
+ }
+
+ public ngDoCheck() {
+ if (this.ngControl) {
+ // We need to re-evaluate this on every change detection cycle, because there are some
+ // error triggers that we can't subscribe to (e.g. parent form submissions). This means
+ // that whatever logic is in here has to be super lean or we risk destroying the performance.
+ this.updateErrorState();
+ }
+ }
+
+ public setDescribedByIds(ids: string[]): void {
+ this._ariaDescribedby = ids.join(' ');
+ }
+ public onContainerClick(_event: MouseEvent): void {
+ // funktioniert nicht, fokus liegt dann auf der ganzen component und man kann in die zeit nur reintabben
+ // this.dateInputEl.nativeElement.focus();
+ }
+
+ public ngOnDestroy() {
+ this.stateChanges.complete();
+ }
+
+ public onBlur() {
+ this._focused = false;
+ this.stateChanges.next();
+ }
+
+ public patchDateValue(newValue: Date | null) {
+ if (newValue !== null && !isValid(newValue)) {
+ return;
+ }
+
+ if (newValue !== null) {
+ newValue = new Date(newValue);
+ }
+ const uhrzeit =
+ newValue !== null
+ ? (newValue.getHours() < 10 ? '0' : '') +
+ newValue.getHours() +
+ ':' +
+ (newValue.getMinutes() < 10 ? '0' : '') +
+ newValue.getMinutes()
+ : null;
+
+ this._value = newValue;
+ this.datum = newValue;
+ this.uhrzeit = uhrzeit;
+
+ this.cd.markForCheck();
+ this.stateChanges.next();
+ }
+
+ public clearInput() {
+ this.datum = null;
+ this.uhrzeit = null;
+ this.cd.markForCheck();
+ }
+
+ public onFocus() {
+ this._focused = true;
+ this.stateChanges.next();
+ this._touchedFn();
+ }
+
+ public onChanged() {
+ this.updateValue();
+ }
+
+ public updateValue() {
+ this._value = this.convertToDate();
+ this._changeFn(this._value);
+ }
+
+ public convertToDate() {
+ const datum = new Date(this.datum);
+ const uhrzeit = this.uhrzeit;
+ const uhrzeitIsValid = /^([0-1][0-9]|2[0-3]):([0-5][0-9])$/.test(uhrzeit);
+
+ if (!datum && this.dateInputEl.nativeElement.value) {
+ return new Date(NaN);
+ }
+
+ if (!datum && !uhrzeit) {
+ return null;
+ }
+
+ if (!uhrzeitIsValid || !isValid(datum)) {
+ return new Date(NaN);
+ }
+
+ if (uhrzeit && datum) {
+ const uhrzeitSplit = uhrzeit.split(':');
+ datum.setHours(parseInt(uhrzeitSplit[0], 10), parseInt(uhrzeitSplit[1], 10));
+ this.stateChanges.next();
+ this.cd.markForCheck();
+ return datum;
+ }
+
+ return new Date(NaN);
+ }
+}
diff --git a/projects/components/date-time-input/src/date-time-input.module.ts b/projects/components/date-time-input/src/date-time-input.module.ts
new file mode 100644
index 00000000..1e65e678
--- /dev/null
+++ b/projects/components/date-time-input/src/date-time-input.module.ts
@@ -0,0 +1,16 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatDatepickerModule } from '@angular/material/datepicker';
+import { MatInputModule } from '@angular/material/input';
+
+import { PsDateTimeInputComponent } from './date-time-input.component';
+
+export const dateTimeModuleImports = [CommonModule, MatDatepickerModule, FormsModule, MatInputModule];
+
+@NgModule({
+ imports: dateTimeModuleImports,
+ declarations: [PsDateTimeInputComponent],
+ exports: [PsDateTimeInputComponent],
+})
+export class PsDateTimeInputModule {}
diff --git a/projects/components/date-time-input/src/testing/date-time-input.harness.ts b/projects/components/date-time-input/src/testing/date-time-input.harness.ts
new file mode 100644
index 00000000..3e825347
--- /dev/null
+++ b/projects/components/date-time-input/src/testing/date-time-input.harness.ts
@@ -0,0 +1,30 @@
+// tslint:disable: member-ordering
+import { BaseHarnessFilters, HarnessPredicate } from '@angular/cdk/testing';
+import { MatFormFieldControlHarness } from '@angular/material/form-field/testing/control';
+
+/** A set of criteria that can be used to filter a list of `MatSelectHarness` instances. */
+// tslint:disable-next-line: no-empty-interface
+export interface PsDateTimeInputHarnessFilters extends BaseHarnessFilters {}
+
+/** Harness for interacting with a standard mat-select in tests. */
+export class PsDateTimeInputHarness extends MatFormFieldControlHarness {
+ static hostSelector = 'ps-date-time-input';
+
+ _dateInput = this.locatorFor('.ps-date-time-picker__date-input');
+ _timeInput = this.locatorFor('.ps-date-time-picker__time-input');
+
+ /**
+ * Gets a `HarnessPredicate` that can be used to search for a `PsDateTimeInputHarness` that meets
+ * certain criteria.
+ * @param options Options for filtering which select instances are considered a match.
+ * @return a `HarnessPredicate` configured with the given options.
+ */
+ static with(options: PsDateTimeInputHarnessFilters = {}): HarnessPredicate {
+ return new HarnessPredicate(PsDateTimeInputHarness, options);
+ }
+
+ /** Gets a boolean promise indicating if the select is disabled. */
+ async isDisabled(): Promise {
+ return (await this._dateInput()).getProperty('disabled') && (await this._timeInput()).getProperty('disabled');
+ }
+}
diff --git a/projects/components/utils/public_api.ts b/projects/components/utils/public_api.ts
index dddfb110..c23fa41e 100644
--- a/projects/components/utils/public_api.ts
+++ b/projects/components/utils/public_api.ts
@@ -1,2 +1,4 @@
-export { objectToKeyValueArray } from './src/object';
+// tslint:disable:no-export-all
+export * from './src/date-fns';
export { isInViewport } from './src/dom';
+export { objectToKeyValueArray } from './src/object';
diff --git a/projects/components/utils/src/date-fns.spec.ts b/projects/components/utils/src/date-fns.spec.ts
new file mode 100644
index 00000000..7480e54a
--- /dev/null
+++ b/projects/components/utils/src/date-fns.spec.ts
@@ -0,0 +1,146 @@
+import { INVALID_DATE, isAfterOrEqual, isBeforeOrEqual, parseHumanInput } from './date-fns';
+
+describe('isBeforeOrEqual', () => {
+ it('should work', () => {
+ const before = new Date(2019, 0, 2, 13, 12, 10);
+ const date = new Date(2019, 0, 2, 13, 12, 11);
+ const equal = new Date(2019, 0, 2, 13, 12, 11);
+ const after = new Date(2019, 0, 2, 13, 12, 12);
+
+ expect(isBeforeOrEqual(before, date)).toBe(true);
+ expect(isBeforeOrEqual(equal, date)).toBe(true);
+ expect(isBeforeOrEqual(date, date)).toBe(true);
+ expect(isBeforeOrEqual(after, date)).toBe(false);
+ });
+});
+
+describe('isAfterOrEqual', () => {
+ it('should work', () => {
+ const before = new Date(2019, 0, 2, 13, 12, 10);
+ const date = new Date(2019, 0, 2, 13, 12, 11);
+ const equal = new Date(2019, 0, 2, 13, 12, 11);
+ const after = new Date(2019, 0, 2, 13, 12, 12);
+
+ expect(isAfterOrEqual(before, date)).toBe(false);
+ expect(isAfterOrEqual(equal, date)).toBe(true);
+ expect(isAfterOrEqual(date, date)).toBe(true);
+ expect(isAfterOrEqual(after, date)).toBe(true);
+ });
+});
+
+describe('parseHumanInput', () => {
+ beforeEach(() => {
+ (parseHumanInput as any).currentYear = 2020;
+ });
+
+ function buildDate(y: number, m: number, d: number) {
+ return new Date(y, m - 1, d);
+ }
+
+ it('should accept 6 digits as ddmmyy, check for bounds and expand year', () => {
+ expect(parseHumanInput('101192')).toEqual(buildDate(1992, 11, 10));
+ expect(parseHumanInput('010516')).toEqual(buildDate(2016, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('011316')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('320516')).toBe(INVALID_DATE);
+
+ // up to 10 years in the future -> use this century
+ expect(parseHumanInput('010520')).toEqual(buildDate(2020, 5, 1));
+ expect(parseHumanInput('010525')).toEqual(buildDate(2025, 5, 1));
+ expect(parseHumanInput('010530')).toEqual(buildDate(2030, 5, 1));
+
+ // year more than 10 years in the future -> use last century
+ expect(parseHumanInput('010531')).toEqual(buildDate(1931, 5, 1));
+ expect(parseHumanInput('010540')).toEqual(buildDate(1940, 5, 1));
+ expect(parseHumanInput('010550')).toEqual(buildDate(1950, 5, 1));
+ expect(parseHumanInput('010560')).toEqual(buildDate(1960, 5, 1));
+ expect(parseHumanInput('010570')).toEqual(buildDate(1970, 5, 1));
+ expect(parseHumanInput('010580')).toEqual(buildDate(1980, 5, 1));
+ expect(parseHumanInput('010590')).toEqual(buildDate(1990, 5, 1));
+ expect(parseHumanInput('010599')).toEqual(buildDate(1999, 5, 1));
+
+ // test with different currentYear
+ (parseHumanInput as any).currentYear = 2010;
+ expect(parseHumanInput('010510')).toEqual(buildDate(2010, 5, 1));
+ expect(parseHumanInput('010521')).toEqual(buildDate(1921, 5, 1));
+ });
+
+ it("should accept 7 digits as ddmmyyy, check for bounds but don't expand year", () => {
+ expect(parseHumanInput('1011992')).toEqual(buildDate(992, 11, 10));
+ expect(parseHumanInput('0105016')).toEqual(buildDate(16, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('0113016')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('3205016')).toBe(INVALID_DATE);
+ });
+
+ it('should accept 8 digits as ddmmyyyy and check for bounds', () => {
+ expect(parseHumanInput('10111992')).toEqual(buildDate(1992, 11, 10));
+ expect(parseHumanInput('01052016')).toEqual(buildDate(2016, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('0113016')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('3205016')).toBe(INVALID_DATE);
+ });
+
+ it("should accept '.' separated date", () => {
+ expect(parseHumanInput('01.05.20')).toEqual(buildDate(2020, 5, 1));
+ expect(parseHumanInput('01.05.530')).toEqual(buildDate(530, 5, 1));
+ expect(parseHumanInput('01.05.1870')).toEqual(buildDate(1870, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('01.13.16')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('32.05.16')).toBe(INVALID_DATE);
+
+ // up to 10 years in the future -> use this century
+ expect(parseHumanInput('01.05.30')).toEqual(buildDate(2030, 5, 1));
+
+ // year more than 10 years in the future -> use last century
+ expect(parseHumanInput('01.05.31')).toEqual(buildDate(1931, 5, 1));
+ });
+
+ it("should accept '/' separated date", () => {
+ expect(parseHumanInput('01/05/20')).toEqual(buildDate(2020, 5, 1));
+ expect(parseHumanInput('01/05/530')).toEqual(buildDate(530, 5, 1));
+ expect(parseHumanInput('01/05/1870')).toEqual(buildDate(1870, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('01/13/16')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('32/05/16')).toBe(INVALID_DATE);
+
+ // up to 10 years in the future -> use this century
+ expect(parseHumanInput('01/05/30')).toEqual(buildDate(2030, 5, 1));
+
+ // year more than 10 years in the future -> use last century
+ expect(parseHumanInput('01/05/31')).toEqual(buildDate(1931, 5, 1));
+ });
+
+ it("should accept ' ' separated date", () => {
+ expect(parseHumanInput('01 05 20')).toEqual(buildDate(2020, 5, 1));
+ expect(parseHumanInput('01 05 530')).toEqual(buildDate(530, 5, 1));
+ expect(parseHumanInput('01 05 1870')).toEqual(buildDate(1870, 5, 1));
+
+ // month out of bounds
+ expect(parseHumanInput('01 13 16')).toBe(INVALID_DATE);
+
+ // day out of bounds
+ expect(parseHumanInput('32 05 16')).toBe(INVALID_DATE);
+
+ // up to 10 years in the future -> use this century
+ expect(parseHumanInput('01 05 30')).toEqual(buildDate(2030, 5, 1));
+
+ // year more than 10 years in the future -> use last century
+ expect(parseHumanInput('01 05 31')).toEqual(buildDate(1931, 5, 1));
+ });
+});
diff --git a/projects/components/utils/src/date-fns.ts b/projects/components/utils/src/date-fns.ts
new file mode 100644
index 00000000..d971ed5b
--- /dev/null
+++ b/projects/components/utils/src/date-fns.ts
@@ -0,0 +1,538 @@
+import {
+ differenceInCalendarWeeks as differenceInCalendarWeeksDateFns,
+ eachWeekOfInterval as eachWeekOfIntervalDateFns,
+ endOfWeek as endOfWeekDateFns,
+ format as formatDateFns,
+ formatDistance as formatDistanceDateFns,
+ formatDistanceStrict as formatDistanceStrictDateFns,
+ formatDistanceToNow as formatDistanceToNowDateFns,
+ formatRelative as formatRelativeDateFns,
+ getWeek as getWeekDateFns,
+ getWeekOfMonth as getWeekOfMonthDateFns,
+ getWeeksInMonth as getWeeksInMonthDateFns,
+ getWeekYear as getWeekYearDateFns,
+ isAfter as isAfterDateFns,
+ isBefore as isBeforeDateFns,
+ isEqual as isEqualDateFns,
+ isSameWeek as isSameWeekDateFns,
+ isThisWeek as isThisWeekDateFns,
+ lastDayOfWeek as lastDayOfWeekDateFns,
+ parse as parseDateFns,
+ setDay as setDayDateFns,
+ setWeek as setWeekDateFns,
+ setWeekYear as setWeekYearDateFns,
+ startOfWeek as startOfWeekDateFns,
+ startOfWeekYear as startOfWeekYearDateFns,
+} from 'date-fns';
+import { de, enGB } from 'date-fns/locale';
+
+// =====================================
+// = Default Locale Helper
+// =====================================
+
+let defaultLocale: Locale = de;
+
+export function getLocale(localeId?: string) {
+ if (!localeId) {
+ return defaultLocale;
+ }
+ return localeId.startsWith('de') ? de : enGB;
+}
+
+export function setDefaultLocale(localeId: string) {
+ defaultLocale = getLocale(localeId);
+}
+
+function buildOptions(options: T): T {
+ return { locale: getLocale(), ...(options || ({} as any)) };
+}
+
+// =====================================
+// = New Date Functions
+// =====================================
+
+export const INVALID_DATE = new Date(NaN);
+
+export function isBeforeOrEqual(date: Date | number, dateToCompare: Date | number): boolean {
+ return isBeforeDateFns(date, dateToCompare) || isEqualDateFns(date, dateToCompare);
+}
+
+export function isAfterOrEqual(date: Date | number, dateToCompare: Date | number): boolean {
+ return isAfterDateFns(date, dateToCompare) || isEqualDateFns(date, dateToCompare);
+}
+
+export function parseHumanInput(value: any): Date | null {
+ // We have no way using the native JS Date to set the parse format or locale, so we ignore these
+ // parameters.
+ if (typeof value === 'number') {
+ return new Date(value);
+ }
+ if (!value) {
+ return null;
+ }
+
+ const stringValue = `${value}`.trim();
+ const dateParserExact = /^(\d+)[\.\/ ](\d+)[\.\/ ](\d+)$/;
+ const dateParserFuzzy = /^(\d{2})(\d{2})(\d{2,4})$/;
+ const match = stringValue.match(dateParserExact) || stringValue.match(dateParserFuzzy);
+ if (match) {
+ const day = +match[1];
+ if (day > 31) {
+ return INVALID_DATE;
+ }
+
+ const month = +match[2] - 1;
+ if (month > 11) {
+ return INVALID_DATE;
+ }
+
+ let year;
+ if (match[3].length <= 2) {
+ year = +match[3];
+
+ const currentYear = parseHumanInput.currentYear;
+ const currentCentury = Math.round(currentYear / 100) * 100;
+ const difference = year - (currentYear - currentCentury);
+ year = year + currentCentury;
+ // Wenn es bei gleichem Jahrhundert 10 Jahre in der Zukunft wäre, nehmen wir das letzte Jahrhundert.
+ if (difference > 10) {
+ year = year - 100;
+ }
+ } else {
+ year = +match[3];
+ }
+ return new Date(year, month, day);
+ }
+
+ return INVALID_DATE;
+}
+parseHumanInput.currentYear = new Date().getFullYear();
+
+// =====================================
+// = Option Wrappers
+// =====================================
+
+export function differenceInCalendarWeeks(
+ dateLeft: Date | number,
+ dateRight: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): number {
+ return differenceInCalendarWeeksDateFns(dateLeft, dateRight, buildOptions(options));
+}
+
+export function eachWeekOfInterval(
+ interval: Interval,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): Date[] {
+ return eachWeekOfIntervalDateFns(interval, buildOptions(options));
+}
+
+export function endOfWeek(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): Date {
+ return endOfWeekDateFns(date, buildOptions(options));
+}
+
+export function format(
+ date: Date | number,
+ // tslint:disable-next-line: no-shadowed-variable
+ format: string,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: number;
+ useAdditionalWeekYearTokens?: boolean;
+ useAdditionalDayOfYearTokens?: boolean;
+ }
+): string {
+ return formatDateFns(date, format, buildOptions(options));
+}
+
+export function formatDistance(
+ date: Date | number,
+ baseDate: Date | number,
+ options?: {
+ includeSeconds?: boolean;
+ addSuffix?: boolean;
+ locale?: Locale;
+ }
+): string {
+ return formatDistanceDateFns(date, baseDate, buildOptions(options));
+}
+
+export function formatDistanceStrict(
+ date: Date | number,
+ baseDate: Date | number,
+ options?: {
+ addSuffix?: boolean;
+ unit?: 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year';
+ roundingMethod?: 'floor' | 'ceil' | 'round';
+ locale?: Locale;
+ }
+): string {
+ return formatDistanceStrictDateFns(date, baseDate, buildOptions(options));
+}
+
+export function formatDistanceToNow(
+ date: Date | number,
+ options?: {
+ includeSeconds?: boolean;
+ addSuffix?: boolean;
+ locale?: Locale;
+ }
+): string {
+ return formatDistanceToNowDateFns(date, buildOptions(options));
+}
+
+export function formatRelative(
+ date: Date | number,
+ baseDate: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): string {
+ return formatRelativeDateFns(date, baseDate, buildOptions(options));
+}
+
+export function getWeek(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ }
+): number {
+ return getWeekDateFns(date, buildOptions(options));
+}
+
+export function getWeekOfMonth(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): number {
+ return getWeekOfMonthDateFns(date, buildOptions(options));
+}
+
+export function getWeeksInMonth(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): number {
+ return getWeeksInMonthDateFns(date, buildOptions(options));
+}
+
+export function getWeekYear(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ }
+): number {
+ return getWeekYearDateFns(date, buildOptions(options));
+}
+
+export function isSameWeek(
+ dateLeft: Date | number,
+ dateRight: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): boolean {
+ return isSameWeekDateFns(dateLeft, dateRight, buildOptions(options));
+}
+
+export function isThisWeek(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): boolean {
+ return isThisWeekDateFns(date, buildOptions(options));
+}
+
+export function lastDayOfWeek(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): Date {
+ return lastDayOfWeekDateFns(date, buildOptions(options));
+}
+
+export function parse(
+ dateString: string,
+ formatString: string,
+ backupDate: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ useAdditionalWeekYearTokens?: boolean;
+ useAdditionalDayOfYearTokens?: boolean;
+ }
+): Date {
+ return parseDateFns(dateString, formatString, backupDate, buildOptions(options));
+}
+
+export function setDay(
+ date: Date | number,
+ day: number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): Date {
+ return setDayDateFns(date, day, buildOptions(options));
+}
+
+export function setWeek(
+ date: Date | number,
+ week: number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ }
+): Date {
+ return setWeekDateFns(date, week, buildOptions(options));
+}
+
+export function setWeekYear(
+ date: Date | number,
+ week: number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ }
+): Date {
+ return setWeekYearDateFns(date, week, buildOptions(options));
+}
+
+export function startOfWeek(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ }
+): Date {
+ return startOfWeekDateFns(date, buildOptions(options));
+}
+
+export function startOfWeekYear(
+ date: Date | number,
+ options?: {
+ locale?: Locale;
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+ firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
+ }
+): Date {
+ return startOfWeekYearDateFns(date, buildOptions(options));
+}
+
+// =====================================
+// = Reexports
+// =====================================
+
+export {
+ Interval,
+ Locale,
+ addBusinessDays,
+ addDays,
+ addHours,
+ addISOWeekYears,
+ addMilliseconds,
+ addMinutes,
+ addMonths,
+ addQuarters,
+ addSeconds,
+ addWeeks,
+ addYears,
+ areIntervalsOverlapping,
+ closestIndexTo,
+ closestTo,
+ compareAsc,
+ compareDesc,
+ differenceInBusinessDays,
+ differenceInCalendarDays,
+ differenceInCalendarISOWeekYears,
+ differenceInCalendarISOWeeks,
+ differenceInCalendarMonths,
+ differenceInCalendarQuarters,
+ // differenceInCalendarWeeks,
+ differenceInCalendarYears,
+ differenceInDays,
+ differenceInHours,
+ differenceInISOWeekYears,
+ differenceInMilliseconds,
+ differenceInMinutes,
+ differenceInMonths,
+ differenceInQuarters,
+ differenceInSeconds,
+ differenceInWeeks,
+ differenceInYears,
+ eachDayOfInterval,
+ // eachWeekOfInterval,
+ eachWeekendOfInterval,
+ eachWeekendOfMonth,
+ eachWeekendOfYear,
+ endOfDay,
+ endOfDecade,
+ endOfHour,
+ endOfISOWeek,
+ endOfISOWeekYear,
+ endOfMinute,
+ endOfMonth,
+ endOfQuarter,
+ endOfSecond,
+ endOfToday,
+ endOfTomorrow,
+ // endOfWeek,
+ endOfYear,
+ endOfYesterday,
+ // format,
+ // formatDistance,
+ // formatDistanceStrict,
+ // formatDistanceToNow,
+ // formatRelative,
+ fromUnixTime,
+ getDate,
+ getDay,
+ getDayOfYear,
+ getDaysInMonth,
+ getDaysInYear,
+ getDecade,
+ getHours,
+ getISODay,
+ getISOWeek,
+ getISOWeekYear,
+ getISOWeeksInYear,
+ getMilliseconds,
+ getMinutes,
+ getMonth,
+ getOverlappingDaysInIntervals,
+ getQuarter,
+ getSeconds,
+ getTime,
+ getUnixTime,
+ // getWeek,
+ // getWeekOfMonth,
+ // getWeekYear,
+ // getWeeksInMonth,
+ getYear,
+ isAfter,
+ isBefore,
+ isDate,
+ isEqual,
+ isFirstDayOfMonth,
+ isFriday,
+ isFuture,
+ isLastDayOfMonth,
+ isLeapYear,
+ isMonday,
+ isPast,
+ isSameDay,
+ isSameHour,
+ isSameISOWeek,
+ isSameISOWeekYear,
+ isSameMinute,
+ isSameMonth,
+ isSameQuarter,
+ isSameSecond,
+ // isSameWeek,
+ isSameYear,
+ isSaturday,
+ isSunday,
+ isThisHour,
+ isThisISOWeek,
+ isThisMinute,
+ isThisMonth,
+ isThisQuarter,
+ isThisSecond,
+ // isThisWeek,
+ isThisYear,
+ isThursday,
+ isToday,
+ isTomorrow,
+ isTuesday,
+ isValid,
+ isWednesday,
+ isWeekend,
+ isWithinInterval,
+ isYesterday,
+ lastDayOfDecade,
+ lastDayOfISOWeek,
+ lastDayOfISOWeekYear,
+ lastDayOfMonth,
+ lastDayOfQuarter,
+ // lastDayOfWeek,
+ lastDayOfYear,
+ lightFormat,
+ max,
+ maxTime,
+ min,
+ minTime,
+ // parse,
+ parseISO,
+ roundToNearestMinutes,
+ setDate,
+ // setDay,
+ setDayOfYear,
+ setHours,
+ setISODay,
+ setISOWeek,
+ setISOWeekYear,
+ setMilliseconds,
+ setMinutes,
+ setMonth,
+ setQuarter,
+ setSeconds,
+ // setWeek,
+ // setWeekYear,
+ setYear,
+ startOfDay,
+ startOfDecade,
+ startOfHour,
+ startOfISOWeek,
+ startOfISOWeekYear,
+ startOfMinute,
+ startOfMonth,
+ startOfQuarter,
+ startOfSecond,
+ startOfToday,
+ startOfTomorrow,
+ // startOfWeek,
+ // startOfWeekYear,
+ startOfYear,
+ startOfYesterday,
+ subDays,
+ subHours,
+ subISOWeekYears,
+ subMilliseconds,
+ subMinutes,
+ subMonths,
+ subQuarters,
+ subSeconds,
+ subWeeks,
+ subYears,
+ toDate,
+} from 'date-fns';
diff --git a/projects/prosoft-components-demo/src/app/app.component.html b/projects/prosoft-components-demo/src/app/app.component.html
index 1629e090..5289394c 100644
--- a/projects/prosoft-components-demo/src/app/app.component.html
+++ b/projects/prosoft-components-demo/src/app/app.component.html
@@ -11,6 +11,7 @@
Select
Number Input
+ DateTime Input
Slider
Flip Container
diff --git a/projects/prosoft-components-demo/src/app/app.module.ts b/projects/prosoft-components-demo/src/app/app.module.ts
index 9ce2cc10..6bce9a8f 100644
--- a/projects/prosoft-components-demo/src/app/app.module.ts
+++ b/projects/prosoft-components-demo/src/app/app.module.ts
@@ -52,6 +52,10 @@ import { AppComponent } from './app.component';
path: 'number-input',
loadChildren: () => import('./number-input-demo/number-input-demo.module').then((m) => m.NumberInputDemoModule),
},
+ {
+ path: 'date-time-input',
+ loadChildren: () => import('./date-time-input-demo/date-time-input-demo.module').then((m) => m.DateTimeInputDemoModule),
+ },
{
path: 'form',
loadChildren: () => import('./form-demo/form-demo.module').then((m) => m.FormDemoModule),
diff --git a/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts
new file mode 100644
index 00000000..90c21f14
--- /dev/null
+++ b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts
@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormControl, FormGroup } from '@angular/forms';
+
+@Component({
+ selector: 'app-date-time-input-demo',
+ template: `
+
+ Selected value: {{ form.getRawValue().myDateTime }}
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DateTimeInputDemoComponent {
+ public form = new FormGroup({
+ myDateTime: new FormControl(null),
+ });
+}
diff --git a/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts
new file mode 100644
index 00000000..6d8ff7ed
--- /dev/null
+++ b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts
@@ -0,0 +1,42 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { ErrorStateMatcher, MatNativeDateModule } from '@angular/material/core';
+import { MatInputModule } from '@angular/material/input';
+import { RouterModule } from '@angular/router';
+import { PsDateTimeInputModule } from '@prosoft/components/date-time-input';
+import { PsFormBaseModule } from '@prosoft/components/form-base';
+import { PsFormFieldModule } from '@prosoft/components/form-field';
+import { DemoPsFormsService } from '../common/demo-ps-form-service';
+import { InvalidErrorStateMatcher } from '../common/invalid-error-state-matcher';
+import { DateTimeInputDemoComponent } from './date-time-input-demo.component';
+
+@NgModule({
+ imports: [
+ RouterModule.forChild([
+ {
+ path: '',
+ component: DateTimeInputDemoComponent,
+ },
+ ]),
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+
+ PsDateTimeInputModule,
+
+ PsFormBaseModule.forRoot(DemoPsFormsService),
+ PsFormFieldModule,
+ MatCardModule,
+ MatButtonModule,
+ MatCheckboxModule,
+ MatInputModule,
+ MatNativeDateModule,
+ ],
+ declarations: [DateTimeInputDemoComponent],
+ providers: [{ provide: ErrorStateMatcher, useClass: InvalidErrorStateMatcher }],
+})
+export class DateTimeInputDemoModule {}