diff --git a/core/src/components/accordion-group/accordion-group.tsx b/core/src/components/accordion-group/accordion-group.tsx
index ba5b110c8b6..a2b2f82c700 100644
--- a/core/src/components/accordion-group/accordion-group.tsx
+++ b/core/src/components/accordion-group/accordion-group.tsx
@@ -88,17 +88,12 @@ export class AccordionGroup implements ComponentInterface {
*/
printIonWarning(
`[ion-accordion-group] - An array of values was passed, but multiple is "false". This is incorrect usage and may result in unexpected behaviors. To dismiss this warning, pass a string to the "value" property when multiple="false".
-
Value Passed: [${value.map((v) => `'${v}'`).join(', ')}]
`,
this.el
);
}
- /**
- * Do not use `value` here as that will be
- * not account for the adjustment we make above.
- */
this.ionValueChange.emit({ value: this.value });
}
diff --git a/core/src/components/accordion/accordion.tsx b/core/src/components/accordion/accordion.tsx
index 92d28848d20..249f8a260cb 100644
--- a/core/src/components/accordion/accordion.tsx
+++ b/core/src/components/accordion/accordion.tsx
@@ -38,7 +38,40 @@ const enum AccordionState {
})
export class Accordion implements ComponentInterface {
private accordionGroupEl?: HTMLIonAccordionGroupElement | null;
- private updateListener = () => this.updateState(false);
+ private updateListener = () => {
+ /**
+ * Determine if this update will cause an actual state change.
+ * We only want to mark as "interacted" if the state is changing.
+ */
+ const accordionGroup = this.accordionGroupEl;
+ if (accordionGroup) {
+ const value = accordionGroup.value;
+ const accordionValue = this.value;
+ const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
+ const isExpanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding;
+ const stateWillChange = shouldExpand !== isExpanded;
+
+ /**
+ * Only mark as interacted if:
+ * 1. This is not the first update we've received with a defined value
+ * 2. The state is actually changing (prevents redundant updates from enabling animations)
+ */
+ if (this.hasReceivedFirstUpdate && stateWillChange) {
+ this.hasInteracted = true;
+ }
+
+ /**
+ * Only count this as the first update if the group value is defined.
+ * This prevents the initial undefined value from the group's componentDidLoad
+ * from being treated as the first real update.
+ */
+ if (value !== undefined) {
+ this.hasReceivedFirstUpdate = true;
+ }
+ }
+
+ this.updateState();
+ };
private contentEl: HTMLDivElement | undefined;
private contentElWrapper: HTMLDivElement | undefined;
private headerEl: HTMLDivElement | undefined;
@@ -50,6 +83,25 @@ export class Accordion implements ComponentInterface {
@State() state: AccordionState = AccordionState.Collapsed;
@State() isNext = false;
@State() isPrevious = false;
+ /**
+ * Tracks whether a user-initiated interaction has occurred.
+ * Animations are disabled until the first interaction happens.
+ * This prevents the accordion from animating when it's programmatically
+ * set to an expanded or collapsed state on initial load.
+ */
+ @State() hasInteracted = false;
+
+ /**
+ * Tracks if this accordion has ever been expanded.
+ * Used to prevent the first expansion from animating.
+ */
+ private hasEverBeenExpanded = false;
+
+ /**
+ * Tracks if this accordion has received its first update from the group.
+ * Used to distinguish initial programmatic sets from user interactions.
+ */
+ private hasReceivedFirstUpdate = false;
/**
* The value of the accordion. Defaults to an autogenerated
@@ -88,7 +140,7 @@ export class Accordion implements ComponentInterface {
connectedCallback() {
const accordionGroupEl = (this.accordionGroupEl = this.el?.closest('ion-accordion-group'));
if (accordionGroupEl) {
- this.updateState(true);
+ this.updateState();
addEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
}
}
@@ -212,10 +264,16 @@ export class Accordion implements ComponentInterface {
ionItem.appendChild(iconEl);
};
- private expandAccordion = (initialUpdate = false) => {
+ private expandAccordion = () => {
const { contentEl, contentElWrapper } = this;
- if (initialUpdate || contentEl === undefined || contentElWrapper === undefined) {
+
+ /**
+ * If the content elements aren't available yet, just set the state.
+ * This happens on initial render before the DOM is ready.
+ */
+ if (contentEl === undefined || contentElWrapper === undefined) {
this.state = AccordionState.Expanded;
+ this.hasEverBeenExpanded = true;
return;
}
@@ -227,6 +285,12 @@ export class Accordion implements ComponentInterface {
cancelAnimationFrame(this.currentRaf);
}
+ /**
+ * Mark that this accordion has been expanded at least once.
+ * This allows subsequent expansions to animate.
+ */
+ this.hasEverBeenExpanded = true;
+
if (this.shouldAnimate()) {
raf(() => {
this.state = AccordionState.Expanding;
@@ -247,9 +311,14 @@ export class Accordion implements ComponentInterface {
}
};
- private collapseAccordion = (initialUpdate = false) => {
+ private collapseAccordion = () => {
const { contentEl } = this;
- if (initialUpdate || contentEl === undefined) {
+
+ /**
+ * If the content element isn't available yet, just set the state.
+ * This happens on initial render before the DOM is ready.
+ */
+ if (contentEl === undefined) {
this.state = AccordionState.Collapsed;
return;
}
@@ -291,6 +360,19 @@ export class Accordion implements ComponentInterface {
* of what is set in the config.
*/
private shouldAnimate = () => {
+ /**
+ * Don't animate until after the first user interaction.
+ * This prevents animations on initial load when accordions
+ * start in an expanded or collapsed state programmatically.
+ *
+ * Additionally, don't animate the very first expansion even if
+ * hasInteracted is true. This handles edge cases like React StrictMode
+ * where effects run twice and might incorrectly mark as interacted.
+ */
+ if (!this.hasInteracted || !this.hasEverBeenExpanded) {
+ return false;
+ }
+
if (typeof (window as any) === 'undefined') {
return false;
}
@@ -312,7 +394,7 @@ export class Accordion implements ComponentInterface {
return true;
};
- private updateState = async (initialUpdate = false) => {
+ private updateState = async () => {
const accordionGroup = this.accordionGroupEl;
const accordionValue = this.value;
@@ -325,10 +407,10 @@ export class Accordion implements ComponentInterface {
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
if (shouldExpand) {
- this.expandAccordion(initialUpdate);
+ this.expandAccordion();
this.isNext = this.isPrevious = false;
} else {
- this.collapseAccordion(initialUpdate);
+ this.collapseAccordion();
/**
* When using popout or inset,
@@ -386,6 +468,12 @@ export class Accordion implements ComponentInterface {
if (disabled || readonly) return;
+ /**
+ * Mark that the user has interacted with the accordion.
+ * This enables animations for all future state changes.
+ */
+ this.hasInteracted = true;
+
if (accordionGroupEl) {
/**
* Because the accordion group may or may
diff --git a/core/src/components/accordion/test/accordion.spec.ts b/core/src/components/accordion/test/accordion.spec.ts
index 54883002dbf..e10fdc9d279 100644
--- a/core/src/components/accordion/test/accordion.spec.ts
+++ b/core/src/components/accordion/test/accordion.spec.ts
@@ -200,6 +200,87 @@ it('should set default values if not provided', async () => {
expect(accordion.classList.contains('accordion-collapsed')).toEqual(false);
});
+it('should not animate when initial value is set before load', async () => {
+ const page = await newSpecPage({
+ components: [Item, Accordion, AccordionGroup],
+ });
+
+ const accordionGroup = page.doc.createElement('ion-accordion-group');
+ accordionGroup.innerHTML = `
+
+ Label
+ Content
+
+
+ Label
+ Content
+
+ `;
+
+ accordionGroup.value = 'first';
+ page.body.appendChild(accordionGroup);
+
+ await page.waitForChanges();
+
+ const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
+
+ expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
+ expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
+});
+
+it('should not animate when initial value is set after load', async () => {
+ const page = await newSpecPage({
+ components: [Item, Accordion, AccordionGroup],
+ });
+
+ const accordionGroup = page.doc.createElement('ion-accordion-group');
+ accordionGroup.innerHTML = `
+
+ Label
+ Content
+
+
+ Label
+ Content
+
+ `;
+
+ page.body.appendChild(accordionGroup);
+ await page.waitForChanges();
+
+ accordionGroup.value = 'first';
+ await page.waitForChanges();
+
+ const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
+
+ expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
+ expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
+});
+
+it('should not have animated class on first expansion', async () => {
+ const page = await newSpecPage({
+ components: [Item, Accordion, AccordionGroup],
+ html: `
+
+
+ Label
+ Content
+
+
+ `,
+ });
+
+ const accordionGroup = page.body.querySelector('ion-accordion-group')!;
+ const firstAccordion = page.body.querySelector('ion-accordion[value="first"]')!;
+
+ // First expansion should not have the animated class
+ accordionGroup.value = 'first';
+ await page.waitForChanges();
+
+ expect(firstAccordion.classList.contains('accordion-animated')).toEqual(false);
+ expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
+});
+
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/27047
it('should not have animated class when animated="false"', async () => {
const page = await newSpecPage({
diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx
index 2f7f4a63ded..634af89f075 100644
--- a/packages/react/test/base/src/App.tsx
+++ b/packages/react/test/base/src/App.tsx
@@ -37,6 +37,7 @@ import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted'
import OverlayComponents from './pages/overlay-components/OverlayComponents';
import OverlayHooks from './pages/overlay-hooks/OverlayHooks';
import ReorderGroup from './pages/ReorderGroup';
+import AccordionGroup from './pages/AccordionGroup';
setupIonicReact();
@@ -69,6 +70,7 @@ const App: React.FC = () => (
+
diff --git a/packages/react/test/base/src/pages/AccordionGroup.tsx b/packages/react/test/base/src/pages/AccordionGroup.tsx
new file mode 100644
index 00000000000..ffcfaca8bd2
--- /dev/null
+++ b/packages/react/test/base/src/pages/AccordionGroup.tsx
@@ -0,0 +1,54 @@
+import { IonHeader, IonTitle, IonToolbar, IonPage, IonContent, IonAccordionGroup, IonAccordion, IonItem, IonLabel } from '@ionic/react';
+import { useEffect, useRef } from 'react';
+
+const AccordionGroup: React.FC = () => {
+ const accordionGroup = useRef(null);
+
+ useEffect(() => {
+ if (!accordionGroup.current) {
+ return;
+ }
+
+ accordionGroup.current.value = ['first', 'third'];
+ }, []);
+
+ return (
+
+
+
+ Accordion Group
+
+
+
+
+
+
+ First Accordion
+
+
+ First Content
+
+
+
+
+ Second Accordion
+
+
+ Second Content
+
+
+
+
+ Third Accordion
+
+
+ Third Content
+
+
+
+
+
+ );
+};
+
+export default AccordionGroup;
diff --git a/packages/react/test/base/src/pages/Main.tsx b/packages/react/test/base/src/pages/Main.tsx
index dd87350d9be..3873cd3d5b5 100644
--- a/packages/react/test/base/src/pages/Main.tsx
+++ b/packages/react/test/base/src/pages/Main.tsx
@@ -22,6 +22,9 @@ const Main: React.FC = () => {
+
+ Accordion Group
+
Overlay Hooks