Skip to content

Commit bede576

Browse files
committed
fix(accordion): trying to prevent animation based on interaction rather than render
1 parent f84c484 commit bede576

File tree

4 files changed

+99
-107
lines changed

4 files changed

+99
-107
lines changed

core/src/components/accordion-group/accordion-group-interface.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export interface AccordionGroupChangeEventDetail<T = any> {
22
value: T;
3-
initial?: boolean;
43
}
54

65
export interface AccordionGroupCustomEvent<T = any> extends CustomEvent {

core/src/components/accordion-group/accordion-group.tsx

Lines changed: 5 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core';
3-
import { printIonWarning } from '@utils/logging';
43

54
import { getIonMode } from '../../global/ionic-global';
65

@@ -73,13 +72,9 @@ export class AccordionGroup implements ComponentInterface {
7372
*/
7473
@Event() ionValueChange!: EventEmitter<AccordionGroupChangeEventDetail>;
7574

76-
private hasEmittedInitialValue = false;
77-
private isUserInitiatedChange = false;
78-
7975
@Watch('value')
8076
valueChanged() {
81-
this.emitValueChange(false, this.isUserInitiatedChange);
82-
this.isUserInitiatedChange = false;
77+
this.ionValueChange.emit({ value: this.value });
8378
}
8479

8580
@Watch('disabled')
@@ -164,12 +159,11 @@ export class AccordionGroup implements ComponentInterface {
164159
* it is possible for the value to be set after the Web Component
165160
* initializes but before the value watcher is set up in Stencil.
166161
* As a result, the watcher callback may not be fired.
167-
* We work around this by manually emitting a value change when the component
168-
* has loaded and the watcher is configured.
162+
* We work around this by manually calling the watcher
163+
* callback when the component has loaded and the watcher
164+
* is configured.
169165
*/
170-
if (!this.hasEmittedInitialValue) {
171-
this.emitValueChange(true);
172-
}
166+
this.valueChanged();
173167
}
174168

175169
/**
@@ -181,7 +175,6 @@ export class AccordionGroup implements ComponentInterface {
181175
* accordion group to an array.
182176
*/
183177
private setValue(accordionValue: string | string[] | null | undefined) {
184-
this.isUserInitiatedChange = true;
185178
const value = (this.value = accordionValue);
186179
this.ionChange.emit({ value });
187180
}
@@ -258,40 +251,6 @@ export class AccordionGroup implements ComponentInterface {
258251
return Array.from(this.el.querySelectorAll(':scope > ion-accordion')) as HTMLIonAccordionElement[];
259252
}
260253

261-
private emitValueChange(initial: boolean, isUserInitiated = false) {
262-
const { value, multiple } = this;
263-
264-
if (!multiple && Array.isArray(value)) {
265-
/**
266-
* We do some processing on the `value` array so
267-
* that it looks more like an array when logged to
268-
* the console.
269-
* Example given ['a', 'b']
270-
* Default toString() behavior: a,b
271-
* Custom behavior: ['a', 'b']
272-
*/
273-
printIonWarning(
274-
`[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".
275-
276-
Value Passed: [${value.map((v) => `'${v}'`).join(', ')}]
277-
`,
278-
this.el
279-
);
280-
}
281-
282-
/**
283-
* Track if this is the initial value update so accordions
284-
* can skip transition animations when they first render.
285-
*/
286-
const shouldMarkInitial = initial || (!this.hasEmittedInitialValue && value !== undefined && !isUserInitiated);
287-
288-
this.ionValueChange.emit({ value, initial: shouldMarkInitial });
289-
290-
if (value !== undefined) {
291-
this.hasEmittedInitialValue = true;
292-
}
293-
}
294-
295254
render() {
296255
const { disabled, readonly, expand } = this;
297256
const mode = getIonMode(this);

core/src/components/accordion/accordion.tsx

Lines changed: 88 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { chevronDown } from 'ionicons/icons';
55

66
import { config } from '../../global/config';
77
import { getIonMode } from '../../global/ionic-global';
8-
import type { AccordionGroupChangeEventDetail } from '../accordion-group/accordion-group-interface';
98

109
const enum AccordionState {
1110
Collapsed = 1 << 0,
@@ -39,9 +38,39 @@ const enum AccordionState {
3938
})
4039
export class Accordion implements ComponentInterface {
4140
private accordionGroupEl?: HTMLIonAccordionGroupElement | null;
42-
private updateListener = (ev: CustomEvent<AccordionGroupChangeEventDetail>) => {
43-
const initialUpdate = ev.detail?.initial ?? false;
44-
this.updateState(initialUpdate);
41+
private updateListener = () => {
42+
/**
43+
* Determine if this update will cause an actual state change.
44+
* We only want to mark as "interacted" if the state is changing.
45+
*/
46+
const accordionGroup = this.accordionGroupEl;
47+
if (accordionGroup) {
48+
const value = accordionGroup.value;
49+
const accordionValue = this.value;
50+
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
51+
const isExpanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding;
52+
const stateWillChange = shouldExpand !== isExpanded;
53+
54+
/**
55+
* Only mark as interacted if:
56+
* 1. This is not the first update we've received with a defined value
57+
* 2. The state is actually changing (prevents redundant updates from enabling animations)
58+
*/
59+
if (this.hasReceivedFirstUpdate && stateWillChange) {
60+
this.hasInteracted = true;
61+
}
62+
63+
/**
64+
* Only count this as the first update if the group value is defined.
65+
* This prevents the initial undefined value from the group's componentDidLoad
66+
* from being treated as the first real update.
67+
*/
68+
if (value !== undefined) {
69+
this.hasReceivedFirstUpdate = true;
70+
}
71+
}
72+
73+
this.updateState();
4574
};
4675
private contentEl: HTMLDivElement | undefined;
4776
private contentElWrapper: HTMLDivElement | undefined;
@@ -55,12 +84,24 @@ export class Accordion implements ComponentInterface {
5584
@State() isNext = false;
5685
@State() isPrevious = false;
5786
/**
58-
* Tracks whether the component has completed its initial render.
59-
* Animations are disabled until after the first render completes.
60-
* This prevents the accordion from animating when it starts
61-
* expanded or collapsed on initial load.
87+
* Tracks whether a user-initiated interaction has occurred.
88+
* Animations are disabled until the first interaction happens.
89+
* This prevents the accordion from animating when it's programmatically
90+
* set to an expanded or collapsed state on initial load.
6291
*/
63-
@State() hasRendered = false;
92+
@State() hasInteracted = false;
93+
94+
/**
95+
* Tracks if this accordion has ever been expanded.
96+
* Used to prevent the first expansion from animating.
97+
*/
98+
private hasEverBeenExpanded = false;
99+
100+
/**
101+
* Tracks if this accordion has received its first update from the group.
102+
* Used to distinguish initial programmatic sets from user interactions.
103+
*/
104+
private hasReceivedFirstUpdate = false;
64105

65106
/**
66107
* The value of the accordion. Defaults to an autogenerated
@@ -99,7 +140,7 @@ export class Accordion implements ComponentInterface {
99140
connectedCallback() {
100141
const accordionGroupEl = (this.accordionGroupEl = this.el?.closest('ion-accordion-group'));
101142
if (accordionGroupEl) {
102-
this.updateState(true);
143+
this.updateState();
103144
addEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
104145
}
105146
}
@@ -130,18 +171,6 @@ export class Accordion implements ComponentInterface {
130171
});
131172
}
132173

133-
componentDidRender() {
134-
/**
135-
* After the first render completes, mark that we've rendered.
136-
* Setting this state property triggers a re-render, at which point
137-
* animations will be enabled. This ensures animations are disabled
138-
* only for the initial render, avoiding unwanted animations on load.
139-
*/
140-
if (!this.hasRendered) {
141-
this.hasRendered = true;
142-
}
143-
}
144-
145174
private setItemDefaults = () => {
146175
const ionItem = this.getSlottedHeaderIonItem();
147176
if (!ionItem) {
@@ -235,10 +264,16 @@ export class Accordion implements ComponentInterface {
235264
ionItem.appendChild(iconEl);
236265
};
237266

238-
private expandAccordion = (initialUpdate = false) => {
267+
private expandAccordion = () => {
239268
const { contentEl, contentElWrapper } = this;
240-
if (initialUpdate || contentEl === undefined || contentElWrapper === undefined) {
269+
270+
/**
271+
* If the content elements aren't available yet, just set the state.
272+
* This happens on initial render before the DOM is ready.
273+
*/
274+
if (contentEl === undefined || contentElWrapper === undefined) {
241275
this.state = AccordionState.Expanded;
276+
this.hasEverBeenExpanded = true;
242277
return;
243278
}
244279

@@ -250,6 +285,12 @@ export class Accordion implements ComponentInterface {
250285
cancelAnimationFrame(this.currentRaf);
251286
}
252287

288+
/**
289+
* Mark that this accordion has been expanded at least once.
290+
* This allows subsequent expansions to animate.
291+
*/
292+
this.hasEverBeenExpanded = true;
293+
253294
if (this.shouldAnimate()) {
254295
raf(() => {
255296
this.state = AccordionState.Expanding;
@@ -270,9 +311,14 @@ export class Accordion implements ComponentInterface {
270311
}
271312
};
272313

273-
private collapseAccordion = (initialUpdate = false) => {
314+
private collapseAccordion = () => {
274315
const { contentEl } = this;
275-
if (initialUpdate || contentEl === undefined) {
316+
317+
/**
318+
* If the content element isn't available yet, just set the state.
319+
* This happens on initial render before the DOM is ready.
320+
*/
321+
if (contentEl === undefined) {
276322
this.state = AccordionState.Collapsed;
277323
return;
278324
}
@@ -315,11 +361,15 @@ export class Accordion implements ComponentInterface {
315361
*/
316362
private shouldAnimate = () => {
317363
/**
318-
* Don't animate until after the first render cycle completes.
364+
* Don't animate until after the first user interaction.
319365
* This prevents animations on initial load when accordions
320-
* start in an expanded or collapsed state.
366+
* start in an expanded or collapsed state programmatically.
367+
*
368+
* Additionally, don't animate the very first expansion even if
369+
* hasInteracted is true. This handles edge cases like React StrictMode
370+
* where effects run twice and might incorrectly mark as interacted.
321371
*/
322-
if (!this.hasRendered) {
372+
if (!this.hasInteracted || !this.hasEverBeenExpanded) {
323373
return false;
324374
}
325375

@@ -344,7 +394,7 @@ export class Accordion implements ComponentInterface {
344394
return true;
345395
};
346396

347-
private updateState = async (initialUpdate = false) => {
397+
private updateState = async () => {
348398
const accordionGroup = this.accordionGroupEl;
349399
const accordionValue = this.value;
350400

@@ -357,10 +407,10 @@ export class Accordion implements ComponentInterface {
357407
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
358408

359409
if (shouldExpand) {
360-
this.expandAccordion(initialUpdate);
410+
this.expandAccordion();
361411
this.isNext = this.isPrevious = false;
362412
} else {
363-
this.collapseAccordion(initialUpdate);
413+
this.collapseAccordion();
364414

365415
/**
366416
* When using popout or inset,
@@ -418,6 +468,12 @@ export class Accordion implements ComponentInterface {
418468

419469
if (disabled || readonly) return;
420470

471+
/**
472+
* Mark that the user has interacted with the accordion.
473+
* This enables animations for all future state changes.
474+
*/
475+
this.hasInteracted = true;
476+
421477
if (accordionGroupEl) {
422478
/**
423479
* Because the accordion group may or may

core/src/components/accordion/test/accordion.spec.ts

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { newSpecPage } from '@stencil/core/testing';
22

3-
import type { AccordionGroupChangeEventDetail } from '../../accordion-group/accordion-group-interface';
43
import { AccordionGroup } from '../../accordion-group/accordion-group';
54
import { Item } from '../../item/item';
65
import { Accordion } from '../accordion';
@@ -218,18 +217,11 @@ it('should not animate when initial value is set before load', async () => {
218217
</ion-accordion>
219218
`;
220219

221-
const details: AccordionGroupChangeEventDetail[] = [];
222-
accordionGroup.addEventListener('ionValueChange', (event: CustomEvent<AccordionGroupChangeEventDetail>) => {
223-
details.push(event.detail);
224-
});
225-
226220
accordionGroup.value = 'first';
227221
page.body.appendChild(accordionGroup);
228222

229223
await page.waitForChanges();
230224

231-
expect(details[0]?.initial).toBe(true);
232-
233225
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
234226

235227
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
@@ -253,27 +245,19 @@ it('should not animate when initial value is set after load', async () => {
253245
</ion-accordion>
254246
`;
255247

256-
const details: AccordionGroupChangeEventDetail[] = [];
257-
accordionGroup.addEventListener('ionValueChange', (event: CustomEvent<AccordionGroupChangeEventDetail>) => {
258-
details.push(event.detail);
259-
});
260-
261248
page.body.appendChild(accordionGroup);
262249
await page.waitForChanges();
263250

264251
accordionGroup.value = 'first';
265252
await page.waitForChanges();
266253

267-
const firstDetail = details.find((detail) => detail.value === 'first');
268-
expect(firstDetail?.initial).toBe(true);
269-
270254
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
271255

272256
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
273257
expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
274258
});
275259

276-
it('should animate when accordion is first opened by user', async () => {
260+
it('should not have animated class on first expansion', async () => {
277261
const page = await newSpecPage({
278262
components: [Item, Accordion, AccordionGroup],
279263
html: `
@@ -287,20 +271,14 @@ it('should animate when accordion is first opened by user', async () => {
287271
});
288272

289273
const accordionGroup = page.body.querySelector('ion-accordion-group')!;
274+
const firstAccordion = page.body.querySelector('ion-accordion[value="first"]')!;
290275

291-
const details: AccordionGroupChangeEventDetail[] = [];
292-
accordionGroup.addEventListener('ionValueChange', (event: CustomEvent<AccordionGroupChangeEventDetail>) => {
293-
details.push(event.detail);
294-
});
295-
296-
await accordionGroup.requestAccordionToggle('first', true);
276+
// First expansion should not have the animated class
277+
accordionGroup.value = 'first';
297278
await page.waitForChanges();
298279

299-
const lastDetail = details[details.length - 1];
300-
expect(lastDetail?.initial).toBe(false);
301-
302-
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
303-
expect(firstAccordion.classList.contains('accordion-animated')).toEqual(true);
280+
expect(firstAccordion.classList.contains('accordion-animated')).toEqual(false);
281+
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
304282
});
305283

306284
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/27047

0 commit comments

Comments
 (0)