Created
December 14, 2022 22:58
-
-
Save wking-io/aa0634c7a9e06a39a6df0e90c8643750 to your computer and use it in GitHub Desktop.
Combobox Tag field
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
import { Combobox } from "@headlessui/react"; | |
import { | |
ChevronUpDownIcon, | |
PlusIcon, | |
XMarkIcon, | |
} from "@heroicons/react/24/solid"; | |
import { useActionData, useLocation, useTransition } from "@remix-run/react"; | |
import { useCallback, useEffect, useRef, useState } from "react"; | |
import { useShowModal } from "~/components/impl/Modal"; | |
import Button from "~/components/kits/Button"; | |
import type { FormProps } from "~/components/kits/Form"; | |
import Form from "~/components/kits/Form"; | |
import ModalKit, { Body, Text, Title } from "~/components/kits/Modal"; | |
import type { createTag } from "~/features/content/server"; | |
import type { Tag } from "~/models/tag.server"; | |
import type { Errors } from "~/types"; | |
import { slugit } from "~/utils"; | |
const TagModal: React.FunctionComponent< | |
{ | |
errors?: Errors; | |
} & FormProps | |
> = ({ errors, action }) => { | |
const { pathname } = useLocation(); | |
const [slug, setSlug] = useState(""); | |
const nameRef = useRef<HTMLInputElement>(null); | |
const slugRef = useRef<HTMLInputElement>(null); | |
useEffect(() => { | |
if (errors?.name) { | |
nameRef.current?.focus(); | |
} else if (errors?.slug) { | |
slugRef.current?.focus(); | |
} | |
}, [errors]); | |
return ( | |
<ModalKit> | |
{(close) => ( | |
<Body className="mx-auto max-w-md"> | |
<Title>Become a member first.</Title> | |
<Text> | |
You have to be a member to have access to this feature. Sign up | |
below and get access to the members only content and features. | |
</Text> | |
<Form | |
method="post" | |
action={action ?? pathname} | |
actionId="createTag" | |
className="space-y-6 text-left" | |
onSubmit={() => close()} | |
> | |
<div> | |
<label | |
htmlFor="name" | |
className="block text-sm font-medium text-gray-700" | |
> | |
Tag Name | |
</label> | |
<div className="mt-1"> | |
<input | |
ref={nameRef} | |
id="name" | |
required | |
autoFocus={true} | |
onChange={(e) => setSlug(slugit(e.target.value ?? ""))} | |
name="name" | |
type="text" | |
aria-invalid={errors?.name ? true : undefined} | |
aria-describedby="name-error" | |
className="w-full border border-gray-500 px-2 py-1 text-lg focus:border-accent focus:ring focus:ring-accent-bright/50" | |
/> | |
{errors?.name && ( | |
<div className="pt-1 text-red-700" id="name-error"> | |
{errors.name} | |
</div> | |
)} | |
</div> | |
</div> | |
<div> | |
<label | |
htmlFor="slug" | |
className="block text-sm font-medium text-gray-700" | |
> | |
Tag Slug | |
</label> | |
<div className="mt-1"> | |
<input | |
ref={slugRef} | |
id="slug" | |
required | |
autoFocus={true} | |
name="slug" | |
type="text" | |
aria-invalid={errors?.slug ? true : undefined} | |
aria-describedby="slug-error" | |
value={slug} | |
onChange={(e) => setSlug(slugit(e.target.value ?? ""))} | |
className="w-full border border-gray-500 px-2 py-1 text-lg focus:border-accent focus:ring focus:ring-accent-bright/50" | |
/> | |
{errors?.slug && ( | |
<div className="pt-1 text-red-700" id="slug-error"> | |
{errors.slug} | |
</div> | |
)} | |
</div> | |
</div> | |
<Button type="submit" className="w-full"> | |
Create Tag | |
</Button> | |
</Form> | |
</Body> | |
)} | |
</ModalKit> | |
); | |
}; | |
export const TagField: React.FunctionComponent<{ | |
init: Tag[]; | |
all: Tag[]; | |
}> = ({ init, all }) => { | |
const showModal = useShowModal(); | |
const [selectedTags, setSelected] = useState(init); | |
const [query, setQuery] = useState(""); | |
const transition = useTransition(); | |
const actionData = useActionData<typeof createTag>(); | |
// List of all tags including the one just created | |
const tags = [ | |
...all, | |
...(actionData?.success && actionData?.data?.tag | |
? [actionData.data.tag] | |
: []), | |
]; | |
// Tags filtered based on query | |
const filtered = | |
query === "" | |
? tags | |
: tags.filter((tag) => { | |
return tag.name.toLowerCase().includes(query.toLowerCase()); | |
}); | |
const showTagModal = useCallback(() => { | |
showModal(<TagModal />); | |
}, [showModal]); | |
useEffect(() => { | |
// If error show modal with errors | |
if (!actionData?.success && transition.type === "actionReload") { | |
if (actionData?.errors) { | |
showModal(<TagModal errors={actionData.errors} />); | |
} | |
} else { | |
if (actionData?.data?.tag) { | |
setSelected((prev) => [...prev, actionData?.data?.tag]); | |
} | |
} | |
}, [actionData]); | |
return ( | |
<> | |
<div className="mb-1 flex flex-wrap gap-1 bg-gray-100 p-2"> | |
{selectedTags.length > 0 ? ( | |
selectedTags.map(({ name, id }) => ( | |
<div | |
className="flex items-center gap-2 bg-gray-1100 text-sm text-white" | |
key={id} | |
> | |
<input type="hidden" name="tags[][id]" value={id} /> | |
<span className="py-1 pl-2">{name}</span> | |
<button | |
type="button" | |
onClick={() => | |
setSelected((prev) => prev.filter((pt) => pt.id !== id)) | |
} | |
className="p-1 hover:text-danger" | |
> | |
<XMarkIcon className="h-4 w-4"></XMarkIcon> | |
</button> | |
</div> | |
)) | |
) : ( | |
<p className="text-sm italic">Selected tags will show here.</p> | |
)} | |
</div> | |
<Combobox | |
as="div" | |
value={selectedTags} | |
by={(a, b) => a.id === b.id} | |
onChange={(selected) => setSelected(selected)} | |
multiple | |
className="relative" | |
> | |
<div className="relative flex border focus-within:ring focus-within:ring-accent-bright/50"> | |
<Combobox.Input | |
name="tagSearch" | |
onChange={(event) => setQuery(event.target.value)} | |
className="flex-1 border-0 p-2 focus:outline-none" | |
/> | |
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center p-1.5"> | |
<ChevronUpDownIcon | |
className="h-5 w-5 text-gray-400" | |
aria-hidden="true" | |
/> | |
</Combobox.Button> | |
</div> | |
<Combobox.Options className="absolute top-full left-0 z-10 mt-2 max-h-48 w-full overflow-y-auto overflow-x-hidden border bg-white shadow-xl"> | |
{filtered.map((tag) => ( | |
<Combobox.Option | |
key={tag.id} | |
value={tag} | |
className="w-full px-4 py-2 hover:bg-gray-100" | |
> | |
{tag.name} | |
</Combobox.Option> | |
))} | |
<button | |
type="button" | |
onClick={showTagModal} | |
className="flex w-full items-center justify-center gap-1 border-t py-2 px-3 first:border-0 hover:bg-gradient-to-br hover:from-accent hover:to-accent-bright hover:text-white" | |
> | |
<PlusIcon className="h-4 w-4" /> | |
Create new tag | |
</button> | |
</Combobox.Options> | |
</Combobox> | |
</> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment