From 55c8da2a3fea279beb923aa42f662563d41ba1c6 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 9 Jun 2026 10:49:49 -0700 Subject: [PATCH 1/3] feat(ripple-effect): add recipe and tokens --- BREAKING.md | 20 ++++ core/api.txt | 2 +- core/src/components.d.ts | 16 +--- core/src/components/button/button.ionic.scss | 11 ++- .../ripple-effect/ripple-effect.common.scss | 79 ---------------- .../ripple-effect/ripple-effect.interface.ts | 6 ++ .../ripple-effect/ripple-effect.ionic.scss | 49 ---------- .../ripple-effect/ripple-effect.scss | 94 +++++++++++++++++++ .../ripple-effect/ripple-effect.tsx | 27 +++--- core/src/themes/ionic/default.tokens.ts | 4 + core/src/themes/ios/default.tokens.ts | 4 + core/src/themes/md/default.tokens.ts | 4 + core/src/themes/themes.interfaces.ts | 2 + packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- 15 files changed, 166 insertions(+), 160 deletions(-) delete mode 100644 core/src/components/ripple-effect/ripple-effect.common.scss create mode 100644 core/src/components/ripple-effect/ripple-effect.interface.ts delete mode 100644 core/src/components/ripple-effect/ripple-effect.ionic.scss create mode 100644 core/src/components/ripple-effect/ripple-effect.scss diff --git a/BREAKING.md b/BREAKING.md index e48fe76f98d..863a3d09e66 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -28,6 +28,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver - [Item Divider](#version-9x-item-divider) - [Menu Toggle](#version-9x-menu-toggle) - [Radio Group](#version-9x-radio-group) + - [Ripple Effect](#version-9x-ripple-effect) - [Row](#version-9x-row) - [Spinner](#version-9x-spinner) - [Text](#version-9x-text) @@ -352,6 +353,25 @@ If you were targeting the internals of `ion-radio-group` in your CSS, you will n Additionally, the `radio-group-wrapper` div element has been removed, causing slotted elements to be direct children of the `ion-radio-group`. +

Ripple Effect

+ +The following breaking changes apply to `ion-ripple-effect`: + +1. The previously undocumented `--ripple-opacity` CSS variable has been renamed to `--ion-ripple-effect-opacity`. [1](#version-9x-ripple-effect-opacity-variable) +2. Theme classes (`ion-ripple-effect.md`, `ion-ripple-effect.ios`) are no longer supported. [2](#version-9x-ripple-effect-theme-classes) + +
Opacity variable
+ +The ripple fade opacity is now part of the centralized Ionic Theming system. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: + +| Old (8.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--ripple-opacity` | `IonRippleEffect.opacity` | `--ion-ripple-effect-opacity` | + +
Theme classes
+ +Remove any instances that target the theme classes: `ion-ripple-effect.md`, `ion-ripple-effect.ios`. +

Row

The following breaking changes apply to `ion-row`: diff --git a/core/api.txt b/core/api.txt index 36040cb0c13..125b08bd490 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2230,9 +2230,9 @@ ion-reorder-group,event,ionReorderStart,void,true ion-ripple-effect,shadow ion-ripple-effect,prop,mode,"ios" | "md",undefined,false,false -ion-ripple-effect,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-ripple-effect,prop,type,"bounded" | "unbounded",'bounded',false,false ion-ripple-effect,method,addRipple,addRipple(x: number, y: number) => Promise<() => void> +ion-ripple-effect,css-prop,--ion-ripple-effect-opacity ion-route,none ion-route,prop,beforeEnter,(() => NavigationHookResult | Promise) | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 0e1bafc4319..b6060ae638b 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -36,6 +36,7 @@ import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/r import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; import { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; import { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; +import { IonRippleEffectType } from "./components/ripple-effect/ripple-effect.interface"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; @@ -80,6 +81,7 @@ export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/r export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; export { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; export { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; +export { IonRippleEffectType } from "./components/ripple-effect/ripple-effect.interface"; export { NavigationHookCallback } from "./components/route/route-interface"; export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; @@ -3318,15 +3320,11 @@ export namespace Components { * The mode determines the platform behaviors of the component. */ "mode"?: "ios" | "md"; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; /** * Sets the type of ripple-effect: - `bounded`: the ripple effect expands from the user's click position - `unbounded`: the ripple effect expands from the center of the button and overflows the container. NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, while surfaces for unbounded ripples should have it set to visible. * @default 'bounded' */ - "type": 'bounded' | 'unbounded'; + "type": IonRippleEffectType; } interface IonRoute { /** @@ -9354,15 +9352,11 @@ declare namespace LocalJSX { * The mode determines the platform behaviors of the component. */ "mode"?: "ios" | "md"; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; /** * Sets the type of ripple-effect: - `bounded`: the ripple effect expands from the user's click position - `unbounded`: the ripple effect expands from the center of the button and overflows the container. NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, while surfaces for unbounded ripples should have it set to visible. * @default 'bounded' */ - "type"?: 'bounded' | 'unbounded'; + "type"?: IonRippleEffectType; } interface IonRoute { /** @@ -11183,7 +11177,7 @@ declare namespace LocalJSX { "disabled": boolean; } interface IonRippleEffectAttributes { - "type": 'bounded' | 'unbounded'; + "type": IonRippleEffectType; } interface IonRouteAttributes { "url": string; diff --git a/core/src/components/button/button.ionic.scss b/core/src/components/button/button.ionic.scss index 89e9ec27e0d..22344274858 100644 --- a/core/src/components/button/button.ionic.scss +++ b/core/src/components/button/button.ionic.scss @@ -49,7 +49,6 @@ --background-hover-opacity: 1; --background: #{globals.ion-color(primary, base)}; --color: #{globals.ion-color(primary, contrast)}; - --ripple-opacity: var(--background-activated-opacity, 1); --ripple-color: var(--background-activated); } @@ -65,7 +64,6 @@ --background-hover-opacity: 1; --border-color: #{globals.ion-color(primary, base)}; --color: #{globals.ion-color(primary, base)}; - --ripple-opacity: var(--background-activated-opacity, 1); --ripple-color: var(--background-activated); } @@ -85,13 +83,20 @@ --background-hover: #{globals.ion-color(primary, shade, $subtle: true)}; --background-hover-opacity: 1; --color: #{globals.ion-color(primary, foreground)}; - --ripple-opacity: var(--background-activated-opacity, 1); --ripple-color: var(--background-activated); } // Ripple Effect // ------------------------------------------------------------------------------- +// Set via the custom property (not `opacity` directly) because the ripple's +// opacity is animated in @keyframes, which would override a direct value. +:host(.button-solid) ion-ripple-effect, +:host(.button-outline) ion-ripple-effect, +:host(.button-clear) ion-ripple-effect { + --ion-ripple-effect-opacity: var(--background-activated-opacity, 1); +} + :host(.button-solid.ion-color) ion-ripple-effect { color: globals.current-color(shade); } diff --git a/core/src/components/ripple-effect/ripple-effect.common.scss b/core/src/components/ripple-effect/ripple-effect.common.scss deleted file mode 100644 index ed5fc4b9b85..00000000000 --- a/core/src/components/ripple-effect/ripple-effect.common.scss +++ /dev/null @@ -1,79 +0,0 @@ -@import "../../themes/native/native.globals"; - -// Material Design Ripple Effect -// -------------------------------------------------- - -$scale-duration: 225ms; -$fade-in-duration: 75ms; -$fade-out-duration: 150ms; - -:host { - @include position(0, 0, 0, 0); - - position: absolute; - - contain: strict; - pointer-events: none; -} - -:host(.unbounded) { - contain: layout size style; -} - -.ripple-effect { - @include border-radius(50%); - - position: absolute; - - // Should remain static for performance reasons - background-color: currentColor; - color: inherit; - - contain: strict; - opacity: 0; - animation: $scale-duration rippleAnimation forwards, $fade-in-duration fadeInAnimation forwards; - - will-change: transform, opacity; - pointer-events: none; -} - -.fade-out { - transform: translate(var(--translate-end)) scale(var(--final-scale, 1)); - animation: $fade-out-duration fadeOutAnimation forwards; -} - -@keyframes rippleAnimation { - from { - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - - transform: scale(1); - } - - to { - transform: translate(var(--translate-end)) scale(var(--final-scale, 1)); - } -} - -@keyframes fadeInAnimation { - from { - animation-timing-function: linear; - - opacity: 0; - } - - to { - opacity: 0.16; - } -} - -@keyframes fadeOutAnimation { - from { - animation-timing-function: linear; - - opacity: 0.16; - } - - to { - opacity: 0; - } -} diff --git a/core/src/components/ripple-effect/ripple-effect.interface.ts b/core/src/components/ripple-effect/ripple-effect.interface.ts new file mode 100644 index 00000000000..02c149f37f4 --- /dev/null +++ b/core/src/components/ripple-effect/ripple-effect.interface.ts @@ -0,0 +1,6 @@ +export type IonRippleEffectRecipe = { + opacity?: string; +}; + +export const ION_RIPPLE_EFFECT_TYPES = ['bounded', 'unbounded'] as const; +export type IonRippleEffectType = (typeof ION_RIPPLE_EFFECT_TYPES)[number]; diff --git a/core/src/components/ripple-effect/ripple-effect.ionic.scss b/core/src/components/ripple-effect/ripple-effect.ionic.scss deleted file mode 100644 index fd855b56cbf..00000000000 --- a/core/src/components/ripple-effect/ripple-effect.ionic.scss +++ /dev/null @@ -1,49 +0,0 @@ -@use "./ripple-effect.common"; -@use "../../themes/ionic/ionic.globals.scss" as globals; - -// Ionic Ripple Effect -// -------------------------------------------------- - -.ripple-effect { - animation-name: rippleAnimation, fadeInAnimation; -} - -.fade-out { - animation-name: fadeOutAnimation; -} - -@keyframes rippleAnimation { - from { - animation-timing-function: globals.$ion-transition-curve-expressive; - - transform: scale(1); - } - - to { - transform: translate(var(--translate-end)) scale(var(--final-scale, 1)); - } -} - -@keyframes fadeInAnimation { - from { - animation-timing-function: linear; - - opacity: 0; - } - - to { - opacity: var(--ripple-opacity, 0.16); - } -} - -@keyframes fadeOutAnimation { - from { - animation-timing-function: linear; - - opacity: var(--ripple-opacity, 0.16); - } - - to { - opacity: 0; - } -} diff --git a/core/src/components/ripple-effect/ripple-effect.scss b/core/src/components/ripple-effect/ripple-effect.scss new file mode 100644 index 00000000000..a065c29f370 --- /dev/null +++ b/core/src/components/ripple-effect/ripple-effect.scss @@ -0,0 +1,94 @@ +@use "../../themes/mixins" as mixins; + +// Ripple Effect: Common Styles +// -------------------------------------------------- + +/* + * Duration of the ripple scale animation in milliseconds. This MUST stay in + * sync with SCALE_DURATION in ripple-effect.tsx: the ripple cleanup is + * scheduled off this value, so changing one without the other desyncs the + * animation from the removal timing. + */ +$scale-duration: 225ms; +$fade-in-duration: 75ms; +$fade-out-duration: 150ms; + +:host { + /** + * @prop --ion-ripple-effect-opacity: Peak opacity the ripple fades in to. + */ + + @include mixins.position(0, 0, 0, 0); + + position: absolute; + + contain: strict; + pointer-events: none; +} + +:host(.unbounded) { + contain: layout size style; +} + +.ripple-effect { + @include mixins.border-radius(50%); + + position: absolute; + + /* + * Keep `background-color` and `color` as static keywords (never `var()`) + * for performance issues. A new .ripple-effect div is created and animated + * on every tap, so a custom property lookup for every ripple would be + * costly. The color is inherited from its host. + */ + background-color: currentColor; + color: inherit; + + contain: strict; + opacity: 0; + animation: $scale-duration rippleAnimation forwards, $fade-in-duration fadeInAnimation forwards; + + will-change: transform, opacity; + pointer-events: none; +} + +.fade-out { + transform: translate(var(--internal-translate-end)) scale(var(--internal-final-scale, 1)); + animation: $fade-out-duration fadeOutAnimation forwards; +} + +@keyframes rippleAnimation { + from { + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + + transform: scale(1); + } + + to { + transform: translate(var(--internal-translate-end)) scale(var(--internal-final-scale, 1)); + } +} + +@keyframes fadeInAnimation { + from { + animation-timing-function: linear; + + opacity: 0; + } + + to { + opacity: var(--ion-ripple-effect-opacity, 0.16); + } +} + +@keyframes fadeOutAnimation { + from { + animation-timing-function: linear; + + opacity: var(--ion-ripple-effect-opacity, 0.16); + } + + to { + opacity: 0; + } +} diff --git a/core/src/components/ripple-effect/ripple-effect.tsx b/core/src/components/ripple-effect/ripple-effect.tsx index 11d48b55db0..f9b5ebfd6f7 100644 --- a/core/src/components/ripple-effect/ripple-effect.tsx +++ b/core/src/components/ripple-effect/ripple-effect.tsx @@ -1,19 +1,14 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Method, Prop, h, readTask, writeTask } from '@stencil/core'; -import { getIonTheme } from '../../global/ionic-global'; +import type { IonRippleEffectType } from './ripple-effect.interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. - * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. */ @Component({ tag: 'ion-ripple-effect', - styleUrls: { - ios: 'ripple-effect.common.scss', - md: 'ripple-effect.common.scss', - ionic: 'ripple-effect.ionic.scss', - }, + styleUrl: 'ripple-effect.scss', shadow: true, }) export class RippleEffect implements ComponentInterface { @@ -28,7 +23,7 @@ export class RippleEffect implements ComponentInterface { * NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, * while surfaces for unbounded ripples should have it set to visible. */ - @Prop() type: 'bounded' | 'unbounded' = 'bounded'; + @Prop() type: IonRippleEffectType = 'bounded'; /** * Adds the ripple effect to the parent element. @@ -66,8 +61,8 @@ export class RippleEffect implements ComponentInterface { style.top = styleY + 'px'; style.left = styleX + 'px'; style.width = style.height = initialSize + 'px'; - style.setProperty('--final-scale', `${finalScale}`); - style.setProperty('--translate-end', `${moveX}px, ${moveY}px`); + style.setProperty('--internal-final-scale', `${finalScale}`); + style.setProperty('--internal-translate-end', `${moveX}px, ${moveY}px`); const container = this.el.shadowRoot || this.el; container.appendChild(div); @@ -75,7 +70,7 @@ export class RippleEffect implements ComponentInterface { resolve(() => { removeRipple(div); }); - }, 225 + 100); + }, SCALE_DURATION + 100); }); }); }); @@ -86,12 +81,10 @@ export class RippleEffect implements ComponentInterface { } render() { - const theme = getIonTheme(this); return ( @@ -108,3 +101,11 @@ const removeRipple = (ripple: HTMLElement) => { const PADDING = 10; const INITIAL_ORIGIN_SCALE = 0.5; + +/* + * Duration of the ripple scale animation in milliseconds. This MUST stay in + * sync with $scale-duration in ripple-effect.scss: the ripple cleanup is + * scheduled off this value, so changing one without the other desyncs the + * animation from the removal timing. + */ +const SCALE_DURATION = 225; diff --git a/core/src/themes/ionic/default.tokens.ts b/core/src/themes/ionic/default.tokens.ts index 600c00380e5..c6bfc6660c0 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -871,6 +871,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonRippleEffect: { + opacity: '0.16', + }, + IonRow: { gap: 'var(--ion-spacing-0)', }, diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index 65da975615c..7bf27661a23 100644 --- a/core/src/themes/ios/default.tokens.ts +++ b/core/src/themes/ios/default.tokens.ts @@ -899,6 +899,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonRippleEffect: { + opacity: '0.16', + }, + IonRow: { gap: 'var(--ion-spacing-0)', }, diff --git a/core/src/themes/md/default.tokens.ts b/core/src/themes/md/default.tokens.ts index 3a92cacb7f6..c60dc77f60d 100644 --- a/core/src/themes/md/default.tokens.ts +++ b/core/src/themes/md/default.tokens.ts @@ -1024,6 +1024,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonRippleEffect: { + opacity: '0.16', + }, + IonRow: { gap: 'var(--ion-spacing-0)', }, diff --git a/core/src/themes/themes.interfaces.ts b/core/src/themes/themes.interfaces.ts index 75e96039e25..4d036580aab 100644 --- a/core/src/themes/themes.interfaces.ts +++ b/core/src/themes/themes.interfaces.ts @@ -5,6 +5,7 @@ import type { IonContentRecipe } from '../components/content/content.interfaces' import type { IonGridRecipe } from '../components/grid/grid.interface'; import type { IonItemDividerRecipe } from '../components/item-divider/item-divider.interfaces'; import type { IonProgressBarConfig, IonProgressBarRecipe } from '../components/progress-bar/progress-bar.interfaces'; +import type { IonRippleEffectRecipe } from '../components/ripple-effect/ripple-effect.interface'; import type { IonRowRecipe } from '../components/row/row.interface'; import type { IonSpinnerConfig, IonSpinnerRecipe } from '../components/spinner/spinner.interfaces'; import type { IonTextConfig, IonTextRecipe } from '../components/text/text.interfaces'; @@ -299,6 +300,7 @@ type Components = { IonGrid?: IonGridRecipe; IonItemDivider?: IonItemDividerRecipe; IonProgressBar?: IonProgressBarRecipe; + IonRippleEffect?: IonRippleEffectRecipe; IonRow?: IonRowRecipe; IonSpinner?: IonSpinnerRecipe; IonText?: IonTextRecipe; diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 73d727c59e2..faa50529794 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1993,7 +1993,7 @@ to be called in order to finalize the reorder action. @ProxyCmp({ - inputs: ['mode', 'theme', 'type'], + inputs: ['mode', 'type'], methods: ['addRipple'] }) @Component({ @@ -2001,7 +2001,7 @@ to be called in order to finalize the reorder action. changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['mode', 'theme', 'type'], + inputs: ['mode', 'type'], }) export class IonRippleEffect { protected el: HTMLIonRippleEffectElement; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 6156dd36fc2..7e08227991a 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -1859,7 +1859,7 @@ to be called in order to finalize the reorder action. @ProxyCmp({ defineCustomElementFn: defineIonRippleEffect, - inputs: ['mode', 'theme', 'type'], + inputs: ['mode', 'type'], methods: ['addRipple'] }) @Component({ @@ -1867,7 +1867,7 @@ to be called in order to finalize the reorder action. changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['mode', 'theme', 'type'], + inputs: ['mode', 'type'], standalone: true }) export class IonRippleEffect { From 54a5130e408fa7e4764006f4708bc6d805f04294 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 9 Jun 2026 14:29:00 -0700 Subject: [PATCH 2/3] test(ripple-effect): add persistant trigger --- .../ripple-effect/test/basic/index.html | 126 ++++++++++++++---- 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/core/src/components/ripple-effect/test/basic/index.html b/core/src/components/ripple-effect/test/basic/index.html index d9016411e73..4053926629e 100644 --- a/core/src/components/ripple-effect/test/basic/index.html +++ b/core/src/components/ripple-effect/test/basic/index.html @@ -20,7 +20,6 @@ color: white; width: 300px; height: 100px; - margin: 1rem; } .block { @@ -31,7 +30,30 @@ width: 300px; height: 300px; border-radius: 20px; + } + + .grid { + display: grid; + grid-template-columns: repeat(3, max-content); + gap: 1rem; + align-items: start; + } + + .ripple-demo { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 200px; + height: 120px; margin: 1rem; + background: #262626; + color: white; + border-radius: 8px; + } + + .ripple-demo ion-ripple-effect { + border-radius: inherit; } @@ -47,37 +69,34 @@

Small -

-

Large -

-

Large -

-

Large

-
- - This is just a div + effect behind - Nested button -
-
- This is just a div + effect on top - Nested button - -
-
- This is just a div + effect - -
+
+
+ + This is just a div + effect behind + Nested button +
+
+ This is just a div + effect on top + Nested button + +
- - This is just a a + effect on top - Nested button - - +
+ This is just a div + effect + +
+ + + This is just a a + effect on top + Nested button + + +
+

+
+ Bounded + +
+
+ Unbounded + +
+ +

+ Solid + Outline + Clear +

From 70b3b4abf8892a5ee591f73ccc67d0cf46c2e8e1 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 9 Jun 2026 16:20:13 -0700 Subject: [PATCH 3/3] fix(ripple-effect): prevent addRipple from hanging when the host is removed --- .../ripple-effect/ripple-effect.tsx | 13 ++++++ .../ripple-effect/test/basic/index.html | 4 -- .../ripple-effect/test/ripple-effect.spec.ts | 44 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 core/src/components/ripple-effect/test/ripple-effect.spec.ts diff --git a/core/src/components/ripple-effect/ripple-effect.tsx b/core/src/components/ripple-effect/ripple-effect.tsx index f9b5ebfd6f7..8c132418cf3 100644 --- a/core/src/components/ripple-effect/ripple-effect.tsx +++ b/core/src/components/ripple-effect/ripple-effect.tsx @@ -55,6 +55,16 @@ export class RippleEffect implements ComponentInterface { const moveY = height * 0.5 - posY; writeTask(() => { + /* + * The host may have been removed from the DOM between the read and write + * tasks. Resolve with a no-op so callers awaiting `addRipple` never hang, + * and skip appending a ripple to a detached host. + */ + if (!this.el.isConnected) { + resolve(noop); + return; + } + const div = document.createElement('div'); div.classList.add('ripple-effect'); const style = div.style; @@ -99,6 +109,9 @@ const removeRipple = (ripple: HTMLElement) => { }, 200); }; +// Callable no-op cleanup returned by addRipple when there is nothing to remove +const noop = () => undefined; + const PADDING = 10; const INITIAL_ORIGIN_SCALE = 0.5; diff --git a/core/src/components/ripple-effect/test/basic/index.html b/core/src/components/ripple-effect/test/basic/index.html index 4053926629e..332d3806d90 100644 --- a/core/src/components/ripple-effect/test/basic/index.html +++ b/core/src/components/ripple-effect/test/basic/index.html @@ -129,10 +129,6 @@

Ripple at full expansion (auto-triggered, persists for inspection)