Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
exo/libs/shared/ui/src/lib/Combobox/index.tsx
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork and 2 spoons outside of the repository.
206 lines (198 sloc)
8.12 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState, useEffect } from 'react' | |
import { useCombobox } from 'downshift' | |
import { w, W } from 'windstitch' | |
import Text from '../Text' | |
export const classNames = (...classes: string[]) => { | |
return classes.filter(Boolean).join(' ') | |
} | |
function itemToString(item: ItemType | null) { | |
return item ? item.title : '' | |
} | |
function getFilteredData(inputValue?: string) { | |
return function booksFilter(item: ItemType) { | |
return ( | |
!inputValue || | |
item.title.toLowerCase().includes(inputValue.toLowerCase()) || | |
item.secondaryText?.toLowerCase().includes(inputValue.toLowerCase()) || | |
item.description?.toLowerCase().includes(inputValue.toLowerCase()) | |
) | |
} | |
} | |
const ChevronUpDownIcon = (props: any) => ( | |
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> | |
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /> | |
</svg> | |
) | |
const ClearIcon = (props: any) => ( | |
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {...props}> | |
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> | |
</svg> | |
) | |
const CheckIcon = (props: any) => ( | |
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}> | |
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> | |
</svg> | |
) | |
const size = { | |
xs: 'max-w-full md:max-w-[10rem]', | |
sm: 'max-w-full md:max-w-[15rem]', | |
md: 'max-w-full md:max-w-xs', | |
lg: 'max-w-full md:max-w-sm', | |
xl: 'max-w-full md:max-w-md', | |
'2xl': 'max-w-full md:max-w-lg', | |
'3xl': 'max-w-full md:max-w-2xl', | |
full: 'max-w-full md:max-w-full' | |
} as const | |
type OptionProps = { item: ItemType, selectedItem: ItemType | null; itemProps: any; highlightedIndex: number; index: number; hasStatus?: boolean; hasAvatar?: boolean } | |
type SizeType = W.Infer<typeof InputContainer>['size'] | |
export type ItemType = { | |
id: number | string; | |
title: string; | |
secondaryText?: string; | |
online?: boolean; | |
avatar?: string; | |
description?: string; | |
} | |
export type DataType = ItemType[] | |
export interface ComboboxProps { | |
data: DataType; | |
label?: string; | |
inputPlaceholder?: string; | |
asRow?: boolean; | |
ExpendableIcon?: React.ElementType; | |
size?: SizeType; | |
onChange?: (item: any) => void; | |
} | |
const Container = w.div('relative') | |
const GroupContainer = w.div('w-full max-w-full flex', { | |
variants: { | |
direction: (dir: 'col' | 'row') => dir === 'row' ? 'gap-2 flex-col md:items-center md:flex-row' : 'flex-col gap-1' | |
}, | |
defaultVariants: { | |
direction: 'col' | |
} | |
}) | |
const InputContainer = w.input('relative w-screen flex gap-2 items-center rounded-md border border-gray-300 bg-white py-2 pl-3 text-left shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500', { | |
variants: { | |
size, | |
}, | |
defaultVariants: { | |
size: 'md' | |
} | |
}) | |
const ChevronIconContainer = w.button('absolute inset-y-0 right-2 flex items-center text-gray-400 group') | |
const OptionsContainer = w.ul('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm', { | |
variants: { | |
size, | |
}, | |
defaultVariants: { | |
size: 'md' | |
} | |
}) | |
const OptionInnerContainer = w.div('relative flex items-center gap-2 pr-9') | |
const OptionContainer = w.li('cursor-pointer select-none py-2 pl-3 flex flex-col gap-1', { | |
variants: { | |
hovered: (hovered: boolean) => hovered ? 'bg-gray-600 text-white' : 'text-gray-900' | |
} | |
}) | |
const CheckIconContainer = w.span('absolute inset-y-0 right-0 flex items-center pr-3', { | |
variants: { | |
state: ({ selected, hovered }: { selected: boolean; hovered: boolean}) => selected && hovered ? 'text-white' : selected ? 'text-gray-600' : '' | |
} | |
}) | |
const Description = w.span('', { | |
variants: { | |
hovered: (hovered: boolean) => hovered ? 'text-white' : 'text-gray-600', | |
state: ({ hasStatus, hasAvatar }: { hasStatus?: boolean, hasAvatar?: boolean }) => hasStatus && hasAvatar ? 'pl-9' : hasAvatar ? 'pl-8' : hasStatus ? 'pl-4' : '' | |
} | |
}) | |
export const Combobox = ({ data, label, asRow, ExpendableIcon, size, inputPlaceholder, onChange }: ComboboxProps) => { | |
const [items, setItems] = React.useState(data) | |
const { | |
isOpen, | |
getToggleButtonProps, | |
getLabelProps, | |
getMenuProps, | |
getInputProps, | |
getComboboxProps, | |
highlightedIndex, | |
getItemProps, | |
selectedItem, | |
selectItem, | |
} = useCombobox({ | |
items, | |
onInputValueChange({ inputValue }) { | |
setItems(data.filter(getFilteredData(inputValue))) | |
}, | |
itemToString, | |
}) | |
useEffect(() => { | |
onChange && onChange(selectedItem) | |
}, [selectedItem]) | |
const hasStatus = data.some(i => i.online) | |
const hasAvatar = data.some(i => i.avatar) | |
const otherProps = !isOpen && selectedItem ? { value: selectedItem.title } : {} | |
return data ? ( | |
<Container> | |
<GroupContainer direction={asRow ? 'row' : 'col'} {...getComboboxProps()}> | |
<Label label={label} {...getLabelProps()} /> | |
<div className='relative'> | |
<InputContainer | |
placeholder={inputPlaceholder} | |
className="w-full p-1.5" | |
{...getInputProps()} | |
{...otherProps} | |
/> | |
<ChevronIconContainer aria-hidden="true"> | |
{selectedItem ? <ClearIcon className="h-5 w-5 group-hover:bg-gray-100 rounded" aria-hidden="true" onClick={() => selectItem(null)} /> : ExpendableIcon ? (<ExpendableIcon isOpen={isOpen} />) : <ChevronUpDownIcon className="h-5 w-5 group-hover:bg-gray-100 rounded" aria-hidden="true" {...getToggleButtonProps()} />} | |
</ChevronIconContainer> | |
</div> | |
</GroupContainer> | |
{(isOpen && items.length) ? ( | |
<OptionsContainer size={size} {...getMenuProps()}> | |
{items.map((item, index) => ( | |
<Option itemProps={{ ...getItemProps({ item, index }) }} item={item} selectedItem={selectedItem} highlightedIndex={highlightedIndex} index={index} hasStatus={hasStatus} hasAvatar={hasAvatar} /> | |
))} | |
</OptionsContainer> | |
) : null} | |
</Container> | |
) : null | |
} | |
const Label = ({ label, ...props }: { label: string }) => label ? <Text size='sm' weight='medium' {...props} className='block min-w-max text-gray-700'>{label}</Text> : null | |
const Option = ({ item, selectedItem, itemProps, highlightedIndex, index, hasStatus, hasAvatar }: OptionProps ) => { | |
const selected = selectedItem?.id === item.id | |
const hovered = highlightedIndex === index | |
return ( | |
<OptionContainer {...itemProps} hovered={hovered}> | |
<OptionInnerContainer> | |
<Status show={('online' in item && !item.avatar)} online={!!item.online} /> | |
{item?.avatar ? <Avatar src={item.avatar} state={{hasStatus: !!hasStatus, online: !!item.online}} /> : null} | |
<Text weight={selected ? 'semibold' : 'normal'} size='sm' className='block truncate'> | |
{item.title} | |
{('online' in item) ? <span className="sr-only"> is {item.online ? 'online' : 'offline'}</span> : null} | |
</Text> | |
{item?.secondaryText ? <Text size='sm' className='truncate text-gray-400'>{item?.secondaryText}</Text> : null} | |
{selected ? ( | |
<CheckIconContainer state={{ selected, hovered }}> | |
<CheckIcon className="h-5 w-5" aria-hidden="true" /> | |
</CheckIconContainer> | |
) : null} | |
</OptionInnerContainer> | |
{item.description ? <Description hovered={hovered} state={{hasStatus, hasAvatar}}>{item.description}</Description> : null} | |
</OptionContainer> | |
) | |
} | |
const Avatar = w.img("h-6 w-6 flex-shrink-0 rounded-full box-content", { | |
variants: { | |
state: ({ hasStatus, online }: { hasStatus?: boolean, online?: boolean }) => hasStatus ? online !== null && online !== undefined ? (online ? 'border-[2px] border-green-400' : 'border-gray-200 border-[3px]') : '' : '' | |
} | |
}) | |
const Status = ({ show, online }: { show: boolean; online: boolean}) => ( | |
show ? ( | |
<span | |
aria-label={online ? 'Online' : 'Offline'} | |
className={classNames(online ? 'bg-green-400' : 'bg-gray-200', 'inline-block h-2 w-2 flex-shrink-0 rounded-full')} | |
/> | |
) : null | |
) | |
export default ComboBox |