diff --git a/core/api.txt b/core/api.txt index d54e7a6f408..a5619099b68 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1033,6 +1033,7 @@ ion-radio,part,mark ion-radio-group,none ion-radio-group,prop,allowEmptySelection,boolean,false,false,false +ion-radio-group,prop,compareWith,((currentValue: any, compareValue: any) => boolean) | null | string | undefined,undefined,false,false ion-radio-group,prop,name,string,this.inputId,false,false ion-radio-group,prop,value,any,undefined,false,false ion-radio-group,event,ionChange,RadioGroupChangeEventDetail,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 82e7dd5a3cd..791fbfc9b9e 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -27,7 +27,7 @@ import { PickerButton, PickerColumn } from "./components/picker/picker-interface import { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces"; import { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces"; import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; -import { RadioGroupChangeEventDetail } from "./components/radio-group/radio-group-interface"; +import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; import { RefresherEventDetail } from "./components/refresher/refresher-interface"; import { ItemReorderEventDetail } from "./components/reorder-group/reorder-group-interface"; @@ -63,7 +63,7 @@ export { PickerButton, PickerColumn } from "./components/picker/picker-interface export { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces"; export { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces"; export { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; -export { RadioGroupChangeEventDetail } from "./components/radio-group/radio-group-interface"; +export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; export { RefresherEventDetail } from "./components/refresher/refresher-interface"; export { ItemReorderEventDetail } from "./components/reorder-group/reorder-group-interface"; @@ -2265,6 +2265,10 @@ export namespace Components { * If `true`, the radios can be deselected. */ "allowEmptySelection": boolean; + /** + * This property allows developers to specify a custom function or property name for comparing objects when determining the selected option in the ion-radio-group. When not specified, the default behavior will use strict equality (===) for comparison. + */ + "compareWith"?: string | RadioGroupCompareFn | null; /** * The name of the control, which is submitted with the form data. */ @@ -2684,7 +2688,7 @@ export namespace Components { */ "color"?: Color; /** - * A property name or function used to compare object values + * This property allows developers to specify a custom function or property name for comparing objects when determining the selected option in the ion-select. When not specified, the default behavior will use strict equality (===) for comparison. */ "compareWith"?: string | SelectCompareFn | null; /** @@ -6941,6 +6945,10 @@ declare namespace LocalJSX { * If `true`, the radios can be deselected. */ "allowEmptySelection"?: boolean; + /** + * This property allows developers to specify a custom function or property name for comparing objects when determining the selected option in the ion-radio-group. When not specified, the default behavior will use strict equality (===) for comparison. + */ + "compareWith"?: string | RadioGroupCompareFn | null; /** * The name of the control, which is submitted with the form data. */ @@ -7423,7 +7431,7 @@ declare namespace LocalJSX { */ "color"?: Color; /** - * A property name or function used to compare object values + * This property allows developers to specify a custom function or property name for comparing objects when determining the selected option in the ion-select. When not specified, the default behavior will use strict equality (===) for comparison. */ "compareWith"?: string | SelectCompareFn | null; /** diff --git a/core/src/components/radio-group/radio-group-interface.ts b/core/src/components/radio-group/radio-group-interface.ts index 3aea4602885..993c0840625 100644 --- a/core/src/components/radio-group/radio-group-interface.ts +++ b/core/src/components/radio-group/radio-group-interface.ts @@ -7,3 +7,5 @@ export interface RadioGroupCustomEvent extends CustomEvent { detail: RadioGroupChangeEventDetail; target: HTMLIonRadioGroupElement; } + +export type RadioGroupCompareFn = (currentValue: any, compareValue: any) => boolean; diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index 9a61befae3b..2bf0dd5a11a 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -4,7 +4,7 @@ import { renderHiddenInput } from '@utils/helpers'; import { getIonMode } from '../../global/ionic-global'; -import type { RadioGroupChangeEventDetail } from './radio-group-interface'; +import type { RadioGroupChangeEventDetail, RadioGroupCompareFn } from './radio-group-interface'; @Component({ tag: 'ion-radio-group', @@ -21,6 +21,14 @@ export class RadioGroup implements ComponentInterface { */ @Prop() allowEmptySelection = false; + /** + * This property allows developers to specify a custom function or property + * name for comparing objects when determining the selected option in the + * ion-radio-group. When not specified, the default behavior will use strict + * equality (===) for comparison. + */ + @Prop() compareWith?: string | RadioGroupCompareFn | null; + /** * The name of the control, which is submitted with the form data. */ diff --git a/core/src/components/radio-group/test/compare-with/index.html b/core/src/components/radio-group/test/compare-with/index.html new file mode 100644 index 00000000000..71a019c1ad5 --- /dev/null +++ b/core/src/components/radio-group/test/compare-with/index.html @@ -0,0 +1,74 @@ + + + + + Radio Group - compareWith + + + + + + + + + + + + + Radio Group - compareWith + + + + + + Compare with String + + + + + Compare with Function + + + + + + + + diff --git a/core/src/components/radio-group/test/radio-group.spec.tsx b/core/src/components/radio-group/test/radio-group.spec.tsx new file mode 100644 index 00000000000..fdd6c644267 --- /dev/null +++ b/core/src/components/radio-group/test/radio-group.spec.tsx @@ -0,0 +1,111 @@ +import { h } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { Radio } from '../../radio/radio'; +import { RadioGroup } from '../radio-group'; + +describe('ion-radio-group', () => { + it('should correctly set value when using compareWith string', async () => { + const page = await newSpecPage({ + components: [Radio, RadioGroup], + template: () => ( + + Red + Blue + Green + + ), + }); + + const radioGroup = page.body.querySelector('ion-radio-group')!; + const radios = document.querySelectorAll('ion-radio')!; + + await radios[2].click(); + await page.waitForChanges(); + + expect(radios[2].getAttribute('aria-checked')).toBe('true'); + expect(radioGroup.value).toEqual({ + label: 'Green', + value: 'green', + }); + }); + + it('should correctly set value when using compareWith function', async () => { + const page = await newSpecPage({ + components: [Radio, RadioGroup], + template: () => ( + + Red + Blue + Green + + ), + }); + + const radioGroup = page.body.querySelector('ion-radio-group')!; + const radios = document.querySelectorAll('ion-radio')!; + radioGroup.compareWith = (a, b) => a.value === b.value; + + await radios[2].click(); + await page.waitForChanges(); + + expect(radios[2].getAttribute('aria-checked')).toBe('true'); + expect(radioGroup.value).toEqual({ + label: 'Green', + value: 'green', + }); + }); + + it('should correctly set value when using compareWith null', async () => { + const page = await newSpecPage({ + components: [RadioGroup, Radio], + template: () => ( + + Red +
+ Blue +
+ Green +
+ ), + }); + + const radioGroup = page.body.querySelector('ion-radio-group')!; + const radios = document.querySelectorAll('ion-radio')!; + + await radios[2].click(); + await page.waitForChanges(); + + expect(radios[2].getAttribute('aria-checked')).toBe('true'); + expect(radioGroup.value).toEqual('green'); + }); + + it('should work with different parameter types', async () => { + const page = await newSpecPage({ + components: [Radio, RadioGroup], + template: () => ( + + Option #1 + Option #2 + Option #3 + + ), + }); + + const radioGroup = page.body.querySelector('ion-radio-group')!; + radioGroup.compareWith = (val1, val2) => { + // convert val1 to a number + return +val1 === val2; + }; + + const radios = document.querySelectorAll('ion-radio')!; + + await expect(radios[1].getAttribute('aria-checked')).toBe('true'); + + await radios[2].click(); + await page.waitForChanges(); + + expect(radios[2].getAttribute('aria-checked')).toBe('true'); + expect(radioGroup.value).toEqual(3); + }); +}); diff --git a/core/src/components/radio/radio.tsx b/core/src/components/radio/radio.tsx index 012dd61727f..343d7d9e30b 100644 --- a/core/src/components/radio/radio.tsx +++ b/core/src/components/radio/radio.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import type { LegacyFormController } from '@utils/forms'; -import { createLegacyFormController } from '@utils/forms'; +import { createLegacyFormController, isOptionSelected } from '@utils/forms'; import { addEventListener, getAriaLabel, removeEventListener } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -196,7 +196,9 @@ export class Radio implements ComponentInterface { private updateState = () => { if (this.radioGroup) { - this.checked = this.radioGroup.value === this.value; + const { compareWith, value: radioGroupValue } = this.radioGroup; + + this.checked = isOptionSelected(radioGroupValue, this.value, compareWith); } }; diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index df8a00ae182..6eaeb551486 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core'; import type { LegacyFormController, NotchController } from '@utils/forms'; -import { createLegacyFormController, createNotchController } from '@utils/forms'; +import { compareOptions, createLegacyFormController, createNotchController, isOptionSelected } from '@utils/forms'; import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -82,7 +82,10 @@ export class Select implements ComponentInterface { @Prop({ reflect: true }) color?: Color; /** - * A property name or function used to compare object values + * This property allows developers to specify a custom function or property + * name for comparing objects when determining the selected option in the + * ion-select. When not specified, the default behavior will use strict + * equality (===) for comparison. */ @Prop() compareWith?: string | SelectCompareFn | null; @@ -1076,21 +1079,6 @@ Developers can use the "legacy" property to continue using the legacy form marku } } -const isOptionSelected = ( - currentValue: any[] | any, - compareValue: any, - compareWith?: string | SelectCompareFn | null -) => { - if (currentValue === undefined) { - return false; - } - if (Array.isArray(currentValue)) { - return currentValue.some((val) => compareOptions(val, compareValue, compareWith)); - } else { - return compareOptions(currentValue, compareValue, compareWith); - } -}; - const getOptionValue = (el: HTMLIonSelectOptionElement) => { const value = el.value; return value === undefined ? el.textContent || '' : value; @@ -1106,20 +1094,6 @@ const parseValue = (value: any) => { return value.toString(); }; -const compareOptions = ( - currentValue: any, - compareValue: any, - compareWith?: string | SelectCompareFn | null -): boolean => { - if (typeof compareWith === 'function') { - return compareWith(currentValue, compareValue); - } else if (typeof compareWith === 'string') { - return currentValue[compareWith] === compareValue[compareWith]; - } else { - return Array.isArray(compareValue) ? compareValue.includes(currentValue) : currentValue === compareValue; - } -}; - const generateText = ( opts: HTMLIonSelectOptionElement[], value: any | any[], diff --git a/core/src/utils/forms/compare-with-utils.ts b/core/src/utils/forms/compare-with-utils.ts new file mode 100644 index 00000000000..2ab1fd0237b --- /dev/null +++ b/core/src/utils/forms/compare-with-utils.ts @@ -0,0 +1,44 @@ +type CompareFn = (currentValue: any, compareValue: any) => boolean; + +/** + * Uses the compareWith param to compare two values to determine if they are equal. + * + * @param currentValue The current value of the control. + * @param compareValue The value to compare against. + * @param compareWith The function or property name to use to compare values. + */ +export const compareOptions = ( + currentValue: any, + compareValue: any, + compareWith?: string | CompareFn | null +): boolean => { + if (typeof compareWith === 'function') { + return compareWith(currentValue, compareValue); + } else if (typeof compareWith === 'string') { + return currentValue[compareWith] === compareValue[compareWith]; + } else { + return Array.isArray(compareValue) ? compareValue.includes(currentValue) : currentValue === compareValue; + } +}; + +/** + * Compares a value against the current value(s) to determine if it is selected. + * + * @param currentValue The current value of the control. + * @param compareValue The value to compare against. + * @param compareWith The function or property name to use to compare values. + */ +export const isOptionSelected = ( + currentValue: any[] | any, + compareValue: any, + compareWith?: string | CompareFn | null +) => { + if (currentValue === undefined) { + return false; + } + if (Array.isArray(currentValue)) { + return currentValue.some((val) => compareOptions(val, compareValue, compareWith)); + } else { + return compareOptions(currentValue, compareValue, compareWith); + } +}; diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index f219f3978a9..5b870bd513a 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,2 +1,3 @@ export * from './form-controller'; export * from './notch-controller'; +export * from './compare-with-utils'; diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 2e4766de237..83cae5db284 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1535,14 +1535,14 @@ export declare interface IonRadio extends Components.IonRadio { @ProxyCmp({ - inputs: ['allowEmptySelection', 'name', 'value'] + inputs: ['allowEmptySelection', 'compareWith', 'name', 'value'] }) @Component({ selector: 'ion-radio-group', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['allowEmptySelection', 'name', 'value'], + inputs: ['allowEmptySelection', 'compareWith', 'name', 'value'], }) export class IonRadioGroup { protected el: HTMLElement; diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index de9aebb8b92..e81c9d23279 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -596,6 +596,7 @@ export const IonRadio = /*@__PURE__*/ defineContainer('ion-radio-group', defineIonRadioGroup, [ 'allowEmptySelection', + 'compareWith', 'name', 'value', 'ionChange',