import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import FormItemWrapper from '../form/form-item-wrapper'; export type BirthdateFormat = 'DD.MM.YYYY' | 'MM.DD.YYYY' | 'YYYY.MM.DD' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD'; interface FormatConfig { separator: string; order: ('day' | 'month' | 'year')[]; placeholder: string; maxLength: number; } const FORMAT_CONFIGS: Record = { 'DD.MM.YYYY': { separator: '.', order: [ 'day', 'month', 'year', ], placeholder: 'DD.MM.YYYY', maxLength: 10 }, 'MM.DD.YYYY': { separator: '.', order: [ 'month', 'day', 'year', ], placeholder: 'MM.DD.YYYY', maxLength: 10 }, 'YYYY.MM.DD': { separator: '.', order: [ 'year', 'month', 'day', ], placeholder: 'YYYY.MM.DD', maxLength: 10 }, 'DD/MM/YYYY': { separator: '/', order: [ 'day', 'month', 'year', ], placeholder: 'DD/MM/YYYY', maxLength: 10 }, 'MM/DD/YYYY': { separator: '/', order: [ 'month', 'day', 'year', ], placeholder: 'MM/DD/YYYY', maxLength: 10 }, 'YYYY/MM/DD': { separator: '/', order: [ 'year', 'month', 'day', ], placeholder: 'YYYY/MM/DD', maxLength: 10 }, }; interface BirthdateInputArgs extends Omit { value: Temporal.PlainDate; disabled?: boolean; placeholder?: string; format?: BirthdateFormat; changed: (newValue: Temporal.PlainDate) => void; } @Component class BirthdateInputComponent extends TsxComponent implements BirthdateInputArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: Temporal.PlainDate; @Prop() disabled!: boolean; @Prop() placeholder!: string; @Prop() mandatory!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() maxWidth?: number; @Prop() marginType?: MarginType; @Prop() changed: (newValue: Temporal.PlainDate) => void; @Prop() showClearValueButton!: boolean; @Prop() format?: BirthdateFormat; inputValue: string = ''; private get errorId(): string { return `birthdate-error-${this.$.uid}`; } get formatConfig(): FormatConfig { return FORMAT_CONFIGS[this.format || 'DD.MM.YYYY']; } raiseChangeEvent(newValue: Temporal.PlainDate) { this.populateValidationDeclaration(); if (this.changed != null) { this.changed(newValue); } } getDisabled(): boolean { return this.disabled ?? false; } formatDateToDisplay(date: Temporal.PlainDate): string { if (date == null) { return ''; } const config = this.formatConfig; const day = date.day.toString().padStart(2, '0'); const month = date.month.toString().padStart(2, '0'); const year = date.year.toString(); // eslint-disable-next-line array-callback-return const parts = config.order.map((part) => { switch (part) { case 'day': return day; case 'month': return month; case 'year': return year; } }); return parts.join(config.separator); } parseDateFromInput(input: string): Temporal.PlainDate | null { if (isNullOrEmpty(input)) { return null; } const config = this.formatConfig; const separatorRegex = new RegExp(`[^\\d${config.separator === '.' ? '\\.' : config.separator}]`, 'g'); const cleaned = input.replace(separatorRegex, ''); const parts = cleaned.split(config.separator); if (parts.length !== 3) { return null; } let day: number, month: number, year: number; config.order.forEach((part, index) => { const value = parseInt(parts[index], 10); switch (part) { case 'day': day = value; break; case 'month': month = value; break; case 'year': year = value; break; } }); // Validate basic ranges if (isNaN(day) || isNaN(month) || isNaN(year)) { return null; } if (day < 1 || day > 31 || month < 1 || month > 12 || year < 1900 || year > 2100) { return null; } try { return Temporal.PlainDate.from({ year, month, day }); } catch { return null; } } applyInputMask(value: string, previousValue: string): string { const config = this.formatConfig; const sep = config.separator; const sepRegex = sep === '.' ? '\\.' : sep; // Only allow digits and the configured separator let cleaned = value.replace(new RegExp(`[^\\d${sepRegex}]`, 'g'), ''); // Remove leading separators while (cleaned.startsWith(sep)) { cleaned = cleaned.substring(1); } // Don't allow consecutive separators cleaned = cleaned.replace(new RegExp(`${sepRegex}{2,}`, 'g'), sep); // Split by separator const parts = cleaned.split(sep); // Limit to 3 parts if (parts.length > 3) { parts.length = 3; } // Process each part based on format order const result: string[] = []; for (let i = 0; i < parts.length; i++) { let part = parts[i]; const partType = config.order[i]; if (partType === 'day') { if (part.length > 2) { part = part.substring(0, 2); } if (part.length === 2) { const num = parseInt(part, 10); if (num > 31) { part = '31'; } else if (num === 0) { part = '01'; } } } else if (partType === 'month') { if (part.length > 2) { part = part.substring(0, 2); } if (part.length === 2) { const num = parseInt(part, 10); if (num > 12) { part = '12'; } else if (num === 0) { part = '01'; } } } else if (partType === 'year') { if (part.length > 4) { part = part.substring(0, 4); } } result.push(part); } // Auto-add separator when part is complete let finalValue = result.join(sep); const isTyping = value.length > previousValue.length; const endsWithSep = value.endsWith(sep); // Determine expected length of first two parts based on format const firstPartLength = config.order[0] === 'year' ? 4 : 2; const secondPartLength = config.order[1] === 'year' ? 4 : 2; if (result.length === 1 && result[0].length === firstPartLength && isTyping && !endsWithSep) { finalValue += sep; } else if (result.length === 2 && result[1].length === secondPartLength && isTyping && !endsWithSep) { finalValue += sep; } return finalValue; } handleInputChange(e: Event) { const input = e.target as HTMLInputElement; const newValue = input.value; const previousValue = this.inputValue; // Apply input mask const maskedValue = this.applyInputMask(newValue, previousValue); this.inputValue = maskedValue; // Update the input element value if it differs if (input.value !== maskedValue) { input.value = maskedValue; } // Try to parse and raise change event const parsedDate = this.parseDateFromInput(maskedValue); // Only raise change if we have a complete valid date or the input is empty if (parsedDate != null || isNullOrEmpty(maskedValue)) { this.raiseChangeEvent(parsedDate); } } handleBlur(e: Event) { const input = e.target as HTMLInputElement; const parsedDate = this.parseDateFromInput(input.value); if (parsedDate != null) { // Reformat to ensure consistent display this.inputValue = this.formatDateToDisplay(parsedDate); input.value = this.inputValue; } else if (!isNullOrEmpty(input.value)) { // Invalid date entered, try to preserve what we can or clear const parts = input.value.split('.'); if (parts.length === 3 && parts[2].length === 4) { // Has year but invalid date - keep as is for user to fix } else { // Incomplete - leave for user to complete } } this.raiseChangeEvent(parsedDate); } mounted() { if (this.value != null) { this.inputValue = this.formatDateToDisplay(this.value); } } updated() { // Sync input value with prop if changed externally const propFormatted = this.formatDateToDisplay(this.value); const currentParsed = this.parseDateFromInput(this.inputValue); const currentFormatted = this.formatDateToDisplay(currentParsed); // Only update if the external value is different from what we have if (propFormatted !== currentFormatted) { this.inputValue = propFormatted; } } render(h) { return ( this.handleInputChange(e)} onBlur={e => this.handleBlur(e)} class={PowerduckState.getFormControlCssClass()} placeholder={this.formatConfig.placeholder} maxlength={this.formatConfig.maxLength} autocomplete="bday" aria-invalid={this.hasValidationError ? 'true' : undefined} aria-describedby={this.hasValidationError ? this.errorId : undefined} aria-required={this.mandatory ? 'true' : undefined} /> ); } } const BirthdateInput = toNative(BirthdateInputComponent); export default BirthdateInput;