mirror of
https://github.com/prymitive/karma
synced 2026-08-28 11:17:28 +00:00
fix(ui): speed up animations
This commit is contained in:
committed by
Łukasz Mierzwa
parent
7d64866f86
commit
73da266750
@@ -2,8 +2,6 @@ import { FC, useEffect, useRef, useState, memo } from "react";
|
||||
|
||||
import Linkify from "react-linkify";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt";
|
||||
import { faAngleLeft } from "@fortawesome/free-solid-svg-icons/faAngleLeft";
|
||||
@@ -31,7 +29,7 @@ const RenderNonLinkAnnotation: FC<{
|
||||
}
|
||||
});
|
||||
|
||||
const { ref, props } = useFlashTransition(value);
|
||||
const { ref } = useFlashTransition(value);
|
||||
|
||||
const className =
|
||||
"mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100";
|
||||
@@ -60,16 +58,14 @@ const RenderNonLinkAnnotation: FC<{
|
||||
rel: "noopener noreferrer",
|
||||
}}
|
||||
>
|
||||
<CSSTransition {...props}>
|
||||
{allowHTML ? (
|
||||
<span
|
||||
ref={ref}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
></span>
|
||||
) : (
|
||||
<span ref={ref}>{value}</span>
|
||||
)}
|
||||
</CSSTransition>
|
||||
{allowHTML ? (
|
||||
<span
|
||||
ref={ref}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
></span>
|
||||
) : (
|
||||
<span ref={ref}>{value}</span>
|
||||
)}
|
||||
</Linkify>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -189,7 +189,7 @@ const Grid: FC<{
|
||||
}, [debouncedRepack, onAlertGridCollapseEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
repack();
|
||||
debouncedRepack();
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,8 +2,6 @@ import { FC, useCallback, MouseEvent } from "react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import type { AlertStore } from "Stores/AlertStore";
|
||||
import { QueryOperators, FormatQuery } from "Common/Query";
|
||||
import { TooltipWrapper } from "Components/TooltipWrapper";
|
||||
@@ -32,7 +30,7 @@ const FilteringCounterBadge: FC<{
|
||||
defaultColor = "bg-light",
|
||||
isAppend = true,
|
||||
}) => {
|
||||
const { ref, props } = useFlashTransition(counter);
|
||||
const { ref } = useFlashTransition(counter);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -70,24 +68,22 @@ const FilteringCounterBadge: FC<{
|
||||
<TooltipWrapper
|
||||
title={`Click to only show ${name}=${value} alerts or Alt+Click to hide them`}
|
||||
>
|
||||
<CSSTransition {...props}>
|
||||
<span
|
||||
ref={ref}
|
||||
className={
|
||||
themed
|
||||
? cs.className
|
||||
: [
|
||||
`${defaultColor}`,
|
||||
"rounded-pill components-label-with-hover",
|
||||
...cs.baseClassNames,
|
||||
].join(" ")
|
||||
}
|
||||
style={themed ? {} : cs.style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{counter}
|
||||
</span>
|
||||
</CSSTransition>
|
||||
<span
|
||||
ref={ref}
|
||||
className={
|
||||
themed
|
||||
? cs.className
|
||||
: [
|
||||
`${defaultColor}`,
|
||||
"rounded-pill components-label-with-hover",
|
||||
...cs.baseClassNames,
|
||||
].join(" ")
|
||||
}
|
||||
style={themed ? {} : cs.style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{counter}
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,6 @@ import { parseISO } from "date-fns/parseISO";
|
||||
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faEdit } from "@fortawesome/free-solid-svg-icons/faEdit";
|
||||
import { faCalendarCheck } from "@fortawesome/free-solid-svg-icons/faCalendarCheck";
|
||||
@@ -30,22 +28,20 @@ const SilenceIDCopyButton: FC<{
|
||||
id: string;
|
||||
}> = ({ id }) => {
|
||||
const [clickCount, setClickCount] = useState<number>(0);
|
||||
const { ref, props } = useFlashTransition(clickCount);
|
||||
const { ref } = useFlashTransition(clickCount);
|
||||
|
||||
return (
|
||||
<TooltipWrapper title="Copy silence ID to the clipboard">
|
||||
<CSSTransition {...props}>
|
||||
<span
|
||||
ref={ref}
|
||||
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
|
||||
onClick={() => {
|
||||
void copy(id);
|
||||
setClickCount(clickCount + 1);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
</CSSTransition>
|
||||
<span
|
||||
ref={ref}
|
||||
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
|
||||
onClick={() => {
|
||||
void copy(id);
|
||||
setClickCount(clickCount + 1);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@ import React, { FC, useState, useCallback } from "react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
|
||||
|
||||
@@ -26,22 +24,20 @@ const OverviewModal: FC<{
|
||||
|
||||
const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]);
|
||||
|
||||
const { ref, props } = useFlashTransition(alertStore.info.totalAlerts);
|
||||
const { ref } = useFlashTransition(alertStore.info.totalAlerts);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipWrapper title="Show alert overview">
|
||||
<CSSTransition {...props}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
|
||||
isVisible ? "border-info" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{alertStore.info.totalAlerts}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
|
||||
isVisible ? "border-info" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{alertStore.info.totalAlerts}
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<Modal size="modal-xl" isOpen={isVisible} toggleOpen={toggle}>
|
||||
<React.Suspense
|
||||
|
||||
@@ -5,8 +5,6 @@ import { observer } from "mobx-react-lite";
|
||||
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus";
|
||||
import { faUser } from "@fortawesome/free-solid-svg-icons/faUser";
|
||||
@@ -73,7 +71,7 @@ const ShareButton: FC<{
|
||||
window.location.pathname,
|
||||
].join("");
|
||||
|
||||
const { ref, props, nodeRef } = useFlashTransition(clickCount);
|
||||
const { ref } = useFlashTransition(clickCount);
|
||||
|
||||
return (
|
||||
<div className="input-group mb-3">
|
||||
@@ -89,7 +87,6 @@ const ShareButton: FC<{
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<span
|
||||
ref={ref}
|
||||
className="input-group-text text-muted cursor-pointer"
|
||||
onClick={() => {
|
||||
void copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`);
|
||||
@@ -97,14 +94,9 @@ const ShareButton: FC<{
|
||||
}}
|
||||
>
|
||||
<TooltipWrapper title="Copy to clipboard">
|
||||
<CSSTransition {...props}>
|
||||
<span
|
||||
ref={nodeRef as React.RefObject<HTMLSpanElement>}
|
||||
className="d-inline-block"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
</CSSTransition>
|
||||
<span ref={ref} className="d-inline-block">
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act } from "react";
|
||||
import { act, ReactNode } from "react";
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
@@ -6,7 +6,19 @@ import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { mockInViewResponse } from "__fixtures__/InView";
|
||||
|
||||
import { useFlashTransition, defaultProps } from "./useFlashTransition";
|
||||
import { ThemeContext, ThemeCtx } from "Components/Theme";
|
||||
|
||||
import { useFlashTransition } from "./useFlashTransition";
|
||||
|
||||
const animationsOffCtx: ThemeCtx = {
|
||||
isDark: false,
|
||||
reactSelectStyles: {},
|
||||
animations: { duration: 0 },
|
||||
};
|
||||
|
||||
const animationsOffWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeContext value={animationsOffCtx}>{children}</ThemeContext>
|
||||
);
|
||||
|
||||
describe("useFlashTransition", () => {
|
||||
beforeEach(() => {
|
||||
@@ -20,11 +32,12 @@ describe("useFlashTransition", () => {
|
||||
|
||||
let value = 0;
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value));
|
||||
expect(result.current.props).toMatchObject(defaultProps);
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
value = 1;
|
||||
rerender();
|
||||
expect(result.current.props).toMatchObject(defaultProps);
|
||||
expect(node.className).toBe("");
|
||||
});
|
||||
|
||||
it("flashes when value changes and element is in viewport", () => {
|
||||
@@ -34,15 +47,44 @@ describe("useFlashTransition", () => {
|
||||
|
||||
let value = 2;
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value));
|
||||
expect(result.current.props).toMatchObject(defaultProps);
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
expect(node.className).toBe("");
|
||||
|
||||
value = 3;
|
||||
rerender();
|
||||
expect(result.current.props).toMatchObject({
|
||||
...defaultProps,
|
||||
in: true,
|
||||
enter: true,
|
||||
});
|
||||
expect(node.className).toBe("components-animation-flash");
|
||||
});
|
||||
|
||||
it("removes the flash class after the animation duration", () => {
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
|
||||
let value = 2;
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value));
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
value = 3;
|
||||
rerender();
|
||||
expect(node.className).toBe("components-animation-flash");
|
||||
|
||||
act(() => jest.advanceTimersByTime(800));
|
||||
expect(node.className).toBe("");
|
||||
});
|
||||
|
||||
it("does not flash on initial mount", () => {
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(1));
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
rerender();
|
||||
expect(node.className).toBe("");
|
||||
});
|
||||
|
||||
it("flashes when value changes and element moves into viewport", () => {
|
||||
@@ -52,42 +94,38 @@ describe("useFlashTransition", () => {
|
||||
|
||||
let value = 2;
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value));
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
value = 3;
|
||||
rerender();
|
||||
expect(result.current.props).toMatchObject(defaultProps);
|
||||
expect(node.className).toBe("");
|
||||
|
||||
act(() => {
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
});
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
rerender();
|
||||
expect(result.current.props).toMatchObject({
|
||||
...defaultProps,
|
||||
in: true,
|
||||
enter: true,
|
||||
});
|
||||
expect(node.className).toBe("components-animation-flash");
|
||||
});
|
||||
|
||||
it("stops flashing props.onEntered is called", () => {
|
||||
it("does not flash when animations are disabled", () => {
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
|
||||
let value = 2;
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value));
|
||||
const { result, rerender } = renderHook(() => useFlashTransition(value), {
|
||||
wrapper: animationsOffWrapper,
|
||||
});
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
value = 3;
|
||||
rerender();
|
||||
expect(result.current.props).toMatchObject({
|
||||
...defaultProps,
|
||||
in: true,
|
||||
enter: true,
|
||||
});
|
||||
expect(node.className).toBe("");
|
||||
|
||||
act(() => result.current.props.onEntered!({} as HTMLElement, false));
|
||||
expect(result.current.props).toMatchObject(defaultProps);
|
||||
act(() => jest.advanceTimersByTime(800));
|
||||
expect(node.className).toBe("");
|
||||
});
|
||||
|
||||
it("unmounts cleanly when not flashing", () => {
|
||||
@@ -101,14 +139,19 @@ describe("useFlashTransition", () => {
|
||||
|
||||
it("unmounts cleanly when flashing", () => {
|
||||
(useInView as jest.MockedFunction<typeof useInView>).mockReturnValue(
|
||||
mockInViewResponse(false),
|
||||
mockInViewResponse(true),
|
||||
);
|
||||
|
||||
let value = 5;
|
||||
const { rerender, unmount } = renderHook(() => useFlashTransition(value));
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useFlashTransition(value),
|
||||
);
|
||||
const node = document.createElement("span");
|
||||
act(() => result.current.ref(node));
|
||||
|
||||
value = 6;
|
||||
rerender();
|
||||
expect(node.className).toBe("components-animation-flash");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
import {
|
||||
use,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
} from "react";
|
||||
|
||||
import type { CSSTransitionProps } from "react-transition-group/CSSTransition";
|
||||
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
const defaultProps: CSSTransitionProps = {
|
||||
in: false,
|
||||
classNames: "components-animation-flash",
|
||||
timeout: 800,
|
||||
appear: false,
|
||||
enter: false,
|
||||
exit: false,
|
||||
};
|
||||
import { ThemeContext } from "Components/Theme";
|
||||
|
||||
// must match $duration in Styles/Components/_Flash.scss
|
||||
const flashDuration = 800;
|
||||
|
||||
const useFlashTransition = (
|
||||
flashOn: ReactNode,
|
||||
): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
props: CSSTransitionProps;
|
||||
nodeRef: RefObject<HTMLElement | null>;
|
||||
} => {
|
||||
const context = use(ThemeContext);
|
||||
const mountRef = useRef<boolean>(false);
|
||||
const nodeRef = useRef<HTMLElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [ref, inView] = useInView();
|
||||
const [isPending, setIsPending] = useState<boolean>(false);
|
||||
const [props, setProps] = useState<CSSTransitionProps>(defaultProps);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountRef.current) {
|
||||
@@ -42,15 +35,35 @@ const useFlashTransition = (
|
||||
}, [flashOn]);
|
||||
|
||||
useEffect(() => {
|
||||
setProps({
|
||||
...defaultProps,
|
||||
in: isPending && inView,
|
||||
enter: isPending && inView,
|
||||
onEntered: () => setIsPending(false),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
nodeRef: nodeRef as any,
|
||||
});
|
||||
}, [inView, isPending]);
|
||||
if (!isPending) {
|
||||
return;
|
||||
}
|
||||
// drop stale flashes rather than play them if animations get re-enabled
|
||||
if (context.animations.duration === 0) {
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
if (inView && nodeRef.current) {
|
||||
const node = nodeRef.current;
|
||||
// adding the class starts the CSS animation, removing it after the
|
||||
// duration passes allows the next flash to restart it without a reflow
|
||||
node.classList.add("components-animation-flash");
|
||||
timerRef.current = setTimeout(
|
||||
() => node.classList.remove("components-animation-flash"),
|
||||
flashDuration,
|
||||
);
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [inView, isPending, context.animations.duration]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const combinedRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
@@ -60,7 +73,7 @@ const useFlashTransition = (
|
||||
[ref],
|
||||
);
|
||||
|
||||
return { ref: combinedRef, props, nodeRef };
|
||||
return { ref: combinedRef };
|
||||
};
|
||||
|
||||
export { useFlashTransition, defaultProps };
|
||||
export { useFlashTransition };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
$duration: 800ms;
|
||||
|
||||
.components-animation-flash-appear,
|
||||
.components-animation-flash-enter {
|
||||
.components-animation-flash {
|
||||
animation-name: flash;
|
||||
animation-duration: $duration;
|
||||
animation-timing-function: ease-in-out;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
Reference in New Issue
Block a user