Skip to content

Commit 2329c0e

Browse files
authored
Merge pull request framer#373 from framer/global-search/filterer
Global search: adding filterer
2 parents 40ec284 + f4e26a0 commit 2329c0e

18 files changed

Lines changed: 623 additions & 31 deletions

plugins/global-search/src/App.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { describe, expect, it } from "vitest"
44
import { App } from "./App"
55

66
describe("App", () => {
7-
it("should render", () => {
7+
it("should render search interface", () => {
88
render(<App />)
9-
expect(screen.getByText(/Welcome!/gi)).toBeInTheDocument()
9+
expect(screen.getByPlaceholderText(/Search/i)).toBeInTheDocument()
10+
expect(screen.getByLabelText(/Search for anything in your Framer project/i)).toBeInTheDocument()
1011
})
1112
})

plugins/global-search/src/App.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { framer } from "framer-plugin"
22
import { useEffect, useState } from "react"
33
import { ErrorBoundary } from "react-error-boundary"
44
import { DevToolsScene } from "./components/DevToolsScene"
5+
import { SearchScene } from "./components/SearchScene"
56
import { IndexerProvider } from "./utils/indexer/IndexerProvider"
67

78
framer.showUI({
89
position: "top right",
9-
width: 400,
10-
height: 600,
11-
resizable: true,
10+
width: 280,
11+
height: 64,
1212
})
1313

1414
export function App() {
@@ -34,11 +34,8 @@ export function App() {
3434
)}
3535
>
3636
<IndexerProvider>
37-
{activeScene === "dev-tools" ? (
38-
<DevToolsScene />
39-
) : (
40-
<div>Welcome! This is under development. Select "Open Dev Tools" to get started.</div>
41-
)}
37+
{activeScene === "dev-tools" && <DevToolsScene />}
38+
{activeScene === "search" && <SearchScene />}
4239
</IndexerProvider>
4340
</ErrorBoundary>
4441
)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useMemo, useState } from "react"
1+
import { framer } from "framer-plugin"
2+
import { useEffect, useMemo, useState } from "react"
23
import { cn } from "../utils/className"
34
import { type IndexEntry } from "../utils/indexer/types"
45
import { useIndexer } from "../utils/indexer/useIndexer"
@@ -28,6 +29,14 @@ export function DevToolsScene() {
2829
[entries, filterQuery]
2930
)
3031

32+
useEffect(() => {
33+
framer.showUI({
34+
height: Infinity,
35+
width: Infinity,
36+
resizable: true,
37+
})
38+
}, [])
39+
3140
const stats = useMemo(
3241
() => ({
3342
total: entries.length,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { DetailedHTMLProps } from "react"
2+
import { cn } from "../utils/className"
3+
import { IconSearch } from "./ui/IconSearch"
4+
5+
type SearchInputProps = DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
6+
7+
export function SearchInput({ className, ...props }: SearchInputProps) {
8+
return (
9+
<label className="flex items-center gap-2 text-framer-text-tertiary flex-1 border border-amber-500 rounded-md -ml-1 pl-2">
10+
<span className="sr-only">Search for anything in your Framer project</span>
11+
<IconSearch aria-hidden />
12+
<input
13+
type="text"
14+
className={cn(
15+
"flex-1 bg-transparent border-none outline-none focus-visible:outline-none focus-visible:ring-0 text-xs selection:bg-amber-500",
16+
className
17+
)}
18+
placeholder="Search..."
19+
{...props}
20+
/>
21+
</label>
22+
)
23+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { framer, type MenuItem } from "framer-plugin"
2+
import { startTransition, useCallback, useEffect, useMemo, useState } from "react"
3+
import { assertNever } from "../utils/assert"
4+
import { type ReadonlyGroupedResults } from "../utils/filter/group-results"
5+
import type { Range } from "../utils/filter/ranges"
6+
import { type CollectionItemResult, type NodeResult, type Result } from "../utils/filter/types"
7+
import { useFilter } from "../utils/filter/useFilter"
8+
import type { RootNodeType } from "../utils/indexer/types"
9+
import { useIndexer } from "../utils/indexer/useIndexer"
10+
import { entries } from "../utils/object"
11+
import { SearchInput } from "./SearchInput"
12+
import { IconEllipsis } from "./ui/IconEllipsis"
13+
import { Menu } from "./ui/Menu"
14+
15+
export function SearchScene() {
16+
const { index } = useIndexer()
17+
const [query, setQuery] = useState("")
18+
const { searchOptions, optionsMenuItems } = useOptionsMenuItems()
19+
const { results } = useFilter(query, searchOptions, index)
20+
21+
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
22+
startTransition(() => {
23+
setQuery(event.target.value)
24+
})
25+
}, [])
26+
27+
const hasResults = useMemo(() => {
28+
for (const [, resultForRootNodeType] of entries(results)) {
29+
if (!resultForRootNodeType) continue
30+
31+
for (const resultsForRootId of Object.values(resultForRootNodeType)) {
32+
if (resultsForRootId.length > 0) return true
33+
}
34+
}
35+
return false
36+
}, [results])
37+
38+
useEffect(() => {
39+
if (query && hasResults) {
40+
framer.showUI({
41+
height: 320,
42+
})
43+
} else if (query && !hasResults) {
44+
framer.showUI({
45+
height: 140,
46+
})
47+
} else {
48+
framer.showUI({
49+
height: 64,
50+
})
51+
}
52+
}, [query, hasResults])
53+
54+
return (
55+
<main className="flex flex-col h-full">
56+
<div className="flex gap-2 border-b border-framer-divider border-t py-3 mx-3">
57+
<SearchInput value={query} onChange={handleQueryChange} />
58+
<Menu items={optionsMenuItems}>
59+
<IconEllipsis />
60+
</Menu>
61+
</div>
62+
<div className="flex-1 overflow-y-auto px-4 flex flex-col">
63+
{query && hasResults ? <SearchResultsByRootType results={results} /> : <NoResults />}
64+
</div>
65+
</main>
66+
)
67+
}
68+
69+
// All components below this line are temporary and will be removed when the search results are implemented
70+
// Having them ensures it's easier to verify the indexer and filterer are working as expected
71+
72+
function NoResults() {
73+
return (
74+
<div className="flex-1 flex justify-center items-center">
75+
<div className="text-center text-amber-500">No results found.</div>
76+
</div>
77+
)
78+
}
79+
80+
function SearchResultsByRootType({ results }: { results: ReadonlyGroupedResults }) {
81+
return Object.entries(results).map(([rootNodeType, resultsByRootId]) => (
82+
<RootNodeTypeSection key={rootNodeType} resultsByRootId={resultsByRootId} />
83+
))
84+
}
85+
86+
function RootNodeTypeSection({ resultsByRootId }: { resultsByRootId: { readonly [id: string]: readonly Result[] } }) {
87+
return (
88+
<div className="flex flex-col gap-2 mb-4 text-amber-500">
89+
{Object.entries(resultsByRootId).map(([rootNodeId, results]) => (
90+
<SearchResultGroup key={rootNodeId} results={results} />
91+
))}
92+
</div>
93+
)
94+
}
95+
96+
function SearchResultGroup({ results }: { results: readonly Result[] }) {
97+
const [first] = results
98+
99+
if (!first) return null
100+
101+
return (
102+
<div>
103+
<div className="text-lg text-amber-800">
104+
{first.entry.rootNodeName} ({first.entry.rootNodeType} {first.entry.rootNode.id})
105+
</div>
106+
<ul className="flex flex-col gap-2">
107+
{results.map(result => (
108+
<SearchResult key={result.id} result={result} />
109+
))}
110+
</ul>
111+
</div>
112+
)
113+
}
114+
115+
function SearchResult({ result }: { result: Result }) {
116+
if (result.type === "CollectionItem") {
117+
return <CollectionItemSearchResult result={result} />
118+
} else if (result.type === "Node") {
119+
return <NodeSearchResult result={result} />
120+
}
121+
122+
assertNever(result)
123+
}
124+
125+
function NodeSearchResult({ result }: { result: NodeResult }) {
126+
if (!result.entry.text) return null
127+
128+
return <SearchResultRanges text={result.entry.text} ranges={result.ranges} resultId={result.id} />
129+
}
130+
131+
function CollectionItemSearchResult({ result }: { result: CollectionItemResult }) {
132+
if (!result.text) return null
133+
134+
return <SearchResultRanges text={result.text} ranges={result.ranges} resultId={result.id} />
135+
}
136+
137+
function SearchResultRanges({ text, ranges, resultId }: { text: string; ranges: readonly Range[]; resultId: string }) {
138+
return ranges.map(range => (
139+
<li key={`${resultId}-${range.join("-")}`} className="text-ellipsis overflow-hidden whitespace-nowrap">
140+
<HighlightedTextWithContext text={text} range={range} /> ({resultId})
141+
</li>
142+
))
143+
}
144+
145+
function HighlightedTextWithContext({ text, range }: { text: string; range: Range }) {
146+
const [start, end] = range
147+
const before = text.slice(0, start)
148+
const match = text.slice(start, end)
149+
const after = text.slice(end)
150+
151+
return (
152+
<>
153+
{before}
154+
<span className="font-bold">{match}</span>
155+
{after}
156+
</>
157+
)
158+
}
159+
160+
/**
161+
* Contains if you can filter by a root node type.
162+
*
163+
* During current state of the plugin, not all types are indexed yet.
164+
*/
165+
const optionsEnabled = {
166+
ComponentNode: true,
167+
WebPageNode: true,
168+
Collection: true,
169+
} as const satisfies Record<RootNodeType, boolean>
170+
171+
const defaultSearchOptions = entries(optionsEnabled)
172+
.filter(([, enabled]) => enabled)
173+
.map(([rootNode]) => rootNode)
174+
175+
const optionsMenuLabels = {
176+
ComponentNode: "Components",
177+
WebPageNode: "Pages",
178+
Collection: "Collections",
179+
} as const satisfies Record<RootNodeType, string>
180+
181+
function useOptionsMenuItems() {
182+
const [searchOptions, setSearchOptions] = useState<readonly RootNodeType[]>(defaultSearchOptions)
183+
184+
const optionsMenuItems = useMemo((): MenuItem[] => {
185+
return entries(optionsEnabled).map(([rootNode, enabled]) => ({
186+
id: rootNode,
187+
label: optionsMenuLabels[rootNode],
188+
enabled,
189+
checked: searchOptions.includes(rootNode),
190+
onAction: () => {
191+
setSearchOptions(prev => {
192+
if (prev.includes(rootNode)) {
193+
return prev.filter(option => option !== rootNode)
194+
}
195+
196+
return [...prev, rootNode]
197+
})
198+
},
199+
}))
200+
}, [searchOptions])
201+
202+
return { searchOptions, optionsMenuItems }
203+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function IconEllipsis() {
2+
return (
3+
<svg
4+
xmlns="http://www.w3.org/2000/svg"
5+
width="16"
6+
height="13"
7+
fill="none"
8+
viewBox="0 0 16 13"
9+
aria-label="Ellipsis"
10+
>
11+
<title>Ellipsis</title>
12+
<path
13+
d="M 2.999 5 C 3.827 5 4.499 5.672 4.499 6.5 C 4.499 7.328 3.827 8 2.999 8 C 2.171 8 1.499 7.328 1.499 6.5 C 1.499 5.672 2.171 5 2.999 5 Z M 7.999 5 C 8.827 5 9.499 5.672 9.499 6.5 C 9.499 7.328 8.827 8 7.999 8 C 7.171 8 6.499 7.328 6.499 6.5 C 6.499 5.672 7.171 5 7.999 5 Z M 12.999 5 C 13.827 5 14.499 5.672 14.499 6.5 C 14.499 7.328 13.827 8 12.999 8 C 12.171 8 11.499 7.328 11.499 6.5 C 11.499 5.672 12.171 5 12.999 5 Z"
14+
fill="currentColor"
15+
/>
16+
</svg>
17+
)
18+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { SVGProps } from "react"
2+
3+
export function IconSearch(props: SVGProps<SVGSVGElement>) {
4+
return (
5+
<svg xmlns="http://www.w3.org/2000/svg" width={11.4} height={11.1} fill="none" overflow="visible" {...props}>
6+
<path
7+
fill="currentColor"
8+
d="M5 0a5 5 0 014.1 7.8l2 2a.8.8 0 01-1 1.1l-2-2A5 5 0 115 0zM1.5 5a3.5 3.5 0 107 0 3.5 3.5 0 00-7 0z"
9+
/>
10+
</svg>
11+
)
12+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { framer, type MenuItem } from "framer-plugin"
2+
import { memo, type ReactNode, useCallback, useRef } from "react"
3+
4+
interface MenuProps {
5+
items: MenuItem[]
6+
children: ReactNode
7+
}
8+
9+
export const Menu = memo(function Menu({ items, children }: MenuProps) {
10+
const buttonRef = useRef<HTMLButtonElement>(null)
11+
12+
const toggleMenu = useCallback(
13+
async (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => {
14+
if (!buttonRef.current) return
15+
if ("key" in event && event.key !== "Enter" && event.key !== " ") return
16+
17+
const buttonBounds = buttonRef.current.getBoundingClientRect()
18+
19+
await framer.showContextMenu(items, {
20+
location: { x: buttonBounds.right - 5, y: buttonBounds.bottom },
21+
placement: "bottom-left",
22+
width: 200,
23+
})
24+
},
25+
[items]
26+
)
27+
28+
return (
29+
<div className="relative">
30+
<button
31+
type="button"
32+
ref={buttonRef}
33+
onMouseDown={toggleMenu}
34+
onKeyDown={toggleMenu}
35+
className="border border-amber-500 group size-6 text-white rounded-md flex-shrink-0 flex items-center justify-center bg-transparent p-0 focus-visible:outline-none hover:text-framer-text-base focus-visible:text-framer-text-base disabled:opacity-50 disabled:pointer-events-none disabled:cursor-default visible"
36+
aria-haspopup="true"
37+
>
38+
<div className="flex items-center justify-center w-fit h-fit flex-shrink-0 bg-transparent text-framer-text-tertiary group-hover:text-framer-text-base group-focus-visible:text-framer-text-base">
39+
{children}
40+
</div>
41+
</button>
42+
</div>
43+
)
44+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function assert(condition: unknown, ...message: unknown[]): asserts condition {
2+
if (condition) return
3+
throw Error(`Assertion error: ${message.join(", ")}`)
4+
}
5+
6+
export function assertNever(x: never): never {
7+
throw new Error(`Unexpected value: ${String(x)}`)
8+
}

0 commit comments

Comments
 (0)