import { Component, State, Element, h, Prop, Event, Listen, Watch, EventEmitter, AttachInternals } from '@stencil/core'; import { v4 as uuid } from 'uuid'; import { DropdownOption } from './dropdown-option.interface'; import { Dropdown } from './dropdown.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 { validateObjectExists, validatePropExists, validateLanguage, } from '../../utils/validation/validation-functions'; import { ConsoleMessageClass } from '../../utils/console-message/console-message'; import { hasMultipleTrueValues } from '../../utils/helper/utils'; import { getImageAssetSrcPath } from '../../utils/helper/assets'; import { Language } from '../../utils/common/language-types'; import { constructHintTextObject } from '../../utils/components/hints/hints'; import { InputFocusBlurEvent, EventType, InputInteractionEvent } from '../../utils/events/event-handler.interface'; import { handleInputEvent } from '../../utils/events/event-handler'; import { default as translations } from '../../translations/global.i18n.json'; import { ErrorMessage } from '../../utils/components/error-message/error-message'; import { HeaderLanguageToggleEventDetails } from '../../utils/events/common-events.interface'; /** * Ontario Dropdown List presents a selectable list of predefined options. * * 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/dropdown-lists.html * - https://designsystem.ontario.ca/developer-docs/components/ontario-dropdown-list/ * * Disabled/read-only policy source: * - https://designsystem.ontario.ca/components/detail/buttons.html#disabled-buttons */ @Component({ tag: 'ontario-dropdown-list', styleUrl: 'ontario-dropdown-list.scss', shadow: true, formAssociated: true, assetsDirs: ['./assets'], }) export class OntarioDropdownList implements Dropdown { @Element() element: HTMLElement; @AttachInternals() internals: ElementInternals; hintTextRef: HTMLOntarioHintTextElement | undefined; /** * The text to display for the dropdown list label. * * @example * * */ @Prop() caption: Caption | 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; /** * The name for the dropdown list. The name value is used to reference form data after a form is submitted. */ @Prop() name: string; /** * The ID for the dropdown list. * If no ID is provided, one will be generated. */ @Prop({ mutable: true }) elementId?: string; /** * The currently selected dropdown value. * * The component keeps the host `value` in sync as users interact with the dropdown. * If `value` is provided, it takes precedence over any `selected` flags passed through `options`. */ @Prop({ mutable: true }) value?: string; /** * The options for dropdown list. * * Each option will be passed in through an object in the options array. * This can either be passed in as an object directly (if using react), or as a string in HTML. * * In the example below, the options are being passed in as a string and there are three dropdown options displayed. * * @example * * */ @Prop() options: string | DropdownOption[]; /** * This is used to determine whether the dropdown list is required or not. * This prop also gets passed to the InputCaption utility to display either an optional or required flag in the label. * If no prop is set, it will default to false (optional). */ @Prop() required?: boolean = false; /** * This prop is used to determine whether or not the initial option displayed is empty. * If set to `true`, it will render the default “select” text. * If set to a string, it will render the string value. * * @example * * * or * * */ @Prop() isEmptyStartOption?: boolean | string = false; /** * Used to include the ontario-hint-text component for the dropdown list. * This is optional. */ @Prop() hintText?: string | Hint; /** * Used to include the ontario-hint-expander component for the dropdown list component. * This is passed in as an object with key-value pairs. * * This is optional. * * @example * * */ @Prop() hintExpander?: HintExpander | string; /** * Set this to display an error message */ @Prop({ mutable: true }) errorMessage?: string; /** * Used to add a custom function to the dropdown onChange event. */ @Prop() customOnChange?: (event: globalThis.Event) => void; /** * Used to add a custom function to the dropdown onBlur event. */ @Prop() customOnBlur?: (event: globalThis.Event) => void; /** * Used to add a custom function to the dropdown onFocus event. */ @Prop() customOnFocus?: (event: globalThis.Event) => void; /** * Used for the `aria-describedby` value of the dropdown list. This will match with the id of the hint text. */ @State() hintTextId: string | null | undefined; /** * Instantiate an InputCaption object for internal logic use */ @State() private captionState: InputCaption; /** * The options are re-assigned to the internalOptions array. */ @State() private internalOptions: DropdownOption[]; /** * 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; @State() translations: any = translations; /** * Emitted when a keyboard input or mouse event occurs when a dropdown list has been changed. */ @Event() dropdownOnChange: EventEmitter; /** * Emitted when a keyboard input event occurs when a dropdown list has lost focus. */ @Event() dropdownOnBlur: EventEmitter; /** * Emitted when a keyboard input event occurs when a dropdown list has gained focus. */ @Event() dropdownOnFocus: EventEmitter; /** * Emitted when an error message is reported to the component. */ @Event() inputErrorOccurred: EventEmitter<{ 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. */ @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); } /* * 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 in the `options` prop for validation purposes. * * Validate the `options` and make sure the `options` prop has a value. * Log a warning if user doesn't input a value for the `options`. */ @Watch('options') validateOptions(newValue: object) { if (validateObjectExists(newValue)) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' options ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('was not provided') .printMessage(); } } /** * Watch for changes to the `options` prop. * * If an `options` prop is passed, it will be parsed (if it is a string), and the result will be set to the `internalOptions` state. The result will be run through a validation function. */ @Watch('options') parseOptions() { if (typeof this.options !== 'undefined') { try { if (!Array.isArray(this.options)) { this.internalOptions = JSON.parse(this.options); } else { this.internalOptions = this.options; } } catch { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' options ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('was not provided in the correct format') .printMessage(); this.internalOptions = []; } } // Check selected status of options and set the selectedValue this.validateSelectedOption(this.internalOptions); this.syncValueFromOptions(); } /** * 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, false, this.required, ); } /** * 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 `language` prop to render either the English or French translations */ @Watch('language') updateLanguage() { this.updateCaptionState(this.caption); } /** * 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; try { if (hintExpander) { if (typeof hintExpander === 'string') this.internalHintExpander = JSON.parse(hintExpander); else this.internalHintExpander = hintExpander; } } catch { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' hintExpander ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('was not provided in the correct format') .printMessage(); } } @Watch('errorMessage') broadcastInputErrorOccurredEvent() { // Emit event to notify anyone who wants to listen for errors occurring this.inputErrorOccurred.emit({ errorMessage: this.errorMessage ?? '' }); } @Watch('value') syncValueFromValueProp() { if (this.internalOptions) { this.syncValueFromOptions(); } } /** * Function to handle dropdown list events and the information pertaining to the dropdown list to emit. */ private handleEvent(event: Event, eventType: EventType) { if (eventType === EventType.Change) { event.stopPropagation(); } const input = event.target as HTMLSelectElement | null; this.value = input?.value ?? ''; this.internals?.setFormValue?.(this.value ?? ''); handleInputEvent( event, eventType, input, this.dropdownOnChange, this.dropdownOnFocus, this.dropdownOnBlur, undefined, 'dropdown', this.customOnChange, this.customOnFocus, this.customOnBlur, undefined, undefined, ); if (eventType === EventType.Change) { this.emitHostValueEvent('change'); } } private handleInput(event: Event) { event.stopPropagation(); const input = event.target as HTMLSelectElement | null; this.value = input?.value ?? ''; this.internals?.setFormValue?.(this.value ?? ''); this.emitHostValueEvent('input'); } private emitHostValueEvent(name: 'input' | 'change') { this.element.dispatchEvent( new CustomEvent(name, { bubbles: true, composed: true, detail: { value: this.value ?? '', }, }), ); } public getId(): string { return this.elementId ?? ''; } /** * This function will set a selected key to `false` for each dropdown if no selected value is passed. * * It will also pass a warning to the user if multiple `true` selected values are passed. * * @param options * @returns options */ private validateSelectedOption(options: DropdownOption[]) { const selected = 'selected'; options.forEach((singleOption) => { // if no selected key is passed, set a default key to false if (!singleOption.hasOwnProperty(selected)) { singleOption.selected = false; } }); if (hasMultipleTrueValues(options, selected)) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' There are duplicate truthy `selected` values ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('options. Please update options so only one truthy `selected` value exists.') .printMessage(); } return options; } private syncValueFromOptions() { if (this.value === '' && this.hasEmptyStartOption()) { this.internals?.setFormValue?.(this.value ?? ''); return; } const hasMatchingValue = this.value && this.internalOptions?.some((option) => option.value === this.value); if (hasMatchingValue) { this.internals?.setFormValue?.(this.value ?? ''); return; } if (this.value) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' value ') .addRegularText('for') .addMonospaceText(' ') .addRegularText( 'did not match any option values. Falling back to the selected option, empty start option, or first available option.', ) .printMessage(); } const selectedOption = this.internalOptions?.find((option) => option.selected); const firstOption = this.internalOptions?.[0]; this.value = selectedOption?.value ?? (this.hasEmptyStartOption() ? '' : (firstOption?.value ?? '')); this.internals?.setFormValue?.(this.value ?? ''); } private isOptionSelected(option: DropdownOption) { return this.value ? option.value === this.value : !!option.selected; } private hasEmptyStartOption() { return ( this.isEmptyStartOption === true || this.isEmptyStartOption === 'true' || typeof this.isEmptyStartOption === 'string' ); } private getDropdownArrow() { return { backgroundImage: `url(${getImageAssetSrcPath('ontario-material-dropdown-arrow-48px.svg')})`, }; } private getClass(): string { return this.hintExpander ? `ontario-input ontario-dropdown ontario-dropdown-hint-expander--true` : `ontario-input ontario-dropdown`; } /** * If a `hintText` prop is passed, the id generated from it will be set to the internal `hintTextId` state to match with the select `aria-describedBy` attribute. */ async componentDidLoad() { this.hintTextId = await this.hintTextRef?.getHintTextId(); } componentWillLoad() { this.updateCaptionState(this.caption); this.parseOptions(); this.validateName(this.name); this.validateOptions(this.internalOptions); this.elementId = this.elementId ?? uuid(); this.parseHintText(); this.parseHintExpander(); this.language = validateLanguage(this.language); } render() { const error = !!this.errorMessage; return (
{this.captionState.getCaption(this.getId(), !!this.internalHintExpander)} {this.internalHintText && ( (this.hintTextRef = el)} > )} {this.internalHintExpander && ( )}
); } }