import { Component, Event, h, Prop, State, Listen, Element, Watch, EventEmitter, AttachInternals } from '@stencil/core';
import { v4 as uuid } from 'uuid';
import { Input } from '../../utils/common/input/input';
import { TextInput } from './input.interface';
import { HintExpander } from '../ontario-hint-expander/hint-expander.interface';
import { Hint } from '../../utils/common/common.interface';
import { InputCaption } from '../../utils/common/input-caption/input-caption';
import { Caption } from '../../utils/common/input-caption/caption.interface';
import { Language } from '../../utils/common/language-types';
import { validateLanguage, validatePropExists } from '../../utils/validation/validation-functions';
import { translations as globalTranslations, Translations } from '../../translations';
import { constructHintTextObject } from '../../utils/components/hints/hints';
import {
InputFocusBlurEvent,
EventType,
InputInteractionEvent,
InputInputEvent,
} from '../../utils/events/event-handler.interface';
import { handleInputEvent } from '../../utils/events/event-handler';
import { ConsoleMessageClass } from '../../utils/console-message/console-message';
import { ErrorMessage } from '../../utils/components/error-message/error-message';
import { HeaderLanguageToggleEventDetails } from '../../utils/events/common-events.interface';
/**
* Ontario Input captures single-line text input.
*
* 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/text-inputs.html
* - https://designsystem.ontario.ca/developer-docs/components/ontario-input/
*
* Disabled/read-only policy source:
* - https://designsystem.ontario.ca/components/detail/buttons.html#disabled-buttons
*/
@Component({
tag: 'ontario-input',
styleUrl: 'ontario-input.scss',
shadow: true,
formAssociated: true,
})
export class OntarioInput implements TextInput {
@Element() element: HTMLElement;
@AttachInternals() internals: ElementInternals;
hintTextRef: HTMLOntarioHintTextElement | undefined;
/**
* The text to display as the input label
*
* @example
*
*/
@Prop() required?: boolean = false;
/**
* The input type value.
*
* If no `type` is provided, it will default to 'text'.
*/
@Prop({ mutable: true }) type: 'text' | 'tel' | 'email' | 'password' = 'text';
/**
* The input content value.
*
* This is optional.
*/
@Prop({ mutable: true }) value?: string;
/**
* Set this to display an error message.
*/
@Prop({ mutable: true }) errorMessage?: string;
/**
* 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 no language is passed, it will default to English.
*/
@Prop({ mutable: true }) language?: Language = 'en';
/**
* Used to include the ontario-hint-expander component for the input component.
* This is passed in as an object with key-value pairs.
*
* This is optional.
*
* @example
*
*
*/
@Prop() hintExpander?: HintExpander | string;
/**
* Used for the `aria-describedby` value of the input. This will match with the id of the hint text.
*/
@State() hintTextId: string | undefined;
/**
* Enable live validation on the input. Custom live validation can be performed using an `inputValidator`
* validation function. It will also validate the `required` state if no errors are returned from
* the `inputValidator`. Please set a `requiredValidationMessage` to report concisely to the end user what
* they are required to set.
*/
@Prop() enableLiveValidation: boolean = false;
/**
* Validate the validity of the input value `onBlur`. This `async` function should return a result
* to trigger an error message. Returning `undefined` or `null` will clear it.
*/
@Prop() inputValidator?: (value?: string) => Promise<{ errorMessage?: string } | null | undefined>;
/**
* Used to add a custom function to the input onInput event.
*/
@Prop() customOnInput?: (event: globalThis.Event) => void;
/**
* Used to add a custom function to the input onChange event.
*/
@Prop() customOnChange?: (event: globalThis.Event) => void;
/**
* Used to add a custom function to the input onBlur event.
*/
@Prop() customOnBlur?: (event: globalThis.Event) => void;
/**
* Used to add a custom function to the input onFocus event.
*/
@Prop() customOnFocus?: (event: globalThis.Event) => void;
/**
* Custom error message to display if a required field is not filled out. _Please add a
* custom message when setting an input as required_.
*/
@Prop() requiredValidationMessage: string;
/**
* The hint text options are re-assigned to the internalHintText array.
*/
@State() private internalHintText: Hint;
/**
* The hint expander options are re-assigned to the internalHintExpander array.
*/
@State() private internalHintExpander: HintExpander;
/**
* Instantiate an InputCaption object for internal logic use
*/
@State() private captionState: InputCaption;
/**
* Track if the input has been interacted with, used to validate if
* a `required` field is in error.
*/
@State() private hasBeenInteractedWith: boolean = false;
/**
* Global translations for accessing built-in translations
*/
@State() private translations: Translations = globalTranslations;
/**
* Emitted when a input occurs when an input has been changed.
*/
@Event() inputOnInput: EventEmitter;
/**
* Emitted when a keyboard input or mouse event occurs when an input has been changed.
*/
@Event() inputOnChange: EventEmitter;
/**
* 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 }>;
/**
* 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.
* @param event The language that has been selected.
*/
@Listen('setAppLanguage', { target: 'window' })
handleSetAppLanguage(event: CustomEvent) {
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);
}
/**
* Handle the change in the `value` property and validate if the input has been interacted with by
* the user to aid in determining if the required state should produce an error.
*/
@Watch('value')
handleValueChange() {
this.hasBeenInteractedWith = this.hasBeenInteractedWith || !!this.value;
this.internals?.setFormValue?.(this.value ?? '');
}
/*
* Watch for changes in the `name` prop for validation purposes.
*
* Validate the `name` and make sure the `name` prop has a value.
* Log a warning if user doesn't input a value for the `name`.
*/
@Watch('name')
validateName(newValue: string) {
if (validatePropExists(newValue)) {
const message = new ConsoleMessageClass();
message
.addDesignSystemTag()
.addMonospaceText(' name ')
.addRegularText('for')
.addMonospaceText(' ')
.addRegularText('was not provided')
.printMessage();
}
}
/**
* Watch for changes to the `hintText` prop.
*
* If a `hintText` prop is passed, the `constructHintTextObject` function will convert it to the correct format, and set the result to the `internalHintText` state.
*/
@Watch('hintText')
private parseHintText() {
if (this.hintText) {
const hintTextObject = constructHintTextObject(this.hintText);
this.internalHintText = hintTextObject;
}
}
/**
* Watch for changes to the `hintExpander` prop.
*
* If a `hintExpander` prop is passed, it will be parsed (if it is a string), and the result will be set to the `internalHintExpander` state.
*/
@Watch('hintExpander')
private parseHintExpander() {
const hintExpander = this.hintExpander;
if (hintExpander) {
if (typeof hintExpander === 'string') this.internalHintExpander = JSON.parse(hintExpander);
else this.internalHintExpander = hintExpander;
}
}
/**
* 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,
this.translations,
this.language,
false,
this.required,
);
}
/**
* Watch for changes in the `language` prop to render either the English or French translations
*/
@Watch('language')
updateLanguage() {
this.updateCaptionState(this.caption);
}
/**
* Handle the component being blurred and perform validation logic on the input. Custom validation
* takes persistance, followed by validating the required state.
*
* Finally, an event is emitted to notify anything listening for the `inputErrorOccurred` that
* an error occurred.
*/
@Listen('blur', { capture: true })
async handleComponentBlur() {
if (this.enableLiveValidation) {
// Call inputValidator function to perform custom validation
const validationResult = this.inputValidator && this.inputValidator(this.value);
await validationResult?.then((x) => (this.errorMessage = x?.errorMessage));
// Validate the `required` field
// Only report a required error if no other error message is reported via validation
if (this.required && this.hasBeenInteractedWith && !validationResult)
if (!this.value)
this.errorMessage =
this.requiredValidationMessage || this.translations.input.requiredFieldError[this.getComponentLanguage()];
else this.errorMessage = undefined;
}
}
@Watch('errorMessage')
broadcastInputErrorOccurredEvent() {
// Emit event to notify anyone who wants to listen for errors occurring
this.inputErrorOccurred.emit({ inputId: this.getId(), errorMessage: this.errorMessage ?? '' });
}
/**
* Function to handle input events and the information pertaining to the input to emit.
*/
private handleEvent(event: globalThis.Event, eventType: EventType) {
const input = event.target as HTMLInputElement | null;
// Update the component value to match the value of the input element.
this.value = input?.value;
this.internals?.setFormValue?.(this.value ?? '');
handleInputEvent(
event,
eventType,
input,
this.inputOnChange,
this.inputOnFocus,
this.inputOnBlur,
this.inputOnInput,
'input',
this.customOnChange,
this.customOnFocus,
this.customOnBlur,
this.customOnInput,
this.element,
);
}
public getId(): string {
// A UUID is assigned in `componentWillLoad` if there is no given `elementId`.
return this.elementId ?? '';
}
private getValue(): string | number {
return this.value ?? '';
}
private getClass(): string {
if (this.hintExpander) {
return this.inputWidth === 'default'
? `ontario-input ontario-input-hint-expander--true`
: `ontario-input ontario-input--${this.inputWidth} ontario-input-hint-expander--true`;
} else {
return this.inputWidth === 'default' ? `ontario-input` : `ontario-input ontario-input--${this.inputWidth}`;
}
}
private getComponentLanguage() {
return this.language ?? 'en';
}
/**
* If a `hintText` prop is passed, the id generated from it will be set to the internal `hintTextId` state to match with the input `aria-describedBy` attribute.
*/
async componentDidLoad() {
this.hintTextId = await this.hintTextRef?.getHintTextId();
}
componentWillLoad() {
this.updateCaptionState(this.caption);
this.elementId = this.elementId ?? uuid();
this.parseHintText();
this.parseHintExpander();
this.validateName(this.name);
this.language = validateLanguage(this.language);
}
render() {
const error = !!this.errorMessage;
return (