Skip to content

Commit

Permalink
[Feat]: implement multi-select dropdown with persistent open state for (
Browse files Browse the repository at this point in the history
#2955)

* feat: implement multi-select dropdown with persistent open state for multiple selections

* feat: modal for filter and improv mult-select

* feat: MultiSelect component for universal variable naming and improved reusability in Modal Filter
  • Loading branch information
Innocent-Akim authored Sep 1, 2024
1 parent 25ac65e commit 46fbd9e
Show file tree
Hide file tree
Showing 9 changed files with 470 additions and 174 deletions.
22 changes: 19 additions & 3 deletions apps/web/app/[locale]/calendar/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import React from "react";
import { DateRange } from "react-day-picker";
import { LuCalendarDays } from "react-icons/lu";
import { Input } from "@components/ui/input";
import { SettingFilterIcon } from "assets/svg";

export function HeadCalendar({
openModal,
Expand Down Expand Up @@ -60,14 +61,19 @@ export function HeadCalendar({
}


export function HeadTimeSheet({ timesheet }: { timesheet?: timesheetCalendar }) {
export function HeadTimeSheet({ timesheet, isOpen, openModal, closeModal }: { timesheet?: timesheetCalendar, isOpen?: boolean, openModal?: () => void, closeModal?: () => void }) {

const [date, setDate] = React.useState<DateRange | undefined>({
from: new Date(2022, 0, 20),
to: addDays(new Date(2022, 0, 20), 20)
});
})
return (

<div>
<TimeSheetFilter
closeModal={closeModal!}
isOpen={isOpen!}
/>
<div className='flex items-center justify-between w-full dark:!bg-dark--theme h-28'>
{timesheet === 'TimeSheet' && (
<div className="flex justify-between items-center w-full p-2 gap-x-3">
Expand Down Expand Up @@ -123,7 +129,17 @@ export function HeadTimeSheet({ timesheet }: { timesheet?: timesheetCalendar })
/>
</div>
<div>
<TimeSheetFilter />
<Button
onClick={openModal}
className='flex items-center justify-center h-10 rounded-lg bg-white dark:bg-dark--theme-light gap-x-3 border dark:border-gray-700 hover:bg-white' >
<SettingFilterIcon className="text-gray-700 dark:text-white w-3.5" strokeWidth="1.8" />
<div className="gap-x-2 flex items-center w-full">
<span className="text-gray-700 dark:text-white">Filter</span>
<div className="bg-gray-700 dark:bg-white h-6 w-6 rounded-full flex items-center justify-center text-whiten dark:text-gray-700">
<span>6</span>
</div>
</div>
</Button>
</div>
</div>
</div>
Expand Down
98 changes: 2 additions & 96 deletions apps/web/lib/components/custom-select/index.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,2 @@
import { Button } from '@components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover';
import { cn } from 'lib/utils';
import { useEffect, useState } from 'react';
import { MdOutlineKeyboardArrowDown } from 'react-icons/md';

interface SelectItemsProps<T> {
items: T[];
onValueChange?: (value: T) => void;
itemToString: (item: T) => string;
itemId: (item: T) => string;
triggerClassName?: string;
popoverClassName?: string;
renderItem?: (item: T, onClick: () => void) => JSX.Element;
defaultValue?: T;
}

export function SelectItems<T>({
items,
onValueChange,
itemToString,
itemId,
triggerClassName = '',
popoverClassName = '',
renderItem,
defaultValue
}: SelectItemsProps<T>) {
const [selectedItem, setSelectedItem] = useState<T | null>(null);
const [isPopoverOpen, setPopoverOpen] = useState(false);

const onClick = (item: T) => {
setSelectedItem(item);
setPopoverOpen(false);
if (onValueChange) {
onValueChange(item);
}
};

useEffect(() => {
if (defaultValue) {
setSelectedItem(defaultValue);
if (onValueChange) {
onValueChange(defaultValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);

return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={() => setPopoverOpen(!isPopoverOpen)}
variant="outline"
className={cn(
'w-full justify-between text-left font-normal h-10 rounded-lg dark:bg-dark--theme-light',
// !selectedItem && 'text-muted-foreground',
triggerClassName
)}
>
{selectedItem ? (
<span className="truncate">{itemToString(selectedItem)}</span>
) : (
<span>Select an item</span>
)}
<MdOutlineKeyboardArrowDown
className={cn('h-4 w-4 transition-transform', isPopoverOpen && 'rotate-180')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn(
'w-[430px] border border-transparent max-h-[80vh] dark:bg-dark--theme-light',
popoverClassName
)}
>
<div className="w-full max-h-[80vh] overflow-auto flex flex-col">
{items.map((item) =>
renderItem ? (
renderItem(item, () => onClick(item))
) : (
<span
onClick={() => onClick(item)}
key={itemId(item)}
className="truncate hover:cursor-pointer hover:bg-slate-50 w-full text-[13px] hover:rounded-lg p-1 hover:font-normal dark:text-white dark:hover:bg-primary"
style={{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }}
>
{itemToString(item)}
</span>
)
)}
</div>
</PopoverContent>
</Popover>
);
}
export * from './multi-select';
export * from './select-items'
153 changes: 153 additions & 0 deletions apps/web/lib/components/custom-select/multi-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Button } from '@components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover';
import { cn } from 'lib/utils';
import { useEffect, useState, useRef } from 'react';
import { MdOutlineKeyboardArrowDown, MdClose } from 'react-icons/md';

interface MultiSelectProps<T> {
items: T[];
onValueChange?: (value: T | T[] | null) => void;
itemToString: (item: T) => string;
itemId: (item: T) => string;
triggerClassName?: string;
popoverClassName?: string;
renderItem?: (item: T, onClick: () => void, isSelected: boolean) => JSX.Element;
defaultValue?: T | T[];
multiSelect?: boolean;
}

export function MultiSelect<T>({
items,
onValueChange,
itemToString,
itemId,
triggerClassName = '',
popoverClassName = '',
renderItem,
defaultValue,
multiSelect = false,
}: MultiSelectProps<T>) {
const [selectedItems, setSelectedItems] = useState<T[]>(Array.isArray(defaultValue) ? defaultValue : defaultValue ? [defaultValue] : []);
const [isPopoverOpen, setPopoverOpen] = useState(false);
const [popoverWidth, setPopoverWidth] = useState<number | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);

const onClick = (item: T) => {
let newSelectedItems: T[];
if (multiSelect) {
if (selectedItems.some((selectedItem) => itemId(selectedItem) === itemId(item))) {
newSelectedItems = selectedItems.filter((selectedItem) => itemId(selectedItem) !== itemId(item));
} else {
newSelectedItems = [...selectedItems, item];
}
} else {
newSelectedItems = [item];
setPopoverOpen(false);
}
setSelectedItems(newSelectedItems);
if (onValueChange) {
onValueChange(multiSelect ? newSelectedItems : newSelectedItems[0]);
}
};

const removeItem = (item: T) => {
const newSelectedItems = selectedItems.filter((selectedItem) => itemId(selectedItem) !== itemId(item));
setSelectedItems(newSelectedItems);
if (onValueChange) {
onValueChange(multiSelect ? newSelectedItems : newSelectedItems.length > 0 ? newSelectedItems[0] : null);
}
};

useEffect(() => {
const initialItems = Array.isArray(defaultValue) ? defaultValue : defaultValue ? [defaultValue] : [];
setSelectedItems(initialItems);
if (onValueChange) {
onValueChange(multiSelect ? initialItems : initialItems[0] || null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);

useEffect(() => {
if (triggerRef.current) {
setPopoverWidth(triggerRef.current.offsetWidth);
}
}, [triggerRef.current]);

return (
<div className="relative w-full overflow-hidden">
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
onClick={() => setPopoverOpen(!isPopoverOpen)}
variant="outline"
className={cn(
'w-full justify-between text-left font-normal h-10 rounded-lg dark:bg-dark--theme-light',
triggerClassName
)}
>
{selectedItems.length > 0 ? (
<span className="truncate">
{multiSelect
? `${selectedItems.length} item(s) selected`
: itemToString(selectedItems[0])}
</span>
) : (
<span>Select items</span>
)}
<MdOutlineKeyboardArrowDown
className={cn('h-4 w-4 transition-transform', isPopoverOpen && 'rotate-180')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn(
'w-full max-w-full max-h-[80vh] border border-transparent dark:bg-dark--theme-light',
popoverClassName
)}
style={{ width: popoverWidth || 'auto', overflow: 'auto' }}
>
<div className="w-full max-h-[80vh] overflow-auto flex flex-col">
{items.map((item) => {
const isSelected = selectedItems.some((selectedItem) => itemId(selectedItem) === itemId(item));
return renderItem ? (
renderItem(item, () => onClick(item), isSelected)
) : (
<span
onClick={() => onClick(item)}
key={itemId(item)}
className={cn(
'truncate hover:cursor-pointer hover:bg-slate-50 w-full text-[13px] hover:rounded-lg p-1 hover:font-normal dark:text-white dark:hover:bg-primary',
isSelected && 'font-semibold bg-slate-100 dark:bg-primary-light'
)}
style={{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }}
>
{itemToString(item)}
</span>
);
})}
</div>
</PopoverContent>
</Popover>
{selectedItems.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedItems.map((item) => (
<div
key={itemId(item)}
className="flex items-center justify-between bg-gray-100 dark:bg-slate-700 px-2 py-[0.5px] rounded text-[12px] dark:text-white"
>
<span>{itemToString(item)}</span>
<button
onClick={() => removeItem(item)}
className="ml-2 text-gray-600 dark:text-white hover:text-red-500 dark:hover:text-red-500"
aria-label="Remove item"
>
<MdClose className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}
96 changes: 96 additions & 0 deletions apps/web/lib/components/custom-select/select-items.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Button } from '@components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover';
import { cn } from 'lib/utils';
import { useEffect, useState } from 'react';
import { MdOutlineKeyboardArrowDown } from 'react-icons/md';

interface SelectItemsProps<T> {
items: T[];
onValueChange?: (value: T) => void;
itemToString: (item: T) => string;
itemId: (item: T) => string;
triggerClassName?: string;
popoverClassName?: string;
renderItem?: (item: T, onClick: () => void) => JSX.Element;
defaultValue?: T;
}

export function SelectItems<T>({
items,
onValueChange,
itemToString,
itemId,
triggerClassName = '',
popoverClassName = '',
renderItem,
defaultValue
}: SelectItemsProps<T>) {
const [selectedItem, setSelectedItem] = useState<T | null>(null);
const [isPopoverOpen, setPopoverOpen] = useState(false);

const onClick = (item: T) => {
setSelectedItem(item);
setPopoverOpen(false);
if (onValueChange) {
onValueChange(item);
}
};

useEffect(() => {
if (defaultValue) {
setSelectedItem(defaultValue);
if (onValueChange) {
onValueChange(defaultValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);

return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={() => setPopoverOpen(!isPopoverOpen)}
variant="outline"
className={cn(
'w-full justify-between text-left font-normal h-10 rounded-lg dark:bg-dark--theme-light',
// !selectedItem && 'text-muted-foreground',
triggerClassName
)}
>
{selectedItem ? (
<span className="truncate">{itemToString(selectedItem)}</span>
) : (
<span>Select an item</span>
)}
<MdOutlineKeyboardArrowDown
className={cn('h-4 w-4 transition-transform', isPopoverOpen && 'rotate-180')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn(
'w-[430px] border border-transparent max-h-[80vh] dark:bg-dark--theme-light',
popoverClassName
)}
>
<div className="w-full max-h-[80vh] overflow-auto flex flex-col">
{items.map((item) =>
renderItem ? (
renderItem(item, () => onClick(item))
) : (
<span
onClick={() => onClick(item)}
key={itemId(item)}
className="truncate hover:cursor-pointer hover:bg-slate-50 w-full text-[13px] hover:rounded-lg p-1 hover:font-normal dark:text-white dark:hover:bg-primary"
style={{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }}
>
{itemToString(item)}
</span>
)
)}
</div>
</PopoverContent>
</Popover>
);
}
Loading

0 comments on commit 46fbd9e

Please sign in to comment.