feat: improve tag selector

This commit is contained in:
trafficlunar 2025-10-31 15:26:32 +00:00
parent a09b3cb56d
commit de63677650
5 changed files with 104 additions and 73 deletions

View file

@ -32,7 +32,7 @@ body {
} }
.pill { .pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md; @apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
} }
.button { .button {

View file

@ -164,7 +164,7 @@ export default function EditForm({ mii, likes }: Props) {
<label htmlFor="tags" className="font-semibold"> <label htmlFor="tags" className="font-semibold">
Tags Tags
</label> </label>
<TagSelector tags={tags} setTags={setTags} /> <TagSelector tags={tags} setTags={setTags} showTagLimit />
</div> </div>
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">

View file

@ -182,7 +182,7 @@ export default function SubmitForm() {
<label htmlFor="tags" className="font-semibold"> <label htmlFor="tags" className="font-semibold">
Tags Tags
</label> </label>
<TagSelector tags={tags} setTags={setTags} /> <TagSelector tags={tags} setTags={setTags} showTagLimit />
</div> </div>
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">

View file

@ -1,19 +1,21 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useRef } from "react";
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface Props { interface Props {
tags: string[]; tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>; setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
} }
const tagRegex = /^[a-z0-9-_]*$/; const tagRegex = /^[a-z0-9-_]*$/;
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"]; const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
export default function TagSelector({ tags, setTags }: Props) { export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
const [inputValue, setInputValue] = useState<string>(""); const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] => const getFilteredItems = (): string[] =>
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item)); predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
@ -23,7 +25,7 @@ export default function TagSelector({ tags, setTags }: Props) {
const hasSelectedItems = tags.length > 0; const hasSelectedItems = tags.length > 0;
const addTag = (tag: string) => { const addTag = (tag: string) => {
if (!tags.includes(tag) && tags.length < 8) { if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
setTags([...tags, tag]); setTags([...tags, tag]);
} }
}; };
@ -32,7 +34,7 @@ export default function TagSelector({ tags, setTags }: Props) {
setTags(tags.filter((t) => t !== tag)); setTags(tags.filter((t) => t !== tag));
}; };
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({ const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
inputValue, inputValue,
items: filteredItems, items: filteredItems,
onInputValueChange: ({ inputValue }) => { onInputValueChange: ({ inputValue }) => {
@ -61,65 +63,82 @@ export default function TagSelector({ tags, setTags }: Props) {
} }
}; };
const handleContainerClick = () => {
if (!isMaxItemsSelected) {
inputRef.current?.focus();
openMenu();
}
};
return ( return (
<div <div className="col-span-2 relative">
className={`col-span-2 justify-between! pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${ <div
tags.length > 0 ? "py-1.5!" : "" className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
}`} tags.length > 0 ? "py-1.5! px-2!" : ""
> }`}
{/* Tags */} onClick={handleContainerClick}
<div className="flex flex-wrap gap-1.5 w-full"> >
{tags.map((tag) => ( {/* Tags */}
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"> <div className="flex flex-wrap gap-1.5 w-full">
{tag} {tags.map((tag) => (
<button <span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
type="button" {tag}
aria-label="Delete Tag" <button
className="text-black cursor-pointer" type="button"
onClick={(e) => { aria-label="Delete Tag"
e.stopPropagation(); className="text-black cursor-pointer"
removeTag(tag); onClick={(e) => {
}} e.stopPropagation();
> removeTag(tag);
<Icon icon="mdi:close" className="text-xs" /> }}
>
<Icon icon="mdi:close" className="text-xs" />
</button>
</span>
))}
{/* Input */}
<input
{...getInputProps({
ref: inputRef,
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
maxLength: 20,
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
</button> </button>
</span> )}
))}
{/* Input */} <button
<input type="button"
{...getInputProps({ aria-label="Toggle Tag Dropdown"
onKeyDown: handleKeyDown, {...getToggleButtonProps()}
disabled: isMaxItemsSelected, disabled={isMaxItemsSelected}
placeholder: tags.length > 0 ? "" : "Type or select a tag...", className="text-black cursor-pointer text-xl disabled:text-black/35"
className: "w-full flex-1 outline-none placeholder:text-black/40", >
})} <Icon icon="mdi:chevron-down" />
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1">
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
</button> </button>
)} </div>
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl"> {/* Dropdown menu */}
<Icon icon="mdi:chevron-down" /> {!isMaxItemsSelected && (
</button> <ul
</div> {...getMenuProps()}
onClick={(e) => e.stopPropagation()}
{/* Dropdown menu */} className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
{!isMaxItemsSelected && ( isOpen ? "block" : "hidden"
<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 ${ {filteredItems.map((item, index) => (
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
filteredItems.map((item, index) => (
<li <li
key={item} key={item}
{...getItemProps({ item, index })} {...getItemProps({ item, index })}
@ -128,18 +147,30 @@ export default function TagSelector({ tags, setTags }: Props) {
{item} {item}
</li> </li>
))} ))}
{isOpen && inputValue && !filteredItems.includes(inputValue) && ( {inputValue && !filteredItems.includes(inputValue) && (
<li <li
className="px-4 py-1 cursor-pointer text-sm bg-black/15" className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => { onClick={() => {
addTag(inputValue); addTag(inputValue);
setInputValue(""); setInputValue("");
}} }}
> >
Add &quot;{inputValue}&quot; Add &quot;{inputValue}&quot;
</li> </li>
)}
</ul>
)}
</div>
{/* Tag limit message */}
{showTagLimit && (
<div className="mt-1.5 text-xs min-h-4">
{isMaxItemsSelected ? (
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
) : (
<span className="text-black/60">{tags.length}/8 tags</span>
)} )}
</ul> </div>
)} )}
</div> </div>
); );

View file

@ -26,7 +26,7 @@ export const tagsSchema = z
z z
.string() .string()
.min(2, { error: "Tags must be at least 2 characters long" }) .min(2, { error: "Tags must be at least 2 characters long" })
.max(64, { error: "Tags cannot be more than 20 characters long" }) .max(20, { error: "Tags cannot be more than 20 characters long" })
.regex(/^[a-z0-9-_]+$/, { .regex(/^[a-z0-9-_]+$/, {
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.", error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
}) })