diff --git a/.changeset/empty-apes-fetch.md b/.changeset/empty-apes-fetch.md new file mode 100644 index 00000000000..6ab2fb2c1dd --- /dev/null +++ b/.changeset/empty-apes-fetch.md @@ -0,0 +1,5 @@ +--- +'@audius/harmony': minor +--- + +Add showFilterInput and popupMaxHeight to FilterButton diff --git a/packages/common/src/audius-query/README.md b/packages/common/src/audius-query/README.md index 2397ef60a9a..e8ecda32037 100644 --- a/packages/common/src/audius-query/README.md +++ b/packages/common/src/audius-query/README.md @@ -13,6 +13,7 @@ - [Pre-fetching related entities](#pre-fetching-related-entities) - [Cascading hooks](#cascading-hooks) - [Pre-fetching in endpoint implementations](#pre-fetching-in-endpoint-implementations) + - [Batching requests](#batching-requests) - [Query Hook options](#query-hook-options) - [Caching](#caching) - [Endpoint response caching](#endpoint-response-caching) diff --git a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx index d82cbff18b2..78ac77ace49 100644 --- a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx +++ b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx @@ -3,10 +3,11 @@ import { forwardRef, RefObject, useRef, useState, useCallback } from 'react' import { CSSObject, useTheme } from '@emotion/react' import { BaseButton } from 'components/button/BaseButton/BaseButton' -import { Box, Flex, Paper } from 'components/layout' +import { TextInput, TextInputSize } from 'components/input' +import { Flex, Paper } from 'components/layout' import { Popup } from 'components/popup' import { useControlled } from 'hooks/useControlled' -import { IconCaretDown, IconCloseAlt } from 'icons' +import { IconCaretDown, IconCloseAlt, IconSearch } from 'icons' import { FilterButtonOption, FilterButtonProps } from './types' @@ -18,10 +19,13 @@ export const FilterButton = forwardRef( options, onSelect, disabled, + showFilterInput, + filterInputPlaceholder = 'Search', variant = 'fillContainer', size = 'default', iconRight = IconCaretDown, popupAnchorOrigin, + popupMaxHeight, popupTransformOrigin, popupPortalLocation, popupZIndex @@ -33,6 +37,7 @@ export const FilterButton = forwardRef( stateName: 'selection', componentName: 'FilterButton' }) + const [filterInputValue, setFilterInputValue] = useState('') const selectedOption = options.find((option) => option.value === selection) const selectedLabel = selectedOption?.label ?? selectedOption?.value @@ -143,6 +148,7 @@ export const FilterButton = forwardRef( } const handleButtonClick = useCallback(() => { + setFilterInputValue('') if (variant === 'fillContainer' && selection !== null) { setSelection(null) // @ts-ignore @@ -190,32 +196,56 @@ export const FilterButton = forwardRef( zIndex={popupZIndex} > - - - {options.map((option) => ( - + + {showFilterInput ? ( + { + e.stopPropagation() }} - onClick={() => handleOptionSelect(option)} - aria-label={option.label ?? option.value} - role='option' - > - {option.label ?? option.value} - - ))} + onChange={(e) => { + setFilterInputValue(e.target.value) + }} + /> + ) : null} + {options + .filter(({ label }) => { + return ( + !filterInputValue || + label + ?.toLowerCase() + .includes(filterInputValue.toLowerCase()) + ) + }) + .map((option) => ( + handleOptionSelect(option)} + aria-label={option.label ?? option.value} + role='option' + > + {option.label ?? option.value} + + ))} - + diff --git a/packages/harmony/src/components/button/FilterButton/types.ts b/packages/harmony/src/components/button/FilterButton/types.ts index 74f595bffff..0c5487b2321 100644 --- a/packages/harmony/src/components/button/FilterButton/types.ts +++ b/packages/harmony/src/components/button/FilterButton/types.ts @@ -76,6 +76,11 @@ export type FilterButtonProps = { */ popupAnchorOrigin?: Origin + /** + * Popup max height + */ + popupMaxHeight?: number + /** * Popup transform origin * @default { horizontal: 'center', vertical: 'top' } @@ -91,4 +96,14 @@ export type FilterButtonProps = { * zIndex applied to the inner Popup component */ popupZIndex?: number + + /** + * Show a text input to filter the options + */ + showFilterInput?: boolean + + /** + * Placeholder text for the filter input + */ + filterInputPlaceholder?: string } diff --git a/packages/web/src/pages/search-page-v2/RecentSearches.tsx b/packages/web/src/pages/search-page-v2/RecentSearches.tsx index 4f741bdd82d..8e72d6c08f4 100644 --- a/packages/web/src/pages/search-page-v2/RecentSearches.tsx +++ b/packages/web/src/pages/search-page-v2/RecentSearches.tsx @@ -211,7 +211,9 @@ const RecentSearchUser = (props: RecentSearchUserProps) => { > - + + + Profile diff --git a/packages/web/src/pages/search-page-v2/SearchHeader.tsx b/packages/web/src/pages/search-page-v2/SearchHeader.tsx new file mode 100644 index 00000000000..d572b5542e8 --- /dev/null +++ b/packages/web/src/pages/search-page-v2/SearchHeader.tsx @@ -0,0 +1,205 @@ +import { ChangeEvent, ReactElement, useCallback } from 'react' + +import { GENRES, Maybe, convertGenreLabelToValue } from '@audius/common/utils' +import { + FilterButton, + Flex, + IconAlbum, + IconNote, + IconPlaylists, + IconUser, + RadioGroup, + SelectablePill, + Text +} from '@audius/harmony' +import { capitalize } from 'lodash' +import { useSearchParams } from 'react-router-dom-v5-compat' + +import Header from 'components/header/desktop/Header' +import { useMedia } from 'hooks/useMedia' + +import { Category, Filter } from './types' + +export const categories = { + all: { filters: [] }, + profiles: { icon: IconUser, filters: ['genre', 'isVerified'] }, + tracks: { + icon: IconNote, + filters: ['genre', 'mood', 'key', 'bpm', 'isPremium', 'hasDownloads'] + }, + albums: { icon: IconAlbum, filters: ['genre', 'mood'] }, + playlists: { icon: IconPlaylists, filters: ['genre', 'mood'] } +} satisfies Record + +export type CategoryKey = keyof typeof categories + +type SearchHeaderProps = { + category?: CategoryKey + setCategory: (category: CategoryKey) => void + title: string + query: Maybe +} + +const GenreFilter = () => { + const [urlSearchParams, setUrlSearchParams] = useSearchParams() + const genre = urlSearchParams.get('genre') + + return ( + { + if (value) { + setUrlSearchParams((params) => ({ ...params, genre: value })) + } else { + setUrlSearchParams(({ genre, ...params }: any) => params) + } + }} + options={GENRES.map((genre) => ({ + label: genre, + value: convertGenreLabelToValue(genre) + }))} + showFilterInput + filterInputPlaceholder='Search genre' + /> + ) +} + +const filters: Record ReactElement> = { + genre: GenreFilter, + mood: () => ( + + ), + key: () => ( + + ), + bpm: () => ( + + ), + isPremium: () => ( + + ), + hasDownloads: () => ( + + ), + isVerified: () => ( + + ) +} + +export const SearchHeader = (props: SearchHeaderProps) => { + const { category: categoryKey = 'all', setCategory, query, title } = props + + const { isMobile } = useMedia() + + const handleChange = useCallback( + (e: ChangeEvent) => { + const value = e.target.value + setCategory(value as CategoryKey) + }, + [setCategory] + ) + + const filterKeys = categories[categoryKey].filters + + const categoryRadioGroup = ( + + {Object.entries(categories) + .filter(([key]) => !isMobile || key !== 'all') + .map(([key, category]) => ( + + ))} + + ) + + return isMobile ? ( + + {categoryRadioGroup} + + ) : ( +
+ + “{query}” + + + ) : null + } + bottomBar={ + + {filterKeys.map((filterKey) => { + const FilterComponent = filters[filterKey] + return + })} + + } + rightDecorator={categoryRadioGroup} + variant='main' + /> + ) +} diff --git a/packages/web/src/pages/search-page-v2/SearchPageV2.tsx b/packages/web/src/pages/search-page-v2/SearchPageV2.tsx index 8bff894377c..31ef8c6007c 100644 --- a/packages/web/src/pages/search-page-v2/SearchPageV2.tsx +++ b/packages/web/src/pages/search-page-v2/SearchPageV2.tsx @@ -1,24 +1,10 @@ -import { ChangeEvent, useCallback, useContext, useEffect } from 'react' +import { useCallback, useContext, useEffect } from 'react' import { Status } from '@audius/common/models' -import { Maybe } from '@audius/common/utils' -import { - Flex, - IconAlbum, - IconComponent, - IconNote, - IconPlaylists, - IconUser, - LoadingSpinner, - RadioGroup, - SelectablePill, - Text -} from '@audius/harmony' -import { capitalize } from 'lodash' +import { Flex, LoadingSpinner } from '@audius/harmony' import { useParams } from 'react-router-dom' import { useHistoryContext } from 'app/HistoryProvider' -import Header from 'components/header/desktop/Header' import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' import NavContext, { CenterPreset, @@ -30,102 +16,7 @@ import { useMedia } from 'hooks/useMedia' import { RecentSearches } from './RecentSearches' import { SearchCatalogTile } from './SearchCatalogTile' - -type Filter = - | 'genre' - | 'mood' - | 'key' - | 'bpm' - | 'isPremium' - | 'hasDownloads' - | 'isVerified' - -type Category = { - filters: Filter[] - icon?: IconComponent -} - -const categories = { - all: { filters: [] }, - profiles: { icon: IconUser, filters: ['genre', 'isVerified'] }, - tracks: { - icon: IconNote, - filters: ['genre', 'mood', 'key', 'bpm', 'isPremium', 'hasDownloads'] - }, - albums: { icon: IconAlbum, filters: ['genre', 'mood'] }, - playlists: { icon: IconPlaylists, filters: ['genre', 'mood'] } -} satisfies Record - -type CategoryKey = keyof typeof categories - -type SearchHeaderProps = { - category?: CategoryKey - setCategory: (category: CategoryKey) => void - title: string - query: Maybe -} - -const SearchHeader = (props: SearchHeaderProps) => { - const { category: categoryKey = 'all', setCategory, query, title } = props - - const { isMobile } = useMedia() - - const handleChange = useCallback( - (e: ChangeEvent) => { - const value = e.target.value - setCategory(value as CategoryKey) - }, - [setCategory] - ) - - const categoryRadioGroup = ( - - {Object.entries(categories) - .filter(([key]) => !isMobile || key !== 'all') - .map(([key, category]) => ( - - ))} - - ) - - return isMobile ? ( - - {categoryRadioGroup} - - ) : ( -
- - “{query}” - - - ) : null - } - rightDecorator={categoryRadioGroup} - variant='main' - /> - ) -} +import { CategoryKey, SearchHeader } from './SearchHeader' export const SearchPageV2 = () => { const { isMobile } = useMedia() diff --git a/packages/web/src/pages/search-page-v2/types.ts b/packages/web/src/pages/search-page-v2/types.ts new file mode 100644 index 00000000000..b7b362ee490 --- /dev/null +++ b/packages/web/src/pages/search-page-v2/types.ts @@ -0,0 +1,15 @@ +import { IconComponent } from '@audius/harmony' + +export type Filter = + | 'genre' + | 'mood' + | 'key' + | 'bpm' + | 'isPremium' + | 'hasDownloads' + | 'isVerified' + +export type Category = { + filters: Filter[] + icon?: IconComponent +}