@hug/ngx-time-picker 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ ## 1.1.0 (2024-06-23)
2
+
3
+
4
+ ### 🚀 Features
5
+
6
+ - **time-picker:** first commit ([721f5e8](https://github.com/DSI-HUG/ngx-components/commit/721f5e8))
7
+
8
+
9
+ ### ❤️ Thank You
10
+
11
+ - Badisi
package/README.md ADDED
@@ -0,0 +1,6 @@
1
+ @hug/ngx-time-picker
2
+ =======
3
+
4
+ The sources for this package are in the main [DSI-HUG/ngx-components](https://github.com/dsi-hug/ngx-components) repo. Please file issues and pull requests against that repo.
5
+
6
+ License: GPL-3.0-only
package/karma.conf.js ADDED
@@ -0,0 +1,45 @@
1
+ // Karma configuration file, see link for more information
2
+ // https://karma-runner.github.io/1.0/config/configuration-file.html
3
+
4
+ module.exports = config => {
5
+ config.set({
6
+ basePath: '',
7
+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
8
+ plugins: [
9
+ require('karma-jasmine'),
10
+ require('karma-chrome-launcher'),
11
+ require('karma-jasmine-html-reporter'),
12
+ require('karma-coverage'),
13
+ require('@angular-devkit/build-angular/plugins/karma')
14
+ ],
15
+ client: {
16
+ jasmine: {
17
+ // you can add configuration options for Jasmine here
18
+ // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
19
+ // for example, you can disable the random execution with `random: false`
20
+ // or set a specific seed with `seed: 4321`
21
+ },
22
+ clearContext: false // leave Jasmine Spec Runner output visible in browser
23
+ },
24
+ jasmineHtmlReporter: {
25
+ suppressAll: true // removes the duplicated traces
26
+ },
27
+ coverageReporter: {
28
+ dir: require('path').join(__dirname, '../../coverage/time-picker'),
29
+ subdir: '.',
30
+ reporters: [
31
+ { type: 'html' },
32
+ { type: 'text-summary' }
33
+ ]
34
+ },
35
+ reporters: ['progress', 'kjhtml'],
36
+ port: 9876,
37
+ colors: true,
38
+ logLevel: config.LOG_INFO,
39
+ autoWatch: true,
40
+ browsers: ['Chrome'],
41
+ singleRun: false,
42
+ failOnEmptyTestSuite: false,
43
+ restartOnFileChange: true
44
+ });
45
+ };
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/time-picker",
4
+ "assets": [
5
+ "CHANGELOG.md"
6
+ ],
7
+ "lib": {
8
+ "entryFile": "src/index.ts"
9
+ }
10
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@hug/ngx-time-picker",
3
+ "version": "1.1.0",
4
+ "description": "HUG Angular - time picker component",
5
+ "homepage": "https://github.com/dsi-hug/ngx-components",
6
+ "license": "GPL-3.0-only",
7
+ "author": "HUG - Hôpitaux Universitaires Genève",
8
+ "contributors": [
9
+ "badisi (https://github.com/badisi)",
10
+ "vapkse (https://github.com/vapkse)"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/dsi-hug/ngx-components.git"
15
+ },
16
+ "keywords": [
17
+ "angular",
18
+ "material",
19
+ "material design",
20
+ "components"
21
+ ],
22
+ "sideEffects": false,
23
+ "scripts": {
24
+ "lint": "eslint . --fix",
25
+ "test": "ng test time-picker",
26
+ "test:ci": "ng test time-picker --watch=false --browsers=ChromeHeadless",
27
+ "build:ng": "ng build time-picker -c=production",
28
+ "build": "nx build:ng @hug/ngx-time-picker --verbose",
29
+ "release": "nx release -p=@hug/ngx-time-picker --yes --verbose",
30
+ "release:dry-run": "nx release -p=@hug/ngx-time-picker --verbose --dry-run"
31
+ },
32
+ "peerDependencies": {
33
+ "@angular/common": ">= 14",
34
+ "@angular/core": ">= 14",
35
+ "@angular/cdk": ">= 14",
36
+ "@angular/forms": ">= 14",
37
+ "@angular/material": ">= 14",
38
+ "rxjs": ">= 7.0.0",
39
+ "date-fns": "^2.30.0",
40
+ "@hug/ngx-core": "1.1.4",
41
+ "@hug/ngx-numeric-stepper": "1.1.1"
42
+ },
43
+ "dependencies": {
44
+ "tslib": "^2.6.3"
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './time-picker.component';
package/src/test.ts ADDED
@@ -0,0 +1,28 @@
1
+ // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2
+
3
+ import 'zone.js';
4
+ import 'zone.js/testing';
5
+
6
+ import { getTestBed } from '@angular/core/testing';
7
+ import {
8
+ BrowserDynamicTestingModule,
9
+ platformBrowserDynamicTesting
10
+ } from '@angular/platform-browser-dynamic/testing';
11
+
12
+ declare const require: {
13
+ context: (path: string, deep?: boolean, filter?: RegExp) => {
14
+ <T>(id: string): T;
15
+ keys: () => string[];
16
+ };
17
+ };
18
+
19
+ // First, initialize the Angular testing environment.
20
+ getTestBed().initTestEnvironment(
21
+ BrowserDynamicTestingModule,
22
+ platformBrowserDynamicTesting()
23
+ );
24
+
25
+ // Then we find all the tests.
26
+ const context = require.context('./', true, /\.spec\.ts$/);
27
+ // And load the modules.
28
+ context.keys().forEach(context);
@@ -0,0 +1,14 @@
1
+ <!-- Hours -->
2
+ <mat-form-field [appearance]="appearance" class="hours" [class.disabled]="disabled || mode === 'fullTimeWithHoursDisabled'" [style.display]="mode === 'minutesOnly' ? 'none' : 'block'" floatLabel="never">
3
+ <input #hours matInput type="text" [ngModel]="hoursValue | number:'2.'" (change)="onHoursChange$.next($event)" (keydown)="onKeyDown($event, 'hours')" [disabled]="disabled || mode === 'fullTimeWithHoursDisabled'" [maxLength]="2" autocomplete="off" [placeholder]="defaultPlaceholderHours" (click)="onClick('hours')" />
4
+ <numeric-stepper (increment)="incrementValue('hours')" (decrement)="decrementValue('hours')"></numeric-stepper>
5
+ </mat-form-field>
6
+
7
+ <!-- Separator -->
8
+ <span class="time-separator" [style.display]="mode === 'minutesOnly' || mode === 'hoursOnly' ? 'none' : 'block'">:</span>
9
+
10
+ <!-- Minutes -->
11
+ <mat-form-field [appearance]="appearance" class="minutes" [class.disabled]="disabled || mode === 'fullTimeWithMinutesDisabled'" [style.display]="mode === 'hoursOnly' ? 'none' : 'block'" floatLabel="never">
12
+ <input #minutes matInput type="text" [ngModel]="minutesValue | number:'2.'" (change)="onMinutesChange$.next($event)" (keydown)="onKeyDown($event, 'minutes')" [disabled]="disabled || mode === 'fullTimeWithMinutesDisabled'" [maxLength]="2" autocomplete="off" [placeholder]="defaultPlaceholderMinutes" (click)="onClick('minutes')" />
13
+ <numeric-stepper (increment)="incrementValue('minutes')" (decrement)="decrementValue('minutes')"></numeric-stepper>
14
+ </mat-form-field>
@@ -0,0 +1,27 @@
1
+ time-picker {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: baseline;
5
+ justify-content: center;
6
+
7
+ &:not([inform]) .mat-form-field-appearance-outline {
8
+ .mat-form-field-wrapper {
9
+ padding-bottom: 0;
10
+
11
+ .mat-form-field-infix {
12
+ border-bottom: 0.7em solid transparent;
13
+ }
14
+ }
15
+ }
16
+
17
+ mat-form-field[numeric-stepper-form-field] {
18
+ width: 48px;
19
+ }
20
+
21
+ .time-separator {
22
+ width: 4px;
23
+ font-size: 14.5px;
24
+ line-height: 17px;
25
+ margin: 0 8px;
26
+ }
27
+ }
@@ -0,0 +1,317 @@
1
+ import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
2
+ import { CommonModule } from '@angular/common';
3
+ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, Optional, Output, Self, ViewChild, ViewEncapsulation } from '@angular/core';
4
+ import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms';
5
+ import { MatFormFieldAppearance, MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatInputModule } from '@angular/material/input';
7
+ import { Destroy } from '@hug/ngx-core';
8
+ import { NumericStepperComponent } from '@hug/ngx-numeric-stepper';
9
+ import { isSameHour, set } from 'date-fns';
10
+ import { debounce, distinctUntilChanged, map, Subject, takeUntil, timer } from 'rxjs';
11
+
12
+ export type TimePickerDisplayMode = 'fullTime' | 'fullTimeWithHoursDisabled' | 'fullTimeWithMinutesDisabled' | 'hoursOnly' | 'minutesOnly';
13
+
14
+ export type DateOrDuration = Date | Duration;
15
+
16
+ type DataType = 'date' | 'duration';
17
+
18
+ type FieldType = 'hours' | 'minutes';
19
+
20
+ // TODO sdil refactor rxjs flows
21
+ @Component({
22
+ changeDetection: ChangeDetectionStrategy.OnPush,
23
+ selector: 'time-picker',
24
+ styleUrls: ['./time-picker.component.scss'],
25
+ templateUrl: './time-picker.component.html',
26
+ encapsulation: ViewEncapsulation.None,
27
+ standalone: true,
28
+ imports: [
29
+ CommonModule,
30
+ FormsModule,
31
+ MatFormFieldModule,
32
+ MatInputModule,
33
+ NumericStepperComponent
34
+ ]
35
+ })
36
+ export class TimePickerComponent extends Destroy implements ControlValueAccessor {
37
+ @ViewChild('hours') public hours?: ElementRef<HTMLInputElement>;
38
+ @ViewChild('minutes') public minutes?: ElementRef<HTMLInputElement>;
39
+
40
+ @Output() public readonly timeChange = new EventEmitter<DateOrDuration>();
41
+
42
+ /** Display mode for the time-picker */
43
+ @Input() public mode: TimePickerDisplayMode = 'fullTime';
44
+
45
+ /** Data type to manage (Date or Duration) */
46
+ @Input() public dataType: DataType = 'date';
47
+
48
+ /**
49
+ * Force the hour or minute to be null (only if the other field is disabled)
50
+ *
51
+ * For instance, if set to true and the hours are disabled, the minutes input value will be null
52
+ * This is useful to force the user to provide a value
53
+ */
54
+ @Input() public forceNullValue = false;
55
+
56
+ @Input() public appearance: MatFormFieldAppearance = 'outline';
57
+
58
+ @Input()
59
+ public set autoFocus(value: BooleanInput) {
60
+ this._autoFocus = coerceBooleanProperty(value);
61
+ }
62
+
63
+ @Input() public defaultPlaceholderHours = '_ _';
64
+ @Input() public defaultPlaceholderMinutes = '_ _';
65
+
66
+ @Input()
67
+ public set time(value: DateOrDuration | undefined) {
68
+ this.writeValue(value);
69
+ }
70
+
71
+ public get time(): DateOrDuration | undefined {
72
+ return this.value;
73
+ }
74
+
75
+ /** Step of the arrows */
76
+ @Input()
77
+ public set step(value: NumberInput) {
78
+ this._step = coerceNumberProperty(value);
79
+ this.changeDetectorRef.markForCheck();
80
+ }
81
+
82
+ /** To get the step of the minutes arrows */
83
+ public get step(): NumberInput {
84
+ return this._step;
85
+ }
86
+
87
+ /** Disabled property setter. Can be string or empty so you can use it like : <time-picker disabled></time-picker> */
88
+ @Input()
89
+ public set disabled(value: BooleanInput) {
90
+ this._disabled = coerceBooleanProperty(value);
91
+ this.changeDetectorRef.markForCheck();
92
+ }
93
+
94
+ /** To get disabled attribute. */
95
+ public get disabled(): BooleanInput {
96
+ return this._disabled;
97
+ }
98
+
99
+ public onHoursChange$ = new Subject<Event | number>();
100
+ public onMinutesChange$ = new Subject<Event | number>();
101
+ public _step = 1;
102
+ private _disabled = false;
103
+ private _value?: DateOrDuration;
104
+ private _autoFocus = true;
105
+
106
+ public constructor(
107
+ private changeDetectorRef: ChangeDetectorRef,
108
+ @Self() @Optional() public control: NgControl
109
+ ) {
110
+ super();
111
+
112
+ if (this.control) {
113
+ this.control.valueAccessor = this;
114
+ }
115
+
116
+ this.onHoursChange$.pipe(
117
+ debounce(hours => timer(typeof hours === 'object' ? 0 : 10)),
118
+ distinctUntilChanged(),
119
+ map(hours => {
120
+ if (typeof hours === 'object') {
121
+ const value = (hours.target as HTMLInputElement).value;
122
+ return [value !== undefined ? parseInt(value, 10) : undefined, true] as const;
123
+ }
124
+ return [!isNaN(hours) ? hours : 0, false] as const;
125
+ }),
126
+ takeUntil(this.destroyed$)
127
+ ).subscribe(([hours, isEvent]) => {
128
+ if (!this.value) {
129
+ this.value = this.dataType === 'date' ? set(new Date(), { hours, minutes: 0, seconds: 0, milliseconds: 0 }) : { hours, minutes: 0 } as Duration;
130
+ } else if (this.value instanceof Date) {
131
+ const value = this.value?.getTime();
132
+ const clone = new Date(value);
133
+ if (hours !== undefined) {
134
+ clone.setHours(hours);
135
+ }
136
+ this.value = clone;
137
+ } else {
138
+ this.value = {
139
+ hours: hours && hours < 0 ? 0 : hours,
140
+ minutes: this.value.minutes
141
+ };
142
+ }
143
+ this.changeDetectorRef.markForCheck();
144
+
145
+ if (isEvent && this._autoFocus && this.minutes) {
146
+ this.minutes.nativeElement.focus({
147
+ preventScroll: true
148
+ });
149
+ this.minutes.nativeElement.select();
150
+ }
151
+ });
152
+
153
+ this.onMinutesChange$.pipe(
154
+ debounce(minutes => timer(typeof minutes === 'object' ? 0 : 10)),
155
+ distinctUntilChanged(),
156
+ map(event => {
157
+ let minutes: number | undefined;
158
+ if (typeof event === 'object') {
159
+ const value = (event.target as HTMLInputElement).value;
160
+ minutes = value !== undefined ? parseInt(value, 10) : undefined;
161
+ } else {
162
+ minutes = event;
163
+ }
164
+ return minutes && !isNaN(minutes) && minutes || 0;
165
+ }),
166
+ takeUntil(this.destroyed$)
167
+ ).subscribe(minutes => {
168
+ if (!this.value) {
169
+ this.value = this.dataType === 'date' ? set(new Date(), { hours: 0, minutes, seconds: 0, milliseconds: 0 }) : { hours: 0, minutes } as Duration;
170
+ } else if (this.value instanceof Date) {
171
+ const newValue = new Date(this.value.getTime());
172
+ if (minutes < 0) {
173
+ minutes += 60;
174
+ } else if (minutes >= 60) {
175
+ minutes -= 60;
176
+ }
177
+ newValue.setMinutes(minutes);
178
+
179
+ if (this.mode !== 'fullTimeWithHoursDisabled' || (this.mode === 'fullTimeWithHoursDisabled' && isSameHour(this.value, newValue))) {
180
+ this.value = newValue;
181
+ }
182
+ } else {
183
+ this.value = {
184
+ hours: this.value.hours,
185
+ minutes: minutes < 0 || minutes >= 60 ? 0 : minutes
186
+ };
187
+ }
188
+ this.changeDetectorRef.markForCheck();
189
+ });
190
+ }
191
+
192
+ public onKeyDown($event: KeyboardEvent, mode: 'hours' | 'minutes'): void {
193
+ // Get input element
194
+ const inputElement = mode === 'hours' ? this.hours?.nativeElement : this.minutes?.nativeElement;
195
+ if ($event.key?.toLowerCase() === 'd') {
196
+ $event.stopPropagation();
197
+ $event.preventDefault();
198
+ this.value = new Date();
199
+ } else if (inputElement) {
200
+ if ($event.key?.toLowerCase() === 'a' && $event.ctrlKey) {
201
+ inputElement.select();
202
+ } else if ($event.key && !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Enter', 'Control', 'Shift'].includes($event.key)) {
203
+ // Set regex for input format validation (differs if we are dealing with a date or a duration)
204
+ let regex;
205
+ if (mode === 'hours') {
206
+ regex = this.dataType === 'date' ? /^(\d|[01]\d|2[0-3])$/ : /^(\d+)$/;
207
+ } else {
208
+ regex = /^(\d|[0-5]\d)$/;
209
+ }
210
+
211
+ // Get the selection in input element
212
+ const [selectionStart, selectionEnd] = [inputElement.selectionStart || 0, inputElement.selectionEnd || 0].sort((a, b) => a - b);
213
+ const selectionDiff = selectionEnd - selectionStart;
214
+
215
+ // Get the current value in input element and update it with the new touched key
216
+ const inputValue = inputElement.value || '';
217
+ const inputValueArr = Array.from(inputValue);
218
+ inputValueArr.splice(selectionStart, selectionDiff, $event.key);
219
+ const newInputValue = inputValueArr.join('');
220
+
221
+ // Prevent event if the time is not valid
222
+ if (!regex.test(newInputValue)) {
223
+ $event.stopPropagation();
224
+ $event.preventDefault();
225
+ } else if (this._autoFocus && mode === 'hours' && ((this.dataType === 'date' && parseFloat(newInputValue) >= 3) || newInputValue.length === 2)) {
226
+ this.onHoursChange$.next($event);
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ public get hoursValue(): number | undefined {
233
+ if (!this.value || (this.forceNullValue && this.mode === 'fullTimeWithMinutesDisabled' && this.control.pristine)) {
234
+ return undefined;
235
+ }
236
+ return this.value instanceof Date ? this.value.getHours() : this.value.hours;
237
+ }
238
+
239
+ public get minutesValue(): number | undefined {
240
+ if (!this.value || (this.forceNullValue && this.mode === 'fullTimeWithHoursDisabled' && this.control.pristine)) {
241
+ return undefined;
242
+ }
243
+ return this.value instanceof Date ? this.value.getMinutes() : this.value.minutes;
244
+ }
245
+
246
+ public incrementValue(fieldType: FieldType): void {
247
+ if (fieldType === 'hours') {
248
+ this.onHoursChange$.next((this.hoursValue || 0) + 1);
249
+ } else {
250
+ this.onMinutesChange$.next((this.minutesValue || 0) + this._step);
251
+ }
252
+ }
253
+
254
+ public decrementValue(fieldType: FieldType): void {
255
+ if (fieldType === 'hours') {
256
+ this.onHoursChange$.next((this.hoursValue || 0) - 1);
257
+ } else {
258
+ this.onMinutesChange$.next((this.minutesValue || 0) - this._step);
259
+ }
260
+ }
261
+
262
+ public onClick(mode: 'hours' | 'minutes'): void {
263
+ if (this._autoFocus) {
264
+ if (mode === 'hours') {
265
+ this.hours?.nativeElement.select();
266
+ } else {
267
+ this.minutes?.nativeElement.select();
268
+ }
269
+ }
270
+ }
271
+
272
+ // ************* ControlValueAccessor Implementation **************
273
+ /** set accessor including call the onchange callback */
274
+ public set value(v: DateOrDuration | undefined) {
275
+ if (v !== this._value) {
276
+ this.writeValue(v);
277
+ this.onChangeCallback(v);
278
+ this.timeChange.emit(v);
279
+ }
280
+ }
281
+
282
+ /** get accessor */
283
+ public get value(): DateOrDuration | undefined {
284
+ return this._value;
285
+ }
286
+
287
+ /** From ControlValueAccessor interface */
288
+ public writeValue(value: DateOrDuration | undefined): void {
289
+ if ((value ?? null) !== (this._value ?? null)) {
290
+ if (value instanceof Date) {
291
+ this._value = value ? new Date(value.getTime()) : set(new Date(), { hours: 0, minutes: 0, seconds: 0 });
292
+ } else {
293
+ this._value = value;
294
+ }
295
+
296
+ this.changeDetectorRef.markForCheck();
297
+ }
298
+ }
299
+
300
+ /** From ControlValueAccessor interface */
301
+ public registerOnChange(fn: (_a: unknown) => void): void {
302
+ this.onChangeCallback = fn;
303
+ }
304
+
305
+ /** From ControlValueAccessor interface */
306
+ public registerOnTouched(fn: () => void): void {
307
+ this.onTouchedCallback = fn;
308
+ }
309
+
310
+ public setDisabledState(isDisabled: boolean): void {
311
+ this.disabled = isDisabled;
312
+ }
313
+ // ************* End of ControlValueAccessor Implementation **************
314
+
315
+ protected onChangeCallback = (_a: unknown): void => undefined;
316
+ protected onTouchedCallback = (): void => undefined;
317
+ }
@@ -0,0 +1,15 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/lib",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "inlineSources": true,
9
+ "types": []
10
+ },
11
+ "exclude": [
12
+ "src/test.ts",
13
+ "**/*.spec.ts"
14
+ ]
15
+ }
@@ -0,0 +1,10 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "./tsconfig.lib.json",
4
+ "compilerOptions": {
5
+ "declarationMap": false
6
+ },
7
+ "angularCompilerOptions": {
8
+ "compilationMode": "partial"
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/spec",
6
+ "types": [
7
+ "jasmine"
8
+ ]
9
+ },
10
+ "files": [
11
+ "src/test.ts"
12
+ ],
13
+ "include": [
14
+ "**/*.spec.ts",
15
+ "**/*.d.ts"
16
+ ]
17
+ }