-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
2,060 additions
and
186 deletions.
There are no files selected for viewing
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
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
329 changes: 329 additions & 0 deletions
329
packages/filigran-ui/src/components/clients/tag-input/autocomplete.tsx
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,329 @@ | ||
import React, {useCallback, useEffect, useRef, useState} from 'react' | ||
import {type TagInputStyleClassesProps, type Tag as TagType} from './tag-input' | ||
import {Popover, PopoverContent, PopoverTrigger} from '../popover' | ||
import {cn} from '../../../lib/utils' | ||
import {Button} from '../../servers/button' | ||
|
||
type AutocompleteProps = { | ||
tags: TagType[] | ||
setTags: React.Dispatch<React.SetStateAction<TagType[]>> | ||
setInputValue: React.Dispatch<React.SetStateAction<string>> | ||
setTagCount: React.Dispatch<React.SetStateAction<number>> | ||
autocompleteOptions: TagType[] | ||
maxTags?: number | ||
onTagAdd?: (tag: string) => void | ||
onTagRemove?: (tag: string) => void | ||
allowDuplicates: boolean | ||
children: React.ReactNode | ||
inlineTags?: boolean | ||
classStyleProps: TagInputStyleClassesProps['autoComplete'] | ||
usePortal?: boolean | ||
} | ||
|
||
export const Autocomplete: React.FC<AutocompleteProps> = ({ | ||
tags, | ||
setTags, | ||
setInputValue, | ||
setTagCount, | ||
autocompleteOptions, | ||
maxTags, | ||
onTagAdd, | ||
onTagRemove, | ||
allowDuplicates, | ||
inlineTags, | ||
children, | ||
classStyleProps, | ||
usePortal, | ||
}) => { | ||
const triggerContainerRef = useRef<HTMLDivElement | null>(null) | ||
const triggerRef = useRef<HTMLButtonElement | null>(null) | ||
const inputRef = useRef<HTMLInputElement | null>(null) | ||
const popoverContentRef = useRef<HTMLDivElement | null>(null) | ||
|
||
const [popoverWidth, setPopoverWidth] = useState<number>(0) | ||
const [isPopoverOpen, setIsPopoverOpen] = useState(false) | ||
const [inputFocused, setInputFocused] = useState(false) | ||
const [popooverContentTop, setPopoverContentTop] = useState<number>(0) | ||
const [selectedIndex, setSelectedIndex] = useState<number>(-1) | ||
|
||
// Dynamically calculate the top position for the popover content | ||
useEffect(() => { | ||
if (!triggerContainerRef.current || !triggerRef.current) return | ||
setPopoverContentTop( | ||
triggerContainerRef.current?.getBoundingClientRect().bottom - | ||
triggerRef.current?.getBoundingClientRect().bottom | ||
) | ||
}, [tags]) | ||
|
||
// Close the popover when clicking outside of it | ||
useEffect(() => { | ||
const handleOutsideClick = ( | ||
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent | ||
) => { | ||
if ( | ||
isPopoverOpen && | ||
triggerContainerRef.current && | ||
popoverContentRef.current && | ||
!triggerContainerRef.current.contains(event.target as Node) && | ||
!popoverContentRef.current.contains(event.target as Node) | ||
) { | ||
setIsPopoverOpen(false) | ||
} | ||
} | ||
|
||
document.addEventListener('mousedown', handleOutsideClick) | ||
|
||
return () => { | ||
document.removeEventListener('mousedown', handleOutsideClick) | ||
} | ||
}, [isPopoverOpen]) | ||
|
||
const handleOpenChange = useCallback( | ||
(open: boolean) => { | ||
if (open && triggerContainerRef.current) { | ||
const {width} = triggerContainerRef.current.getBoundingClientRect() | ||
setPopoverWidth(width) | ||
} | ||
|
||
if (open) { | ||
inputRef.current?.focus() | ||
setIsPopoverOpen(open) | ||
} | ||
}, | ||
[inputFocused] | ||
) | ||
|
||
const handleInputFocus = ( | ||
event: | ||
| React.FocusEvent<HTMLInputElement> | ||
| React.FocusEvent<HTMLTextAreaElement> | ||
) => { | ||
if (triggerContainerRef.current) { | ||
const {width} = triggerContainerRef.current.getBoundingClientRect() | ||
setPopoverWidth(width) | ||
setIsPopoverOpen(true) | ||
} | ||
|
||
// Only set inputFocused to true if the popover is already open. | ||
// This will prevent the popover from opening due to an input focus if it was initially closed. | ||
if (isPopoverOpen) { | ||
setInputFocused(true) | ||
} | ||
|
||
const userOnFocus = (children as React.ReactElement<any>).props.onFocus | ||
if (userOnFocus) userOnFocus(event) | ||
} | ||
|
||
const handleInputBlur = ( | ||
event: | ||
| React.FocusEvent<HTMLInputElement> | ||
| React.FocusEvent<HTMLTextAreaElement> | ||
) => { | ||
setInputFocused(false) | ||
|
||
// Allow the popover to close if no other interactions keep it open | ||
if (!isPopoverOpen) { | ||
setIsPopoverOpen(false) | ||
} | ||
|
||
const userOnBlur = (children as React.ReactElement<any>).props.onBlur | ||
if (userOnBlur) userOnBlur(event) | ||
} | ||
|
||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||
if (!isPopoverOpen) return | ||
|
||
switch (event.key) { | ||
case 'ArrowUp': | ||
event.preventDefault() | ||
setSelectedIndex((prevIndex) => | ||
prevIndex <= 0 ? autocompleteOptions.length - 1 : prevIndex - 1 | ||
) | ||
break | ||
case 'ArrowDown': | ||
event.preventDefault() | ||
setSelectedIndex((prevIndex) => | ||
prevIndex === autocompleteOptions.length - 1 ? 0 : prevIndex + 1 | ||
) | ||
break | ||
case 'Enter': | ||
event.preventDefault() | ||
if (selectedIndex !== -1) { | ||
toggleTag(autocompleteOptions[selectedIndex]) | ||
setSelectedIndex(-1) | ||
} | ||
break | ||
} | ||
} | ||
|
||
const toggleTag = (option: TagType) => { | ||
// Check if the tag already exists in the array | ||
const index = tags.findIndex((tag) => tag.text === option.text) | ||
|
||
if (index >= 0) { | ||
// Tag exists, remove it | ||
const newTags = tags.filter((_, i) => i !== index) | ||
setTags(newTags) | ||
setTagCount((prevCount) => prevCount - 1) | ||
if (onTagRemove) { | ||
onTagRemove(option.text) | ||
} | ||
} else { | ||
// Tag doesn't exist, add it if allowed | ||
if (!allowDuplicates && tags.some((tag) => tag.text === option.text)) { | ||
// If duplicates aren't allowed and a tag with the same text exists, do nothing | ||
return | ||
} | ||
|
||
// Add the tag if it doesn't exceed max tags, if applicable | ||
if (!maxTags || tags.length < maxTags) { | ||
setTags([...tags, option]) | ||
setTagCount((prevCount) => prevCount + 1) | ||
setInputValue('') | ||
if (onTagAdd) { | ||
onTagAdd(option.text) | ||
} | ||
} | ||
} | ||
setSelectedIndex(-1) | ||
} | ||
|
||
const childrenWithProps = React.cloneElement( | ||
children as React.ReactElement<any>, | ||
{ | ||
onKeyDown: handleKeyDown, | ||
onFocus: handleInputFocus, | ||
onBlur: handleInputBlur, | ||
ref: inputRef, | ||
} | ||
) | ||
|
||
return ( | ||
<div | ||
className={cn( | ||
'flex h-full w-full flex-col overflow-hidden rounded-md', | ||
classStyleProps?.command | ||
)}> | ||
<Popover | ||
open={isPopoverOpen} | ||
onOpenChange={handleOpenChange} | ||
modal={usePortal}> | ||
<div | ||
className="relative flex h-full items-center rounded-md border bg-transparent pr-3" | ||
ref={triggerContainerRef}> | ||
{childrenWithProps} | ||
<PopoverTrigger | ||
asChild | ||
ref={triggerRef}> | ||
<Button | ||
variant="ghost" | ||
size="icon" | ||
role="combobox" | ||
className={cn( | ||
`hover:bg-transparent ${!inlineTags ? 'ml-auto' : ''}`, | ||
classStyleProps?.popoverTrigger | ||
)} | ||
onClick={() => { | ||
setIsPopoverOpen(!isPopoverOpen) | ||
}}> | ||
<svg | ||
xmlns="http://www.w3.org/2000/svg" | ||
width="24" | ||
height="24" | ||
viewBox="0 0 24 24" | ||
fill="none" | ||
stroke="currentColor" | ||
strokeWidth="2" | ||
strokeLinecap="round" | ||
strokeLinejoin="round" | ||
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? 'rotate-180' : 'rotate-0'}`}> | ||
<path d="m6 9 6 6 6-6"></path> | ||
</svg> | ||
</Button> | ||
</PopoverTrigger> | ||
</div> | ||
<PopoverContent | ||
ref={popoverContentRef} | ||
side="bottom" | ||
align="start" | ||
forceMount | ||
className={cn(`relative p-0`, classStyleProps?.popoverContent)} | ||
style={{ | ||
top: `${popooverContentTop}px`, | ||
marginLeft: `calc(-${popoverWidth}px + 36px)`, | ||
width: `${popoverWidth}px`, | ||
minWidth: `${popoverWidth}px`, | ||
zIndex: 9999, | ||
}}> | ||
<div | ||
className={cn( | ||
'max-h-[300px] overflow-y-auto overflow-x-hidden', | ||
classStyleProps?.commandList | ||
)} | ||
style={{ | ||
minHeight: '68px', | ||
}} | ||
key={autocompleteOptions.length}> | ||
{autocompleteOptions.length > 0 ? ( | ||
<div | ||
key={autocompleteOptions.length} | ||
role="group" | ||
className={cn( | ||
'overflow-hidden overflow-y-auto p-1 text-foreground', | ||
classStyleProps?.commandGroup | ||
)} | ||
style={{ | ||
minHeight: '68px', | ||
}}> | ||
<span className="px-2 py-1.5 pb-2 text-sm font-medium text-muted-foreground"> | ||
Suggestions | ||
</span> | ||
<div | ||
role="separator" | ||
className="py-0.5" | ||
/> | ||
{autocompleteOptions.map((option, index) => { | ||
const isSelected = index === selectedIndex | ||
return ( | ||
<div | ||
key={option.id} | ||
role="option" | ||
aria-selected={isSelected} | ||
className={cn( | ||
'aria-selected:bg-accent aria-selected:text-accent-foreground hover:bg-accent relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | ||
isSelected && 'bg-accent text-accent-foreground', | ||
classStyleProps?.commandItem | ||
)} | ||
data-value={option.text} | ||
onClick={() => toggleTag(option)}> | ||
<div className="flex w-full items-center gap-2"> | ||
{option.text} | ||
{tags.some((tag) => tag.text === option.text) && ( | ||
<svg | ||
xmlns="http://www.w3.org/2000/svg" | ||
width="14" | ||
height="14" | ||
viewBox="0 0 24 24" | ||
fill="none" | ||
stroke="currentColor" | ||
strokeWidth="2" | ||
strokeLinecap="round" | ||
strokeLinejoin="round" | ||
className="lucide lucide-check"> | ||
<path d="M20 6 9 17l-5-5"></path> | ||
</svg> | ||
)} | ||
</div> | ||
</div> | ||
) | ||
})} | ||
</div> | ||
) : ( | ||
<div className="py-6 text-center text-sm">No results found.</div> | ||
)} | ||
</div> | ||
</PopoverContent> | ||
</Popover> | ||
</div> | ||
) | ||
} |
Oops, something went wrong.