audit log, character list
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { CircleX } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Blocker({
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
expandAllFeature,
|
||||
hotkeysCoreFeature,
|
||||
searchFeature,
|
||||
selectionFeature,
|
||||
syncDataLoaderFeature,
|
||||
type TreeState,
|
||||
} from "@headless-tree/core";
|
||||
import { useTree } from "@headless-tree/react";
|
||||
import {
|
||||
CircleXIcon,
|
||||
FilterIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Tree, TreeItem, TreeItemLabel } from "@/components/tree";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
const items: Record<string, Item> = {
|
||||
company: {
|
||||
name: "Company",
|
||||
children: ["engineering", "marketing", "operations"],
|
||||
},
|
||||
engineering: {
|
||||
name: "Engineering",
|
||||
children: ["frontend", "backend", "platform-team"],
|
||||
},
|
||||
frontend: { name: "Frontend", children: ["design-system", "web-platform"] },
|
||||
"design-system": {
|
||||
name: "Design System",
|
||||
children: ["components", "tokens", "guidelines"],
|
||||
},
|
||||
components: { name: "Components" },
|
||||
tokens: { name: "Tokens" },
|
||||
guidelines: { name: "Guidelines" },
|
||||
"web-platform": { name: "Web Platform" },
|
||||
backend: { name: "Backend", children: ["apis", "infrastructure"] },
|
||||
apis: { name: "APIs" },
|
||||
infrastructure: { name: "Infrastructure" },
|
||||
"platform-team": { name: "Platform Team" },
|
||||
marketing: { name: "Marketing", children: ["content", "seo"] },
|
||||
content: { name: "Content" },
|
||||
seo: { name: "SEO" },
|
||||
operations: { name: "Operations", children: ["hr", "finance"] },
|
||||
hr: { name: "HR" },
|
||||
finance: { name: "Finance" },
|
||||
};
|
||||
|
||||
const indent = 20;
|
||||
|
||||
export default function Component() {
|
||||
// Store the initial expanded items to reset when search is cleared
|
||||
const initialExpandedItems = ["engineering", "frontend", "design-system"];
|
||||
const [state, setState] = useState<Partial<TreeState<Item>>>({});
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const tree = useTree<Item>({
|
||||
state,
|
||||
setState,
|
||||
initialState: {
|
||||
expandedItems: initialExpandedItems,
|
||||
},
|
||||
indent,
|
||||
rootItemId: "company",
|
||||
getItemName: (item) => item.getItemData().name,
|
||||
isItemFolder: (item) => (item.getItemData()?.children?.length ?? 0) > 0,
|
||||
dataLoader: {
|
||||
getItem: (itemId) => items[itemId],
|
||||
getChildren: (itemId) => items[itemId].children ?? [],
|
||||
},
|
||||
features: [
|
||||
syncDataLoaderFeature,
|
||||
hotkeysCoreFeature,
|
||||
selectionFeature,
|
||||
searchFeature,
|
||||
expandAllFeature,
|
||||
],
|
||||
});
|
||||
|
||||
// Handle clearing the search
|
||||
const handleClearSearch = () => {
|
||||
setSearchValue("");
|
||||
|
||||
// Manually trigger the tree's search onChange with an empty value
|
||||
// to ensure item.isMatchingSearch() is correctly updated.
|
||||
const searchProps = tree.getSearchInputElementProps();
|
||||
if (searchProps.onChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: "" },
|
||||
} as React.ChangeEvent<HTMLInputElement>; // Cast to the expected event type
|
||||
searchProps.onChange(syntheticEvent);
|
||||
}
|
||||
|
||||
// Reset tree state to initial expanded items
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
expandedItems: initialExpandedItems,
|
||||
}));
|
||||
|
||||
// Clear custom filtered items
|
||||
setFilteredItems([]);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
// Also clear the internal search input
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Keep track of filtered items separately from the tree's internal search state
|
||||
const [filteredItems, setFilteredItems] = useState<string[]>([]);
|
||||
|
||||
// This function determines if an item should be visible based on our custom filtering
|
||||
const shouldShowItem = (itemId: string) => {
|
||||
if (!searchValue || searchValue.length === 0) return true;
|
||||
return filteredItems.includes(itemId);
|
||||
};
|
||||
|
||||
// Update filtered items when search value changes
|
||||
useEffect(() => {
|
||||
if (!searchValue || searchValue.length === 0) {
|
||||
setFilteredItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all items
|
||||
const allItems = tree.getItems();
|
||||
|
||||
// First, find direct matches
|
||||
const directMatches = allItems
|
||||
.filter((item) => {
|
||||
const name = item.getItemName().toLowerCase();
|
||||
return name.includes(searchValue.toLowerCase());
|
||||
})
|
||||
.map((item) => item.getId());
|
||||
|
||||
// Then, find all parent IDs of matching items
|
||||
const parentIds = new Set<string>();
|
||||
directMatches.forEach((matchId) => {
|
||||
let item = tree.getItems().find((i) => i.getId() === matchId);
|
||||
while (item?.getParent?.()) {
|
||||
const parent = item.getParent();
|
||||
if (parent) {
|
||||
parentIds.add(parent.getId());
|
||||
item = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find all children of matching items
|
||||
const childrenIds = new Set<string>();
|
||||
directMatches.forEach((matchId) => {
|
||||
const item = tree.getItems().find((i) => i.getId() === matchId);
|
||||
if (item?.isFolder()) {
|
||||
// Get all descendants recursively
|
||||
const getDescendants = (itemId: string) => {
|
||||
const children = items[itemId]?.children || [];
|
||||
children.forEach((childId) => {
|
||||
childrenIds.add(childId);
|
||||
if (items[childId]?.children?.length) {
|
||||
getDescendants(childId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
getDescendants(item.getId());
|
||||
}
|
||||
});
|
||||
|
||||
// Combine direct matches, parents, and children
|
||||
setFilteredItems([
|
||||
...directMatches,
|
||||
...Array.from(parentIds),
|
||||
...Array.from(childrenIds),
|
||||
]);
|
||||
|
||||
// Keep all folders expanded during search to ensure all matches are visible
|
||||
// Store current expanded state first
|
||||
const currentExpandedItems = tree.getState().expandedItems || [];
|
||||
|
||||
// Get all folder IDs that need to be expanded to show matches
|
||||
const folderIdsToExpand = allItems
|
||||
.filter((item) => item.isFolder())
|
||||
.map((item) => item.getId());
|
||||
|
||||
// Update expanded items in the tree state
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
expandedItems: [
|
||||
...new Set([...currentExpandedItems, ...folderIdsToExpand]),
|
||||
],
|
||||
}));
|
||||
}, [searchValue, tree]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 *:nth-2:grow">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="peer ps-9"
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
|
||||
// Apply the search to the tree's internal state as well
|
||||
const searchProps = tree.getSearchInputElementProps();
|
||||
if (searchProps.onChange) {
|
||||
searchProps.onChange(e);
|
||||
}
|
||||
|
||||
if (value.length > 0) {
|
||||
// If input has at least one character, expand all items
|
||||
tree.expandAll();
|
||||
} else {
|
||||
// If input is cleared, reset to initial expanded state
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
expandedItems: initialExpandedItems,
|
||||
}));
|
||||
setFilteredItems([]);
|
||||
}
|
||||
}}
|
||||
// Prevent the internal search from being cleared on blur
|
||||
onBlur={(e) => {
|
||||
// Prevent default blur behavior
|
||||
e.preventDefault();
|
||||
|
||||
// Re-apply the search to ensure it stays active
|
||||
if (searchValue && searchValue.length > 0) {
|
||||
const searchProps = tree.getSearchInputElementProps();
|
||||
if (searchProps.onChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: searchValue },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
searchProps.onChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="search"
|
||||
placeholder="Filter items..."
|
||||
/>
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 peer-disabled:opacity-50">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
{searchValue && (
|
||||
<button
|
||||
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Clear search"
|
||||
onClick={handleClearSearch}
|
||||
type="button"
|
||||
>
|
||||
<CircleXIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tree indent={indent} tree={tree}>
|
||||
{searchValue && filteredItems.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-sm">
|
||||
No items found for "{searchValue}"
|
||||
</p>
|
||||
) : (
|
||||
tree.getItems().map((item) => {
|
||||
const isVisible = shouldShowItem(item.getId());
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
key={item.getId()}
|
||||
item={item}
|
||||
data-visible={isVisible || !searchValue}
|
||||
className="data-[visible=false]:hidden"
|
||||
>
|
||||
<TreeItemLabel>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.isFolder() &&
|
||||
(item.isExpanded() ? (
|
||||
<FolderOpenIcon className="text-muted-foreground pointer-events-none size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground pointer-events-none size-4" />
|
||||
))}
|
||||
{item.getItemName()}
|
||||
</span>
|
||||
</TreeItemLabel>
|
||||
</TreeItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string; start: number; end: number }
|
||||
| { type: "filter"; key: string; value?: string; start: number; end: number };
|
||||
|
||||
interface InputContext {
|
||||
mode: "text" | "filterKey" | "filterValue";
|
||||
activeFilter?: string;
|
||||
token?: Token;
|
||||
}
|
||||
|
||||
export interface ParsedQuery {
|
||||
search: string;
|
||||
filters: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Split text into tokens and track their ranges */
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
const parts = input.split(/(\s+)/);
|
||||
for (const part of parts) {
|
||||
const start = cursor;
|
||||
const end = start + part.length;
|
||||
cursor = end;
|
||||
|
||||
if (!part.trim()) continue;
|
||||
|
||||
const colonIndex = part.indexOf(":");
|
||||
if (colonIndex !== -1) {
|
||||
const key = part.slice(0, colonIndex);
|
||||
const val = part.slice(colonIndex + 1);
|
||||
tokens.push({
|
||||
type: "filter",
|
||||
key,
|
||||
value: val || undefined,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
} else {
|
||||
tokens.push({ type: "text", value: part, start, end });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Parse query into search text and filters */
|
||||
function parseQuery(input: string): ParsedQuery {
|
||||
const tokens = tokenize(input);
|
||||
const searchParts: string[] = [];
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === "filter" && token.value) {
|
||||
filters[token.key] = token.value;
|
||||
} else if (token.type === "text") {
|
||||
searchParts.push(token.value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
search: searchParts.join(" ").trim(),
|
||||
filters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Determine which token and mode the caret is inside */
|
||||
function detectContext(input: string, caret: number): InputContext {
|
||||
const tokens = tokenize(input);
|
||||
|
||||
// Find token at caret position
|
||||
const token = tokens.find((t) => caret >= t.start && caret <= t.end);
|
||||
|
||||
if (!token) {
|
||||
// Check if we're right after a token
|
||||
const prevToken = tokens.find((t) => t.end === caret);
|
||||
if (prevToken?.type === "filter" && !prevToken.value) {
|
||||
return {
|
||||
mode: "filterValue",
|
||||
activeFilter: prevToken.key,
|
||||
token: prevToken,
|
||||
};
|
||||
}
|
||||
return { mode: "text" };
|
||||
}
|
||||
|
||||
if (token.type === "filter") {
|
||||
const colonPos = input.indexOf(":", token.start);
|
||||
if (colonPos === -1 || caret <= colonPos) {
|
||||
return { mode: "filterKey", token };
|
||||
} else {
|
||||
return { mode: "filterValue", activeFilter: token.key, token };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text token looks like it's starting a filter
|
||||
if (token.type === "text" && token.value.endsWith(":")) {
|
||||
const key = token.value.slice(0, -1);
|
||||
return { mode: "filterValue", activeFilter: key, token };
|
||||
}
|
||||
|
||||
return { mode: "filterKey", token };
|
||||
}
|
||||
|
||||
export type Filter = () => Promise<string[]> | string[];
|
||||
export type Filters = Record<string, Filter>;
|
||||
|
||||
/** Filter colors for highlighting */
|
||||
const filterColors: string[] = [
|
||||
"bg-blue-700 text-blue-100",
|
||||
"bg-purple-700 text-purple-100",
|
||||
"bg-green-700 text-green-100",
|
||||
"bg-orange-700 text-orange-100",
|
||||
"bg-yellow-700 text-yellow-100",
|
||||
"bg-red-700 text-red-100",
|
||||
];
|
||||
|
||||
/** Get suggestions based on caret context */
|
||||
async function getSuggestions(input: string, caret: number, filters: Filters) {
|
||||
const ctx = detectContext(input, caret);
|
||||
|
||||
if (ctx.mode === "filterKey") {
|
||||
const partial =
|
||||
ctx.token?.type === "text" ? ctx.token.value.toLowerCase() : "";
|
||||
return Object.keys(filters)
|
||||
.filter((f) => f.startsWith(partial))
|
||||
.map((f) => `${f}:`);
|
||||
}
|
||||
|
||||
if (ctx.mode === "filterValue" && ctx.activeFilter) {
|
||||
const source = filters[ctx.activeFilter];
|
||||
if (source) {
|
||||
const values = await source();
|
||||
const partial =
|
||||
ctx.token?.type === "filter" && ctx.token.value
|
||||
? ctx.token.value.toLowerCase()
|
||||
: "";
|
||||
return values.filter((v) => v.toLowerCase().includes(partial));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function SearchBox({
|
||||
onChange = () => {}, // Prevent react screaming about undefined dependencies
|
||||
onQueryChange = () => {},
|
||||
filters,
|
||||
value = "",
|
||||
className,
|
||||
}: {
|
||||
onChange?: (value: string) => void;
|
||||
onQueryChange?: (value: ParsedQuery) => void;
|
||||
className?: string;
|
||||
filters: Filters;
|
||||
value?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState(value);
|
||||
|
||||
useEffect(() => onChange?.(query), [onChange, query]);
|
||||
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [parsedQuery, setParsedQuery] = useState<ParsedQuery>({
|
||||
search: "",
|
||||
filters: {},
|
||||
});
|
||||
|
||||
useEffect(() => onQueryChange?.(parsedQuery), [onQueryChange, parsedQuery]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function updateSuggestions(newValue: string, caretPos?: number) {
|
||||
const caret =
|
||||
caretPos ?? inputRef.current?.selectionStart ?? newValue.length;
|
||||
setSuggestionLoading(true);
|
||||
const items = await getSuggestions(newValue, caret, filters).finally(() =>
|
||||
setSuggestionLoading(false),
|
||||
);
|
||||
setSuggestions(items);
|
||||
setHighlight(0);
|
||||
setIsOpen(items.length > 0);
|
||||
setParsedQuery(parseQuery(newValue));
|
||||
}
|
||||
|
||||
function applySuggestion(s: string) {
|
||||
const caret = inputRef.current?.selectionStart ?? query.length;
|
||||
const ctx = detectContext(query, caret);
|
||||
|
||||
if (!ctx.token) {
|
||||
setQuery(`${query}${s} `);
|
||||
setIsOpen(false);
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const before = query.slice(0, ctx.token.start);
|
||||
const after = query.slice(ctx.token.end);
|
||||
let replacement = "";
|
||||
|
||||
if (ctx.mode === "filterKey") {
|
||||
replacement = s;
|
||||
} else if (ctx.mode === "filterValue") {
|
||||
replacement = `${ctx.activeFilter}:${s}`;
|
||||
}
|
||||
|
||||
const newQuery = `${before}${replacement}${after}`.trim();
|
||||
setQuery(`${newQuery} `);
|
||||
setIsOpen(false);
|
||||
setParsedQuery(parseQuery(`${newQuery} `));
|
||||
|
||||
// Focus back and move caret to end
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
const newPos = before.length + replacement.length + 1;
|
||||
inputRef.current?.setSelectionRange(newPos, newPos);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Render highlighted input
|
||||
function renderHighlightedInput() {
|
||||
const tokens = tokenize(query);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastEnd = 0;
|
||||
|
||||
tokens.forEach((token, i) => {
|
||||
const idx = i;
|
||||
// Add any text before this token
|
||||
if (token.start > lastEnd) {
|
||||
parts.push(
|
||||
<span key={`gap-${idx}`}>{query.slice(lastEnd, token.start)}</span>,
|
||||
);
|
||||
}
|
||||
|
||||
// Add the token
|
||||
if (token.type === "filter" && token.value) {
|
||||
const filterId = Object.keys(filters).indexOf(token.key);
|
||||
const colorClass =
|
||||
filterId < 0
|
||||
? "text-gray-500"
|
||||
: filterColors[filterId % filterColors.length];
|
||||
parts.push(
|
||||
<span key={`token-${idx}`} className={`${colorClass} rounded`}>
|
||||
{query.slice(token.start, token.end)}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<span key={`token-${idx}`}>
|
||||
{query.slice(token.start, token.end)}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
lastEnd = token.end;
|
||||
});
|
||||
|
||||
// Add any remaining text
|
||||
if (lastEnd < query.length) {
|
||||
parts.push(<span key="end">{query.slice(lastEnd)}</span>);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 z-10" />
|
||||
|
||||
{/* Highlighted overlay */}
|
||||
<div className="absolute inset-0 flex items-center pl-9 pr-9 pointer-events-none overflow-hidden">
|
||||
<div className="whitespace-pre text-sm">
|
||||
{renderHighlightedInput()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual input (transparent text) */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
updateSuggestions(e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setIsOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
updateSuggestions(query);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && suggestions[highlight]) {
|
||||
e.preventDefault();
|
||||
applySuggestion(suggestions[highlight]);
|
||||
} else if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === "Tab" && suggestions[highlight]) {
|
||||
e.preventDefault();
|
||||
applySuggestion(suggestions[highlight]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative w-full h-10 pl-9 pr-9 rounded-md border bg-transparent text-sm text-transparent caret-black ring-offset-gray-400 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
placeholder={`Search...${Object.keys(filters).length ? " Try: " : ""}${Object.keys(
|
||||
filters,
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((f) => `${f}:`)
|
||||
.join(" ")}`}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
setIsOpen(false);
|
||||
setParsedQuery({ search: "", filters: {} });
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors z-10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (suggestions.length > 0 || suggestionLoading) && (
|
||||
<div className="absolute z-50 w-full mt-1 rounded-md border bg-card shadow-lg overflow-hidden">
|
||||
<ul className="max-h-64 overflow-auto py-1">
|
||||
{suggestionLoading ? (
|
||||
<li className="relative flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm outline-none mx-1 hover:brightness-125 gap-2">
|
||||
<Spinner /> Loading...
|
||||
</li>
|
||||
) : (
|
||||
suggestions.map((s, i) => (
|
||||
<li
|
||||
key={`${s}-${i + 1}`}
|
||||
onClick={() => applySuggestion(s)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
className={`
|
||||
relative flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm outline-none mx-1
|
||||
${i === highlight ? "bg-muted" : "hover:brightness-125"}
|
||||
`}
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ItemInstance } from "@headless-tree/core";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TreeContextValue<T = any> {
|
||||
indent: number;
|
||||
currentItem?: ItemInstance<T>;
|
||||
tree?: any;
|
||||
}
|
||||
|
||||
const TreeContext = React.createContext<TreeContextValue>({
|
||||
indent: 20,
|
||||
currentItem: undefined,
|
||||
tree: undefined,
|
||||
});
|
||||
|
||||
function useTreeContext<T = any>() {
|
||||
return React.useContext(TreeContext) as TreeContextValue<T>;
|
||||
}
|
||||
|
||||
interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
indent?: number;
|
||||
tree?: any;
|
||||
}
|
||||
|
||||
function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
|
||||
const containerProps =
|
||||
tree && typeof tree.getContainerProps === "function"
|
||||
? tree.getContainerProps()
|
||||
: {};
|
||||
const mergedProps = { ...props, ...containerProps };
|
||||
|
||||
// Extract style from mergedProps to merge with our custom styles
|
||||
const { style: propStyle, ...otherProps } = mergedProps;
|
||||
|
||||
// Merge styles
|
||||
const mergedStyle = {
|
||||
...propStyle,
|
||||
"--tree-indent": `${indent}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<TreeContext.Provider value={{ indent, tree }}>
|
||||
<div
|
||||
data-slot="tree"
|
||||
style={mergedStyle}
|
||||
className={cn("flex flex-col", className)}
|
||||
{...otherProps}
|
||||
/>
|
||||
</TreeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeItemProps<T = any>
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
item: ItemInstance<T>;
|
||||
indent?: number;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function TreeItem<T = any>({
|
||||
item,
|
||||
className,
|
||||
asChild,
|
||||
children,
|
||||
...props
|
||||
}: Omit<TreeItemProps<T>, "indent">) {
|
||||
const { indent } = useTreeContext<T>();
|
||||
|
||||
const itemProps = typeof item.getProps === "function" ? item.getProps() : {};
|
||||
const mergedProps = { ...props, ...itemProps };
|
||||
|
||||
// Extract style from mergedProps to merge with our custom styles
|
||||
const { style: propStyle, ...otherProps } = mergedProps;
|
||||
|
||||
// Merge styles
|
||||
const mergedStyle = {
|
||||
...propStyle,
|
||||
"--tree-padding": `${item.getItemMeta().level * indent}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<TreeContext.Provider value={{ indent, currentItem: item }}>
|
||||
<Comp
|
||||
data-slot="tree-item"
|
||||
style={mergedStyle}
|
||||
className={cn(
|
||||
"z-10 ps-(--tree-padding) outline-hidden select-none not-last:pb-0.5 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-focus={
|
||||
typeof item.isFocused === "function"
|
||||
? item.isFocused() || false
|
||||
: undefined
|
||||
}
|
||||
data-folder={
|
||||
typeof item.isFolder === "function"
|
||||
? item.isFolder() || false
|
||||
: undefined
|
||||
}
|
||||
data-selected={
|
||||
typeof item.isSelected === "function"
|
||||
? item.isSelected() || false
|
||||
: undefined
|
||||
}
|
||||
data-drag-target={
|
||||
typeof item.isDragTarget === "function"
|
||||
? item.isDragTarget() || false
|
||||
: undefined
|
||||
}
|
||||
data-search-match={
|
||||
typeof item.isMatchingSearch === "function"
|
||||
? item.isMatchingSearch() || false
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={item.isExpanded()}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
</TreeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeItemLabelProps<T = any>
|
||||
extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
item?: ItemInstance<T>;
|
||||
}
|
||||
|
||||
function TreeItemLabel<T = any>({
|
||||
item: propItem,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TreeItemLabelProps<T>) {
|
||||
const { currentItem } = useTreeContext<T>();
|
||||
const item = propItem || currentItem;
|
||||
|
||||
if (!item) {
|
||||
console.warn("TreeItemLabel: No item provided via props or context");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="tree-item-label"
|
||||
className={cn(
|
||||
"in-focus-visible:ring-ring/50 bg-background hover:bg-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-foreground in-data-[drag-target=true]:bg-accent flex items-center gap-1 rounded-sm px-2 py-1.5 text-sm transition-colors not-in-data-[folder=true]:ps-7 in-focus-visible:ring-[3px] in-data-[search-match=true]:bg-blue-400/20! [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{item.isFolder() && (
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 in-aria-[expanded=false]:-rotate-90" />
|
||||
)}
|
||||
{children ||
|
||||
(typeof item.getItemName === "function" ? item.getItemName() : null)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeDragLine({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { tree } = useTreeContext();
|
||||
|
||||
if (!tree || typeof tree.getDragLineStyle !== "function") {
|
||||
console.warn(
|
||||
"TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const dragLine = tree.getDragLineStyle();
|
||||
return (
|
||||
<div
|
||||
style={dragLine}
|
||||
className={cn(
|
||||
"bg-primary before:bg-background before:border-primary absolute z-30 -mt-px h-0.5 w-[unset] before:absolute before:-top-[3px] before:left-0 before:size-2 before:rounded-full before:border-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tree, TreeItem, TreeItemLabel, TreeDragLine };
|
||||
Reference in New Issue
Block a user