diff --git a/core/api.txt b/core/api.txt index e4e1ca4d603..4bf94c415f6 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2326,6 +2326,24 @@ ion-select,css-prop,--placeholder-opacity,md ion-select,css-prop,--ripple-color,ionic ion-select,css-prop,--ripple-color,ios ion-select,css-prop,--ripple-color,md +ion-select,css-prop,--select-text-media-border-color,ionic +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,ionic +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,ionic +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,ionic +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,ionic +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,ionic +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 @@ -2345,6 +2363,7 @@ 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,mode,"ios" | "md",undefined,false,false ion-select-option,prop,theme,"ios" | "md" | "ionic",undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ecbdb7475c6..86ac882a669 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -3813,6 +3813,10 @@ 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 @@ -9897,6 +9901,10 @@ 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 @@ -11268,6 +11276,7 @@ declare namespace LocalJSX { interface IonSelectOptionAttributes { "disabled": boolean; "value": string; + "description": string; } interface IonSelectPopoverAttributes { "header": string; diff --git a/core/src/components/action-sheet/action-sheet.scss b/core/src/components/action-sheet/action-sheet.common.scss similarity index 96% rename from core/src/components/action-sheet/action-sheet.scss rename to core/src/components/action-sheet/action-sheet.common.scss index 2a2f85bb456..7e857346ac9 100644 --- a/core/src/components/action-sheet/action-sheet.scss +++ b/core/src/components/action-sheet/action-sheet.common.scss @@ -233,3 +233,20 @@ } } } + +// Action Sheet: Select Option +// -------------------------------------------------- + +.action-sheet-button-label { + display: flex; + + align-items: center; +} + +.select-option-content { + flex: 1; +} + +.select-option-description { + display: block; +} diff --git a/core/src/components/action-sheet/action-sheet.ionic.scss b/core/src/components/action-sheet/action-sheet.ionic.scss new file mode 100644 index 00000000000..b2c749d4e0a --- /dev/null +++ b/core/src/components/action-sheet/action-sheet.ionic.scss @@ -0,0 +1,22 @@ +@use "../../themes/ionic/ionic.globals.scss" as globals; +@use "./action-sheet.common"; +@use "./action-sheet.md" as action-sheet-md; + +// Ionic Action Sheet +// -------------------------------------------------- + +// Action Sheet: Select Option +// -------------------------------------------------- + +.action-sheet-button-label { + gap: globals.$ion-space-300; +} + +.select-option-description { + @include globals.typography(globals.$ion-body-md-regular); + @include globals.padding(0); + + color: globals.$ion-text-subtle; + + font-size: globals.$ion-font-size-350; +} diff --git a/core/src/components/action-sheet/action-sheet.ios.scss b/core/src/components/action-sheet/action-sheet.ios.scss index fe9bba89903..94b98447981 100644 --- a/core/src/components/action-sheet/action-sheet.ios.scss +++ b/core/src/components/action-sheet/action-sheet.ios.scss @@ -1,4 +1,4 @@ -@import "./action-sheet"; +@import "./action-sheet.native"; @import "./action-sheet.ios.vars"; // iOS Action Sheet diff --git a/core/src/components/action-sheet/action-sheet.md.scss b/core/src/components/action-sheet/action-sheet.md.scss index 8e1c7f07027..e46f06085b3 100644 --- a/core/src/components/action-sheet/action-sheet.md.scss +++ b/core/src/components/action-sheet/action-sheet.md.scss @@ -1,4 +1,4 @@ -@import "./action-sheet"; +@import "./action-sheet.native"; @import "./action-sheet.md.vars"; // Material Design Action Sheet Title diff --git a/core/src/components/action-sheet/action-sheet.native.scss b/core/src/components/action-sheet/action-sheet.native.scss new file mode 100644 index 00000000000..affa6aeb126 --- /dev/null +++ b/core/src/components/action-sheet/action-sheet.native.scss @@ -0,0 +1,19 @@ +@use "../../themes/native/native.theme.default" as native; +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.font" as font; +@use "./action-sheet.common"; + +// Action Sheet: Native +// -------------------------------------------------- + +.action-sheet-button-label { + gap: 12px; +} + +.select-option-description { + @include mixins.padding(5px, 0, 0, 0); + + color: native.$text-color-step-300; + + font-size: font.dynamic-font(12px); +} diff --git a/core/src/components/action-sheet/action-sheet.tsx b/core/src/components/action-sheet/action-sheet.tsx index 5d79ab90f51..9c5a112ab62 100644 --- a/core/src/components/action-sheet/action-sheet.tsx +++ b/core/src/components/action-sheet/action-sheet.tsx @@ -16,11 +16,13 @@ import { safeCall, setOverlayId, } from '@utils/overlays'; +import { renderOptionLabel } from '@utils/select-option-render'; import { getClassMap } from '@utils/theme'; import { getIonMode, getIonTheme } 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'; @@ -37,7 +39,7 @@ import { mdLeaveAnimation } from './animations/md.leave'; styleUrls: { ios: 'action-sheet.ios.scss', md: 'action-sheet.md.scss', - ionic: 'action-sheet.md.scss', + ionic: 'action-sheet.ionic.scss', }, scoped: true, }) @@ -559,6 +561,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/alert/alert.scss b/core/src/components/alert/alert.common.scss similarity index 95% rename from core/src/components/alert/alert.scss rename to core/src/components/alert/alert.common.scss index 9948a4127a9..84e35eca5c3 100644 --- a/core/src/components/alert/alert.scss +++ b/core/src/components/alert/alert.common.scss @@ -247,3 +247,21 @@ textarea.alert-input { min-height: $alert-input-min-height; resize: none; } + +// Alert Button: Select Option +// -------------------------------------------------- + +.alert-radio-label, +.alert-checkbox-label { + display: flex; + + align-items: center; +} + +.select-option-content { + flex: 1; +} + +.select-option-description { + display: block; +} diff --git a/core/src/components/alert/alert.ionic.scss b/core/src/components/alert/alert.ionic.scss new file mode 100644 index 00000000000..3c54136b477 --- /dev/null +++ b/core/src/components/alert/alert.ionic.scss @@ -0,0 +1,23 @@ +@use "../../themes/ionic/ionic.globals.scss" as globals; +@use "./alert.common"; +@use "./alert.md" as alert-md; + +// Ionic Alert +// -------------------------------------------------- + +// Alert: Select Option +// -------------------------------------------------- + +.alert-radio-label, +.alert-checkbox-label { + gap: globals.$ion-space-300; +} + +.select-option-description { + @include globals.typography(globals.$ion-body-md-regular); + @include globals.padding(0); + + color: globals.$ion-text-subtle; + + font-size: globals.$ion-font-size-350; +} diff --git a/core/src/components/alert/alert.ios.scss b/core/src/components/alert/alert.ios.scss index 714efc03baf..2671dc0940b 100644 --- a/core/src/components/alert/alert.ios.scss +++ b/core/src/components/alert/alert.ios.scss @@ -1,4 +1,4 @@ -@import "./alert"; +@import "./alert.native"; @import "./alert.ios.vars"; // iOS Alert diff --git a/core/src/components/alert/alert.md.scss b/core/src/components/alert/alert.md.scss index 5ac468c760f..2fbd0fd8775 100644 --- a/core/src/components/alert/alert.md.scss +++ b/core/src/components/alert/alert.md.scss @@ -1,4 +1,4 @@ -@import "./alert"; +@import "./alert.native"; @import "./alert.md.vars"; // Material Design Alert diff --git a/core/src/components/alert/alert.native.scss b/core/src/components/alert/alert.native.scss new file mode 100644 index 00000000000..e2d5a87b8a5 --- /dev/null +++ b/core/src/components/alert/alert.native.scss @@ -0,0 +1,20 @@ +@use "../../themes/native/native.theme.default" as native; +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.font" as font; +@use "./alert.common"; + +// Alert: Native +// -------------------------------------------------- + +.alert-radio-label, +.alert-checkbox-label { + gap: 12px; +} + +.select-option-description { + @include mixins.padding(5px, 0, 0, 0); + + color: native.$text-color-step-300; + + font-size: font.dynamic-font(12px); +} diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx index e4e98b67a42..db9ed59c9ce 100644 --- a/core/src/components/alert/alert.tsx +++ b/core/src/components/alert/alert.tsx @@ -19,6 +19,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'; @@ -26,6 +27,7 @@ import { getIonMode, getIonTheme } 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'; @@ -44,7 +46,7 @@ import { mdLeaveAnimation } from './animations/md.leave'; styleUrls: { ios: 'alert.ios.scss', md: 'alert.md.scss', - ionic: 'alert.md.scss', + ionic: 'alert.ionic.scss', }, scoped: true, }) @@ -329,25 +331,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() { @@ -569,33 +566,50 @@ export class Alert implements ComponentInterface, OverlayInterface { return (
- {inputs.map((i) => ( -
- {theme === 'md' && } - - ))} + {theme === 'md' && } + + ); + })} ); } @@ -609,32 +623,49 @@ export class Alert implements ComponentInterface, OverlayInterface { return (
- {inputs.map((i) => ( -
- - ))} + + ); + })} ); } diff --git a/core/src/components/select-modal/select-modal.common.scss b/core/src/components/select-modal/select-modal.common.scss index 683ae23faeb..3bbb48b557d 100644 --- a/core/src/components/select-modal/select-modal.common.scss +++ b/core/src/components/select-modal/select-modal.common.scss @@ -1,3 +1,19 @@ +// Select Modal +// -------------------------------------------------- + :host { height: 100%; } + +// Select Modal: Select Option +// -------------------------------------------------- + +.select-option-label { + display: flex; + + align-items: center; +} + +.select-option-description { + display: block; +} diff --git a/core/src/components/select-modal/select-modal.ionic.scss b/core/src/components/select-modal/select-modal.ionic.scss index 23d7705b660..ca137a075d3 100644 --- a/core/src/components/select-modal/select-modal.ionic.scss +++ b/core/src/components/select-modal/select-modal.ionic.scss @@ -77,3 +77,18 @@ ion-content { --background-focused-opacity: 1; } } + +// Select Modal: Select Option +// -------------------------------------------------- + +.select-option-label { + gap: globals.$ion-space-300; +} + +.select-option-description { + @include globals.typography(globals.$ion-body-md-regular); + + color: globals.$ion-text-subtle; + + font-size: globals.$ion-font-size-350; +} diff --git a/core/src/components/select-modal/select-modal.ios.scss b/core/src/components/select-modal/select-modal.ios.scss index eea8d57f0ba..abac9c8220b 100644 --- a/core/src/components/select-modal/select-modal.ios.scss +++ b/core/src/components/select-modal/select-modal.ios.scss @@ -1,4 +1,4 @@ -@import "./select-modal.common"; +@import "./select-modal.native"; @import "../item/item.ios.vars"; @import "../radio/radio.ios.vars"; diff --git a/core/src/components/select-modal/select-modal.md.scss b/core/src/components/select-modal/select-modal.md.scss index 505ea2a061c..260f6aba5be 100644 --- a/core/src/components/select-modal/select-modal.md.scss +++ b/core/src/components/select-modal/select-modal.md.scss @@ -1,4 +1,4 @@ -@import "./select-modal.common"; +@import "./select-modal.native"; @import "../../themes/mixins.scss"; @import "../item/item.md.vars"; diff --git a/core/src/components/select-modal/select-modal.native.scss b/core/src/components/select-modal/select-modal.native.scss new file mode 100644 index 00000000000..29b81819fcf --- /dev/null +++ b/core/src/components/select-modal/select-modal.native.scss @@ -0,0 +1,19 @@ +@use "../../themes/native/native.theme.default" as native; +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.font" as font; +@use "./select-modal.common"; + +// Select Modal: Native +// -------------------------------------------------- + +.select-option-label { + gap: 12px; +} + +.select-option-description { + @include mixins.padding(5px, 0, 0, 0); + + color: native.$text-color-step-300; + + font-size: font.dynamic-font(12px); +} diff --git a/core/src/components/select-modal/select-modal.tsx b/core/src/components/select-modal/select-modal.tsx index c277c194da8..4ebe3d91b7e 100644 --- a/core/src/components/select-modal/select-modal.tsx +++ b/core/src/components/select-modal/select-modal.tsx @@ -2,10 +2,12 @@ import { getIonMode } from '@global/ionic-global'; import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core'; import { safeCall } from '@utils/overlays'; +import { renderOptionLabel } from '@utils/select-option-render'; import { getClassMap, hostContext } 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'; @@ -92,66 +94,100 @@ export class SelectModal implements ComponentInterface { return ( this.callOptionHandler(ev)}> - {this.options.map((option) => ( - - this.closeModal()} - onKeyUp={(ev) => { - if (ev.key === ' ') { - /** - * Selecting a radio option with keyboard navigation, - * either through the Enter or Space keys, should - * dismiss the modal. - */ - this.closeModal(); - } + {this.options.map((option, index) => { + /** + * 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 optionLabelOptions = { + id: `modal-option-${index}`, + label: richOption.text, + startContent: richOption.startContent, + endContent: richOption.endContent, + description: richOption.description, + }; + + return ( + - {option.text} - - - ))} + this.closeModal()} + onKeyUp={(ev) => { + if (ev.key === ' ') { + /** + * Selecting a radio option with keyboard navigation, + * either through the Enter or Space keys, should + * dismiss the modal. + */ + this.closeModal(); + } + }} + > + {renderOptionLabel(optionLabelOptions, 'select-option-label')} + + + ); + })} ); } private renderCheckboxOptions() { - return this.options.map((option) => ( - - { - this.setChecked(ev); - this.callOptionHandler(ev); + return this.options.map((option, index) => { + /** + * 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 optionLabelOptions = { + id: `modal-option-${index}`, + label: richOption.text, + startContent: richOption.startContent, + endContent: richOption.endContent, + description: richOption.description, + }; + + return ( + - {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-option/select-option.tsx b/core/src/components/select-option/select-option.tsx index b088f4ea72e..dea73b4fd3c 100644 --- a/core/src/components/select-option/select-option.tsx +++ b/core/src/components/select-option/select-option.tsx @@ -27,8 +27,14 @@ 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; + render() { const theme = getIonTheme(this); + return ( + > + + + + ); } } diff --git a/core/src/components/select-popover/select-popover.scss b/core/src/components/select-popover/select-popover.common.scss similarity index 59% rename from core/src/components/select-popover/select-popover.scss rename to core/src/components/select-popover/select-popover.common.scss index de7cb783300..cedc62bbf3a 100644 --- a/core/src/components/select-popover/select-popover.scss +++ b/core/src/components/select-popover/select-popover.common.scss @@ -1,5 +1,8 @@ @import "../../themes/native/native.globals"; +// Select Popover +// -------------------------------------------------- + :host ion-list { @include margin(0); } @@ -18,3 +21,16 @@ ion-label { :host { overflow-y: auto; } + +// Select Popover: Select Option +// -------------------------------------------------- + +.select-option-label { + display: flex; + + align-items: center; +} + +.select-option-description { + display: block; +} diff --git a/core/src/components/select-popover/select-popover.ionic.scss b/core/src/components/select-popover/select-popover.ionic.scss new file mode 100644 index 00000000000..1813794975d --- /dev/null +++ b/core/src/components/select-popover/select-popover.ionic.scss @@ -0,0 +1,22 @@ +@use "../../themes/ionic/ionic.globals.scss" as globals; +@use "./select-popover.common"; +@use "./select-popover.md" as select-popover-md; + +// Ionic Select Popover +// -------------------------------------------------- + +// Select Popover: Select Option +// -------------------------------------------------- + +.select-option-label { + gap: globals.$ion-space-300; +} + +.select-option-description { + @include globals.typography(globals.$ion-body-md-regular); + @include globals.padding(0); + + color: globals.$ion-text-subtle; + + font-size: globals.$ion-font-size-350; +} diff --git a/core/src/components/select-popover/select-popover.ios.scss b/core/src/components/select-popover/select-popover.ios.scss index 3330a261d80..de3cfea6135 100644 --- a/core/src/components/select-popover/select-popover.ios.scss +++ b/core/src/components/select-popover/select-popover.ios.scss @@ -1,2 +1,2 @@ -@import "./select-popover"; +@import "./select-popover.native"; @import "./select-popover.ios.vars"; diff --git a/core/src/components/select-popover/select-popover.md.scss b/core/src/components/select-popover/select-popover.md.scss index 001b0123632..c7728bcaf04 100644 --- a/core/src/components/select-popover/select-popover.md.scss +++ b/core/src/components/select-popover/select-popover.md.scss @@ -1,4 +1,4 @@ -@import "./select-popover"; +@import "./select-popover.native"; @import "./select-popover.md.vars"; ion-list ion-radio::part(container) { diff --git a/core/src/components/select-popover/select-popover.native.scss b/core/src/components/select-popover/select-popover.native.scss new file mode 100644 index 00000000000..0b52fafe932 --- /dev/null +++ b/core/src/components/select-popover/select-popover.native.scss @@ -0,0 +1,19 @@ +@use "../../themes/native/native.theme.default" as native; +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.font" as font; +@use "./select-popover.common"; + +// Select Popover: Native +// -------------------------------------------------- + +.select-option-label { + gap: 12px; +} + +.select-option-description { + @include mixins.padding(5px, 0, 0, 0); + + color: native.$text-color-step-300; + + font-size: font.dynamic-font(12px); +} diff --git a/core/src/components/select-popover/select-popover.tsx b/core/src/components/select-popover/select-popover.tsx index efba1c8d9c1..6fdd7d5a4ef 100644 --- a/core/src/components/select-popover/select-popover.tsx +++ b/core/src/components/select-popover/select-popover.tsx @@ -1,11 +1,13 @@ import type { ComponentInterface } from '@stencil/core'; import { Element, Component, Host, Prop, h, forceUpdate } from '@stencil/core'; import { safeCall } from '@utils/overlays'; +import { renderOptionLabel } from '@utils/select-option-render'; import { getClassMap } from '@utils/theme'; import { getIonTheme } 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'; @@ -20,7 +22,7 @@ import type { SelectPopoverOption } from './select-popover-interface'; styleUrls: { ios: 'select-popover.ios.scss', md: 'select-popover.md.scss', - ionic: 'select-popover.md.scss', + ionic: 'select-popover.ionic.scss', }, scoped: true, }) @@ -119,31 +121,48 @@ export class SelectPopover implements ComponentInterface { } renderCheckboxOptions(options: SelectPopoverOption[]) { - return options.map((option) => ( - - { - this.setChecked(ev); - this.callOptionHandler(ev); + return options.map((option, index) => { + /** + * 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 optionLabelOptions = { + id: `popover-option-${index}`, + label: richOption.text, + startContent: richOption.startContent, + endContent: richOption.endContent, + description: richOption.description, + }; + + return ( + - {option.text} - - - )); + { + this.setChecked(ev); + this.callOptionHandler(ev); + // TODO FW-4784 + forceUpdate(this); + }} + > + {renderOptionLabel(optionLabelOptions, 'select-option-label')} + + + ); + }); } renderRadioOptions(options: SelectPopoverOption[]) { @@ -151,33 +170,50 @@ export class SelectPopover implements ComponentInterface { return ( this.callOptionHandler(ev)}> - {options.map((option) => ( - - this.dismissParentPopover()} - onKeyUp={(ev) => { - if (ev.key === ' ') { - /** - * Selecting a radio option with keyboard navigation, - * either through the Enter or Space keys, should - * dismiss the popover. - */ - this.dismissParentPopover(); - } + {options.map((option, index) => { + /** + * 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 optionLabelOptions = { + id: `popover-option-${index}`, + label: richOption.text, + startContent: richOption.startContent, + endContent: richOption.endContent, + description: richOption.description, + }; + + return ( + - {option.text} - - - ))} + this.dismissParentPopover()} + onKeyUp={(ev) => { + if (ev.key === ' ') { + /** + * Selecting a radio option with keyboard navigation, + * either through the Enter or Space keys, should + * dismiss the popover. + */ + this.dismissParentPopover(); + } + }} + > + {renderOptionLabel(optionLabelOptions, 'select-option-label')} + + + ); + })} ); } diff --git a/core/src/components/select/select-interface.ts b/core/src/components/select/select-interface.ts index 8e65377a825..18785cd17a4 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,27 @@ 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; +} + +export interface SelectOverlayOption extends Omit, RichContentOption { + /** The main text for the option as a string or an HTMLElement. */ + text?: string | HTMLElement; +} + +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.common.scss b/core/src/components/select/select.common.scss index 43f3d13399f..b3866c6f15b 100644 --- a/core/src/components/select/select.common.scss +++ b/core/src/components/select/select.common.scss @@ -25,6 +25,13 @@ * @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. */ --padding-top: 0px; --padding-end: 0px; @@ -36,6 +43,8 @@ --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; /** * This is a private API that is used to switch @@ -152,6 +161,21 @@ button { overflow: hidden; } +.select-text img, +.select-text ion-img, +.select-text ion-icon, +.select-text ion-thumbnail, +.select-text ion-avatar { + @include mixins.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.ionic.scss b/core/src/components/select/select.ionic.scss index 2ba5f722406..5f0bf27977f 100644 --- a/core/src/components/select/select.ionic.scss +++ b/core/src/components/select/select.ionic.scss @@ -50,6 +50,15 @@ color: globals.$ion-text-default; } +/** + * If the select text contains rich content, we want to add some + * spacing between the items without changing the display to prevent + * losing the ellipsis behavior. + */ +.select-text > * { + margin-inline-start: globals.$ion-space-200; +} + // Select Label // ---------------------------------------------------------------- diff --git a/core/src/components/select/select.native.scss b/core/src/components/select/select.native.scss index 876fcb1579f..262b6efdbee 100644 --- a/core/src/components/select/select.native.scss +++ b/core/src/components/select/select.native.scss @@ -88,6 +88,15 @@ min-width: 16px; } +/** + * If the select text contains rich content, we want to add some + * spacing between the items without changing the display to prevent + * losing the ellipsis behavior. + */ +.select-text > * { + margin-inline-start: 8px; +} + // Select Label // ---------------------------------------------------------------- diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 8071434ba8c..e1dfd349619 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,6 +1,7 @@ import caretDownRegular from '@phosphor-icons/core/assets/regular/caret-down.svg'; 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'; @@ -9,6 +10,7 @@ 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 { sanitizeDOMString } from '@utils/sanitization'; import { createColorClasses, hostContext } from '@utils/theme'; import { watchForOptions } from '@utils/watch-options'; import { caretDownSharp, chevronExpand } from 'ionicons/icons'; @@ -24,11 +26,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 @@ -72,8 +78,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; @@ -566,7 +572,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); @@ -576,10 +582,15 @@ export class Select implements ComponentInterface { .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; const isSelected = isOptionSelected(selectValue, value, this.compareWith); + const text = this.customHTMLEnabled ? getOptionContent(option) : option.textContent; + const startContent = this.customHTMLEnabled + ? (getOptionContent(option, 'start') as HTMLElement | null) + : undefined; + const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; return { role: isSelected ? 'selected' : '', - text: option.textContent, + text: text ?? '', cssClass: optClass, handler: () => { this.setValue(value); @@ -588,7 +599,10 @@ export class Select implements ComponentInterface { 'aria-checked': isSelected ? 'true' : 'false', role: 'radio', }, - } as ActionSheetButton; + startContent: startContent ?? undefined, + endContent: endContent ?? undefined, + description: option.description, + } as SelectActionSheetButton; }); // Add "cancel" button @@ -607,7 +621,7 @@ export class Select implements ComponentInterface { data: HTMLIonSelectOptionElement[], inputType: 'checkbox' | 'radio', selectValue: any - ): AlertInput[] { + ): SelectAlertInput[] { const alertInputs = data.map((option) => { const value = getOptionValue(option); @@ -616,21 +630,29 @@ export class Select implements ComponentInterface { .filter((cls) => cls !== 'hydrated') .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; + const label = this.customHTMLEnabled ? getOptionContent(option) : option.textContent; + const startContent = this.customHTMLEnabled + ? (getOptionContent(option, 'start') as HTMLElement | null) + : undefined; + const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; return { type: inputType, cssClass: optClass, - label: option.textContent || '', + label: label ?? '', value, checked: isOptionSelected(selectValue, value, this.compareWith), disabled: option.disabled, + startContent: startContent ?? undefined, + endContent: endContent ?? undefined, + description: option.description, }; }); 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); @@ -639,9 +661,14 @@ export class Select implements ComponentInterface { .filter((cls) => cls !== 'hydrated') .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; + const text = this.customHTMLEnabled ? getOptionContent(option) : option.textContent; + const startContent = this.customHTMLEnabled + ? (getOptionContent(option, 'start') as HTMLElement | null) + : undefined; + const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; return { - text: option.textContent || '', + text: text ?? '', cssClass: optClass, value, checked: isOptionSelected(selectValue, value, this.compareWith), @@ -652,6 +679,9 @@ export class Select implements ComponentInterface { this.close(); } }, + startContent: startContent ?? undefined, + endContent: endContent ?? undefined, + description: option.description, }; }); @@ -879,12 +909,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() { @@ -1069,7 +1105,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 +1121,10 @@ export class Select implements ComponentInterface { const textPart = addPlaceholderClass ? 'placeholder' : 'text'; + if (this.customHTMLEnabled) { + return ; + } + return (