Skip to content

feat(select): allow HTML within options#31072

Open
thetaPC wants to merge 27 commits intonextfrom
FW-7137
Open

feat(select): allow HTML within options#31072
thetaPC wants to merge 27 commits intonextfrom
FW-7137

Conversation

@thetaPC
Copy link
Copy Markdown
Contributor

@thetaPC thetaPC commented Apr 9, 2026

Issue number: resolves #29890


What is the current behavior?

Select only allows plain text to be passed within it's options.

What is the new behavior?

ion-select-option

  • Added default slot support for custom HTML content (images, avatars, icons, etc.) when innerHTMLTemplatesEnabled is true
  • Added start and end named slots for content that appears in the overlay interface but not in the selected text
  • Added description prop for text rendered below the option label in the overlay

ion-select

  • Selected text renders HTML content from the default slot, excluding start/end slot content
  • aria-label derives plain text only from the default slot, ensuring screen readers don't read slotted or element content
  • Added CSS variables for styling media within the selected text (--select-text-media-width, --select-text-media-height, etc.)

Overlay interfaces (alert, action-sheet, select-popover, select-modal)

  • All four interfaces render rich content (start, end, description, HTML label) consistently via the shared renderOptionLabel utility
  • Public interfaces (ActionSheetButton, AlertInput, SelectPopoverOption, SelectModalOption) are unchanged — rich content fields are on internal extended interfaces (SelectActionSheetButton, SelectAlertInput, SelectOverlayOption)
  • Content is sanitized via sanitizeDOMString before DOM injection to prevent XSS

Utilities

  • Added select-option-render.tsx: shared render utility for option labels across all overlay components
  • Added getOptionContent: extracts and clones slot content from ion-select-option for overlays and selected text
  • Added getDefaultSlotPlainText — extracts plain text from the default slot, used for labels and aria

Tests

  • Added E2E tests for rich content rendering across all four interfaces (single and multiple selection)
  • Added tests for slot positioning (start/end placement verification)
  • Added tests for selected text content verification (excludes slotted content)

Does this introduce a breaking change?

  • Yes
  • No

No, developers will be able to continue using plain text for select options as usual.

Other information

Preview

@github-actions github-actions Bot added the package: core @ionic/core package label Apr 9, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview, Comment Apr 25, 2026 3:32am

Request Review

// --------------------------------------------------

