diff --git a/.github/workflows/ci_test_core.yml b/.github/workflows/ci_test_core.yml index 121d5241..b400d3f6 100644 --- a/.github/workflows/ci_test_core.yml +++ b/.github/workflows/ci_test_core.yml @@ -43,3 +43,7 @@ jobs: ci_test_status: needs: ci_test_core uses: ./.github/workflows/ci_test_status.yml + + ci_test_time-picker: + needs: ci_test_core + uses: ./.github/workflows/ci_test_time-picker.yml diff --git a/.github/workflows/ci_test_numeric-stepper.yml b/.github/workflows/ci_test_numeric-stepper.yml index 9fb67aa1..1ebc5a38 100644 --- a/.github/workflows/ci_test_numeric-stepper.yml +++ b/.github/workflows/ci_test_numeric-stepper.yml @@ -24,3 +24,7 @@ jobs: working-directory: projects/numeric-stepper runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' node-versions: '[18, 20]' + + ci_test_time-picker: + needs: ci_test_numeric-stepper + uses: ./.github/workflows/ci_test_time-picker.yml diff --git a/.github/workflows/ci_test_time-picker.yml b/.github/workflows/ci_test_time-picker.yml new file mode 100644 index 00000000..4ec49e03 --- /dev/null +++ b/.github/workflows/ci_test_time-picker.yml @@ -0,0 +1,26 @@ +name: Test time-picker + +on: + workflow_dispatch: + workflow_call: + push: + branches: + - '**' + tags-ignore: + - '**' + paths: + - '.github/workflows/ci_test_time-picker.yml' + - 'projects/time-picker/**' + +concurrency: + group: ci-test-time-picker-group-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci_test_time-picker: + if: "${{ !contains(github.event.head_commit.message, 'chore(release): publish') }}" + uses: dsi-hug/action/.github/workflows/action.yml@v1 + with: + working-directory: projects/time-picker + runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' + node-versions: '[18, 20]' diff --git a/projects/time-picker/ng-package.json b/projects/time-picker/ng-package.json index ebb748bf..d51a14aa 100644 --- a/projects/time-picker/ng-package.json +++ b/projects/time-picker/ng-package.json @@ -1,7 +1,10 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", "dest": "../../dist/time-picker", + "assets": [ + "CHANGELOG.md" + ], "lib": { "entryFile": "src/index.ts" } -} \ No newline at end of file +} diff --git a/projects/time-picker/package.json b/projects/time-picker/package.json index 0db175f3..44645c84 100644 --- a/projects/time-picker/package.json +++ b/projects/time-picker/package.json @@ -11,7 +11,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/dsi-hug/ngx-components.git" + "url": "git+https://github.com/dsi-hug/ngx-components.git" }, "keywords": [ "angular", @@ -24,11 +24,21 @@ "lint": "eslint . --fix", "test": "ng test time-picker", "test:ci": "ng test time-picker --watch=false --browsers=ChromeHeadless", - "build": "ng build time-picker -c production" + "build:ng": "ng build time-picker -c=production", + "build": "nx build:ng @hug/ngx-time-picker --verbose", + "release": "nx release -p=@hug/ngx-time-picker --yes --verbose", + "release:dry-run": "nx release -p=@hug/ngx-time-picker --verbose --dry-run" }, "peerDependencies": { - "@angular/common": "^14.3.0", - "@angular/core": "^14.3.0" + "@angular/common": ">= 14", + "@angular/core": ">= 14", + "@angular/cdk": ">= 14", + "@angular/forms": ">= 14", + "@angular/material": ">= 14", + "rxjs": ">= 7.0.0", + "date-fns": "^2.30.0", + "@hug/ngx-core": "1.1.4", + "@hug/ngx-numeric-stepper": "1.1.1" }, "dependencies": { "tslib": "^2.6.3" diff --git a/projects/time-picker/src/example/example.component.spec.ts b/projects/time-picker/src/example/example.component.spec.ts deleted file mode 100644 index 61022488..00000000 --- a/projects/time-picker/src/example/example.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ExampleComponent } from './example.component'; - -describe('ExampleComponent', () => { - let component: ExampleComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ExampleComponent] - }).compileComponents(); - - fixture = TestBed.createComponent(ExampleComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/time-picker/src/example/example.component.ts b/projects/time-picker/src/example/example.component.ts deleted file mode 100644 index d6dc2085..00000000 --- a/projects/time-picker/src/example/example.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -@Component({ - selector: 'deja-example', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], - template: '

example works!

', - styles: [] -}) -export class ExampleComponent { } diff --git a/projects/time-picker/src/example/example.service.spec.ts b/projects/time-picker/src/example/example.service.spec.ts deleted file mode 100644 index 33a65090..00000000 --- a/projects/time-picker/src/example/example.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { ExampleService } from './example.service'; - -describe('ExampleService', () => { - let service: ExampleService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(ExampleService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/time-picker/src/example/example.service.ts b/projects/time-picker/src/example/example.service.ts deleted file mode 100644 index 278d5bcf..00000000 --- a/projects/time-picker/src/example/example.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class ExampleService { } diff --git a/projects/time-picker/src/index.ts b/projects/time-picker/src/index.ts index 020b9f72..c9aa6cfd 100644 --- a/projects/time-picker/src/index.ts +++ b/projects/time-picker/src/index.ts @@ -1,6 +1 @@ -/* - * Public API Surface of lib - */ - -export * from './example/example.service'; -export * from './example/example.component'; +export * from './time-picker.component'; diff --git a/projects/time-picker/src/time-picker.component.html b/projects/time-picker/src/time-picker.component.html new file mode 100644 index 00000000..e7d60b11 --- /dev/null +++ b/projects/time-picker/src/time-picker.component.html @@ -0,0 +1,14 @@ + + + + + + + +: + + + + + + diff --git a/projects/time-picker/src/time-picker.component.scss b/projects/time-picker/src/time-picker.component.scss new file mode 100644 index 00000000..7d5ffbf9 --- /dev/null +++ b/projects/time-picker/src/time-picker.component.scss @@ -0,0 +1,27 @@ +time-picker { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + + &:not([inform]) .mat-form-field-appearance-outline { + .mat-form-field-wrapper { + padding-bottom: 0; + + .mat-form-field-infix { + border-bottom: 0.7em solid transparent; + } + } + } + + mat-form-field[numeric-stepper-form-field] { + width: 48px; + } + + .time-separator { + width: 4px; + font-size: 14.5px; + line-height: 17px; + margin: 0 8px; + } +} diff --git a/projects/time-picker/src/time-picker.component.ts b/projects/time-picker/src/time-picker.component.ts new file mode 100644 index 00000000..e82d14c7 --- /dev/null +++ b/projects/time-picker/src/time-picker.component.ts @@ -0,0 +1,317 @@ +import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, Optional, Output, Self, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; +import { MatFormFieldAppearance, MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { Destroy } from '@hug/ngx-core'; +import { NumericStepperComponent } from '@hug/ngx-numeric-stepper'; +import { isSameHour, set } from 'date-fns'; +import { debounce, distinctUntilChanged, map, Subject, takeUntil, timer } from 'rxjs'; + +export type TimePickerDisplayMode = 'fullTime' | 'fullTimeWithHoursDisabled' | 'fullTimeWithMinutesDisabled' | 'hoursOnly' | 'minutesOnly'; + +export type DateOrDuration = Date | Duration; + +type DataType = 'date' | 'duration'; + +type FieldType = 'hours' | 'minutes'; + +// TODO sdil refactor rxjs flows +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'time-picker', + styleUrls: ['./time-picker.component.scss'], + templateUrl: './time-picker.component.html', + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + NumericStepperComponent + ] +}) +export class TimePickerComponent extends Destroy implements ControlValueAccessor { + @ViewChild('hours') public hours?: ElementRef; + @ViewChild('minutes') public minutes?: ElementRef; + + @Output() public readonly timeChange = new EventEmitter(); + + /** Display mode for the time-picker */ + @Input() public mode: TimePickerDisplayMode = 'fullTime'; + + /** Data type to manage (Date or Duration) */ + @Input() public dataType: DataType = 'date'; + + /** + * Force the hour or minute to be null (only if the other field is disabled) + * + * For instance, if set to true and the hours are disabled, the minutes input value will be null + * This is useful to force the user to provide a value + */ + @Input() public forceNullValue = false; + + @Input() public appearance: MatFormFieldAppearance = 'outline'; + + @Input() + public set autoFocus(value: BooleanInput) { + this._autoFocus = coerceBooleanProperty(value); + } + + @Input() public defaultPlaceholderHours = '_ _'; + @Input() public defaultPlaceholderMinutes = '_ _'; + + @Input() + public set time(value: DateOrDuration | undefined) { + this.writeValue(value); + } + + public get time(): DateOrDuration | undefined { + return this.value; + } + + /** Step of the arrows */ + @Input() + public set step(value: NumberInput) { + this._step = coerceNumberProperty(value); + this.changeDetectorRef.markForCheck(); + } + + /** To get the step of the minutes arrows */ + public get step(): NumberInput { + return this._step; + } + + /** Disabled property setter. Can be string or empty so you can use it like : */ + @Input() + public set disabled(value: BooleanInput) { + this._disabled = coerceBooleanProperty(value); + this.changeDetectorRef.markForCheck(); + } + + /** To get disabled attribute. */ + public get disabled(): BooleanInput { + return this._disabled; + } + + public onHoursChange$ = new Subject(); + public onMinutesChange$ = new Subject(); + public _step = 1; + private _disabled = false; + private _value?: DateOrDuration; + private _autoFocus = true; + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + @Self() @Optional() public control: NgControl + ) { + super(); + + if (this.control) { + this.control.valueAccessor = this; + } + + this.onHoursChange$.pipe( + debounce(hours => timer(typeof hours === 'object' ? 0 : 10)), + distinctUntilChanged(), + map(hours => { + if (typeof hours === 'object') { + const value = (hours.target as HTMLInputElement).value; + return [value !== undefined ? parseInt(value, 10) : undefined, true] as const; + } + return [!isNaN(hours) ? hours : 0, false] as const; + }), + takeUntil(this.destroyed$) + ).subscribe(([hours, isEvent]) => { + if (!this.value) { + this.value = this.dataType === 'date' ? set(new Date(), { hours, minutes: 0, seconds: 0, milliseconds: 0 }) : { hours, minutes: 0 } as Duration; + } else if (this.value instanceof Date) { + const value = this.value?.getTime(); + const clone = new Date(value); + if (hours !== undefined) { + clone.setHours(hours); + } + this.value = clone; + } else { + this.value = { + hours: hours && hours < 0 ? 0 : hours, + minutes: this.value.minutes + }; + } + this.changeDetectorRef.markForCheck(); + + if (isEvent && this._autoFocus && this.minutes) { + this.minutes.nativeElement.focus({ + preventScroll: true + }); + this.minutes.nativeElement.select(); + } + }); + + this.onMinutesChange$.pipe( + debounce(minutes => timer(typeof minutes === 'object' ? 0 : 10)), + distinctUntilChanged(), + map(event => { + let minutes: number | undefined; + if (typeof event === 'object') { + const value = (event.target as HTMLInputElement).value; + minutes = value !== undefined ? parseInt(value, 10) : undefined; + } else { + minutes = event; + } + return minutes && !isNaN(minutes) && minutes || 0; + }), + takeUntil(this.destroyed$) + ).subscribe(minutes => { + if (!this.value) { + this.value = this.dataType === 'date' ? set(new Date(), { hours: 0, minutes, seconds: 0, milliseconds: 0 }) : { hours: 0, minutes } as Duration; + } else if (this.value instanceof Date) { + const newValue = new Date(this.value.getTime()); + if (minutes < 0) { + minutes += 60; + } else if (minutes >= 60) { + minutes -= 60; + } + newValue.setMinutes(minutes); + + if (this.mode !== 'fullTimeWithHoursDisabled' || (this.mode === 'fullTimeWithHoursDisabled' && isSameHour(this.value, newValue))) { + this.value = newValue; + } + } else { + this.value = { + hours: this.value.hours, + minutes: minutes < 0 || minutes >= 60 ? 0 : minutes + }; + } + this.changeDetectorRef.markForCheck(); + }); + } + + public onKeyDown($event: KeyboardEvent, mode: 'hours' | 'minutes'): void { + // Get input element + const inputElement = mode === 'hours' ? this.hours?.nativeElement : this.minutes?.nativeElement; + if ($event.key?.toLowerCase() === 'd') { + $event.stopPropagation(); + $event.preventDefault(); + this.value = new Date(); + } else if (inputElement) { + if ($event.key?.toLowerCase() === 'a' && $event.ctrlKey) { + inputElement.select(); + } else if ($event.key && !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Enter', 'Control', 'Shift'].includes($event.key)) { + // Set regex for input format validation (differs if we are dealing with a date or a duration) + let regex; + if (mode === 'hours') { + regex = this.dataType === 'date' ? /^(\d|[01]\d|2[0-3])$/ : /^(\d+)$/; + } else { + regex = /^(\d|[0-5]\d)$/; + } + + // Get the selection in input element + const [selectionStart, selectionEnd] = [inputElement.selectionStart || 0, inputElement.selectionEnd || 0].sort((a, b) => a - b); + const selectionDiff = selectionEnd - selectionStart; + + // Get the current value in input element and update it with the new touched key + const inputValue = inputElement.value || ''; + const inputValueArr = Array.from(inputValue); + inputValueArr.splice(selectionStart, selectionDiff, $event.key); + const newInputValue = inputValueArr.join(''); + + // Prevent event if the time is not valid + if (!regex.test(newInputValue)) { + $event.stopPropagation(); + $event.preventDefault(); + } else if (this._autoFocus && mode === 'hours' && ((this.dataType === 'date' && parseFloat(newInputValue) >= 3) || newInputValue.length === 2)) { + this.onHoursChange$.next($event); + } + } + } + } + + public get hoursValue(): number | undefined { + if (!this.value || (this.forceNullValue && this.mode === 'fullTimeWithMinutesDisabled' && this.control.pristine)) { + return undefined; + } + return this.value instanceof Date ? this.value.getHours() : this.value.hours; + } + + public get minutesValue(): number | undefined { + if (!this.value || (this.forceNullValue && this.mode === 'fullTimeWithHoursDisabled' && this.control.pristine)) { + return undefined; + } + return this.value instanceof Date ? this.value.getMinutes() : this.value.minutes; + } + + public incrementValue(fieldType: FieldType): void { + if (fieldType === 'hours') { + this.onHoursChange$.next((this.hoursValue || 0) + 1); + } else { + this.onMinutesChange$.next((this.minutesValue || 0) + this._step); + } + } + + public decrementValue(fieldType: FieldType): void { + if (fieldType === 'hours') { + this.onHoursChange$.next((this.hoursValue || 0) - 1); + } else { + this.onMinutesChange$.next((this.minutesValue || 0) - this._step); + } + } + + public onClick(mode: 'hours' | 'minutes'): void { + if (this._autoFocus) { + if (mode === 'hours') { + this.hours?.nativeElement.select(); + } else { + this.minutes?.nativeElement.select(); + } + } + } + + // ************* ControlValueAccessor Implementation ************** + /** set accessor including call the onchange callback */ + public set value(v: DateOrDuration | undefined) { + if (v !== this._value) { + this.writeValue(v); + this.onChangeCallback(v); + this.timeChange.emit(v); + } + } + + /** get accessor */ + public get value(): DateOrDuration | undefined { + return this._value; + } + + /** From ControlValueAccessor interface */ + public writeValue(value: DateOrDuration | undefined): void { + if ((value ?? null) !== (this._value ?? null)) { + if (value instanceof Date) { + this._value = value ? new Date(value.getTime()) : set(new Date(), { hours: 0, minutes: 0, seconds: 0 }); + } else { + this._value = value; + } + + this.changeDetectorRef.markForCheck(); + } + } + + /** From ControlValueAccessor interface */ + public registerOnChange(fn: (_a: unknown) => void): void { + this.onChangeCallback = fn; + } + + /** From ControlValueAccessor interface */ + public registerOnTouched(fn: () => void): void { + this.onTouchedCallback = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + // ************* End of ControlValueAccessor Implementation ************** + + protected onChangeCallback = (_a: unknown): void => undefined; + protected onTouchedCallback = (): void => undefined; +} diff --git a/projects/time-picker/tsconfig.lib.json b/projects/time-picker/tsconfig.lib.json index 8f6c5298..599bb9f4 100644 --- a/projects/time-picker/tsconfig.lib.json +++ b/projects/time-picker/tsconfig.lib.json @@ -1,6 +1,6 @@ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "../../out-tsc/lib", "declaration": true,