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 (
{this.captionState.getCaption(this.getId(), !!this.internalHintExpander)} {this.internalHintText && ( (this.hintTextRef = el)} > )} this.handleEvent(e, EventType.Input)} onChange={(e) => this.handleEvent(e, EventType.Change)} onBlur={(e) => this.handleEvent(e, EventType.Blur)} onFocus={(e) => this.handleEvent(e, EventType.Focus)} type={this.type} value={this.getValue()} required={!!this.required} > {this.internalHintExpander && ( )}
); } }