️ feat: impelment user select

This commit is contained in:
Andre H 2024-02-28 23:07:54 +07:00
parent 2f466d4fbb
commit 0381fdbbed
6 changed files with 351 additions and 0 deletions

View File

@ -0,0 +1,58 @@
import { tv, VariantProps } from 'tailwind-variants';
export const userSelectTheme = tv({
slots: {
container: ['flex', 'flex-col', 'relative', 'gap-2'],
inputWrapper: [
'relative',
'flex',
'flex-wrap',
'gap-1',
'min-w-[200px]',
'w-full',
'rounded-lg',
'bg-transparent',
'text-elements-mid-em',
'shadow-sm',
'border',
'border-border-interactive',
'focus-ring',
'disabled:shadow-none',
'disabled:border-none',
],
input: ['outline-none'],
popover: [
'mt-12',
'z-20',
'absolute',
'px-1',
'py-1',
'flex-col',
'gap-0.5',
'min-w-full',
'bg-surface-floating',
'shadow-dropdown',
'w-auto',
'max-h-60',
'overflow-auto',
'border',
'border-gray-200',
'rounded-xl',
],
},
variants: {
isOpen: {
true: {
popover: ['flex'],
},
false: {
popover: ['hidden'],
},
},
hasValue: {
true: '',
},
},
});
export type UserSelectTheme = VariantProps<typeof userSelectTheme>;

View File

@ -0,0 +1,186 @@
import React, {
useState,
ComponentPropsWithoutRef,
useCallback,
useRef,
useEffect,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useCombobox } from 'downshift';
import { UserSelectTheme, userSelectTheme } from './UserSelect.theme';
import {
BuildingIcon,
ChevronUpDown,
SettingsSlidersIcon,
} from 'components/shared/CustomIcon';
import { cn } from 'utils/classnames';
import { EmptyUserSelectItem, UserSelectItem } from './UserSelectItem';
export type UserSelectOption = {
value: string;
label: string;
imgSrc?: string;
};
interface UserSelectProps
extends Omit<ComponentPropsWithoutRef<'div'>, 'value' | 'onChange'>,
UserSelectTheme {
options: UserSelectOption[];
value?: UserSelectOption;
}
export const UserSelect = ({ options, value }: UserSelectProps) => {
const theme = userSelectTheme();
const navigate = useNavigate();
const [selectedItem, setSelectedItem] = useState<UserSelectOption | null>(
null,
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>(
'bottom',
);
const popoverRef = useRef(null); // Ref for the popover
const inputWrapperRef = useRef(null); // Ref for the input wrapper
// Calculate and update popover position
useEffect(() => {
if (dropdownOpen && popoverRef.current && inputWrapperRef.current) {
const popover = popoverRef.current;
// @ts-expect-error we know it's not null lol
const input = inputWrapperRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - input.bottom;
const spaceAbove = input.top;
// @ts-expect-error we know it's not null lol
const popoverHeight = popover.offsetHeight;
// Determine if there's enough space below
if (spaceBelow >= popoverHeight) {
setDropdownPosition('bottom');
} else if (spaceAbove >= popoverHeight) {
setDropdownPosition('top');
} else {
// Default to bottom if neither has enough space, but you could also set logic to choose the side with more space
setDropdownPosition('bottom');
}
}
}, [dropdownOpen]); // Re-calculate whenever the dropdown is opened
useEffect(() => {
setSelectedItem(value as UserSelectOption);
}, [value]);
const handleSelectedItemChange = (selectedItem: UserSelectOption | null) => {
setSelectedItem(selectedItem);
navigate(`/${selectedItem?.value}`);
};
const isSelected = useCallback(
(item: UserSelectOption) => selectedItem?.value === item.value,
[selectedItem],
);
const { isOpen, getMenuProps, highlightedIndex, getItemProps, openMenu } =
useCombobox({
items: options,
// @ts-expect-error there are two params but we don't need the second one
isItemDisabled: (item) => item.disabled,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
console.log(selectedItem);
handleSelectedItemChange(selectedItem);
}
},
onIsOpenChange: ({ isOpen }) => {
setDropdownOpen(isOpen ?? false);
},
selectedItem: selectedItem,
itemToString: (item) => (item ? item.label : ''),
});
const handleManage = () => {
//TODO: implement manage handler
};
return (
<div className={theme.container()}>
{/* Input */}
<div
ref={inputWrapperRef}
onClick={() => !dropdownOpen && openMenu()}
className="cursor-pointer relative py-2 pl-2 pr-4 flex min-w-[200px] w-full items-center justify-between rounded-xl bg-surface-card shadow-sm"
>
<div className="flex gap-3 w-full mr-2">
<img
src="/logo.svg"
alt="Snowball Logo"
className="h-10 w-10 rounded-lg"
/>
<div className="flex flex-col justify-center h-10 w-full">
{selectedItem?.label ? (
<p className="text-sm text-elements-high-em">
{selectedItem?.label}
</p>
) : (
<div className="animate-pulse h-1.5 mb-1 w-full rounded-full bg-elements-on-disabled" />
)}
<p className="text-xs text-elements-low-em">Organization</p>
</div>
</div>
<div className="h-4 w-4 text-slate-400">
<ChevronUpDown size={16} />
</div>
</div>
{/* Popover */}
<ul
{...getMenuProps({ ref: popoverRef }, { suppressRefError: true })}
id="popover"
ref={popoverRef}
className={cn(
theme.popover({ isOpen }),
{
// Position the popover based on the dropdown position
'top-[27.5%]': dropdownPosition === 'bottom',
'bottom-[92.5%]': dropdownPosition === 'top',
},
'px-2 py-2',
)}
>
{/* Settings header */}
<div className="flex justify-between h-8 items-center">
<div className="flex gap-1 text-elements-mid-em">
<BuildingIcon size={16} />
<p className="text-xs font-medium">Other teams</p>
</div>
<div
className="flex gap-1 text-elements-link cursor-pointer"
onClick={handleManage}
>
<p className="text-xs font-medium">Manage</p>
<SettingsSlidersIcon size={16} />
</div>
</div>
{/* Organization */}
{isOpen && options.length !== 0 ? (
options.map((item, index) => (
<UserSelectItem
{...getItemProps({ item, index })}
key={item.value}
selected={isSelected(item)}
option={item}
hovered={highlightedIndex === index}
/>
))
) : (
<EmptyUserSelectItem />
)}
{/* //TODO:Squiggly line */}
{/* //TODO:Personal */}
</ul>
</div>
);
};

View File

@ -0,0 +1,28 @@
import { tv, VariantProps } from 'tailwind-variants';
export const userSelectItemTheme = tv({
slots: {
wrapper: [
'p-2',
'gap-3',
'flex',
'items-center',
'justify-between',
'rounded-lg',
'cursor-pointer',
],
content: ['flex', 'gap-3', 'items-center'],
img: ['h-5', 'w-5'],
selectedIcon: ['h-5', 'w-5', 'text-controls-primary'],
label: ['text-sm', 'text-elements-high-em', 'tracking-[-0.006em]'],
},
variants: {
active: {
true: {
wrapper: ['bg-base-bg-emphasized'],
},
},
},
});
export type UserSelectItemTheme = VariantProps<typeof userSelectItemTheme>;

View File

@ -0,0 +1,75 @@
import React, { forwardRef, ComponentPropsWithoutRef, useMemo } from 'react';
import { Overwrite, UseComboboxGetItemPropsReturnValue } from 'downshift';
import {
userSelectItemTheme,
UserSelectItemTheme,
} from './UserSelectItem.theme';
import { CheckRadioIcon } from 'components/shared/CustomIcon';
import { UserSelectOption } from 'components/shared/UserSelect';
import { OmitCommon } from 'types/common';
/**
* Represents a type that merges ComponentPropsWithoutRef<'li'> with certain exclusions.
* @type {MergedComponentPropsWithoutRef}
*/
type MergedComponentPropsWithoutRef = OmitCommon<
ComponentPropsWithoutRef<'li'>,
Omit<
Overwrite<UseComboboxGetItemPropsReturnValue, UserSelectOption[]>,
'index' | 'item'
>
>;
export interface UserSelectItemProps
extends MergedComponentPropsWithoutRef,
UserSelectItemTheme {
selected: boolean;
option: UserSelectOption;
hovered?: boolean;
}
const UserSelectItem = forwardRef<HTMLLIElement, UserSelectItemProps>(
({ className, selected, hovered, option, ...props }, ref) => {
const theme = userSelectItemTheme();
const { value, label, imgSrc } = option;
const renderLeftImage = useMemo(
() => (
<div className="grid place-items-center w-10 h-10 rounded-lg bg-blue-400">
<img src={imgSrc} alt={`${value}-logo`} className={theme.img()} />
</div>
),
[imgSrc, value],
);
return (
<li
{...props}
ref={ref}
className={theme.wrapper({ className, active: selected || hovered })}
>
<div className={theme.content()}>
{renderLeftImage}
<p className={theme.label()}>{label}</p>
</div>
{selected && <CheckRadioIcon className={theme.selectedIcon()} />}
</li>
);
},
);
export const EmptyUserSelectItem = () => {
const theme = userSelectItemTheme();
return (
<li className={theme.wrapper()}>
<div className={theme.content()}>
<p className={theme.label()}>No results found</p>
</div>
</li>
);
};
UserSelectItem.displayName = 'UserSelectItem';
export { UserSelectItem };

View File

@ -0,0 +1,2 @@
export * from './UserSelectItem';
export * from './UserSelectItem.theme';

View File

@ -0,0 +1,2 @@
export * from './UserSelect';
export * from './UserSelect.theme';