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 (