Skip to content

Commit ff961e4

Browse files
committed
Add useSpinDelay hook
1 parent 651b990 commit ff961e4

3 files changed

Lines changed: 71 additions & 3 deletions

File tree

packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { useCardState } from '@/elements/contexts';
3232
import { Drawer } from '@/elements/Drawer';
3333
import { IconButton } from '@/elements/IconButton';
3434
import { Pagination } from '@/elements/Pagination';
35-
import { useClipboard } from '@/hooks';
35+
import { useClipboard, useSpinDelay } from '@/hooks';
3636
import { Check, Copy, LinkIcon, RotateLeftRight } from '@/icons';
3737
import { common, mqu } from '@/styledSystem';
3838
import { handleError } from '@/utils/errorHandler';
@@ -65,6 +65,7 @@ export const TestConfigurationStep = (): JSX.Element => {
6565
});
6666

6767
const isRefreshingTestRuns = areTestRunsFetching && !areTestRunsLoading;
68+
const showRefreshLogsSpinner = useSpinDelay(isRefreshingTestRuns);
6869
const pageCount = totalCount ? Math.ceil(totalCount / TEST_RUNS_PAGE_SIZE) : 0;
6970

7071
const handleTestRunCreated = () => {
@@ -114,10 +115,10 @@ export const TestConfigurationStep = (): JSX.Element => {
114115
colorScheme='secondary'
115116
size='xs'
116117
onClick={() => void revalidateTestRuns()}
117-
isDisabled={isRefreshingTestRuns}
118+
isDisabled={showRefreshLogsSpinner}
118119
sx={t => ({ gap: t.space.$1x5 })}
119120
>
120-
{isRefreshingTestRuns ? (
121+
{showRefreshLogsSpinner ? (
121122
<Spinner
122123
elementDescriptor={descriptors.spinner}
123124
size='xs'

packages/ui/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './usePrefersReducedMotion';
1616
export * from './useSafeState';
1717
export * from './useScrollLock';
1818
export * from './useSearchInput';
19+
export * from './useSpinDelay';
1920
export * from './useTotalEnabledAuthMethods';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { useSafeState } from './useSafeState';
4+
5+
type UseSpinDelayOptions = {
6+
/**
7+
* The amount of time (in ms) to wait before reflecting `value === true`. If `value` flips back
8+
* to `false` before this elapses, the flag is never set to `true` (the spinner is skipped).
9+
*
10+
* @default 0
11+
*/
12+
delay?: number;
13+
/**
14+
* Once the flag becomes `true`, it stays `true` for at least this long (in ms) even if the
15+
* underlying `value` returns to `false` earlier. Prevents the spinner from flickering on fast
16+
* operations.
17+
*
18+
* @default 425
19+
*/
20+
minDuration?: number;
21+
};
22+
23+
const DEFAULT_DELAY = 0;
24+
// 425ms is the default used by the dashboard (`apps/dashboard/app/hooks/use-pending-hooks.ts`),
25+
// chosen by trial-and-error as the threshold above which a spinner feels intentional rather
26+
// than a flicker.
27+
const DEFAULT_MIN_DURATION = 425;
28+
29+
/**
30+
* Smooths a transient boolean flag (typically a loading/fetching state) so the consumer never
31+
* shows a spinner that flickers on and off within a single frame.
32+
*
33+
* @example
34+
* const isFetching = useSomeQuery();
35+
* const showSpinner = useSpinDelay(isFetching, { delay: 0, minDuration: 425 });
36+
* return showSpinner ? <Spinner /> : <Icon />;
37+
*/
38+
export function useSpinDelay(
39+
value: boolean,
40+
{ delay = DEFAULT_DELAY, minDuration = DEFAULT_MIN_DURATION }: UseSpinDelayOptions = {},
41+
): boolean {
42+
const [displayed, setDisplayed] = useSafeState(false);
43+
const shownAtRef = useRef<number | null>(null);
44+
45+
useEffect(() => {
46+
if (value && !displayed) {
47+
const timeout = setTimeout(() => {
48+
shownAtRef.current = Date.now();
49+
setDisplayed(true);
50+
}, delay);
51+
return () => clearTimeout(timeout);
52+
}
53+
54+
if (!value && displayed) {
55+
const elapsed = shownAtRef.current != null ? Date.now() - shownAtRef.current : minDuration;
56+
const remaining = Math.max(0, minDuration - elapsed);
57+
const timeout = setTimeout(() => {
58+
shownAtRef.current = null;
59+
setDisplayed(false);
60+
}, remaining);
61+
return () => clearTimeout(timeout);
62+
}
63+
}, [value, displayed, delay, minDuration, setDisplayed]);
64+
65+
return displayed;
66+
}

0 commit comments

Comments
 (0)