Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(radio-group): add compareWith property #28452

Merged
merged 10 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>,true
Expand Down
16 changes: 12 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
/**
Expand Down
2 changes: 2 additions & 0 deletions core/src/components/radio-group/radio-group-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export interface RadioGroupCustomEvent<T = any> extends CustomEvent {
detail: RadioGroupChangeEventDetail<T>;
target: HTMLIonRadioGroupElement;
}

export type RadioGroupCompareFn = (currentValue: any, compareValue: any) => boolean;
10 changes: 9 additions & 1 deletion core/src/components/radio-group/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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.
*/
Expand Down
74 changes: 74 additions & 0 deletions core/src/components/radio-group/test/compare-with/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Radio Group - compareWith</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - compareWith</ion-title>
</ion-toolbar>
</ion-header>

<ion-content>
<ion-list>
<ion-list-header>Compare with String</ion-list-header>
<ion-radio-group id="compareWithString" compare-with="value"> </ion-radio-group>
</ion-list>

<ion-list>
<ion-list-header>Compare with Function</ion-list-header>
<ion-radio-group id="compareWithFn"></ion-radio-group>
</ion-list>
</ion-content>
</ion-app>

<script>
const options = [
{
label: 'Red',
value: 'red',
},
{
label: 'Blue',
value: 'blue',
},
{
label: 'Green',
value: 'green',
},
];

const radioGroupWithFn = document.querySelector('#compareWithFn');
radioGroupWithFn.compareWith = (a, b) => a.value === b.value;

document.querySelectorAll('ion-radio-group').forEach((radioGroup) => {
radioGroup.value = options[1];

options.forEach((option) => {
const radio = document.createElement('ion-radio');

radio.value = option;
radio.textContent = option.label;

const item = document.createElement('ion-item');
item.appendChild(radio);

radioGroup.appendChild(item);
});
});
</script>
</body>
</html>
111 changes: 111 additions & 0 deletions core/src/components/radio-group/test/radio-group.spec.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<ion-radio-group compareWith="value" value={{ label: 'Blue', value: 'blue' }}>
<ion-radio value={{ label: 'Red', value: 'red' }}>Red</ion-radio>
<ion-radio value={{ label: 'Blue', value: 'blue' }}>Blue</ion-radio>
<ion-radio value={{ label: 'Green', value: 'green' }}>Green</ion-radio>
</ion-radio-group>
),
});

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: () => (
<ion-radio-group value={{ label: 'Blue', value: 'blue' }}>
<ion-radio value={{ label: 'Red', value: 'red' }}>Red</ion-radio>
<ion-radio value={{ label: 'Blue', value: 'blue' }}>Blue</ion-radio>
<ion-radio value={{ label: 'Green', value: 'green' }}>Green</ion-radio>
</ion-radio-group>
),
});

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: () => (
<ion-radio-group compareWith={null} value="blue">
<ion-radio value="red">Red</ion-radio>
<br />
<ion-radio value="blue">Blue</ion-radio>
<br />
<ion-radio value="green">Green</ion-radio>
</ion-radio-group>
),
});

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: () => (
<ion-radio-group value={2}>
<ion-radio value={1}>Option #1</ion-radio>
<ion-radio value={2}>Option #2</ion-radio>
<ion-radio value={3}>Option #3</ion-radio>
</ion-radio-group>
),
});

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);
});
});
6 changes: 4 additions & 2 deletions core/src/components/radio/radio.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
};

Expand Down
36 changes: 5 additions & 31 deletions core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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[],
Expand Down
Loading
Loading