commit09bed34946Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 8 14:03:50 2022 +0100 Fixed the layout of a search list item commit90126d43adAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 17:25:13 2022 +0100 Insanely complicated path search results highlighitng and truncating commita4bbd5521eAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 14:05:01 2022 +0100 Improved the title editing in the header commit5814053968Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:38:33 2022 +0100 Improved text highlight colour commit110de37242Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:36:57 2022 +0100 Icon hover state now uses isHighlighted commitfb6da943d9Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:31:11 2022 +0100 Light mode styling commit2c378d5158Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 12:24:43 2022 +0100 Spacing between search items no longer a hack commit85d90bb0aeAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:19:10 2022 +0100 Added ellipsis to search matches if they are windowed commit91f3e70676Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:08:00 2022 +0100 Fixed search item icons commite384c87c6fAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:52:58 2022 +0100 Improved hover state commitf4681edc07Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:47:33 2022 +0100 Removed focus state on button commitfaa2d0663cAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:42 2022 +0100 made the search palette a little bigger commitdf8bd521feAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:14 2022 +0100 Remove onOverlayClick prop commitecd046b3b2Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:18:46 2022 +0100 Close search when the overlay is clicked commitfba330b5d9Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:10:35 2022 +0100 hitting esc when search input is focused and empty should close the search commitfc2fe8ae6fAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:00:08 2022 +0100 Highlight the window of the best search match in the search results commit45193ec92cAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 10:45:01 2022 +0100 Now you can arrow down to the last item in the list commit0de5fef7e9Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 10:37:20 2022 +0100 Added a hacky margin between items and remove the transition to make it feel snappier commit167b548343Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 09:30:33 2022 +0100 WIP implementing search commit5655631f12Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Mar 25 15:28:19 2022 +0000 Build and styled search modal commite368db81c8Author: Eric Allam <eallam@icloud.com> Date: Fri Mar 25 14:42:03 2022 +0000 Implement JSON search through fuse.js + web worker + react hook commit8caa11ba1cAuthor: Eric Allam <eallam@icloud.com> Date: Wed Mar 23 10:21:33 2022 +0000 Start of the Command Palette using radix-ui Dialog commit904b64d781Author: Eric Allam <eallam@icloud.com> Date: Wed Mar 23 09:58:52 2022 +0000 Uncommented out the search bar field
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch";
|
|
import {
|
|
ChevronRightIcon,
|
|
ExclamationIcon,
|
|
SearchIcon,
|
|
} from "@heroicons/react/outline";
|
|
import { EscapeKeyIcon } from "./Icons/EscapeKeyIcon";
|
|
import { ArrowKeysUpDownIcon } from "./Icons/ArrowKeysUpDownIcon";
|
|
import { LoadingIcon } from "./Icons/LoadingIcon";
|
|
import { Body } from "./Primitives/Body";
|
|
import { ShortcutIcon } from "./Icons/ShortcutIcon";
|
|
import { Mono } from "./Primitives/Mono";
|
|
import {
|
|
useCombobox,
|
|
UseComboboxState,
|
|
UseComboboxStateChangeOptions,
|
|
} from "downshift";
|
|
import {
|
|
getComponentSlices,
|
|
getStringSlices,
|
|
JsonSearchEntry,
|
|
} from "~/utilities/search";
|
|
import Fuse from "fuse.js";
|
|
import classnames from "~/utilities/classnames";
|
|
import { iconForValue } from "~/utilities/icons";
|
|
import { useRef, useCallback } from "react";
|
|
import { useVirtual } from "react-virtual";
|
|
import { sortedLastIndex, truncate } from "lodash-es";
|
|
import { JSONHeroPath } from "@jsonhero/path";
|
|
import { useHotkeys } from "react-hotkeys-hook";
|
|
import { useJson } from "~/hooks/useJson";
|
|
|
|
export function SearchPalette({
|
|
onSelect,
|
|
onClose,
|
|
}: {
|
|
onSelect?: (entry: JsonSearchEntry) => void;
|
|
onClose?: () => void;
|
|
}) {
|
|
const searchState = useJsonSearchState();
|
|
const searchApi = useJsonSearchApi();
|
|
|
|
useHotkeys(
|
|
"esc",
|
|
(e) => {
|
|
e.preventDefault();
|
|
searchApi.reset();
|
|
onClose?.();
|
|
},
|
|
[onClose]
|
|
);
|
|
|
|
const listRef = useRef<HTMLElement>(null);
|
|
|
|
const rowVirtualizer = useVirtual({
|
|
size: (searchState.results ?? []).length,
|
|
parentRef: listRef,
|
|
estimateSize: useCallback(() => 70, []),
|
|
overscan: 6,
|
|
});
|
|
|
|
function comboboxReducer(
|
|
state: UseComboboxState<Fuse.FuseResult<JsonSearchEntry>>,
|
|
actionAndChanges: UseComboboxStateChangeOptions<
|
|
Fuse.FuseResult<JsonSearchEntry>
|
|
>
|
|
): Partial<UseComboboxState<Fuse.FuseResult<JsonSearchEntry>>> {
|
|
const { changes, ...action } = actionAndChanges;
|
|
|
|
// Don't update the input field when selecting an item
|
|
switch (action.type) {
|
|
case useCombobox.stateChangeTypes.ItemClick:
|
|
case useCombobox.stateChangeTypes.InputKeyDownEnter: {
|
|
return {
|
|
...changes,
|
|
inputValue: state.inputValue,
|
|
};
|
|
}
|
|
default:
|
|
return changes;
|
|
}
|
|
}
|
|
|
|
const cb = useCombobox({
|
|
items: searchState.results ?? [],
|
|
stateReducer: comboboxReducer,
|
|
circularNavigation: false,
|
|
scrollIntoView: () => {},
|
|
onSelectedItemChange: ({ selectedItem }) => {
|
|
if (selectedItem) {
|
|
onSelect?.(selectedItem.item);
|
|
searchApi.reset();
|
|
}
|
|
},
|
|
onHighlightedIndexChange: ({ highlightedIndex }) =>
|
|
highlightedIndex && rowVirtualizer.scrollToIndex(highlightedIndex),
|
|
onInputValueChange: ({ inputValue }) =>
|
|
inputValue ? searchApi.search(inputValue) : searchApi.reset(),
|
|
});
|
|
|
|
const handleInputKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Escape" && onClose && cb.inputValue.length === 0) {
|
|
searchApi.reset();
|
|
onClose?.();
|
|
}
|
|
},
|
|
[onClose, cb.inputValue]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
{...cb.getComboboxProps()}
|
|
className="max-h-[60vh] px-4 pt-4 overflow-hidden"
|
|
>
|
|
<label
|
|
{...cb.getLabelProps()}
|
|
className="relative text-slate-400 focus-within:text-slate-600 block"
|
|
>
|
|
<SearchIcon className="absolute w-7 h-7 top-1/2 transform -translate-y-1/2 left-3 text-slate-700 transition dark:text-white pointer-events-none" />
|
|
<input
|
|
{...cb.getInputProps({ onKeyDown: handleInputKeyDown })}
|
|
type="text"
|
|
placeholder="Search the JSON…"
|
|
className="w-full pl-12 pr-4 py-4 rounded-sm text-slate-900 bg-slate-100 text-2xl caret-indigo-700 border-indigo-700 transition dark:text-white dark:bg-slate-900 focus:outline-none focus:ring focus:ring-indigo-700"
|
|
/>
|
|
</label>
|
|
<div className="flex flex-col mt-4 mb-2">
|
|
<div className="results flex">
|
|
{searchState.status !== "idle" &&
|
|
(!searchState.results || searchState.results.length === 0) && (
|
|
<div className="results-loading flex">
|
|
<LoadingIcon className="animate-spin h-5 w-5 mr-1"></LoadingIcon>
|
|
<Body className="text-slate-400">Loading…</Body>
|
|
</div>
|
|
)}
|
|
{searchState.results && searchState.results.length > 0 && (
|
|
<div className="results-returned">
|
|
<Body className="text-slate-400">
|
|
{searchState.results.length === 1
|
|
? "1 result"
|
|
: `${searchState.results.length} results`}
|
|
</Body>
|
|
</div>
|
|
)}
|
|
{searchState.status === "idle" &&
|
|
searchState.query &&
|
|
searchState.query.length > 1 &&
|
|
(!searchState.results || searchState.results.length === 0) && (
|
|
<div className="results-none flex">
|
|
<ExclamationIcon className="h-5 w-5 mr-1 text-white"></ExclamationIcon>
|
|
<Body className="text-slate-400">
|
|
No results for "{cb.inputValue}"
|
|
</Body>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ul
|
|
{...cb.getMenuProps({ ref: listRef })}
|
|
className="w-full max-h-[calc(60vh-120px)] overflow-y-auto relative"
|
|
>
|
|
<li
|
|
key="total-size"
|
|
style={{ height: rowVirtualizer.totalSize }}
|
|
className="mb-[1rem]"
|
|
/>
|
|
{rowVirtualizer.virtualItems.map((virtualRow) => {
|
|
const result = (searchState.results ?? [])[virtualRow.index];
|
|
|
|
return (
|
|
<SearchItem
|
|
key={result.item.path}
|
|
itemProps={cb.getItemProps({
|
|
item: result,
|
|
index: virtualRow.index,
|
|
style: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
height: virtualRow.size,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
},
|
|
})}
|
|
result={result}
|
|
isHighlighted={virtualRow.index === cb.highlightedIndex}
|
|
/>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
<div className="flex items-center w-full gap-4 px-3 py-2 border-t-[1px] bg-slate-100 border-slate-200 rounded-br-lg rounded-bl-lg transition dark:bg-slate-900 dark:border-slate-700">
|
|
<div className="flex items-center gap-1">
|
|
<ShortcutIcon className="w-4 h-4 text-sm text-slate-900 bg-slate-300 transition duration-75 group-hover:bg-slate-100 dark:bg-slate-500 dark:group-hover:bg-slate-600">
|
|
⏎
|
|
</ShortcutIcon>
|
|
<Body className="text-slate-700 dakr:text-slate-500">to select</Body>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<ArrowKeysUpDownIcon className="transition text-slate-300 dark:text-slate-500" />
|
|
<Body className="text-slate-700 dakr:text-slate-500">
|
|
to navigate
|
|
</Body>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<EscapeKeyIcon className="transition text-slate-300 dark:text-slate-500" />
|
|
<Body className="text-slate-700 dakr:text-slate-500">to close</Body>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type SearchItemProps = {
|
|
itemProps: React.HTMLAttributes<HTMLLIElement>;
|
|
result: Fuse.FuseResult<JsonSearchEntry>;
|
|
isHighlighted: boolean;
|
|
};
|
|
|
|
export function SearchItem({
|
|
itemProps,
|
|
result,
|
|
isHighlighted,
|
|
}: SearchItemProps) {
|
|
const heroPath = new JSONHeroPath(result.item.path);
|
|
const [json] = useJson();
|
|
|
|
const itemValue = heroPath.first(json);
|
|
const ItemIcon = iconForValue(itemValue);
|
|
|
|
return (
|
|
<li
|
|
{...itemProps}
|
|
className={classnames("flex w-full hover:cursor-pointer")}
|
|
>
|
|
<div
|
|
className={classnames(
|
|
"w-full h-[calc(100%-4px)] mb-2 rounded-sm group",
|
|
isHighlighted ? "bg-indigo-700" : "bg-slate-100 dark:bg-slate-900"
|
|
)}
|
|
>
|
|
<div className="flex items-center w-full p-2 pl-4 pr-3">
|
|
<ItemIcon
|
|
className={classnames(
|
|
"h-6 w-6",
|
|
isHighlighted
|
|
? "text-white"
|
|
: "text-slate-500 dark:text-slate-400"
|
|
)}
|
|
></ItemIcon>
|
|
<div className="flex flex-col ml-3">
|
|
<div className="flex w-full items-center">
|
|
<SearchPathResult
|
|
path={heroPath}
|
|
searchResult={result}
|
|
isHighlighted={isHighlighted}
|
|
/>
|
|
</div>
|
|
<div className="key-value flex justify-between">
|
|
{result.item.rawValue && (
|
|
<SearchResultValue
|
|
isHighlighted={isHighlighted}
|
|
keyName="rawValue"
|
|
stringValue={result.item.rawValue}
|
|
searchResult={result}
|
|
/>
|
|
)}
|
|
{result.item.formattedValue &&
|
|
result.item.formattedValue !== result.item.rawValue && (
|
|
<SearchResultValue
|
|
isHighlighted={isHighlighted}
|
|
keyName="formattedValue"
|
|
stringValue={result.item.formattedValue}
|
|
searchResult={result}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// Outputs the following pair for each component except for the last one:
|
|
// <Body className="text-lg">{component}</Body>,
|
|
// <ChevronRightIcon className="w-4 h-4" />,
|
|
//
|
|
// Highlights parts of the component that match the search query.
|
|
// The match indices match against the stringified version of the path (e.g. $.foo.bar.0.details.description)
|
|
//
|
|
// If combined component strings are too long, then we need to choose some components to hide behind an ellipsis, making sure we don't hide matches
|
|
function SearchPathResult({
|
|
path,
|
|
searchResult,
|
|
isHighlighted,
|
|
maxWeight = 90,
|
|
}: {
|
|
path: JSONHeroPath;
|
|
isHighlighted: boolean;
|
|
searchResult: Fuse.FuseResult<JsonSearchEntry>;
|
|
maxWeight?: number;
|
|
}) {
|
|
const components = path.components.slice(1);
|
|
|
|
const match = (searchResult.matches ?? []).find(
|
|
(match) => match.key === "path" && match.indices.length > 0
|
|
);
|
|
|
|
const matchingIndices = (match?.indices ?? []) as [number, number][];
|
|
|
|
const displayPath = components.join(".");
|
|
|
|
const slices = getComponentSlices(
|
|
displayPath,
|
|
matchingIndices.map(([start, end]) => [start - 2, end - 2]),
|
|
maxWeight
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{slices.map((slice, i) =>
|
|
slice.type === "component" ? (
|
|
<span
|
|
key={i}
|
|
className={
|
|
slice.slice.isMatch
|
|
? classnames(
|
|
"font-sans text-xl",
|
|
isHighlighted
|
|
? "text-white underline underline-offset-1"
|
|
: "text-indigo-600 dark:text-indigo-400"
|
|
)
|
|
: classnames(
|
|
"font-sans text-xl",
|
|
isHighlighted
|
|
? "text-white"
|
|
: "text-slate-800 dark:text-white"
|
|
)
|
|
}
|
|
>
|
|
{slice.slice.slice}
|
|
</span>
|
|
) : slice.type === "ellipsis" ? (
|
|
<Body key={i} className="text-lg mx-1">
|
|
…
|
|
</Body>
|
|
) : (
|
|
<ChevronRightIcon
|
|
key={i}
|
|
className={classnames(
|
|
"w-4 h-4 mx-1",
|
|
isHighlighted ? "text-white" : "text-slate-800 dark:text-white"
|
|
)}
|
|
/>
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SearchResultValue({
|
|
isHighlighted,
|
|
keyName,
|
|
stringValue,
|
|
searchResult,
|
|
}: {
|
|
isHighlighted: boolean;
|
|
keyName: string;
|
|
stringValue: string;
|
|
searchResult: Fuse.FuseResult<JsonSearchEntry>;
|
|
}) {
|
|
const match = (searchResult.matches ?? []).find(
|
|
(match) => match.key === keyName
|
|
);
|
|
|
|
const output = createOutputForMatch(stringValue, isHighlighted, match);
|
|
|
|
return (
|
|
<Mono
|
|
className={classnames(
|
|
"",
|
|
isHighlighted ? "text-white" : "text-slate-600 dark:text-slate-500"
|
|
)}
|
|
>
|
|
{output}
|
|
</Mono>
|
|
);
|
|
}
|
|
|
|
function createOutputForMatch(
|
|
stringValue: string,
|
|
isHighlighted: boolean,
|
|
match?: Fuse.FuseResultMatch,
|
|
maxLength: number = 68
|
|
): JSX.Element {
|
|
if (!match) {
|
|
return <>{truncate(stringValue, { length: maxLength })}</>;
|
|
}
|
|
|
|
const stringSlices = getStringSlices(stringValue, match.indices, maxLength);
|
|
|
|
return (
|
|
<>
|
|
{stringSlices.map((s, index) => {
|
|
return (
|
|
<span
|
|
key={index}
|
|
className={
|
|
s.isMatch
|
|
? classnames(
|
|
"",
|
|
isHighlighted
|
|
? "text-white underline underline-offset-1"
|
|
: "text-indigo-600 dark:text-indigo-400"
|
|
)
|
|
: ""
|
|
}
|
|
>
|
|
{s.slice}
|
|
</span>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|