Skip to content

Commit

Permalink
search first 1/4
Browse files Browse the repository at this point in the history
  • Loading branch information
sphinxrave committed Oct 22, 2023
1 parent 9589a76 commit dd5bc58
Show file tree
Hide file tree
Showing 8 changed files with 845 additions and 29 deletions.
4 changes: 2 additions & 2 deletions packages/react/src/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { toggleSidebarAtom } from "@/hooks/useFrame";
import { darkAtom } from "@/hooks/useTheme";
import { Button } from "@/shadcn/ui/button";
import { Input } from "@/shadcn/ui/input";
import { userAtom } from "@/store/auth";
import { useAtom, useAtomValue } from "jotai";
import { useSetAtom } from "jotai/react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { SearchBar } from "./searchbar/SearchBar";

interface HeaderProps
extends React.DetailedHTMLProps<
Expand All @@ -33,7 +33,7 @@ export function Header({ id }: HeaderProps) {
<div className="i-heroicons:bars-3 rounded-md p-3" />
</Button>
<div className="flex grow" />
<Input type="search" placeholder="Search" className="max-w-lg" />
<SearchBar className="max-w-lg" />
<Button
size="icon"
variant="ghost"
Expand Down
118 changes: 118 additions & 0 deletions packages/react/src/components/header/searchbar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
## search bar design doc:

Basically it's a multi-select with validation and autocomplete given typed information.

```js
watch(search, (newValue) => {
// reflect value back to input
for (const _key of ["has_song", "type", "lang", "other"] as const)
ac_opts[_key] = [];
// clear ^
const [search_class, search_term] = splitSearchClassTerms(
newValue,
langCategoryReversemapClass.value
);
console.log(search_class, search_term);

if (search_class === "org" || search_class === undefined) {
const lower_search_term = search_term.toLowerCase();
ac_opts.org =
orgs.data.value
?.filter(
(x) =>
x.name.toLowerCase().includes(lower_search_term) ||
x.name_jp?.toLowerCase().includes(lower_search_term)
)
?.slice(0, search_class === "org" ? 20 : 5) // only give 5 suggestions when searching broadly.
?.map((x) => ({
type: "org",
value: x.name,
text: langPrefs.preferredLocaleFn(x.name, x.name_jp) || x.name,
})) || [];
} else {
ac_opts.org = []; // clear
}

if (search_class === undefined) {
const categoryAutofill = FIRST_SEARCH.filter((x) =>
t(`search.class.${x.type}`, x.type).startsWith(search_term)
);

const ok = JSON_SCHEMA.search.suggestionOK?.(query.value);
ac_opts.other = ok
? [
{
type: "search",
value: search_term,
text: "?",
replace: ok === "replace",
},
...categoryAutofill,
]
: categoryAutofill;
return;
}

// everything else only gets autocompleted when needed:
switch (search_class) {
case "has_song":
const ok = JSON_SCHEMA.has_song.suggestionOK?.(query.value);
if (ok) {
ac_opts.has_song = [
{
type: "has_song",
value: "none",
text: "$t",
replace: ok === "replace",
},
{
type: "has_song",
value: "non-zero",
text: "$t",
replace: ok === "replace",
},
{
type: "has_song",
value: "one",
text: "$t",
replace: ok === "replace",
},
{
type: "has_song",
value: "many",
text: "$t",
replace: ok === "replace",
},
];
}
return;
case "lang":
ac_opts.lang = CLIPPER_LANGS.map((x) => ({ ...x, type: "lang" }));
return;
case "type":
ac_opts.type = [
{ type: "type", value: "clip", text: "$t" },
{ type: "type", value: "stream", text: "$t" },
{ type: "type", value: "placeholder", text: "$t" },
];
return;
case "org":
case "topic":
case "vtuber":
return;
default:
const ok2 = JSON_SCHEMA[search_class].suggestionOK?.(query.value);
if (ok2 ?? true) {
ac_opts.other = [
{
type: search_class,
value: search_term,
text: "?",
replace: ok2 === "replace",
},
];
}
}
})

```
162 changes: 162 additions & 0 deletions packages/react/src/components/header/searchbar/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import * as React from "react";
import { X } from "lucide-react";

import { Command as CommandPrimitive } from "cmdk";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from "@/shadcn/ui/command";
import { Badge } from "@/shadcn/ui/badge";
import { cn } from "@/lib/utils";

type Framework = Record<"value" | "label", string>;

const FRAMEWORKS = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
{
value: "wordpress",
label: "WordPress",
},
{
value: "express.js",
label: "Express.js",
},
{
value: "nest.js",
label: "Nest.js",
},
] satisfies Framework[];

export function SearchBar({ className }: React.HTMLAttributes<HTMLDivElement>) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [selected, setSelected] = React.useState<Framework[]>([FRAMEWORKS[4]]);
const [inputValue, setInputValue] = React.useState("");

const handleUnselect = React.useCallback((framework: Framework) => {
setSelected((prev) => prev.filter((s) => s.value !== framework.value));
}, []);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "") {
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
return newSelected;
});
}
}
// This is not a default behaviour of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[],
);

const selectables = FRAMEWORKS.filter(
(framework) => !selected.includes(framework),
);

return (
<Command
onKeyDown={handleKeyDown}
className={cn("overflow-visible bg-transparent", className)}
>
<div className="group rounded-md border border-base px-3 py-2 text-sm ring-offset-base-2 focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2">
<div className="flex flex-wrap gap-1">
{selected.map((framework) => {
return (
<Badge key={framework.value} variant="primary">
{framework.label}
<button
className="ml-1 rounded-full outline-none ring-offset-base-2 focus:ring-2 focus:ring-primary-9 focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(framework);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(framework)}
>
<X className="h-3 w-3 text-base-8 hover:text-base-11" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder="Select frameworks..."
className="ml-2 flex-1 bg-transparent outline-none placeholder:text-base-8"
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ? (
<>
<div className="absolute top-0 z-10 w-full rounded-md border border-base bg-base-1 text-base-11 shadow-md outline-none animate-in">
<CommandGroup heading="Search Options" />
<CommandSeparator />
<CommandGroup className="h-full overflow-auto">
<CommandEmpty>Nothing to search?</CommandEmpty>
{selectables.map((framework) => {
return (
<CommandItem
key={framework.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(_) => {
setInputValue("");
setSelected((prev) => [...prev, framework]);
}}
className={"cursor-pointer"}
>
{framework.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
</>
) : null}
</div>
</Command>
);
}
24 changes: 24 additions & 0 deletions packages/react/src/components/header/searchbar/SearchBarAtoms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { atom } from "jotai";
import { QueryItem, SearchableCategory } from "./types";
import { atomWithReset } from "jotai/utils";
import { useTranslation } from "react-i18next";

const searchAutocompleteOptions = atomWithReset({
vtuber: [],
topic: [],
org: [],
has_song: [],
type: [],
lang: [],
from: [],
to: [],
search: [],
description: [],
} as Record<SearchableCategory, QueryItem[]>);

// const query = atomWithReset([] as QueryItem[]);

// const useAutocomplete(query: string) {
// const { t } = useTranslation();

// }
Loading

0 comments on commit dd5bc58

Please sign in to comment.