Commit 49c5cbbf authored by Charles Bachmeier's avatar Charles Bachmeier Committed by GitHub

feat: Add sell page filters sidebar (#4630)

* working sell filters

* split filters into own file

* include new file

* fix eslint warnings

* update filter button param and fix rerender bug

* de morgon's law

* usecallback

* move max_padding

* extend htmlinputelement for checkbox

* styles cleanup

* simplify checkbox sprinkles

* add null check to collectionfilteritem

* remove x axis scrollbar on collections

* update fitlerbutton logic

* scrollbar width
Co-authored-by: default avatarCharles Bachmeier <charlie@genie.xyz>
parent efaefe2e
......@@ -3,8 +3,9 @@ import { Box } from 'nft/components/Box'
import * as styles from 'nft/components/collection/FilterButton.css'
import { Row } from 'nft/components/Flex'
import { FilterIcon } from 'nft/components/icons'
import { useCollectionFilters } from 'nft/hooks'
import { useCollectionFilters, useWalletCollections } from 'nft/hooks'
import { putCommas } from 'nft/utils/putCommas'
import { useLocation } from 'react-router-dom'
export const FilterButton = ({
onClick,
......@@ -26,8 +27,13 @@ export const FilterButton = ({
markets: state.markets,
buyNow: state.buyNow,
}))
const collectionFilters = useWalletCollections((state) => state.collectionFilters)
const { pathname } = useLocation()
const isSellPage = pathname.startsWith('/nfts/sell')
const showFilterBadge = minPrice || maxPrice || minRarity || maxRarity || traits.length || markets.length || buyNow
const showFilterBadge = isSellPage
? collectionFilters.length > 0
: minPrice || maxPrice || minRarity || maxRarity || traits.length || markets.length || buyNow
return (
<Box
className={clsx(styles.filterButton, !isFiltersExpanded && styles.filterButtonExpanded)}
......
......@@ -12,19 +12,21 @@ const pathAnim = keyframes({
const pathAnimCommonProps = {
animationDirection: 'alternate',
animationTimingFunction: 'linear',
animation: `0.5s infinite ${pathAnim}`,
}
export const path = style({
selectors: {
'&:nth-child(1)': {
animation: `0.5s infinite ${pathAnim}`,
...pathAnimCommonProps,
},
'&:nth-child(2)': {
animation: `0.5s infinite ${pathAnim}`,
animationDelay: '0.1s',
...pathAnimCommonProps,
},
'&:nth-child(3)': {
animation: `0.5s infinite ${pathAnim}`,
animationDelay: '0.2s',
...pathAnimCommonProps,
},
......
import { style } from '@vanilla-extract/css'
export const activeDropdown = style({
borderBottom: 'none',
})
export const activeDropDownItems = style({
borderTop: 'none',
})
import clsx from 'clsx'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { Box } from 'nft/components/Box'
import { Row } from 'nft/components/Flex'
import { ArrowsIcon, ChevronUpIcon, ReversedArrowsIcon } from 'nft/components/icons'
import { buttonTextMedium } from 'nft/css/common.css'
import { themeVars } from 'nft/css/sprinkles.css'
import { DropDownOption } from 'nft/types'
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'
import * as styles from './SortDropdown.css'
export const SortDropdown = ({
dropDownOptions,
inFilters,
mini,
miniPrompt,
top,
left,
}: {
dropDownOptions: DropDownOption[]
inFilters?: boolean
mini?: boolean
miniPrompt?: string
top?: number
left?: number
}) => {
const [isOpen, toggleOpen] = useReducer((s) => !s, false)
const [isReversed, toggleReversed] = useReducer((s) => !s, false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [maxWidth, setMaxWidth] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useOnClickOutside(ref, () => isOpen && toggleOpen())
useEffect(() => setMaxWidth(0), [dropDownOptions])
const reversable = useMemo(
() => dropDownOptions[selectedIndex].reverseOnClick || dropDownOptions[selectedIndex].reverseIndex,
[selectedIndex, dropDownOptions]
)
return (
<Box
ref={ref}
transition="250"
borderRadius="12"
borderBottomLeftRadius={isOpen ? '0' : undefined}
borderBottomRightRadius={isOpen ? '0' : undefined}
height="44"
style={{ width: inFilters ? 'full' : mini ? 'min' : maxWidth ? maxWidth : '300px' }}
>
<Box
as="button"
fontSize="14"
borderRadius="12"
borderStyle={isOpen && !mini ? 'solid' : 'none'}
background={mini ? 'none' : 'lightGray'}
borderColor="medGray"
borderWidth="1px"
borderBottomLeftRadius={isOpen ? '0' : undefined}
borderBottomRightRadius={isOpen ? '0' : undefined}
padding={inFilters ? '12' : mini ? '0' : '8'}
color="blackBlue"
whiteSpace="nowrap"
display="flex"
justifyContent="space-between"
alignItems="center"
width={inFilters ? 'full' : 'inherit'}
onClick={toggleOpen}
cursor="pointer"
className={clsx(isOpen && !mini && styles.activeDropdown)}
>
<Box display="flex" alignItems="center">
{!isOpen && reversable && (
<Row
onClick={(e) => {
e.stopPropagation()
if (dropDownOptions[selectedIndex].reverseOnClick) {
dropDownOptions[selectedIndex].reverseOnClick?.()
toggleReversed()
} else {
dropDownOptions[dropDownOptions[selectedIndex].reverseIndex ?? 1 - 1].onClick()
setSelectedIndex(dropDownOptions[selectedIndex].reverseIndex ?? 1 - 1)
}
}}
>
{dropDownOptions[selectedIndex].reverseOnClick && (isReversed ? <ArrowsIcon /> : <ReversedArrowsIcon />)}
{dropDownOptions[selectedIndex].reverseIndex &&
(selectedIndex > (dropDownOptions[selectedIndex].reverseIndex ?? 1) - 1 ? (
<ArrowsIcon />
) : (
<ReversedArrowsIcon />
))}
</Row>
)}
<Box
marginLeft={reversable ? '4' : '0'}
marginRight={mini ? '2' : '0'}
color="blackBlue"
className={buttonTextMedium}
>
{mini ? miniPrompt : isOpen ? 'Sort by' : dropDownOptions[selectedIndex].displayText}
</Box>
</Box>
<ChevronUpIcon
secondaryColor={mini ? themeVars.colors.blackBlue : undefined}
secondaryWidth={mini ? '20' : undefined}
secondaryHeight={mini ? '20' : undefined}
style={{
transform: isOpen ? '' : 'rotate(180deg)',
}}
/>
</Box>
<Box
position="absolute"
zIndex="2"
width={inFilters ? 'auto' : 'inherit'}
right={inFilters ? '16' : 'auto'}
paddingBottom="8"
fontSize="14"
background="lightGray"
borderStyle="solid"
borderColor="medGray"
borderWidth="1px"
borderRadius="8"
borderTopLeftRadius={mini ? undefined : '0'}
borderTopRightRadius={mini ? undefined : '0'}
overflowY="hidden"
transition="250"
display={isOpen || !maxWidth ? 'block' : 'none'}
visibility={maxWidth ? 'visible' : 'hidden'}
marginTop={mini ? '12' : '0'}
className={clsx(!mini && styles.activeDropDownItems)}
style={{
top: top ? `${top}px` : 'inherit',
left: inFilters ? '16px' : left ? `${left}px` : 'inherit',
}}
>
{!maxWidth
? [
dropDownOptions.reduce((acc, curr) => {
return curr.displayText.length >= acc.displayText.length ? curr : acc
}, dropDownOptions[0]),
].map((option, index) => {
return <LargestItem key={index} option={option} index={index} setMaxWidth={setMaxWidth} />
})
: isOpen &&
dropDownOptions.map((option, index) => {
return (
<DropDownItem
key={index}
option={option}
index={index}
mini={mini}
onClick={() => {
dropDownOptions[index].onClick()
setSelectedIndex(index)
toggleOpen()
isReversed && toggleReversed()
}}
/>
)
})}
</Box>
</Box>
)
}
const DropDownItem = ({
option,
index,
onClick,
mini,
}: {
option: DropDownOption
index: number
onClick?: () => void
mini?: boolean
}) => {
return (
<Box
as="button"
border="none"
key={index}
display="flex"
alignItems="center"
paddingTop="10"
paddingBottom="10"
paddingLeft="12"
paddingRight={mini ? '20' : '0'}
width="full"
background={{
default: 'lightGray',
hover: 'lightGrayButton',
}}
color="blackBlue"
onClick={onClick}
cursor="pointer"
>
{option.icon && (
<Box width="28" height="28">
{option.icon}
</Box>
)}
<Box marginLeft="8" className={buttonTextMedium}>
{option.displayText}
</Box>
</Box>
)
}
const MAX_PADDING = 52
const LargestItem = ({
option,
index,
setMaxWidth,
}: {
option: DropDownOption
index: number
setMaxWidth: (width: number) => void
}) => {
const maxWidthRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (maxWidthRef && maxWidthRef.current) {
setMaxWidth(Math.ceil(maxWidthRef.current.getBoundingClientRect().width) + MAX_PADDING)
}
})
return (
<Box key={index} position="absolute" ref={maxWidthRef}>
<DropDownItem option={option} index={index} />
</Box>
)
}
import { style } from '@vanilla-extract/css'
import { sprinkles } from 'nft/css/sprinkles.css'
export const input = style([
sprinkles({ position: 'absolute' }),
{
top: '-24px',
selectors: {
'&[type="checkbox"]': {
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: '1px',
overflow: 'hidden',
position: 'absolute',
whiteSpace: 'nowrap',
width: '1px',
},
},
},
])
export const checkbox = style([
sprinkles({
display: 'inline-block',
marginRight: '1',
borderRadius: '4',
height: '24',
width: '24',
borderStyle: 'solid',
borderWidth: '2px',
}),
])
export const checkMark = sprinkles({
display: 'none',
height: '24',
width: '24',
color: 'blue400',
})
export const checkMarkActive = style([
sprinkles({
display: 'inline-block',
color: 'blue400',
position: 'absolute',
top: '0',
right: '1',
}),
])
import clsx from 'clsx'
import { Box } from 'nft/components/Box'
import { ApprovedCheckmarkIcon } from 'nft/components/icons'
import React from 'react'
import * as styles from './Checkbox.css'
interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
hovered: boolean
children: React.ReactNode
}
export const Checkbox: React.FC<CheckboxProps> = ({ hovered, children, ...props }: CheckboxProps) => {
return (
<Box
as="label"
display="flex"
alignItems="center"
position="relative"
overflow="hidden"
cursor="pointer"
lineHeight="1"
>
{children}
<Box
as="span"
borderColor={props.checked || hovered ? 'blue400' : 'grey400'}
className={styles.checkbox}
// This element is purely decorative so
// we hide it for screen readers
aria-hidden="true"
/>
<input {...props} className={styles.input} type="checkbox" />
<ApprovedCheckmarkIcon className={clsx(styles.checkMark, props.checked && styles.checkMarkActive)} />
</Box>
)
}
import { AnimatedBox, Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { XMarkIcon } from 'nft/components/icons'
import { Checkbox } from 'nft/components/layout/Checkbox'
import { buttonTextSmall, headlineSmall } from 'nft/css/common.css'
import { themeVars } from 'nft/css/sprinkles.css'
import { useFiltersExpanded, useIsMobile, useWalletCollections } from 'nft/hooks'
import { WalletCollection } from 'nft/types'
import { Dispatch, FormEvent, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react'
import { useSpring } from 'react-spring/web'
import * as styles from './SelectPage.css'
export const FilterSidebar = ({ SortDropdown }: { SortDropdown: () => JSX.Element }) => {
const collectionFilters = useWalletCollections((state) => state.collectionFilters)
const setCollectionFilters = useWalletCollections((state) => state.setCollectionFilters)
const walletCollections = useWalletCollections((state) => state.walletCollections)
const listFilter = useWalletCollections((state) => state.listFilter)
const setListFilter = useWalletCollections((state) => state.setListFilter)
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
const isMobile = useIsMobile()
const { sidebarX } = useSpring({
sidebarX: isFiltersExpanded ? 0 : -360,
})
return (
// @ts-ignore
<AnimatedBox
position={{ sm: 'fixed', md: 'sticky' }}
top={{ sm: '40', md: 'unset' }}
left={{ sm: '0', md: 'unset' }}
width={{ sm: 'full', md: 'auto' }}
height={{ sm: 'full', md: 'auto' }}
zIndex={{ sm: '3', md: 'auto' }}
display={isFiltersExpanded ? 'flex' : 'none'}
style={{ transform: sidebarX.interpolate((x) => `translateX(${x}px)`) }}
>
<Box
paddingTop={{ sm: '24', md: '0' }}
paddingLeft={{ sm: '16', md: '0' }}
paddingRight="16"
width={{ sm: 'full', md: 'auto' }}
>
<Row width="full" justifyContent="space-between">
<Row as="span" className={headlineSmall} color="blackBlue">
Filters
</Row>
{isMobile && (
<Box
as="button"
border="none"
backgroundColor="transparent"
color="darkGray"
onClick={() => setFiltersExpanded(false)}
>
<XMarkIcon fill={themeVars.colors.blackBlue} />
</Box>
)}
</Row>
<Row marginTop="14" marginLeft="2" gap="6" flexWrap="wrap" width="276">
<ListStatusFilterButtons listFilter={listFilter} setListFilter={setListFilter} />
</Row>
{isMobile && (
<Box paddingTop="20">
<SortDropdown />
</Box>
)}
<CollectionSelect
collections={walletCollections}
collectionFilters={collectionFilters}
setCollectionFilters={setCollectionFilters}
/>
</Box>
</AnimatedBox>
)
}
const CollectionSelect = ({
collections,
collectionFilters,
setCollectionFilters,
}: {
collections: WalletCollection[]
collectionFilters: Array<string>
setCollectionFilters: (address: string) => void
}) => {
const [collectionSearchText, setCollectionSearchText] = useState('')
const [displayCollections, setDisplayCollections] = useState(collections)
useEffect(() => {
if (collectionSearchText) {
const filtered = collections.filter((collection) =>
collection.name?.toLowerCase().includes(collectionSearchText.toLowerCase())
)
setDisplayCollections(filtered)
} else {
setDisplayCollections(collections)
}
}, [collectionSearchText, collections])
return (
<>
<Box className={headlineSmall} marginTop="20" marginBottom="12">
Collections
</Box>
<Box paddingBottom="12" paddingTop="0" borderRadius="8">
<Column as="ul" paddingLeft="0" gap="10" style={{ maxHeight: '508px' }}>
<CollectionFilterSearch
collectionSearchText={collectionSearchText}
setCollectionSearchText={setCollectionSearchText}
/>
<Box
background="lightGray"
borderRadius="12"
paddingTop="8"
paddingBottom="8"
overflowY="scroll"
style={{ scrollbarWidth: 'none' }}
>
{displayCollections?.map((collection, index) => (
<CollectionItem
key={index}
collection={collection}
collectionFilters={collectionFilters}
setCollectionFilters={setCollectionFilters}
/>
))}
</Box>
</Column>
</Box>
</>
)
}
const CollectionFilterSearch = ({
collectionSearchText,
setCollectionSearchText,
}: {
collectionSearchText: string
setCollectionSearchText: Dispatch<SetStateAction<string>>
}) => {
return (
<Box
as="input"
borderColor={{ default: 'medGray', focus: 'genieBlue' }}
borderWidth="1px"
borderStyle="solid"
borderRadius="8"
padding="12"
marginLeft="0"
marginBottom="24"
backgroundColor="white"
fontSize="14"
color={{ placeholder: 'darkGray', default: 'blackBlue' }}
placeholder="Search collections"
value={collectionSearchText}
onChange={(e: FormEvent<HTMLInputElement>) => setCollectionSearchText(e.currentTarget.value)}
/>
)
}
const CollectionItem = ({
collection,
collectionFilters,
setCollectionFilters,
}: {
collection: WalletCollection
collectionFilters: Array<string>
setCollectionFilters: (address: string) => void
}) => {
const [isCheckboxSelected, setCheckboxSelected] = useState(false)
const [hovered, toggleHovered] = useReducer((state) => {
return !state
}, false)
const isChecked = useCallback(
(address: string) => {
return collectionFilters.some((collection) => collection === address)
},
[collectionFilters]
)
const handleCheckbox = () => {
setCheckboxSelected(!isCheckboxSelected)
setCollectionFilters(collection.address)
}
return (
<Row
cursor="pointer"
paddingRight="14"
height="44"
as="li"
background={hovered ? 'medGray' : undefined}
onMouseEnter={toggleHovered}
onMouseLeave={toggleHovered}
onClick={handleCheckbox}
>
<Box as="img" borderRadius="round" marginLeft="16" width="20" height="20" src={collection.image} />
<Box as="span" marginLeft="6" marginRight="auto" className={styles.collectionName}>
{collection.name}{' '}
</Box>
<Checkbox checked={isChecked(collection.address)} hovered={hovered} onChange={handleCheckbox}>
<Box as="span" color="darkGray" marginRight="12" marginLeft="auto">
{collection.count}
</Box>
</Checkbox>
</Row>
)
}
const statusArray = ['All', 'Unlisted', 'Listed']
const ListStatusFilterButtons = ({
listFilter,
setListFilter,
}: {
listFilter: string
setListFilter: (value: string) => void
}) => {
return (
<>
{statusArray.map((value, index) => (
<Row
key={index}
borderRadius="12"
backgroundColor="medGray"
height="44"
className={value === listFilter ? styles.buttonSelected : null}
onClick={() => setListFilter(value)}
width="max"
padding="14"
cursor="pointer"
>
<Box className={buttonTextSmall}>{value}</Box>
</Row>
))}
</>
)
}
......@@ -21,7 +21,7 @@ export const mobileSellWrapper = style([
top: { sm: '0', md: 'unset' },
zIndex: { sm: '3', md: 'auto' },
height: { sm: 'full', md: 'auto' },
width: { sm: 'full', md: 'auto' },
width: 'full',
overflowY: 'scroll',
}),
{
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment