Skip to content

bug: ion-select shows the wrong text for an option with multiple child nodes #31381

Description

@ptmkenny

Prerequisites

Ionic Framework Version

v9.x

Current Behavior

ion-select derives the text it displays for the selected option, and that option's
contribution to the button's aria-label, from the option's child nodes. In v9 that
derivation is wrong whenever an option has more than one child node, or has its text
wrapped in an element.

ion-select-option content browser renders v8 v9
Star Star Star Star
two sibling text nodes, {'★'}{'Star'} ★Star ★Star ★ Star
A <b>Star</b> A Star A Star A
<b>Star</b> Star Star (empty)

Two distinct defects:

  1. A space is inserted between adjacent text nodes. Every framework renders
    {icon}{label} as two sibling text nodes with no whitespace between them, so any
    option with an emoji, flag, or icon prefix gains a space that is not in the DOM.

  2. Text wrapped in an element is dropped entirely. An option whose content is
    <b>Star</b>, <span>Star</span>, or an i18n component's wrapper element renders as
    an empty select with an empty accessible name, silently. This is the more damaging
    of the two.

Both affect the visible selected text and the aria-label, and both propagate to every
overlay interface — createAlertInputs, createActionSheetButtons, and
createOverlaySelectOptions read the option through the same helper. In the linked
reproduction, opening the first select shows an alert radio labelled ★ Star.

There is no error or warning; the text is just wrong.

Expected Behavior

The selected text and aria-label should match what the browser renders for the option's
content, which is what v8 produced via textContent: ★Star for two adjacent text nodes,
and A Star for A <b>Star</b>.

Steps to Reproduce

  1. git clone https://github.com/ptmkenny/ionic-react-router-6-test.git

  2. git checkout select-option-text-space

  3. npm install

  4. npm run dev and open the app (Tab 1).

  5. Observe: the heading reads 2 of 2 wrong. The page reads each select's shadow DOM
    after it renders and prints what the component displays next to what the markup should
    produce:

    ❌ Adjacent text nodes
    option content: {'★'}{'Star'}
    expected:   "★Star"
    displayed:  "★ Star"
    aria-label: "Adjacent text nodes, ★ Star"
    
    ❌ Text wrapped in an element
    option content: A <b>Star</b>
    expected:   "A Star"
    displayed:  "A"
    aria-label: "Wrapped in an element, A"
    
  6. Optionally open either select: the alert interface shows the same wrong text
    (★ Star).

Code Reproduction URL

https://github.com/ptmkenny/ionic-react-router-6-test/tree/select-option-text-space

Ionic Info

Ionic:

   Ionic CLI       : 7.2.1 (/home/node/.npm/_npx/f6fddb685269761d/node_modules/@ionic/cli)
   Ionic Framework : @ionic/react 9.0.0

Capacitor:

   Capacitor CLI      : 8.0.0
   @capacitor/android : not installed
   @capacitor/core    : 8.0.0
   @capacitor/ios     : not installed

Utility:

   cordova-res : not installed globally
   native-run  : 2.0.1

System:

   NodeJS : v24.19.0 (/usr/local/bin/node)
   npm    : 11.17.0
   NodeJS : v24.19.0 (/usr/local/bin/node)
   npm    : 11.17.0
   OS     : Linux 6.18

Additional Information

Root cause analysis by Claude Opus:

textForValue() in core/src/components/select/select.tsx used to be:

return selectOpt ? selectOpt.textContent : null;

#31241
(a35e13d59872c2136a3f44871fbb0146d0007c19, resolving
#29890) replaced
option.textContent with getDefaultSlotPlainText() at four call sites — textForValue,
createAlertInputs, createActionSheetButtons, and createOverlaySelectOptions. That
helper is:

const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => {
  const texts = Array.from(option.childNodes)
    .filter((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        return !(node as HTMLElement).hasAttribute('slot');
      }
`textForValue()` in `core/src/components/select/select.tsx` used to be:

```ts
return selectOpt ? selectOpt.textContent : null;

#31241
(a35e13d59872c2136a3f44871fbb0146d0007c19, resolving
#29890) replaced
option.textContent with getDefaultSlotPlainText() at four call sites — textForValue,
createAlertInputs, createActionSheetButtons, and createOverlaySelectOptions. That
helper is:

const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => {
  const texts = Array.from(option.childNodes)
    .filter((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        return !(node as HTMLElement).hasAttribute('slot');
      }
      return node.nodeType === Node.TEXT_NODE;
    })
    .filter((node) => node.nodeType === Node.TEXT_NODE)   // discards every element
    .map((n) => n.textContent?.trim())
    .filter((t) => t);
  return texts.join(' ');                                  // separator not in the DOM
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions