From 0381fdbbedbe1ff80cc26a4d69e6ac76188d3e6c Mon Sep 17 00:00:00 2001 From: Andre H Date: Wed, 28 Feb 2024 23:07:54 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20impelment=20user?= =?UTF-8?q?=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/UserSelect/UserSelect.theme.ts | 58 ++++++ .../shared/UserSelect/UserSelect.tsx | 186 ++++++++++++++++++ .../UserSelectItem/UserSelectItem.theme.ts | 28 +++ .../UserSelectItem/UserSelectItem.tsx | 75 +++++++ .../shared/UserSelect/UserSelectItem/index.ts | 2 + .../src/components/shared/UserSelect/index.ts | 2 + 6 files changed, 351 insertions(+) create mode 100644 packages/frontend/src/components/shared/UserSelect/UserSelect.theme.ts create mode 100644 packages/frontend/src/components/shared/UserSelect/UserSelect.tsx create mode 100644 packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.theme.ts create mode 100644 packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.tsx create mode 100644 packages/frontend/src/components/shared/UserSelect/UserSelectItem/index.ts create mode 100644 packages/frontend/src/components/shared/UserSelect/index.ts diff --git a/packages/frontend/src/components/shared/UserSelect/UserSelect.theme.ts b/packages/frontend/src/components/shared/UserSelect/UserSelect.theme.ts new file mode 100644 index 00000000..7e17355e --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelect.theme.ts @@ -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; diff --git a/packages/frontend/src/components/shared/UserSelect/UserSelect.tsx b/packages/frontend/src/components/shared/UserSelect/UserSelect.tsx new file mode 100644 index 00000000..7bfca072 --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelect.tsx @@ -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, 'value' | 'onChange'>, + UserSelectTheme { + options: UserSelectOption[]; + value?: UserSelectOption; +} + +export const UserSelect = ({ options, value }: UserSelectProps) => { + const theme = userSelectTheme(); + const navigate = useNavigate(); + + const [selectedItem, setSelectedItem] = useState( + 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 ( +
+ {/* Input */} +
!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" + > +
+ Snowball Logo +
+ {selectedItem?.label ? ( +

+ {selectedItem?.label} +

+ ) : ( +
+ )} +

Organization

+
+
+ +
+ +
+
+ + {/* Popover */} +
    + {/* Settings header */} +
    +
    + +

    Other teams

    +
    +
    +

    Manage

    + +
    +
    + + {/* Organization */} + {isOpen && options.length !== 0 ? ( + options.map((item, index) => ( + + )) + ) : ( + + )} + + {/* //TODO:Squiggly line */} + {/* //TODO:Personal */} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.theme.ts b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.theme.ts new file mode 100644 index 00000000..e5b8685e --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.theme.ts @@ -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; diff --git a/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.tsx b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.tsx new file mode 100644 index 00000000..9684bc9a --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.tsx @@ -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, + 'index' | 'item' + > +>; + +export interface UserSelectItemProps + extends MergedComponentPropsWithoutRef, + UserSelectItemTheme { + selected: boolean; + option: UserSelectOption; + hovered?: boolean; +} + +const UserSelectItem = forwardRef( + ({ className, selected, hovered, option, ...props }, ref) => { + const theme = userSelectItemTheme(); + + const { value, label, imgSrc } = option; + + const renderLeftImage = useMemo( + () => ( +
+ {`${value}-logo`} +
+ ), + [imgSrc, value], + ); + + return ( +
  • +
    + {renderLeftImage} +

    {label}

    +
    + {selected && } +
  • + ); + }, +); + +export const EmptyUserSelectItem = () => { + const theme = userSelectItemTheme(); + return ( +
  • +
    +

    No results found

    +
    +
  • + ); +}; + +UserSelectItem.displayName = 'UserSelectItem'; + +export { UserSelectItem }; diff --git a/packages/frontend/src/components/shared/UserSelect/UserSelectItem/index.ts b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/index.ts new file mode 100644 index 00000000..57e76fc6 --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/index.ts @@ -0,0 +1,2 @@ +export * from './UserSelectItem'; +export * from './UserSelectItem.theme'; diff --git a/packages/frontend/src/components/shared/UserSelect/index.ts b/packages/frontend/src/components/shared/UserSelect/index.ts new file mode 100644 index 00000000..8225a33c --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/index.ts @@ -0,0 +1,2 @@ +export * from './UserSelect'; +export * from './UserSelect.theme';