Przeglądaj źródła

Carousel context menu fix (#4268)

* fix the issue

* use zustand to close all the rest popovers
Bartosz Dryl 1 rok temu
rodzic
commit
ceacf94cbe

+ 3 - 0
packages/atlas/src/components/_nft/NftTile/NftTile.tsx

@@ -16,6 +16,7 @@ import { Member, NftTileDetails } from './NftTileDetails'
 
 export type NftTileProps = {
   status?: 'idle' | 'buy-now' | 'auction'
+  isInCarousel?: boolean
   thumbnail?: VideoThumbnailProps
   title?: string | null
   owner?: Member
@@ -38,6 +39,7 @@ export type NftTileProps = {
 
 export const NftTile: FC<NftTileProps> = ({
   status,
+  isInCarousel,
   thumbnail,
   loading,
   title,
@@ -99,6 +101,7 @@ export const NftTile: FC<NftTileProps> = ({
         }}
       />
       <NftTileDetails
+        isInCarousel={isInCarousel}
         videoHref={thumbnail?.videoHref as string}
         hovered={hovered}
         owner={owner}

+ 190 - 155
packages/atlas/src/components/_nft/NftTile/NftTileDetails.tsx

@@ -1,4 +1,4 @@
-import { FC, ReactElement, ReactNode, memo, useMemo, useState } from 'react'
+import { FC, ReactElement, ReactNode, memo, useEffect, useId, useMemo, useRef, useState } from 'react'
 import useResizeObserver from 'use-resize-observer'
 
 import { SvgActionMore, SvgActionNotForSale } from '@/assets/icons'
@@ -8,6 +8,8 @@ import { NumberFormat } from '@/components/NumberFormat'
 import { Text } from '@/components/Text'
 import { SkeletonLoader } from '@/components/_loaders/SkeletonLoader'
 import { ContextMenu } from '@/components/_overlays/ContextMenu'
+import { PopoverImperativeHandle } from '@/components/_overlays/Popover'
+import { useMiscStore } from '@/providers/misc/store'
 import { cVar } from '@/styles'
 
 import {
@@ -30,6 +32,7 @@ export type Member = {
 
 export type NftTileDetailsProps = {
   loading?: boolean
+  isInCarousel?: boolean
   owner?: Member
   creator?: Member
   role?: 'owner' | 'viewer'
@@ -48,169 +51,201 @@ type TileSize = 'small' | 'medium' | 'big' | 'bigSmall'
 
 const SMALL_SIZE_WIDTH = 288
 
-export const NftTileDetails: FC<NftTileDetailsProps> = ({
-  loading,
-  creator,
-  owner,
-  nftStatus,
-  startingPrice,
-  buyNowPrice,
-  topBid,
-  title,
-  hovered,
-  videoHref,
-  interactable = true,
-  contextMenuItems,
-}) => {
-  const [contentHovered, setContentHovered] = useState(false)
-  const toggleContentHover = () => setContentHovered((prevState) => !prevState)
-  const [tileSize, setTileSize] = useState<TileSize>()
-  const { ref: contentRef } = useResizeObserver<HTMLAnchorElement>({
-    box: 'border-box',
-    onResize: (size) => {
-      const { width } = size
-      if (width) {
-        if (tileSize !== 'small' && width < SMALL_SIZE_WIDTH) {
-          setTileSize('small')
-        }
-        if (tileSize !== 'medium' && width >= SMALL_SIZE_WIDTH) {
-          setTileSize('medium')
+export const NftTileDetails: FC<NftTileDetailsProps> = memo(
+  ({
+    loading,
+    isInCarousel,
+    creator,
+    owner,
+    nftStatus,
+    startingPrice,
+    buyNowPrice,
+    topBid,
+    title,
+    hovered,
+    videoHref,
+    interactable = true,
+    contextMenuItems,
+  }) => {
+    const [contentHovered, setContentHovered] = useState(false)
+    const setOpenedContextMenuId = useMiscStore((state) => state.actions.setOpenedContextMenuId)
+    const openedContexMenuId = useMiscStore((state) => state.openedContexMenuId)
+    const toggleContentHover = () => setContentHovered((prevState) => !prevState)
+    const [tileSize, setTileSize] = useState<TileSize>()
+    const { ref: contentRef } = useResizeObserver<HTMLAnchorElement>({
+      box: 'border-box',
+      onResize: (size) => {
+        const { width } = size
+        if (width) {
+          if (tileSize !== 'small' && width < SMALL_SIZE_WIDTH) {
+            setTileSize('small')
+          }
+          if (tileSize !== 'medium' && width >= SMALL_SIZE_WIDTH) {
+            setTileSize('medium')
+          }
         }
+      },
+    })
+    const id = useId()
+    const ref = useRef<HTMLButtonElement>(null)
+    const contextMenuInstanceRef = useRef<PopoverImperativeHandle>(null)
+
+    // This useEffect is called only inside carousel and it's a workaround fix for https://github.com/Joystream/atlas/issues/4239
+    // We need manually remove all popovers, because tippy is not working well with swiper carousel
+    useEffect(() => {
+      if (!openedContexMenuId || !isInCarousel) {
+        return
       }
-    },
-  })
+      if (openedContexMenuId !== id) {
+        contextMenuInstanceRef.current?.hide()
+      }
+    }, [id, isInCarousel, openedContexMenuId])
 
-  const getDetails = useMemo(() => {
-    if (loading) {
-      return (
-        <CaptionSkeletonWrapper>
-          <SkeletonLoader width="17%" height={tileSize === 'medium' ? 20 : 16} bottomSpace={4} />
-          <SkeletonLoader width="28%" height={tileSize === 'medium' ? 24 : 20} />
-        </CaptionSkeletonWrapper>
-      )
-    }
-    switch (nftStatus) {
-      case 'idle':
+    const getDetails = useMemo(() => {
+      if (loading) {
         return (
-          <DetailsContent
-            tileSize={tileSize}
-            caption="Status"
-            content="Not for sale"
-            icon={<SvgActionNotForSale />}
-            secondary
-          />
+          <CaptionSkeletonWrapper>
+            <SkeletonLoader width="17%" height={tileSize === 'medium' ? 20 : 16} bottomSpace={4} />
+            <SkeletonLoader width="28%" height={tileSize === 'medium' ? 24 : 20} />
+          </CaptionSkeletonWrapper>
         )
-      case 'buy-now':
-        return (
-          <DetailsContent
-            tileSize={tileSize}
-            caption="Buy now"
-            content={buyNowPrice ?? 0}
-            icon={<JoyTokenIcon size={16} variant="regular" />}
+      }
+      switch (nftStatus) {
+        case 'idle':
+          return (
+            <DetailsContent
+              tileSize={tileSize}
+              caption="Status"
+              content="Not for sale"
+              icon={<SvgActionNotForSale />}
+              secondary
+            />
+          )
+        case 'buy-now':
+          return (
+            <DetailsContent
+              tileSize={tileSize}
+              caption="Buy now"
+              content={buyNowPrice ?? 0}
+              icon={<JoyTokenIcon size={16} variant="regular" />}
+            />
+          )
+        case 'auction':
+          return (
+            <>
+              {topBid ? (
+                <DetailsContent
+                  tileSize={tileSize}
+                  caption="Top bid"
+                  content={topBid}
+                  icon={<JoyTokenIcon size={16} variant="regular" />}
+                />
+              ) : (
+                <DetailsContent
+                  tileSize={tileSize}
+                  caption="Min bid"
+                  content={startingPrice ?? 0}
+                  icon={<JoyTokenIcon size={16} variant="regular" />}
+                />
+              )}
+              {!!buyNowPrice && (
+                <DetailsContent
+                  tileSize={tileSize}
+                  caption="Buy now"
+                  content={buyNowPrice}
+                  icon={<JoyTokenIcon size={16} variant="regular" />}
+                />
+              )}
+            </>
+          )
+      }
+    }, [loading, nftStatus, tileSize, buyNowPrice, topBid, startingPrice])
+
+    const avatars = useMemo(
+      () => [
+        {
+          url: creator?.assetUrl,
+          tooltipText: `Creator: ${creator?.name}`,
+          onClick: creator?.onClick,
+          loading: creator?.loading,
+        },
+        ...(owner
+          ? [
+              {
+                url: owner?.assetUrl,
+                tooltipText: `Owner: ${owner?.name}`,
+                onClick: owner?.onClick,
+                loading: owner.loading,
+              },
+            ]
+          : []),
+      ],
+      [creator?.assetUrl, creator?.loading, creator?.name, creator?.onClick, owner]
+    )
+
+    return (
+      <Content
+        to={videoHref || ''}
+        ref={contentRef}
+        loading={loading}
+        onMouseEnter={toggleContentHover}
+        onMouseLeave={toggleContentHover}
+        tileSize={tileSize}
+        shouldHover={(contentHovered || hovered) && interactable}
+      >
+        <Header>
+          <StyledAvatarGroup
+            avatarStrokeColor={
+              (contentHovered || hovered) && interactable
+                ? cVar('colorBackground', true)
+                : cVar('colorBackgroundMuted', true)
+            }
+            loading={loading}
+            avatars={avatars}
           />
-        )
-      case 'auction':
-        return (
-          <>
-            {topBid ? (
-              <DetailsContent
-                tileSize={tileSize}
-                caption="Top bid"
-                content={topBid}
-                icon={<JoyTokenIcon size={16} variant="regular" />}
+          {contextMenuItems && (
+            <div>
+              <KebabMenuButtonIcon
+                ref={ref}
+                icon={<SvgActionMore />}
+                variant="tertiary"
+                size="small"
+                isActive={!loading}
+                onClick={(e) => {
+                  e.stopPropagation()
+                  e.preventDefault()
+                }}
               />
-            ) : (
-              <DetailsContent
-                tileSize={tileSize}
-                caption="Min bid"
-                content={startingPrice ?? 0}
-                icon={<JoyTokenIcon size={16} variant="regular" />}
+              <ContextMenu
+                ref={contextMenuInstanceRef}
+                appendTo={document.body}
+                placement="bottom-end"
+                flipEnabled={false}
+                disabled={loading}
+                onShow={() => {
+                  setOpenedContextMenuId(id)
+                }}
+                items={contextMenuItems}
+                trigger={null}
+                triggerTarget={ref.current}
               />
-            )}
-            {!!buyNowPrice && (
-              <DetailsContent
-                tileSize={tileSize}
-                caption="Buy now"
-                content={buyNowPrice}
-                icon={<JoyTokenIcon size={16} variant="regular" />}
-              />
-            )}
-          </>
-        )
-    }
-  }, [loading, nftStatus, tileSize, buyNowPrice, topBid, startingPrice])
-
-  const avatars = useMemo(
-    () => [
-      {
-        url: creator?.assetUrl,
-        tooltipText: `Creator: ${creator?.name}`,
-        onClick: creator?.onClick,
-        loading: creator?.loading,
-      },
-      ...(owner
-        ? [
-            {
-              url: owner?.assetUrl,
-              tooltipText: `Owner: ${owner?.name}`,
-              onClick: owner?.onClick,
-              loading: owner.loading,
-            },
-          ]
-        : []),
-    ],
-    [creator?.assetUrl, creator?.loading, creator?.name, creator?.onClick, owner]
-  )
-
-  return (
-    <Content
-      to={videoHref || ''}
-      ref={contentRef}
-      loading={loading}
-      onMouseEnter={toggleContentHover}
-      onMouseLeave={toggleContentHover}
-      tileSize={tileSize}
-      shouldHover={(contentHovered || hovered) && interactable}
-    >
-      <Header>
-        <StyledAvatarGroup
-          avatarStrokeColor={
-            (contentHovered || hovered) && interactable
-              ? cVar('colorBackground', true)
-              : cVar('colorBackgroundMuted', true)
-          }
-          loading={loading}
-          avatars={avatars}
-        />
-        {contextMenuItems && (
-          <div
-            onClick={(e) => {
-              e.stopPropagation()
-              e.preventDefault()
-            }}
-          >
-            <ContextMenu
-              placement="bottom-end"
-              disabled={loading}
-              items={contextMenuItems}
-              trigger={
-                <KebabMenuButtonIcon icon={<SvgActionMore />} variant="tertiary" size="small" isActive={!loading} />
-              }
-            />
-          </div>
+            </div>
+          )}
+        </Header>
+        {loading ? (
+          <SkeletonLoader width="55.6%" height={24} />
+        ) : (
+          <Title as="h3" variant={tileSize === 'medium' ? 'h400' : 'h300'}>
+            {title}
+          </Title>
         )}
-      </Header>
-      {loading ? (
-        <SkeletonLoader width="55.6%" height={24} />
-      ) : (
-        <Title as="h3" variant={tileSize === 'medium' ? 'h400' : 'h300'}>
-          {title}
-        </Title>
-      )}
-      <Details>{getDetails}</Details>
-    </Content>
-  )
-}
+        <Details>{getDetails}</Details>
+      </Content>
+    )
+  }
+)
+
+NftTileDetails.displayName = 'NftTileDetails'
 
 type DetailsContentProps = {
   caption: string

+ 3 - 1
packages/atlas/src/components/_nft/NftTileViewer/NftTileViewer.tsx

@@ -13,9 +13,10 @@ import { NftTile, NftTileProps } from '../NftTile'
 
 type NftTileViewerProps = {
   nftId?: string
+  isInCarousel?: boolean
 }
 
-export const NftTileViewer: FC<NftTileViewerProps> = ({ nftId }) => {
+export const NftTileViewer: FC<NftTileViewerProps> = ({ nftId, isInCarousel }) => {
   const { nftStatus, nft, loading } = useNft(nftId || '')
   const navigate = useNavigate()
   const thumbnailUrl = nft?.video.thumbnailPhoto?.resolvedUrl
@@ -100,6 +101,7 @@ export const NftTileViewer: FC<NftTileViewerProps> = ({ nftId }) => {
   return (
     <NftTile
       {...nftCommonProps}
+      isInCarousel={isInCarousel}
       timerLoading={timerLoading}
       buyNowPrice={
         nftStatus?.status === 'auction' || nftStatus?.status === 'buy-now' ? nftStatus.buyNowPrice : undefined

+ 23 - 25
packages/atlas/src/components/_overlays/ContextMenu/ContextMenu.tsx

@@ -1,5 +1,6 @@
 import styled from '@emotion/styled'
-import { FC, useRef } from 'react'
+import { forwardRef, useRef } from 'react'
+import { mergeRefs } from 'react-merge-refs'
 
 import { List } from '@/components/List'
 import { ListItemProps, ListItemSizes } from '@/components/ListItem'
@@ -12,31 +13,28 @@ export type ContextMenuProps = {
   size?: ListItemSizes
 } & Omit<PopoverProps, 'content' | 'instanceRef'>
 
-export const ContextMenu: FC<ContextMenuProps> = ({
-  children,
-  items,
-  scrollable = false,
-  size = 'medium',
-  ...rest
-}) => {
-  const contextMenuInstanceRef = useRef<PopoverImperativeHandle>(null)
-  return (
-    <Popover hideOnClick ref={contextMenuInstanceRef} {...rest}>
-      <StyledList
-        scrollable={scrollable}
-        size={size}
-        items={items.map((item) => ({
-          ...item,
-          onClick: (e) => {
-            item.onClick?.(e)
-            contextMenuInstanceRef.current?.hide()
-          },
-        }))}
-      />
-    </Popover>
-  )
-}
+export const ContextMenu = forwardRef<PopoverImperativeHandle, ContextMenuProps>(
+  ({ children, items, scrollable = false, size = 'medium', ...rest }, ref) => {
+    const contextMenuInstanceRef = useRef<PopoverImperativeHandle>(null)
+    return (
+      <Popover hideOnClick ref={mergeRefs([contextMenuInstanceRef, ref])} {...rest}>
+        <StyledList
+          scrollable={scrollable}
+          size={size}
+          items={items.map((item) => ({
+            ...item,
+            onClick: (e) => {
+              item.onClick?.(e)
+              contextMenuInstanceRef.current?.hide()
+            },
+          }))}
+        />
+      </Popover>
+    )
+  }
+)
 
+ContextMenu.displayName = 'ContextMenu'
 export const StyledList = styled(List)`
   width: 192px;
 `

+ 8 - 5
packages/atlas/src/components/_overlays/Popover/Popover.tsx

@@ -21,7 +21,7 @@ export type PopoverProps = PropsWithChildren<{
   className?: string
   appendTo?: Element | 'parent' | ((ref: Element) => Element) | undefined
   onHide?: () => void
-  onShow?: () => void
+  onShow?: (instance?: Instance) => void
   disabled?: boolean
   flipEnabled?: boolean
   animation?: boolean
@@ -103,7 +103,7 @@ const _Popover: ForwardRefRenderFunction<PopoverImperativeHandle | undefined, Po
       onTrigger={onTrigger}
       onShow={(instance) => {
         onTrigger(instance)
-        onShow?.()
+        onShow?.(instance)
       }}
       onHide={(instance) => {
         const box = instance.popper?.firstElementChild
@@ -140,13 +140,16 @@ const _Popover: ForwardRefRenderFunction<PopoverImperativeHandle | undefined, Po
       placement={placement}
       offset={offset}
     >
-      <TriggerContainer tabIndex={1}>{trigger}</TriggerContainer>
+      <TriggerContainer tabIndex={1} isTrigger={!!trigger}>
+        {trigger}
+      </TriggerContainer>
     </Tippy>
   )
 }
 
-const TriggerContainer = styled.div`
-  height: max-content;
+const TriggerContainer = styled.div<{ isTrigger: boolean }>`
+  /* if we use triggerElement, don't set height */
+  height: ${({ isTrigger }) => (isTrigger ? 'max-content' : 'unset')};
 `
 
 const ContentContainer = styled.div<{ animation?: boolean }>`

+ 22 - 0
packages/atlas/src/providers/misc/store.ts

@@ -0,0 +1,22 @@
+import { createStore } from '@/utils/store'
+
+export type MiscStoreState = {
+  openedContexMenuId?: string
+}
+
+type MiscStoreActions = {
+  setOpenedContextMenuId: (id: string) => void
+}
+
+export const useMiscStore = createStore<MiscStoreState, MiscStoreActions>({
+  state: {
+    openedContexMenuId: '',
+  },
+  actionsFactory: (set) => ({
+    setOpenedContextMenuId: (id) => {
+      set((state) => {
+        state.openedContexMenuId = id
+      })
+    },
+  }),
+})

+ 1 - 1
packages/atlas/src/views/viewer/MarketplaceView/FeaturedNftsSection/FeaturedNftsSection.tsx

@@ -147,7 +147,7 @@ export const FeaturedNftsSection: FC = () => {
             }}
             contentProps={{
               type: 'carousel',
-              children: items.map((nft, idx) => <NftTileViewer nftId={nft.id} key={idx} />),
+              children: items.map((nft, idx) => <NftTileViewer isInCarousel nftId={nft.id} key={idx} />),
               spaceBetween: mdMatch ? 24 : 16,
               breakpoints: responsive,
             }}