From 36b17fe550b8d5fdf4a4aa2d8fb6e4d7c23be6a6 Mon Sep 17 00:00:00 2001 From: soundproofboot Date: Mon, 23 Jun 2025 16:09:47 -0500 Subject: [PATCH 1/3] fix(item): allow nested content to be conditionally interactive --- core/src/components/item/item.tsx | 47 ++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 6384927de80..c0b71a5ea53 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -32,11 +32,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; + private observer?: MutationObserver; @Element() el!: HTMLIonItemElement; @State() multipleInputs = false; @State() focusable = true; + @State() isInteractive = false; /** * The color to use from your application's color palette. @@ -163,6 +165,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac connectedCallback() { this.hasStartEl(); + + // create MutationObserver to watch for changes in nested content + // and update state to reflect change in behavior and rerender + if (typeof MutationObserver !== 'undefined' && !this.observer) { + this.observer = new MutationObserver(() => { + this.setIsInteractive(); + this.setMultipleInputs(); + }); + + this.observer.observe(this.el, { + childList: true, + }); + } } componentWillLoad() { @@ -176,10 +191,14 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac }); } - // If the item contains multiple clickable elements and/or inputs, then the item - // should not have a clickable input cover over the entire item to prevent - // interfering with their individual click events - private setMultipleInputs() { + disconnectedCallback(): void { + // disconnect MutationObserver when component is disconnected from the DOM + if (this.observer) { + this.observer.disconnect(); + } + } + + private totalNestedInputs() { // The following elements have a clickable cover that is relative to the entire item const covers = this.el.querySelectorAll('ion-checkbox, ion-datetime, ion-select, ion-radio'); @@ -193,6 +212,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac // The following elements should also stay clickable when an input with cover is present const clickables = this.el.querySelectorAll('ion-router-link, ion-button, a, button'); + return { + covers, + inputs, + clickables, + }; + } + + // If the item contains multiple clickable elements and/or inputs, then the item + // should not have a clickable input cover over the entire item to prevent + // interfering with their individual click events + private setMultipleInputs() { + const { covers, inputs, clickables } = this.totalNestedInputs(); + // Check for multiple inputs to change the position of the input cover to relative // for all of the covered inputs above this.multipleInputs = @@ -201,6 +233,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac (covers.length > 0 && this.isClickable()); } + private setIsInteractive() { + // If item contains any interactive children, set isInteractive to `true` + const { covers, inputs, clickables } = this.totalNestedInputs(); + + this.isInteractive = !!(covers.length + inputs.length + clickables.length); + } + // If the item contains an input including a checkbox, datetime, select, or radio // then the item will have a clickable input cover that covers the item // that should get the hover, focused and activated states UNLESS it has multiple From 1575647fe46fe6b36d9f6936f9916aa4f022e65e Mon Sep 17 00:00:00 2001 From: soundproofboot Date: Thu, 10 Jul 2025 14:35:58 -0500 Subject: [PATCH 2/3] fix(item): replace mutation observer with slot change listeners --- core/src/components/item/item.tsx | 36 ++++++++++--------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index c0b71a5ea53..e5269bb4f0b 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -32,7 +32,6 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; - private observer?: MutationObserver; @Element() el!: HTMLIonItemElement; @@ -165,19 +164,6 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac connectedCallback() { this.hasStartEl(); - - // create MutationObserver to watch for changes in nested content - // and update state to reflect change in behavior and rerender - if (typeof MutationObserver !== 'undefined' && !this.observer) { - this.observer = new MutationObserver(() => { - this.setIsInteractive(); - this.setMultipleInputs(); - }); - - this.observer.observe(this.el, { - childList: true, - }); - } } componentWillLoad() { @@ -187,17 +173,11 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac componentDidLoad() { raf(() => { this.setMultipleInputs(); + this.setIsInteractive(); this.focusable = this.isFocusable(); }); } - disconnectedCallback(): void { - // disconnect MutationObserver when component is disconnected from the DOM - if (this.observer) { - this.observer.disconnect(); - } - } - private totalNestedInputs() { // The following elements have a clickable cover that is relative to the entire item const covers = this.el.querySelectorAll('ion-checkbox, ion-datetime, ion-select, ion-radio'); @@ -237,9 +217,15 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac // If item contains any interactive children, set isInteractive to `true` const { covers, inputs, clickables } = this.totalNestedInputs(); - this.isInteractive = !!(covers.length + inputs.length + clickables.length); + this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0; } + // slot change listener updates state to reflect how/if item should be interactive + private updateInteractivityOnSlotChange = () => { + this.setIsInteractive(); + this.setMultipleInputs(); + }; + // If the item contains an input including a checkbox, datetime, select, or radio // then the item will have a clickable input cover that covers the item // that should get the hover, focused and activated states UNLESS it has multiple @@ -403,12 +389,12 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac disabled={disabled} {...clickFn} > - +
- +
- + {showDetail && ( Date: Tue, 15 Jul 2025 10:09:37 -0500 Subject: [PATCH 3/3] test(item): add test to check conditional interactivity --- .../components/item/test/inputs/item.e2e.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/core/src/components/item/test/inputs/item.e2e.ts b/core/src/components/item/test/inputs/item.e2e.ts index 27ca3149b91..29685e87521 100644 --- a/core/src/components/item/test/inputs/item.e2e.ts +++ b/core/src/components/item/test/inputs/item.e2e.ts @@ -252,5 +252,46 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { await expect(list).toHaveScreenshot(screenshot(`item-inputs-div-with-inputs`)); }); + + test('should update interactivity state when elements are conditionally rendered', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29763', + }); + + await page.setContent( + ` + + + Conditional Checkbox + + + `, + config + ); + + const item = page.locator('ion-item'); + + await page.evaluate(() => { + const item = document.querySelector('ion-item'); + const checkbox = document.createElement('ion-checkbox'); + item?.appendChild(checkbox); + }); + + await page.waitForChanges(); + + const checkbox = page.locator('ion-checkbox'); + await expect(checkbox).not.toBeChecked(); + + // Test that clicking on the left edge of the item toggles the checkbox + await item.click({ + position: { + x: 5, + y: 5, + }, + }); + + await expect(checkbox).toBeChecked(); + }); }); });