Skip to content

Commit a81ced2

Browse files
feat(select-modal): add cancel icon (#31089)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? Currently the select modal only accepts a string for the `Cancel` action. ## What is the new behavior? This PR adds a boolean prop, `cancelIcon` to the `IonSelect` modal which defaults to false. When the `cancelIcon` prop is true, an `X` icon is added in place of the `Cancel` text. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information **Preview Links:** - https://ionic-framework-git-rou-12695-add-select-cancel-icon-ionic1.vercel.app/src/components/select-modal/test/basic/ - https://ionic-framework-git-rou-12695-add-select-cancel-icon-ionic1.vercel.app/src/components/select-modal/test/basic?ionic:theme=ionic --------- Co-authored-by: ionitron <hi@ionicframework.com>
1 parent da75baf commit a81ced2

20 files changed

Lines changed: 150 additions & 12 deletions

core/api.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,7 @@ ion-segment-view,prop,swipeGesture,boolean,true,false,false
22472247
ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true
22482248

22492249
ion-select,shadow
2250+
ion-select,prop,cancelIcon,boolean,false,false,false
22502251
ion-select,prop,cancelText,string,'Cancel',false,false
22512252
ion-select,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
22522253
ion-select,prop,compareWith,((currentValue: any, compareValue: any) => boolean) | null | string | undefined,undefined,false,false
@@ -2339,6 +2340,7 @@ ion-select,part,text
23392340
ion-select,part,wrapper
23402341

23412342
ion-select-modal,scoped
2343+
ion-select-modal,prop,cancelIcon,boolean,false,false,false
23422344
ion-select-modal,prop,cancelText,string,'Close',false,false
23432345
ion-select-modal,prop,header,string | undefined,undefined,false,false
23442346
ion-select-modal,prop,multiple,boolean | undefined,undefined,false,false

core/src/components.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3684,6 +3684,11 @@ export namespace Components {
36843684
"swipeGesture": boolean;
36853685
}
36863686
interface IonSelect {
3687+
/**
3688+
* If `true`, the cancel button will display an icon instead of the `cancelText`. Only applies when `interface` is set to `"modal"`. Has no effect on `"action-sheet"`, `"alert"`, or `"popover"` interfaces. When `cancelIcon` is `true`, the `cancelText` property is ignored for display but is used as the accessible label for the icon button.
3689+
* @default false
3690+
*/
3691+
"cancelIcon": boolean;
36873692
/**
36883693
* The text to display on the cancel button.
36893694
* @default 'Cancel'
@@ -3800,6 +3805,11 @@ export namespace Components {
38003805
"value"?: any | null;
38013806
}
38023807
interface IonSelectModal {
3808+
/**
3809+
* If `true`, the cancel button will display a close icon instead of the `cancelText`. When `cancelIcon` is `true`, `cancelText` is not displayed visually but is still used as the accessible label (`aria-label`) for the button.
3810+
* @default false
3811+
*/
3812+
"cancelIcon": boolean;
38033813
/**
38043814
* The text to display on the cancel button.
38053815
* @default 'Close'
@@ -9749,6 +9759,11 @@ declare namespace LocalJSX {
97499759
"swipeGesture"?: boolean;
97509760
}
97519761
interface IonSelect {
9762+
/**
9763+
* If `true`, the cancel button will display an icon instead of the `cancelText`. Only applies when `interface` is set to `"modal"`. Has no effect on `"action-sheet"`, `"alert"`, or `"popover"` interfaces. When `cancelIcon` is `true`, the `cancelText` property is ignored for display but is used as the accessible label for the icon button.
9764+
* @default false
9765+
*/
9766+
"cancelIcon"?: boolean;
97529767
/**
97539768
* The text to display on the cancel button.
97549769
* @default 'Cancel'
@@ -9884,6 +9899,11 @@ declare namespace LocalJSX {
98849899
"value"?: any | null;
98859900
}
98869901
interface IonSelectModal {
9902+
/**
9903+
* If `true`, the cancel button will display a close icon instead of the `cancelText`. When `cancelIcon` is `true`, `cancelText` is not displayed visually but is still used as the accessible label (`aria-label`) for the button.
9904+
* @default false
9905+
*/
9906+
"cancelIcon"?: boolean;
98879907
/**
98889908
* The text to display on the cancel button.
98899909
* @default 'Close'
@@ -11237,6 +11257,7 @@ declare namespace LocalJSX {
1123711257
}
1123811258
interface IonSelectAttributes {
1123911259
"cancelText": string;
11260+
"cancelIcon": boolean;
1124011261
"color": Color;
1124111262
"compareWith": string | SelectCompareFn | null;
1124211263
"disabled": boolean;
@@ -11263,6 +11284,7 @@ declare namespace LocalJSX {
1126311284
interface IonSelectModalAttributes {
1126411285
"header": string;
1126511286
"cancelText": string;
11287+
"cancelIcon": boolean;
1126611288
"multiple": boolean;
1126711289
}
1126811290
interface IonSelectOptionAttributes {
Loading
Loading

core/src/components/select-modal/select-modal.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { getIonMode } from '@global/ionic-global';
1+
import { getIonMode, getIonTheme } from '@global/ionic-global';
2+
import xRegular from '@phosphor-icons/core/assets/regular/x.svg';
23
import type { ComponentInterface } from '@stencil/core';
34
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
45
import { safeCall } from '@utils/overlays';
56
import { getClassMap, hostContext } from '@utils/theme';
7+
import { closeOutline, closeSharp } from 'ionicons/icons';
68

9+
import type { Theme } from '../../interface';
710
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
811
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
912

@@ -28,6 +31,13 @@ export class SelectModal implements ComponentInterface {
2831
*/
2932
@Prop() cancelText = 'Close';
3033

34+
/**
35+
* If `true`, the cancel button will display a close icon instead of the `cancelText`.
36+
* When `cancelIcon` is `true`, `cancelText` is not displayed visually but is still used
37+
* as the accessible label (`aria-label`) for the button.
38+
*/
39+
@Prop() cancelIcon = false;
40+
3141
@Prop() multiple?: boolean;
3242

3343
@Prop() options: SelectModalOption[] = [];
@@ -79,6 +89,16 @@ export class SelectModal implements ComponentInterface {
7989
}
8090
}
8191

92+
private get cancelButtonIcon(): string {
93+
const theme = getIonTheme(this);
94+
const icons: Record<Theme, string> = {
95+
ios: closeOutline,
96+
md: closeSharp,
97+
ionic: xRegular,
98+
};
99+
return icons[theme];
100+
}
101+
82102
private getModalContextClasses() {
83103
const el = this.el;
84104
return {
@@ -167,7 +187,13 @@ export class SelectModal implements ComponentInterface {
167187
{this.header !== undefined && <ion-title>{this.header}</ion-title>}
168188

169189
<ion-buttons slot="end">
170-
<ion-button onClick={() => this.closeModal()}>{this.cancelText}</ion-button>
190+
<ion-button aria-label={this.cancelIcon ? this.cancelText : undefined} onClick={() => this.closeModal()}>
191+
{this.cancelIcon ? (
192+
<ion-icon aria-hidden="true" slot="icon-only" icon={this.cancelButtonIcon}></ion-icon>
193+
) : (
194+
this.cancelText
195+
)}
196+
</ion-button>
171197
</ion-buttons>
172198
</ion-toolbar>
173199
</ion-header>

core/src/components/select-modal/test/basic/index.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,34 @@
2323
</ion-header>
2424

2525
<ion-content>
26-
<ion-modal is-open="true">
27-
<ion-select-modal multiple="false"></ion-select-modal>
26+
<ion-list>
27+
<ion-item>
28+
<ion-label>Cancel Text (default)</ion-label>
29+
</ion-item>
30+
</ion-list>
31+
<ion-modal id="modal-text" is-open="true">
32+
<ion-select-modal id="select-modal-text" multiple="false"></ion-select-modal>
33+
</ion-modal>
34+
35+
<ion-list>
36+
<ion-item button onclick="document.getElementById('modal-icon').isOpen = true">
37+
<ion-label>Cancel Icon</ion-label>
38+
</ion-item>
39+
</ion-list>
40+
<ion-modal id="modal-icon">
41+
<ion-select-modal id="select-modal-icon" multiple="false" cancel-icon="true"></ion-select-modal>
2842
</ion-modal>
2943
</ion-content>
3044
</ion-app>
3145

3246
<script>
33-
const selectModal = document.querySelector('ion-select-modal');
34-
selectModal.options = [
47+
const options = [
3548
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
3649
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
3750
];
51+
52+
document.getElementById('select-modal-text').options = options;
53+
document.getElementById('select-modal-icon').options = options;
3854
</script>
3955
</body>
4056
</html>

core/src/components/select-modal/test/custom/select-modal.e2e.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,65 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
4141
// Verify the cancel button text has been updated
4242
await expect(cancelButton).toHaveText('Close me');
4343
});
44+
45+
test('should render an icon on the cancel button when cancelIcon is true', async () => {
46+
await selectModalPage.setup(config, options, false);
47+
48+
const cancelButton = selectModalPage.selectModal.locator('ion-button');
49+
50+
// Verify no icon is shown by default
51+
await expect(cancelButton.locator('ion-icon')).not.toBeAttached();
52+
53+
await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
54+
selectModal.cancelIcon = true;
55+
});
56+
57+
// Verify the icon is now rendered
58+
await expect(cancelButton.locator('ion-icon')).toBeAttached();
59+
});
60+
61+
test('should use cancelText as aria-label on the cancel button when cancelIcon is true', async () => {
62+
await selectModalPage.setup(config, options, false);
63+
64+
const cancelButton = selectModalPage.selectModal.locator('ion-button');
65+
66+
await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
67+
selectModal.cancelIcon = true;
68+
selectModal.cancelText = 'Dismiss';
69+
});
70+
71+
await expect(cancelButton).toHaveAttribute('aria-label', 'Dismiss');
72+
});
73+
74+
test('should not set aria-label on the cancel button when cancelIcon is false', async () => {
75+
await selectModalPage.setup(config, options, false);
76+
77+
const cancelButton = selectModalPage.selectModal.locator('ion-button');
78+
79+
await expect(cancelButton).not.toHaveAttribute('aria-label');
80+
});
81+
});
82+
});
83+
84+
/**
85+
* Visual regression tests for cancelIcon across all themes.
86+
*/
87+
configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
88+
test.describe(title('select-modal: cancel icon'), () => {
89+
let selectModalPage: SelectModalPage;
90+
91+
test.beforeEach(async ({ page }) => {
92+
selectModalPage = new SelectModalPage(page);
93+
});
94+
95+
test('should not have visual regressions with cancelIcon', async () => {
96+
await selectModalPage.setup(config, options, false);
97+
98+
await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
99+
selectModal.cancelIcon = true;
100+
});
101+
102+
await selectModalPage.screenshot(screenshot, 'select-modal-cancel-icon-diff');
103+
});
44104
});
45105
});
Loading
Loading
Loading

0 commit comments

Comments
 (0)