216 lines
5.7 KiB
TypeScript
216 lines
5.7 KiB
TypeScript
import { Popover, Transition } from '@headlessui/react'
|
|
import classNames from 'classnames'
|
|
import React, { ReactNode, useRef } from 'react'
|
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
|
import { popover, BlurMenuButtonOnEscape } from './popover'
|
|
import { useSearchableItems } from '../hooks/use-searchable-items'
|
|
import { SearchInput } from './search-input'
|
|
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
|
|
|
|
export const TabWrapper = ({
|
|
className,
|
|
children
|
|
}: {
|
|
className?: string
|
|
children: ReactNode
|
|
}) => (
|
|
<div
|
|
className={classNames(
|
|
'flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline',
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
const TabButtonText = ({
|
|
children,
|
|
active
|
|
}: {
|
|
children: ReactNode
|
|
active: boolean
|
|
}) => (
|
|
<span
|
|
className={classNames('truncate text-left transition-colors duration-150', {
|
|
'hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer':
|
|
!active,
|
|
'text-indigo-600 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-600 dark:decoration-indigo-500':
|
|
active
|
|
})}
|
|
>
|
|
{children}
|
|
</span>
|
|
)
|
|
|
|
export const TabButton = ({
|
|
className,
|
|
children,
|
|
onClick,
|
|
active
|
|
}: {
|
|
className?: string
|
|
children: ReactNode
|
|
onClick: () => void
|
|
active: boolean
|
|
}) => (
|
|
<button className={classNames('rounded-sm', className)} onClick={onClick}>
|
|
<TabButtonText active={active}>{children}</TabButtonText>
|
|
</button>
|
|
)
|
|
|
|
export const DropdownTabButton = ({
|
|
className,
|
|
transitionClassName,
|
|
active,
|
|
children,
|
|
...optionsProps
|
|
}: {
|
|
className?: string
|
|
transitionClassName?: string
|
|
active: boolean
|
|
children: ReactNode
|
|
} & Omit<ItemsProps, 'closeDropdown'>) => {
|
|
const dropdownButtonRef = useRef<HTMLButtonElement>(null)
|
|
|
|
return (
|
|
<Popover className={className}>
|
|
{({ close: closeDropdown }) => (
|
|
<>
|
|
<BlurMenuButtonOnEscape targetRef={dropdownButtonRef} />
|
|
<Popover.Button
|
|
className="inline-flex justify-between rounded-xs"
|
|
ref={dropdownButtonRef}
|
|
>
|
|
<TabButtonText active={active}>{children}</TabButtonText>
|
|
|
|
<div
|
|
className="flex self-stretch -mr-1 ml-1 items-center"
|
|
aria-hidden="true"
|
|
>
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</Popover.Button>
|
|
|
|
<Transition
|
|
as="div"
|
|
{...popover.transition.props}
|
|
className={classNames(
|
|
popover.transition.classNames.fullwidth,
|
|
'mt-2',
|
|
transitionClassName
|
|
)}
|
|
>
|
|
<Popover.Panel className={popover.panel.classNames.roundedSheet}>
|
|
<Items closeDropdown={closeDropdown} {...optionsProps} />
|
|
</Popover.Panel>
|
|
</Transition>
|
|
</>
|
|
)}
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
type ItemsProps = {
|
|
closeDropdown: () => void
|
|
options: Array<{ selected: boolean; onClick: () => void; label: string }>
|
|
searchable?: boolean
|
|
collectionTitle?: string
|
|
}
|
|
|
|
const Items = ({
|
|
options,
|
|
searchable,
|
|
collectionTitle,
|
|
closeDropdown
|
|
}: ItemsProps) => {
|
|
const {
|
|
filteredData,
|
|
showableData,
|
|
showSearch,
|
|
searching,
|
|
searchRef,
|
|
handleSearchInput,
|
|
handleClearSearch,
|
|
handleShowAll,
|
|
countOfMoreToShow
|
|
} = useSearchableItems({
|
|
data: options,
|
|
maxItemsInitially: searchable ? 5 : options.length,
|
|
itemMatchesSearchValue: (option, trimmedSearchString) =>
|
|
option.label.toLowerCase().includes(trimmedSearchString.toLowerCase())
|
|
})
|
|
|
|
const itemClassName = classNames(
|
|
'w-full text-left',
|
|
popover.items.classNames.navigationLink,
|
|
popover.items.classNames.selectedOption,
|
|
popover.items.classNames.hoverLink,
|
|
{
|
|
[popover.items.classNames.roundedStart]: !searchable || !showSearch
|
|
// when the menu is not searchable, the first item needs rounded top
|
|
},
|
|
popover.items.classNames.roundedEnd
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{searchable && showSearch && (
|
|
<div className="flex items-center py-2 px-4">
|
|
{collectionTitle && (
|
|
<div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4">
|
|
{collectionTitle}
|
|
</div>
|
|
)}
|
|
<SearchInput
|
|
searchRef={searchRef}
|
|
placeholderUnfocused="Press / to search"
|
|
className="ml-auto w-full py-1 text-sm"
|
|
onSearch={handleSearchInput}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={'max-h-[210px] overflow-y-scroll'}>
|
|
{showableData.map(({ selected, label, onClick }, index) => {
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => {
|
|
onClick()
|
|
closeDropdown()
|
|
}}
|
|
data-selected={selected}
|
|
className={itemClassName}
|
|
>
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
{countOfMoreToShow > 0 && (
|
|
<button
|
|
onClick={handleShowAll}
|
|
className={classNames(
|
|
itemClassName,
|
|
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
|
|
)}
|
|
>
|
|
{`Show ${countOfMoreToShow} more`}
|
|
<EllipsisHorizontalIcon className="block w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{searching && !filteredData.length && (
|
|
<button
|
|
className={classNames(
|
|
itemClassName,
|
|
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
|
|
)}
|
|
onClick={handleClearSearch}
|
|
>
|
|
No items found. Clear search to show all.
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|