@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
JavaScript
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