import { Component, h, Prop, Element, Event, EventEmitter, Listen, State, Watch, AttachInternals } from '@stencil/core'; import { Input } from '../../utils/common/input/input'; import { CheckboxOption } from './checkbox-option.interface'; import { Checkboxes } from './checkboxes.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 { ConsoleType } from '../../utils/console-message/console-message.enum'; import { Language } from '../../utils/common/language-types'; import { constructHintTextObject } from '../../utils/components/hints/hints'; import { InputFocusBlurEvent, RadioAndCheckboxChangeEvent, EventType, } 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 Checkboxes collects one or more selections from a defined option set. * * This component intentionally does not expose group-level `readOnly` or `disabled` props. * * To support accessible and understandable form completion: * - keep options and submission actions available * - use validation and error messaging to guide corrections * * For component guidance, see: * - https://designsystem.ontario.ca/components/detail/checkboxes.html * - https://designsystem.ontario.ca/developer-docs/components/ontario-checkboxes/ * * Disabled/read-only policy source: * - https://designsystem.ontario.ca/components/detail/buttons.html#disabled-buttons */ @Component({ tag: 'ontario-checkboxes', styleUrl: 'ontario-checkboxes.scss', shadow: true, formAssociated: true, }) export class OntarioCheckboxes implements Checkboxes { @Element() element: HTMLElement; @AttachInternals() internals: ElementInternals; hintTextRef: HTMLOntarioHintTextElement | undefined; private isSyncingValue = false; /** * The text to display for the checkbox legend. * * @example * * */ @Prop() hintExpander?: HintExpander | string; /** * The currently selected checkbox option values. * * The component keeps the host `value` in sync as users interact with the checkbox group. * If `value` is provided, it takes precedence over any `checked` flags passed through `options`. * * In HTML, pass `value` as a JSON string array. * * @example * * */ @Prop({ mutable: true }) value?: string[] | string; /** * The options for the checkbox group. * * Each property 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. * If there are multiple checkboxes in a fieldset, each checkbox will be displayed as an option. * * In the example below, the options are being passed in as a string and there are two checkboxes to be displayed in the fieldset. * * @example * * */ @Prop() options: CheckboxOption[] | string; /** * This is used to determine whether the checkbox 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; /** * Set this to display an error message */ @Prop({ mutable: true }) errorMessage?: string; /** * Used to add a custom function to the checkbox onChange event. */ @Prop() customOnChange?: (event: globalThis.Event) => void; /** * Used to add a custom function to the checkbox onBlur event. */ @Prop() customOnBlur?: (event: globalThis.Event) => void; /** * Used to add a custom function to the checkbox onFocus event. */ @Prop() customOnFocus?: (event: globalThis.Event) => void; /** * Used for the `aria-describedby` value of the checkbox fieldset. 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 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; /** * The options are re-assigned to the internalOptions array. */ @State() private internalOptions: CheckboxOption[]; /** * Emitted when a keyboard input or mouse event occurs when a checkbox option has been changed. */ @Event() checkboxOnChange: EventEmitter; /** * Emitted when a keyboard input event occurs when a checkbox option has lost focus. */ @Event() checkboxOnBlur: EventEmitter; /** * Emitted when a keyboard input event occurs when a checkbox option has gained focus. */ @Event() checkboxOnFocus: 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 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. * Includes error handling for invalid JSON strings. */ @Watch('hintExpander') private parseHintExpander() { const hintExpander = this.hintExpander; if (hintExpander) { if (typeof hintExpander === 'string') { try { this.internalHintExpander = JSON.parse(hintExpander); } catch { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' hintExpander ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('could not be parsed from a string. Please ensure it is valid JSON.') .printMessage(); } } else { this.internalHintExpander = hintExpander; } } } /** * 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. * Includes error handling for invalid JSON strings */ @Watch('options') parseOptions() { if (typeof this.options !== 'undefined') { try { this.internalOptions = Array.isArray(this.options) ? this.options : JSON.parse(this.options); this.validateOptions(this.internalOptions); } catch { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' options ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('could not be parsed. Please ensure it is valid JSON.') .printMessage(); } } this.syncValueFromOptions(); } @Watch('value') syncValueFromValueProp() { if (!this.isSyncingValue && this.internalOptions) { this.syncValueFromOptions(); } } /** * 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`. * * @param newValue object to be validated */ 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 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 `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') updateCaptionState(newValue: Caption | string) { this.captionState = new InputCaption( this.element.tagName, newValue, translations, this.language, true, this.required, ); } /** * Watch for changes to the `language` prop to render either the English or French translations */ @Watch('language') updateLanguage() { this.updateCaptionState(this.caption); } @Watch('errorMessage') broadcastInputErrorOccurredEvent() { // Emit event to notify anyone who wants to listen for errors occurring this.inputErrorOccurred.emit({ errorMessage: this.errorMessage ?? '' }); } /** * Function to handle checkbox events and the information pertaining to the checkbox to emit. */ private handleEvent(event: globalThis.Event, eventType: EventType) { if (eventType === EventType.Change) { event.stopPropagation(); } const input = event.target as HTMLInputElement | null; if (input) { input.checked = input.checked ?? ''; } // Update internalOptions checked state this.internalOptions = this.internalOptions.map((option) => ({ ...option, checked: option.value === input?.value ? !!input?.checked : !!option.checked, })); this.updateHostValue(this.getSelectedValues()); // Set the value within the form this.setFormValue(); handleInputEvent( event, eventType, input, this.checkboxOnChange, this.checkboxOnFocus, this.checkboxOnBlur, undefined, 'checkbox', this.customOnChange, this.customOnFocus, this.customOnBlur, undefined, undefined, ); if (eventType === EventType.Change) { this.emitHostValueEvent('change'); } } private emitHostValueEvent(name: 'change') { this.element.dispatchEvent( new CustomEvent(name, { bubbles: true, composed: true, detail: { value: this.value ?? [], }, }), ); } private updateHostValue(nextValue: string[]) { this.isSyncingValue = true; this.value = nextValue; this.isSyncingValue = false; } private setFormValue() { const selectedValues = this.getSelectedValues(); const formData = selectedValues.reduce((data, currentValue) => { data.append(this.name, currentValue); return data; }, new FormData()); this.internals?.setFormValue?.(selectedValues.length ? formData : null); } private getSelectedValues() { return this.internalOptions?.filter((option) => !!option.checked).map((option) => option.value) ?? []; } private normalizeValue() { if (Array.isArray(this.value)) { return this.value.filter((entry): entry is string => typeof entry === 'string'); } if (typeof this.value === 'undefined') { return undefined; } if (this.value === '') { return []; } try { const parsedValue = JSON.parse(this.value); if (Array.isArray(parsedValue) && parsedValue.every((entry) => typeof entry === 'string')) { return parsedValue; } } catch (error) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' value ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('was not provided in a valid format. Expected a string array or JSON string array.') .addMonospaceText(error instanceof Error ? (error.stack ?? error.message) : '') .printMessage(ConsoleType.Error); return []; } const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' value ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('was not provided in a valid format. Expected a string array or JSON string array.') .printMessage(); return []; } private syncValueFromOptions() { const requestedValues = this.normalizeValue(); const optionValues = new Set(this.internalOptions?.map((option) => option.value) ?? []); if (typeof requestedValues !== 'undefined') { const selectedValues = requestedValues.filter((requestedValue) => optionValues.has(requestedValue)); if (selectedValues.length !== requestedValues.length) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addMonospaceText(' value ') .addRegularText('for') .addMonospaceText(' ') .addRegularText('included values that did not match any option values. Those values were ignored.') .printMessage(); } this.internalOptions = this.internalOptions?.map((option) => ({ ...option, checked: selectedValues.includes(option.value), })); this.updateHostValue(selectedValues); this.setFormValue(); return; } this.updateHostValue(this.getSelectedValues()); this.setFormValue(); } /** * If a `hintText` prop is passed, the id generated from it will be set to the internal `hintTextId` state to match with the fieldset `aria-describedBy` attribute. */ async componentDidLoad() { this.hintTextId = await this.hintTextRef?.getHintTextId(); } componentWillLoad() { this.updateCaptionState(this.caption); this.parseOptions(); this.parseHintExpander(); this.parseHintText(); this.validateName(this.name); this.language = validateLanguage(this.language); } render() { const error = !!this.errorMessage; return (
{this.captionState.getCaption(undefined, !!this.internalHintExpander)} {this.internalHintText && ( (this.hintTextRef = el)} > )}
{this.internalOptions?.map((checkbox) => (
this.handleEvent(e, EventType.Change)} onBlur={(e) => this.handleEvent(e, EventType.Blur)} onFocus={(e) => this.handleEvent(e, EventType.Focus)} > {checkbox.hintExpander && (
)}
))} {this.internalHintExpander && (
)}
); } }