UNPKG

@redkhat/timepicker

Version:

This project contains a series of time selection components. The timepicker allows users to select a time from a clock-like interface. The timepicker is built using **Angular 19+** and **Angular Material 19+**.

1,077 lines (1,067 loc) 65.7 kB
import * as i0 from '@angular/core'; import { model, computed, viewChild, inject, ElementRef, Renderer2, DestroyRef, Injector, signal, afterNextRender, effect, untracked, Component, ChangeDetectionStrategy, ViewEncapsulation, input, output, afterRender, booleanAttribute, Directive } from '@angular/core'; import { DOCUMENT, NgClass } from '@angular/common'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { Dialog, DialogModule } from '@angular/cdk/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { fromEvent, merge, tap, switchMap, takeUntil } from 'rxjs'; import * as i1 from '@angular/material/core'; import { MatRippleModule } from '@angular/material/core'; import { FormsModule, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms'; import { MAT_INPUT_VALUE_ACCESSOR } from '@angular/material/input'; /** * A class that implements the `Iterable` interface to generate coordinates for clock labels. * * @template T - The type of the labels. */ class ClockIterable { /** * The labels to be positioned around the clock. */ labels; /** * The radius of the clock. */ radio; /** * The size of each label. */ labelSize; /** * The inset distance from the edge of the clock. */ inset; /** * The degrees between each label. */ degreesByNumber = 30; /** * Creates an instance of ClockIterable. * * @param labels - An array of labels to be positioned around the clock [12, 1, 2, 3...]. * @param labelSize - The size of each label. * @param diameter - The diameter of the clock. * @param inset - The inset distance from the edge of the clock (default is 0). */ constructor(labels, labelSize, diameter, inset = 0) { this.labels = labels; this.labelSize = labelSize / 2; this.radio = (diameter / 2) - inset; this.inset = inset; } /** * Returns an iterator that yields the label and its x, y coordinates. * * @returns An iterator that yields a tuple containing the label and its x, y coordinates. */ *[Symbol.iterator]() { for (let i = 0; i < this.labels.length; i++) { const x = this.radio + (this.radio - this.labelSize) * Math.sin(i * this.degreesByNumber * Math.PI / 180); const y = this.radio - (this.radio - this.labelSize) * Math.cos(i * this.degreesByNumber * Math.PI / 180); yield [this.labels[i], this.inset + x - this.labelSize, this.inset + y - this.labelSize]; } } } function secondsToDate(seconds) { const initialDate = new Date(0, 0, 0, 0); const date = new Date(initialDate.getTime() + seconds * 1000); return date; } function detectTimeFormat(date) { const localTime = date.toLocaleTimeString(); if (localTime.includes('AM') || localTime.includes('PM')) { return '12h'; } else { return '24h'; } } function splitDate(date) { const hours = date.getHours(); const minutes = date.getMinutes(); return { hours, minutes }; } function amPmTransform(isAm, date) { const newDate = new Date(date); const tempHours = newDate.getHours(); if (isAm === 'AM' && tempHours >= 12) { newDate.setHours(tempHours - 12); } else if (isAm === 'PM' && tempHours < 12) { newDate.setHours(tempHours + 12); } return newDate; } function validateTimeValue(value, max) { let numericValue = value.replace(/[^0-9]/g, ''); if (numericValue.length > 2) { numericValue = numericValue.slice(0, 2); } if (numericValue === '') { return ''; } const num = parseInt(numericValue, 10); return num > max ? max.toString() : numericValue; } function formatTimeValue(value, defaultValue, padLength = 2) { if (value === '') { return defaultValue; } const num = parseInt(value, 10); if (num === 0) { return defaultValue; } return value.padStart(padLength, '0'); } /** * Converts a time string or number to the number of seconds since midnight. * * Supported formats include: * - Integers (e.g., "10", "1030", 10, 1030) in 24-hour format (up to 2359). * - Space-separated numbers (e.g., "10 30") as HH MM. * - Colon-separated numbers (e.g., "10:30" or "10:30:00") as HH:MM or HH:MM:SS. * - Formats with regional AM/PM markers either at the beginning or at the end * (e.g., "10:30am", "am 10:30", "午後3:25:00", "10:30 du matin"). * * Additionally, this function normalizes Arabic digits (Unicode range \u0660-\u0669) * to their Western counterparts before processing. * * @param input The input to convert (can be a string or number). * @returns The number of seconds since midnight, or null if the input is invalid. */ function timeToSecondsi18n(input) { // Convert input to string and trim whitespace. let inputStr = typeof input === 'number' ? String(input) : input; inputStr = inputStr.trim(); // Normalize Arabic digits to Western digits if found. if (/[\u0660-\u0669]/.test(inputStr)) { inputStr = inputStr.replace(/[\u0660-\u0669]/g, d => (d.charCodeAt(0) - 0x0660).toString()); } // Extended list of AM and PM markers across various regions/languages. const AM_MARKERS = [ 'am', 'a.m.', 'a.m', // English '午前', // Japanese '上午', // Chinese '오전', // Korean 'ص', 'صباح', 'صباحاً', // Arabic 'du matin', 'matin' // French ]; const PM_MARKERS = [ 'pm', 'p.m.', 'p.m', // English '午後', // Japanese '下午', // Chinese '오후', // Korean 'م', 'مساء', 'مساءً', // Arabic "de l’après-midi", "de l'aprés-midi", "de l'après-midi", 'après-midi', 'du soir', 'soir' // French ]; let period = null; // Helper functions to compare prefix/suffix ignoring case. const startsWithIgnoreCase = (str, prefix) => str.substring(0, prefix.length).toLowerCase() === prefix.toLowerCase(); const endsWithIgnoreCase = (str, suffix) => str.substring(str.length - suffix.length).toLowerCase() === suffix.toLowerCase(); // Check if the period marker is at the beginning. for (const marker of AM_MARKERS) { if (startsWithIgnoreCase(inputStr, marker)) { period = 'AM'; inputStr = inputStr.substring(marker.length).trim(); break; } } if (!period) { for (const marker of PM_MARKERS) { if (startsWithIgnoreCase(inputStr, marker)) { period = 'PM'; inputStr = inputStr.substring(marker.length).trim(); break; } } } // If not found at the beginning, check at the end. if (!period) { for (const marker of AM_MARKERS) { if (endsWithIgnoreCase(inputStr, marker)) { period = 'AM'; inputStr = inputStr.substring(0, inputStr.length - marker.length).trim(); break; } } } if (!period) { for (const marker of PM_MARKERS) { if (endsWithIgnoreCase(inputStr, marker)) { period = 'PM'; inputStr = inputStr.substring(0, inputStr.length - marker.length).trim(); break; } } } // Split the numeric part of the time. let timeParts = []; if (inputStr.includes(':')) { // Colon-separated format. Accepts HH:MM or HH:MM:SS. const parts = inputStr.split(':'); if (parts.length < 2 || parts.length > 3) return null; for (const part of parts) { const num = parseInt(part, 10); if (isNaN(num)) return null; timeParts.push(num); } } else if (inputStr.includes(' ')) { // Space-separated format (e.g., "10 30"). const parts = inputStr.split(/\s+/); if (parts.length > 3) return null; for (const part of parts) { const num = parseInt(part, 10); if (isNaN(num)) return null; timeParts.push(num); } } else if (/^\d+$/.test(inputStr)) { // Numeric case without separators: "10", "1030". if (inputStr.length <= 2) { timeParts = [parseInt(inputStr, 10)]; } else if (inputStr.length === 3) { timeParts = [ parseInt(inputStr.substring(0, 1), 10), parseInt(inputStr.substring(1), 10), ]; } else if (inputStr.length === 4) { timeParts = [ parseInt(inputStr.substring(0, 2), 10), parseInt(inputStr.substring(2), 10), ]; } else { // Unsupported numeric format with more than 4 digits. return null; } } else { // Unrecognized format. return null; } // Extract hour, minute, and second (defaulting to 0 if not provided). let hour = timeParts[0]; let minute = timeParts.length >= 2 ? timeParts[1] : 0; let second = timeParts.length === 3 ? timeParts[2] : 0; // Validate minute and second ranges. if (minute < 0 || minute > 59) return null; if (second < 0 || second > 59) return null; // Adjust hour based on the AM/PM marker. if (period) { if (hour < 1 || hour > 12) return null; if (period === 'PM') { hour = hour === 12 ? 12 : hour + 12; } else { hour = hour === 12 ? 0 : hour; } } else { // Without a period, assume 24-hour format. if (hour < 0 || hour > 23) return null; } return hour * 3600 + minute * 60 + second; } function cubicBezier(p1x, p1y, p2x, p2y, t) { const cx = 3.0 * p1x; const bx = 3.0 * (p2x - p1x) - cx; const ax = 1.0 - cx - bx; const cy = 3.0 * p1y; const by = 3.0 * (p2y - p1y) - cy; const ay = 1.0 - cy - by; let x = t; for (let i = 0; i < 4; i++) { const t_0 = x * x * x * ax + x * x * bx + x * cx; const t_1 = 3 * x * x * ax + 2 * x * bx + cx; x = x - (t_0 - t) / t_1; } return x * x * x * ay + x * x * by + x * cy; } function normalizeEvent(event) { if (event instanceof MouseEvent) { return event; } else if (event instanceof TouchEvent) { return event.touches[0] || event.changedTouches[0]; } else if (event instanceof Touch) { return { clientX: event.clientX, clientY: event.clientY, pageX: event.pageX, pageY: event.pageY, screenX: event.screenX, screenY: event.screenY, target: event.target }; } return event; } function createPointerEvents(element) { const pointerDown$ = fromEvent(element, 'pointerdown'); const pointerMove$ = fromEvent(document, 'pointermove'); const pointerUp$ = fromEvent(document, 'pointerup'); const pointerLeave$ = fromEvent(document, 'pointerleave'); const start$ = pointerDown$; const move$ = pointerMove$; const end$ = merge(pointerUp$, pointerLeave$); return { start$, move$, end$ }; } function snapAngle(angle, steps) { // Calc the size of each step in the scale. 12 for hours, 60 for minutes const stepSize = 360 / steps; // Find the closest multiple of the step size. 12 for hours, 60 for minutes const closestStep = Math.round(angle / stepSize); // Calc the snapped angle const snappedAngle = closestStep * stepSize; return snappedAngle; } function hoursToAngle(hour) { const angle = (hour - 3) * 30; return (angle + 360) % 360; } function minutesToAngle(minute) { let angle = (minute - 15) * 6; angle = (angle + 360) % 360; return angle; } function hours24ToAngle(hour) { let angle; if (hour >= 0 && hour <= 11) { angle = (hour - 3) * 30; // Dial 1: 0 to 11 } else { angle = (hour - 15) * 30; // Dial 2: 12 to 23 } angle = (angle + 360) % 360; return angle; } function angleToHours(angle) { // Normalize the angle to be within 0-360 range angle = (angle + 360) % 360; // Correct mapping: 0 degrees -> 3, 270 degrees -> 12 let hour = Math.floor((angle + 90) / 30) % 12; // Handle the 0 hour case (midnight) if (hour === 0) { hour = 12; } return hour; } function angleToMinutes(angle) { // Normalize the angle angle = (angle + 360) % 360; let minute = (angle / 6) + 15; minute = (minute + 60) % 60; // Ensure positive value and wrap around 60 return Math.floor(minute); } function angleToHours24(angle, dial) { angle = (angle + 360) % 360; let hour = 0; const baseValue = angle / 30; if (dial === 1) { hour = (Math.floor(baseValue + 3) % 12 + 12) % 12; } else if (dial === 2) { hour = (Math.floor(baseValue + 3) % 12) + 12; } return hour; } const HOURS_LABEL = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; const HOURS_LABEL_24_P1 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; const HOURS_LABEL_24_P2 = [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; const MINUTES_LABEL = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]; class RkTimepickerDial { currentTime = model.required({ alias: 'time' }); timeFormat = model('12h', { alias: 'format' }); selectedTime = model('hours', { alias: 'selected' }); period = model('AM'); value = computed(() => { const date = this.currentTime(); return date ? splitDate(date) : { hours: 0, minutes: 0 }; }); format = computed(() => { const fm = this.timeFormat(); return (fm && (fm === '24h' || fm === '12h')) ? fm : detectTimeFormat(new Date()); }); selected = computed(() => { const selected = this.selectedTime(); return selected === 'hours' || selected === 'minutes' ? selected : 'hours'; }); _dial = viewChild.required('dial'); _clipedLabel = viewChild.required('dial2'); _dialSelector = viewChild.required('dialSelector'); _elRef = inject(ElementRef); _renderer = inject(Renderer2); _destroyRef = inject(DestroyRef); _injector = inject(Injector); _document = inject(DOCUMENT); _currentDial = signal(1); _currentDegree = signal(0); _loading = signal(false); _motion = 250; _radius = 128; _labelSize = 48; constructor() { afterNextRender(() => { this._fillBySelected(this.selected(), this.format()); effect(() => { const selected = this.selected(); const format = this.format(); this._fillBySelected(selected, format); untracked(() => { const value = this.value(); this.period.set(this.value().hours < 12 ? 'AM' : 'PM'); this._currentDial.set(format === '24h' && selected === 'hours' && value.hours >= 12 ? 2 : 1); this._handleInputsChange(value, format, selected); }); }, { injector: this._injector }); effect(() => { const value = this.value(); untracked(() => { if (this._loading()) return; const format = this.format(); const selected = this.selected(); this.period.set(this.value().hours < 12 ? 'AM' : 'PM'); this._currentDial.set(format === '24h' && selected === 'hours' && value.hours >= 12 ? 2 : 1); this._handleInputsChange(value, format, selected, true); }); }, { injector: this._injector }); effect(() => { const period = this.period(); untracked(() => { const currentTime = this.currentTime(); if (period === null || currentTime === null) return; this.currentTime.set(amPmTransform(period, currentTime)); }); }, { injector: this._injector }); const { start$, move$, end$ } = createPointerEvents(this._elRef.nativeElement); start$.pipe(tap((event) => this._onPointerEventInit(normalizeEvent(event))), switchMap(() => { return move$.pipe(takeUntil(end$.pipe(tap((event) => this._onPointerEventStop(normalizeEvent(event)))))); }), takeUntilDestroyed(this._destroyRef)).subscribe(event => { const normalized = normalizeEvent(event); this._onPointerEventInit(normalized); }); }); } _handleInputsChange(value, format, selected, animate = true) { if (selected === 'hours') { const angle = format === '24h' ? hours24ToAngle(value.hours) : hoursToAngle(value.hours); if (animate) { this._rotateAnimation(this._currentDegree(), angle, this._motion, 2); } else { this._moveByAngle(angle, 2); } this._currentDegree.set(angle); } else { const angle = minutesToAngle(value.minutes); if (animate) { this._rotateAnimation(this._currentDegree(), angle, this._motion, 2); } else { this._moveByAngle(angle, 2); } this._currentDegree.set(angle); } } _onPointerEventInit(event) { this._loading.set(true); this._moveByTouchClick(event, this._dial().nativeElement); } async _onPointerEventStop(event) { const currentDegree = this._currentDegree(); const snapDegrees = this.selected() === 'hours' ? snapAngle(currentDegree, 12) : snapAngle(currentDegree, 60); await this._rotateAnimation(this._currentDegree(), snapDegrees, this._motion / 2, 2); this._currentDegree.set(snapDegrees); this._loading.set(false); if (this.selected() === 'hours') { this.selectedTime.set('minutes'); } } _fillDial(dial, labels, withClean, inset = 2) { if (withClean) { dial.innerHTML = ''; } const clockIterable = new ClockIterable(labels, this._labelSize, this._radius * 2, inset); for (const [label, x, y] of clockIterable) { const numero = this._document.createElement('div'); numero.classList.add('rk-clock-dial-label'); numero.textContent = this.selected() === 'hours' ? label.toString() : label.toString().padStart(2, '0'); numero.style.position = 'absolute'; numero.style.left = x + 'px'; numero.style.top = y + 'px'; this._renderer.appendChild(dial, numero); } } _fillBySelected(selected, format) { switch (selected) { case 'hours': if (format === '12h') { this._fillDial(this._dial().nativeElement, HOURS_LABEL, true); this._fillDial(this._clipedLabel().nativeElement, HOURS_LABEL, true); } else if (format === '24h') { this._fillDial(this._dial().nativeElement, HOURS_LABEL_24_P1, true); this._fillDial(this._dial().nativeElement, HOURS_LABEL_24_P2, false, 38); this._fillDial(this._clipedLabel().nativeElement, HOURS_LABEL_24_P1, true); this._fillDial(this._clipedLabel().nativeElement, HOURS_LABEL_24_P2, false, 38); } break; case 'minutes': this._fillDial(this._dial().nativeElement, MINUTES_LABEL, true); this._fillDial(this._clipedLabel().nativeElement, MINUTES_LABEL, true); break; default: this._fillDial(this._dial().nativeElement, HOURS_LABEL, true); this._fillDial(this._clipedLabel().nativeElement, HOURS_LABEL, true); } } _moveByTouchClick(event, dial, inset = 2) { const rect = dial.getBoundingClientRect(); // relative to the center of the clock const x = event.clientX - rect.left - this._radius; const y = event.clientY - rect.top - this._radius; // Calc angle in degrees let angle = Math.atan2(y, x) * (180 / Math.PI); if (angle < 0) { angle += 360; //make sure the angle is in the range [0, 360] } // Calc new position for the object (using radius as reference) let radius = this._radius - (this._labelSize / 2) - inset; //--------------------------------------------------------------------------------------------------------------// // logic when format is 24h and two dial exist const distanceFromCenter = Math.sqrt(x * x + y * y); const MAX_RAD = radius - 20; let selectorInset = 0; if (distanceFromCenter < MAX_RAD && this.selected() === 'hours' && this.format() === '24h') { radius -= 36; this._currentDial.set(2); selectorInset = 36; } else { this._currentDial.set(1); selectorInset = 0; } //----------------------------------------------------------------------------------------------------------------// const moveX = this._radius + radius * Math.cos(angle * (Math.PI / 180)); const moveY = this._radius + radius * Math.sin(angle * (Math.PI / 180)); // Call the provided functions with the calculated values this._moveTrack(moveX, moveY); this._moveSelector(angle, selectorInset); this._currentDegree.set(angle); //update time const snapDegrees = this.selected() === 'hours' ? snapAngle(angle, 12) : snapAngle(angle, 60); const currentDate = this.currentTime(); const date = currentDate ? new Date(currentDate) : new Date(); if (this.selected() === 'hours') { let hours = this.format() === '24h' ? angleToHours24(snapDegrees, this._currentDial()) : angleToHours(snapDegrees); if (this.format() === '24h') { this.period.set(hours < 12 ? 'AM' : 'PM'); } if (this.format() !== '24h' && this.period() === 'PM') { hours = hours < 12 ? hours + 12 : hours; } if (this.period() === 'AM' && hours >= 12 && this.format() === '12h') { hours = hours - 12; } date.setHours(hours); } else { date.setMinutes(angleToMinutes(snapDegrees)); } this.currentTime.set(date); } _moveByAngle(angle, inset = 2) { // Ensure angle is within 0-360 range angle = angle % 360; if (angle < 0) { angle += 360; } // Calc new position for the object (using radius as reference) let radius = this._radius - (this._labelSize / 2) - inset; // logic when format is 24h and two dial exist let selectorInset = 0; if (this.selected() === 'hours' && this.format() === '24h' && this._currentDial() === 2) { radius -= 36; selectorInset = 36; } const moveX = this._radius + radius * Math.cos(angle * (Math.PI / 180)); const moveY = this._radius + radius * Math.sin(angle * (Math.PI / 180)); // Call the provided functions with the calculated values this._moveTrack(moveX, moveY); this._moveSelector(angle, selectorInset); } _moveTrack(x, y) { this._renderer.setStyle(this._clipedLabel().nativeElement, 'clip-path', `circle(24px at ${x}px ${y}px)`); } _moveSelector(degrees, inset = 0) { this._renderer.setStyle(this._dialSelector().nativeElement, 'width', 104 - inset + 'px'); this._renderer.setStyle(this._dialSelector().nativeElement, 'transform', `rotate(${degrees}deg)`); } async _rotateAnimation(currentDegree, degree, animationTime, inset = 2) { return new Promise((resolve) => { // Calculate the shortest angle difference between the current and target degrees. let angleDiff = degree - currentDegree; // Adjust the angle difference to be within the range of -180 to 180 degrees. if (angleDiff > 180) { angleDiff -= 360; } else if (angleDiff < -180) { angleDiff += 360; } // Store the start time of the animation. let startTime = null; const animate = (timestamp) => { // Initialize the start time if it hasn't been set yet. if (!startTime) startTime = timestamp; // Calculate the progress of the animation (0 to 1). let progress = Math.min((timestamp - startTime) / animationTime, 1); // Apply a cubic Bezier easing function to the progress. This makes the animation smoother. const easedProgress = cubicBezier(0.05, 0.7, 0.1, 1.0, progress); // Calculate the current angle based on the eased progress. const currentAngle = currentDegree + angleDiff * easedProgress; // Update the position of the dial and selector based on the current angle. this._moveByAngle(currentAngle, inset); // Continue the animation if it's not finished. if (progress < 1) { requestAnimationFrame(animate); } else { resolve(); // Resolve the promise when the animation is complete. } }; requestAnimationFrame(animate); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepickerDial, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.1.6", type: RkTimepickerDial, isStandalone: true, selector: "rk-timepicker-dial", inputs: { currentTime: { classPropertyName: "currentTime", publicName: "time", isSignal: true, isRequired: true, transformFunction: null }, timeFormat: { classPropertyName: "timeFormat", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, selectedTime: { classPropertyName: "selectedTime", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, period: { classPropertyName: "period", publicName: "period", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentTime: "timeChange", timeFormat: "formatChange", selectedTime: "selectedChange", period: "periodChange" }, host: { classAttribute: "rk-timepicker-dial" }, viewQueries: [{ propertyName: "_dial", first: true, predicate: ["dial"], descendants: true, isSignal: true }, { propertyName: "_clipedLabel", first: true, predicate: ["dial2"], descendants: true, isSignal: true }, { propertyName: "_dialSelector", first: true, predicate: ["dialSelector"], descendants: true, isSignal: true }], ngImport: i0, template: ` <div class="rk-timepicker-dial-container"> <div #dial class="rk-label-container rk-dial-size"> </div> <div #dial2 class="rk-cliped-label rk-dial-size"> </div> <div #dialSelector class="rk-dial-selector"></div> <div class="rk-pivot-point"></div> </div> `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepickerDial, decorators: [{ type: Component, args: [{ selector: 'rk-timepicker-dial', host: { 'class': 'rk-timepicker-dial', }, imports: [], template: ` <div class="rk-timepicker-dial-container"> <div #dial class="rk-label-container rk-dial-size"> </div> <div #dial2 class="rk-cliped-label rk-dial-size"> </div> <div #dialSelector class="rk-dial-selector"></div> <div class="rk-pivot-point"></div> </div> `, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }] }], ctorParameters: () => [] }); class RkTimepickerInputLabel { currentTime = model.required({ alias: 'time' }); selected = model('hours'); period = model('AM'); format = model('12h'); editable = model(false); hours = signal(''); minutes = signal(''); inputSupportLabels = input(['Hour', 'Minute'], { alias: 'inputLabels' }); inputHours = viewChild('inputH'); inputMinutes = viewChild('inputM'); value = computed(() => { const date = this.currentTime(); const format = this.format(); if (date) { const { hours, minutes } = splitDate(date); if (format === '12h') { return { hours: hours > 12 ? hours - 12 : hours === 0 ? 12 : hours, minutes }; } return { hours, minutes }; } return { hours: 0, minutes: 0 }; }); constructor() { effect(() => { const editable = this.editable(); const selected = this.selected(); if (editable) { if (selected === 'hours') { this.inputHours()?.nativeElement.focus(); } else if (selected === 'minutes') { this.inputMinutes()?.nativeElement.focus(); } } }); effect(() => { const value = this.value(); untracked(() => { this.onHoursInput(value.hours.toString().padStart(2, '0')); this.onMinutesInput(value.minutes.toString().padStart(2, '0')); }); }); } onHoursInput(event) { const input = this.inputHours()?.nativeElement; const value = typeof event === 'string' ? event : event.target?.value; const max = this.format() === '24h' ? 23 : 12; const validatedValue = validateTimeValue(value, max); if (input && input.value !== validatedValue) { input.value = validatedValue; } this.hours.set(validatedValue); } onMinutesInput(event) { const input = this.inputMinutes()?.nativeElement; const value = typeof event === 'string' ? event : event.target?.value; const validatedValue = validateTimeValue(value, 59); if (input && input.value !== validatedValue) { input.value = validatedValue; } this.minutes.set(validatedValue); } onHoursBlur() { const defaultHour = this.format() === '24h' ? '00' : '01'; const formattedValue = formatTimeValue(this.hours(), defaultHour); this.hours.set(formattedValue); this.updateModel(); } onMinutesBlur() { const formattedValue = formatTimeValue(this.minutes(), '00'); this.minutes.set(formattedValue); this.updateModel(); } updateModel() { const tempDate = this.currentTime(); if (tempDate) { const newDate = new Date(tempDate); let hoursN = Number(this.hours()); const minutesN = Number(this.minutes()); if (this.format() === '12h' && this.period() === 'PM') { hoursN = hoursN < 12 ? hoursN + 12 : hoursN; } newDate.setHours(hoursN); newDate.setMinutes(minutesN); this.currentTime.set(newDate); } } handleKeydown(event, selected) { if (['Enter', 'Escape', 'Tab'].includes(event.code)) { if (selected === 'hours') { this.onHoursBlur(); this.selected.set('minutes'); } else { this.onMinutesBlur(); } } } selectedChange(selected) { if (this.selected() === selected) { return; } this.selected.set(selected); } periodSelector(period) { if (this.period() === period) { return; } this.period.set(period); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepickerInputLabel, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: RkTimepickerInputLabel, isStandalone: true, selector: "rk-timepicker-input-label", inputs: { currentTime: { classPropertyName: "currentTime", publicName: "time", isSignal: true, isRequired: true, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, period: { classPropertyName: "period", publicName: "period", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, inputSupportLabels: { classPropertyName: "inputSupportLabels", publicName: "inputLabels", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentTime: "timeChange", selected: "selectedChange", period: "periodChange", format: "formatChange", editable: "editableChange" }, host: { classAttribute: "rk-time-input-field" }, viewQueries: [{ propertyName: "inputHours", first: true, predicate: ["inputH"], descendants: true, isSignal: true }, { propertyName: "inputMinutes", first: true, predicate: ["inputM"], descendants: true, isSignal: true }], ngImport: i0, template: ` <div class="rk-time-input-label-container"> <div class="rk-time-selector-container" matRipple [ngClass]="{'rk-time-selector-container-selected': selected() === 'hours'}" (click)="selectedChange('hours')"> @if (!editable()) { {{value().hours.toString().padStart(2, '0')}} } @else { <input type="number" maxlength="2" #inputH [value]="hours()" (focus)="selectedChange('hours')" (input)="onHoursInput($event)" (keydown)="handleKeydown($event, 'hours')" (blur)="onHoursBlur()" name="hours"> } </div> @if (editable()) { <span class="rk-time-input-support-label">{{inputSupportLabels()[0]}}</span> } </div> <div class="rk-time-divider">:</div> <div class="rk-time-input-label-container"> <div class="rk-time-selector-container" matRipple [ngClass]="{'rk-time-selector-container-selected': selected() === 'minutes'}" (click)="selectedChange('minutes')"> @if (!editable()) { {{value().minutes.toString().padStart(2, '0')}} } @else { <input type="number" maxlength="2" #inputM [value]="minutes()" (focus)="selectedChange('minutes')" (input)="onMinutesInput($event)" (keydown)="handleKeydown($event, 'minutes')" (blur)="onMinutesBlur()" name="minutes"> } </div> @if (editable()) { <span class="rk-time-input-support-label">{{inputSupportLabels()[1]}}</span> } </div> @if (format() === '12h') { <div class="rk-period-selector-container"> <div class="rk-period-selector" matRipple [ngClass]="{'rk-period-selector-selected': period() === 'AM'}" (click)="periodSelector('AM')">AM</div> <div class="rk-period-selector" matRipple [ngClass]="{'rk-period-selector-selected': period() === 'PM'}" (click)="periodSelector('PM')">PM</div> </div> } `, isInline: true, styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: MatRippleModule }, { kind: "directive", type: i1.MatRipple, selector: "[mat-ripple], [matRipple]", inputs: ["matRippleColor", "matRippleUnbounded", "matRippleCentered", "matRippleRadius", "matRippleAnimation", "matRippleDisabled", "matRippleTrigger"], exportAs: ["matRipple"] }, { kind: "ngmodule", type: FormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepickerInputLabel, decorators: [{ type: Component, args: [{ selector: 'rk-timepicker-input-label', imports: [NgClass, MatRippleModule, FormsModule], host: { 'class': 'rk-time-input-field', }, template: ` <div class="rk-time-input-label-container"> <div class="rk-time-selector-container" matRipple [ngClass]="{'rk-time-selector-container-selected': selected() === 'hours'}" (click)="selectedChange('hours')"> @if (!editable()) { {{value().hours.toString().padStart(2, '0')}} } @else { <input type="number" maxlength="2" #inputH [value]="hours()" (focus)="selectedChange('hours')" (input)="onHoursInput($event)" (keydown)="handleKeydown($event, 'hours')" (blur)="onHoursBlur()" name="hours"> } </div> @if (editable()) { <span class="rk-time-input-support-label">{{inputSupportLabels()[0]}}</span> } </div> <div class="rk-time-divider">:</div> <div class="rk-time-input-label-container"> <div class="rk-time-selector-container" matRipple [ngClass]="{'rk-time-selector-container-selected': selected() === 'minutes'}" (click)="selectedChange('minutes')"> @if (!editable()) { {{value().minutes.toString().padStart(2, '0')}} } @else { <input type="number" maxlength="2" #inputM [value]="minutes()" (focus)="selectedChange('minutes')" (input)="onMinutesInput($event)" (keydown)="handleKeydown($event, 'minutes')" (blur)="onMinutesBlur()" name="minutes"> } </div> @if (editable()) { <span class="rk-time-input-support-label">{{inputSupportLabels()[1]}}</span> } </div> @if (format() === '12h') { <div class="rk-period-selector-container"> <div class="rk-period-selector" matRipple [ngClass]="{'rk-period-selector-selected': period() === 'AM'}" (click)="periodSelector('AM')">AM</div> <div class="rk-period-selector" matRipple [ngClass]="{'rk-period-selector-selected': period() === 'PM'}" (click)="periodSelector('PM')">PM</div> </div> } `, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }] }], ctorParameters: () => [] }); class RkTimepicker { selected = model('hours'); format = model('12h'); period = model('AM'); editable = model(false); orientation = model('portrait'); _inputSupportLabels = input(['Hour', 'Minute'], { alias: 'inputLabels' }); _headline = input(['Enter time', 'Select time'], { alias: 'headline' }); _actions = input(['Cancel', 'OK'], { alias: 'actions' }); /** Whether the timepicker is currently disabled. */ disabled = computed(() => !!this._input()?.disabled()); defaultTime = input(null, { alias: 'time' }); load = signal(false); isLandscape = computed(() => { return this.orientation() === 'landscape'; }); headline = computed(() => { const editable = this.editable(); const labels = this._headline(); return editable ? labels[0] : labels[1]; }); _panelTemplate = viewChild.required('panelTemplate'); _dialogRef = signal(null); _input = signal(null); _isOpen = signal(false); currentTime = signal(null); animated = signal(false); /** Emits when the timepicker is opened. */ opened = output(); /** Emits when the timepicker is closed. */ closed = output(); selectedTime = output(); _dialog = inject(Dialog); constructor() { effect(() => { const value = this.defaultTime(); if (!value) { this.currentTime.set(new Date()); } else { this.currentTime.set(value); } }); effect(() => { const editable = this.editable(); untracked(() => { this.animated.set(true); }); }); afterRender(() => { this.load.set(true); }); } /** Opens the timepicker. */ open() { const input = this._input(); if (!input) { return; } if (this._isOpen()) { return; } this.currentTime.set(input.value()); this.animated.set(false); this._isOpen.set(true); this.opened.emit(); const dialogRef = this._dialog.open(this._panelTemplate(), { width: '328px', }); this._dialogRef.set(dialogRef); dialogRef.closed.subscribe(result => { if (result) { this._input()?.value.set(result); this.selectedTime.emit(result); input.simulateEnter(); } this._isOpen.set(false); this.closed.emit(); this.selected.set('hours'); input.focus(); }); } /** Registers an input with the timepicker. */ registerInput(input) { const currentInput = this._input(); if (currentInput && input !== currentInput) { console.warn('RkTimepicker can only be registered with one input at a time'); } this._input.set(input); } _handleKeydown(event) { const keyCode = event.key; if (keyCode === 'Enter') { event.preventDefault(); this.onConfirm(); } else if (keyCode === 'Escape') { event.preventDefault(); this.onCancel(); } } changeMode() { this.editable.set(!this.editable()); } onConfirm() { this._dialogRef()?.close(this.currentTime()); } onCancel() { this._dialogRef()?.close(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepicker, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: RkTimepicker, isStandalone: true, selector: "rk-timepicker", inputs: { selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, period: { classPropertyName: "period", publicName: "period", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, _inputSupportLabels: { classPropertyName: "_inputSupportLabels", publicName: "inputLabels", isSignal: true, isRequired: false, transformFunction: null }, _headline: { classPropertyName: "_headline", publicName: "headline", isSignal: true, isRequired: false, transformFunction: null }, _actions: { classPropertyName: "_actions", publicName: "actions", isSignal: true, isRequired: false, transformFunction: null }, defaultTime: { classPropertyName: "defaultTime", publicName: "time", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selected: "selectedChange", format: "formatChange", period: "periodChange", editable: "editableChange", orientation: "orientationChange", opened: "opened", closed: "closed", selectedTime: "selectedTime" }, host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.rk-timepicker-landscape": "isLandscape()", "class.rk-timepicker-editable": "editable()" } }, viewQueries: [{ propertyName: "_panelTemplate", first: true, predicate: ["panelTemplate"], descendants: true, isSignal: true }], exportAs: ["rkTimepicker"], ngImport: i0, template: ` <ng-template #panelTemplate> <div class="rk-timepicker rk-timepicker-panel"> <div class="rk-timepicker-container" [ngClass]="{ 'rk-timepicker-container-hide': !load() }"> <span class="rk-timepicker-headline">{{ headline() }}</span> <rk-timepicker-input-label [(time)]="currentTime" [inputLabels]="_inputSupportLabels()" [(editable)]="editable" [(format)]="format" [(period)]="period" [(selected)]="selected"></rk-timepicker-input-label> <rk-timepicker-dial [ngClass]="{ 'rk-dial-closed': editable(), 'rk-dial-animated': animated()}" [(time)]="currentTime" [(format)]="format" [(period)]="period" [(selected)]="selected"></rk-timepicker-dial> <div class="rk-timepicker-footer"> <button class="rk-timepicker-mode-button" (click)="changeMode()" mat-icon-button> @if (editable()) { <mat-icon>schedule</mat-icon> } @else { <mat-icon>keyboard</mat-icon> } </button> <div class="rk-timepicker-actions"> <button mat-button (click)="onCancel()">{{_actions()[0]}}</button> <button mat-button (click)="onConfirm()">{{_actions()[1]}}</button> </div> </div> </div> </div> </ng-template> `, isInline: true, styles: [""], dependencies: [{ kind: "component", type: RkTimepickerInputLabel, selector: "rk-timepicker-input-label", inputs: ["time", "selected", "period", "format", "editable", "inputLabels"], outputs: ["timeChange", "selectedChange", "periodChange", "formatChange", "editableChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: DialogModule }, { kind: "component", type: RkTimepickerDial, selector: "rk-timepicker-dial", inputs: ["time", "format", "selected", "period"], outputs: ["timeChange", "formatChange", "selectedChange", "periodChange"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: RkTimepicker, decorators: [{ type: Component, args: [{ selector: 'rk-timepicker', exportAs: 'rkTimepicker', imports: [RkTimepickerInputLabel, NgClass, DialogModule, RkTimepickerDial, MatButton, MatIconButton, MatIcon], host: { '[class.rk-timepicker-landscape]': 'isLandscape()', '[class.rk-timepicker-editable]': 'editable()', '(keydown)': '_handleKeydown($event)', }, template: ` <ng-template #panelTemplate> <div class="rk-timepicker rk-timepicker-panel"> <div class="rk-timepicker-container" [ngClass]="{ 'rk-timepicker-container-hide': !load() }"> <span class="rk-timepicker-headline">{{ headline() }}</span> <rk-timepicker-input-label [(time)]="currentTime" [inputLabels]="_inputSupportLabels()" [(editable)]="editable" [(format)]="format" [(period)]="period" [(selected)]="selected"></rk-timepicker-input-label> <rk-timepicker-dial [ngClass]="{ 'rk-dial-closed': editable(), 'rk-dial-animated': animated()}" [(time)]="currentTime" [(format)]="format" [(period)]="period" [(selected)]="selected"></rk-timepicker-dial> <div class="rk-timepicker-footer"> <button class="rk-timepicker-mode-button" (click)="changeMode()" mat-icon-button> @if (editable()) { <mat-icon>schedule</mat-icon> } @else { <mat-icon>keyboard</mat-icon> } </button> <div class="rk-timepicker-actions"> <button mat-button (click)="onCancel()">{{_actions()[0]}}</button> <button mat-button (click)="onConfirm()">{{_actions()[1]}}</button> </div> </div> </div> </div> </ng-template> `, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }] }], ctorParameters: () => [] }); class RkTimepickerInput { _elementRef = inject(ElementRef); _renderer = inject(Renderer2); /** Necessary for ControlValueAccessor implementation */ _onChange; _onTouched; _accessorDisabled = signal(false); _validatorOnChange; /** Current value of the input. */ value = model(null); _tempDateSafe = signal(null); /** Timepicker that the input is associated with. */ timepicker = input.required({ alias: 'rkTimepicker', }); /** Whether the input is disabled. */ disabled = computed(() => this.disabledInput() || this._accessorDisabled()); /** * Whether the input should be disabled through the template. */ disabledInput = input(false, { transform: booleanAttribute, alias: 'disabled', }); constructor() { this._registerTimepicker(); } _updateModelValue() { const value = this.value(); if (!value) { this._validatorOnChange?.(); return; } this._onChange?.(value); } _validateTimeOrNull(value) { if (value === null || v