feat: global component for combobox with new design

This commit is contained in:
Aaryan Khandelwal 2023-03-04 19:10:35 +05:30
parent 4d598fd6b6
commit a875c608d4
4 changed files with 319 additions and 253 deletions

View File

@ -1,35 +1,23 @@
import { useState, FC, Fragment } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// headless ui
import { Transition, Combobox } from "@headlessui/react";
// services // services
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// ui // ui
import { AssigneesList, Avatar } from "components/ui"; import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui";
// icons // icons
import { UserGroupIcon, MagnifyingGlassIcon, CheckIcon } from "@heroicons/react/24/outline"; import { UserGroupIcon } from "@heroicons/react/24/outline";
// fetch-keys
// fetch keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
export type IssueAssigneeSelectProps = { export type Props = {
projectId: string; projectId: string;
value: string[]; value: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
}; };
export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], onChange }) => {
projectId,
value = [],
onChange,
}) => {
// states
const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -41,120 +29,44 @@ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
: null : null
); );
const options = people?.map((person) => ({ const options =
people?.map((person) => ({
value: person.member.id, value: person.member.id,
display: query:
person.member.first_name && person.member.first_name !== "" person.member.first_name && person.member.first_name !== ""
? person.member.first_name ? person.member.first_name
: person.member.email, : person.member.email,
})); content: (
<div className="flex items-center gap-2">
const filteredOptions = <Avatar user={person.member} />
query === "" {person.member.first_name && person.member.first_name !== ""
? options ? person.member.first_name
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase())); : person.member.email}
</div>
),
})) ?? [];
return ( return (
<Combobox <CustomSearchSelect
as="div"
value={value} value={value}
onChange={(val) => onChange(val)} onChange={onChange}
className="relative flex-shrink-0" options={options}
multiple label={
> <div className="flex items-center gap-2 text-gray-500">
{({ open }: any) => (
<>
<Combobox.Button
className={({ open }) =>
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200
${
open
? "border-theme bg-theme/5 outline-none ring-1 ring-theme "
: "hover:bg-theme/5 "
}`
}
>
{value && value.length > 0 && Array.isArray(value) ? ( {value && value.length > 0 && Array.isArray(value) ? (
<span className="flex items-center justify-center gap-2 px-3 py-1"> <span className="flex items-center justify-center gap-2">
<AssigneesList userIds={value} length={3} showLength={false} /> <AssigneesList userIds={value} length={3} showLength={false} />
<span className=" text-gray-500">{value.length} Assignees</span> <span className=" text-gray-500">{value.length} Assignees</span>
</span> </span>
) : ( ) : (
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs"> <span className="flex items-center justify-center gap-2">
<UserGroupIcon className="h-4 w-4 text-gray-500 " /> <UserGroupIcon className="h-4 w-4 text-gray-500 " />
<span className=" text-gray-500">Assignee</span> <span className=" text-gray-500">Assignee</span>
</span> </span>
)} )}
</Combobox.Button>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
bg-white px-2 py-2 text-xs shadow-md focus:outline-none`}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search for a person..."
displayValue={(assigned: any) => assigned?.name}
/>
</div> </div>
<div className="py-1.5">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
className={({ active }) =>
`${
active ? "bg-hover-gray" : ""
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-500`
} }
value={option.value} multiple
>
{({ selected, active }) => (
<div className="flex w-full justify-between gap-2 rounded">
<div className="flex items-center justify-start gap-1">
<Avatar
user={people?.find((p) => p.member.id === option.value)?.member}
/> />
<span>{option.display}</span>
</div>
<div
className={`flex items-center justify-center rounded border border-gray-500 border-opacity-0 p-1 group-hover:border-opacity-100
${selected ? "border-opacity-100 " : ""}
${active ? "bg-gray-100" : ""} `}
>
<CheckIcon
className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`}
/>
</div>
</div>
)}
</Combobox.Option>
))
) : (
<p className="px-2 text-xs text-gray-500">No assignees found</p>
)
) : (
<p className="px-2 text-xs text-gray-500">Loading...</p>
)}
</div>
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -6,20 +6,15 @@ import useSWR from "swr";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
// headless ui // ui
import { import { CustomSearchSelect } from "components/ui";
Squares2X2Icon,
PlusIcon,
MagnifyingGlassIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
// icons // icons
import { Combobox, Transition } from "@headlessui/react"; import { PlusIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// helpers // helpers
import { getStatesList } from "helpers/state.helper"; import { getStatesList } from "helpers/state.helper";
// fetch keys // fetch keys
import { STATE_LIST } from "constants/fetch-keys"; import { STATE_LIST } from "constants/fetch-keys";
import { getStateGroupIcon } from "components/icons";
type Props = { type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -30,8 +25,6 @@ type Props = {
export const IssueStateSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => { export const IssueStateSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => {
// states // states
const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -45,123 +38,40 @@ export const IssueStateSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
const options = states?.map((state) => ({ const options = states?.map((state) => ({
value: state.id, value: state.id,
display: state.name, query: state.name,
color: state.color, content: (
group: state.group, <div className="flex items-center gap-2">
{getStateGroupIcon(state.group, "16", "16", state.color)}
{state.name}
</div>
),
})); }));
const filteredOptions = const selectedOption = states?.find((s) => s.id === value);
query === ""
? options
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
const currentOption = options?.find((option) => option.value === value);
return ( return (
<Combobox <CustomSearchSelect
as="div"
value={value} value={value}
onChange={(val: any) => onChange(val)} onChange={onChange}
className="relative flex-shrink-0" options={options}
> label={
{({ open }: any) => ( <div className="flex items-center gap-2 text-gray-500">
<> <Squares2X2Icon className="h-4 w-4" />
<Combobox.Button {selectedOption &&
className={({ open }) => getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200 {selectedOption?.name ?? "State"}
${ </div>
open ? "border-theme bg-theme/5 outline-none ring-1 ring-theme " : "hover:bg-theme/5"
}`
} }
> footerOption={
{value && value !== "" ? (
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
{currentOption && currentOption.group
? getStateGroupIcon(currentOption.group, "16", "16", currentOption.color)
: ""}
<span className=" text-gray-600">{currentOption?.display}</span>
</span>
) : (
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
<Squares2X2Icon className="h-4 w-4 text-gray-500 " />
<span className=" text-gray-500">{currentOption?.display || "State"}</span>
</span>
)}
</Combobox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
bg-white px-2 py-2 text-xs shadow-md focus:outline-none`}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search States"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="py-1.5">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
className={({ active }) =>
`${
active ? "bg-gray-200" : ""
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
}
value={option.value}
>
{({ selected, active }) =>
states && (
<div className="flex w-full justify-between gap-2 rounded">
<div className="flex items-center justify-start gap-2">
{getStateGroupIcon(option.group, "16", "16", option.color)}
<span>{option.display}</span>
</div>
<div className="flex items-center justify-center rounded p-1">
<CheckIcon
className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`}
/>
</div>
</div>
)
}
</Combobox.Option>
))
) : (
<p className="px-2 text-xs text-gray-500">No states found</p>
)
) : (
<p className="px-2 text-xs text-gray-500">Loading...</p>
)}
<button <button
type="button" type="button"
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-gray-200" className="flex w-full select-none items-center gap-2 rounded px-1 py-1.5 text-xs text-gray-500 hover:bg-hover-gray"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<span className="flex items-center justify-start gap-1"> <PlusIcon className="h-4 w-4" aria-hidden="true" />
<PlusIcon className="h-4 w-4 text-gray-600" aria-hidden="true" /> Create New State
<span className="text-gray-600">Create New State</span>
</span>
</button> </button>
</div> }
</Combobox.Options> />
</Transition>
</>
)}
</Combobox>
); );
}; };

View File

@ -0,0 +1,243 @@
import React, { useState } from "react";
// headless ui
import { Combobox, Transition } from "@headlessui/react";
// icons
import { CheckIcon, ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
type CustomSearchSelectProps = {
value: any;
onChange: any;
options: {
value: any;
query: string;
content: JSX.Element;
}[];
label?: string | JSX.Element;
textAlignment?: "left" | "center" | "right";
position?: "right" | "left";
input?: boolean;
noChevron?: boolean;
customButton?: JSX.Element;
optionsClassName?: string;
disabled?: boolean;
selfPositioned?: boolean;
multiple?: boolean;
footerOption?: JSX.Element;
};
export const CustomSearchSelect = ({
label,
textAlignment,
value,
onChange,
options,
position = "left",
input = false,
noChevron = false,
customButton,
optionsClassName = "",
disabled = false,
selfPositioned = false,
multiple = false,
footerOption,
}: CustomSearchSelectProps) => {
const [query, setQuery] = useState("");
const filteredOptions =
query === ""
? options
: options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
return (
<>
{/* TODO: Improve this multiple logic */}
{multiple ? (
<Combobox
as="div"
value={value}
onChange={onChange}
className={`${!selfPositioned ? "relative" : ""} flex-shrink-0 text-left`}
multiple
>
{({ open }: any) => (
<>
{customButton ? (
<Combobox.Button as="div">{customButton}</Combobox.Button>
) : (
<Combobox.Button
className={`flex w-full ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center justify-between gap-1 rounded-md border shadow-sm duration-300 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
input ? "border-gray-300 px-3 py-2 text-sm" : "px-3 py-1.5 text-xs"
} ${
textAlignment === "right"
? "text-right"
: textAlignment === "center"
? "text-center"
: "text-left"
}`}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</Combobox.Button>
)}
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options
className={`${optionsClassName} absolute min-w-[10rem] p-2 ${
position === "right" ? "right-0" : "left-0"
} z-10 mt-1 origin-top-right overflow-y-auto rounded-md bg-white text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
input ? "max-h-48" : ""
}`}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`${active || selected ? "bg-hover-gray" : ""} ${
selected ? "font-medium" : ""
} flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-gray-500`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className="h-4 w-4" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-xs text-gray-500">No matching results</p>
)
) : (
<p className="text-xs text-gray-500">Loading...</p>
)}
</div>
{footerOption}
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
) : (
<Combobox
as="div"
value={value}
onChange={onChange}
className={`${!selfPositioned ? "relative" : ""} flex-shrink-0 text-left`}
>
{({ open }: any) => (
<>
{customButton ? (
<Combobox.Button as="div">{customButton}</Combobox.Button>
) : (
<Combobox.Button
className={`flex w-full ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
} items-center justify-between gap-1 rounded-md border shadow-sm duration-300 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
input ? "border-gray-300 px-3 py-2 text-sm" : "px-3 py-1.5 text-xs"
} ${
textAlignment === "right"
? "text-right"
: textAlignment === "center"
? "text-center"
: "text-left"
}`}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</Combobox.Button>
)}
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options
className={`${optionsClassName} absolute min-w-[10rem] p-2 ${
position === "right" ? "right-0" : "left-0"
} z-10 mt-1 origin-top-right overflow-y-auto rounded-md bg-white text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
input ? "max-h-48" : ""
}`}
>
<div className="flex w-full items-center justify-start rounded-sm border bg-gray-100 px-2 text-gray-500">
<MagnifyingGlassIcon className="h-3 w-3" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`${active || selected ? "bg-hover-gray" : ""} ${
selected ? "font-medium" : ""
} flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-gray-500`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className="h-4 w-4" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-gray-500">No matching results</p>
)
) : (
<p className="text-gray-500">Loading...</p>
)}
</div>
{footerOption}
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
)}
</>
);
};

View File

@ -3,6 +3,7 @@ export * from "./text-area";
export * from "./avatar"; export * from "./avatar";
export * from "./button"; export * from "./button";
export * from "./custom-menu"; export * from "./custom-menu";
export * from "./custom-search-select";
export * from "./custom-select"; export * from "./custom-select";
export * from "./datepicker"; export * from "./datepicker";
export * from "./empty-space"; export * from "./empty-space";