UNPKG

@ionic/core

Version:
407 lines (406 loc) • 16.9 kB
/*! * (C) Ionic http://ionicframework.com - MIT License */ import { Host, h } from "@stencil/core"; import { renderHiddenInput } from "../../utils/helpers"; import { getIonMode } from "../../global/ionic-global"; export class RadioGroup { constructor() { this.inputId = `ion-rg-${radioGroupIds++}`; this.helperTextId = `${this.inputId}-helper-text`; this.errorTextId = `${this.inputId}-error-text`; this.labelId = `${this.inputId}-lbl`; this.setRadioTabindex = (value) => { const radios = this.getRadios(); // Get the first radio that is not disabled and the checked one const first = radios.find((radio) => !radio.disabled); const checked = radios.find((radio) => radio.value === value && !radio.disabled); if (!first && !checked) { return; } // If an enabled checked radio exists, set it to be the focusable radio // otherwise we default to focus the first radio const focusable = checked || first; for (const radio of radios) { const tabindex = radio === focusable ? 0 : -1; radio.setButtonTabindex(tabindex); } }; this.onClick = (ev) => { ev.preventDefault(); /** * The Radio Group component mandates that only one radio button * within the group can be selected at any given time. Since `ion-radio` * is a shadow DOM component, it cannot natively perform this behavior * using the `name` attribute. */ const selectedRadio = ev.target && ev.target.closest('ion-radio'); /** * Our current disabled prop definition causes Stencil to mark it * as optional. While this is not desired, fixing this behavior * in Stencil is a significant breaking change, so this effort is * being de-risked in STENCIL-917. Until then, we compromise * here by checking for falsy `disabled` values instead of strictly * checking `disabled === false`. */ if (selectedRadio && !selectedRadio.disabled) { const currentValue = this.value; const newValue = selectedRadio.value; if (newValue !== currentValue) { this.value = newValue; this.emitValueChange(ev); } else if (this.allowEmptySelection) { this.value = undefined; this.emitValueChange(ev); } } }; this.allowEmptySelection = false; this.compareWith = undefined; this.name = this.inputId; this.value = undefined; this.helperText = undefined; this.errorText = undefined; } valueChanged(value) { this.setRadioTabindex(value); this.ionValueChange.emit({ value }); } componentDidLoad() { /** * There's an issue when assigning a value to the radio group * within the Angular primary content (rendering within the * app component template). When the template is isolated to a route, * the value is assigned correctly. * To address this issue, we need to ensure that the watcher is * called after the component has finished loading, * allowing the emit to be dispatched correctly. */ this.valueChanged(this.value); } async connectedCallback() { // Get the list header if it exists and set the id // this is used to set aria-labelledby const header = this.el.querySelector('ion-list-header') || this.el.querySelector('ion-item-divider'); if (header) { const label = (this.label = header.querySelector('ion-label')); if (label) { this.labelId = label.id = this.name + '-lbl'; } } } getRadios() { return Array.from(this.el.querySelectorAll('ion-radio')); } /** * Emits an `ionChange` event. * * This API should be called for user committed changes. * This API should not be used for external value changes. */ emitValueChange(event) { const { value } = this; this.ionChange.emit({ value, event }); } onKeydown(ev) { // We don't want the value to automatically change/emit when the radio group is part of a select interface // as this will cause the interface to close when navigating through the radio group options const inSelectInterface = !!this.el.closest('ion-select-popover') || !!this.el.closest('ion-select-modal'); if (ev.target && !this.el.contains(ev.target)) { return; } // Get all radios inside of the radio group and then // filter out disabled radios since we need to skip those const radios = this.getRadios().filter((radio) => !radio.disabled); // Only move the radio if the current focus is in the radio group if (ev.target && radios.includes(ev.target)) { const index = radios.findIndex((radio) => radio === ev.target); const current = radios[index]; let next; // If hitting arrow down or arrow right, move to the next radio // If we're on the last radio, move to the first radio if (['ArrowDown', 'ArrowRight'].includes(ev.key)) { next = index === radios.length - 1 ? radios[0] : radios[index + 1]; } // If hitting arrow up or arrow left, move to the previous radio // If we're on the first radio, move to the last radio if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) { next = index === 0 ? radios[radios.length - 1] : radios[index - 1]; } if (next && radios.includes(next)) { next.setFocus(ev); if (!inSelectInterface) { this.value = next.value; this.emitValueChange(ev); } } // Update the radio group value when a user presses the // space bar on top of a selected radio if ([' '].includes(ev.key)) { const previousValue = this.value; this.value = this.allowEmptySelection && this.value !== undefined ? undefined : current.value; if (previousValue !== this.value || this.allowEmptySelection) { /** * Value change should only be emitted if the value is different, * such as selecting a new radio with the space bar or if * the radio group allows for empty selection and the user * is deselecting a checked radio. */ this.emitValueChange(ev); } // Prevent browsers from jumping // to the bottom of the screen ev.preventDefault(); } } } /** @internal */ async setFocus() { const radioToFocus = this.getRadios().find((r) => r.tabIndex !== -1); radioToFocus === null || radioToFocus === void 0 ? void 0 : radioToFocus.setFocus(); } /** * Renders the helper text or error text values */ renderHintText() { const { helperText, errorText, helperTextId, errorTextId } = this; const hasHintText = !!helperText || !!errorText; if (!hasHintText) { return; } return (h("div", { class: "radio-group-top" }, h("div", { id: helperTextId, class: "helper-text" }, helperText), h("div", { id: errorTextId, class: "error-text" }, errorText))); } getHintTextID() { const { el, helperText, errorText, helperTextId, errorTextId } = this; if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { return errorTextId; } if (helperText) { return helperTextId; } return undefined; } render() { const { label, labelId, el, name, value } = this; const mode = getIonMode(this); renderHiddenInput(true, el, name, value, false); return (h(Host, { key: 'cac92777297029d7fd1b6af264d92850e35dfbba', role: "radiogroup", "aria-labelledby": label ? labelId : null, "aria-describedby": this.getHintTextID(), "aria-invalid": this.getHintTextID() === this.errorTextId, onClick: this.onClick, class: mode }, this.renderHintText(), h("div", { key: '6b5c634dba30d54eedc031b077863f3d6a9d9e9b', class: "radio-group-wrapper" }, h("slot", { key: '443edb3ff6f4c59d4c4324c8a19f2d6def47a322' })))); } static get is() { return "ion-radio-group"; } static get originalStyleUrls() { return { "ios": ["radio-group.ios.scss"], "md": ["radio-group.md.scss"] }; } static get styleUrls() { return { "ios": ["radio-group.ios.css"], "md": ["radio-group.md.css"] }; } static get properties() { return { "allowEmptySelection": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "If `true`, the radios can be deselected." }, "attribute": "allow-empty-selection", "reflect": false, "defaultValue": "false" }, "compareWith": { "type": "string", "mutable": false, "complexType": { "original": "string | RadioGroupCompareFn | null", "resolved": "((currentValue: any, compareValue: any) => boolean) | null | string | undefined", "references": { "RadioGroupCompareFn": { "location": "import", "path": "./radio-group-interface", "id": "src/components/radio-group/radio-group-interface.ts::RadioGroupCompareFn" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "This property allows developers to specify a custom function or property\nname for comparing objects when determining the selected option in the\nion-radio-group. When not specified, the default behavior will use strict\nequality (===) for comparison." }, "attribute": "compare-with", "reflect": false }, "name": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The name of the control, which is submitted with the form data." }, "attribute": "name", "reflect": false, "defaultValue": "this.inputId" }, "value": { "type": "any", "mutable": true, "complexType": { "original": "any | null", "resolved": "any", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "the value of the radio group." }, "attribute": "value", "reflect": false }, "helperText": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string | undefined", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The helper text to display at the top of the radio group." }, "attribute": "helper-text", "reflect": false }, "errorText": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string | undefined", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The error text to display at the top of the radio group." }, "attribute": "error-text", "reflect": false } }; } static get events() { return [{ "method": "ionChange", "name": "ionChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the value has changed.\n\nThis event will not emit when programmatically setting the `value` property." }, "complexType": { "original": "RadioGroupChangeEventDetail", "resolved": "RadioGroupChangeEventDetail<any>", "references": { "RadioGroupChangeEventDetail": { "location": "import", "path": "./radio-group-interface", "id": "src/components/radio-group/radio-group-interface.ts::RadioGroupChangeEventDetail" } } } }, { "method": "ionValueChange", "name": "ionValueChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Emitted when the `value` property has changed.\nThis is used to ensure that `ion-radio` can respond\nto any value property changes from the group." }, "complexType": { "original": "RadioGroupChangeEventDetail", "resolved": "RadioGroupChangeEventDetail<any>", "references": { "RadioGroupChangeEventDetail": { "location": "import", "path": "./radio-group-interface", "id": "src/components/radio-group/radio-group-interface.ts::RadioGroupChangeEventDetail" } } } }]; } static get methods() { return { "setFocus": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [{ "name": "internal", "text": undefined }] } } }; } static get elementRef() { return "el"; } static get watchers() { return [{ "propName": "value", "methodName": "valueChanged" }]; } static get listeners() { return [{ "name": "keydown", "method": "onKeydown", "target": "document", "capture": false, "passive": false }]; } } let radioGroupIds = 0;