Skip to content
2 changes: 2 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,7 @@ ion-segment-view,prop,swipeGesture,boolean,true,false,false
ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true

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

ion-select-modal,scoped
ion-select-modal,prop,cancelIcon,boolean,false,false,false
ion-select-modal,prop,cancelText,string,'Close',false,false
ion-select-modal,prop,header,string | undefined,undefined,false,false
ion-select-modal,prop,multiple,boolean | undefined,undefined,false,false
Expand Down
22 changes: 22 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3684,6 +3684,11 @@ export namespace Components {
"swipeGesture": boolean;
}
interface IonSelect {
/**
* 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.
* @default false
*/
"cancelIcon": boolean;
/**
* The text to display on the cancel button.
* @default 'Cancel'
Expand Down Expand Up @@ -3800,6 +3805,11 @@ export namespace Components {
"value"?: any | null;
}
interface IonSelectModal {
/**
* 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.
* @default false
*/
"cancelIcon": boolean;
/**
* The text to display on the cancel button.
* @default 'Close'
Expand Down Expand Up @@ -9749,6 +9759,11 @@ declare namespace LocalJSX {
"swipeGesture"?: boolean;
}
interface IonSelect {
/**
* 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.
* @default false
*/
"cancelIcon"?: boolean;
/**
* The text to display on the cancel button.
* @default 'Cancel'
Expand Down Expand Up @@ -9884,6 +9899,11 @@ declare namespace LocalJSX {
"value"?: any | null;
}
interface IonSelectModal {
/**
* 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.
* @default false
*/
"cancelIcon"?: boolean;
/**
* The text to display on the cancel button.
* @default 'Close'
Expand Down Expand Up @@ -11237,6 +11257,7 @@ declare namespace LocalJSX {
}
interface IonSelectAttributes {
"cancelText": string;
"cancelIcon": boolean;
"color": Color;
"compareWith": string | SelectCompareFn | null;
"disabled": boolean;
Expand All @@ -11263,6 +11284,7 @@ declare namespace LocalJSX {
interface IonSelectModalAttributes {
"header": string;
"cancelText": string;
"cancelIcon": boolean;
"multiple": boolean;
}
interface IonSelectOptionAttributes {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 27 additions & 2 deletions core/src/components/select-modal/select-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { getIonMode } from '@global/ionic-global';
import { getIonMode, getIonTheme } from '@global/ionic-global';
import xRegular from '@phosphor-icons/core/assets/regular/x.svg';
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
import { safeCall } from '@utils/overlays';
import { getClassMap, hostContext } from '@utils/theme';
import { closeOutline, closeSharp } from 'ionicons/icons';

import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
Expand All @@ -28,6 +30,13 @@ export class SelectModal implements ComponentInterface {
*/
@Prop() cancelText = 'Close';

/**
* 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.
*/
@Prop() cancelIcon = false;

@Prop() multiple?: boolean;

@Prop() options: SelectModalOption[] = [];
Expand Down Expand Up @@ -79,6 +88,16 @@ export class SelectModal implements ComponentInterface {
}
}

private get cancelButtonIcon(): string {
const theme = getIonTheme(this);
const icons: Record<string, string> = {
ios: closeOutline,
md: closeSharp,
ionic: xRegular,
};
return icons[theme];
}

private getModalContextClasses() {
const el = this.el;
return {
Expand Down Expand Up @@ -167,7 +186,13 @@ export class SelectModal implements ComponentInterface {
{this.header !== undefined && <ion-title>{this.header}</ion-title>}

<ion-buttons slot="end">
<ion-button onClick={() => this.closeModal()}>{this.cancelText}</ion-button>
<ion-button aria-label={this.cancelIcon ? this.cancelText : undefined} onClick={() => this.closeModal()}>
{this.cancelIcon ? (
<ion-icon aria-hidden="true" slot="icon-only" icon={this.cancelButtonIcon}></ion-icon>
) : (
this.cancelText
)}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
Expand Down
24 changes: 20 additions & 4 deletions core/src/components/select-modal/test/basic/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,34 @@
</ion-header>

<ion-content>
<ion-modal is-open="true">
<ion-select-modal multiple="false"></ion-select-modal>
<ion-list>
<ion-item>
<ion-label>Cancel Text (default)</ion-label>
</ion-item>
</ion-list>
<ion-modal id="modal-text" is-open="true">
<ion-select-modal id="select-modal-text" multiple="false"></ion-select-modal>
</ion-modal>

<ion-list>
<ion-item button onclick="document.getElementById('modal-icon').isOpen = true">
<ion-label>Cancel Icon</ion-label>
</ion-item>
</ion-list>
<ion-modal id="modal-icon">
<ion-select-modal id="select-modal-icon" multiple="false" cancel-icon="true"></ion-select-modal>
</ion-modal>
</ion-content>
</ion-app>

<script>
const selectModal = document.querySelector('ion-select-modal');
selectModal.options = [
const options = [
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];

document.getElementById('select-modal-text').options = options;
document.getElementById('select-modal-icon').options = options;
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,65 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
// Verify the cancel button text has been updated
await expect(cancelButton).toHaveText('Close me');
});

test('should render an icon on the cancel button when cancelIcon is true', async () => {
await selectModalPage.setup(config, options, false);

const cancelButton = selectModalPage.selectModal.locator('ion-button');

// Verify no icon is shown by default
await expect(cancelButton.locator('ion-icon')).not.toBeAttached();

await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
selectModal.cancelIcon = true;
});

// Verify the icon is now rendered
await expect(cancelButton.locator('ion-icon')).toBeAttached();
});

