From 76cd2c3030829fbd17fc1d735a4ca9d2fc13de42 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Thu, 20 Aug 2026 13:48:13 +0900 Subject: [PATCH 01/11] spacing fix --- core/src/components/select/select.tsx | 18 ++--- .../components/select/test/select.spec.tsx | 68 +++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index c870291ec4c..c595d868cd0 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1639,19 +1639,15 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null /** * Extracts plain text from only the default slot of an option, * excluding content assigned to named slots (start/end). + * + * The nodes are read the way the browser renders them: their text is + * concatenated with no separator, and collapsible whitespace is collapsed to a + * single space. */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { - const texts = Array.from(option.childNodes) - .filter((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - return !(node as HTMLElement).hasAttribute('slot'); - } - return node.nodeType === Node.TEXT_NODE; - }) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' '); + const text = (getOptionDefaultSlot(option) ?? []).map((node) => node.textContent ?? '').join(''); + // Only the whitespace characters HTML collapses; NBSP and friends are kept. + return text.replace(/[ \t\n\r\f]+/g, ' ').trim(); }; /** diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index ad7c0d3d050..4d04b738815 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -160,6 +160,74 @@ describe('ion-select: required', () => { }); }); +describe('ion-select: option plain text', () => { + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + const option = select.querySelector('ion-select-option')!; + + /** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ + option.append(document.createTextNode('★'), document.createTextNode('Star')); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); + }); + + it('should read option text that is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `A Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('A Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('A Star'); + }); + + it('should ignore content assigned to the start and end slots', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `LeadStarTrail`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + }); + + it('should collapse whitespace from the source markup around option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star Option + + + `, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star Option'); + }); +}); + describe('ion-select: option content property reflection', () => { beforeEach(() => { // Cloning rich option content into the select text only happens when From 15f519ebcc91b0abd6558e1a2458a49b9aaed5cb Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 00:44:57 +0900 Subject: [PATCH 02/11] add test cases for "whole content wrapped" and "overlay (alert) label" --- .../components/select/test/select.spec.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 4d04b738815..54e07d93a05 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -1,5 +1,6 @@ import { h } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { alertController } from '@utils/overlays'; import { config } from '../../../global/config'; import { SelectOption } from '../../select-option/select-option'; @@ -197,6 +198,24 @@ describe('ion-select: option plain text', () => { expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('A Star'); }); + it('should read option text when the whole option content is wrapped in an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * An option with no text node of its own, such as one whose label comes + * from an i18n component, would otherwise render as an empty select with + * an empty accessible name. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + it('should ignore content assigned to the start and end slots', async () => { const page = await newSpecPage({ components: [Select, SelectOption], @@ -228,6 +247,60 @@ describe('ion-select: option plain text', () => { }); }); +describe('ion-select: overlay option labels', () => { + /** + * The overlay interfaces build their labels from the same helper that + * produces the displayed text, so they need the same coverage. `ion-alert` + * is not defined in a spec page, so the created overlay is stubbed and the + * options passed to the controller are asserted instead. + */ + const stubAlertController = () => + jest.spyOn(alertController, 'create').mockImplementation(async () => { + const overlay = document.createElement('div') as any; + overlay.present = () => Promise.resolve(); + // Never resolves, so the select keeps treating the overlay as open. + overlay.onDidDismiss = () => new Promise(() => {}); + return overlay; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ` + + + Star + + `, + }); + + const select = page.body.querySelector('ion-select')!; + + /** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ + select + .querySelector('ion-select-option[value="adjacent"]')! + .append(document.createTextNode('\u2605'), document.createTextNode('Star')); + + await page.waitForChanges(); + + await select.open(); + + expect(createAlert).toHaveBeenCalledTimes(1); + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['\u2605Star', 'Star']); + }); +}); + describe('ion-select: option content property reflection', () => { beforeEach(() => { // Cloning rich option content into the select text only happens when From 411e73cba2901bb43c333ebe9c2b09771f9397c5 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 00:48:44 +0900 Subject: [PATCH 03/11] apply suggestion by ShaneK in https://github.com/ionic-team/ionic-framework/pull/31382#discussion_r3822801867 that GitHub can't auto-apply --- core/src/components/select/select.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index c595d868cd0..3386679292b 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1638,15 +1638,16 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null /** * Extracts plain text from only the default slot of an option, - * excluding content assigned to named slots (start/end). + * excluding content assigned to named slots (start/end). Text is + * concatenated with no separator and collapsible whitespace is + * collapsed, approximating how the browser renders the option. + * NBSP is not collapsible, so it is preserved. * - * The nodes are read the way the browser renders them: their text is - * concatenated with no separator, and collapsible whitespace is collapsed to a - * single space. + * @param option - The `ion-select-option` element to read text from. + * @returns The option's default slot text. */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { const text = (getOptionDefaultSlot(option) ?? []).map((node) => node.textContent ?? '').join(''); - // Only the whitespace characters HTML collapses; NBSP and friends are kept. return text.replace(/[ \t\n\r\f]+/g, ' ').trim(); }; From 7b962af77e04a2852f074bf4460af71a0f7403f4 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 00:51:14 +0900 Subject: [PATCH 04/11] add aria-label assertion --- core/src/components/select/test/select.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 54e07d93a05..fc1686e2ccd 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -226,6 +226,7 @@ describe('ion-select: option plain text', () => { await page.waitForChanges(); expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); }); it('should collapse whitespace from the source markup around option text', async () => { From 526ba9b163914c710314b8f541a885bb55faed68 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 01:00:41 +0900 Subject: [PATCH 05/11] fix adjacent text nodes when innerHTMLTemplatesEnabled is on --- core/src/components/select/select.tsx | 8 +- .../components/select/test/select.spec.tsx | 91 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 3386679292b..05b3dd02a49 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1570,9 +1570,13 @@ const getOptionContent = ( return null; } - // Return plain text if no elements are found + /** + * Return plain text if no elements are found. This reads the option the + * same way the non-custom-HTML path does, so the two do not disagree + * about what an option's text is. + */ if (!slotName && nodes.every((n) => n.nodeType === Node.TEXT_NODE)) { - return nodes.map((n) => n.textContent?.trim()).join(' ') || null; + return getDefaultSlotPlainText(option) || null; } /** diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index fc1686e2ccd..1438e36b95d 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -248,22 +248,22 @@ describe('ion-select: option plain text', () => { }); }); -describe('ion-select: overlay option labels', () => { - /** - * The overlay interfaces build their labels from the same helper that - * produces the displayed text, so they need the same coverage. `ion-alert` - * is not defined in a spec page, so the created overlay is stubbed and the - * options passed to the controller are asserted instead. - */ - const stubAlertController = () => - jest.spyOn(alertController, 'create').mockImplementation(async () => { - const overlay = document.createElement('div') as any; - overlay.present = () => Promise.resolve(); - // Never resolves, so the select keeps treating the overlay as open. - overlay.onDidDismiss = () => new Promise(() => {}); - return overlay; - }); +/** + * The overlay interfaces build their labels from the same helper that produces + * the displayed text, so they need the same coverage. `ion-alert` is not + * defined in a spec page, so the created overlay is stubbed and the options + * passed to the controller are asserted instead. + */ +const stubAlertController = () => + jest.spyOn(alertController, 'create').mockImplementation(async () => { + const overlay = document.createElement('div') as any; + overlay.present = () => Promise.resolve(); + // Never resolves, so the select keeps treating the overlay as open. + overlay.onDidDismiss = () => new Promise(() => {}); + return overlay; + }); +describe('ion-select: overlay option labels', () => { afterEach(() => { jest.restoreAllMocks(); }); @@ -302,6 +302,67 @@ describe('ion-select: overlay option labels', () => { }); }); +describe('ion-select: option plain text with custom HTML enabled', () => { + /** + * With `innerHTMLTemplatesEnabled` on, the option is read through + * `getOptionContent` instead. An option that holds only text still has to + * produce the same text as the default path. + */ + beforeEach(() => { + config.reset({ innerHTMLTemplatesEnabled: true }); + }); + + afterEach(() => { + config.reset({}); + jest.restoreAllMocks(); + }); + + const appendAdjacentTextNodes = (select: HTMLIonSelectElement) => { + /** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ + select + .querySelector('ion-select-option')! + .append(document.createTextNode('\u2605'), document.createTextNode('Star')); + }; + + it('should not insert a space between adjacent text nodes in an option', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select); + + select.value = 'star'; + await page.waitForChanges(); + + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe('\u2605Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u2605Star'); + }); + + it('should label alert inputs with the text the option renders', async () => { + const createAlert = stubAlertController(); + + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select); + await page.waitForChanges(); + + await select.open(); + + const { inputs } = createAlert.mock.calls[0][0]; + expect(inputs!.map((input) => input.label)).toEqual(['\u2605Star']); + }); +}); + describe('ion-select: option content property reflection', () => { beforeEach(() => { // Cloning rich option content into the select text only happens when From 313566d72ee0270e4b83a56a1b2285401d29b500 Mon Sep 17 00:00:00 2001 From: ptmkenny <1451472+ptmkenny@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:58:01 +0900 Subject: [PATCH 06/11] Update core/src/components/select/select.tsx Co-authored-by: Shane --- core/src/components/select/select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 05b3dd02a49..55c959a60cb 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1652,7 +1652,7 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { const text = (getOptionDefaultSlot(option) ?? []).map((node) => node.textContent ?? '').join(''); - return text.replace(/[ \t\n\r\f]+/g, ' ').trim(); + return text.replace(/[ \t\n\r\f]+/g, ' ').replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, ''); }; /** From b1d43aef48ed009b9ea9504ba1a2b8e47e2827d5 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 10:10:40 +0900 Subject: [PATCH 07/11] ignore blockedTags --- core/src/components/select/select.tsx | 32 +++++++++++++++++-- .../components/select/test/select.spec.tsx | 18 +++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 55c959a60cb..6cf6168cb53 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -15,7 +15,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 { reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; +import { blockedTags, reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; import type { SlotMutationController } from '@utils/slot-mutation-controller'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -1640,6 +1640,34 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null return defaultSlotNodes; }; +/** + * Concatenates the text a node renders, skipping the subtrees of tags + * whose contents the browser never paints (`script`, `style`, and the + * rest of `blockedTags`). `textContent` includes those, so reading it + * directly would put stylesheet or script source into the select text + * and the `aria-label`. + * + * @param node - The node to read text from. + * @returns The node's rendered text. + */ +const getRenderedTextContent = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + if (blockedTags.includes((node as Element).tagName.toLowerCase())) { + return ''; + } + + return Array.from(node.childNodes) + .map((child) => getRenderedTextContent(child)) + .join(''); +}; + /** * Extracts plain text from only the default slot of an option, * excluding content assigned to named slots (start/end). Text is @@ -1651,7 +1679,7 @@ const getOptionDefaultSlot = (option: HTMLIonSelectOptionElement): Node[] | null * @returns The option's default slot text. */ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => { - const text = (getOptionDefaultSlot(option) ?? []).map((node) => node.textContent ?? '').join(''); + const text = (getOptionDefaultSlot(option) ?? []).map((node) => getRenderedTextContent(node)).join(''); return text.replace(/[ \t\n\r\f]+/g, ' ').replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, ''); }; diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 1438e36b95d..965b476f0c7 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -229,6 +229,24 @@ describe('ion-select: option plain text', () => { expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); }); + it('should not read text the browser never paints', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * `textContent` includes the source of tags the browser does not render, + * and those tags are the same ones the sanitizer strips from the + * custom HTML path, so both paths have to agree to ignore them. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('Star'); + }); + it('should collapse whitespace from the source markup around option text', async () => { const page = await newSpecPage({ components: [Select, SelectOption], From 5b2443f9868d6fa780559f8c2674bea91e7b2c50 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 10:19:22 +0900 Subject: [PATCH 08/11] derive option text through one helper on both config paths --- core/src/components/select/select.tsx | 33 ++++--------------- .../components/select/test/select.spec.tsx | 29 ++++++++++++++++ 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 6cf6168cb53..7c3f7f7477f 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1481,33 +1481,14 @@ const textForValue = ( } /** - * When custom HTML is enabled, extract only the default slot content. - * This ensures aria-label and other text-only contexts read only - * the relevant option text. + * Every text-only context reads only the default slot, so the start + * and end slots stay out of the `aria-label` and the overlay labels. + * Both config paths derive that text through the same helper, so they + * cannot disagree about what an option's text is. `null` marks an + * option with no text, which is dropped from the joined text of a + * `multiple` select rather than joined in as an empty entry. */ - if (customHTMLEnabled) { - const content = getOptionContent(selectOpt); - - if (typeof content === 'string') { - return content; - } - - /** - * Elements were found in the default slot, extract and concatenate - * their text content while trimming whitespace. - */ - if (content) { - const texts = Array.from(content.childNodes) - .map((n) => n.textContent?.trim()) - .filter((t) => t); - return texts.join(' ') || null; - } - - // Empty option - return null; - } - - return getDefaultSlotPlainText(selectOpt); + return getDefaultSlotPlainText(selectOpt) || null; }; /** diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 965b476f0c7..220c6f74cd4 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -362,6 +362,35 @@ describe('ion-select: option plain text with custom HTML enabled', () => { expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u2605Star'); }); + it('should not insert a space between adjacent text nodes in an option that also holds an element', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: ``, + }); + + const select = page.body.querySelector('ion-select')!; + appendAdjacentTextNodes(select); + + const badge = document.createElement('ion-badge'); + badge.textContent = 'NEW'; + select.querySelector('ion-select-option')!.append(badge); + + select.value = 'star'; + await page.waitForChanges(); + + /** + * An element in the default slot reads the option through a different + * branch than an option that holds only text. The text nodes render as + * one span, so the `aria-label` has to keep them together too. The + * visible separation from the badge comes from `--select-text-gap` + * rather than from a space in the text. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe( + '\u2605StarNEW' + ); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u2605StarNEW'); + }); + it('should label alert inputs with the text the option renders', async () => { const createAlert = stubAlertController(); From 1e8847bd5012509fc3f16ba19491a1eb4df84c07 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 10:22:30 +0900 Subject: [PATCH 09/11] hoist comment and consistently use symbol for star --- .../components/select/test/select.spec.tsx | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 220c6f74cd4..0b6f9b0afcc 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -169,14 +169,8 @@ describe('ion-select: option plain text', () => { }); const select = page.body.querySelector('ion-select')!; - const option = select.querySelector('ion-select-option')!; - /** - * Frameworks render `{icon}{label}` as two sibling text nodes with no - * whitespace between them. The nodes have to be built here rather than in - * markup, because a parser collapses adjacent text into a single node. - */ - option.append(document.createTextNode('★'), document.createTextNode('Star')); + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); select.value = 'star'; await page.waitForChanges(); @@ -266,6 +260,15 @@ describe('ion-select: option plain text', () => { }); }); +/** + * Frameworks render `{icon}{label}` as two sibling text nodes with no + * whitespace between them. The nodes have to be built here rather than in + * markup, because a parser collapses adjacent text into a single node. + */ +const appendAdjacentTextNodes = (option: Element) => { + option.append(document.createTextNode('★'), document.createTextNode('Star')); +}; + /** * The overlay interfaces build their labels from the same helper that produces * the displayed text, so they need the same coverage. `ion-alert` is not @@ -301,14 +304,7 @@ describe('ion-select: overlay option labels', () => { const select = page.body.querySelector('ion-select')!; - /** - * Frameworks render `{icon}{label}` as two sibling text nodes with no - * whitespace between them. The nodes have to be built here rather than in - * markup, because a parser collapses adjacent text into a single node. - */ - select - .querySelector('ion-select-option[value="adjacent"]')! - .append(document.createTextNode('\u2605'), document.createTextNode('Star')); + appendAdjacentTextNodes(select.querySelector('ion-select-option[value="adjacent"]')!); await page.waitForChanges(); @@ -316,7 +312,7 @@ describe('ion-select: overlay option labels', () => { expect(createAlert).toHaveBeenCalledTimes(1); const { inputs } = createAlert.mock.calls[0][0]; - expect(inputs!.map((input) => input.label)).toEqual(['\u2605Star', 'Star']); + expect(inputs!.map((input) => input.label)).toEqual(['★Star', 'Star']); }); }); @@ -335,17 +331,6 @@ describe('ion-select: option plain text with custom HTML enabled', () => { jest.restoreAllMocks(); }); - const appendAdjacentTextNodes = (select: HTMLIonSelectElement) => { - /** - * Frameworks render `{icon}{label}` as two sibling text nodes with no - * whitespace between them. The nodes have to be built here rather than in - * markup, because a parser collapses adjacent text into a single node. - */ - select - .querySelector('ion-select-option')! - .append(document.createTextNode('\u2605'), document.createTextNode('Star')); - }; - it('should not insert a space between adjacent text nodes in an option', async () => { const page = await newSpecPage({ components: [Select, SelectOption], @@ -353,13 +338,13 @@ describe('ion-select: option plain text with custom HTML enabled', () => { }); const select = page.body.querySelector('ion-select')!; - appendAdjacentTextNodes(select); + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); select.value = 'star'; await page.waitForChanges(); - expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe('\u2605Star'); - expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u2605Star'); + expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe('★Star'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★Star'); }); it('should not insert a space between adjacent text nodes in an option that also holds an element', async () => { @@ -369,11 +354,12 @@ describe('ion-select: option plain text with custom HTML enabled', () => { }); const select = page.body.querySelector('ion-select')!; - appendAdjacentTextNodes(select); + const option = select.querySelector('ion-select-option')!; + appendAdjacentTextNodes(option); const badge = document.createElement('ion-badge'); badge.textContent = 'NEW'; - select.querySelector('ion-select-option')!.append(badge); + option.append(badge); select.value = 'star'; await page.waitForChanges(); @@ -386,9 +372,9 @@ describe('ion-select: option plain text with custom HTML enabled', () => { * rather than from a space in the text. */ expect(select.shadowRoot!.querySelector('.select-text')!.innerHTML).toBe( - '\u2605StarNEW' + '★StarNEW' ); - expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u2605StarNEW'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('★StarNEW'); }); it('should label alert inputs with the text the option renders', async () => { @@ -400,13 +386,13 @@ describe('ion-select: option plain text with custom HTML enabled', () => { }); const select = page.body.querySelector('ion-select')!; - appendAdjacentTextNodes(select); + appendAdjacentTextNodes(select.querySelector('ion-select-option')!); await page.waitForChanges(); await select.open(); const { inputs } = createAlert.mock.calls[0][0]; - expect(inputs!.map((input) => input.label)).toEqual(['\u2605Star']); + expect(inputs!.map((input) => input.label)).toEqual(['★Star']); }); }); From 88183ab136bf16655b1f0e5467cd164aa930b948 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 10:25:58 +0900 Subject: [PATCH 10/11] rename for clarity --- .../select/test/rich-content-option/select.e2e.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts b/core/src/components/select/test/rich-content-option/select.e2e.ts index 3acf2d806ac..fa29d7e49e5 100644 --- a/core/src/components/select/test/rich-content-option/select.e2e.ts +++ b/core/src/components/select/test/rich-content-option/select.e2e.ts @@ -430,7 +430,7 @@ configs({ modes: ['md'] }).forEach(({ title, config }) => { */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('select: rich content options'), () => { - test('it should only render text nodes when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { + test('should not render markup when `innerHTMLTemplatesEnabled` is disabled', async ({ page }) => { await page.setContent( ` @@ -466,6 +466,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(endContainer).toHaveCount(0); await expect(span).toHaveCount(0); + /** + * The span is not rendered, but the text it wrapped still reads as + * text, so the option is not silently emptied out. + */ + await expect(firstOption).toContainText('Full Content This is a span element'); + // Click on the first option await firstOption.click(); @@ -479,6 +485,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectTextSpan = selectText.locator('.span-style'); await expect(selectTextSpan).toHaveCount(0); + + /** + * Only the default slot is read, so the text of the `start` and `end` + * slots stays out of the selected text. + */ + await expect(selectText).toHaveText('Full Content This is a span element'); }); }); }); From 33c7efa2a51799009be5409ddc85536e8a9eed34 Mon Sep 17 00:00:00 2001 From: Patrick Kenny Date: Fri, 21 Aug 2026 10:40:08 +0900 Subject: [PATCH 11/11] add additional test on handling of non-breaking spaces --- .../src/components/select/test/select.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index 0b6f9b0afcc..f214e289501 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -258,6 +258,24 @@ describe('ion-select: option plain text', () => { expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('Star Option'); }); + + it('should preserve a non-breaking space that indents option text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `  Star Option`, + }); + + const select = page.body.querySelector('ion-select')!; + await page.waitForChanges(); + + /** + * NBSP is not collapsible, so an option indented with ` ` to fake a + * hierarchy keeps its indentation. Trimming has to leave it alone too, + * which rules out `String.prototype.trim`. + */ + expect(select.shadowRoot!.querySelector('.select-text')!.textContent).toBe('\u00a0\u00a0Star Option'); + expect(select.shadowRoot!.querySelector('button')!.getAttribute('aria-label')).toBe('\u00a0\u00a0Star Option'); + }); }); /**