Files
2025-10-30 16:17:34 -07:00

433 lines
13 KiB
TypeScript

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 `<input type="checkbox">`,
* it's only used when the checkbox participates in a native `<form>`.
*/
@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<CheckboxChangeEventDetail>;
/**
* Emitted when the checkbox has focus.
*/
@Event() ionFocus!: EventEmitter<void>;
/**
* Emitted when the checkbox loses focus.
*/
@Event() ionBlur!: EventEmitter<void>;
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 (
<div class="checkbox-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
}
/**
* 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 (
<Host
role="checkbox"
aria-checked={indeterminate ? 'mixed' : `${checked}`}
aria-describedby={this.hintTextID}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
aria-label={inheritedAttributes['aria-label'] || null}
aria-disabled={disabled ? 'true' : null}
aria-required={required ? 'true' : undefined}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
onClick={this.onClick}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
'checkbox-checked': checked,
'checkbox-disabled': disabled,
'checkbox-indeterminate': indeterminate,
interactive: true,
[`checkbox-justify-${justify}`]: justify !== undefined,
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
[`checkbox-label-placement-${labelPlacement}`]: true,
})}
>
<label class="checkbox-wrapper" htmlFor={inputId}>
{/*
The native control must be rendered
before the visible label text due to https://bugs.webkit.org/show_bug.cgi?id=251951
*/}
<input
type="checkbox"
checked={checked ? true : undefined}
disabled={disabled}
id={inputId}
onChange={this.toggleChecked}
required={required}
{...inheritedAttributes}
/>
<div
class={{
'label-text-wrapper': true,
'label-text-wrapper-hidden': !hasLabelContent,
}}
part="label"
id={this.inputLabelId}
onClick={this.onDivLabelClick}
>
<slot></slot>
{this.renderHintText()}
</div>
<div class="native-wrapper">
<svg class="checkbox-icon" viewBox="0 0 24 24" part="container" aria-hidden="true">
{path}
</svg>
</div>
</label>
</Host>
);
}
private getSVGPath(mode: Mode, indeterminate: boolean): HTMLElement {
let path = indeterminate ? (
<path d="M6 12L18 12" part="mark" />
) : (
<path d="M5.9,12.5l3.8,3.8l8.8-8.8" part="mark" />
);
if (mode === 'md') {
path = indeterminate ? (
<path d="M2 12H22" part="mark" />
) : (
<path d="M1.73,12.91 8.1,19.28 22.79,4.59" part="mark" />
);
}
return path;
}
}
let checkboxIds = 0;