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
31 changes: 31 additions & 0 deletions apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,37 @@ export const EmptyState: Story = {
},
};

export const FilteredContent: Story = {
render: () => {
const [value, setValue] = useState("");
const allFruits = [...fruits, ...tropicalFruits, ...citrusFruits];

return (
<Combobox.Root value={value} onValueChange={setValue}>
<Combobox.Trigger placeholder="Search fruits..." />
<Combobox.Content items={allFruits} getValue={(f) => f.label} limit={5}>
{({ filtered, hasMore, moreCount }) => (
<>
<Combobox.Input placeholder="Type to search..." />
<Combobox.Empty>No fruits found.</Combobox.Empty>
{filtered.map((fruit) => (
<Combobox.Item key={fruit.value} value={fruit.value}>
{fruit.label}
</Combobox.Item>
))}
{hasMore && (
<div className="combobox-label">
{moreCount} more — type to filter
</div>
)}
</>
)}
</Combobox.Content>
</Combobox.Root>
);
},
};

export const ControlledSearch: Story = {
render: () => {
const [value, setValue] = useState("");
Expand Down
91 changes: 79 additions & 12 deletions apps/code/src/renderer/components/ui/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import React, {
} from "react";
import { Tooltip } from "../Tooltip";
import "./Combobox.css";
import { useComboboxFilter } from "./useComboboxFilter";

type ComboboxSize = "1" | "2" | "3";
type ComboboxTriggerVariant =
Expand Down Expand Up @@ -44,6 +45,12 @@ function useComboboxContext() {
return context;
}

interface FilterContextValue {
onSearchChange: (value: string) => void;
}

const FilterContext = createContext<FilterContextValue | null>(null);

interface ComboboxRootProps {
children: React.ReactNode;
value?: string;
Expand Down Expand Up @@ -183,34 +190,81 @@ function ComboboxTrigger({
);
}

interface ComboboxContentProps {
children: React.ReactNode;
interface FilterResult<T> {
filtered: T[];
hasMore: boolean;
moreCount: number;
}

interface ComboboxContentBaseProps {
className?: string;
variant?: ComboboxContentVariant;
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
align?: "start" | "center" | "end";
style?: React.CSSProperties;
}

interface ComboboxContentStaticProps extends ComboboxContentBaseProps {
items?: undefined;
shouldFilter?: boolean;
children: React.ReactNode;
}

function ComboboxContent({
interface ComboboxContentFilteredProps<T> extends ComboboxContentBaseProps {
/** Items to filter. Activates built-in fuzzy filtering (bypasses cmdk). */
items: T[];
/** Extract the searchable string from each item. Defaults to `String(item)`. */
getValue?: (item: T) => string;
/** Maximum items to render. Defaults to 50. */
limit?: number;
/** Values pinned to the top regardless of score. */
pinned?: string[];
children: (result: FilterResult<T>) => React.ReactNode;
}

type ComboboxContentProps<T = never> =
| ComboboxContentStaticProps
| ComboboxContentFilteredProps<T>;

function ComboboxContent<T>({
children,
className = "",
variant = "soft",
side = "bottom",
sideOffset = 4,
align = "start",
style,
shouldFilter = true,
}: ComboboxContentProps) {
const { size, onOpenChange } = useComboboxContext();
...rest
}: ComboboxContentProps<T>) {
const { size, open, onOpenChange } = useComboboxContext();

const hasItems = "items" in rest && rest.items !== undefined;
const filterItems = hasItems ? rest.items : ([] as T[]);
const getValue = hasItems ? rest.getValue : undefined;
const limit = hasItems ? rest.limit : undefined;
const pinned = hasItems ? rest.pinned : undefined;
const shouldFilter = hasItems
? false
: "shouldFilter" in rest
? (rest.shouldFilter ?? true)
: true;

const filter = useComboboxFilter(
filterItems,
{ limit, pinned, open },
getValue,
);

const hasInput = React.Children.toArray(children).some(
const resolvedChildren = hasItems
? (children as (result: FilterResult<T>) => React.ReactNode)(filter)
: (children as React.ReactNode);

const hasInput = React.Children.toArray(resolvedChildren).some(
(child) => React.isValidElement(child) && child.type === ComboboxInput,
);

return (
const content = (
<Popover.Content
className={`combobox-content size-${size} variant-${variant} ${className}`}
side={side}
Expand All @@ -233,28 +287,38 @@ function ComboboxContent({
>
<CmdkCommand shouldFilter={shouldFilter} loop>
{hasInput &&
React.Children.map(children, (child) =>
React.Children.map(resolvedChildren, (child) =>
React.isValidElement(child) && child.type === ComboboxInput
? child
: null,
)}
<CmdkCommand.List>
{React.Children.map(children, (child) =>
{React.Children.map(resolvedChildren, (child) =>
React.isValidElement(child) &&
child.type !== ComboboxInput &&
child.type !== ComboboxFooter
? child
: null,
)}
</CmdkCommand.List>
{React.Children.map(children, (child) =>
{React.Children.map(resolvedChildren, (child) =>
React.isValidElement(child) && child.type === ComboboxFooter
? child
: null,
)}
</CmdkCommand>
</Popover.Content>
);

if (hasItems) {
return (
<FilterContext.Provider value={{ onSearchChange: filter.onSearchChange }}>
{content}
</FilterContext.Provider>
);
}

return content;
}

interface ComboboxInputProps {
Expand All @@ -268,6 +332,9 @@ const ComboboxInput = React.forwardRef<
React.ElementRef<typeof CmdkCommand.Input>,
ComboboxInputProps
>(({ placeholder = "Search...", className, value, onValueChange }, ref) => {
const filterCtx = useContext(FilterContext);
const handleValueChange = onValueChange ?? filterCtx?.onSearchChange;

return (
<div className="combobox-input-wrapper">
<MagnifyingGlass
Expand All @@ -280,7 +347,7 @@ const ComboboxInput = React.forwardRef<
className={className}
placeholder={placeholder}
value={value}
onValueChange={onValueChange}
onValueChange={handleValueChange}
autoFocus
/>
</div>
Expand Down
112 changes: 112 additions & 0 deletions apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { defaultFilter } from "cmdk";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

const DEFAULT_LIMIT = 50;
const MIN_FUZZY_SCORE = 0.1;
const DEBOUNCE_MS = 150;

interface UseComboboxFilterOptions {
/** Maximum number of items to render. Defaults to 50. */
limit?: number;
/** Values pinned to the top regardless of score. */
pinned?: string[];
/** Popover open state. Search resets when this becomes false. */
open?: boolean;
}

interface UseComboboxFilterResult<T> {
filtered: T[];
onSearchChange: (value: string) => void;
hasMore: boolean;
moreCount: number;
}

/**
* Fuzzy-filters and caps a list of items for use with Combobox.
*
* Prefer passing `items` directly to `Combobox.Content` which handles all
* wiring automatically. Use this hook directly only when you need custom
* control over the filtering lifecycle.
*/
export function useComboboxFilter<T>(
items: T[],
options?: UseComboboxFilterOptions,
getValue?: (item: T) => string,
): UseComboboxFilterResult<T> {
const [search, setSearch] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const limit = options?.limit ?? DEFAULT_LIMIT;
const pinned = options?.pinned;
const open = options?.open;

const debouncedSetSearch = useCallback((value: string) => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setSearch(value), DEBOUNCE_MS);
}, []);

useEffect(() => {
if (!open) {
clearTimeout(debounceRef.current);
setSearch("");
}
}, [open]);

useEffect(() => () => clearTimeout(debounceRef.current), []);

const resolve = useCallback(
(item: T): string => (getValue ? getValue(item) : String(item)),
[getValue],
);

const { filtered, totalMatches } = useMemo(() => {
const query = search.trim();

// Score and filter items. cmdk's fuzzy matcher can produce very low scores
// for scattered single-character matches (e.g. "vojta" matching v-o-j-t-a
// across "chore-remoVe-cOhort-Join-aTtempt"), so we require a minimum score
// to avoid noisy results.
let scored: Array<{ item: T; score: number }>;
if (query) {
scored = [];
for (const item of items) {
const score = defaultFilter(resolve(item), query);
if (score >= MIN_FUZZY_SCORE) scored.push({ item, score });
}
} else {
scored = items.map((item) => ({ item, score: 0 }));
}

const total = scored.length;

// Sort: pinned first (in order), then by score descending (stable for equal scores)
if (pinned) {
const pinnedSet = new Set(pinned);
scored.sort((a, b) => {
const aVal = resolve(a.item);
const bVal = resolve(b.item);
const aPinned = pinnedSet.has(aVal);
const bPinned = pinnedSet.has(bVal);
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
if (aPinned && bPinned) {
return pinned.indexOf(aVal) - pinned.indexOf(bVal);
}
return b.score - a.score;
});
} else if (query) {
scored.sort((a, b) => b.score - a.score);
}

return {
filtered: scored.slice(0, limit).map((s) => s.item),
totalMatches: total,
};
}, [items, search, limit, pinned, resolve]);

return {
filtered,
onSearchChange: debouncedSetSearch,
hasMore: totalMatches > filtered.length,
moreCount: Math.max(0, totalMatches - filtered.length),
};
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Combobox } from "@components/ui/combobox/Combobox";
import { GithubLogo } from "@phosphor-icons/react";
import { Button, Flex, Text } from "@radix-ui/themes";
import { useState } from "react";

interface GitHubRepoPickerProps {
value: string | null;
Expand All @@ -21,6 +22,8 @@ export function GitHubRepoPicker({
size = "1",
disabled = false,
}: GitHubRepoPickerProps) {
const [open, setOpen] = useState(false);

if (isLoading) {
return (
<Button color="gray" variant="outline" size={size} disabled>
Expand All @@ -47,6 +50,8 @@ export function GitHubRepoPicker({
<Combobox.Root
value={value ?? ""}
onValueChange={onChange}
open={open}
onOpenChange={setOpen}
size={size}
disabled={disabled}
>
Expand All @@ -58,14 +63,24 @@ export function GitHubRepoPicker({
</Text>
</Flex>
</Combobox.Trigger>
<Combobox.Content>
<Combobox.Input placeholder="Search repositories..." />
<Combobox.Empty>No repositories found.</Combobox.Empty>
{repositories.map((repo) => (
<Combobox.Item key={repo} value={repo} textValue={repo}>
{repo}
</Combobox.Item>
))}
<Combobox.Content items={repositories} limit={50}>
{({ filtered, hasMore, moreCount }) => (
<>
<Combobox.Input placeholder="Search repositories..." />
<Combobox.Empty>No repositories found.</Combobox.Empty>
{filtered.map((repo) => (
<Combobox.Item key={repo} value={repo} textValue={repo}>
{repo}
</Combobox.Item>
))}
{hasMore && (
<div className="combobox-label">
{moreCount} more {moreCount === 1 ? "repo" : "repos"} — type to
filter
</div>
)}
</>
)}
</Combobox.Content>
</Combobox.Root>
);
Expand Down
Loading
Loading