mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: replace react-select in tag selector with downshift
This commit is contained in:
parent
9f8de81610
commit
a0ea8c2646
3 changed files with 131 additions and 513 deletions
|
|
@ -1,86 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import React, { useState, KeyboardEvent } from "react";
|
||||
import { useCombobox, useMultipleSelection } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const stringToOption = (input: string) => ({ value: input, label: input });
|
||||
|
||||
const options = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"].map(stringToOption);
|
||||
const tagRegex = /^[a-z]*$/;
|
||||
const predefinedItems = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags }: Props) {
|
||||
// todo: tag validating
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedItems.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
const hasSelectedItems = tags.length > 0;
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!tags.includes(tag) && tags.length < 8) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
if (!tagRegex.test(inputValue)) return;
|
||||
setInputValue(inputValue || "");
|
||||
},
|
||||
onStateChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
// Spill onto last tag
|
||||
if (event.key === "Backspace" && inputValue === "") {
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CreatableSelect
|
||||
isMulti
|
||||
placeholder="Select or create tags..."
|
||||
options={options}
|
||||
value={tags.map(stringToOption)}
|
||||
onChange={(newValue) => setTags(newValue.map((option) => option.value))}
|
||||
className="pill input col-span-2 w-full min-h-11 !py-0.5"
|
||||
styles={{
|
||||
control: (provided) => ({
|
||||
...provided,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
}),
|
||||
valueContainer: (provided) => ({
|
||||
...provided,
|
||||
padding: "0",
|
||||
}),
|
||||
multiValue: (provided) => ({
|
||||
...provided,
|
||||
borderRadius: "16px",
|
||||
padding: "2px 8px",
|
||||
backgroundColor: "var(--color-orange-300)",
|
||||
}),
|
||||
multiValueRemove: (provided) => ({
|
||||
...provided,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-black)",
|
||||
},
|
||||
}),
|
||||
indicatorsContainer: (provided) => ({
|
||||
...provided,
|
||||
"*": {
|
||||
padding: "1px",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: "none",
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "var(--color-orange-200)",
|
||||
border: "2px solid var(--color-orange-400)",
|
||||
borderRadius: "8px",
|
||||
}),
|
||||
option: (provided, { isFocused }) => ({
|
||||
...provided,
|
||||
backgroundColor: isFocused ? "rgba(0, 0, 0, 0.15)" : "var(--color-orange-200)",
|
||||
cursor: "pointer",
|
||||
padding: "2px 8px",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
|
||||
tags.length > 0 ? "!py-1.5" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="text-black cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select an item...",
|
||||
className: "w-full flex-1 outline-none",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasSelectedItems && (
|
||||
<button type="button" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="button" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
filteredItems.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
{isOpen && inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue