diff --git a/packages/frontend/src/components/shared/CustomIcon/BuildingIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/BuildingIcon.tsx new file mode 100644 index 00000000..cae3610e --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/BuildingIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const BuildingIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/ChevronUpDown.tsx b/packages/frontend/src/components/shared/CustomIcon/ChevronUpDown.tsx new file mode 100644 index 00000000..ac6f1723 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/ChevronUpDown.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const ChevronUpDown = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/index.ts b/packages/frontend/src/components/shared/CustomIcon/index.ts index 15048904..1aec3b33 100644 --- a/packages/frontend/src/components/shared/CustomIcon/index.ts +++ b/packages/frontend/src/components/shared/CustomIcon/index.ts @@ -4,6 +4,7 @@ export * from './CheckIcon'; export * from './ChevronGrabberHorizontal'; export * from './ChevronLeft'; export * from './ChevronRight'; +export * from './ChevronUpDown'; export * from './InfoSquareIcon'; export * from './WarningIcon'; export * from './SearchIcon'; @@ -26,6 +27,7 @@ export * from './GithubIcon'; export * from './GitTeaIcon'; export * from './LockIcon'; export * from './PencilIcon'; +export * from './BuildingIcon'; export * from './CheckRadioIcon'; export * from './ChevronDownIcon'; export * from './BranchIcon'; diff --git a/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx index 8ac93774..060be52a 100644 --- a/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx +++ b/packages/frontend/src/components/shared/Select/SelectItem/SelectItem.tsx @@ -62,13 +62,13 @@ const SelectItem = forwardRef(

{label}

- {orientation === 'horizontal' && description && ( - - )} {description && ( -

- {description} -

+ <> + {orientation === 'horizontal' && } +

+ {description} +

+ )} {renderRightIcon} diff --git a/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx b/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx index e6ea17c6..2e3eb143 100644 --- a/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx +++ b/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx @@ -3,13 +3,10 @@ import { NavLink, useNavigate, useParams } from 'react-router-dom'; import { Organization, User } from 'gql-client'; import { motion } from 'framer-motion'; -import { Option } from '@material-tailwind/react'; import { useDisconnect } from 'wagmi'; import { useGQLClient } from 'context/GQLClientContext'; -import AsyncSelect from 'components/shared/AsyncSelect'; import { - ChevronGrabberHorizontal, GlobeIcon, LifeBuoyIcon, LogoutIcon, @@ -24,6 +21,7 @@ import { Button } from 'components/shared/Button'; import { cn } from 'utils/classnames'; import { useMediaQuery } from 'usehooks-ts'; import { SIDEBAR_MENU } from './constants'; +import { UserSelect } from 'components/shared/UserSelect'; interface SidebarProps { mobileOpen?: boolean; @@ -60,23 +58,21 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => { setSelectedOrgSlug(orgSlug); }, [orgSlug]); - const renderOrganizations = useMemo(() => { - return organizations.map((org) => ( - - )); - }, [organizations]); + const formattedSelected = useMemo(() => { + const selected = organizations.find((org) => org.slug === selectedOrgSlug); + return { + value: selected?.slug ?? '', + label: selected?.name ?? '', + imgSrc: '/logo.svg', + }; + }, [organizations, selectedOrgSlug, orgSlug]); + const formattedSelectOptions = useMemo(() => { + return organizations.map((org) => ({ + value: org.slug, + label: org.name, + imgSrc: '/logo.svg', + })); + }, [organizations, selectedOrgSlug, orgSlug]); const renderMenu = useMemo(() => { return SIDEBAR_MENU(orgSlug).map(({ title, icon, url }, index) => ( @@ -114,37 +110,10 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => { {/* Switch organization */}
- { - setSelectedOrgSlug(value!); - navigate(`/${value}`); - }} - selected={(_, index) => ( -
- Application Logo -
-
- {organizations[index!]?.name} -
-
Organization
-
-
- )} - arrow={ - - } - > - {/* // TODO: Show label organization and manage in option */} - {renderOrganizations} -
+ {renderMenu} 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..bca34a1c --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelect.theme.ts @@ -0,0 +1,57 @@ +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', + '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', + ], + popoverItemWrapper: ['flex', 'flex-col', 'px-2', 'py-2', 'gap-1'], + }, + 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..81ef7923 --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelect.tsx @@ -0,0 +1,208 @@ +import React, { + useState, + ComponentPropsWithoutRef, + useRef, + useEffect, +} from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSelect } from 'downshift'; +import { UserSelectTheme, userSelectTheme } from './UserSelect.theme'; +import { EmptyUserSelectItem, UserSelectItem } from './UserSelectItem'; +import { + BuildingIcon, + ChevronUpDown, + SettingsSlidersIcon, +} from 'components/shared/CustomIcon'; +import { WavyBorder } from 'components/shared/WavyBorder'; +import { cn } from 'utils/classnames'; + +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( + (value as UserSelectOption) || 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 = (item: UserSelectOption) => + selectedItem?.value === item.value; + + const { + isOpen, + getMenuProps, + getToggleButtonProps, + highlightedIndex, + getItemProps, + openMenu, + } = useSelect({ + 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) { + handleSelectedItemChange(selectedItem); + } + }, + onIsOpenChange: ({ isOpen }) => { + setDropdownOpen(isOpen ?? false); + }, + 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) => ( + + )) + ) : ( + + )} +
    + + {/* WavyBorder */} + {/* //TODO:remove if personal dont exist */} + + +
    + {/* //TODO:Personal (replace options with Personal Options) */} + {isOpen && options.length !== 0 ? ( + options.map((item, index) => ( + + )) + ) : ( + + )} +
    +
+
+ ); +}; 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..17f28bdd --- /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-10', 'w-10', 'rounded-lg'], + 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..4885d6c2 --- /dev/null +++ b/packages/frontend/src/components/shared/UserSelect/UserSelectItem/UserSelectItem.tsx @@ -0,0 +1,66 @@ +import React, { forwardRef, ComponentPropsWithoutRef } 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 = './logo.svg' } = option; + + return ( +
  • +
    + {`${value}-logo`} +

    {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';