import { Component, EventEmitter, Element, Watch, Event, Prop, h, State, Listen, AttachInternals } from '@stencil/core'; import { v4 as uuid } from 'uuid'; import { Language } from '../../utils/common/language-types'; import { validateLanguage } from '../../utils/validation/validation-functions'; import { translations, Translations } from '../../translations'; import { Input } from './components'; import { getDateErrorMessage, getVisibleDateFields } from './utils'; import { DateInputFieldType, DateInputPlaceholder, DateInputValueParts, DateValidatorReturnType, } from './ontario-date-input-interface'; import { ErrorMessage } from '../../utils/components/error-message/error-message'; import { ConsoleMessageClass } from '../../utils/console-message/console-message'; import { ConsoleType } from '../../utils/console-message/console-message.enum'; import { InputCaption } from '../../utils/common/input-caption/input-caption'; import { Caption } from '../../utils/common/input-caption/caption.interface'; import { emitEvent } from '../../utils/events/event-handler'; import { HeaderLanguageToggleEventDetails } from '../../utils/events/common-events.interface'; /** * Ontario Date Input captures day, month, and year values as a single date field. * * This component intentionally does not expose `readOnly` or `disabled` props. * * To support accessible and understandable form completion: * - keep form fields and submission actions available * - use validation and error messaging to guide corrections * * For component guidance, see: * - https://designsystem.ontario.ca/components/detail/dates.html * - https://designsystem.ontario.ca/developer-docs/components/ontario-date-input/ * * Disabled/read-only policy source: * - https://designsystem.ontario.ca/components/detail/buttons.html#disabled-buttons */ @Component({ tag: 'ontario-date-input', styleUrl: 'ontario-date-input.scss', shadow: true, formAssociated: true, }) export class OntarioDateInput { @Element() element: HTMLElement; @AttachInternals() internals: ElementInternals; /** * The language of the component. * This is used for translations, and is by default set through event listeners checking for a language property from the header. If none are passed, it will default to English. */ @Prop({ mutable: true }) language?: Language; /** * A boolean value to determine whether or not the date input is required. * * This is optional. If no prop is passed, it will default to `false`. */ @Prop() required?: boolean = false; /** * An object value used to set the placeholder text for the day, month and year input fields. Any combination of the three input fields (i.e day, month, year) * of the date component can be overridden. * * This is optional. If no prop is passed, it will not display any placeholder text. */ @Prop() placeholder?: DateInputPlaceholder | string; /** * The aggregate date value for the component. * * Accepts either a plain ISO date (`YYYY-MM-DD`) or a full ISO 8601 timestamp. When a valid value is provided, * the component hydrates the internal day, month, and year fields and normalizes the stored form value to a full * UTC ISO timestamp (`YYYY-MM-DDT00:00:00.000Z`). */ @Prop({ mutable: true }) value?: string; /** * The text to display as the input label * * @example * DateValidatorReturnType; /** * Emitted when an `input` event occurs within the component. */ @Event() inputOnInput: EventEmitter<{ value: string; fieldType: 'day' | 'month' | 'year'; }>; /** * Emitted when a `change` event occurs within the component. */ @Event() inputOnChange: EventEmitter<{ value: string; fieldType: 'day' | 'month' | 'year'; }>; /** * Emitted when a keyboard input event occurs when an input has lost focus. */ @Event() inputOnBlur: EventEmitter; /** * Emitted when a keyboard input event occurs when an input has gained focus. */ @Event() inputOnFocus: EventEmitter; /** * Emitted when an error message is reported to the component. */ @Event() inputErrorOccurred: EventEmitter<{ inputId: string; errorMessage: string }>; @Watch('errorMessage') broadcastInputErrorOccurredEvent() { // Emit event to notify anyone who wants to listen for errors occurring this.inputErrorOccurred.emit({ inputId: this.getId(), errorMessage: this.errorMessage ?? '' }); } /** * This listens for the `setAppLanguage` event sent from the test language toggler when it is is connected to the DOM. It is used for the initial language when the input component loads. */ @Listen('setAppLanguage', { target: 'window' }) handleSetAppLanguage(event: CustomEvent) { if (!this.language) { this.language = validateLanguage(event); } } /** * Handles an update to the language should the user request a language update from the language toggle. * @param {CustomEvent} - The language that has been selected. */ @Listen('headerLanguageToggled', { target: 'window' }) handleHeaderLanguageToggled(event: CustomEvent) { this.language = validateLanguage(event.detail.newLanguage); } @Listen('blur', { capture: true }) handleComponentBlur() { const { day, month, year, minYear, maxYear, dateValidator, dateOptionsState } = this; const { dayVisible, monthVisible, yearVisible } = getVisibleDateFields(dateOptionsState); // if user has not interacted with the component, skip validation if (!this.isDateTyped) { return; } const errorMessages = translations.dateInput.error[this.getLanguage()]; const { dayInvalid, monthInvalid, yearInvalid, errorMessage } = dateValidator ? dateValidator(day, month, year) : getDateErrorMessage({ dayValue: day, monthValue: month, yearValue: year, errorMessages, minYear, maxYear, dayVisible, monthVisible, yearVisible, }); this.dayInvalid = dayInvalid; this.monthInvalid = monthInvalid; this.yearInvalid = yearInvalid; this.errorMessage = errorMessage; } @State() private translations: Translations = translations; @State() private captionState: InputCaption; @State() private isDateTyped: boolean = false; @State() private dayInvalid: boolean = false; @State() private monthInvalid: boolean = false; @State() private yearInvalid: boolean = false; @State() private errorMessage: string | undefined; @State() private day: string = ''; @State() private month: string = ''; @State() private year: string = ''; @State() private placeholderState: DateInputPlaceholder; @State() private dateOptionsState: Array; private isSyncingValue = false; private lastCommittedValue = ''; /** * Watch for changes to the `caption` prop. * * The caption will be run through the InputCaption constructor to convert it to the correct format, and set the result to the `captionState` state. * @param newValue: Caption | string */ @Watch('caption') private updateCaptionState(newValue: Caption | string) { this.captionState = new InputCaption( this.element.tagName, newValue, translations, this.language, true, this.required, ); } /** * Watch for changes in the `language` prop to render either the English or French translations */ @Watch('language') updateLanguage() { this.updateCaptionState(this.caption); } @Watch('value') syncValueProp(newValue?: string) { if (this.isSyncingValue) { return; } if (typeof newValue !== 'undefined' && typeof newValue !== 'string') { this.logInvalidValueProp(newValue); return; } if (!newValue) { this.day = ''; this.month = ''; this.year = ''; this.isDateTyped = false; this.resetErrorState(); this.isSyncingValue = true; this.value = ''; this.isSyncingValue = false; if (typeof this.internals?.setFormValue === 'function') { this.internals.setFormValue(''); } this.lastCommittedValue = ''; return; } const parsedValue = this.parseDateValue(newValue); if (!parsedValue) { this.logInvalidValueProp(newValue); return; } this.day = parsedValue.day; this.month = parsedValue.month; this.year = parsedValue.year; this.isDateTyped = false; this.resetErrorState(); this.syncAggregateValue(parsedValue.normalizedValue); this.lastCommittedValue = this.value ?? ''; } private processPlaceholder() { this.parseOptions(this.placeholder); } private processDateOptions() { this.parseOptions(this.dateOptions); } private parseOptions(options: any) { const isString = typeof options === 'string'; if (!options) { return; } try { if (options === this.placeholder) { this.placeholderState = isString ? JSON.parse(options) : options; } else if (options === this.dateOptions) { this.dateOptionsState = isString ? JSON.parse(options) : options; } } catch (error) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addRegularText(' failed to parse props for ') .addMonospaceText('') .addRegularText(' in ') .addMonospaceText('parseOptions()') .addRegularText(' method \n ') .addMonospaceText(error.stack) .printMessage(ConsoleType.Error); } } private isInvalidDate = () => { return this.dayInvalid || this.monthInvalid || this.yearInvalid; }; private getNormalizedDateValue(year = this.year, month = this.month, day = this.day): string | undefined { if (!year || !month || !day) { return undefined; } const numericYear = Number(year); const numericMonth = Number(month); const numericDay = Number(day); const desiredDate = new Date(Date.UTC(numericYear, numericMonth - 1, numericDay, 0, 0, 0, 0)); if ( Number.isNaN(desiredDate.getTime()) || desiredDate.getUTCFullYear() !== numericYear || desiredDate.getUTCMonth() + 1 !== numericMonth || desiredDate.getUTCDate() !== numericDay ) { return undefined; } return desiredDate.toISOString(); } private parseDateValue(value: string): DateInputValueParts | undefined { const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T.*)?$/); if (!match) { return undefined; } const [, year, month, day] = match; const normalizedValue = this.getNormalizedDateValue(year, month, day); if (!normalizedValue) { return undefined; } return { year, month, day, normalizedValue }; } private logInvalidValueProp(value: unknown) { const message = new ConsoleMessageClass(); const formattedValue = typeof value === 'string' ? value : (() => { try { return JSON.stringify(value); } catch { return String(value); } })(); message .addDesignSystemTag() .addRegularText(' invalid ') .addMonospaceText(' value ') .addRegularText('on') .addMonospaceText(' ') .addRegularText('received') .addMonospaceText(` ${formattedValue} `) .addRegularText('Expected a plain ISO date (`YYYY-MM-DD`) or full ISO 8601 timestamp.') .printMessage(ConsoleType.Error); } private resetErrorState = () => { if (!this.isInvalidDate()) { return; } this.dayInvalid = false; this.monthInvalid = false; this.yearInvalid = false; this.errorMessage = ''; }; private updateDateState = (value: string, inputFieldType: DateInputFieldType) => { switch (inputFieldType) { case 'day': this.day = value; break; case 'month': this.month = value; break; case 'year': this.year = value; break; } }; private emitAggregateValueEvent(name: 'input' | 'change') { emitEvent(this.element, name, { value: this.value }); } private syncAggregateValue(normalizedValue?: string): boolean { const previousValue = this.value ?? ''; const nextValue = normalizedValue ?? ''; this.isSyncingValue = true; this.value = nextValue; this.isSyncingValue = false; if (typeof this.internals?.setFormValue === 'function') { this.internals.setFormValue(nextValue); } return previousValue !== nextValue; } private handleDateUpdates = (value: string, fieldType: DateInputFieldType): boolean => { // set boolean indicating user interaction with the component for validation if (!this.isDateTyped) { this.isDateTyped = true; } // reset error state when user starts typing this.resetErrorState(); // update date state this.updateDateState(value, fieldType); return this.syncAggregateValue(this.getNormalizedDateValue()); }; private handleDateInput = (value: string, fieldType: DateInputFieldType) => { const aggregateValueChanged = this.handleDateUpdates(value, fieldType); // emit date change event this.inputOnInput.emit({ value, fieldType }); if (aggregateValueChanged) { this.emitAggregateValueEvent('input'); } }; private handleDateChanged = (value: string, fieldType: DateInputFieldType) => { this.handleDateUpdates(value, fieldType); // emit date change event this.inputOnChange.emit({ value, fieldType }); if ((this.value ?? '') !== this.lastCommittedValue) { this.emitAggregateValueEvent('change'); this.lastCommittedValue = this.value ?? ''; } }; private handleDateFocus = (fieldType: DateInputFieldType) => { // emit date field focus event this.inputOnFocus.emit(fieldType); }; private handleDateBlur = (fieldType: DateInputFieldType) => { // emit date field focus event this.inputOnBlur.emit(fieldType); }; private getLanguage(): Language { return this.language ?? 'en'; } private getCaption(): Caption | string { const language = this.getLanguage(); const captionText = translations.dateInput.caption[language]; return this.caption ?? { captionText, captionType: 'default' }; } private getId(): string { return this.elementId ?? ''; } private getHintTextId(): string { return `date-input-hint-${this.getId()}`; } private getInputIds() { const id = this.getId(); const dayId = `day-${id}`; const monthId = `month-${id}`; const yearId = `year-${id}`; return { dayId, monthId, yearId }; } componentWillLoad() { this.processPlaceholder(); this.processDateOptions(); this.updateCaptionState(this.getCaption()); this.elementId = this.elementId ?? uuid(); this.language = validateLanguage(this.language) as Language; this.syncValueProp(this.value); } render() { const { dateOptionsState, required, translations, hintText, placeholderState } = this; const language = this.getLanguage(); const dateStrings = translations.dateInput; const placeholderText = placeholderState ?? {}; const { dayVisible, monthVisible, yearVisible } = getVisibleDateFields(dateOptionsState); const { dayId, monthId, yearId } = this.getInputIds(); const hintTextId = this.getHintTextId(); return (
{this.captionState.getCaption()} {!!hintText && (

{hintText}

)}
{yearVisible && ( )} {monthVisible && ( )} {dayVisible && ( )}
); } }