Skip to content

Commit 18e838c

Browse files
author
Dre Grant
committed
Bug 2047788 - Animate the widgets row height when toggling show more/less r=thecount
Clicking show more/less previously snapped the widgets container to its new height. Capture the pre-toggle height and FLIP-animate the container to its new height using the same timing tokens widgets use when resizing, so the row collapses and expands smoothly. Hidden widgets still use display:none so tab order and a11y are unaffected. The animation is skipped under prefers-reduced-motion. Differential Revision: https://phabricator.services.mozilla.com/D306894
1 parent e433fa0 commit 18e838c

7 files changed

Lines changed: 224 additions & 4 deletions

File tree

browser/extensions/newtab/content-src/components/Widgets/Widgets.jsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
33
* You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import React, { useContext, useEffect, useRef } from "react";
5+
import React, { useContext, useEffect, useLayoutEffect, useRef } from "react";
66
import { useDispatch, useSelector, batch } from "react-redux";
77
import { BaseContext } from "content-src/lib/BaseContext";
88
// Bug 2034542: these per-widget imports can be removed once the non-Nova render
@@ -50,6 +50,9 @@ const PREF_WIDGETS_FEEDBACK_ENABLED = "widgets.feedback.enabled";
5050
const PREF_WIDGETS_HIDE_ALL_TOAST_ENABLED = "widgets.hideAllToast.enabled";
5151
const WIDGETS_FEEDBACK_URL =
5252
"https://support.mozilla.org/kb/firefox-new-tab-widgets";
53+
// Safety net in case transitionend never fires. Keep this above the CSS
54+
// height transition duration (--widget-size-transition-duration, 180ms).
55+
const ROW_TOGGLE_HEIGHT_ANIMATION_FALLBACK_MS = 300;
5356

5457
// resets timer to default values (exported for testing)
5558
// In practice, this logic runs inside a useEffect when
@@ -293,6 +296,52 @@ function Widgets() {
293296
// track previous timerEnabled state to detect when it becomes disabled
294297
const prevTimerEnabledRef = useRef(timerEnabled);
295298

299+
const rowToggleFromHeightRef = useRef(null);
300+
301+
useLayoutEffect(() => {
302+
const fromHeight = rowToggleFromHeightRef.current;
303+
rowToggleFromHeightRef.current = null;
304+
const container = widgetsContainerRef.current;
305+
if (fromHeight === null || !container) {
306+
return undefined;
307+
}
308+
const toHeight = container.getBoundingClientRect().height;
309+
if (fromHeight === toHeight) {
310+
return undefined;
311+
}
312+
container.style.height = `${fromHeight}px`;
313+
container.classList.add("is-animating-height");
314+
// Commit the start height before transitioning to the target.
315+
void container.offsetHeight;
316+
container.style.height = `${toHeight}px`;
317+
318+
let fallbackTimer;
319+
// Invoked from transitionend/transitioncancel (with an event), from the
320+
// fallback timer, or as the effect cleanup (no event). Ignore events
321+
// bubbling up from child widgets; the container only transitions height,
322+
// so its own events need no propertyName check.
323+
const finishRowHeightAnimation = e => {
324+
if (e && e.target !== container) {
325+
return;
326+
}
327+
globalThis.clearTimeout(fallbackTimer);
328+
container.style.height = "";
329+
container.classList.remove("is-animating-height");
330+
container.removeEventListener("transitionend", finishRowHeightAnimation);
331+
container.removeEventListener(
332+
"transitioncancel",
333+
finishRowHeightAnimation
334+
);
335+
};
336+
container.addEventListener("transitionend", finishRowHeightAnimation);
337+
container.addEventListener("transitioncancel", finishRowHeightAnimation);
338+
fallbackTimer = globalThis.setTimeout(
339+
finishRowHeightAnimation,
340+
ROW_TOGGLE_HEIGHT_ANIMATION_FALLBACK_MS
341+
);
342+
return finishRowHeightAnimation;
343+
}, [rowExpanded]);
344+
296345
// Reset timer when it becomes disabled
297346
useEffect(() => {
298347
const wasTimerEnabled = prevTimerEnabledRef.current;
@@ -432,6 +481,13 @@ function Widgets() {
432481

433482
function toggleRowExpanded() {
434483
const next = !rowExpanded;
484+
const container = widgetsContainerRef.current;
485+
const prefersReducedMotion = globalThis.matchMedia?.(
486+
"(prefers-reduced-motion: reduce)"
487+
)?.matches;
488+
if (container && !prefersReducedMotion) {
489+
rowToggleFromHeightRef.current = container.getBoundingClientRect().height;
490+
}
435491
batch(() => {
436492
dispatch(ac.SetPref(PREF_WIDGETS_ROW_EXPANDED, next));
437493
dispatch(

browser/extensions/newtab/content-src/components/Widgets/_Widgets.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,19 @@
189189
grid-template-columns: repeat(auto-fill, var(--col-width));
190190
grid-auto-rows: var(--row-height);
191191
gap: var(--space-medium);
192+
193+
@include widget-size-transition-tokens;
194+
}
195+
196+
.nova-enabled &.is-animating-height {
197+
overflow: hidden;
198+
transition: height var(--widget-size-transition-duration) var(--widget-size-transition-easing);
199+
}
200+
201+
@media (prefers-reduced-motion: reduce) {
202+
.nova-enabled &.is-animating-height {
203+
transition: none;
204+
}
192205
}
193206

194207
// Classic layout (when Nova is disabled)

browser/extensions/newtab/content-src/styles/_mixins.scss

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,17 @@ $side-col-width-px: $col-width-px * 2;
181181
}
182182
}
183183

184-
// Baseline styles for all widgets
185-
@mixin widget-base-style {
184+
// Shared size-change transition tokens. Included by widget-base-style (each
185+
// widget card) and by the Nova widgets-container so the row-height animation
186+
// stays in sync with per-widget resize timing.
187+
@mixin widget-size-transition-tokens {
186188
--widget-size-transition-duration: 180ms;
187189
--widget-size-transition-easing: ease;
190+
}
188191

192+
// Baseline styles for all widgets
193+
@mixin widget-base-style {
194+
@include widget-size-transition-tokens;
189195
@include newtab-card-style;
190196

191197
padding: var(--space-medium);

browser/extensions/newtab/css/activity-stream.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5094,6 +5094,17 @@ dialog:dir(rtl)::after {
50945094
grid-template-columns: repeat(auto-fill, var(--col-width));
50955095
grid-auto-rows: var(--row-height);
50965096
gap: var(--space-medium);
5097+
--widget-size-transition-duration: 180ms;
5098+
--widget-size-transition-easing: ease;
5099+
}
5100+
.nova-enabled .widgets-container.is-animating-height {
5101+
overflow: hidden;
5102+
transition: height var(--widget-size-transition-duration) var(--widget-size-transition-easing);
5103+
}
5104+
@media (prefers-reduced-motion: reduce) {
5105+
.nova-enabled .widgets-container.is-animating-height {
5106+
transition: none;
5107+
}
50975108
}
50985109
.classic-enabled .widgets-container {
50995110
--widgets-card-width: var(--newtab-card-grid-layout-width);

browser/extensions/newtab/css/nova/activity-stream.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4775,6 +4775,17 @@ button.arrow-button:dir(rtl) {
47754775
grid-template-columns: repeat(auto-fill, var(--col-width));
47764776
grid-auto-rows: var(--row-height);
47774777
gap: var(--space-medium);
4778+
--widget-size-transition-duration: 180ms;
4779+
--widget-size-transition-easing: ease;
4780+
}
4781+
.nova-enabled .widgets-container.is-animating-height {
4782+
overflow: hidden;
4783+
transition: height var(--widget-size-transition-duration) var(--widget-size-transition-easing);
4784+
}
4785+
@media (prefers-reduced-motion: reduce) {
4786+
.nova-enabled .widgets-container.is-animating-height {
4787+
transition: none;
4788+
}
47784789
}
47794790
.classic-enabled .widgets-container {
47804791
--widgets-card-width: var(--newtab-card-grid-layout-width);

browser/extensions/newtab/data/content/activity-stream.bundle.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23937,6 +23937,9 @@ const PREF_WIDGETS_ROW_EXPANDED = "widgets.row.expanded";
2393723937
const PREF_WIDGETS_FEEDBACK_ENABLED = "widgets.feedback.enabled";
2393823938
const PREF_WIDGETS_HIDE_ALL_TOAST_ENABLED = "widgets.hideAllToast.enabled";
2393923939
const WIDGETS_FEEDBACK_URL = "https://support.mozilla.org/kb/firefox-new-tab-widgets";
23940+
// Safety net in case transitionend never fires. Keep this above the CSS
23941+
// height transition duration (--widget-size-transition-duration, 180ms).
23942+
const ROW_TOGGLE_HEIGHT_ANIMATION_FALLBACK_MS = 300;
2394023943

2394123944
// resets timer to default values (exported for testing)
2394223945
// In practice, this logic runs inside a useEffect when
@@ -24111,6 +24114,43 @@ function Widgets() {
2411124114

2411224115
// track previous timerEnabled state to detect when it becomes disabled
2411324116
const prevTimerEnabledRef = (0,external_React_namespaceObject.useRef)(timerEnabled);
24117+
const rowToggleFromHeightRef = (0,external_React_namespaceObject.useRef)(null);
24118+
(0,external_React_namespaceObject.useLayoutEffect)(() => {
24119+
const fromHeight = rowToggleFromHeightRef.current;
24120+
rowToggleFromHeightRef.current = null;
24121+
const container = widgetsContainerRef.current;
24122+
if (fromHeight === null || !container) {
24123+
return undefined;
24124+
}
24125+
const toHeight = container.getBoundingClientRect().height;
24126+
if (fromHeight === toHeight) {
24127+
return undefined;
24128+
}
24129+
container.style.height = `${fromHeight}px`;
24130+
container.classList.add("is-animating-height");
24131+
// Commit the start height before transitioning to the target.
24132+
void container.offsetHeight;
24133+
container.style.height = `${toHeight}px`;
24134+
let fallbackTimer;
24135+
// Invoked from transitionend/transitioncancel (with an event), from the
24136+
// fallback timer, or as the effect cleanup (no event). Ignore events
24137+
// bubbling up from child widgets; the container only transitions height,
24138+
// so its own events need no propertyName check.
24139+
const finishRowHeightAnimation = e => {
24140+
if (e && e.target !== container) {
24141+
return;
24142+
}
24143+
globalThis.clearTimeout(fallbackTimer);
24144+
container.style.height = "";
24145+
container.classList.remove("is-animating-height");
24146+
container.removeEventListener("transitionend", finishRowHeightAnimation);
24147+
container.removeEventListener("transitioncancel", finishRowHeightAnimation);
24148+
};
24149+
container.addEventListener("transitionend", finishRowHeightAnimation);
24150+
container.addEventListener("transitioncancel", finishRowHeightAnimation);
24151+
fallbackTimer = globalThis.setTimeout(finishRowHeightAnimation, ROW_TOGGLE_HEIGHT_ANIMATION_FALLBACK_MS);
24152+
return finishRowHeightAnimation;
24153+
}, [rowExpanded]);
2411424154

2411524155
// Reset timer when it becomes disabled
2411624156
(0,external_React_namespaceObject.useEffect)(() => {
@@ -24235,6 +24275,11 @@ function Widgets() {
2423524275
}
2423624276
function toggleRowExpanded() {
2423724277
const next = !rowExpanded;
24278+
const container = widgetsContainerRef.current;
24279+
const prefersReducedMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
24280+
if (container && !prefersReducedMotion) {
24281+
rowToggleFromHeightRef.current = container.getBoundingClientRect().height;
24282+
}
2423824283
(0,external_ReactRedux_namespaceObject.batch)(() => {
2423924284
dispatch(actionCreators.SetPref(PREF_WIDGETS_ROW_EXPANDED, next));
2424024285
dispatch(actionCreators.OnlyToMain({

browser/extensions/newtab/test/jest/content-src/components/Widgets/Widgets.test.jsx

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, fireEvent } from "@testing-library/react";
1+
import { render, fireEvent, act } from "@testing-library/react";
22
import { WrapWithProvider } from "test/jest/test-utils";
33
import { Provider } from "react-redux";
44
import { createStore, combineReducers } from "redux";
@@ -281,6 +281,28 @@ describe("<Widgets> row toggle", () => {
281281
});
282282
}
283283

284+
function startRowHeightAnimation() {
285+
const { container, store } = renderWidgets(novaStateWith(false));
286+
const widgetsContainer = container.querySelector("#widgets-container");
287+
jest
288+
.spyOn(widgetsContainer, "getBoundingClientRect")
289+
.mockReturnValueOnce({ height: 40 })
290+
.mockReturnValueOnce({ height: 80 });
291+
292+
// The click captures the pre-toggle height; the pref change round-trips
293+
// from the main process as PREF_CHANGED, flipping rowExpanded and running
294+
// the height animation effect.
295+
fireEvent.click(container.querySelector(".widgets-row-toggle"));
296+
act(() => {
297+
store.dispatch({
298+
type: at.PREF_CHANGED,
299+
data: { name: "widgets.row.expanded", value: true },
300+
});
301+
});
302+
303+
return widgetsContainer;
304+
}
305+
284306
it("renders the toggle button when nova is enabled", () => {
285307
const { container } = renderWidgets(novaStateWith(false));
286308
expect(container.querySelector(".widgets-row-toggle")).toBeInTheDocument();
@@ -322,6 +344,62 @@ describe("<Widgets> row toggle", () => {
322344
})
323345
);
324346
});
347+
348+
it("clears row height animation if transitionend does not fire", () => {
349+
jest.useFakeTimers();
350+
try {
351+
const widgetsContainer = startRowHeightAnimation();
352+
353+
expect(widgetsContainer).toHaveClass("is-animating-height");
354+
expect(widgetsContainer.style.height).toBe("80px");
355+
356+
act(() => {
357+
jest.runOnlyPendingTimers();
358+
});
359+
360+
expect(widgetsContainer).not.toHaveClass("is-animating-height");
361+
expect(widgetsContainer.style.height).toBe("");
362+
} finally {
363+
jest.useRealTimers();
364+
}
365+
});
366+
367+
it("clears row height animation on transitionend", () => {
368+
const widgetsContainer = startRowHeightAnimation();
369+
370+
expect(widgetsContainer).toHaveClass("is-animating-height");
371+
expect(widgetsContainer.style.height).toBe("80px");
372+
373+
fireEvent.transitionEnd(widgetsContainer, { propertyName: "height" });
374+
375+
expect(widgetsContainer).not.toHaveClass("is-animating-height");
376+
expect(widgetsContainer.style.height).toBe("");
377+
});
378+
379+
it("skips the row height animation under prefers-reduced-motion", () => {
380+
const originalMatchMedia = globalThis.matchMedia;
381+
globalThis.matchMedia = () => ({ matches: true });
382+
try {
383+
const { container, store } = renderWidgets(novaStateWith(false));
384+
const widgetsContainer = container.querySelector("#widgets-container");
385+
const rectSpy = jest.spyOn(widgetsContainer, "getBoundingClientRect");
386+
387+
fireEvent.click(container.querySelector(".widgets-row-toggle"));
388+
act(() => {
389+
store.dispatch({
390+
type: at.PREF_CHANGED,
391+
data: { name: "widgets.row.expanded", value: true },
392+
});
393+
});
394+
395+
// The container is never measured, so the FLIP animation never starts.
396+
expect(rectSpy).not.toHaveBeenCalled();
397+
expect(widgetsContainer).not.toHaveClass("is-animating-height");
398+
expect(widgetsContainer.style.height).toBe("");
399+
} finally {
400+
globalThis.matchMedia = originalMatchMedia;
401+
}
402+
});
325403
});
326404

327405
describe("<Widgets> maximize toggle", () => {

0 commit comments

Comments
 (0)