feat: improve tag selector
This commit is contained in:
parent
a09b3cb56d
commit
de63677650
5 changed files with 104 additions and 73 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,11 +63,20 @@ export default function TagSelector({ tags, setTags }: Props) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContainerClick = () => {
|
||||||
|
if (!isMaxItemsSelected) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
openMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="col-span-2 relative">
|
||||||
<div
|
<div
|
||||||
className={`col-span-2 justify-between! pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
|
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
|
||||||
tags.length > 0 ? "py-1.5!" : ""
|
tags.length > 0 ? "py-1.5! px-2!" : ""
|
||||||
}`}
|
}`}
|
||||||
|
onClick={handleContainerClick}
|
||||||
>
|
>
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex flex-wrap gap-1.5 w-full">
|
<div className="flex flex-wrap gap-1.5 w-full">
|
||||||
|
|
@ -89,23 +100,31 @@ export default function TagSelector({ tags, setTags }: Props) {
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<input
|
<input
|
||||||
{...getInputProps({
|
{...getInputProps({
|
||||||
|
ref: inputRef,
|
||||||
onKeyDown: handleKeyDown,
|
onKeyDown: handleKeyDown,
|
||||||
disabled: isMaxItemsSelected,
|
disabled: isMaxItemsSelected,
|
||||||
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
||||||
|
maxLength: 20,
|
||||||
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
{hasSelectedItems && (
|
{hasSelectedItems && (
|
||||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||||
<Icon icon="mdi:close" />
|
<Icon icon="mdi:close" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle Tag Dropdown"
|
||||||
|
{...getToggleButtonProps()}
|
||||||
|
disabled={isMaxItemsSelected}
|
||||||
|
className="text-black cursor-pointer text-xl disabled:text-black/35"
|
||||||
|
>
|
||||||
<Icon icon="mdi:chevron-down" />
|
<Icon icon="mdi:chevron-down" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,12 +133,12 @@ export default function TagSelector({ tags, setTags }: Props) {
|
||||||
{!isMaxItemsSelected && (
|
{!isMaxItemsSelected && (
|
||||||
<ul
|
<ul
|
||||||
{...getMenuProps()}
|
{...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 ${
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
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 ${
|
||||||
isOpen ? "block" : "hidden"
|
isOpen ? "block" : "hidden"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isOpen &&
|
{filteredItems.map((item, index) => (
|
||||||
filteredItems.map((item, index) => (
|
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item}
|
||||||
{...getItemProps({ item, index })}
|
{...getItemProps({ item, index })}
|
||||||
|
|
@ -128,7 +147,7 @@ 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={() => {
|
||||||
|
|
@ -142,5 +161,17 @@ export default function TagSelector({ tags, setTags }: Props) {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue