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 ( 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 + + + + + + + + + + + + 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) => ( -
- {mode === 'md' && } - - ))} + {mode === 'md' && } + + ); + })} ); } @@ -635,32 +649,49 @@ export class Alert implements ComponentInterface, OverlayInterface { return (
- {inputs.map((i) => ( -
- - ))} + + ); + })} ); } 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 + + + + + + + + + + + + + + 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 + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + 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 (