Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat]: implement multi-select dropdown with persistent open state for #2955

Merged
merged 4 commits into from
Sep 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading