import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, h, forceUpdate, Build } from '@stencil/core'; import { checkInvalidState } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers'; import { createColorClasses, hostContext } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; import type { Color, Mode } from '../../interface'; import type { CheckboxChangeEventDetail } from './checkbox-interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot - The label text to associate with the checkbox. Use the "labelPlacement" property to control where the label is placed relative to the checkbox. * * @part container - The container for the checkbox mark. * @part label - The label text describing the checkbox. * @part mark - The checkmark used to indicate the checked state. * @part supporting-text - Supporting text displayed beneath the checkbox label. * @part helper-text - Supporting text displayed beneath the checkbox label when the checkbox is valid. * @part error-text - Supporting text displayed beneath the checkbox label when the checkbox is invalid and touched. */ @Component({ tag: 'ion-checkbox', styleUrls: { ios: 'checkbox.ios.scss', md: 'checkbox.md.scss', }, shadow: true, }) export class Checkbox implements ComponentInterface { private inputId = `ion-cb-${checkboxIds++}`; private inputLabelId = `${this.inputId}-lbl`; private helperTextId = `${this.inputId}-helper-text`; private errorTextId = `${this.inputId}-error-text`; private inheritedAttributes: Attributes = {}; private validationObserver?: MutationObserver; @Element() el!: HTMLIonCheckboxElement; /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. * For more information on colors, see [theming](/docs/theming/basics). */ @Prop({ reflect: true }) color?: Color; /** * The name of the control, which is submitted with the form data. */ @Prop() name: string = this.inputId; /** * If `true`, the checkbox is selected. */ @Prop({ mutable: true }) checked = false; /** * If `true`, the checkbox will visually appear as indeterminate. */ @Prop({ mutable: true }) indeterminate = false; /** * If `true`, the user cannot interact with the checkbox. */ @Prop() disabled = false; /** * Text that is placed under the checkbox label and displayed when an error is detected. */ @Prop() errorText?: string; /** * Text that is placed under the checkbox label and displayed when no error is detected. */ @Prop() helperText?: string; /** * The value of the checkbox does not mean if it's checked or not, use the `checked` * property for that. * * The value of a checkbox is analogous to the value of an ``, * it's only used when the checkbox participates in a native `
`. */ @Prop() value: any | null = 'on'; /** * Where to place the label relative to the checkbox. * `"start"`: The label will appear to the left of the checkbox in LTR and to the right in RTL. * `"end"`: The label will appear to the right of the checkbox in LTR and to the left in RTL. * `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). * `"stacked"`: The label will appear above the checkbox regardless of the direction. The alignment of the label can be controlled with the `alignment` property. */ @Prop() labelPlacement: 'start' | 'end' | 'fixed' | 'stacked' = 'start'; /** * How to pack the label and checkbox within a line. * `"start"`: The label and checkbox will appear on the left in LTR and * on the right in RTL. * `"end"`: The label and checkbox will appear on the right in LTR and * on the left in RTL. * `"space-between"`: The label and checkbox will appear on opposite * ends of the line with space between the two elements. * Setting this property will change the checkbox `display` to `block`. */ @Prop() justify?: 'start' | 'end' | 'space-between'; /** * How to control the alignment of the checkbox and label on the cross axis. * `"start"`: The label and control will appear on the left of the cross axis in LTR, and on the right side in RTL. * `"center"`: The label and control will appear at the center of the cross axis in both LTR and RTL. * Setting this property will change the checkbox `display` to `block`. */ @Prop() alignment?: 'start' | 'center'; /** * If true, screen readers will announce it as a required field. This property * works only for accessibility purposes, it will not prevent the form from * submitting if the value is invalid. */ @Prop() required = false; /** * Track validation state for proper aria-live announcements. */ @State() isInvalid = false; @State() private hintTextID?: string; /** * Emitted when the checked property has changed as a result of a user action such as a click. * * This event will not emit when programmatically setting the `checked` property. */ @Event() ionChange!: EventEmitter; /** * Emitted when the checkbox has focus. */ @Event() ionFocus!: EventEmitter; /** * Emitted when the checkbox loses focus. */ @Event() ionBlur!: EventEmitter; connectedCallback() { const { el } = this; // Watch for class changes to update validation state. if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { const newIsInvalid = checkInvalidState(el); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; /** * Screen readers tend to announce changes * to `aria-describedby` when the attribute * is changed during a blur event for a * native form control. * However, the announcement can be spotty * when using a non-native form control * and `forceUpdate()`. * This is due to `forceUpdate()` internally * rescheduling the DOM update to a lower * priority queue regardless if it's called * inside a Promise or not, thus causing * the screen reader to potentially miss the * change. * By using a State variable inside a Promise, * it guarantees a re-render immediately at * a higher priority. */ Promise.resolve().then(() => { this.hintTextID = this.getHintTextID(); }); } }); this.validationObserver.observe(el, { attributes: true, attributeFilter: ['class'], }); } // Always set initial state this.isInvalid = this.checkInvalidState(); } componentWillLoad() { this.inheritedAttributes = { ...inheritAriaAttributes(this.el), }; } disconnectedCallback() { // Clean up validation observer to prevent memory leaks. if (this.validationObserver) { this.validationObserver.disconnect(); this.validationObserver = undefined; } } /** @internal */ @Method() async setFocus() { this.el.focus(); } /** * Sets the checked property and emits * the ionChange event. Use this to update the * checked state in response to user-generated * actions such as a click. */ private setChecked = (state: boolean) => { const isChecked = (this.checked = state); this.ionChange.emit({ checked: isChecked, value: this.value, }); }; private toggleChecked = (ev: Event) => { ev.preventDefault(); this.setChecked(!this.checked); this.indeterminate = false; }; private onFocus = () => { this.ionFocus.emit(); }; private onBlur = () => { const newIsInvalid = this.checkInvalidState(); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; // Force a re-render to update aria-describedby immediately. forceUpdate(this); } this.ionBlur.emit(); }; private onKeyDown = (ev: KeyboardEvent) => { if (ev.key === ' ') { ev.preventDefault(); if (!this.disabled) { this.toggleChecked(ev); } } }; private onClick = (ev: MouseEvent) => { if (this.disabled) { return; } this.toggleChecked(ev); }; /** * Stops propagation when the display label is clicked, * otherwise, two clicks will be triggered. */ private onDivLabelClick = (ev: MouseEvent) => { ev.stopPropagation(); }; private getHintTextID(): string | undefined { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; if (isInvalid && errorText) { return errorTextId; } if (helperText) { return helperTextId; } return undefined; } /** * Responsible for rendering helper text and error text. * This element should only be rendered if hint text is set. */ private renderHintText() { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; /** * undefined and empty string values should * be treated as not having helper/error text. */ const hasHintText = !!helperText || !!errorText; if (!hasHintText) { return; } return (
{!isInvalid ? helperText : null}
); } /** * Checks if the input is in an invalid state based on Ionic validation classes */ private checkInvalidState(): boolean { const hasIonTouched = this.el.classList.contains('ion-touched'); const hasIonInvalid = this.el.classList.contains('ion-invalid'); return hasIonTouched && hasIonInvalid; } render() { const { color, checked, disabled, el, getSVGPath, indeterminate, inheritedAttributes, inputId, justify, labelPlacement, name, value, alignment, required, } = this; const mode = getIonMode(this); const path = getSVGPath(mode, indeterminate); const hasLabelContent = el.textContent !== ''; renderHiddenInput(true, el, name, checked ? value : '', disabled); // The host element must have a checkbox role to ensure proper VoiceOver // support in Safari for accessibility. return ( ); } private getSVGPath(mode: Mode, indeterminate: boolean): HTMLElement { let path = indeterminate ? ( ) : ( ); if (mode === 'md') { path = indeterminate ? ( ) : ( ); } return path; } } let checkboxIds = 0;