Compare commits

...

2 Commits

Author SHA1 Message Date
blink-so[bot] 3ca14f3f12 fix: update TokenSearch design to match comp
- Filter button now inside search box with border separator
- Token badges show key:value format (e.g. group:devops)
- Dropdown appears full-width below search box
- Cleaner styling matching dark theme comp
- Only show dropdown when typing filter values
2026-02-05 13:49:57 +00:00
blink-so[bot] fff27c28f5 feat: add TokenSearch component prototype
Adds a new token-based search component with:
- Dismissible filter badges
- Dropdown filter selector
- Typeahead autocomplete for filter values
- Full keyboard navigation support
- Storybook stories for Members and Workspaces patterns
2026-02-05 13:34:49 +00:00
3 changed files with 558 additions and 0 deletions
@@ -0,0 +1,179 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import {
TokenSearch,
type FilterDefinition,
type FilterToken,
} from "./TokenSearch";
const meta: Meta<typeof TokenSearch> = {
title: "components/TokenSearch",
component: TokenSearch,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TokenSearch>;
// Sample filter definitions for Members page
const memberFilters: FilterDefinition[] = [
{
key: "role",
label: "Role",
options: [
{ value: "all-perms", label: "All Perms" },
{ value: "my-role", label: "my-role" },
{ value: "create-others-workspaces", label: "CreateOthersWorkspaces" },
{ value: "create-workspace", label: "Create Workspace" },
{ value: "data-scientist", label: "Data Scientist" },
{ value: "super-admin", label: "super-admin" },
],
},
{
key: "group",
label: "Group",
options: [
{ value: "devops", label: "DevOps" },
{ value: "tinkerers", label: "Tinkerers" },
{ value: "prebuilt-workspaces", label: "Prebuilt Workspaces" },
{ value: "bruno-group", label: "bruno-group" },
{ value: "data-science", label: "Data Science" },
{ value: "tracy-test-group", label: "tracy test group" },
{ value: "some-group", label: "Some-group" },
],
},
{
key: "user",
label: "User",
options: [
{ value: "admin", label: "Admin" },
{ value: "tracy", label: "Tracy" },
{ value: "bruno", label: "Bruno" },
],
allowCustom: true,
},
];
// Sample filter definitions for Workspaces page
const workspaceFilters: FilterDefinition[] = [
{
key: "owner",
label: "Owner",
options: [
{ value: "me", label: "Me" },
{ value: "all", label: "All users" },
],
allowCustom: true,
},
{
key: "type",
label: "Type",
options: [
{ value: "workspace", label: "Workspace" },
{ value: "dev-container", label: "Dev Container" },
],
},
{
key: "status",
label: "Status",
options: [
{ value: "pending", label: "Pending" },
{ value: "starting", label: "Starting" },
{ value: "running", label: "Running" },
{ value: "stopping", label: "Stopping" },
{ value: "failed", label: "Failed" },
{ value: "canceling", label: "Canceling" },
],
},
{
key: "name",
label: "Name",
options: [],
allowCustom: true,
},
{
key: "template",
label: "Template",
options: [
{ value: "docker", label: "Docker" },
{ value: "kubernetes", label: "Kubernetes" },
{ value: "aws-ec2", label: "AWS EC2" },
{ value: "gcp-vm", label: "GCP VM" },
],
},
];
// Interactive wrapper component
const TokenSearchDemo = ({
filters,
initialTokens = [],
placeholder,
}: {
filters: FilterDefinition[];
initialTokens?: FilterToken[];
placeholder?: string;
}) => {
const [tokens, setTokens] = useState<FilterToken[]>(initialTokens);
return (
<div className="space-y-4">
<TokenSearch
filters={filters}
tokens={tokens}
onTokensChange={setTokens}
placeholder={placeholder}
/>
<div className="text-sm text-content-secondary">
<strong>Active tokens:</strong>{" "}
{tokens.length === 0
? "None"
: tokens.map((t) => `${t.key}:${t.value}`).join(", ")}
</div>
</div>
);
};
export const MembersSearch: Story = {
render: () => (
<TokenSearchDemo
filters={memberFilters}
placeholder="Search members..."
/>
),
};
export const WorkspacesSearch: Story = {
render: () => (
<TokenSearchDemo
filters={workspaceFilters}
placeholder="Search workspaces..."
/>
),
};
export const WithExistingTokens: Story = {
render: () => (
<TokenSearchDemo
filters={memberFilters}
initialTokens={[
{ key: "group", value: "devops", label: "DevOps" },
{ key: "role", value: "super-admin", label: "super-admin" },
]}
placeholder="Search members..."
/>
),
};
export const WorkspacesWithStatus: Story = {
render: () => (
<TokenSearchDemo
filters={workspaceFilters}
initialTokens={[
{ key: "status", value: "running", label: "Running" },
]}
placeholder="Search workspaces..."
/>
),
};
@@ -0,0 +1,373 @@
import { Command as CommandPrimitive } from "cmdk";
import { Badge } from "components/Badge/Badge";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "components/Command/Command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { SlidersHorizontal, Search, X, ChevronDown } from "lucide-react";
import {
type FC,
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
// Types for filter tokens
export interface FilterOption {
value: string;
label: string;
icon?: ReactNode;
}
export interface FilterDefinition {
key: string;
label: string;
options: FilterOption[];
/** Allows typing custom values not in options list */
allowCustom?: boolean;
}
export interface FilterToken {
key: string;
value: string;
label: string;
}
interface TokenSearchProps {
/** Available filter definitions */
filters: FilterDefinition[];
/** Currently applied filter tokens */
tokens: FilterToken[];
/** Callback when tokens change */
onTokensChange: (tokens: FilterToken[]) => void;
/** Placeholder text for the search input */
placeholder?: string;
/** Optional free-text search value */
searchValue?: string;
/** Callback for free-text search changes */
onSearchChange?: (value: string) => void;
/** Custom class name */
className?: string;
}
type SuggestionMode = "filters" | "values";
export const TokenSearch: FC<TokenSearchProps> = ({
filters,
tokens,
onTokensChange,
placeholder = "Search...",
searchValue = "",
onSearchChange,
className,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>("filters");
const [activeFilterKey, setActiveFilterKey] = useState<string | null>(null);
// Parse input to detect if user is typing a filter key
const parseInput = useCallback((value: string) => {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
return { filterKey: null, filterValue: value };
}
return {
filterKey: value.slice(0, colonIndex).toLowerCase(),
filterValue: value.slice(colonIndex + 1),
};
}, []);
// Get current parsing state
const { filterKey, filterValue } = useMemo(
() => parseInput(inputValue),
[inputValue, parseInput],
);
// Find matching filter definition
const matchedFilter = useMemo(() => {
if (!filterKey) return null;
return filters.find(
(f) =>
f.key.toLowerCase() === filterKey ||
f.label.toLowerCase() === filterKey,
);
}, [filterKey, filters]);
// Update suggestion mode based on input
useEffect(() => {
if (matchedFilter) {
setSuggestionMode("values");
setActiveFilterKey(matchedFilter.key);
} else if (filterKey && filterKey.length > 0) {
// Typing a potential filter key, show matching filters
setSuggestionMode("filters");
setActiveFilterKey(null);
} else {
setSuggestionMode("filters");
setActiveFilterKey(null);
}
}, [matchedFilter, filterKey]);
// Filter suggestions based on current mode and input
const suggestions = useMemo((): FilterOption[] => {
if (suggestionMode === "values" && matchedFilter) {
const searchTerm = filterValue.toLowerCase();
return matchedFilter.options.filter(
(opt) =>
opt.label.toLowerCase().includes(searchTerm) ||
opt.value.toLowerCase().includes(searchTerm),
);
}
// Show filter keys
const searchTerm = inputValue.toLowerCase();
return filters
.filter(
(f) =>
f.key.toLowerCase().includes(searchTerm) ||
f.label.toLowerCase().includes(searchTerm),
)
.map((f): FilterOption => ({
value: f.key,
label: f.label,
}));
}, [suggestionMode, matchedFilter, filterValue, inputValue, filters]);
// Handle selecting a filter key from dropdown
const handleFilterSelect = useCallback((filter: FilterDefinition) => {
setInputValue(`${filter.key}:`);
setSuggestionMode("values");
setActiveFilterKey(filter.key);
setIsOpen(true);
inputRef.current?.focus();
}, []);
// Handle selecting a value (completes the token)
const handleValueSelect = useCallback(
(option: FilterOption) => {
if (!activeFilterKey) return;
const filter = filters.find((f) => f.key === activeFilterKey);
if (!filter) return;
const newToken: FilterToken = {
key: activeFilterKey,
value: option.value,
label: option.label,
};
// Replace existing token with same key or add new one
const existingIndex = tokens.findIndex((t) => t.key === activeFilterKey);
let newTokens: FilterToken[];
if (existingIndex >= 0) {
newTokens = [...tokens];
newTokens[existingIndex] = newToken;
} else {
newTokens = [...tokens, newToken];
}
onTokensChange(newTokens);
setInputValue("");
setSuggestionMode("filters");
setActiveFilterKey(null);
setIsOpen(false);
inputRef.current?.focus();
},
[activeFilterKey, filters, tokens, onTokensChange],
);
// Handle selecting a suggestion (could be filter or value)
const handleSuggestionSelect = useCallback(
(suggestion: FilterOption) => {
if (suggestionMode === "filters") {
const filter = filters.find((f) => f.key === suggestion.value);
if (filter) {
handleFilterSelect(filter);
}
} else {
handleValueSelect(suggestion);
}
},
[suggestionMode, filters, handleFilterSelect, handleValueSelect],
);
// Remove a token
const handleRemoveToken = useCallback(
(tokenKey: string) => {
onTokensChange(tokens.filter((t) => t.key !== tokenKey));
inputRef.current?.focus();
},
[tokens, onTokensChange],
);
// Handle keyboard events
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// Backspace on empty input removes last token
if (e.key === "Backspace" && inputValue === "" && tokens.length > 0) {
const lastToken = tokens[tokens.length - 1];
handleRemoveToken(lastToken.key);
return;
}
// Tab to autocomplete current suggestion
if (e.key === "Tab" && suggestions.length > 0 && isOpen) {
e.preventDefault();
handleSuggestionSelect(suggestions[0]);
return;
}
// Escape to close suggestions
if (e.key === "Escape") {
setIsOpen(false);
return;
}
},
[inputValue, tokens, suggestions, isOpen, handleRemoveToken, handleSuggestionSelect],
);
// Handle input change
const handleInputChange = useCallback((value: string) => {
setInputValue(value);
setIsOpen(true);
}, []);
// Handle focus
const handleFocus = useCallback(() => {
setIsOpen(true);
}, []);
// Handle blur (with delay to allow click on suggestions)
const handleBlur = useCallback(() => {
setTimeout(() => {
setIsOpen(false);
}, 150);
}, []);
return (
<div className={cn("relative w-full", className)} ref={containerRef}>
{/* Main search container */}
<div
className={cn(
"flex items-center rounded-md border border-solid border-border bg-surface-primary",
"focus-within:border-border-hover",
"transition-colors",
)}
onClick={() => inputRef.current?.focus()}
>
{/* Left section: search icon + tokens + input */}
<div className="flex items-center gap-2 flex-1 px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-content-secondary" />
{/* Tokens */}
{tokens.map((token) => (
<Badge
key={token.key}
variant="default"
className="gap-1.5 pr-1.5 font-normal bg-surface-secondary text-content-primary text-sm"
>
<span>{token.key}:{token.value}</span>
<button
type="button"
className="rounded-sm hover:bg-surface-tertiary transition-colors p-0.5"
onClick={(e) => {
e.stopPropagation();
handleRemoveToken(token.key);
}}
aria-label={`Remove ${token.key} filter`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* Input */}
<input
ref={inputRef}
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={tokens.length === 0 ? placeholder : ""}
className="flex-1 min-w-[100px] bg-transparent border-none outline-none text-sm text-content-primary placeholder:text-content-secondary"
aria-label="Search with filters"
/>
</div>
{/* Right section: Filter dropdown button */}
<div className="border-l border-solid border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-2 px-3 py-2 h-full",
"text-content-secondary hover:text-content-primary hover:bg-surface-secondary",
"transition-colors text-sm",
)}
>
<SlidersHorizontal className="h-4 w-4" />
<span>Filter</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
{filters.map((filter) => (
<DropdownMenuItem
key={filter.key}
onClick={() => handleFilterSelect(filter)}
className="text-sm"
>
{filter.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Suggestions dropdown - full width below search box */}
{isOpen && suggestions.length > 0 && suggestionMode === "values" && (
<div
className={cn(
"absolute left-0 right-0 top-full mt-1 z-50",
"rounded-md border border-solid border-border bg-surface-primary shadow-lg",
"overflow-hidden",
)}
>
<Command shouldFilter={false}>
<CommandList className="max-h-[280px] overflow-y-auto py-2">
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion.value}
onSelect={() => handleSuggestionSelect(suggestion)}
className="cursor-pointer px-4 py-2.5 text-sm text-content-primary hover:bg-surface-secondary mx-0 rounded-none"
>
{suggestion.icon}
<span>{suggestion.label}</span>
</CommandItem>
))}
</CommandList>
</Command>
</div>
)}
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
export { TokenSearch } from "./TokenSearch";
export type {
FilterDefinition,
FilterOption,
FilterToken,
} from "./TokenSearch";