diff --git a/core/api.txt b/core/api.txt
index 067b07e818c..8333cd73e79 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -1741,6 +1741,20 @@ ion-select,css-prop,--placeholder-opacity,ios
ion-select,css-prop,--placeholder-opacity,md
ion-select,css-prop,--ripple-color,ios
ion-select,css-prop,--ripple-color,md
+ion-select,css-prop,--select-text-gap,ios
+ion-select,css-prop,--select-text-gap,md
+ion-select,css-prop,--select-text-media-border-color,ios
+ion-select,css-prop,--select-text-media-border-color,md
+ion-select,css-prop,--select-text-media-border-radius,ios
+ion-select,css-prop,--select-text-media-border-radius,md
+ion-select,css-prop,--select-text-media-border-style,ios
+ion-select,css-prop,--select-text-media-border-style,md
+ion-select,css-prop,--select-text-media-border-width,ios
+ion-select,css-prop,--select-text-media-border-width,md
+ion-select,css-prop,--select-text-media-height,ios
+ion-select,css-prop,--select-text-media-height,md
+ion-select,css-prop,--select-text-media-width,ios
+ion-select,css-prop,--select-text-media-width,md
ion-select,part,bottom
ion-select,part,container
ion-select,part,error-text
@@ -1760,7 +1774,11 @@ ion-select-modal,prop,multiple,boolean | undefined,undefined,false,false
ion-select-modal,prop,options,SelectModalOption[],[],false,false
ion-select-option,shadow
+ion-select-option,prop,description,string | undefined,undefined,false,false
ion-select-option,prop,disabled,boolean,false,false,false
+ion-select-option,prop,justify,"end" | "space-between" | "start" | undefined,undefined,false,false
+ion-select-option,prop,labelPlacement,"end" | "start" | undefined,undefined,false,false
+ion-select-option,prop,mode,"ios" | "md",undefined,false,false
ion-select-option,prop,value,any,undefined,false,false
ion-skeleton-text,shadow
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index 2d8a59f1903..7e6c6db7f24 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -3142,11 +3142,27 @@ export namespace Components {
"options": SelectModalOption[];
}
interface IonSelectOption {
+ /**
+ * Text that is placed underneath the option text to provide additional details about the option.
+ */
+ "description"?: string;
/**
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
* @default false
*/
"disabled": boolean;
+ /**
+ * How to pack the label and the option's selection control within a line. `"start"`: The label and radio will appear on the left in LTR and on the right in RTL. `"end"`: The label and radio will appear on the right in LTR and on the left in RTL. `"space-between"`: The label and radio will appear on opposite ends of the line with space between the two elements. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there). When unset, the interface picks a default based on mode and control type.
+ */
+ "justify"?: 'start' | 'end' | 'space-between';
+ /**
+ * Where the label is placed relative to the option's selection control (radio circle or checkbox box) when the option is rendered in an `alert`, `popover`, or `modal` interface. `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL. `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there). When unset, the interface picks a default based on mode and control type.
+ */
+ "labelPlacement"?: 'start' | 'end';
+ /**
+ * The mode determines the platform behaviors of the component.
+ */
+ "mode"?: "ios" | "md";
/**
* The text value of the option.
*/
@@ -8344,11 +8360,27 @@ declare namespace LocalJSX {
"options"?: SelectModalOption[];
}
interface IonSelectOption {
+ /**
+ * Text that is placed underneath the option text to provide additional details about the option.
+ */
+ "description"?: string;
/**
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
* @default false
*/
"disabled"?: boolean;
+ /**
+ * How to pack the label and the option's selection control within a line. `"start"`: The label and radio will appear on the left in LTR and on the right in RTL. `"end"`: The label and radio will appear on the right in LTR and on the left in RTL. `"space-between"`: The label and radio will appear on opposite ends of the line with space between the two elements. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there). When unset, the interface picks a default based on mode and control type.
+ */
+ "justify"?: 'start' | 'end' | 'space-between';
+ /**
+ * Where the label is placed relative to the option's selection control (radio circle or checkbox box) when the option is rendered in an `alert`, `popover`, or `modal` interface. `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL. `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there). When unset, the interface picks a default based on mode and control type.
+ */
+ "labelPlacement"?: 'start' | 'end';
+ /**
+ * The mode determines the platform behaviors of the component.
+ */
+ "mode"?: "ios" | "md";
/**
* The text value of the option.
*/
@@ -9528,6 +9560,9 @@ declare namespace LocalJSX {
interface IonSelectOptionAttributes {
"disabled": boolean;
"value": string;
+ "description": string;
+ "labelPlacement": 'start' | 'end';
+ "justify": 'start' | 'end' | 'space-between';
}
interface IonSelectPopoverAttributes {
"header": string;
diff --git a/core/src/components/action-sheet/action-sheet.scss b/core/src/components/action-sheet/action-sheet.scss
index 2519c214f3f..cc2e3833dea 100644
--- a/core/src/components/action-sheet/action-sheet.scss
+++ b/core/src/components/action-sheet/action-sheet.scss
@@ -1,3 +1,4 @@
+@use "../select-option/select-option.overlay";
@import "./action-sheet.vars";
// Action Sheet
diff --git a/core/src/components/action-sheet/action-sheet.tsx b/core/src/components/action-sheet/action-sheet.tsx
index f5fae2c0ebb..c48e95d862d 100644
--- a/core/src/components/action-sheet/action-sheet.tsx
+++ b/core/src/components/action-sheet/action-sheet.tsx
@@ -17,11 +17,13 @@ import {
safeCall,
setOverlayId,
} from '@utils/overlays';
+import { renderOptionLabel } from '@utils/select-option-render';
import { getClassMap } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { AnimationBuilder, CssClassMap, FrameworkDelegate, OverlayInterface } from '../../interface';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
+import type { SelectActionSheetButton } from '../select/select-interface';
import type { ActionSheetButton } from './action-sheet-interface';
import { iosEnterAnimation } from './animations/ios.enter';
@@ -562,6 +564,21 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
htmlAttrs['aria-checked'] = isActiveRadio ? 'true' : 'false';
}
+ /**
+ * Cast to `SelectActionSheetButton` to access rich content
+ * fields (`startContent`, `endContent`, `description`)
+ * that are passed through from `ion-select` but not
+ * part of the public `ActionSheetButton` interface.
+ */
+ const richButton = b as SelectActionSheetButton;
+ const optionLabelOptions = {
+ id: buttonId,
+ label: richButton.text,
+ startContent: richButton.startContent,
+ endContent: richButton.endContent,
+ description: richButton.description,
+ };
+
return (
{b.icon && }
- {b.text}
+ {renderOptionLabel(optionLabelOptions, 'action-sheet-button-label', true)}
{mode === 'md' && }
diff --git a/core/src/components/action-sheet/test/basic/index.html b/core/src/components/action-sheet/test/basic/index.html
index 640801b2ddf..73f78ca635d 100644
--- a/core/src/components/action-sheet/test/basic/index.html
+++ b/core/src/components/action-sheet/test/basic/index.html
@@ -46,6 +46,8 @@
.my-color-class {
--background: #292929;
--button-background-selected: #222222;
+ --button-background-activated: #393838;
+ --button-background-activated-opacity: 1;
--color: #dfdfdf;
--button-color: #dfdfdf;
diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts
new file mode 100644
index 00000000000..d3d7eac4b8b
--- /dev/null
+++ b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts
@@ -0,0 +1,40 @@
+import { configs, test } from '@utils/test/playwright';
+
+import { ActionSheetFixture } from '../basic/fixture';
+
+/**
+ * This behavior does not vary across directions.
+ */
+configs({ directions: ['ltr'], modes: ['ios', 'md'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('action sheet: states'), () => {
+ /**
+ * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated
+ * projects, suppressing the hover rules:
+ *
+ * - Chromium and WebKit suppress it because of hasTouch and isMobile.
+ * - Headless Firefox doesn't detect input devices and reports no hover
+ * capability regardless of context options, so override it via
+ * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse),
+ * 8 = HOVER, 12 = FINE | HOVER.
+ *
+ * Viewport, userAgent, and scale factor remain mobile-sized.
+ */
+ test.use({
+ hasTouch: false,
+ isMobile: false,
+ });
+
+ test('should render all button states', async ({ page }) => {
+ await page.goto(`/src/components/action-sheet/test/states`, config);
+
+ const actionSheetFixture = new ActionSheetFixture(page, screenshot);
+
+ await actionSheetFixture.open('#basic');
+
+ const defaultButton = page.locator('ion-action-sheet button.action-sheet-button').first();
+ await defaultButton.hover();
+
+ await actionSheetFixture.screenshot('states');
+ });
+ });
+});
diff --git a/core/src/components/action-sheet/test/states/index.html b/core/src/components/action-sheet/test/states/index.html
new file mode 100644
index 00000000000..5d5339d5d1b
--- /dev/null
+++ b/core/src/components/action-sheet/test/states/index.html
@@ -0,0 +1,97 @@
+
+
+
+
+ Action Sheet - States
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Action Sheet - States
+
+
+
+
+ Basic
+
+
+
+
+
+
+
diff --git a/core/src/components/alert/alert.scss b/core/src/components/alert/alert.scss
index 12cdac9040b..97ec1057395 100644
--- a/core/src/components/alert/alert.scss
+++ b/core/src/components/alert/alert.scss
@@ -1,3 +1,4 @@
+@use "../select-option/select-option.overlay";
@import "./alert.vars";
// Alert
diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx
index ac7f84d802f..8c5e99a0d55 100644
--- a/core/src/components/alert/alert.tsx
+++ b/core/src/components/alert/alert.tsx
@@ -20,6 +20,7 @@ import {
setOverlayId,
} from '@utils/overlays';
import { sanitizeDOMString } from '@utils/sanitization';
+import { renderOptionLabel } from '@utils/select-option-render';
import { getClassMap } from '@utils/theme';
import { config } from '../../global/config';
@@ -27,6 +28,7 @@ import { getIonMode } from '../../global/ionic-global';
import type { AnimationBuilder, CssClassMap, OverlayInterface, FrameworkDelegate } from '../../interface';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import type { IonicSafeString } from '../../utils/sanitization';
+import type { SelectAlertInput } from '../select/select-interface';
import type { AlertButton, AlertInput } from './alert-interface';
import { iosEnterAnimation } from './animations/ios.enter';
@@ -339,25 +341,20 @@ export class Alert implements ComponentInterface, OverlayInterface {
}
this.inputType = inputTypes.values().next().value;
- this.processedInputs = inputs.map(
- (i, index) =>
- ({
- type: i.type || 'text',
- name: i.name || `${index}`,
- placeholder: i.placeholder || '',
- value: i.value,
- label: i.label,
- checked: !!i.checked,
- disabled: !!i.disabled,
- id: i.id || `alert-input-${this.overlayIndex}-${index}`,
- handler: i.handler,
- min: i.min,
- max: i.max,
- cssClass: i.cssClass ?? '',
- attributes: i.attributes || {},
- tabindex: i.type === 'radio' && i !== focusable ? -1 : 0,
- } as AlertInput)
- );
+ this.processedInputs = inputs.map((i, index) => {
+ return {
+ ...i,
+ type: i.type || 'text',
+ name: i.name || `${index}`,
+ placeholder: i.placeholder || '',
+ checked: !!i.checked,
+ disabled: !!i.disabled,
+ id: i.id || `alert-input-${this.overlayIndex}-${index}`,
+ cssClass: i.cssClass ?? '',
+ attributes: i.attributes || {},
+ tabindex: i.type === 'radio' && i !== focusable ? -1 : 0,
+ } as AlertInput;
+ });
}
connectedCallback() {
@@ -595,33 +592,50 @@ export class Alert implements ComponentInterface, OverlayInterface {
return (
- {inputs.map((i) => (
-
this.cbClick(i)}
- aria-checked={`${i.checked}`}
- id={i.id}
- disabled={i.disabled}
- tabIndex={i.tabindex}
- role="checkbox"
- class={{
- ...getClassMap(i.cssClass),
- 'alert-tappable': true,
- 'alert-checkbox': true,
- 'alert-checkbox-button': true,
- 'ion-focusable': true,
- 'alert-checkbox-button-disabled': i.disabled || false,
- }}
- >
-
);
}
@@ -635,32 +649,49 @@ export class Alert implements ComponentInterface, OverlayInterface {
return (
- {inputs.map((i) => (
-
this.rbClick(i)}
- aria-checked={`${i.checked}`}
- disabled={i.disabled}
- id={i.id}
- tabIndex={i.tabindex}
- class={{
- ...getClassMap(i.cssClass),
- 'alert-radio-button': true,
- 'alert-tappable': true,
- 'alert-radio': true,
- 'ion-focusable': true,
- 'alert-radio-button-disabled': i.disabled || false,
- }}
- role="radio"
- >
-
);
}
diff --git a/core/src/components/alert/test/basic/alert.e2e.ts b/core/src/components/alert/test/basic/alert.e2e.ts
index 6bc4e8105de..558c13a228b 100644
--- a/core/src/components/alert/test/basic/alert.e2e.ts
+++ b/core/src/components/alert/test/basic/alert.e2e.ts
@@ -177,6 +177,10 @@ class AlertFixture {
this.alert = this.page.locator('ion-alert');
await expect(this.alert).toBeVisible();
+ // Move mouse to the top-left corner of the page to avoid hover
+ // styles on buttons when taking screenshots
+ await this.page.mouse.move(0, 0);
+
return this.alert;
}
diff --git a/core/src/components/alert/test/label-placement/alert.e2e.ts b/core/src/components/alert/test/label-placement/alert.e2e.ts
new file mode 100644
index 00000000000..7d80bcb4181
--- /dev/null
+++ b/core/src/components/alert/test/label-placement/alert.e2e.ts
@@ -0,0 +1,83 @@
+import { expect } from '@playwright/test';
+import type { Locator } from '@playwright/test';
+import type { E2EPage } from '@utils/test/playwright';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * Force every theme's alert to the same canvas so the
+ * label-placement snapshots can be compared one-to-one.
+ * iOS does not respect the viewport so styles must be
+ * updated instead.
+ */
+const ALERT_SIZE_OVERRIDES = `
+ ion-alert {
+ --max-width: 560px !important;
+ --max-height: none !important;
+ }
+ ion-alert .alert-radio-group,
+ ion-alert .alert-checkbox-group {
+ max-height: none !important;
+ }
+`;
+
+configs({ modes: ['md', 'ios'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('alert: label placement'), () => {
+ let alertFixture!: AlertFixture;
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/alert/test/label-placement', config);
+ await page.addStyleTag({ content: ALERT_SIZE_OVERRIDES });
+ alertFixture = new AlertFixture(page, screenshot);
+ });
+
+ test('radio - placement start', async () => {
+ await alertFixture.open('#radioStart');
+ await alertFixture.screenshot('radio-placement-start');
+ });
+
+ test('radio - placement end', async () => {
+ await alertFixture.open('#radioEnd');
+ await alertFixture.screenshot('radio-placement-end');
+ });
+
+ test('checkbox - placement start', async () => {
+ await alertFixture.open('#checkboxStart');
+ await alertFixture.screenshot('checkbox-placement-start');
+ });
+
+ test('checkbox - placement end', async () => {
+ await alertFixture.open('#checkboxEnd');
+ await alertFixture.screenshot('checkbox-placement-end');
+ });
+ });
+});
+
+class AlertFixture {
+ readonly page: E2EPage;
+ readonly screenshotFn: (file: string) => string;
+
+ private alert!: Locator;
+
+ constructor(page: E2EPage, screenshot: (file: string) => string) {
+ this.page = page;
+ this.screenshotFn = screenshot;
+ }
+
+ async open(selector: string) {
+ const ionAlertDidPresent = await this.page.spyOnEvent('ionAlertDidPresent');
+ await this.page.locator(selector).click();
+ await ionAlertDidPresent.next();
+ this.alert = this.page.locator('ion-alert');
+ await expect(this.alert).toBeVisible();
+
+ // Move mouse to away from the alert so hover styles don't interfere with screenshots
+ await this.page.mouse.move(0, 0);
+
+ return this.alert;
+ }
+
+ async screenshot(modifier: string) {
+ const alertWrapper = this.alert.locator('.alert-wrapper');
+ await expect(alertWrapper).toHaveScreenshot(this.screenshotFn(`alert-label-${modifier}`));
+ }
+}
diff --git a/core/src/components/alert/test/label-placement/index.html b/core/src/components/alert/test/label-placement/index.html
new file mode 100644
index 00000000000..ffa252a212f
--- /dev/null
+++ b/core/src/components/alert/test/label-placement/index.html
@@ -0,0 +1,78 @@
+
+
+
+
+ Alert - Label Placement
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alert - Label Placement
+
+
+
+
+ Radio — Placement Start
+ Radio — Placement End
+
+ Checkbox — Placement Start
+
+
+ Checkbox — Placement End
+
+
+
+
+
+
+
diff --git a/core/src/components/alert/test/states/alert.e2e.ts b/core/src/components/alert/test/states/alert.e2e.ts
new file mode 100644
index 00000000000..3f6902fd84f
--- /dev/null
+++ b/core/src/components/alert/test/states/alert.e2e.ts
@@ -0,0 +1,60 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * This behavior does not vary across directions.
+ */
+configs({ directions: ['ltr'], modes: ['ios', 'md'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('alert: input states'), () => {
+ /**
+ * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated
+ * projects, suppressing the hover rules:
+ *
+ * - Chromium and WebKit suppress it because of hasTouch and isMobile.
+ * - Headless Firefox doesn't detect input devices and reports no hover
+ * capability regardless of context options, so override it via
+ * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse),
+ * 8 = HOVER, 12 = FINE | HOVER.
+ *
+ * Viewport, userAgent, and scale factor remain mobile-sized.
+ */
+ test.use({
+ hasTouch: false,
+ isMobile: false,
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`/src/components/alert/test/states`, config);
+ });
+
+ test('should render all radio states', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ await page.locator('#radio').click();
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ await expect(alert).toBeVisible();
+
+ const defaultRadio = alert.locator('button.alert-radio-button').first();
+ await defaultRadio.hover();
+
+ await expect(alert).toHaveScreenshot(screenshot('alert-radio-states'));
+ });
+
+ test('should render all checkbox states', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ await page.locator('#checkbox').click();
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ await expect(alert).toBeVisible();
+
+ const defaultCheckbox = alert.locator('button.alert-checkbox-button').first();
+ await defaultCheckbox.hover();
+
+ await page.waitForChanges();
+
+ await expect(alert).toHaveScreenshot(screenshot('alert-checkbox-states'));
+ });
+ });
+});
diff --git a/core/src/components/alert/test/states/index.html b/core/src/components/alert/test/states/index.html
new file mode 100644
index 00000000000..4591962127a
--- /dev/null
+++ b/core/src/components/alert/test/states/index.html
@@ -0,0 +1,159 @@
+
+
+
+
+ Alert - States
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alert - States
+
+
+
+
+ Radio
+ Checkbox
+
+
+
+
+
+
diff --git a/core/src/components/select-modal/select-modal.scss b/core/src/components/select-modal/select-modal.scss
index 683ae23faeb..41885a7da0a 100644
--- a/core/src/components/select-modal/select-modal.scss
+++ b/core/src/components/select-modal/select-modal.scss
@@ -1,3 +1,5 @@
+@use "../select-option/select-option.overlay";
+
:host {
height: 100%;
}
diff --git a/core/src/components/select-modal/select-modal.tsx b/core/src/components/select-modal/select-modal.tsx
index 74358033caf..1e1b2ec0bbf 100644
--- a/core/src/components/select-modal/select-modal.tsx
+++ b/core/src/components/select-modal/select-modal.tsx
@@ -1,11 +1,14 @@
import { getIonMode } from '@global/ionic-global';
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
+import { getOverlayLabelJustify, getOverlayLabelPlacement } from '@utils/overlay-control-label';
import { safeCall } from '@utils/overlays';
+import { renderOptionLabel } from '@utils/select-option-render';
import { getClassMap } from '@utils/theme';
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
+import type { SelectOverlayOption } from '../select/select-interface';
import type { SelectModalOption } from './select-modal-interface';
@@ -85,77 +88,129 @@ export class SelectModal implements ComponentInterface {
}
private renderRadioOptions() {
+ const mode = getIonMode(this);
const checked = this.options.filter((o) => o.checked).map((o) => o.value)[0];
return (
this.callOptionHandler(ev)}>
- {this.options.map((option) => (
- {
+ /**
+ * Cast to `SelectOverlayOption` to access rich content
+ * fields (`startContent`, `endContent`, `description`)
+ * that are passed through from `ion-select` but not
+ * part of the public `SelectModalOption` interface.
+ */
+ const richOption = option as SelectOverlayOption;
+ const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description;
+ const optionLabelOptions = {
+ id: `modal-option-${index}`,
+ label: richOption.text,
+ startContent: richOption.startContent,
+ endContent: richOption.endContent,
+ description: richOption.description,
+ };
+ const defaultLabelPlacement = getOverlayLabelPlacement(mode, 'radio', 'modal');
+ const defaultJustify = getOverlayLabelJustify(mode, 'radio', 'modal');
+
+ return (
+
- this.closeModal()}
- onKeyDown={(ev) => {
- if (ev.key === 'Enter' && !ev.repeat) {
- this.pendingEnterTarget = ev.currentTarget as HTMLElement;
- }
+ class={{
+ // TODO FW-4784
+ 'item-radio-checked': option.value === checked,
+ ...getClassMap(option.cssClass),
}}
- onKeyUp={(ev) => {
- if (ev.key === ' ') {
- // Space selects and dismisses in one press.
- this.closeModal();
- } else if (ev.key === 'Enter') {
- const shouldClose = this.pendingEnterTarget === ev.currentTarget;
- this.pendingEnterTarget = null;
- if (shouldClose) {
+ >
+ this.closeModal()}
+ onKeyDown={(ev) => {
+ if (ev.key === 'Enter' && !ev.repeat) {
+ this.pendingEnterTarget = ev.currentTarget as HTMLElement;
+ }
+ }}
+ onKeyUp={(ev) => {
+ if (ev.key === ' ') {
+ // Space selects and dismisses in one press.
this.closeModal();
+ } else if (ev.key === 'Enter') {
+ const shouldClose = this.pendingEnterTarget === ev.currentTarget;
+ this.pendingEnterTarget = null;
+ if (shouldClose) {
+ this.closeModal();
+ }
}
- }
- }}
- >
- {option.text}
-
-
- ))}
+ }}
+ >
+ {renderOptionLabel(optionLabelOptions, 'select-option-label')}
+
+
+ );
+ })}
);
}
private renderCheckboxOptions() {
- return this.options.map((option) => (
- {
+ /**
+ * Cast to `SelectOverlayOption` to access rich content
+ * fields (`startContent`, `endContent`, `description`)
+ * that are passed through from `ion-select` but not
+ * part of the public `SelectModalOption` interface.
+ */
+ const richOption = option as SelectOverlayOption;
+ const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description;
+ const optionLabelOptions = {
+ id: `modal-option-${index}`,
+ label: richOption.text,
+ startContent: richOption.startContent,
+ endContent: richOption.endContent,
+ description: richOption.description,
+ };
+ const defaultLabelPlacement = getOverlayLabelPlacement(mode, 'checkbox', 'modal');
+ const defaultJustify = getOverlayLabelJustify(mode, 'checkbox', 'modal');
+
+ return (
+
- {
- this.setChecked(ev);
- this.callOptionHandler(ev);
+ class={{
// TODO FW-4784
- forceUpdate(this);
+ 'item-checkbox-checked': option.checked,
+ ...getClassMap(option.cssClass),
}}
>
- {option.text}
-
-
- ));
+ {
+ this.setChecked(ev);
+ this.callOptionHandler(ev);
+ // TODO FW-4784
+ forceUpdate(this);
+ }}
+ >
+ {renderOptionLabel(optionLabelOptions, 'select-option-label')}
+
+
+ );
+ });
}
render() {
diff --git a/core/src/components/select-modal/test/states/index.html b/core/src/components/select-modal/test/states/index.html
new file mode 100644
index 00000000000..5bd4553a7c9
--- /dev/null
+++ b/core/src/components/select-modal/test/states/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+ Select Modal - States
+
+
+
+
+
+
+
+
+
+
+
+
+ Select Modal - States
+
+
+
+
+ Radio
+ Checkbox
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts b/core/src/components/select-modal/test/states/select-modal.e2e.ts
new file mode 100644
index 00000000000..1001513de8b
--- /dev/null
+++ b/core/src/components/select-modal/test/states/select-modal.e2e.ts
@@ -0,0 +1,80 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * This behavior does not vary across directions.
+ */
+configs({ directions: ['ltr'], modes: ['ios', 'md'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('select-modal: states'), () => {
+ /**
+ * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated
+ * projects, suppressing the hover rules:
+ *
+ * - Chromium and WebKit suppress it because of hasTouch and isMobile.
+ * - Headless Firefox doesn't detect input devices and reports no hover
+ * capability regardless of context options, so override it via
+ * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse),
+ * 8 = HOVER, 12 = FINE | HOVER.
+ *
+ * Viewport, userAgent, and scale factor remain mobile-sized.
+ */
+ test.use({
+ hasTouch: false,
+ isMobile: false,
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`/src/components/select-modal/test/states`, config);
+ });
+
+ test('should render all radio states', async ({ page }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+ await page.locator('#single').click();
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('#modal-single');
+ const selectModal = modal.locator('ion-select-modal');
+ await expect(selectModal).toBeVisible();
+
+ /**
+ * After clicking the trigger button, the cursor sits at the
+ * button's screen coordinates — which may coincide with the
+ * "Default" row once the modal opens, depending on mode/viewport.
+ * Without a transition, `mouseenter` doesn't fire and the JS-driven
+ * label swap never runs, causing inconsistent hover states in the
+ * screenshots.
+ */
+ await page.mouse.move(0, 0);
+
+ const defaultRow = selectModal.locator('ion-item').first();
+ await defaultRow.hover();
+
+ await expect(selectModal).toHaveScreenshot(screenshot('select-modal-radio-states'));
+ });
+
+ test('should render all checkbox states', async ({ page }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+ await page.locator('#multiple').click();
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('#modal-multiple');
+ const selectModal = modal.locator('ion-select-modal');
+ await expect(selectModal).toBeVisible();
+
+ /**
+ * After clicking the trigger button, the cursor sits at the
+ * button's screen coordinates — which may coincide with the
+ * "Default" row once the modal opens, depending on mode/viewport.
+ * Without a transition, `mouseenter` doesn't fire and the JS-driven
+ * label swap never runs, causing inconsistent hover states in the
+ * screenshots.
+ */
+ await page.mouse.move(0, 0);
+
+ const defaultRow = selectModal.locator('ion-item').first();
+ await defaultRow.hover();
+
+ await expect(selectModal).toHaveScreenshot(screenshot('select-modal-checkbox-states'));
+ });
+ });
+});
diff --git a/core/src/components/select-option/select-option.overlay.scss b/core/src/components/select-option/select-option.overlay.scss
new file mode 100644
index 00000000000..ba8c19ba5e0
--- /dev/null
+++ b/core/src/components/select-option/select-option.overlay.scss
@@ -0,0 +1,99 @@
+@use "../../themes/ionic.theme.default" as native;
+@use "../../themes/ionic.mixins" as mixins;
+@use "../../themes/ionic.functions.font" as font;
+
+// Select Option - Overlay
+// --------------------------------------------------
+
+// Outer label container, which also spaces the start
+// and end slots when a select option has rich content
+.action-sheet-button-label-has-rich-content,
+.alert-radio-label-has-rich-content,
+.alert-checkbox-label-has-rich-content,
+.select-option-label-has-rich-content {
+ display: flex;
+
+ align-items: center;
+
+ gap: 16px;
+}
+
+/**
+ * Outer label container has rich content
+ * (start, content, description, end) that needs the
+ * label to span the available row width.
+ */
+.action-sheet-button-label-has-rich-content,
+.alert-radio-label-has-rich-content,
+.alert-checkbox-label-has-rich-content,
+.select-option-content {
+ flex: 1;
+}
+
+// Inner label container of a select option when
+// there is rich content within the default slot
+.action-sheet-button-label-text,
+.alert-checkbox-label-text,
+.alert-radio-label-text,
+.select-option-label-text {
+ display: flex;
+
+ align-items: center;
+
+ gap: 12px;
+}
+
+// Start and end slots
+.select-option-start,
+.select-option-end {
+ display: flex;
+
+ align-items: center;
+
+ gap: 8px;
+}
+
+.select-option-description {
+ @include mixins.padding(5px, 0, 0, 0);
+
+ display: block;
+
+ color: native.$text-color-step-300;
+
+ font-size: font.dynamic-font(12px);
+}
+
+// Select Option: Select Modal / Select Popover
+// --------------------------------------------------
+
+/**
+ * Non-rich labels are plain text and should ellipsize when they
+ * overflow the row. Rich-content labels switch to flex so the
+ * start / content / end pieces can lay out side-by-side and wrap.
+ */
+.select-option-label:not(.select-option-label-has-rich-content) {
+ text-overflow: ellipsis;
+
+ white-space: nowrap;
+
+ overflow: hidden;
+}
+
+.select-option-label-has-rich-content {
+ display: flex;
+
+ align-items: center;
+}
+
+ion-radio.select-option-has-rich-content::part(label),
+ion-checkbox.select-option-has-rich-content::part(label),
+.select-option-content {
+ flex: 1;
+
+ /**
+ * Let rich content wrap instead of inheriting the label part's
+ * single-line truncation, so arbitrary slotted elements render
+ * without clipping.
+ */
+ white-space: normal;
+}
diff --git a/core/src/components/select-option/select-option.tsx b/core/src/components/select-option/select-option.tsx
index a3a69815d39..c43802c16de 100644
--- a/core/src/components/select-option/select-option.tsx
+++ b/core/src/components/select-option/select-option.tsx
@@ -3,6 +3,13 @@ import { Component, Element, Host, Prop, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
+/**
+ * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
+ *
+ * @slot - Content is placed between the named slots if provided without a slot.
+ * @slot start - Content is placed to the left of the select option text in LTR, and to the right in RTL.
+ * @slot end - Content is placed to the right of the select option text in LTR, and to the left in RTL.
+ */
@Component({
tag: 'ion-select-option',
shadow: true,
@@ -23,6 +30,43 @@ export class SelectOption implements ComponentInterface {
*/
@Prop() value?: any | null;
+ /**
+ * Text that is placed underneath the option text to provide additional details about the option.
+ */
+ @Prop() description?: string;
+
+ /**
+ * Where the label is placed relative to the option's selection control
+ * (radio circle or checkbox box) when the option is rendered in an
+ * `alert`, `popover`, or `modal` interface.
+ * `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL.
+ * `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL.
+ *
+ * Applies to the `alert`, `popover`, and `modal` interfaces, but has no
+ * visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there).
+ *
+ * When unset, the interface picks a default based on mode and control
+ * type.
+ */
+ @Prop() labelPlacement?: 'start' | 'end';
+
+ /**
+ * How to pack the label and the option's selection control within a line.
+ * `"start"`: The label and radio will appear on the left in LTR and
+ * on the right in RTL.
+ * `"end"`: The label and radio will appear on the right in LTR and
+ * on the left in RTL.
+ * `"space-between"`: The label and radio will appear on opposite
+ * ends of the line with space between the two elements.
+ *
+ * Applies to the `alert`, `popover`, and `modal` interfaces, but has no
+ * visible effect on radio options in `popover` or `modal` on `md` (the radio control is hidden there).
+ *
+ * When unset, the interface picks a default based on mode and control
+ * type.
+ */
+ @Prop() justify?: 'start' | 'end' | 'space-between';
+
render() {
return ;
}
diff --git a/core/src/components/select-option/test/label-placement/index.html b/core/src/components/select-option/test/label-placement/index.html
new file mode 100644
index 00000000000..fdd0d235b5b
--- /dev/null
+++ b/core/src/components/select-option/test/label-placement/index.html
@@ -0,0 +1,68 @@
+
+
+
+
+ Select Option - Label Placement
+
+
+
+
+
+
+
+
+
+
+
+
+ Select Option - Label Placement
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts b/core/src/components/select-option/test/label-placement/select-option.e2e.ts
new file mode 100644
index 00000000000..38f0d321cfd
--- /dev/null
+++ b/core/src/components/select-option/test/label-placement/select-option.e2e.ts
@@ -0,0 +1,79 @@
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * iOS does not respect the viewport so styles must be updated instead.
+ */
+const ALERT_SIZE_OVERRIDES = `
+ ion-alert {
+ --max-width: 560px !important;
+ --max-height: none !important;
+ }
+ ion-alert .alert-radio-group,
+ ion-alert .alert-checkbox-group {
+ max-height: none !important;
+ }
+`;
+
+const INTERFACES = [
+ { name: 'alert', presentEvent: 'ionAlertDidPresent', locator: 'ion-alert .alert-wrapper' },
+ { name: 'popover', presentEvent: 'ionPopoverDidPresent', locator: 'ion-popover' },
+ { name: 'modal', presentEvent: 'ionModalDidPresent', locator: 'ion-modal' },
+] as const;
+
+const JUSTIFY_VARIANTS = ['start', 'end', 'space-between'] as const;
+
+const LABEL_PLACEMENTS = ['start', 'end'] as const;
+
+const FIRST_OPTION_VALUE = `${JUSTIFY_VARIANTS[0]}-short`;
+
+const renderOptions = (labelPlacement: 'start' | 'end') =>
+ JUSTIFY_VARIANTS.flatMap((justify) => {
+ const longLabel = `Justify ${justify} — ${'long label '.repeat(6).trim()}`;
+ return [
+ `Justify ${justify} `,
+ `${longLabel} `,
+ ];
+ }).join('');
+
+const setContentForInterface = async (
+ page: Page,
+ interfaceName: 'alert' | 'popover' | 'modal',
+ labelPlacement: 'start' | 'end',
+ config: object
+) => {
+ await page.setContent(
+ `
+
+ ${renderOptions(labelPlacement)}
+
+ `,
+ config
+ );
+};
+
+configs({ modes: ['md', 'ios'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('select-option: label placement'), () => {
+ for (const { name, presentEvent, locator } of INTERFACES) {
+ test.describe(`${name} interface`, () => {
+ for (const placement of LABEL_PLACEMENTS) {
+ test(`placement ${placement}`, async ({ page }) => {
+ await setContentForInterface(page, name, placement, config);
+
+ if (name === 'alert') {
+ await page.addStyleTag({ content: ALERT_SIZE_OVERRIDES });
+ }
+
+ const didPresent = await page.spyOnEvent(presentEvent);
+ await page.locator('#select').click();
+ await didPresent.next();
+
+ const overlay = page.locator(locator);
+ await expect(overlay).toHaveScreenshot(screenshot(`select-option-label-${name}-${placement}`));
+ });
+ }
+ });
+ }
+ });
+});
diff --git a/core/src/components/select-popover/select-popover.scss b/core/src/components/select-popover/select-popover.scss
index c22aa273211..ed4448f2bdc 100644
--- a/core/src/components/select-popover/select-popover.scss
+++ b/core/src/components/select-popover/select-popover.scss
@@ -1,3 +1,4 @@
+@use "../select-option/select-option.overlay";
@import "../../themes/ionic.globals";
:host ion-list {
diff --git a/core/src/components/select-popover/select-popover.tsx b/core/src/components/select-popover/select-popover.tsx
index 9e9b3fb13b5..3734b71df61 100644
--- a/core/src/components/select-popover/select-popover.tsx
+++ b/core/src/components/select-popover/select-popover.tsx
@@ -1,11 +1,14 @@
import type { ComponentInterface } from '@stencil/core';
import { Element, Component, Host, Prop, h, forceUpdate } from '@stencil/core';
+import { getOverlayLabelJustify, getOverlayLabelPlacement } from '@utils/overlay-control-label';
import { safeCall } from '@utils/overlays';
+import { renderOptionLabel } from '@utils/select-option-render';
import { getClassMap } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
+import type { SelectOverlayOption } from '../select/select-interface';
import type { SelectPopoverOption } from './select-popover-interface';
@@ -121,72 +124,124 @@ export class SelectPopover implements ComponentInterface {
}
renderCheckboxOptions(options: SelectPopoverOption[]) {
- return options.map((option) => (
- {
+ /**
+ * Cast to `SelectOverlayOption` to access rich content
+ * fields (`startContent`, `endContent`, `description`)
+ * that are passed through from `ion-select` but not
+ * part of the public `SelectPopoverOption` interface.
+ */
+ const richOption = option as SelectOverlayOption;
+ const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description;
+ const optionLabelOptions = {
+ id: `popover-option-${index}`,
+ label: richOption.text,
+ startContent: richOption.startContent,
+ endContent: richOption.endContent,
+ description: richOption.description,
+ };
+ const defaultLabelPlacement = getOverlayLabelPlacement(mode, 'checkbox');
+ const defaultJustify = getOverlayLabelJustify(mode, 'checkbox');
+
+ return (
+
- {
- this.setChecked(ev);
- this.callOptionHandler(ev);
+ class={{
// TODO FW-4784
- forceUpdate(this);
+ 'item-checkbox-checked': option.checked,
+ ...getClassMap(option.cssClass),
}}
>
- {option.text}
-
-
- ));
+ {
+ this.setChecked(ev);
+ this.callOptionHandler(ev);
+ // TODO FW-4784
+ forceUpdate(this);
+ }}
+ >
+ {renderOptionLabel(optionLabelOptions, 'select-option-label')}
+
+
+ );
+ });
}
renderRadioOptions(options: SelectPopoverOption[]) {
+ const mode = getIonMode(this);
const checked = options.filter((o) => o.checked).map((o) => o.value)[0];
return (
this.callOptionHandler(ev)}>
- {options.map((option) => (
- {
+ /**
+ * Cast to `SelectOverlayOption` to access rich content
+ * fields (`startContent`, `endContent`, `description`)
+ * that are passed through from `ion-select` but not
+ * part of the public `SelectPopoverOption` interface.
+ */
+ const richOption = option as SelectOverlayOption;
+ const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description;
+ const optionLabelOptions = {
+ id: `popover-option-${index}`,
+ label: richOption.text,
+ startContent: richOption.startContent,
+ endContent: richOption.endContent,
+ description: richOption.description,
+ };
+
+ return (
+
- this.dismissParentPopover()}
- onKeyDown={(ev) => {
- if (ev.key === 'Enter' && !ev.repeat) {
- this.pendingEnterTarget = ev.currentTarget as HTMLElement;
- }
+ class={{
+ // TODO FW-4784
+ 'item-radio-checked': option.value === checked,
+ ...getClassMap(option.cssClass),
}}
- onKeyUp={(ev) => {
- if (ev.key === ' ') {
- // Space selects and dismisses in one press.
- this.dismissParentPopover();
- } else if (ev.key === 'Enter') {
- const shouldDismiss = this.pendingEnterTarget === ev.currentTarget;
- this.pendingEnterTarget = null;
- if (shouldDismiss) {
+ >
+ this.dismissParentPopover()}
+ onKeyDown={(ev) => {
+ if (ev.key === 'Enter' && !ev.repeat) {
+ this.pendingEnterTarget = ev.currentTarget as HTMLElement;
+ }
+ }}
+ onKeyUp={(ev) => {
+ if (ev.key === ' ') {
+ // Space selects and dismisses in one press.
this.dismissParentPopover();
+ } else if (ev.key === 'Enter') {
+ const shouldDismiss = this.pendingEnterTarget === ev.currentTarget;
+ this.pendingEnterTarget = null;
+ if (shouldDismiss) {
+ this.dismissParentPopover();
+ }
}
- }
- }}
- >
- {option.text}
-
-
- ))}
+ }}
+ >
+ {renderOptionLabel(optionLabelOptions, 'select-option-label')}
+
+
+ );
+ })}
);
}
diff --git a/core/src/components/select-popover/test/basic/index.html b/core/src/components/select-popover/test/basic/index.html
index 69b0e78ceba..679ec678d2c 100644
--- a/core/src/components/select-popover/test/basic/index.html
+++ b/core/src/components/select-popover/test/basic/index.html
@@ -2,7 +2,7 @@
- Select - Popover
+ Select Popover - Basic
+
+
+
+ Select Popover - States
+
+
+
+
+
+
+
+
+
+
+
+
+ Select Popover - States
+
+
+
+
+ Radio
+ Checkbox
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts b/core/src/components/select-popover/test/states/select-popover.e2e.ts
new file mode 100644
index 00000000000..fd796dc4ba3
--- /dev/null
+++ b/core/src/components/select-popover/test/states/select-popover.e2e.ts
@@ -0,0 +1,60 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * This behavior does not vary across directions.
+ */
+configs({ directions: ['ltr'], modes: ['ios', 'md'] }).forEach(({ config, screenshot, title }) => {
+ test.describe(title('select-popover: states'), () => {
+ /**
+ * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated
+ * projects, suppressing the hover rules:
+ *
+ * - Chromium and WebKit suppress it because of hasTouch and isMobile.
+ * - Headless Firefox doesn't detect input devices and reports no hover
+ * capability regardless of context options, so override it via
+ * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse),
+ * 8 = HOVER, 12 = FINE | HOVER.
+ *
+ * Viewport, userAgent, and scale factor remain mobile-sized.
+ */
+ test.use({
+ hasTouch: false,
+ isMobile: false,
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`/src/components/select-popover/test/states`, config);
+ });
+
+ test('should render all radio states', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+ await page.locator('#single').click();
+ await ionPopoverDidPresent.next();
+
+ const popover = page.locator('#popover-single');
+ const selectPopover = popover.locator('ion-select-popover');
+ await expect(selectPopover).toBeVisible();
+
+ const defaultRow = selectPopover.locator('ion-item').first();
+ await defaultRow.hover();
+
+ await expect(selectPopover).toHaveScreenshot(screenshot('select-popover-radio-states'));
+ });
+
+ test('should render all checkbox states', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+ await page.locator('#multiple').click();
+ await ionPopoverDidPresent.next();
+
+ const popover = page.locator('#popover-multiple');
+ const selectPopover = popover.locator('ion-select-popover');
+ await expect(selectPopover).toBeVisible();
+
+ const defaultRow = selectPopover.locator('ion-item').first();
+ await defaultRow.hover();
+
+ await expect(selectPopover).toHaveScreenshot(screenshot('select-popover-checkbox-states'));
+ });
+ });
+});
diff --git a/core/src/components/select/select-interface.ts b/core/src/components/select/select-interface.ts
index 8e65377a825..af1a397c9e1 100644
--- a/core/src/components/select/select-interface.ts
+++ b/core/src/components/select/select-interface.ts
@@ -1,3 +1,7 @@
+import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
+import type { AlertInput } from '../alert/alert-interface';
+import type { SelectPopoverOption } from '../select-popover/select-popover-interface';
+
export type SelectInterface = 'action-sheet' | 'popover' | 'alert' | 'modal';
export type SelectCompareFn = (currentValue: any, compareValue: any) => boolean;
@@ -10,3 +14,35 @@ export interface SelectCustomEvent extends CustomEvent {
detail: SelectChangeEventDetail;
target: HTMLIonSelectElement;
}
+
+export interface SelectActionSheetButton extends Omit, RichContentOption {
+ /** The main text for the option as a string or an HTMLElement. */
+ text?: string | HTMLElement;
+}
+
+export interface SelectAlertInput extends Omit, RichContentOption {
+ /** The main label for the option as a string or an HTMLElement. */
+ label?: string | HTMLElement;
+ /** Where the label sits relative to the option's selection control. */
+ labelPlacement?: 'start' | 'end';
+ /** How to pack the label and the option's selection control within a line. */
+ justify?: 'start' | 'end' | 'space-between';
+}
+
+export interface SelectOverlayOption extends Omit, RichContentOption {
+ /** The main text for the option as a string or an HTMLElement. */
+ text?: string | HTMLElement;
+ /** Where the label sits relative to the option's selection control. */
+ labelPlacement?: 'start' | 'end';
+ /** How to pack the label and the option's selection control within a line. */
+ justify?: 'start' | 'end' | 'space-between';
+}
+
+export interface RichContentOption {
+ /** Content to display at the start of the option. */
+ startContent?: HTMLElement;
+ /** Content to display at the end of the option. */
+ endContent?: HTMLElement;
+ /** A description for the option. */
+ description?: string;
+}
diff --git a/core/src/components/select/select.scss b/core/src/components/select/select.scss
index 157b2f5e358..98e9364271e 100644
--- a/core/src/components/select/select.scss
+++ b/core/src/components/select/select.scss
@@ -25,6 +25,14 @@
* @prop --border-width: Width of the select border
*
* @prop --ripple-color: The color of the ripple effect on MD mode.
+ *
+ * @prop --select-text-media-width: The width of media (icons/images) in the select text.
+ * @prop --select-text-media-height: The height of media (icons/images) in the select text.
+ * @prop --select-text-media-border-width: The border width of media (icons/images) in the select text.
+ * @prop --select-text-media-border-color: The border color of media (icons/images) in the select text.
+ * @prop --select-text-media-border-radius: The border radius of media (icons/images) in the select text.
+ * @prop --select-text-media-border-style: The border style of media (icons/images) in the select text.
+ * @prop --select-text-gap: The gap between elements in the select text.
*/
--padding-top: 0px;
--padding-end: 0px;
@@ -37,6 +45,9 @@
--highlight-color-focused: #{ion-color(primary, base)};
--highlight-color-valid: #{ion-color(success, base)};
--highlight-color-invalid: #{ion-color(danger, base)};
+ --select-text-media-height: 1.5em;
+ --select-text-media-width: 1.5em;
+ --select-text-gap: 12px;
/**
* This is a private API that is used to switch
@@ -189,6 +200,30 @@ button {
overflow: hidden;
}
+/**
+ * If the select text contains rich content, we want to add some
+ * spacing between the elements without changing the display to
+ * prevent losing the ellipses behavior.
+ */
+.select-text > * + * {
+ margin-inline-start: var(--select-text-gap);
+}
+
+.select-text img,
+.select-text ion-img,
+.select-text ion-icon,
+.select-text ion-thumbnail,
+.select-text ion-avatar {
+ @include border-radius(var(--select-text-media-border-radius));
+
+ width: var(--select-text-media-width);
+ height: var(--select-text-media-height);
+
+ border-width: var(--select-text-media-border-width);
+ border-style: var(--select-text-media-border-style);
+ border-color: var(--select-text-media-border-color);
+}
+
// Select Wrapper
// --------------------------------------------------
diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx
index 73fb6c59ac7..2dcf7c5cb8f 100644
--- a/core/src/components/select/select.tsx
+++ b/core/src/components/select/select.tsx
@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
+import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
import type { NotchController } from '@utils/forms';
import { compareOptions, createNotchController, isOptionSelected, checkInvalidState } from '@utils/forms';
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
@@ -8,10 +9,12 @@ import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
import type { OverlaySelect } from '@utils/overlays-interface';
import { isRTL } from '@utils/rtl';
+import { reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization';
import { createColorClasses, hostContext } from '@utils/theme';
import { watchForOptions } from '@utils/watch-options';
import { caretDownSharp, chevronExpand } from 'ionicons/icons';
+import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import type {
ActionSheetOptions,
@@ -22,11 +25,15 @@ import type {
StyleEventDetail,
ModalOptions,
} from '../../interface';
-import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
-import type { AlertInput } from '../alert/alert-interface';
-import type { SelectPopoverOption } from '../select-popover/select-popover-interface';
-import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from './select-interface';
+import type {
+ SelectChangeEventDetail,
+ SelectInterface,
+ SelectCompareFn,
+ SelectActionSheetButton,
+ SelectAlertInput,
+ SelectOverlayOption,
+} from './select-interface';
// TODO(FW-2832): types
@@ -68,8 +75,8 @@ export class Select implements ComponentInterface {
private nativeWrapperEl: HTMLElement | undefined;
private notchSpacerEl: HTMLElement | undefined;
private validationObserver?: MutationObserver;
-
private notchController?: NotchController;
+ private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
@Element() el!: HTMLIonSelectElement;
@@ -583,7 +590,7 @@ export class Select implements ComponentInterface {
}
}
- private createActionSheetButtons(data: HTMLIonSelectOptionElement[], selectValue: any): ActionSheetButton[] {
+ private createActionSheetButtons(data: HTMLIonSelectOptionElement[], selectValue: any): SelectActionSheetButton[] {
const actionSheetButtons = data.map((option) => {
const value = getOptionValue(option);
@@ -593,10 +600,12 @@ export class Select implements ComponentInterface {
.join(' ');
const optClass = `${OPTION_CLASS} ${copyClasses}`;
const isSelected = isOptionSelected(selectValue, value, this.compareWith);
+ const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled);
return {
- text: option.textContent,
+ text: content ?? '',
cssClass: optClass,
+ disabled: option.disabled,
handler: () => {
this.setValue(value);
},
@@ -604,7 +613,10 @@ export class Select implements ComponentInterface {
'aria-checked': isSelected ? 'true' : 'false',
role: 'radio',
},
- } as ActionSheetButton;
+ startContent,
+ endContent,
+ description: option.description,
+ } as SelectActionSheetButton;
});
// Add "cancel" button
@@ -623,7 +635,7 @@ export class Select implements ComponentInterface {
data: HTMLIonSelectOptionElement[],
inputType: 'checkbox' | 'radio',
selectValue: any
- ): AlertInput[] {
+ ): SelectAlertInput[] {
const alertInputs = data.map((option) => {
const value = getOptionValue(option);
@@ -632,21 +644,27 @@ export class Select implements ComponentInterface {
.filter((cls) => cls !== 'hydrated')
.join(' ');
const optClass = `${OPTION_CLASS} ${copyClasses}`;
+ const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled);
return {
type: inputType,
cssClass: optClass,
- label: option.textContent || '',
+ label: content ?? '',
value,
checked: isOptionSelected(selectValue, value, this.compareWith),
disabled: option.disabled,
+ startContent,
+ endContent,
+ description: option.description,
+ labelPlacement: option.labelPlacement,
+ justify: option.justify,
};
});
return alertInputs;
}
- private createOverlaySelectOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
+ private createOverlaySelectOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectOverlayOption[] {
const popoverOptions = data.map((option) => {
const value = getOptionValue(option);
@@ -655,9 +673,10 @@ export class Select implements ComponentInterface {
.filter((cls) => cls !== 'hydrated')
.join(' ');
const optClass = `${OPTION_CLASS} ${copyClasses}`;
+ const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled);
return {
- text: option.textContent || '',
+ text: content ?? '',
cssClass: optClass,
value,
checked: isOptionSelected(selectValue, value, this.compareWith),
@@ -668,6 +687,11 @@ export class Select implements ComponentInterface {
this.close();
}
},
+ startContent,
+ endContent,
+ description: option.description,
+ labelPlacement: option.labelPlacement,
+ justify: option.justify,
};
});
@@ -708,6 +732,9 @@ export class Select implements ComponentInterface {
};
}
+ const options = this.createOverlaySelectOptions(this.childOpts, value);
+ const hasRichContent = options.some((opt) => opt.startContent || opt.endContent || opt.description);
+
const popoverOpts: PopoverOptions = {
mode,
event,
@@ -717,14 +744,18 @@ export class Select implements ComponentInterface {
...interfaceOptions,
component: 'ion-select-popover',
- cssClass: ['select-popover', interfaceOptions.cssClass],
+ cssClass: [
+ 'select-popover',
+ hasRichContent ? 'select-popover-rich-content' : undefined,
+ interfaceOptions.cssClass,
+ ],
componentProps: {
header: interfaceOptions.header,
subHeader: interfaceOptions.subHeader,
message: interfaceOptions.message,
multiple,
value,
- options: this.createOverlaySelectOptions(this.childOpts, value),
+ options,
},
};
@@ -895,12 +926,18 @@ export class Select implements ComponentInterface {
return;
}
- private getText(): string {
+ /**
+ * Returns the text to display in the select based on the selected value.
+ *
+ * @param useHTML If `true`, the returned text will include any custom HTML content from the selected option. If `false`, the returned text will be plain text without any HTML. Defaults to `false`.
+ * @returns The text to display in the select, either with or without HTML based on the `useHTML` parameter.
+ */
+ private getText(useHTML = false): string {
const selectedText = this.selectedText;
if (selectedText != null && selectedText !== '') {
return selectedText;
}
- return generateText(this.childOpts, this.value, this.compareWith);
+ return generateText(this.childOpts, this.value, this.compareWith, useHTML);
}
private setFocus() {
@@ -1061,6 +1098,56 @@ export class Select implements ComponentInterface {
return this.renderLabel();
}
+ /**
+ * Wraps text nodes in the select text with span elements
+ * so spacing can be added between elements without
+ * changing the display to prevent losing the ellipses
+ * behavior.
+ *
+ * Only wraps when the string contains HTML elements
+ * alongside text.
+ */
+ private wrapSelectTextNodes(html: string): string {
+ const temp = document.createElement('div');
+ temp.innerHTML = html;
+
+ const hasElements = Array.from(temp.childNodes).some((n) => n.nodeType === Node.ELEMENT_NODE);
+
+ // Return the plain text
+ if (!hasElements) {
+ return html;
+ }
+
+ Array.from(temp.childNodes).forEach((node) => {
+ if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
+ const text = node.textContent;
+
+ /**
+ * Split comma separator from the text content
+ * e.g., ", Bacon" becomes ", " text node + Bacon .
+ */
+ const commaMatch = text.match(/^(,\s*)(.*)/);
+ if (commaMatch) {
+ const commaNode = document.createTextNode(commaMatch[1]);
+ const wrapper = document.createElement('span');
+
+ wrapper.textContent = commaMatch[2];
+ node.parentNode?.replaceChild(wrapper, node);
+ wrapper.parentNode?.insertBefore(commaNode, wrapper);
+
+ return;
+ }
+
+ const wrapper = document.createElement('span');
+
+ node.parentNode?.replaceChild(wrapper, node);
+ wrapper.appendChild(node);
+ }
+ });
+
+ return temp.innerHTML;
+ }
+
/**
* Renders either the placeholder
* or the selected values based on
@@ -1069,7 +1156,7 @@ export class Select implements ComponentInterface {
private renderSelectText() {
const { placeholder } = this;
- const displayValue = this.getText();
+ const displayValue = this.getText(true);
let addPlaceholderClass = false;
let selectText = displayValue;
@@ -1085,6 +1172,11 @@ export class Select implements ComponentInterface {
const textPart = addPlaceholderClass ? 'placeholder' : 'text';
+ if (this.customHTMLEnabled) {
+ const wrapped = this.wrapSelectTextNodes(selectText);
+ return
;
+ }
+
return (
{selectText}
@@ -1113,6 +1205,7 @@ export class Select implements ComponentInterface {
private get ariaLabel() {
const { placeholder, inheritedAttributes } = this;
+ // Get the plain text from the selected text
const displayValue = this.getText();
// The aria label should be preferred over visible text if both are specified
@@ -1332,30 +1425,250 @@ const parseValue = (value: any) => {
const generateText = (
opts: HTMLIonSelectOptionElement[],
value: any | any[],
- compareWith?: string | SelectCompareFn | null
+ compareWith?: string | SelectCompareFn | null,
+ useHTML = false
) => {
if (value === undefined) {
return '';
}
if (Array.isArray(value)) {
return value
- .map((v) => textForValue(opts, v, compareWith))
+ .map((v) => textForValue(opts, v, compareWith, useHTML))
.filter((opt) => opt !== null)
.join(', ');
} else {
- return textForValue(opts, value, compareWith) || '';
+ return textForValue(opts, value, compareWith, useHTML) || '';
}
};
+/**
+ * Returns the display text for a given value from the list of options.
+ * When `useHTML` is true, returns sanitized HTML for the select text.
+ * When `useHTML` is false, returns plain text for aria-label and other
+ * text-only contexts.
+ *
+ * @param opts - The list of ion-select-option elements.
+ * @param value - The value to find the matching option for.
+ * @param compareWith - Custom comparison function or property name.
+ * @param useHTML - If true, returns HTML string. If false, returns plain text.
+ */
const textForValue = (
opts: HTMLIonSelectOptionElement[],
value: any,
- compareWith?: string | SelectCompareFn | null
+ compareWith?: string | SelectCompareFn | null,
+ useHTML = false
): string | null => {
const selectOpt = opts.find((opt) => {
return compareOptions(value, getOptionValue(opt), compareWith);
});
- return selectOpt ? selectOpt.textContent : null;
+ const customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
+
+ if (!selectOpt) {
+ return null;
+ }
+
+ // Return sanitized HTML for the select text
+ if (customHTMLEnabled && useHTML) {
+ return getOptionContent(selectOpt, undefined, true) as string | null;
+ }
+
+ /**
+ * When custom HTML is enabled, extract only the default slot content.
+ * This ensures aria-label and other text-only contexts read only
+ * the relevant option text.
+ */
+ if (customHTMLEnabled) {
+ const content = getOptionContent(selectOpt);
+
+ if (typeof content === 'string') {
+ return content;
+ }
+
+ /**
+ * Elements were found in the default slot, extract and concatenate
+ * their text content while trimming whitespace.
+ */
+ if (content) {
+ const texts = Array.from(content.childNodes)
+ .map((n) => n.textContent?.trim())
+ .filter((t) => t);
+ return texts.join(' ') || null;
+ }
+
+ // Empty option
+ return null;
+ }
+
+ return getDefaultSlotPlainText(selectOpt);
+};
+
+/**
+ * Trims whitespace from all text nodes within a DOM tree.
+ * This prevents invisible layout shifts and unwanted gaps between
+ * elements when HTML content is injected via innerHTML or cloneNode,
+ * as browsers preserve whitespace (tabs, newlines, spaces) from
+ * the original source markup.
+ *
+ * @param node The root node to start trimming text nodes from.
+ */
+const trimTextNodes = (node: Node): void => {
+ node.childNodes.forEach((child) => {
+ if (child.nodeType === Node.TEXT_NODE) {
+ child.textContent = child.textContent?.trim() || '';
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
+ trimTextNodes(child);
+ }
+ });
+};
+
+/**
+ * Extracts and clones content from an `ion-select-option` element
+ * for rendering within overlay interfaces or the select text when `customHTMLEnabled` is `true`.
+ *
+ * @param option - The `ion-select-option` element to extract content from.
+ * @param slotName - Optional slot name to extract. If omitted, extracts the default slot content.
+ * @param useHTML - If `true`, the returned string will include any custom HTML content. If `false`, the returned string will be plain text without any HTML.
+ * @returns When `useHTML` is `true`, a sanitized HTML string. When `false`, a
+ * div element containing cloned child nodes. Returns `null` if no matching
+ * content is found.
+ */
+const getOptionContent = (
+ option: HTMLIonSelectOptionElement,
+ slotName?: string,
+ useHTML: boolean = false
+): HTMLElement | string | null => {
+ let nodes: Node[];
+
+ if (slotName) {
+ // Named slot: get elements with matching slot attribute
+ nodes = Array.from(option.children).filter((el) => el.getAttribute('slot') === slotName);
+ } else {
+ // Default slot: get nodes without a slot attribute
+ const defaultSlot = getOptionDefaultSlot(option) || [];
+ nodes = defaultSlot.filter((node) => {
+ /**
+ * Exclude whitespace-only text nodes (newline noise between
+ * markup elements). Element nodes are always kept, even when
+ * their textContent is empty (e.g.
, ).
+ */
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent?.trim().length !== 0;
+ }
+ return true;
+ });
+ }
+
+ if (nodes.length === 0) {
+ return null;
+ }
+
+ // Return plain text if no elements are found
+ if (!slotName && nodes.every((n) => n.nodeType === Node.TEXT_NODE)) {
+ return nodes.map((n) => n.textContent?.trim()).join(' ') || null;
+ }
+
+ /**
+ * Mirror known custom-element properties (e.g. ion-icon's `icon`)
+ * onto attributes before cloning. Frameworks like Vue set these as
+ * DOM properties, which `cloneNode` doesn't copy, so without this
+ * step the cloned overlay copy renders without the prop's value.
+ */
+ nodes.forEach((n) => {
+ if (n.nodeType === Node.ELEMENT_NODE) {
+ reflectPropertiesToAttributes(n as Element);
+ }
+ });
+
+ // Clone each node into a temporary container
+ const container = document.createElement('div');
+ nodes.forEach((n) => {
+ const clone = n.cloneNode(true);
+ if (clone.nodeType === Node.TEXT_NODE) {
+ clone.textContent = clone.textContent?.trim() || '';
+ } else {
+ trimTextNodes(clone);
+ }
+ container.appendChild(clone);
+ });
+
+ /**
+ * Sanitize the cloned DOM in place. Trusted attributes (size, color,
+ * shape, etc.) are preserved; event handlers, javascript: URLs, and
+ * blocked tags are stripped.
+ */
+ sanitizeDOMTree(container);
+
+ if (useHTML) {
+ return container.innerHTML.trim() || null;
+ }
+
+ return container;
+};
+
+/**
+ * Returns the child nodes that belong to the default slot of an
+ * option element, excluding any nodes that are assigned to named
+ * slots.
+ *
+ * @param option - The `ion-select-option` element to extract default-slot nodes from.
+ * @returns An array of default slot nodes, or `null` if none are found.
+ */
+const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null => {
+ const defaultSlotNodes = Array.from(option.childNodes).filter((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ return !(node as HTMLElement).hasAttribute('slot');
+ }
+ return node.nodeType === Node.TEXT_NODE;
+ });
+
+ if (defaultSlotNodes.length === 0) {
+ return null;
+ }
+
+ return defaultSlotNodes;
+};
+
+/**
+ * Extracts plain text from only the default slot of an option,
+ * excluding content assigned to named slots (start/end).
+ */
+const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => {
+ const texts = Array.from(option.childNodes)
+ .filter((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ return !(node as HTMLElement).hasAttribute('slot');
+ }
+ return node.nodeType === Node.TEXT_NODE;
+ })
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
+ .map((n) => n.textContent?.trim())
+ .filter((t) => t);
+ return texts.join(' ');
+};
+
+/**
+ * Extracts the rich content from an `ion-select-option`.
+ * When `customHTMLEnabled` is `false`, only the plain text from the
+ * default slot is read and the start and end slots are skipped.
+ *
+ * @param option - The `ion-select-option` element to extract content from.
+ * @param customHTMLEnabled - Whether custom HTML rendering is enabled
+ * via the `innerHTMLTemplatesEnabled` config.
+ */
+const extractOptionContent = (option: HTMLIonSelectOptionElement, customHTMLEnabled: boolean) => {
+ if (!customHTMLEnabled) {
+ return {
+ content: getDefaultSlotPlainText(option),
+ startContent: undefined as HTMLElement | undefined,
+ endContent: undefined as HTMLElement | undefined,
+ };
+ }
+
+ return {
+ content: getOptionContent(option),
+ startContent: (getOptionContent(option, 'start') as HTMLElement | null) ?? undefined,
+ endContent: (getOptionContent(option, 'end') as HTMLElement | null) ?? undefined,
+ };
};
let selectIds = 0;
diff --git a/core/src/components/select/test/basic/index.html b/core/src/components/select/test/basic/index.html
index 4e70f5a2603..ebcc1286782 100644
--- a/core/src/components/select/test/basic/index.html
+++ b/core/src/components/select/test/basic/index.html
@@ -29,7 +29,7 @@
-
+
Apples
Oranges
Pears
@@ -37,7 +37,7 @@
-
+
Apples
Oranges
Pears
@@ -45,7 +45,7 @@
-
+
Apples
Oranges
Pears
@@ -53,7 +53,7 @@
-
+
Apples
Oranges
Pears
@@ -67,7 +67,12 @@
-
+
Apple
Apricot
Avocado
@@ -105,12 +110,7 @@
-
+
Apple
Apricot
Avocado
@@ -148,7 +148,7 @@
-
+
Apple
Apricot
Avocado
@@ -186,7 +186,7 @@
-
+
Apple
Apricot
Avocado
@@ -240,7 +240,7 @@
-
+
Bird
Cat
Dog
@@ -249,7 +249,7 @@
-
+
Bird
Cat
Dog
@@ -263,14 +263,12 @@
Custom Interface Options
-
+
Pepperoni
Bacon
@@ -280,8 +278,15 @@
-
-
+
+
Pepperoni
Bacon
Extra Cheese
@@ -290,13 +295,8 @@
-
-
+
+
Pepperoni
Bacon
Extra Cheese
@@ -305,8 +305,8 @@
-
-
+
+
Pepperoni
Bacon
Extra Cheese
@@ -318,30 +318,22 @@
diff --git a/core/src/components/select/test/disabled/select.e2e.ts b/core/src/components/select/test/disabled/select.e2e.ts
index 621ed048d3e..e567e2ff82e 100644
--- a/core/src/components/select/test/disabled/select.e2e.ts
+++ b/core/src/components/select/test/disabled/select.e2e.ts
@@ -1,8 +1,83 @@
import { expect } from '@playwright/test';
+import type { E2ELocator } from '@utils/test/playwright';
import { configs, test } from '@utils/test/playwright';
+const DISABLED_OPTION_INTERFACES = [
+ {
+ name: 'action-sheet',
+ overlayTag: 'ion-action-sheet',
+ didPresent: 'ionActionSheetDidPresent',
+ didDismiss: 'ionActionSheetDidDismiss',
+ // The option itself is the interactive button.
+ controlSuffix: '',
+ },
+ {
+ name: 'alert',
+ overlayTag: 'ion-alert',
+ didPresent: 'ionAlertDidPresent',
+ didDismiss: 'ionAlertDidDismiss',
+ // The option itself is the interactive radio button.
+ controlSuffix: '',
+ },
+ {
+ name: 'popover',
+ overlayTag: 'ion-popover',
+ didPresent: 'ionPopoverDidPresent',
+ didDismiss: 'ionPopoverDidDismiss',
+ // The interactive control is the nested ion-radio.
+ controlSuffix: ' ion-radio',
+ },
+ {
+ name: 'modal',
+ overlayTag: 'ion-modal',
+ didPresent: 'ionModalDidPresent',
+ didDismiss: 'ionModalDidDismiss',
+ // The interactive control is the nested ion-radio.
+ controlSuffix: ' ion-radio',
+ },
+] as const;
+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select: disabled options'), () => {
+ for (const { name, overlayTag, didPresent, didDismiss, controlSuffix } of DISABLED_OPTION_INTERFACES) {
+ test(`${name}: clicking a disabled option should not change the value or dismiss the overlay`, async ({
+ page,
+ }) => {
+ await page.setContent(
+ `
+
+ Oranges
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select') as E2ELocator;
+
+ const ionChange = await select.spyOnEvent('ionChange');
+ const ionDidPresent = await page.spyOnEvent(didPresent);
+ const ionDidDismiss = await page.spyOnEvent(didDismiss);
+
+ await select.click();
+
+ await ionDidPresent.next();
+
+ const overlay = page.locator(overlayTag);
+ const disabledOption = overlay.locator(`.select-interface-option${controlSuffix}`);
+
+ await disabledOption.click({ force: true });
+
+ await page.waitForChanges();
+
+ const value = await select.evaluate((el: HTMLIonSelectElement) => el.value);
+ expect(value).toBeUndefined();
+
+ expect(ionChange).toHaveReceivedEventTimes(0);
+ expect(ionDidDismiss).toHaveReceivedEventTimes(0);
+ await expect(overlay).toBeVisible();
+ });
+ }
+
test('should not focus a disabled option when no value is set', async ({ page, skip }) => {
// TODO (ROU-5437)
skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.');
diff --git a/core/src/components/select/test/rich-content-option/index.html b/core/src/components/select/test/rich-content-option/index.html
new file mode 100644
index 00000000000..78d3f3987c9
--- /dev/null
+++ b/core/src/components/select/test/rich-content-option/index.html
@@ -0,0 +1,696 @@
+
+
+
+
+ Select - Rich Content Option
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select - Rich Content Option
+
+
+
+
+
+
+ Single Value
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ Multiple Value
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+ SVG
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+ NEW
+
+
+
+ Full Content
+
+
+
+
+
+ Slots
+ NEW
+
+
+
+
+
+ Start Slot
+
+
+ End Slot
+ NEW
+
+ Description
+
+ Inner Content
+ NEW
+
+
+
+
+
+ This is a span element
+ NEW
+
+
+
+
+
+ NEW
+
+
+
+
+
+ This is a very long option label that demonstrates how the start and end slots stay at their intrinsic
+ widths while the content area absorbs the remaining row width
+ NEW
+
+
+
+
+
+ SVGs
+
+
+
+
+
+
+
+
+
+
+ Icons
+
+
+
+
+ Images
+
+
+
+
+
+
+ Thumbnails
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts b/core/src/components/select/test/rich-content-option/select.e2e.ts
new file mode 100644
index 00000000000..3acf2d806ac
--- /dev/null
+++ b/core/src/components/select/test/rich-content-option/select.e2e.ts
@@ -0,0 +1,484 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * This behavior does not vary across directions
+ */
+configs({ directions: ['ltr'], modes: ['md', 'ios'] }).forEach(({ title, screenshot, config }) => {
+ test.describe(title('select: rich content options'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/select/test/rich-content-option', config);
+ });
+
+ test('should not have visual regressions for the action sheet interface', async ({ page }) => {
+ const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
+
+ await page.locator('#action-sheet-select').click();
+ await ionActionSheetDidPresent.next();
+
+ // Move mouse to away from the alert so hover styles don't interfere with screenshots
+ await page.mouse.move(0, 0);
+
+ const firstOption = page.locator('ion-action-sheet .select-interface-option').first();
+
+ await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-action-sheet`));
+ });
+
+ test('should not have visual regressions for the alert interface', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+
+ await page.locator('#alert-select').click();
+ await ionAlertDidPresent.next();
+
+ // Move mouse to away from the alert so hover styles don't interfere with screenshots
+ await page.mouse.move(0, 0);
+
+ const firstOption = page.locator('ion-alert .select-interface-option').first();
+
+ await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-alert`));
+ });
+
+ test('should not have visual regressions for the modal interface', async ({ page }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.locator('#modal-select').click();
+ await ionModalDidPresent.next();
+
+ // Move mouse to away from the alert so hover styles don't interfere with screenshots
+ await page.mouse.move(0, 0);
+
+ const firstOption = page.locator('ion-modal .select-interface-option').first();
+
+ await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-modal`));
+ });
+
+ test('should not have visual regressions for the popover interface', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.locator('#popover-select').click();
+ await ionPopoverDidPresent.next();
+
+ // Move mouse to away from the alert so hover styles don't interfere with screenshots
+ await page.mouse.move(0, 0);
+
+ const firstOption = page.locator('ion-popover .select-interface-option').first();
+
+ await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-popover`));
+ });
+ });
+});
+
+/**
+ * This behavior does not vary across modes/directions
+ */
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('select: rich content option functionality'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/select/test/rich-content-option', config);
+ });
+
+ test('it should render for action sheet interface', async ({ page }) => {
+ const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
+ const ionActionSheetDidDismiss = await page.spyOnEvent('ionActionSheetDidDismiss');
+
+ const select = page.locator('#action-sheet-select');
+
+ await select.click();
+
+ await ionActionSheetDidPresent.next();
+
+ const actionSheet = page.locator('ion-action-sheet');
+ const firstOption = actionSheet.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ await ionActionSheetDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for alert interface and single selection', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+
+ const select = page.locator('#alert-select');
+
+ await select.click();
+
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const firstOption = alert.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ // Confirm the selection
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+ await confirmButton.click();
+
+ await ionAlertDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for alert interface and multiple selection', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+
+ const select = page.locator('#alert-select-multiple');
+
+ await select.click();
+
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const firstOption = alert.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ // Confirm the selection
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+ await confirmButton.click();
+
+ await ionAlertDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for modal interface and single selection', async ({ page }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+ const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
+
+ const select = page.locator('#modal-select');
+
+ await select.click();
+
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const firstOption = modal.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ await ionModalDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for modal interface and multiple selection', async ({ page }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+ const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
+
+ const select = page.locator('#modal-select-multiple');
+
+ await select.click();
+
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const firstOption = modal.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ // Confirm the selection
+ const cancelButton = modal.getByRole('button', { name: 'Cancel' });
+
+ await cancelButton.click();
+ await ionModalDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for popover interface and single selection', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+ const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss');
+
+ const select = page.locator('#popover-select');
+
+ await select.click();
+
+ await ionPopoverDidPresent.next();
+
+ const popover = page.locator('ion-popover');
+ const firstOption = popover.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ await ionPopoverDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render for popover interface and multiple selection', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+ const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss');
+
+ const select = page.locator('#popover-select-multiple');
+
+ await select.click();
+
+ await ionPopoverDidPresent.next();
+
+ const popover = page.locator('ion-popover');
+ const firstOption = popover.locator('.select-interface-option').first();
+ const avatar = firstOption.locator('ion-avatar');
+ const firstOptionText = 'Full Content';
+
+ await expect(firstOption).toContainText(firstOptionText);
+ await expect(avatar).toBeVisible();
+
+ // Click on the first option
+ await firstOption.click();
+
+ // Confirm the selection
+ const backdrop = page.locator('ion-backdrop');
+ await backdrop.click({ position: { x: 10, y: 10 } });
+
+ await ionPopoverDidDismiss.next();
+
+ // Verify that the select text includes the option text
+ const selectText = await select.locator('.select-text').textContent();
+
+ expect(selectText).toContain(firstOptionText);
+
+ // Verify that the select text does not include the avatar and badge
+ const selectTextAvatar = select.locator('.select-text ion-avatar');
+ const selectTextBadge = select.locator('.select-text ion-badge');
+
+ await expect(selectTextAvatar).toHaveCount(0);
+ await expect(selectTextBadge).toHaveCount(0);
+ });
+
+ test('it should render the aria label as plain text', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+
+ const select = page.locator('#alert-select');
+
+ await select.click();
+
+ await ionAlertDidPresent.next();
+
+ // The "no-text" option only has a for its label content,
+ // so its aria label should be the span's plain text.
+ const alert = page.locator('ion-alert');
+ const spanOption = alert.locator('.alert-radio-button', { hasText: 'This is a span element' });
+
+ await spanOption.click();
+
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+ await confirmButton.click();
+
+ await ionAlertDidDismiss.next();
+
+ const nativeButton = select.locator('button');
+ const ariaLabel = await nativeButton.getAttribute('aria-label');
+
+ expect(ariaLabel).toContain('This is a span element');
+ });
+ });
+});
+
+/**
+ * This behavior does not vary across modes
+ */
+configs({ modes: ['md'] }).forEach(({ title, config }) => {
+ test.describe(title('select: rich content options'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/select/test/rich-content-option', config);
+ });
+
+ test('it should render slots in the correct places', async ({ page }) => {
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+
+ const select = page.locator('#alert-select');
+
+ await select.click();
+
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const firstOption = alert.locator('.alert-radio-button').first();
+ const startContainer = firstOption.locator('.select-option-start');
+ const endContainer = firstOption.locator('.select-option-end');
+
+ const avatar = startContainer.locator('ion-avatar');
+ const badge = endContainer.locator('ion-badge');
+
+ await expect(avatar).toBeVisible();
+ await expect(badge).toBeVisible();
+
+ const isRTL = await page.evaluate(() => document.dir === 'rtl');
+ const optionBox = await firstOption.boundingBox();
+ const startBox = await startContainer.boundingBox();
+ const endBox = await endContainer.boundingBox();
+ const optionMidpointX = optionBox!.x + optionBox!.width / 2;
+
+ if (isRTL) {
+ // Verify the start container is rendered on the right,
+ // and the end container is rendered on the left
+ expect(startBox!.x).toBeGreaterThan(optionMidpointX);
+ expect(endBox!.x).toBeLessThan(optionMidpointX);
+ } else {
+ // Verify the start container is rendered on the left,
+ // and the end container is rendered on the right
+ expect(startBox!.x).toBeLessThan(optionMidpointX);
+ expect(endBox!.x).toBeGreaterThan(optionMidpointX);
+ }
+ });
+ });
+});
+
+/**
+ * This behavior does not vary across modes/directions
+ */
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('select: rich content options'), () => {
+ test('it should only render text nodes when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => {
+ await page.setContent(
+ `
+
+
+ NEW
+
+
+
+ Full Content
+ This is a span element
+
+
+ `,
+ config
+ );
+
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+ const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss');
+
+ const select = page.locator('#alert-select');
+
+ await select.click();
+
+ await ionAlertDidPresent.next();
+
+ const alert = page.locator('ion-alert');
+ const firstOption = alert.locator('.alert-radio-button').first();
+ const startContainer = firstOption.locator('.select-option-start');
+ const endContainer = firstOption.locator('.select-option-end');
+ const span = firstOption.locator('.span-style');
+
+ await expect(startContainer).toHaveCount(0);
+ await expect(endContainer).toHaveCount(0);
+ await expect(span).toHaveCount(0);
+
+ // Click on the first option
+ await firstOption.click();
+
+ // Confirm the selection
+ const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
+ await confirmButton.click();
+
+ await ionAlertDidDismiss.next();
+
+ const selectText = select.locator('.select-text');
+ const selectTextSpan = selectText.locator('.span-style');
+
+ await expect(selectTextSpan).toHaveCount(0);
+ });
+ });
+});
diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx
index f00c4c66884..ad7c0d3d050 100644
--- a/core/src/components/select/test/select.spec.tsx
+++ b/core/src/components/select/test/select.spec.tsx
@@ -1,6 +1,8 @@
import { h } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';
+import { config } from '../../../global/config';
+import { SelectOption } from '../../select-option/select-option';
import { Select } from '../select';
describe('ion-select', () => {
@@ -157,3 +159,56 @@ describe('ion-select: required', () => {
expect(nativeButton.getAttribute('aria-required')).toBe('false');
});
});
+
+describe('ion-select: option content property reflection', () => {
+ beforeEach(() => {
+ // Cloning rich option content into the select text only happens when
+ // custom HTML rendering is enabled.
+ config.reset({ innerHTMLTemplatesEnabled: true });
+ });
+
+ afterEach(() => {
+ config.reset({});
+ });
+
+ it('should reflect ion-icon DOM properties onto attributes so they survive cloning into the select text', async () => {
+ const page = await newSpecPage({
+ components: [Select, SelectOption],
+ html: ` Star `,
+ });
+
+ const select = page.body.querySelector('ion-select')!;
+ const sourceIcon = select.querySelector('ion-icon')!;
+
+ /**
+ * Frameworks such as Vue set `icon` as a DOM property rather than an
+ * attribute. `cloneNode` only copies attributes, so without reflection
+ * the cloned copy in the select text would lose the icon value.
+ */
+ (sourceIcon as any).icon = 'logo-ionic';
+
+ // Selecting the option rebuilds the displayed text from the option content.
+ select.value = 'star';
+ await page.waitForChanges();
+
+ const renderedIcon = select.shadowRoot!.querySelector('.select-text ion-icon');
+ expect(renderedIcon).not.toBeNull();
+ expect(renderedIcon!.getAttribute('icon')).toBe('logo-ionic');
+ });
+
+ it('should preserve an ion-icon attribute that is already set when cloning into the select text', async () => {
+ const page = await newSpecPage({
+ components: [Select, SelectOption],
+ html: ` Star `,
+ });
+
+ const select = page.body.querySelector('ion-select')!;
+
+ select.value = 'star';
+ await page.waitForChanges();
+
+ const renderedIcon = select.shadowRoot!.querySelector('.select-text ion-icon');
+ expect(renderedIcon).not.toBeNull();
+ expect(renderedIcon!.getAttribute('icon')).toBe('logo-ionic');
+ });
+});
diff --git a/core/src/utils/overlay-control-label.ts b/core/src/utils/overlay-control-label.ts
new file mode 100644
index 00000000000..ffecb19fac7
--- /dev/null
+++ b/core/src/utils/overlay-control-label.ts
@@ -0,0 +1,56 @@
+// TODO(FW-6886, FW-6892, FW-6891): Remove this file in favor of the Modular Ionic component config. Each overlay will be able to select its own defaults for label placement and justify based on the interface and mode, so this utility will no longer be necessary.
+
+import type { Mode } from '../interface';
+
+/**
+ * Returns the default `labelPlacement` for a radio or checkbox option
+ * rendered inside an overlay. Defaults follow each mode's established
+ * option-row layout:
+ * - `ios`: `"start"` for radio in `alert` and `popover`. The `modal`
+ * interface flips iOS radio back to `"end"`. Checkbox is always
+ * `"end"` on iOS.
+ * - everything else (e.g. `md`): `"end"`.
+ *
+ * `interfaceType` is optional; only `"modal"` changes the result, so
+ * callers that aren't a modal can omit it.
+ *
+ * Used by `select-popover` and `select-modal` as the fallback when an
+ * option doesn't explicitly set `labelPlacement`.
+ */
+export const getOverlayLabelPlacement = (
+ mode: Mode,
+ control: 'radio' | 'checkbox',
+ interfaceType?: 'alert' | 'popover' | 'modal'
+): 'start' | 'end' => {
+ if (mode === 'ios' && control === 'radio' && interfaceType !== 'modal') {
+ return 'start';
+ }
+
+ return 'end';
+};
+
+/**
+ * Returns the default `justify` for a radio or checkbox option rendered
+ * inside an overlay. Defaults follow each mode's option-row layout:
+ * - `ios`: `"space-between"` for radio in `alert` and `popover`. The
+ * `modal` interface falls back to `"start"`. Checkbox is always `"start"`
+ * on iOS.
+ * - everything else (e.g. `md`): `"start"`.
+ *
+ * `interfaceType` is optional; only `"modal"` changes the result, so
+ * callers that aren't a modal can omit it.
+ *
+ * Used by `select-popover` and `select-modal` as the fallback when an
+ * option doesn't explicitly set `justify`.
+ */
+export const getOverlayLabelJustify = (
+ mode: Mode,
+ control: 'radio' | 'checkbox',
+ interfaceType?: 'alert' | 'popover' | 'modal'
+): 'start' | 'end' | 'space-between' => {
+ if (mode === 'ios' && control === 'radio' && interfaceType !== 'modal') {
+ return 'space-between';
+ }
+
+ return 'start';
+};
diff --git a/core/src/utils/sanitization/index.ts b/core/src/utils/sanitization/index.ts
index acab505d828..5c31c608f61 100644
--- a/core/src/utils/sanitization/index.ts
+++ b/core/src/utils/sanitization/index.ts
@@ -1,8 +1,22 @@
import { printIonError } from '@utils/logging';
/**
- * Does a simple sanitization of all elements
- * in an untrusted string
+ * Sanitize an untrusted HTML string.
+ *
+ * Parses the string into a detached DOM, removes blocked tags, strips
+ * attributes outside the `allowedAttributes` list (refer `sanitizeElement`),
+ * and scrubs script-scheme URLs. Returns the sanitized HTML string.
+ *
+ * Use this when you have an HTML string from an unknown source and need to
+ * render it via `innerHTML`. Use `sanitizeDOMTree` instead when you already
+ * have a DOM tree and want to sanitize it in place without a string round
+ * trip; both apply the same attribute policy.
+ *
+ * @param untrustedString - The HTML string to sanitize. Pass an
+ * `IonicSafeString` to bypass sanitization, or `undefined` to short-circuit.
+ * @returns The sanitized HTML string, or `undefined` if the input was
+ * `undefined`. Returns `''` if sanitization fails or the input contains
+ * an inline `onload=` handler.
*/
export const sanitizeDOMString = (untrustedString: IonicSafeString | string | undefined): string | undefined => {
try {
@@ -88,6 +102,39 @@ export const sanitizeDOMString = (untrustedString: IonicSafeString | string | un
}
};
+/**
+ * Sanitize an entire trusted DOM tree in place.
+ *
+ * Removes blocked tags (`script`, `iframe`, etc.) from the subtree and
+ * then sanitizes attributes on every remaining element using the same
+ * allowlist policy as `sanitizeDOMString` (refer `sanitizeElement`).
+ * Component presentational attributes (`size`, `color`, `shape`, inline
+ * SVG, `aria-*`, `data-*`) are preserved; `style`, event handlers (`on*`),
+ * form/navigation-hijack attributes, script-scheme URLs, and non-image
+ * `data:` URLs are stripped.
+ *
+ * Use this when you have a DOM tree the developer controls (e.g.
+ * cloned slot content from a component) and you need to render it
+ * elsewhere safely.
+ *
+ * @param root - The root element whose subtree will be sanitized in
+ * place. No-op when the sanitizer is disabled via `Ionic.config`.
+ */
+export const sanitizeDOMTree = (root: HTMLElement) => {
+ if (!isSanitizerEnabled()) {
+ return;
+ }
+
+ blockedTags.forEach((tag) => {
+ const matches = root.querySelectorAll(tag);
+ for (let i = matches.length - 1; i >= 0; i--) {
+ matches[i].remove();
+ }
+ });
+
+ sanitizeElement(root);
+};
+
/**
* Clean up current element based on allowed attributes
* and then recursively dig down into any child elements to
@@ -111,12 +158,27 @@ const sanitizeElement = (element: any) => {
return;
}
+ /**
+ * Always strip `style` (CSS injection, `background:url()` beaconing, UI
+ * spoofing). It is never on the allowlist, but it is removed explicitly
+ * here because some engines (e.g. jsdom) don't enumerate the CSSOM-backed
+ * `style` attribute in `element.attributes`, which would let the loop
+ * below skip over it.
+ */
+ element.removeAttribute('style');
+
for (let i = element.attributes.length - 1; i >= 0; i--) {
const attribute = element.attributes.item(i);
const attributeName = attribute.name;
+ const lowerName = attributeName.toLowerCase();
- // remove non-allowed attribs
- if (!allowedAttributes.includes(attributeName.toLowerCase())) {
+ /**
+ * Remove any attribute that is not on the allowlist. This drops event
+ * handlers (`on*`), `style`, the form/navigation-hijack attributes
+ * (`formaction`, `action`, `target`), namespaced attributes like
+ * `xlink:href`, and anything else not explicitly known to be safe.
+ */
+ if (!isAttributeAllowed(lowerName)) {
element.removeAttribute(attributeName);
continue;
}
@@ -124,22 +186,40 @@ const sanitizeElement = (element: any) => {
// clean up any allowed attribs
// that attempt to do any JS funny-business
const attributeValue = attribute.value;
+ if (attributeValue == null) {
+ continue;
+ }
/**
- * We also need to check the property value
- * as javascript: can allow special characters
- * such as 	 and still be valid (i.e. java	script)
+ * Scrub dangerous schemes from the value. The value is normalized first
+ * (whitespace and ASCII control characters removed) so entity-obfuscated
+ * payloads such as `java script:`, which the parser decodes to
+ * `java\tscript:`, are still caught. Normalizing the value also covers
+ * namespaced attributes, where the previous `element[attributeName]`
+ * property reflection returned `undefined` and let them slip through.
*/
- const propertyValue = element[attributeName];
+ const normalizedValue = attributeValue.replace(controlCharactersAndWhitespace, '').toLowerCase();
+
+ // Script schemes are never allowed, on any attribute.
+ if (normalizedValue.includes('javascript:') || normalizedValue.includes('vbscript:')) {
+ element.removeAttribute(attributeName);
+ continue;
+ }
- /* eslint-disable */
+ /**
+ * For URL-bearing attributes, allow `data:` URIs only for raster
+ * images. Document-bearing types (`text/html`, `image/svg+xml`,
+ * `application/*`, etc.) can carry markup or script when navigated to
+ * or rendered, so they are stripped, while safe image types are kept so
+ * inline images keep working.
+ */
if (
- (attributeValue != null && attributeValue.toLowerCase().includes('javascript:')) ||
- (propertyValue != null && propertyValue.toLowerCase().includes('javascript:'))
+ urlAttributes.includes(lowerName) &&
+ normalizedValue.startsWith('data:') &&
+ !safeDataImageUri.test(normalizedValue)
) {
element.removeAttribute(attributeName);
}
- /* eslint-enable */
}
/**
@@ -175,8 +255,183 @@ const isSanitizerEnabled = (): boolean => {
return true;
};
-const allowedAttributes = ['class', 'id', 'href', 'src', 'name', 'slot'];
-const blockedTags = ['script', 'style', 'iframe', 'meta', 'link', 'object', 'embed'];
+/**
+ * Mirror known custom-element DOM properties onto attributes so they
+ * survive `cloneNode`. Call this on a DOM subtree before cloning it for
+ * rendering elsewhere (e.g. cloning slotted option content into an
+ * overlay).
+ *
+ * Only sets the attribute when the property holds a non-empty string
+ * and the attribute isn't already present, so existing attributes
+ * take precedence.
+ *
+ * @param root - The root element whose subtree (and itself) will be
+ * inspected.
+ */
+export const reflectPropertiesToAttributes = (root: Element): void => {
+ const candidates: Element[] = [];
+ if (root.tagName in elementPropsToReflect) {
+ candidates.push(root);
+ }
+ for (const tagName of Object.keys(elementPropsToReflect)) {
+ candidates.push(...Array.from(root.querySelectorAll(tagName.toLowerCase())));
+ }
+
+ for (const el of candidates) {
+ if (!(el.tagName in elementPropsToReflect)) {
+ continue;
+ }
+ const props = elementPropsToReflect[el.tagName];
+ for (const prop of props) {
+ const value = (el as unknown as Record)[prop];
+ if (typeof value === 'string' && value.length > 0 && !el.hasAttribute(prop)) {
+ el.setAttribute(prop, value);
+ }
+ }
+ }
+};
+
+/**
+ * Attribute names that are always safe to keep. Covers global HTML
+ * attributes, the Ionic component presentational props that cloned rich
+ * content (e.g. `ion-select-option` markup) relies on, and the inert SVG
+ * presentation attributes used by inline icons.
+ *
+ * `aria-*` and `data-*` are allowed separately by prefix (refer
+ * `allowedAttributePrefixes`) since they are inert and not worth
+ * enumerating. URL-bearing names (`href`, `src`) are allowed here, but
+ * their values are still scrubbed for script schemes in `sanitizeElement`.
+ *
+ * Notably absent: `style`, event handlers (`on*`), and the
+ * form/navigation-hijack attributes (`formaction`, `action`, `target`),
+ * which are therefore stripped.
+ */
+const allowedAttributes = [
+ // Global / structural
+ 'class',
+ 'id',
+ 'slot',
+ 'name',
+ 'title',
+ 'alt',
+ 'lang',
+ 'dir',
+ 'role',
+ 'type',
+ 'value',
+ 'disabled',
+ 'width',
+ 'height',
+ 'href',
+ 'src',
+ // Ionic component presentational props
+ 'color',
+ 'size',
+ 'shape',
+ 'fill',
+ 'expand',
+ 'mode',
+ 'theme',
+ 'icon',
+ 'label',
+ 'label-placement',
+ 'justify',
+ 'inset',
+ 'lines',
+ 'ios',
+ 'md',
+ // SVG presentation attributes (compared lowercased, e.g. `viewBox`)
+ 'xmlns',
+ 'viewbox',
+ 'preserveaspectratio',
+ 'stroke',
+ 'stroke-width',
+ 'stroke-linecap',
+ 'stroke-linejoin',
+ 'stroke-opacity',
+ 'stroke-dasharray',
+ 'fill-rule',
+ 'fill-opacity',
+ 'clip-rule',
+ 'd',
+ 'points',
+ 'cx',
+ 'cy',
+ 'r',
+ 'rx',
+ 'ry',
+ 'x',
+ 'y',
+ 'x1',
+ 'y1',
+ 'x2',
+ 'y2',
+ 'transform',
+ 'opacity',
+];
+
+/**
+ * Attribute-name prefixes that are always safe to keep. `aria-*` and
+ * `data-*` attributes cannot execute script or load resources, so they are
+ * allowed wholesale rather than enumerated by name.
+ */
+const allowedAttributePrefixes = ['aria-', 'data-'];
+
+/**
+ * Whether an attribute name (already lowercased) is safe to keep, by exact
+ * match or by an allowed prefix.
+ */
+const isAttributeAllowed = (lowerName: string): boolean => {
+ if (allowedAttributes.includes(lowerName)) {
+ return true;
+ }
+ return allowedAttributePrefixes.some((prefix) => lowerName.startsWith(prefix));
+};
+
+/**
+ * Matches ASCII control characters and whitespace (including the
+ * non-breaking space). Used to normalize attribute values before matching
+ * a URL scheme so entity-obfuscated payloads such as `java script:`
+ * (decoded by the parser to `java\tscript:`) can't smuggle a scheme past
+ * the check.
+ */
+// eslint-disable-next-line no-control-regex -- matching control characters is the point
+const controlCharactersAndWhitespace = /[\u0000-\u0020\u007f-\u00a0]/g;
+
+/**
+ * Attributes whose values are URLs. Their values are scheme-checked in
+ * `sanitizeElement` (e.g. `data:` filtering) beyond the script-scheme scrub
+ * applied to every attribute.
+ */
+const urlAttributes = ['href', 'src'];
+
+/**
+ * Matches a `data:` URI for a raster image type that cannot carry script.
+ * `image/svg+xml` is deliberately excluded (SVG can execute script), as are
+ * document types like `text/html` and `application/*`. The value is already
+ * lowercased and whitespace-stripped before this is tested.
+ */
+const safeDataImageUri = /^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/;
+
+/**
+ * Tags removed entirely (with their subtree) before attribute sanitization.
+ * Exported so tests can assert the set without hardcoding it.
+ */
+export const blockedTags = ['script', 'style', 'iframe', 'meta', 'link', 'object', 'embed', 'base'];
+
+/**
+ * Properties on custom elements that frameworks (Vue, Angular) often
+ * set as DOM properties rather than attributes. `cloneNode` only copies
+ * attributes, so these values are lost when slotted content is cloned
+ * into an overlay. For each known custom element, we mirror the listed
+ * properties onto attributes so the cloned copy still has the data it
+ * needs to render.
+ *
+ * Keyed by uppercased tagName so the lookup matches `Element.tagName`.
+ */
+const elementPropsToReflect: Record = {
+ 'ION-ICON': ['icon', 'name', 'src', 'ios', 'md'],
+};
export class IonicSafeString {
constructor(public value: string) {}
diff --git a/core/src/utils/sanitization/test/sanitization.spec.ts b/core/src/utils/sanitization/test/sanitization.spec.ts
index 295dd306279..22379fe595e 100644
--- a/core/src/utils/sanitization/test/sanitization.spec.ts
+++ b/core/src/utils/sanitization/test/sanitization.spec.ts
@@ -1,4 +1,4 @@
-import { IonicSafeString, sanitizeDOMString } from '..';
+import { blockedTags, IonicSafeString, reflectPropertiesToAttributes, sanitizeDOMString, sanitizeDOMTree } from '..';
describe('sanitizeDOMString', () => {
it('disable sanitizer', () => {
@@ -64,6 +64,243 @@ describe('sanitizeDOMString', () => {
});
});
+describe('sanitizeDOMTree', () => {
+ beforeEach(() => {
+ enableSanitizer(true);
+ });
+
+ it('should strip a blocked ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('script')).toBeNull();
+ expect(root.querySelector('p')).not.toBeNull();
+ });
+
+ it('should strip every blocked tag type', () => {
+ const root = document.createElement('div');
+ root.innerHTML = blockedTags.map((tag) => `<${tag}>${tag}>`).join('') + 'keep ';
+
+ sanitizeDOMTree(root);
+
+ for (const blocked of blockedTags) {
+ expect(root.querySelector(blocked)).toBeNull();
+ }
+ expect(root.querySelector('span')).not.toBeNull();
+ });
+
+ it('should strip blocked elements nested deep in the tree', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('script')).toBeNull();
+ expect(root.querySelector('span')?.textContent).toBe('keep');
+ });
+
+ it('should remove on* event-handler attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'click ';
+
+ sanitizeDOMTree(root);
+
+ const button = root.querySelector('button')!;
+ expect(button.hasAttribute('onclick')).toBe(false);
+ expect(button.hasAttribute('onmouseover')).toBe(false);
+ });
+
+ it('should strip javascript: URLs while keeping the element', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'link ';
+
+ sanitizeDOMTree(root);
+
+ const anchor = root.querySelector('a')!;
+ expect(anchor).not.toBeNull();
+ expect(anchor.hasAttribute('href')).toBe(false);
+ });
+
+ it('should preserve component attributes like size, color, and shape', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'button ';
+
+ sanitizeDOMTree(root);
+
+ const button = root.querySelector('ion-button')!;
+ expect(button.getAttribute('size')).toBe('small');
+ expect(button.getAttribute('color')).toBe('primary');
+ expect(button.getAttribute('shape')).toBe('round');
+ });
+
+ it('should strip the style attribute', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'text ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('span')!.hasAttribute('style')).toBe(false);
+ });
+
+ it('should strip form/navigation-hijack attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'go ';
+
+ sanitizeDOMTree(root);
+
+ const button = root.querySelector('button')!;
+ expect(button.hasAttribute('formaction')).toBe(false);
+ expect(button.hasAttribute('action')).toBe(false);
+ expect(button.hasAttribute('target')).toBe(false);
+ });
+
+ it('should preserve inline SVG presentation attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ '' +
+ ' ';
+
+ sanitizeDOMTree(root);
+
+ const svg = root.querySelector('svg')!;
+ expect(svg.getAttribute('viewBox')).toBe('0 0 24 24');
+ expect(svg.getAttribute('width')).toBe('24');
+ const circle = root.querySelector('circle')!;
+ expect(circle.getAttribute('cx')).toBe('12');
+ expect(circle.getAttribute('r')).toBe('10');
+ expect(circle.getAttribute('fill')).toBe('red');
+ expect(circle.getAttribute('stroke-width')).toBe('2');
+ });
+
+ it('should preserve aria-* and data-* attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = ' ';
+
+ sanitizeDOMTree(root);
+
+ const icon = root.querySelector('ion-icon')!;
+ expect(icon.getAttribute('aria-hidden')).toBe('true');
+ expect(icon.getAttribute('data-value')).toBe('star');
+ });
+
+ it('should strip namespaced attributes such as xlink:href', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'x ';
+
+ sanitizeDOMTree(root);
+
+ const anchor = root.querySelector('a')!;
+ expect(anchor.hasAttribute('xlink:href')).toBe(false);
+ });
+
+ it('should strip entity-obfuscated javascript: schemes', () => {
+ const root = document.createElement('div');
+ // The parser decodes to a tab, hiding the scheme from a naive
+ // substring check; normalization must still catch it.
+ root.innerHTML = 'link ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('a')!.hasAttribute('href')).toBe(false);
+ });
+
+ it('should strip vbscript: schemes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'link ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('a')!.hasAttribute('href')).toBe(false);
+ });
+
+ it('should keep safe image data: URIs', () => {
+ const root = document.createElement('div');
+ const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgAB';
+ root.innerHTML = ` `;
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('img')!.getAttribute('src')).toBe(png);
+ });
+
+ it('should strip document-bearing data: URIs', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ 'html ' +
+ ' ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('a')!.hasAttribute('href')).toBe(false);
+ expect(root.querySelector('img')!.hasAttribute('src')).toBe(false);
+ });
+
+ it('should be a no-op when the sanitizer is disabled', () => {
+ enableSanitizer(false);
+ const root = document.createElement('div');
+ root.innerHTML = 'click ';
+
+ sanitizeDOMTree(root);
+
+ expect(root.querySelector('script')).not.toBeNull();
+ expect(root.querySelector('button')!.hasAttribute('onclick')).toBe(true);
+ });
+});
+
+describe('reflectPropertiesToAttributes', () => {
+ it('should reflect a known DOM property onto its attribute', () => {
+ const icon = document.createElement('ion-icon');
+ (icon as any).name = 'star';
+
+ reflectPropertiesToAttributes(icon);
+
+ expect(icon.getAttribute('name')).toBe('star');
+ });
+
+ it('should reflect properties on a nested element', () => {
+ const root = document.createElement('div');
+ const icon = document.createElement('ion-icon');
+ (icon as any).icon = 'logo-ionic';
+ root.appendChild(icon);
+
+ reflectPropertiesToAttributes(root);
+
+ expect(icon.getAttribute('icon')).toBe('logo-ionic');
+ });
+
+ it('should not overwrite an attribute that is already present', () => {
+ const icon = document.createElement('ion-icon');
+ icon.setAttribute('name', 'existing');
+ (icon as any).name = 'from-property';
+
+ reflectPropertiesToAttributes(icon);
+
+ expect(icon.getAttribute('name')).toBe('existing');
+ });
+
+ it('should ignore empty-string and non-string property values', () => {
+ const icon = document.createElement('ion-icon');
+ (icon as any).name = '';
+ (icon as any).icon = 42;
+
+ reflectPropertiesToAttributes(icon);
+
+ expect(icon.hasAttribute('name')).toBe(false);
+ expect(icon.hasAttribute('icon')).toBe(false);
+ });
+
+ it('should leave elements without reflected properties untouched', () => {
+ const div = document.createElement('div');
+ (div as any).name = 'value';
+
+ reflectPropertiesToAttributes(div);
+
+ expect(div.hasAttribute('name')).toBe(false);
+ });
+});
+
const enableSanitizer = (enable = true) => {
(window as any).Ionic = {};
(window as any).Ionic.config = {};
diff --git a/core/src/utils/select-option-render.tsx b/core/src/utils/select-option-render.tsx
new file mode 100644
index 00000000000..e9fa1f59da6
--- /dev/null
+++ b/core/src/utils/select-option-render.tsx
@@ -0,0 +1,163 @@
+import type { VNode } from '@stencil/core';
+import { h } from '@stencil/core';
+import { sanitizeDOMTree } from '@utils/sanitization';
+
+import type { RichContentOption as RichContentOpt } from '../components/select/select-interface';
+
+interface RichContentOption extends RichContentOpt {
+ /** Unique identifier for stable virtual DOM keys across re-renders. */
+ id: string;
+ /** The main label for the option as a string or an HTMLElement. */
+ label?: string | HTMLElement;
+}
+
+/**
+ * Converts a DOM node into a Stencil VNode (or text string) so the
+ * resulting tree is rendered through the component's normal render
+ * path. Rendering through Stencil ensures that scoped CSS classes
+ * (e.g. `sc-ion-action-sheet-ionic`) are applied to every element.
+ *
+ * Highly recommended to pre-sanitize the source DOM (see
+ * `getOptionContent` in select.tsx). This function performs pure
+ * structural conversion — no security filtering.
+ *
+ * Preserves attributes only — properties set imperatively on the source
+ * element (e.g. `input.value` after a user types) won't carry through.
+ * In practice this isn't a concern: interactive controls shouldn't
+ * appear in select-option rich content since they'd nest inside the
+ * overlay's button/radio/checkbox wrapper, which is invalid HTML and
+ * an accessibility issue.
+ *
+ * @param node - The DOM node to convert. Text nodes become strings,
+ * element nodes become VNodes, and any other node types are skipped.
+ * @param keyPrefix - String prefix used to build a stable VNode key,
+ * so Stencil's diff can preserve elements across re-renders.
+ * @param index - Position of this node among its siblings. Combined
+ * with `keyPrefix` to form the final unique key.
+ * @returns The converted VNode, a text string, or `null` if the node
+ * type isn't supported.
+ *
+ * @internal Exported only so it can be unit tested; not part of the
+ * public API.
+ */
+export const cloneToVNode = (node: Node, keyPrefix: string, index: number): VNode | string | null => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent ?? '';
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+
+ const el = node as Element;
+ const tag = el.tagName.toLowerCase();
+ const key = `${keyPrefix}-${index}`;
+
+ const attrs: Record = { key };
+ for (let i = 0; i < el.attributes.length; i++) {
+ const attr = el.attributes.item(i)!;
+ attrs[attr.name] = attr.value;
+ }
+
+ const children = Array.from(el.childNodes)
+ .map((child, i) => cloneToVNode(child, key, i))
+ .filter((c): c is VNode | string => c !== null);
+
+ return h(tag as any, attrs, children as any);
+};
+
+/**
+ * Renders cloned DOM content as Stencil JSX. Walking the source DOM
+ * into VNodes (rather than injecting it via innerHTML) keeps the
+ * content inside Stencil's render path, so scoped CSS classes are
+ * applied automatically and component attributes like `size` or
+ * `color` survive intact.
+ *
+ * Span elements should be used when this content renders within buttons,
+ * depending on the select interface. Buttons can only have phrasing
+ * content to prevent accessibility issues.
+ *
+ * @param id - Unique identifier for generating stable virtual DOM keys.
+ * @param content - The HTMLElement container whose child nodes will be cloned.
+ * @param className - CSS class applied to the wrapper element.
+ * @param useSpan - Whether to use a span element instead of a div for the wrapper.
+ */
+const renderClonedContent = (id: string, content: HTMLElement, className: string, useSpan = false) => {
+ const Tag = useSpan ? 'span' : 'div';
+ const keyPrefix = `${className}-${id}`;
+
+ sanitizeDOMTree(content);
+
+ return (
+
+ {Array.from(content.childNodes).map((child, i) => cloneToVNode(child, keyPrefix, i))}
+
+ );
+};
+
+/**
+ * Renders the label content for a select option within an overlay
+ * interface based on the presence of rich content.
+ *
+ * Span elements should be used when this content renders within buttons,
+ * depending on the select interface. Buttons can only have phrasing
+ * content to prevent accessibility issues.
+ *
+ * @param option - The content option data containing label, slots,
+ * and description.
+ * @param className - The base CSS class for the label element.
+ * @param useSpan - Whether to use a span element instead of a div for the label.
+ */
+export const renderOptionLabel = (
+ option: RichContentOption,
+ className: string,
+ useSpan = false
+): HTMLElement | string | undefined => {
+ const { id, label, startContent, endContent, description } = option;
+ const hasRichContent = !!startContent || !!endContent || !!description;
+ const Tag = useSpan ? 'span' : 'div';
+
+ // Render simple string label if there is no rich content to display
+ if (!hasRichContent && (typeof label === 'string' || !label)) {
+ return (
+
+ {label}
+
+ );
+ }
+
+ // Render the main label
+ const labelEl =
+ typeof label === 'string' || !label ? (
+ // Label is a simple string or undefined
+ {label}
+ ) : (
+ // Label is an HTMLElement with potential rich content
+ renderClonedContent(id, label, `${className}-text`, useSpan)
+ );
+
+ // No rich content, render just the label
+ if (!hasRichContent) {
+ return (
+
+ {labelEl}
+
+ );
+ }
+
+ // Render label with rich content (start, end, description)
+ return (
+
+ {startContent && renderClonedContent(id, startContent, 'select-option-start', useSpan)}
+
+ {labelEl}
+ {description && (
+
+ {description}
+
+ )}
+
+ {endContent && renderClonedContent(id, endContent, 'select-option-end', useSpan)}
+
+ );
+};
diff --git a/core/src/utils/test/select-option-render.spec.tsx b/core/src/utils/test/select-option-render.spec.tsx
new file mode 100644
index 00000000000..d6b1cb8dc07
--- /dev/null
+++ b/core/src/utils/test/select-option-render.spec.tsx
@@ -0,0 +1,170 @@
+import type { VNode } from '@stencil/core';
+
+import { cloneToVNode, renderOptionLabel } from '../select-option-render';
+
+/**
+ * `cloneToVNode` returns Stencil's internal VNode object, whose fields are
+ * name-mangled (`$tag$`, `$attrs$`, etc.). Casting to this shape keeps the
+ * assertions readable without depending on the public `VNode` type, which
+ * does not expose those runtime fields.
+ */
+interface RuntimeVNode {
+ $tag$: string | null;
+ $text$: string | null;
+ $attrs$: Record | null;
+ $children$: RuntimeVNode[] | null;
+ $key$: string | null;
+}
+
+const asVNode = (value: VNode | string | null): RuntimeVNode => value as unknown as RuntimeVNode;
+
+describe('cloneToVNode', () => {
+ describe('text nodes', () => {
+ it('should return the text content of a text node as a string', () => {
+ const node = document.createTextNode('hello world');
+
+ expect(cloneToVNode(node, 'prefix', 0)).toBe('hello world');
+ });
+
+ it('should return an empty string when text content is empty', () => {
+ const node = document.createTextNode('');
+
+ expect(cloneToVNode(node, 'prefix', 0)).toBe('');
+ });
+ });
+
+ describe('unsupported nodes', () => {
+ it('should return null for a comment node', () => {
+ const node = document.createComment('a comment');
+
+ expect(cloneToVNode(node, 'prefix', 0)).toBeNull();
+ });
+ });
+
+ describe('element nodes', () => {
+ it('should convert an element to a VNode with the lowercased tag name', () => {
+ const el = document.createElement('SPAN');
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ expect(vnode.$tag$).toBe('span');
+ });
+
+ it('should build a stable key from the prefix and index', () => {
+ const el = document.createElement('div');
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 3));
+
+ expect(vnode.$key$).toBe('prefix-3');
+ expect(vnode.$attrs$?.key).toBe('prefix-3');
+ });
+
+ it('should copy all attributes from the source element', () => {
+ const el = document.createElement('span');
+ el.setAttribute('class', 'foo bar');
+ el.setAttribute('data-value', '42');
+ el.setAttribute('aria-label', 'label');
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ expect(vnode.$attrs$).toEqual({
+ key: 'prefix-0',
+ class: 'foo bar',
+ 'data-value': '42',
+ 'aria-label': 'label',
+ });
+ });
+
+ it('should recursively convert child element nodes', () => {
+ const el = document.createElement('div');
+ el.appendChild(document.createElement('span'));
+ el.appendChild(document.createElement('img'));
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ expect(vnode.$children$).toHaveLength(2);
+ expect(vnode.$children$?.[0].$tag$).toBe('span');
+ expect(vnode.$children$?.[1].$tag$).toBe('img');
+ });
+
+ it('should derive child keys from the parent key', () => {
+ const el = document.createElement('div');
+ el.appendChild(document.createElement('span'));
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 2));
+
+ // Parent key is `prefix-2`, so the first child key is `prefix-2-0`
+ expect(vnode.$children$?.[0].$key$).toBe('prefix-2-0');
+ });
+
+ it('should preserve text child content', () => {
+ const el = document.createElement('span');
+ el.appendChild(document.createTextNode('inner text'));
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ // `cloneToVNode` returns the text as a string, which `h` wraps into a
+ // text VNode (`$tag$` null, content on `$text$`).
+ expect(vnode.$children$).toHaveLength(1);
+ expect(vnode.$children$?.[0].$tag$).toBeNull();
+ expect(vnode.$children$?.[0].$text$).toBe('inner text');
+ });
+
+ it('should filter out unsupported child nodes', () => {
+ const el = document.createElement('div');
+ el.appendChild(document.createElement('span'));
+ el.appendChild(document.createComment('skip me'));
+ el.appendChild(document.createTextNode('keep me'));
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ // The comment node is dropped, leaving the span and the text
+ expect(vnode.$children$).toHaveLength(2);
+ expect(vnode.$children$?.[0].$tag$).toBe('span');
+ expect(vnode.$children$?.[1].$text$).toBe('keep me');
+ });
+
+ it('should convert a deeply nested structure', () => {
+ const el = document.createElement('div');
+ const child = document.createElement('span');
+ const grandchild = document.createElement('strong');
+ grandchild.appendChild(document.createTextNode('deep'));
+ child.appendChild(grandchild);
+ el.appendChild(child);
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ const span = vnode.$children$?.[0];
+ const strong = span?.$children$?.[0];
+ expect(span?.$tag$).toBe('span');
+ expect(strong?.$tag$).toBe('strong');
+ expect(strong?.$children$?.[0].$text$).toBe('deep');
+ });
+
+ it('should produce no children for an empty element', () => {
+ const el = document.createElement('div');
+
+ const vnode = asVNode(cloneToVNode(el, 'prefix', 0));
+
+ // `h` normalizes an empty children array to `null`
+ expect(vnode.$children$).toBeNull();
+ });
+ });
+});
+
+describe('renderOptionLabel', () => {
+ it('should sanitize an HTMLElement label that bypassed ion-select', () => {
+ // Mirrors a vanilla JS caller passing DOM straight to an overlay, which
+ // never runs through `getOptionContent`'s `sanitizeDOMTree`.
+ const label = document.createElement('span');
+ label.innerHTML = 'hi ';
+
+ const result = asVNode(renderOptionLabel({ id: '1', label }, 'select-option') as unknown as VNode);
+
+ const anchor = result.$children$?.[0].$children$?.[0];
+ expect(anchor?.$tag$).toBe('a');
+ expect(anchor?.$attrs$?.href).toBeUndefined();
+ expect(anchor?.$attrs$?.onclick).toBeUndefined();
+ expect(anchor?.$attrs$?.class).toBe('safe');
+ });
+});
diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts
index 943c4de989f..66feafc701c 100644
--- a/packages/angular/src/directives/proxies.ts
+++ b/packages/angular/src/directives/proxies.ts
@@ -2365,14 +2365,14 @@ export declare interface IonSelectModal extends Components.IonSelectModal {}
@ProxyCmp({
- inputs: ['disabled', 'value']
+ inputs: ['description', 'disabled', 'justify', 'labelPlacement', 'mode', 'value']
})
@Component({
selector: 'ion-select-option',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ' ',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
- inputs: ['disabled', 'value'],
+ inputs: ['description', 'disabled', 'justify', 'labelPlacement', 'mode', 'value'],
standalone: false
})
export class IonSelectOption {
diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts
index 274560e5981..5507445158a 100644
--- a/packages/angular/standalone/src/directives/proxies.ts
+++ b/packages/angular/standalone/src/directives/proxies.ts
@@ -1917,14 +1917,14 @@ export declare interface IonSelectModal extends Components.IonSelectModal {}
@ProxyCmp({
defineCustomElementFn: defineIonSelectOption,
- inputs: ['disabled', 'value']
+ inputs: ['description', 'disabled', 'justify', 'labelPlacement', 'mode', 'value']
})
@Component({
selector: 'ion-select-option',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ' ',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
- inputs: ['disabled', 'value'],
+ inputs: ['description', 'disabled', 'justify', 'labelPlacement', 'mode', 'value'],
})
export class IonSelectOption {
protected el: HTMLIonSelectOptionElement;
diff --git a/packages/react/src/components/components.ts b/packages/react/src/components/components.ts
index 7080edec552..785a7bfab0e 100644
--- a/packages/react/src/components/components.ts
+++ b/packages/react/src/components/components.ts
@@ -11,1430 +11,1027 @@ import type { EventName, StencilReactComponent } from '@stencil/react-output-tar
import { createComponent } from '@stencil/react-output-target/runtime';
import React from 'react';
-import {
- type AccordionGroupChangeEventDetail,
- type BreadcrumbCollapsedClickEventDetail,
- type CheckboxChangeEventDetail,
- type DatetimeChangeEventDetail,
- type InputChangeEventDetail,
- type InputInputEventDetail,
- type InputOtpChangeEventDetail,
- type InputOtpCompleteEventDetail,
- type InputOtpInputEventDetail,
- type IonAccordionGroupCustomEvent,
- type IonBackdropCustomEvent,
- type IonBreadcrumbsCustomEvent,
- type IonCheckboxCustomEvent,
- type IonContentCustomEvent,
- type IonDatetimeCustomEvent,
- type IonImgCustomEvent,
- type IonInfiniteScrollCustomEvent,
- type IonInputCustomEvent,
- type IonInputOtpCustomEvent,
- type IonItemOptionsCustomEvent,
- type IonItemSlidingCustomEvent,
- type IonMenuCustomEvent,
- type IonNavCustomEvent,
- type IonPickerColumnCustomEvent,
- type IonRadioCustomEvent,
- type IonRadioGroupCustomEvent,
- type IonRangeCustomEvent,
- type IonRefresherCustomEvent,
- type IonReorderGroupCustomEvent,
- type IonSearchbarCustomEvent,
- type IonSegmentCustomEvent,
- type IonSegmentViewCustomEvent,
- type IonSelectCustomEvent,
- type IonSplitPaneCustomEvent,
- type IonTextareaCustomEvent,
- type IonToggleCustomEvent,
- type ItemReorderEventDetail,
- type MenuCloseEventDetail,
- type PickerColumnChangeEventDetail,
- type RadioGroupChangeEventDetail,
- type RangeChangeEventDetail,
- type RangeKnobMoveEndEventDetail,
- type RangeKnobMoveStartEventDetail,
- type RefresherEventDetail,
- type RefresherPullEndEventDetail,
- type ReorderEndEventDetail,
- type ReorderMoveEventDetail,
- type ScrollBaseDetail,
- type ScrollDetail,
- type SearchbarChangeEventDetail,
- type SearchbarInputEventDetail,
- type SegmentChangeEventDetail,
- type SegmentViewScrollEvent,
- type SelectChangeEventDetail,
- type TextareaChangeEventDetail,
- type TextareaInputEventDetail,
- type ToggleChangeEventDetail,
-} from '@ionic/core';
-import type { Components } from '@ionic/core/components';
-import {
- IonAccordionGroup as IonAccordionGroupElement,
- defineCustomElement as defineIonAccordionGroup,
-} from '@ionic/core/components/ion-accordion-group.js';
-import {
- IonAccordion as IonAccordionElement,
- defineCustomElement as defineIonAccordion,
-} from '@ionic/core/components/ion-accordion.js';
-import {
- IonAvatar as IonAvatarElement,
- defineCustomElement as defineIonAvatar,
-} from '@ionic/core/components/ion-avatar.js';
-import {
- IonBackdrop as IonBackdropElement,
- defineCustomElement as defineIonBackdrop,
-} from '@ionic/core/components/ion-backdrop.js';
-import {
- IonBadge as IonBadgeElement,
- defineCustomElement as defineIonBadge,
-} from '@ionic/core/components/ion-badge.js';
-import {
- IonBreadcrumbs as IonBreadcrumbsElement,
- defineCustomElement as defineIonBreadcrumbs,
-} from '@ionic/core/components/ion-breadcrumbs.js';
-import {
- IonButtons as IonButtonsElement,
- defineCustomElement as defineIonButtons,
-} from '@ionic/core/components/ion-buttons.js';
-import {
- IonCardContent as IonCardContentElement,
- defineCustomElement as defineIonCardContent,
-} from '@ionic/core/components/ion-card-content.js';
-import {
- IonCardHeader as IonCardHeaderElement,
- defineCustomElement as defineIonCardHeader,
-} from '@ionic/core/components/ion-card-header.js';
-import {
- IonCardSubtitle as IonCardSubtitleElement,
- defineCustomElement as defineIonCardSubtitle,
-} from '@ionic/core/components/ion-card-subtitle.js';
-import {
- IonCardTitle as IonCardTitleElement,
- defineCustomElement as defineIonCardTitle,
-} from '@ionic/core/components/ion-card-title.js';
-import {
- IonCheckbox as IonCheckboxElement,
- defineCustomElement as defineIonCheckbox,
-} from '@ionic/core/components/ion-checkbox.js';
-import { IonChip as IonChipElement, defineCustomElement as defineIonChip } from '@ionic/core/components/ion-chip.js';
-import { IonCol as IonColElement, defineCustomElement as defineIonCol } from '@ionic/core/components/ion-col.js';
-import {
- IonContent as IonContentElement,
- defineCustomElement as defineIonContent,
-} from '@ionic/core/components/ion-content.js';
-import {
- IonDatetimeButton as IonDatetimeButtonElement,
- defineCustomElement as defineIonDatetimeButton,
-} from '@ionic/core/components/ion-datetime-button.js';
-import {
- IonDatetime as IonDatetimeElement,
- defineCustomElement as defineIonDatetime,
-} from '@ionic/core/components/ion-datetime.js';
-import {
- IonFabList as IonFabListElement,
- defineCustomElement as defineIonFabList,
-} from '@ionic/core/components/ion-fab-list.js';
-import { IonFab as IonFabElement, defineCustomElement as defineIonFab } from '@ionic/core/components/ion-fab.js';
-import {
- IonFooter as IonFooterElement,
- defineCustomElement as defineIonFooter,
-} from '@ionic/core/components/ion-footer.js';
-import { IonGrid as IonGridElement, defineCustomElement as defineIonGrid } from '@ionic/core/components/ion-grid.js';
-import {
- IonHeader as IonHeaderElement,
- defineCustomElement as defineIonHeader,
-} from '@ionic/core/components/ion-header.js';
-import { IonImg as IonImgElement, defineCustomElement as defineIonImg } from '@ionic/core/components/ion-img.js';
-import {
- IonInfiniteScrollContent as IonInfiniteScrollContentElement,
- defineCustomElement as defineIonInfiniteScrollContent,
-} from '@ionic/core/components/ion-infinite-scroll-content.js';
-import {
- IonInfiniteScroll as IonInfiniteScrollElement,
- defineCustomElement as defineIonInfiniteScroll,
-} from '@ionic/core/components/ion-infinite-scroll.js';
-import {
- IonInputOtp as IonInputOtpElement,
- defineCustomElement as defineIonInputOtp,
-} from '@ionic/core/components/ion-input-otp.js';
-import {
- IonInputPasswordToggle as IonInputPasswordToggleElement,
- defineCustomElement as defineIonInputPasswordToggle,
-} from '@ionic/core/components/ion-input-password-toggle.js';
-import {
- IonInput as IonInputElement,
- defineCustomElement as defineIonInput,
-} from '@ionic/core/components/ion-input.js';
-import {
- IonItemDivider as IonItemDividerElement,
- defineCustomElement as defineIonItemDivider,
-} from '@ionic/core/components/ion-item-divider.js';
-import {
- IonItemGroup as IonItemGroupElement,
- defineCustomElement as defineIonItemGroup,
-} from '@ionic/core/components/ion-item-group.js';
-import {
- IonItemOptions as IonItemOptionsElement,
- defineCustomElement as defineIonItemOptions,
-} from '@ionic/core/components/ion-item-options.js';
-import {
- IonItemSliding as IonItemSlidingElement,
- defineCustomElement as defineIonItemSliding,
-} from '@ionic/core/components/ion-item-sliding.js';
-import {
- IonLabel as IonLabelElement,
- defineCustomElement as defineIonLabel,
-} from '@ionic/core/components/ion-label.js';
-import {
- IonListHeader as IonListHeaderElement,
- defineCustomElement as defineIonListHeader,
-} from '@ionic/core/components/ion-list-header.js';
-import { IonList as IonListElement, defineCustomElement as defineIonList } from '@ionic/core/components/ion-list.js';
-import {
- IonMenuButton as IonMenuButtonElement,
- defineCustomElement as defineIonMenuButton,
-} from '@ionic/core/components/ion-menu-button.js';
-import {
- IonMenuToggle as IonMenuToggleElement,
- defineCustomElement as defineIonMenuToggle,
-} from '@ionic/core/components/ion-menu-toggle.js';
-import { IonMenu as IonMenuElement, defineCustomElement as defineIonMenu } from '@ionic/core/components/ion-menu.js';
-import {
- IonNavLink as IonNavLinkElement,
- defineCustomElement as defineIonNavLink,
-} from '@ionic/core/components/ion-nav-link.js';
-import { IonNav as IonNavElement, defineCustomElement as defineIonNav } from '@ionic/core/components/ion-nav.js';
-import { IonNote as IonNoteElement, defineCustomElement as defineIonNote } from '@ionic/core/components/ion-note.js';
-import {
- IonPickerColumnOption as IonPickerColumnOptionElement,
- defineCustomElement as defineIonPickerColumnOption,
-} from '@ionic/core/components/ion-picker-column-option.js';
-import {
- IonPickerColumn as IonPickerColumnElement,
- defineCustomElement as defineIonPickerColumn,
-} from '@ionic/core/components/ion-picker-column.js';
-import {
- IonPicker as IonPickerElement,
- defineCustomElement as defineIonPicker,
-} from '@ionic/core/components/ion-picker.js';
-import {
- IonProgressBar as IonProgressBarElement,
- defineCustomElement as defineIonProgressBar,
-} from '@ionic/core/components/ion-progress-bar.js';
-import {
- IonRadioGroup as IonRadioGroupElement,
- defineCustomElement as defineIonRadioGroup,
-} from '@ionic/core/components/ion-radio-group.js';
-import {
- IonRadio as IonRadioElement,
- defineCustomElement as defineIonRadio,
-} from '@ionic/core/components/ion-radio.js';
-import {
- IonRange as IonRangeElement,
- defineCustomElement as defineIonRange,
-} from '@ionic/core/components/ion-range.js';
-import {
- IonRefresherContent as IonRefresherContentElement,
- defineCustomElement as defineIonRefresherContent,
-} from '@ionic/core/components/ion-refresher-content.js';
-import {
- IonRefresher as IonRefresherElement,
- defineCustomElement as defineIonRefresher,
-} from '@ionic/core/components/ion-refresher.js';
-import {
- IonReorderGroup as IonReorderGroupElement,
- defineCustomElement as defineIonReorderGroup,
-} from '@ionic/core/components/ion-reorder-group.js';
-import {
- IonReorder as IonReorderElement,
- defineCustomElement as defineIonReorder,
-} from '@ionic/core/components/ion-reorder.js';
-import {
- IonRippleEffect as IonRippleEffectElement,
- defineCustomElement as defineIonRippleEffect,
-} from '@ionic/core/components/ion-ripple-effect.js';
-import { IonRow as IonRowElement, defineCustomElement as defineIonRow } from '@ionic/core/components/ion-row.js';
-import {
- IonSearchbar as IonSearchbarElement,
- defineCustomElement as defineIonSearchbar,
-} from '@ionic/core/components/ion-searchbar.js';
-import {
- IonSegmentButton as IonSegmentButtonElement,
- defineCustomElement as defineIonSegmentButton,
-} from '@ionic/core/components/ion-segment-button.js';
-import {
- IonSegmentContent as IonSegmentContentElement,
- defineCustomElement as defineIonSegmentContent,
-} from '@ionic/core/components/ion-segment-content.js';
-import {
- IonSegmentView as IonSegmentViewElement,
- defineCustomElement as defineIonSegmentView,
-} from '@ionic/core/components/ion-segment-view.js';
-import {
- IonSegment as IonSegmentElement,
- defineCustomElement as defineIonSegment,
-} from '@ionic/core/components/ion-segment.js';
-import {
- IonSelectModal as IonSelectModalElement,
- defineCustomElement as defineIonSelectModal,
-} from '@ionic/core/components/ion-select-modal.js';
-import {
- IonSelectOption as IonSelectOptionElement,
- defineCustomElement as defineIonSelectOption,
-} from '@ionic/core/components/ion-select-option.js';
-import {
- IonSelect as IonSelectElement,
- defineCustomElement as defineIonSelect,
-} from '@ionic/core/components/ion-select.js';
-import {
- IonSkeletonText as IonSkeletonTextElement,
- defineCustomElement as defineIonSkeletonText,
-} from '@ionic/core/components/ion-skeleton-text.js';
-import {
- IonSpinner as IonSpinnerElement,
- defineCustomElement as defineIonSpinner,
-} from '@ionic/core/components/ion-spinner.js';
-import {
- IonSplitPane as IonSplitPaneElement,
- defineCustomElement as defineIonSplitPane,
-} from '@ionic/core/components/ion-split-pane.js';
-import { IonTab as IonTabElement, defineCustomElement as defineIonTab } from '@ionic/core/components/ion-tab.js';
-import { IonText as IonTextElement, defineCustomElement as defineIonText } from '@ionic/core/components/ion-text.js';
-import {
- IonTextarea as IonTextareaElement,
- defineCustomElement as defineIonTextarea,
-} from '@ionic/core/components/ion-textarea.js';
-import {
- IonThumbnail as IonThumbnailElement,
- defineCustomElement as defineIonThumbnail,
-} from '@ionic/core/components/ion-thumbnail.js';
-import {
- IonTitle as IonTitleElement,
- defineCustomElement as defineIonTitle,
-} from '@ionic/core/components/ion-title.js';
-import {
- IonToggle as IonToggleElement,
- defineCustomElement as defineIonToggle,
-} from '@ionic/core/components/ion-toggle.js';
-import {
- IonToolbar as IonToolbarElement,
- defineCustomElement as defineIonToolbar,
-} from '@ionic/core/components/ion-toolbar.js';
+import { type AccordionGroupChangeEventDetail, type BreadcrumbCollapsedClickEventDetail, type CheckboxChangeEventDetail, type DatetimeChangeEventDetail, type InputChangeEventDetail, type InputInputEventDetail, type InputOtpChangeEventDetail, type InputOtpCompleteEventDetail, type InputOtpInputEventDetail, type IonAccordionGroupCustomEvent, type IonBackdropCustomEvent, type IonBreadcrumbsCustomEvent, type IonCheckboxCustomEvent, type IonContentCustomEvent, type IonDatetimeCustomEvent, type IonImgCustomEvent, type IonInfiniteScrollCustomEvent, type IonInputCustomEvent, type IonInputOtpCustomEvent, type IonItemOptionsCustomEvent, type IonItemSlidingCustomEvent, type IonMenuCustomEvent, type IonNavCustomEvent, type IonPickerColumnCustomEvent, type IonRadioCustomEvent, type IonRadioGroupCustomEvent, type IonRangeCustomEvent, type IonRefresherCustomEvent, type IonReorderGroupCustomEvent, type IonSearchbarCustomEvent, type IonSegmentCustomEvent, type IonSegmentViewCustomEvent, type IonSelectCustomEvent, type IonSplitPaneCustomEvent, type IonTextareaCustomEvent, type IonToggleCustomEvent, type ItemReorderEventDetail, type MenuCloseEventDetail, type PickerColumnChangeEventDetail, type RadioGroupChangeEventDetail, type RangeChangeEventDetail, type RangeKnobMoveEndEventDetail, type RangeKnobMoveStartEventDetail, type RefresherEventDetail, type RefresherPullEndEventDetail, type ReorderEndEventDetail, type ReorderMoveEventDetail, type ScrollBaseDetail, type ScrollDetail, type SearchbarChangeEventDetail, type SearchbarInputEventDetail, type SegmentChangeEventDetail, type SegmentViewScrollEvent, type SelectChangeEventDetail, type TextareaChangeEventDetail, type TextareaInputEventDetail, type ToggleChangeEventDetail } from "@ionic/core";
+import type { Components } from "@ionic/core/components";
+import { IonAccordionGroup as IonAccordionGroupElement, defineCustomElement as defineIonAccordionGroup } from "@ionic/core/components/ion-accordion-group.js";
+import { IonAccordion as IonAccordionElement, defineCustomElement as defineIonAccordion } from "@ionic/core/components/ion-accordion.js";
+import { IonAvatar as IonAvatarElement, defineCustomElement as defineIonAvatar } from "@ionic/core/components/ion-avatar.js";
+import { IonBackdrop as IonBackdropElement, defineCustomElement as defineIonBackdrop } from "@ionic/core/components/ion-backdrop.js";
+import { IonBadge as IonBadgeElement, defineCustomElement as defineIonBadge } from "@ionic/core/components/ion-badge.js";
+import { IonBreadcrumbs as IonBreadcrumbsElement, defineCustomElement as defineIonBreadcrumbs } from "@ionic/core/components/ion-breadcrumbs.js";
+import { IonButtons as IonButtonsElement, defineCustomElement as defineIonButtons } from "@ionic/core/components/ion-buttons.js";
+import { IonCardContent as IonCardContentElement, defineCustomElement as defineIonCardContent } from "@ionic/core/components/ion-card-content.js";
+import { IonCardHeader as IonCardHeaderElement, defineCustomElement as defineIonCardHeader } from "@ionic/core/components/ion-card-header.js";
+import { IonCardSubtitle as IonCardSubtitleElement, defineCustomElement as defineIonCardSubtitle } from "@ionic/core/components/ion-card-subtitle.js";
+import { IonCardTitle as IonCardTitleElement, defineCustomElement as defineIonCardTitle } from "@ionic/core/components/ion-card-title.js";
+import { IonCheckbox as IonCheckboxElement, defineCustomElement as defineIonCheckbox } from "@ionic/core/components/ion-checkbox.js";
+import { IonChip as IonChipElement, defineCustomElement as defineIonChip } from "@ionic/core/components/ion-chip.js";
+import { IonCol as IonColElement, defineCustomElement as defineIonCol } from "@ionic/core/components/ion-col.js";
+import { IonContent as IonContentElement, defineCustomElement as defineIonContent } from "@ionic/core/components/ion-content.js";
+import { IonDatetimeButton as IonDatetimeButtonElement, defineCustomElement as defineIonDatetimeButton } from "@ionic/core/components/ion-datetime-button.js";
+import { IonDatetime as IonDatetimeElement, defineCustomElement as defineIonDatetime } from "@ionic/core/components/ion-datetime.js";
+import { IonFabList as IonFabListElement, defineCustomElement as defineIonFabList } from "@ionic/core/components/ion-fab-list.js";
+import { IonFab as IonFabElement, defineCustomElement as defineIonFab } from "@ionic/core/components/ion-fab.js";
+import { IonFooter as IonFooterElement, defineCustomElement as defineIonFooter } from "@ionic/core/components/ion-footer.js";
+import { IonGrid as IonGridElement, defineCustomElement as defineIonGrid } from "@ionic/core/components/ion-grid.js";
+import { IonHeader as IonHeaderElement, defineCustomElement as defineIonHeader } from "@ionic/core/components/ion-header.js";
+import { IonImg as IonImgElement, defineCustomElement as defineIonImg } from "@ionic/core/components/ion-img.js";
+import { IonInfiniteScrollContent as IonInfiniteScrollContentElement, defineCustomElement as defineIonInfiniteScrollContent } from "@ionic/core/components/ion-infinite-scroll-content.js";
+import { IonInfiniteScroll as IonInfiniteScrollElement, defineCustomElement as defineIonInfiniteScroll } from "@ionic/core/components/ion-infinite-scroll.js";
+import { IonInputOtp as IonInputOtpElement, defineCustomElement as defineIonInputOtp } from "@ionic/core/components/ion-input-otp.js";
+import { IonInputPasswordToggle as IonInputPasswordToggleElement, defineCustomElement as defineIonInputPasswordToggle } from "@ionic/core/components/ion-input-password-toggle.js";
+import { IonInput as IonInputElement, defineCustomElement as defineIonInput } from "@ionic/core/components/ion-input.js";
+import { IonItemDivider as IonItemDividerElement, defineCustomElement as defineIonItemDivider } from "@ionic/core/components/ion-item-divider.js";
+import { IonItemGroup as IonItemGroupElement, defineCustomElement as defineIonItemGroup } from "@ionic/core/components/ion-item-group.js";
+import { IonItemOptions as IonItemOptionsElement, defineCustomElement as defineIonItemOptions } from "@ionic/core/components/ion-item-options.js";
+import { IonItemSliding as IonItemSlidingElement, defineCustomElement as defineIonItemSliding } from "@ionic/core/components/ion-item-sliding.js";
+import { IonLabel as IonLabelElement, defineCustomElement as defineIonLabel } from "@ionic/core/components/ion-label.js";
+import { IonListHeader as IonListHeaderElement, defineCustomElement as defineIonListHeader } from "@ionic/core/components/ion-list-header.js";
+import { IonList as IonListElement, defineCustomElement as defineIonList } from "@ionic/core/components/ion-list.js";
+import { IonMenuButton as IonMenuButtonElement, defineCustomElement as defineIonMenuButton } from "@ionic/core/components/ion-menu-button.js";
+import { IonMenuToggle as IonMenuToggleElement, defineCustomElement as defineIonMenuToggle } from "@ionic/core/components/ion-menu-toggle.js";
+import { IonMenu as IonMenuElement, defineCustomElement as defineIonMenu } from "@ionic/core/components/ion-menu.js";
+import { IonNavLink as IonNavLinkElement, defineCustomElement as defineIonNavLink } from "@ionic/core/components/ion-nav-link.js";
+import { IonNav as IonNavElement, defineCustomElement as defineIonNav } from "@ionic/core/components/ion-nav.js";
+import { IonNote as IonNoteElement, defineCustomElement as defineIonNote } from "@ionic/core/components/ion-note.js";
+import { IonPickerColumnOption as IonPickerColumnOptionElement, defineCustomElement as defineIonPickerColumnOption } from "@ionic/core/components/ion-picker-column-option.js";
+import { IonPickerColumn as IonPickerColumnElement, defineCustomElement as defineIonPickerColumn } from "@ionic/core/components/ion-picker-column.js";
+import { IonPicker as IonPickerElement, defineCustomElement as defineIonPicker } from "@ionic/core/components/ion-picker.js";
+import { IonProgressBar as IonProgressBarElement, defineCustomElement as defineIonProgressBar } from "@ionic/core/components/ion-progress-bar.js";
+import { IonRadioGroup as IonRadioGroupElement, defineCustomElement as defineIonRadioGroup } from "@ionic/core/components/ion-radio-group.js";
+import { IonRadio as IonRadioElement, defineCustomElement as defineIonRadio } from "@ionic/core/components/ion-radio.js";
+import { IonRange as IonRangeElement, defineCustomElement as defineIonRange } from "@ionic/core/components/ion-range.js";
+import { IonRefresherContent as IonRefresherContentElement, defineCustomElement as defineIonRefresherContent } from "@ionic/core/components/ion-refresher-content.js";
+import { IonRefresher as IonRefresherElement, defineCustomElement as defineIonRefresher } from "@ionic/core/components/ion-refresher.js";
+import { IonReorderGroup as IonReorderGroupElement, defineCustomElement as defineIonReorderGroup } from "@ionic/core/components/ion-reorder-group.js";
+import { IonReorder as IonReorderElement, defineCustomElement as defineIonReorder } from "@ionic/core/components/ion-reorder.js";
+import { IonRippleEffect as IonRippleEffectElement, defineCustomElement as defineIonRippleEffect } from "@ionic/core/components/ion-ripple-effect.js";
+import { IonRow as IonRowElement, defineCustomElement as defineIonRow } from "@ionic/core/components/ion-row.js";
+import { IonSearchbar as IonSearchbarElement, defineCustomElement as defineIonSearchbar } from "@ionic/core/components/ion-searchbar.js";
+import { IonSegmentButton as IonSegmentButtonElement, defineCustomElement as defineIonSegmentButton } from "@ionic/core/components/ion-segment-button.js";
+import { IonSegmentContent as IonSegmentContentElement, defineCustomElement as defineIonSegmentContent } from "@ionic/core/components/ion-segment-content.js";
+import { IonSegmentView as IonSegmentViewElement, defineCustomElement as defineIonSegmentView } from "@ionic/core/components/ion-segment-view.js";
+import { IonSegment as IonSegmentElement, defineCustomElement as defineIonSegment } from "@ionic/core/components/ion-segment.js";
+import { IonSelectModal as IonSelectModalElement, defineCustomElement as defineIonSelectModal } from "@ionic/core/components/ion-select-modal.js";
+import { IonSelectOption as IonSelectOptionElement, defineCustomElement as defineIonSelectOption } from "@ionic/core/components/ion-select-option.js";
+import { IonSelect as IonSelectElement, defineCustomElement as defineIonSelect } from "@ionic/core/components/ion-select.js";
+import { IonSkeletonText as IonSkeletonTextElement, defineCustomElement as defineIonSkeletonText } from "@ionic/core/components/ion-skeleton-text.js";
+import { IonSpinner as IonSpinnerElement, defineCustomElement as defineIonSpinner } from "@ionic/core/components/ion-spinner.js";
+import { IonSplitPane as IonSplitPaneElement, defineCustomElement as defineIonSplitPane } from "@ionic/core/components/ion-split-pane.js";
+import { IonTab as IonTabElement, defineCustomElement as defineIonTab } from "@ionic/core/components/ion-tab.js";
+import { IonText as IonTextElement, defineCustomElement as defineIonText } from "@ionic/core/components/ion-text.js";
+import { IonTextarea as IonTextareaElement, defineCustomElement as defineIonTextarea } from "@ionic/core/components/ion-textarea.js";
+import { IonThumbnail as IonThumbnailElement, defineCustomElement as defineIonThumbnail } from "@ionic/core/components/ion-thumbnail.js";
+import { IonTitle as IonTitleElement, defineCustomElement as defineIonTitle } from "@ionic/core/components/ion-title.js";
+import { IonToggle as IonToggleElement, defineCustomElement as defineIonToggle } from "@ionic/core/components/ion-toggle.js";
+import { IonToolbar as IonToolbarElement, defineCustomElement as defineIonToolbar } from "@ionic/core/components/ion-toolbar.js";
export type IonAccordionEvents = NonNullable;
-export const IonAccordion: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonAccordion: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-accordion',
elementClass: IonAccordionElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonAccordionEvents,
- defineCustomElement: defineIonAccordion,
- });
+ defineCustomElement: defineIonAccordion
+});
-export type IonAccordionGroupEvents = {
- onIonChange: EventName>;
-};
+export type IonAccordionGroupEvents = { onIonChange: EventName> };
-export const IonAccordionGroup: StencilReactComponent<
- IonAccordionGroupElement,
- IonAccordionGroupEvents,
- Components.IonAccordionGroup
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-accordion-group',
- elementClass: IonAccordionGroupElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: { onIonChange: 'ionChange' } as IonAccordionGroupEvents,
- defineCustomElement: defineIonAccordionGroup,
+export const IonAccordionGroup: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-accordion-group',
+ elementClass: IonAccordionGroupElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: { onIonChange: 'ionChange' } as IonAccordionGroupEvents,
+ defineCustomElement: defineIonAccordionGroup
});
export type IonAvatarEvents = NonNullable;
-export const IonAvatar: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonAvatar: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-avatar',
elementClass: IonAvatarElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonAvatarEvents,
- defineCustomElement: defineIonAvatar,
- });
+ defineCustomElement: defineIonAvatar
+});
export type IonBackdropEvents = { onIonBackdropTap: EventName> };
-export const IonBackdrop: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonBackdrop: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-backdrop',
elementClass: IonBackdropElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: { onIonBackdropTap: 'ionBackdropTap' } as IonBackdropEvents,
- defineCustomElement: defineIonBackdrop,
- });
+ defineCustomElement: defineIonBackdrop
+});
export type IonBadgeEvents = NonNullable;
-export const IonBadge: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonBadge: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-badge',
elementClass: IonBadgeElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonBadgeEvents,
- defineCustomElement: defineIonBadge,
- });
+ defineCustomElement: defineIonBadge
+});
-export type IonBreadcrumbsEvents = {
- onIonCollapsedClick: EventName>;
-};
+export type IonBreadcrumbsEvents = { onIonCollapsedClick: EventName> };
-export const IonBreadcrumbs: StencilReactComponent<
- IonBreadcrumbsElement,
- IonBreadcrumbsEvents,
- Components.IonBreadcrumbs
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-breadcrumbs',
- elementClass: IonBreadcrumbsElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: { onIonCollapsedClick: 'ionCollapsedClick' } as IonBreadcrumbsEvents,
- defineCustomElement: defineIonBreadcrumbs,
+export const IonBreadcrumbs: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-breadcrumbs',
+ elementClass: IonBreadcrumbsElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: { onIonCollapsedClick: 'ionCollapsedClick' } as IonBreadcrumbsEvents,
+ defineCustomElement: defineIonBreadcrumbs
});
export type IonButtonsEvents = NonNullable;
-export const IonButtons: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonButtons: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-buttons',
elementClass: IonButtonsElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonButtonsEvents,
- defineCustomElement: defineIonButtons,
- });
+ defineCustomElement: defineIonButtons
+});
export type IonCardContentEvents = NonNullable;
-export const IonCardContent: StencilReactComponent<
- IonCardContentElement,
- IonCardContentEvents,
- Components.IonCardContent
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-card-content',
- elementClass: IonCardContentElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonCardContentEvents,
- defineCustomElement: defineIonCardContent,
+export const IonCardContent: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-card-content',
+ elementClass: IonCardContentElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonCardContentEvents,
+ defineCustomElement: defineIonCardContent
});
export type IonCardHeaderEvents = NonNullable;
-export const IonCardHeader: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonCardHeader: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-card-header',
elementClass: IonCardHeaderElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonCardHeaderEvents,
- defineCustomElement: defineIonCardHeader,
- });
+ defineCustomElement: defineIonCardHeader
+});
export type IonCardSubtitleEvents = NonNullable;
-export const IonCardSubtitle: StencilReactComponent<
- IonCardSubtitleElement,
- IonCardSubtitleEvents,
- Components.IonCardSubtitle
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-card-subtitle',
- elementClass: IonCardSubtitleElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonCardSubtitleEvents,
- defineCustomElement: defineIonCardSubtitle,
+export const IonCardSubtitle: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-card-subtitle',
+ elementClass: IonCardSubtitleElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonCardSubtitleEvents,
+ defineCustomElement: defineIonCardSubtitle
});
export type IonCardTitleEvents = NonNullable;
-export const IonCardTitle: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonCardTitle: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-card-title',
elementClass: IonCardTitleElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonCardTitleEvents,
- defineCustomElement: defineIonCardTitle,
- });
+ defineCustomElement: defineIonCardTitle
+});
export type IonCheckboxEvents = {
- onIonChange: EventName>;
- onIonFocus: EventName>;
- onIonBlur: EventName>;
+ onIonChange: EventName>,
+ onIonFocus: EventName>,
+ onIonBlur: EventName>
};
-export const IonCheckbox: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonCheckbox: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-checkbox',
elementClass: IonCheckboxElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonChange: 'ionChange',
- onIonFocus: 'ionFocus',
- onIonBlur: 'ionBlur',
+ onIonChange: 'ionChange',
+ onIonFocus: 'ionFocus',
+ onIonBlur: 'ionBlur'
} as IonCheckboxEvents,
- defineCustomElement: defineIonCheckbox,
- });
+ defineCustomElement: defineIonCheckbox
+});
export type IonChipEvents = NonNullable;
-export const IonChip: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonChip: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-chip',
elementClass: IonChipElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonChipEvents,
- defineCustomElement: defineIonChip,
- });
+ defineCustomElement: defineIonChip
+});
export type IonColEvents = NonNullable;
-export const IonCol: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonCol: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-col',
elementClass: IonColElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonColEvents,
- defineCustomElement: defineIonCol,
- });
+ defineCustomElement: defineIonCol
+});
export type IonContentEvents = {
- onIonScrollStart: EventName>;
- onIonScroll: EventName>;
- onIonScrollEnd: EventName>;
+ onIonScrollStart: EventName>,
+ onIonScroll: EventName>,
+ onIonScrollEnd: EventName>
};
-export const IonContent: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonContent: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-content',
elementClass: IonContentElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonScrollStart: 'ionScrollStart',
- onIonScroll: 'ionScroll',
- onIonScrollEnd: 'ionScrollEnd',
+ onIonScrollStart: 'ionScrollStart',
+ onIonScroll: 'ionScroll',
+ onIonScrollEnd: 'ionScrollEnd'
} as IonContentEvents,
- defineCustomElement: defineIonContent,
- });
+ defineCustomElement: defineIonContent
+});
export type IonDatetimeEvents = {
- onIonCancel: EventName>;
- onIonChange: EventName>;
- onIonFocus: EventName>;
- onIonBlur: EventName>;
+ onIonCancel: EventName>,
+ onIonChange: EventName>,
+ onIonFocus: EventName>,
+ onIonBlur: EventName>
};
-export const IonDatetime: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonDatetime: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-datetime',
elementClass: IonDatetimeElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonCancel: 'ionCancel',
- onIonChange: 'ionChange',
- onIonFocus: 'ionFocus',
- onIonBlur: 'ionBlur',
+ onIonCancel: 'ionCancel',
+ onIonChange: 'ionChange',
+ onIonFocus: 'ionFocus',
+ onIonBlur: 'ionBlur'
} as IonDatetimeEvents,
- defineCustomElement: defineIonDatetime,
- });
+ defineCustomElement: defineIonDatetime
+});
export type IonDatetimeButtonEvents = NonNullable;
-export const IonDatetimeButton: StencilReactComponent<
- IonDatetimeButtonElement,
- IonDatetimeButtonEvents,
- Components.IonDatetimeButton
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-datetime-button',
- elementClass: IonDatetimeButtonElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonDatetimeButtonEvents,
- defineCustomElement: defineIonDatetimeButton,
+export const IonDatetimeButton: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-datetime-button',
+ elementClass: IonDatetimeButtonElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonDatetimeButtonEvents,
+ defineCustomElement: defineIonDatetimeButton
});
export type IonFabEvents = NonNullable;
-export const IonFab: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonFab: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-fab',
elementClass: IonFabElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonFabEvents,
- defineCustomElement: defineIonFab,
- });
+ defineCustomElement: defineIonFab
+});
export type IonFabListEvents = NonNullable;
-export const IonFabList: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonFabList: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-fab-list',
elementClass: IonFabListElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonFabListEvents,
- defineCustomElement: defineIonFabList,
- });
+ defineCustomElement: defineIonFabList
+});
export type IonFooterEvents = NonNullable;
-export const IonFooter: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonFooter: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-footer',
elementClass: IonFooterElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonFooterEvents,
- defineCustomElement: defineIonFooter,
- });
+ defineCustomElement: defineIonFooter
+});
export type IonGridEvents = NonNullable;
-export const IonGrid: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonGrid: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-grid',
elementClass: IonGridElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonGridEvents,
- defineCustomElement: defineIonGrid,
- });
+ defineCustomElement: defineIonGrid
+});
export type IonHeaderEvents = NonNullable;
-export const IonHeader: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonHeader: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-header',
elementClass: IonHeaderElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonHeaderEvents,
- defineCustomElement: defineIonHeader,
- });
+ defineCustomElement: defineIonHeader
+});
export type IonImgEvents = {
- onIonImgWillLoad: EventName>;
- onIonImgDidLoad: EventName>;
- onIonError: EventName>;
+ onIonImgWillLoad: EventName>,
+ onIonImgDidLoad: EventName>,
+ onIonError: EventName>
};
-export const IonImg: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonImg: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-img',
elementClass: IonImgElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonImgWillLoad: 'ionImgWillLoad',
- onIonImgDidLoad: 'ionImgDidLoad',
- onIonError: 'ionError',
+ onIonImgWillLoad: 'ionImgWillLoad',
+ onIonImgDidLoad: 'ionImgDidLoad',
+ onIonError: 'ionError'
} as IonImgEvents,
- defineCustomElement: defineIonImg,
- });
+ defineCustomElement: defineIonImg
+});
export type IonInfiniteScrollEvents = { onIonInfinite: EventName> };
-export const IonInfiniteScroll: StencilReactComponent<
- IonInfiniteScrollElement,
- IonInfiniteScrollEvents,
- Components.IonInfiniteScroll
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-infinite-scroll',
- elementClass: IonInfiniteScrollElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: { onIonInfinite: 'ionInfinite' } as IonInfiniteScrollEvents,
- defineCustomElement: defineIonInfiniteScroll,
+export const IonInfiniteScroll: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-infinite-scroll',
+ elementClass: IonInfiniteScrollElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: { onIonInfinite: 'ionInfinite' } as IonInfiniteScrollEvents,
+ defineCustomElement: defineIonInfiniteScroll
});
export type IonInfiniteScrollContentEvents = NonNullable;
-export const IonInfiniteScrollContent: StencilReactComponent<
- IonInfiniteScrollContentElement,
- IonInfiniteScrollContentEvents,
- Components.IonInfiniteScrollContent
-> = /*@__PURE__*/ createComponent<
- IonInfiniteScrollContentElement,
- IonInfiniteScrollContentEvents,
- Components.IonInfiniteScrollContent
->({
- tagName: 'ion-infinite-scroll-content',
- elementClass: IonInfiniteScrollContentElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonInfiniteScrollContentEvents,
- defineCustomElement: defineIonInfiniteScrollContent,
+export const IonInfiniteScrollContent: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-infinite-scroll-content',
+ elementClass: IonInfiniteScrollContentElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonInfiniteScrollContentEvents,
+ defineCustomElement: defineIonInfiniteScrollContent
});
export type IonInputEvents = {
- onIonInput: EventName>;
- onIonChange: EventName>;
- onIonBlur: EventName>;
- onIonFocus: EventName>;
+ onIonInput: EventName>,
+ onIonChange: EventName>,
+ onIonBlur: EventName>,
+ onIonFocus: EventName>
};
-export const IonInput: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonInput: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-input',
elementClass: IonInputElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonInput: 'ionInput',
- onIonChange: 'ionChange',
- onIonBlur: 'ionBlur',
- onIonFocus: 'ionFocus',
+ onIonInput: 'ionInput',
+ onIonChange: 'ionChange',
+ onIonBlur: 'ionBlur',
+ onIonFocus: 'ionFocus'
} as IonInputEvents,
- defineCustomElement: defineIonInput,
- });
+ defineCustomElement: defineIonInput
+});
export type IonInputOtpEvents = {
- onIonInput: EventName>;
- onIonChange: EventName>;
- onIonComplete: EventName>;
- onIonBlur: EventName>;
- onIonFocus: EventName>;
+ onIonInput: EventName>,
+ onIonChange: EventName>,
+ onIonComplete: EventName>,
+ onIonBlur: EventName>,
+ onIonFocus: EventName>
};
-export const IonInputOtp: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonInputOtp: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-input-otp',
elementClass: IonInputOtpElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonInput: 'ionInput',
- onIonChange: 'ionChange',
- onIonComplete: 'ionComplete',
- onIonBlur: 'ionBlur',
- onIonFocus: 'ionFocus',
+ onIonInput: 'ionInput',
+ onIonChange: 'ionChange',
+ onIonComplete: 'ionComplete',
+ onIonBlur: 'ionBlur',
+ onIonFocus: 'ionFocus'
} as IonInputOtpEvents,
- defineCustomElement: defineIonInputOtp,
- });
+ defineCustomElement: defineIonInputOtp
+});
export type IonInputPasswordToggleEvents = NonNullable;
-export const IonInputPasswordToggle: StencilReactComponent<
- IonInputPasswordToggleElement,
- IonInputPasswordToggleEvents,
- Components.IonInputPasswordToggle
-> = /*@__PURE__*/ createComponent<
- IonInputPasswordToggleElement,
- IonInputPasswordToggleEvents,
- Components.IonInputPasswordToggle
->({
- tagName: 'ion-input-password-toggle',
- elementClass: IonInputPasswordToggleElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonInputPasswordToggleEvents,
- defineCustomElement: defineIonInputPasswordToggle,
+export const IonInputPasswordToggle: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-input-password-toggle',
+ elementClass: IonInputPasswordToggleElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonInputPasswordToggleEvents,
+ defineCustomElement: defineIonInputPasswordToggle
});
export type IonItemDividerEvents = NonNullable;
-export const IonItemDivider: StencilReactComponent<
- IonItemDividerElement,
- IonItemDividerEvents,
- Components.IonItemDivider
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-item-divider',
- elementClass: IonItemDividerElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: {} as IonItemDividerEvents,
- defineCustomElement: defineIonItemDivider,
+export const IonItemDivider: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-item-divider',
+ elementClass: IonItemDividerElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as IonItemDividerEvents,
+ defineCustomElement: defineIonItemDivider
});
export type IonItemGroupEvents = NonNullable;
-export const IonItemGroup: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonItemGroup: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-item-group',
elementClass: IonItemGroupElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonItemGroupEvents,
- defineCustomElement: defineIonItemGroup,
- });
+ defineCustomElement: defineIonItemGroup
+});
export type IonItemOptionsEvents = { onIonSwipe: EventName> };
-export const IonItemOptions: StencilReactComponent<
- IonItemOptionsElement,
- IonItemOptionsEvents,
- Components.IonItemOptions
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-item-options',
- elementClass: IonItemOptionsElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: { onIonSwipe: 'ionSwipe' } as IonItemOptionsEvents,
- defineCustomElement: defineIonItemOptions,
+export const IonItemOptions: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-item-options',
+ elementClass: IonItemOptionsElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: { onIonSwipe: 'ionSwipe' } as IonItemOptionsEvents,
+ defineCustomElement: defineIonItemOptions
});
export type IonItemSlidingEvents = { onIonDrag: EventName> };
-export const IonItemSliding: StencilReactComponent<
- IonItemSlidingElement,
- IonItemSlidingEvents,
- Components.IonItemSliding
-> = /*@__PURE__*/ createComponent({
- tagName: 'ion-item-sliding',
- elementClass: IonItemSlidingElement,
- // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
- react: React,
- events: { onIonDrag: 'ionDrag' } as IonItemSlidingEvents,
- defineCustomElement: defineIonItemSliding,
+export const IonItemSliding: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'ion-item-sliding',
+ elementClass: IonItemSlidingElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: { onIonDrag: 'ionDrag' } as IonItemSlidingEvents,
+ defineCustomElement: defineIonItemSliding
});
export type IonLabelEvents = NonNullable;
-export const IonLabel: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonLabel: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-label',
elementClass: IonLabelElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonLabelEvents,
- defineCustomElement: defineIonLabel,
- });
+ defineCustomElement: defineIonLabel
+});
export type IonListEvents = NonNullable;
-export const IonList: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonList: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-list',
elementClass: IonListElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonListEvents,
- defineCustomElement: defineIonList,
- });
+ defineCustomElement: defineIonList
+});
export type IonListHeaderEvents = NonNullable;
-export const IonListHeader: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonListHeader: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-list-header',
elementClass: IonListHeaderElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonListHeaderEvents,
- defineCustomElement: defineIonListHeader,
- });
+ defineCustomElement: defineIonListHeader
+});
export type IonMenuEvents = {
- onIonWillOpen: EventName>;
- onIonWillClose: EventName>;
- onIonDidOpen: EventName>;
- onIonDidClose: EventName>;
+ onIonWillOpen: EventName>,
+ onIonWillClose: EventName>,
+ onIonDidOpen: EventName>,
+ onIonDidClose: EventName>
};
-export const IonMenu: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonMenu: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-menu',
elementClass: IonMenuElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {
- onIonWillOpen: 'ionWillOpen',
- onIonWillClose: 'ionWillClose',
- onIonDidOpen: 'ionDidOpen',
- onIonDidClose: 'ionDidClose',
+ onIonWillOpen: 'ionWillOpen',
+ onIonWillClose: 'ionWillClose',
+ onIonDidOpen: 'ionDidOpen',
+ onIonDidClose: 'ionDidClose'
} as IonMenuEvents,
- defineCustomElement: defineIonMenu,
- });
+ defineCustomElement: defineIonMenu
+});
export type IonMenuButtonEvents = NonNullable;
-export const IonMenuButton: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonMenuButton: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ion-menu-button',
elementClass: IonMenuButtonElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
events: {} as IonMenuButtonEvents,
- defineCustomElement: defineIonMenuButton,
- });
+ defineCustomElement: defineIonMenuButton
+});
export type IonMenuToggleEvents = NonNullable;
-export const IonMenuToggle: StencilReactComponent =
- /*@__PURE__*/ createComponent({
+export const IonMenuToggle: StencilReactComponent