Skip to content

Instantly share code, notes, and snippets.

@wking-io
Created December 14, 2022 22:58
Show Gist options
  • Save wking-io/aa0634c7a9e06a39a6df0e90c8643750 to your computer and use it in GitHub Desktop.
Save wking-io/aa0634c7a9e06a39a6df0e90c8643750 to your computer and use it in GitHub Desktop.
Combobox Tag field
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