Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 0 additions & 1 deletion plugins/global-search/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"framer-plugin": "3.7.0-alpha.1",
"idb": "^8.0.3",
"react": "^18.3.1",
"react-aria": "^3.43.2",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.0.0",
"tailwind-merge": "^3.3.1"
Expand Down
8 changes: 5 additions & 3 deletions plugins/global-search/src/components/GroupHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { assertNever } from "../utils/assert"
import { cn } from "../utils/className"
import type { PreparedGroup } from "../utils/filter/group-results"
import type { RootNodeType } from "../utils/indexer/types"
import { useFocusHandlers } from "../utils/useFocus"
import { headerId } from "../utils/selection/constants"
import { useSelection } from "../utils/selection/useSelection"
import { IconArrowRight } from "./ui/IconArrowRight"
import { IconCode } from "./ui/IconCode"
import { IconCollection } from "./ui/IconCollection"
Expand All @@ -24,11 +25,12 @@ export const GroupHeader = memo(
{ entry, isExpanded, isSticky, onToggle, className, showFadeOut, hasTopBorder, ...props },
ref
) {
const focusProps = useFocusHandlers({ isSelfSelectable: false })
const { getFocusProps } = useSelection()
const displayName = entry.rootNodeName ?? `Unnamed ${entry.rootNodeType}`

return (
<button
tabIndex={-1}
ref={ref}
onClick={onToggle}
className={cn(
Expand All @@ -39,8 +41,8 @@ export const GroupHeader = memo(
className
)}
aria-expanded={isExpanded}
{...focusProps}
{...props}
{...getFocusProps(headerId(entry.id))}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Group Headers Incorrectly Activate on Click

Group headers become active when clicked, which isn't the intended behavior and creates an inconsistency with keyboard navigation that correctly skips them. The getFocusProps function in SelectionProvider sets any focused item as active, including headers.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's very much intended here!

>
<hr
className="border-divider-light dark:border-divider-dark aria-hidden:opacity-0 mb-1"
Expand Down
20 changes: 13 additions & 7 deletions plugins/global-search/src/components/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import { type CSSProperties, forwardRef, useCallback, useMemo } from "react"
import { cn } from "../utils/className"
import { type Range, rangeLength, rangeToCodeFileLocation } from "../utils/filter/ranges"
import { ResultType } from "../utils/filter/types"
import { useSelection } from "../utils/selection/useSelection"
import { truncateFromStart } from "../utils/text"
import { useFocusHandlers } from "../utils/useFocus"

interface CommonMatchProps {
targetId: string
text: string
range: Range
style?: CSSProperties | undefined
className?: string | undefined
resultId: string
}

type TypedMatchProps =
Expand All @@ -27,7 +28,7 @@ type TypedMatchProps =
export type MatchProps = CommonMatchProps & TypedMatchProps

export const Match = forwardRef<HTMLButtonElement, MatchProps>(function Match(props, ref) {
const { targetId, text, range, style, className } = props
const { targetId, text, range, style, className, resultId } = props
const navigateToResult = useCallback(() => {
framer
.navigateTo(targetId, {
Expand All @@ -42,26 +43,31 @@ export const Match = forwardRef<HTMLButtonElement, MatchProps>(function Match(pr
}, [targetId, range, text, props])

const { before, highlight, after } = useHighlightedTextWithContext({ text, range })
const focusProps = useFocusHandlers({ isSelfSelectable: true })
const { activeId, getFocusProps } = useSelection()
const isActive = activeId === resultId

return (
<button
ref={ref}
tabIndex={-1}
onClick={navigateToResult}
className={cn(
"text-secondary-light dark:text-secondary-dark text-xs w-full text-left select-none cursor-pointer pl-5 rounded-lg transition-colors h-6 left-0",
"hover:bg-option-light/50 dark:hover:bg-option-dark/50 hover:text-primary-light dark:hover:text-primary-dark focus:bg-option-light dark:focus:bg-option-dark focus:text-primary-light dark:focus:text-primary-dark",
"hover:bg-option-light/50 dark:hover:bg-option-dark/50 hover:text-primary-light dark:hover:text-primary-dark",
isActive ? "bg-option-light dark:bg-option-dark text-primary-light dark:text-primary-dark" : "",
"focus:outline-none focus:ring-0 focus:ring-offset-0",
className
)}
{...focusProps}
style={style}
role="option"
aria-selected={isActive}
{...getFocusProps(resultId)}
>
<li className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
{before}
<span className="font-semibold bg-transparent">{highlight}</span>
{after}
</li>
</div>
</button>
)
})
Expand Down
27 changes: 22 additions & 5 deletions plugins/global-search/src/components/Results.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
import { type CSSProperties, useCallback, useMemo, useRef, useState } from "react"
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { cn } from "../utils/className"
import type { PreparedGroup, PreparedResult } from "../utils/filter/group-results"
import { ResultType } from "../utils/filter/types"
import { headerId } from "../utils/selection/constants"
import { useSelection } from "../utils/selection/useSelection"
import { GroupHeader } from "./GroupHeader"
import { Match } from "./Match"

Expand All @@ -14,7 +16,8 @@ export function ResultsList({ groups }: ResultsProps) {
const scrollElementRef = useRef<HTMLDivElement>(null)
const activeStickyIndexRef = useRef(0)
const { collapsedGroups, toggleGroup } = useCollapsedGroups()
const { virtualItems, stickyIndexes } = useProcessedResults(groups, collapsedGroups)
const { virtualItems, virtualItemIds, stickyIndexes } = useProcessedResults(groups, collapsedGroups)
const { setItems } = useSelection()

const rowVirtualizer = useVirtualizer({
overscan: 10,
Expand Down Expand Up @@ -43,8 +46,17 @@ export function ResultsList({ groups }: ResultsProps) {
},
})

useEffect(() => {
setItems(virtualItemIds)
}, [virtualItemIds, setItems])

return (
<div ref={scrollElementRef} className="flex-1 min-h-0 overflow-auto scrollbar-hidden contain-strict">
<div
ref={scrollElementRef}
className="flex-1 min-h-0 overflow-auto scrollbar-hidden contain-strict focus-visible:outline-focus-ring-light focus-visible:dark:outline-focus-ring-dark focus-visible:outline-2 rounded-lg"
role="listbox"
aria-label="Search results"
>
<div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const item = virtualItems[virtualRow.index]
Expand Down Expand Up @@ -91,6 +103,7 @@ export function ResultsList({ groups }: ResultsProps) {
ref={rowVirtualizer.measureElement}
type={ResultType.CollectionItemField}
collectionFieldId={item.result.matchingField.id}
resultId={item.resultId}
targetId={item.result.entry.collectionItemId}
text={item.result.text}
range={item.result.range}
Expand All @@ -105,6 +118,7 @@ export function ResultsList({ groups }: ResultsProps) {
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
type={item.result.type}
resultId={item.resultId}
targetId={item.result.entry.nodeId}
text={item.result.text}
range={item.result.range}
Expand Down Expand Up @@ -157,16 +171,18 @@ function useCollapsedGroups(): { collapsedGroups: ReadonlySet<string>; toggleGro
function useProcessedResults(
groupedResults: readonly PreparedGroup[],
collapsedGroups: ReadonlySet<string>
): { virtualItems: readonly VirtualItem[]; stickyIndexes: readonly number[] } {
): { virtualItems: readonly VirtualItem[]; stickyIndexes: readonly number[]; virtualItemIds: readonly string[] } {
return useMemo(() => {
const virtualItems: VirtualItem[] = []
const virtualItemIds: string[] = []
const stickyIndexes: number[] = []

for (const group of groupedResults) {
const isExpanded = !collapsedGroups.has(group.entry.id)

stickyIndexes.push(virtualItems.length)

virtualItemIds.push(headerId(group.entry.id))
virtualItems.push({
type: "group-header",
groupId: group.entry.id,
Expand All @@ -177,6 +193,7 @@ function useProcessedResults(
// Add results to virtual items only if expanded
if (isExpanded) {
for (const processed of group.matches) {
virtualItemIds.push(processed.id)
virtualItems.push({
type: "result",
groupId: group.entry.id,
Expand All @@ -187,6 +204,6 @@ function useProcessedResults(
}
}

return { virtualItems, stickyIndexes }
return { virtualItems, stickyIndexes, virtualItemIds }
}, [groupedResults, collapsedGroups])
}
13 changes: 10 additions & 3 deletions plugins/global-search/src/components/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { DetailedHTMLProps } from "react"
import { cn } from "../utils/className"
import { useFocusHandlers } from "../utils/useFocus"
import { isHeader } from "../utils/selection/constants"
import { useSelection } from "../utils/selection/useSelection"
import { IconSearch } from "./ui/IconSearch"

type SearchInputProps = DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>

export function SearchInput({ className, ...props }: SearchInputProps) {
const focusProps = useFocusHandlers({ isSelfSelectable: true })
const { inputFocusProps, activeId, inputRef } = useSelection()
const activeDescendant = activeId && !isHeader(activeId) ? activeId : undefined

return (
<label className={cn("flex items-center gap-2 flex-1", className)}>
Expand All @@ -17,8 +19,13 @@ export function SearchInput({ className, ...props }: SearchInputProps) {
className="flex-1 h-[18px] bg-transparent border-none outline-none focus:outline-none focus:ring-0 text-xs p-0 text-primary-light dark:text-primary-dark placeholder:text-tertiary-light dark:placeholder:text-tertiary-dark"
placeholder="Search..."
autoFocus
ref={inputRef}
aria-activedescendant={activeDescendant}
onKeyDown={e => {
inputFocusProps.onKeyDown(e)
if (!e.defaultPrevented) props.onKeyDown?.(e)
}}
{...props}
{...focusProps}
/>
</label>
)
Expand Down
8 changes: 4 additions & 4 deletions plugins/global-search/src/components/SearchScene.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { framer, type MenuItem } from "framer-plugin"
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"
import { FocusScope } from "react-aria"
import { cn } from "../utils/className"
import { compareRootNodeTypeByPriority } from "../utils/filter/group-results"
import { useAsyncFilter } from "../utils/filter/useAsyncFilter"
import type { RootNodeType } from "../utils/indexer/types"
import { useIndexer } from "../utils/indexer/useIndexer"
import { entries } from "../utils/object"
import { getPluginUiOptions } from "../utils/plugin-ui"
import { SelectionProvider } from "../utils/selection/SelectionProvider"
import { useDebounceValue } from "../utils/useDebounceValue"
import { useMinimumDuration } from "../utils/useMinimumDuration"
import { ResultMessage } from "./ResultMessage"
Expand Down Expand Up @@ -57,7 +57,7 @@ export function SearchScene() {

return (
<main className="flex flex-col h-full">
<FocusScope>
<SelectionProvider>
<div
className={cn(
"flex gap-2 border-divider-light dark:border-divider-dark border-y py-3 mx-3 transition-colors items-center",
Expand All @@ -78,12 +78,12 @@ export function SearchScene() {
<IconEllipsis className="text-framer-text-tertiary-light dark:text-framer-text-tertiary-dark" />
</Menu>
</div>
<div className="overflow-y-auto px-3 flex flex-col flex-1 scrollbar-hidden">
<div className="px-3 flex flex-col flex-1 scrollbar-hidden">
{queryToUse && hasResults && <ResultsList groups={results} />}
{noResultsState === "searching" && <ResultMessage>Searching…</ResultMessage>}
{noResultsState === "no-results" && <ResultMessage>No Results</ResultMessage>}
</div>
</FocusScope>
</SelectionProvider>
</main>
)
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/global-search/src/components/ui/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const Menu = memo(function Menu({ items, children, className }: MenuProps
ref={buttonRef}
onMouseDown={toggleMenu}
onKeyDown={toggleMenu}
className="group h-full -mx-2 px-2 -my-3 py-3 text-white rounded-md flex-shrink-0 flex items-center justify-center bg-transparent focus:outline-none hover:text-primary-light dark:hover:text-primary-dark focus:text-primary-light dark:focus:text-primary-dark disabled:opacity-50 disabled:pointer-events-none disabled:cursor-default visible"
className="group h-full -mx-2 px-2 -my-3 py-3 text-white rounded-md flex-shrink-0 flex items-center justify-center bg-transparent outline-focus-ring-light dark:outline-focus-ring-dark hover:text-primary-light dark:hover:text-primary-dark focus:text-primary-light dark:focus:text-primary-dark disabled:opacity-50 disabled:pointer-events-none disabled:cursor-default visible"
aria-haspopup="true"
>
<div className="flex items-center justify-center w-fit h-fit flex-shrink-0 bg-transparent text-tertiary-light dark:text-tertiary-dark group-hover:text-primary-light dark:group-hover:text-primary-dark group-focus:text-primary-light dark:group-focus:text-primary-dark">
Expand Down
3 changes: 3 additions & 0 deletions plugins/global-search/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
--color-modal-light: #ffffff;
--color-modal-dark: #111111;

--color-focus-ring-light: #0099ff;
--color-focus-ring-dark: #0099ff;

/* Spacing */
--spacing: 5px;
}
Expand Down
89 changes: 89 additions & 0 deletions plugins/global-search/src/utils/selection/SelectionProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"
import { isHeader, NavigationDirection } from "./constants"
import { SelectionContext } from "./context"

export function SelectionProvider({ children }: { children: React.ReactNode }) {
const [activeId, setActiveId] = useState<string | null>(null)
const idsRef = useRef<readonly string[]>([])
const inputRef = useRef<HTMLInputElement | null>(null)

// scroll to active id
useLayoutEffect(() => {
if (!activeId) return
document.getElementById(activeId)?.scrollIntoView({ behavior: "smooth", block: "center" })
}, [activeId])

const setItems = useCallback(
(ids: readonly string[]) => {
idsRef.current = ids
if (ids.length === 0) setActiveId(null)
// if active id is not in the new ids, clear the active id
// either the group is collapsed or the item is no longer in the results
if (activeId && !ids.includes(activeId)) setActiveId(null)
},
[activeId]
)

const nextIndex = useCallback((fromIndex: number | null, direction: NavigationDirection): number | null => {
const itemCount = idsRef.current.length
const start = fromIndex ?? (direction === NavigationDirection.Down ? -1 : itemCount)
const idx = start + direction
return idx < 0 || idx >= itemCount
? null // out of bounds and clear
: idx
}, [])

const findNextSelectable = useCallback(
(fromIndex: number | null, direction: NavigationDirection): string | null => {
const idx = nextIndex(fromIndex, direction)
if (idx === null) return null
const id = idsRef.current[idx]
// skip headers and continue searching
if (isHeader(id)) return findNextSelectable(idx, direction)

return id ?? null
},
[nextIndex]
)

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
// Ensure the search input retains focus while navigating, prevents other elements from being focused
inputRef.current?.focus()
const currentIndex = activeId ? idsRef.current.indexOf(activeId) : null
const direction = event.key === "ArrowDown" ? NavigationDirection.Down : NavigationDirection.Up
const nextId = findNextSelectable(currentIndex, direction)
// end on the last or first item when repeating
if (event.repeat && nextId === null) return
setActiveId(nextId)
Comment thread
elmarburke marked this conversation as resolved.
} else if (event.key === "Enter" && activeId && !isHeader(activeId)) {
document.getElementById(activeId)?.click()
event.preventDefault()
}
},
[activeId, findNextSelectable]
)

const value = useMemo(
() => ({
activeId,
setItems,
getFocusProps: (id: string) => ({
id,
onFocus: () => {
setActiveId(id)
},
onKeyDown: handleKeyDown,
}),
inputFocusProps: { onKeyDown: handleKeyDown },
inputRef: (el: HTMLInputElement | null) => {
inputRef.current = el
},
}),
[activeId, setItems, handleKeyDown]
)

return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>
}
9 changes: 9 additions & 0 deletions plugins/global-search/src/utils/selection/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const HDR_PREFIX = "hdr:"

export const isHeader = (id: string | undefined) => id?.startsWith(HDR_PREFIX)
export const headerId = (id: string) => `${HDR_PREFIX}${id}`

export enum NavigationDirection {
Down = 1,
Up = -1,
}
Loading
Loading