-
Notifications
You must be signed in to change notification settings - Fork 94
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
9589a76
commit dd5bc58
Showing
8 changed files
with
845 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
packages/react/src/components/header/searchbar/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
162
packages/react/src/components/header/searchbar/SearchBar.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
24
packages/react/src/components/header/searchbar/SearchBarAtoms.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
|
||
// } |
Oops, something went wrong.