Skip to content

Commit 02a8e04

Browse files
committed
feat(global-search): show indexing spinner for a minimum time
1 parent 9daf2e3 commit 02a8e04

3 files changed

Lines changed: 151 additions & 9 deletions

File tree

plugins/global-search/src/components/SearchScene.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { RootNodeType } from "../utils/indexer/types"
88
import { useIndexer } from "../utils/indexer/useIndexer"
99
import { entries } from "../utils/object"
1010
import { getPluginUiOptions } from "../utils/plugin-ui"
11+
import { useMinimumDuration } from "../utils/useMinimumDuration"
1112
import { NoResults } from "./NoResults"
1213
import { ResultsList } from "./Results"
1314
import { SearchInput } from "./SearchInput"
@@ -20,6 +21,7 @@ export function SearchScene() {
2021
const [query, setQuery] = useState("")
2122
const { searchOptions, optionsMenuItems } = useOptionsMenuItems()
2223
const deferredQuery = useDeferredValue(query)
24+
const isIndexingWithMinimumDuration = useMinimumDuration(isIndexing, 500)
2325

2426
const { results, hasResults, error: filterError } = useAsyncFilter(deferredQuery, searchOptions, db, dataVersion)
2527

@@ -46,15 +48,15 @@ export function SearchScene() {
4648
)}
4749
>
4850
<SearchInput value={query} onChange={handleQueryChange} />
49-
{isIndexing && (
50-
// TODO: Discuss if we should add a tooltip to explain what's this.
51-
<span
52-
title="Indexing..."
53-
className="animate-[fade-in_150ms_forwards] [animation-delay:500ms] opacity-0"
54-
>
55-
<IconSpinner className="text-black dark:text-white animate-[spin_0.8s_linear_infinite]" />
56-
</span>
57-
)}
51+
52+
<span
53+
title="Indexing..."
54+
className="aria-hidden:opacity-0 transition"
55+
aria-hidden={!isIndexingWithMinimumDuration}
56+
>
57+
<IconSpinner className="text-black dark:text-white animate-[spin_0.8s_linear_infinite]" />
58+
</span>
59+
5860
<Menu items={optionsMenuItems}>
5961
<IconEllipsis className="text-framer-text-tertiary-light dark:text-framer-text-tertiary-dark" />
6062
</Menu>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { act, renderHook } from "@testing-library/react"
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3+
import { useMinimumDuration } from "./useMinimumDuration"
4+
5+
function advanceTimersByTime(time: number) {
6+
act(() => {
7+
vi.advanceTimersByTime(time)
8+
})
9+
}
10+
11+
describe("useMinimumDuration", () => {
12+
beforeEach(() => {
13+
vi.useFakeTimers()
14+
})
15+
16+
afterEach(() => {
17+
vi.runOnlyPendingTimers()
18+
vi.useRealTimers()
19+
})
20+
21+
it("should delay returning false when input becomes false", () => {
22+
const { result, rerender } = renderHook(({ value }) => useMinimumDuration(value, 1000), {
23+
initialProps: { value: true },
24+
})
25+
26+
expect(result.current).toBe(true)
27+
28+
rerender({ value: false })
29+
expect(result.current).toBe(true)
30+
31+
advanceTimersByTime(500)
32+
33+
expect(result.current).toBe(true)
34+
35+
advanceTimersByTime(500)
36+
37+
expect(result.current).toBe(false)
38+
})
39+
40+
it("should cancel delay when input becomes true again during delay period", () => {
41+
const { result, rerender } = renderHook(({ value }) => useMinimumDuration(value, 1000), {
42+
initialProps: { value: true },
43+
})
44+
45+
expect(result.current).toBe(true)
46+
47+
rerender({ value: false })
48+
expect(result.current).toBe(true)
49+
50+
advanceTimersByTime(500)
51+
52+
expect(result.current).toBe(true)
53+
54+
rerender({ value: true })
55+
expect(result.current).toBe(true)
56+
57+
advanceTimersByTime(600)
58+
59+
expect(result.current).toBe(true)
60+
61+
rerender({ value: false })
62+
expect(result.current).toBe(true)
63+
64+
advanceTimersByTime(1000)
65+
66+
expect(result.current).toBe(false)
67+
})
68+
69+
it("should handle multiple rapid changes correctly", () => {
70+
const { result, rerender } = renderHook(({ value }) => useMinimumDuration(value, 1000), {
71+
initialProps: { value: false },
72+
})
73+
74+
expect(result.current).toBe(false)
75+
76+
rerender({ value: true })
77+
expect(result.current).toBe(true)
78+
79+
rerender({ value: false })
80+
expect(result.current).toBe(true)
81+
82+
rerender({ value: true })
83+
expect(result.current).toBe(true)
84+
85+
rerender({ value: false })
86+
expect(result.current).toBe(true)
87+
88+
advanceTimersByTime(1000)
89+
90+
expect(result.current).toBe(false)
91+
})
92+
93+
it("should clean up timeout on unmount to prevent memory leaks", () => {
94+
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
95+
96+
const { unmount, rerender } = renderHook(({ value }) => useMinimumDuration(value, 1000), {
97+
initialProps: { value: true },
98+
})
99+
100+
rerender({ value: false })
101+
unmount()
102+
103+
expect(clearTimeoutSpy).toHaveBeenCalled()
104+
105+
clearTimeoutSpy.mockRestore()
106+
})
107+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useRef, useState } from "react"
2+
3+
/**
4+
* Hook that ensures a boolean value stays `true` for a minimum duration.
5+
* When the input becomes `true`, it immediately returns `true`.
6+
* When the input becomes `false`, it delays returning `false` for the specified duration.
7+
* If the input becomes `true` again during the delay, the delay is cancelled.
8+
*
9+
* @param value - The boolean value to control
10+
* @param minDuration - Minimum duration in milliseconds to keep the value `true`
11+
* @returns The controlled boolean value
12+
*/
13+
export function useMinimumDuration(value: boolean, minDuration: number): boolean {
14+
const [delayedValue, setDelayedValue] = useState(value)
15+
const timeoutRef = useRef<number>()
16+
17+
useEffect(() => {
18+
if (value) {
19+
clearTimeout(timeoutRef.current)
20+
setDelayedValue(true)
21+
} else if (delayedValue) {
22+
timeoutRef.current = window.setTimeout(() => {
23+
setDelayedValue(false)
24+
}, minDuration)
25+
}
26+
27+
return () => {
28+
clearTimeout(timeoutRef.current)
29+
}
30+
}, [value, delayedValue, minDuration])
31+
32+
return delayedValue
33+
}

0 commit comments

Comments
 (0)