test('should use cancelText as aria-label on the cancel button when cancelIcon is true', async () => {
await selectModalPage.setup(config, options, false);

const cancelButton = selectModalPage.selectModal.locator('ion-button');

await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
selectModal.cancelIcon = true;
selectModal.cancelText = 'Dismiss';
});

await expect(cancelButton).toHaveAttribute('aria-label', 'Dismiss');
});

test('should not set aria-label on the cancel button when cancelIcon is false', async () => {
await selectModalPage.setup(config, options, false);

const cancelButton = selectModalPage.selectModal.locator('ion-button');

await expect(cancelButton).not.toHaveAttribute('aria-label');
});
});
});

/**
* Visual regression tests for cancelIcon across all themes.
*/
configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('select-modal: cancel icon'), () => {
let selectModalPage: SelectModalPage;

test.beforeEach(async ({ page }) => {
selectModalPage = new SelectModalPage(page);
});

test('should not have visual regressions with cancelIcon', async () => {
await selectModalPage.setup(config, options, false);

await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
selectModal.cancelIcon = true;
});

await selectModalPage.screenshot(screenshot, 'select-modal-cancel-icon-diff');
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export class Select implements ComponentInterface {
*/
@Prop() cancelText = 'Cancel';

/**
* 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.
*/
@Prop() cancelIcon = false;

/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
Expand Down Expand Up @@ -815,6 +824,7 @@ export class Select implements ComponentInterface {
componentProps: {
header: interfaceOptions.header,
cancelText: this.cancelText,
cancelIcon: this.cancelIcon,
multiple,
value,
options: this.createOverlaySelectOptions(this.childOpts, value),
Expand Down
8 changes: 4 additions & 4 deletions packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2182,15 +2182,15 @@ export declare interface IonSegmentView extends Components.IonSegmentView {


@ProxyCmp({
inputs: ['cancelText', 'color', 'compareWith', 'disabled', 'errorText', 'expandedIcon', 'fill', 'helperText', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'required', 'selectedText', 'shape', 'size', 'theme', 'toggleIcon', 'value'],
inputs: ['cancelIcon', 'cancelText', 'color', 'compareWith', 'disabled', 'errorText', 'expandedIcon', 'fill', 'helperText', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'required', 'selectedText', 'shape', 'size', 'theme', 'toggleIcon', 'value'],
methods: ['open']
})
@Component({
selector: 'ion-select',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['cancelText', 'color', 'compareWith', 'disabled', 'errorText', 'expandedIcon', 'fill', 'helperText', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'required', 'selectedText', 'shape', 'size', 'theme', 'toggleIcon', 'value'],
inputs: ['cancelIcon', 'cancelText', 'color', 'compareWith', 'disabled', 'errorText', 'expandedIcon', 'fill', 'helperText', 'interface', 'interfaceOptions', 'justify', 'label', 'labelPlacement', 'mode', 'multiple', 'name', 'okText', 'placeholder', 'required', 'selectedText', 'shape', 'size', 'theme', 'toggleIcon', 'value'],
})
export class IonSelect {
protected el: HTMLIonSelectElement;
Expand Down Expand Up @@ -2231,14 +2231,14 @@ This event will not emit when programmatically setting the `value` property.


@ProxyCmp({
inputs: ['cancelText', 'header', 'multiple', 'options']
inputs: ['cancelIcon', 'cancelText', 'header', 'multiple', 'options']
})
@Component({
selector: 'ion-select-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['cancelText', 'header', 'multiple', 'options'],
inputs: ['cancelIcon', 'cancelText', 'header', 'multiple', 'options'],
})
export class IonSelectModal {
protected el: HTMLIonSelectModalElement;
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/standalone/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,14 +1962,14 @@ export declare interface IonSegmentView extends Components.IonSegmentView {

@ProxyCmp({
defineCustomElementFn: defineIonSelectModal,
inputs: ['cancelText', 'header', 'multiple', 'options']
inputs: ['cancelIcon', 'cancelText', 'header', 'multiple', 'options']
})
@Component({
selector: 'ion-select-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['cancelText', 'header', 'multiple', 'options'],
inputs: ['cancelIcon', 'cancelText', 'header', 'multiple', 'options'],
standalone: true
})
export class IonSelectModal {
Expand Down
2 changes: 2 additions & 0 deletions packages/vue/src/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,7 @@ export const IonSegmentView: StencilVueComponent<JSX.IonSegmentView> = /*@__PURE

export const IonSelect: StencilVueComponent<JSX.IonSelect, JSX.IonSelect["value"]> = /*@__PURE__*/ defineContainer<JSX.IonSelect, JSX.IonSelect["value"]>('ion-select', defineIonSelect, [
'cancelText',
'cancelIcon',
'color',
'compareWith',
'disabled',
Expand Down Expand Up @@ -991,6 +992,7 @@ export const IonSelect: StencilVueComponent<JSX.IonSelect, JSX.IonSelect["value"
export const IonSelectModal: StencilVueComponent<JSX.IonSelectModal> = /*@__PURE__*/ defineContainer<JSX.IonSelectModal>('ion-select-modal', defineIonSelectModal, [
'header',
'cancelText',
'cancelIcon',
'multiple',
'options'
]);
Expand Down
Loading