.action-sheet-button-label {
gap: 12px;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to use the same value that ionic uses.

<div class="alert-button-inner">
<div class="alert-checkbox-icon">
<div class="alert-checkbox-inner"></div>
{inputs.map((i) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

<div class="alert-button-inner">
<div class="alert-radio-icon">
<div class="alert-radio-inner"></div>
{inputs.map((i) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

*/
this.closeModal();
}
{this.options.map((option, index) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

onIonChange={(ev) => {
this.setChecked(ev);
this.callOptionHandler(ev);
return this.options.map((option, index) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

/**
* Text that is placed underneath the option text to provide additional details about the option.
*/
@Prop() description?: string;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description isn't being used here to display on the screen because ion-select-option isn't used to display the options, it's done through the respective interface. So description is passed to the interface.

Comment on lines +46 to +48
<slot name="start"></slot>
<slot></slot>
<slot name="end"></slot>
Copy link
Copy Markdown
Contributor Author

@thetaPC thetaPC Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slots aren't being used here to display on the screen because ion-select-option isn't used to display the options, it's done through the respective interface. So slots are passed to the interface and the rendering is done with a new utility.

Even though it's not used to display here, it's still useful to include here so the docs can generate that "slots" are available for the options.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the slots here isn't what generates the docs. You need to add this to 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 item text in LTR, and to the right in RTL.
* @slot end - Content is placed to the right of the item text in LTR, and to the left in RTL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onIonChange={(ev) => {
this.setChecked(ev);
this.callOptionHandler(ev);
return options.map((option, index) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

*/
this.dismissParentPopover();
}
{options.map((option, index) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only change is the addition of optionLabelOptions and renderOptionLabel

// --------------------------------------------------

.action-sheet-button-label {
gap: 12px;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based it off ionic theme

@thetaPC thetaPC requested a review from ShaneK April 16, 2026 01:06
Copy link
Copy Markdown
Member

@ShaneK ShaneK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! I consider these comments to be optional side quests (nits), feel free to disregard

Comment thread core/src/utils/select-option-render.tsx Outdated

import { sanitizeDOMString } from './sanitization';

interface RichContentOption {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RichContentOption is declared here with id, label, startContent, endContent, description, and a separate RichContentOption in select-interface.ts has only the three content fields. Same name, different shape, no shared source. Could we export one from select-interface.ts and import it here so we can avoid drift?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<ion-select id="alert-select" label="Alert" placeholder="Select one" interface="alert">
<ion-select-option value="full" description="Choose me!">
<ion-badge slot="end">NEW</ion-badge>
<ion-avatar id="avatar" slot="start" size="large">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id="avatar" is repeated 35+ times on this page (lines 60, 67, 74, etc.) Duplicate IDs are invalid HTML and none of the tests query by this id. Can we drop the attribute? I assume it's just copy pasta at this point

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread core/src/utils/select-option-render.tsx Outdated

// Render simple string label if there is no rich content to display
if (!hasRichContent && (typeof label === 'string' || !label)) {
// const Tag = useSpan ? 'span' : 'div';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead commented line, Tag is already declared at line 84. Can you drop this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The popover example has cut off elements inside of it in all themes:

ionic ios md
ionic ios md

Copy link
Copy Markdown
Contributor Author

@thetaPC thetaPC Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the ionic theme, when you open and close the modal from the icon it scrolls to the bottom of the view and also the focus indicator is cut off:

bug-with-select-multiple.mov

This does not happen for:

  • The single value modal example
  • The ios theme
  • The md theme
  • The overflowing or multiple basic select test on next
    • However, the focus indicator issue is there on the multiple example

Note: I didn't test if this exact test has the issue on next since this test is new.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not able to replicate either issues on Chrome and Firefox. What are the steps you took?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add screenshot tests for this? We need to check the visual rendering as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason that I didn't create screenshots for this is because the content for the options are based on the project so they're not fixed. Would it be beneficial to create screenshots for something like that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox for the multiple value select modal looks different than the UX requirements - do we have a way for them to make this change?

Our implementation Figma requirements
Our implementation Figma requirements

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That isn't part of the scope in my opinion. The ticket is meant to add HTML content to the options.

Comment on lines +46 to +48
<slot name="start"></slot>
<slot></slot>
<slot name="end"></slot>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the slots here isn't what generates the docs. You need to add this to 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 item text in LTR, and to the right in RTL.
* @slot end - Content is placed to the right of the item text in LTR, and to the left in RTL.

return container;
};

const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a comment describing this function since the others have comments?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


import { sanitizeDOMString } from './sanitization';

interface RichContentOption extends RichContentOpt {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we redefine this here instead of adding those properties to select-interface?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted the types to be strict.

* @param useSpan - Whether to use a span element instead of a div for the wrapper.
*/
const renderClonedContent = (id: string, content: HTMLElement, className: string, useSpan = false) => {
const Tag = useSpan ? 'span' : 'div';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why the span is required, but why do we ever need to render as a div?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's based on the flow of the interface. When it comes to action sheet, that is using buttons to render the option so we should be using span. The other interfaces are using divs to render the option. So in order to be consistent with the flow, I have it using a div.

): HTMLElement | string | undefined => {
const { id, label, startContent, endContent, description } = option;
const hasRichContent = !!startContent || !!endContent || !!description;
const Tag = useSpan ? 'span' : 'div';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why the span is required, but why do we ever need to render as a div?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The avatar size in md theme is way too large. Can we decrease it here to match the other themes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to not modify the width since we'll eventually add sizes to native avatar and we can adjust it at that point. Let me know if you would still prefer to make the change now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: angular @ionic/angular package package: core @ionic/core package package: vue @ionic/vue package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants