Skip to content
Permalink
91e8420402
Switch branches/tags

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?
Go to file
 
 
Cannot retrieve contributors at this time
206 lines (198 sloc) 8.12 KB
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