⚡️ feat: impelment user select
This commit is contained in:
parent
2f466d4fbb
commit
0381fdbbed
@ -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>;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>;
|
@ -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 };
|
@ -0,0 +1,2 @@
|
||||
export * from './UserSelectItem';
|
||||
export * from './UserSelectItem.theme';
|
@ -0,0 +1,2 @@
|
||||
export * from './UserSelect';
|
||||
export * from './UserSelect.theme';
|
Loading…
Reference in New Issue
Block a user