diff --git a/changelog.txt b/changelog.txt index 82df81b6..a329c76b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ -Version 1.4.3 - xth x, 2025 -- New - Added a variant in Input component to show uploaded file preview. -- New - Added a variant in Progress Steps component to show icon and number in the completed step. +Version 1.4.3 - xth xxxx, 2025 +- New - Added new Atom, File Uploader - to show uploaded file preview. +- New - Added new variants in Progress Steps component to show icon and number in the completed step. +- Improvement - Enhanced the UI and functionality of the Searchbox component for better flexibility and user experience. Version 1.4.2 - 6th March, 2025 - New - Added new size 'xs' to the Switch component. diff --git a/src/components/search/search.stories.tsx b/src/components/search/search.stories.tsx index a23061bf..fd16c7f2 100644 --- a/src/components/search/search.stories.tsx +++ b/src/components/search/search.stories.tsx @@ -10,6 +10,7 @@ const meta: Meta = { 'SearchBox.Input': SearchBox.Input, 'SearchBox.Loading': SearchBox.Loading, 'SearchBox.Separator': SearchBox.Separator, + 'SearchBox.Portal': SearchBox.Portal, 'SearchBox.Content': SearchBox.Content, 'SearchBox.List': SearchBox.List, 'SearchBox.Empty': SearchBox.Empty, @@ -48,36 +49,38 @@ const Template: StoryFn = ( args ) => { - - - - }> - Calendar - - }> - Document - - }> - Attendance - - - - - }> - Calendar Folder - - }> - Document Folder - - }> - Attendance Folder - - - - + + + + + }> + Calendar + + }> + Document + + }> + Attendance + + + + + }> + Calendar Folder + + }> + Document Folder + + }> + Attendance Folder + + + + + ); }; @@ -91,8 +94,8 @@ export const SecondarySearchBox = Template.bind( {} ); SecondarySearchBox.args = {}; SecondarySearchBox.decorators = [ () => ( - - + + ), ]; @@ -101,8 +104,8 @@ export const GhostSearchBox = Template.bind( {} ); GhostSearchBox.args = {}; GhostSearchBox.decorators = [ () => ( - - + + ), ]; diff --git a/src/components/search/search.tsx b/src/components/search/search.tsx index 906a733f..3ec676a6 100644 --- a/src/components/search/search.tsx +++ b/src/components/search/search.tsx @@ -7,6 +7,7 @@ import React, { useContext, Children, cloneElement, + useMemo, } from 'react'; import { omit } from 'lodash'; // or define your own omit function import { cn, getOperatingSystem } from '@/utilities/functions'; @@ -31,29 +32,16 @@ import { useInteractions, type UseFloatingReturn, type UseInteractionsReturn, + useListNavigation, + FloatingFocusManager, + FloatingList, + useListItem, } from '@floating-ui/react'; -type TSearchContentValue = Partial<{ - size: 'sm' | 'md' | 'lg'; - searchTerm: string; - isLoading: boolean; - onOpenChange: ( open: boolean ) => void; - refs: UseFloatingReturn['refs']; - floatingStyles: UseFloatingReturn['floatingStyles']; - getReferenceProps: UseInteractionsReturn['getReferenceProps']; - getFloatingProps: UseInteractionsReturn['getFloatingProps']; - setSearchTerm: React.Dispatch>; - open: boolean; - context: UseFloatingReturn['context']; - setIsLoading: ( loading: boolean ) => void; -}>; - -// Define a context for the SearchBox -const SearchContext = createContext( {} ); - -const useSearchContext = () => { - return useContext( SearchContext ); -}; +export interface CommonSearchBoxProps { + /** Additional class names for styling. */ + className?: string; +} // Define the Size type type Size = 'sm' | 'md' | 'lg'; @@ -66,17 +54,75 @@ export interface BaseSearchBoxProps { /** Size of the SearchBox. */ size?: 'sm' | 'md' | 'lg'; + /** Style variant of the input. */ + variant?: 'primary' | 'secondary' | 'ghost'; + /** Whether the dropdown is open. */ open?: boolean; - /** Callback when dropdown state changes. */ + /** + * Call back function to handle the open state of the dropdown. + */ + setOpen?: ( open: boolean ) => void; + + /** + * Callback when dropdown state changes. + * + * @deprecated Use `setOpen` instead. + */ onOpenChange?: ( open: boolean ) => void; + /** Whether to filter children based on the search term. Turn off when you want to filter children manually. */ + filter?: boolean; + /** Whether to show loading state. */ loading?: boolean; /** Child components to be rendered. */ children?: ReactNode; + + /** + * Clear search term after selecting a result. + * + * @default true + */ + clearAfterSelect?: boolean; + + /** + * Close dropdown after selecting a result. + * + * @default true + */ + closeAfterSelect?: boolean; +} + +type SearchBoxPortalProps = { + /** Child components to be rendered. */ + children: ReactNode; + + /** Unique identifier for the portal, which determines where the dropdown will be rendered in the DOM. */ + id?: string; + + /** The HTML element that serves as the root for the portal, defining the location in the DOM where the dropdown will be displayed. This can be null if no specific root is provided. */ + root?: HTMLElement | null; +}; + +// Define props for SearchBoxInput +export interface SearchBoxInputProps extends CommonSearchBoxProps { + /** Type of the input (e.g., text, search). */ + type?: string; + + /** Placeholder text for the input. */ + placeholder?: string; + + /** Whether the input is disabled. */ + disabled?: boolean; + + /** Callback for input changes. */ + onChange?: ( value: string ) => void; + + /** Child components to be rendered. */ + children?: ReactNode; } // Extend the type to allow assigning subcomponents to SearchBox @@ -91,6 +137,39 @@ type SearchBoxComponent = React.ForwardRefExoticComponent< Empty: typeof SearchBoxEmpty; Group: typeof SearchBoxGroup; Item: typeof SearchBoxItem; + Portal: typeof SearchBoxPortal; +}; + +type TSearchContentValue = Partial<{ + size: 'sm' | 'md' | 'lg'; + searchTerm: string; + isLoading: boolean; + onOpenChange: ( open: boolean ) => void; + refs: UseFloatingReturn['refs']; + floatingStyles: UseFloatingReturn['floatingStyles']; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + getItemProps: ( + userProps?: React.HTMLProps + ) => Record; + activeIndex: number | null; + setActiveIndex: React.Dispatch>; + listRef: React.MutableRefObject<( HTMLElement | null )[]>; + setSearchTerm: React.Dispatch>; + open: boolean; + context: UseFloatingReturn['context']; + setIsLoading: ( loading: boolean ) => void; + clearAfterSelect: boolean; + closeAfterSelect: boolean; + variant: BaseSearchBoxProps['variant']; + filter: boolean; +}>; + +// Define a context for the SearchBox +const SearchContext = createContext( {} ); + +const useSearchContext = () => { + return useContext( SearchContext ); }; export const SearchBox = forwardRef( @@ -99,14 +178,31 @@ export const SearchBox = forwardRef( className, size = 'sm' as Size, open = false, - onOpenChange = () => {}, + setOpen, + onOpenChange: _onOpenChange, loading = false, + clearAfterSelect = true, + closeAfterSelect = true, + variant = 'primary', + filter = true, ...props }, ref ) => { const [ searchTerm, setSearchTerm ] = useState( '' ); const [ isLoading, setIsLoading ] = useState( loading ?? false ); + const [ activeIndex, setActiveIndex ] = useState( null ); + const listRef = React.useRef<( HTMLElement | null )[]>( [] ); + + /** + * Memoized function to handle the open state of the dropdown. + */ + const onOpenChange = useMemo( () => { + if ( typeof setOpen === 'function' ) { + return setOpen; + } + return _onOpenChange; + }, [ setOpen, _onOpenChange ] ); const { refs, floatingStyles, context } = useFloating( { open, @@ -114,7 +210,7 @@ export const SearchBox = forwardRef( placement: 'bottom-start', whileElementsMounted: autoUpdate, middleware: [ - offset( size === 'sm' ? 4 : 6 ), + offset( 2 ), flip( { padding: 10 } ), floatingSize( { apply( { rects, elements, availableHeight } ) { @@ -128,11 +224,20 @@ export const SearchBox = forwardRef( } ), ], } ); + + const listNavigation = useListNavigation( context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + // Prevent opening the dropdown with arrow keys + openOnArrowKeyDown: false, + } ); + const dismiss = useDismiss( context ); - const { getReferenceProps, getFloatingProps } = useInteractions( [ - dismiss, - ] ); + const { getReferenceProps, getFloatingProps, getItemProps } = + useInteractions( [ dismiss, listNavigation ] ); useEffect( () => { const operatingSystem = getOperatingSystem(); @@ -165,6 +270,13 @@ export const SearchBox = forwardRef( }; }, [ refs.reference ] ); + // Reset active index when closing dropdown + useEffect( () => { + if ( ! open ) { + setActiveIndex( null ); + } + }, [ open ] ); + return ( ( context, getReferenceProps, getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + listRef, searchTerm, setSearchTerm, isLoading, setIsLoading, + clearAfterSelect, + closeAfterSelect, + variant, + filter, } } >
( ) as SearchBoxComponent; SearchBox.displayName = 'SearchBox'; -// Define props for SearchBoxInput -export interface SearchBoxInputProps extends BaseSearchBoxProps { - /** Type of the input (e.g., text, search). */ - type?: string; - - /** Placeholder text for the input. */ - placeholder?: string; - - /** Style variant of the input. */ - variant?: 'primary' | 'secondary' | 'ghost'; - - /** Whether the input is disabled. */ - disabled?: boolean; - - /** Callback for input changes. */ - onChange?: ( value: string ) => void; - - /** Child components to be rendered. */ - children?: ReactNode; -} - export const SearchBoxInput = forwardRef( ( { className, type = 'text', placeholder = 'Search...', - variant = 'primary', disabled = false, onChange = () => {}, ...props @@ -232,13 +330,18 @@ export const SearchBoxInput = forwardRef( ) => { const { size, - onOpenChange, refs, getReferenceProps, searchTerm, setSearchTerm, + open, + setActiveIndex, + listRef, + onOpenChange, + variant, } = useSearchContext(); const badgeSize = size === 'lg' ? 'sm' : 'xs'; + const handleChange = ( event: React.ChangeEvent ) => { const newValue = event.target.value; setSearchTerm!( newValue ); @@ -253,18 +356,61 @@ export const SearchBoxInput = forwardRef( } }; + const handleFocus = () => { + if ( disabled || typeof onOpenChange !== 'function' ) { + return; + } + if ( searchTerm?.trim() ) { + onOpenChange!( true ); // Open the dropdown on focus if input is not empty + } + }; + + const handleKeyDown = ( event: React.KeyboardEvent ) => { + if ( disabled ) { + return; + } + + // Do not open dropdown on arrow keys + if ( event.key === 'ArrowDown' || event.key === 'ArrowUp' ) { + // Only navigate if dropdown is already open + if ( open ) { + event.preventDefault(); + if ( event.key === 'ArrowDown' ) { + // Navigate to first item if none selected, otherwise listNavigation will handle it + setActiveIndex!( ( prev ) => ( prev === null ? 0 : prev ) ); + } else if ( event.key === 'ArrowUp' ) { + // Navigate to last item if none selected, otherwise listNavigation will handle it + setActiveIndex!( ( prev ) => { + // Get the length of the list to select the last item + const listLength = listRef?.current?.length || 0; + return prev === null && listLength > 0 + ? listLength - 1 + : prev; + } ); + } + } + // Do not open the dropdown + return; + } + + if ( event.key === 'Escape' ) { + onOpenChange!( false ); + } + }; + return (
( ref={ ref } className={ cn( textSizeClassNames[ size! ], - 'flex-grow font-medium bg-transparent border-none outline-none border-transparent focus:ring-0 py-0', - disabled - ? disabledClassNames[ variant ] - : [ - 'text-field-placeholder focus-within:text-field-input group-hover:text-field-input', - 'placeholder:text-field-placeholder', - ], - className + 'flex-grow font-medium bg-transparent border-none outline-none border-transparent focus:ring-0 p-0 min-h-fit', + disabled && + 'text-field-placeholder focus-within:text-field-input group-hover:text-field-input placeholder:text-field-placeholder' ) } disabled={ disabled } value={ searchTerm } onChange={ handleChange } + onFocus={ handleFocus } + onKeyDown={ handleKeyDown } placeholder={ placeholder } // Omit custom props that are not valid for input { ...omit( props, [ @@ -302,10 +445,11 @@ export const SearchBoxInput = forwardRef( ] ) } />
); @@ -318,24 +462,16 @@ export interface SearchBoxContentProps { /** Additional class names for styling. */ className?: string; - /** Root element where the dropdown will be rendered. */ - dropdownPortalRoot?: HTMLElement | null; - - /** Id of the dropdown portal where the dropdown will be rendered. */ - dropdownPortalId?: string; - /** Child components to be rendered inside the dropdown. */ children: ReactNode; } export const SearchBoxContent = ( { className, - dropdownPortalRoot = null, // Root element where the dropdown will be rendered. - dropdownPortalId = '', // Id of the dropdown portal where the dropdown will be rendered. children, ...props }: SearchBoxContentProps ) => { - const { size, open, refs, floatingStyles, getFloatingProps } = + const { size, open, refs, floatingStyles, getFloatingProps, context } = useSearchContext(); if ( ! open ) { @@ -343,14 +479,18 @@ export const SearchBoxContent = ( { } return ( - +
{ children }
-
+ ); }; SearchBoxContent.displayName = 'SearchBox.Content'; -// Define props for SearchBoxList -export interface SearchBoxListProps { - /** Whether to filter children based on the search term. */ - filter?: boolean; +export const SearchBoxPortal = ( { + children, + id, + root, +}: SearchBoxPortalProps ) => { + return ( + + { children } + + ); +}; +SearchBoxPortal.displayName = 'SearchBox.Portal'; +// Define props for SearchBoxList +export interface SearchBoxListProps extends CommonSearchBoxProps { /** Child components to be rendered. */ children: ReactNode; } export const SearchBoxList = ( { - filter = true, children, + className, }: SearchBoxListProps ) => { - const { searchTerm, isLoading } = useSearchContext(); + const { searchTerm, isLoading, listRef, filter = true } = useSearchContext(); if ( ! filter ) { - return
{ children }
; + return ( + +
{ children }
+
+ ); } const filteredChildren = Children.toArray( children ) .map( ( child ) => { @@ -409,17 +563,19 @@ export const SearchBoxList = ( { return ; } return ( -
- { filteredChildren.some( - ( child ) => - React.isValidElement( child ) && - child.type !== SearchBoxSeparator - ) ? ( - filteredChildren - ) : ( - - ) } -
+ +
+ { filteredChildren.some( + ( child ) => + React.isValidElement( child ) && + child.type !== SearchBoxSeparator + ) ? ( + filteredChildren + ) : ( + + ) } +
+
); }; SearchBoxList.displayName = 'SearchBox.List'; @@ -428,10 +584,14 @@ SearchBoxList.displayName = 'SearchBox.List'; export interface SearchBoxEmptyProps { /** Content to display when there are no results. */ children?: ReactNode; + + /** Additional class names for styling. */ + className?: string; } export const SearchBoxEmpty = ( { children = 'No results found.', + className, }: SearchBoxEmptyProps ) => { const { size } = useSearchContext(); return ( @@ -439,7 +599,8 @@ export const SearchBoxEmpty = ( { className={ cn( 'flex justify-center items-center', sizeClassNames.item[ size! ], - 'text-text-tertiary p-4' + 'text-text-tertiary p-4', + className ) } > { children } @@ -470,7 +631,7 @@ export const SearchBoxGroup = ( { heading, children }: SearchBoxGroupProps ) =>
{ heading } @@ -492,19 +653,68 @@ export interface SearchBoxItemProps { /** Child components to be rendered. */ children: ReactNode; + + /** On click handler. */ + onClick?: () => void; } -export const SearchBoxItem = forwardRef( - ( { className, icon, children, ...props }, ref ) => { - const { size } = useSearchContext(); +export const SearchBoxItem = forwardRef( + ( { className, icon, children, onClick, ...props }, ref ) => { + const { + size, + setSearchTerm, + clearAfterSelect, + getItemProps, + activeIndex, + onOpenChange, + closeAfterSelect, + } = useSearchContext(); + const { ref: itemRef, index } = useListItem(); + + // Combine the refs + const combinedRef = ( node: HTMLButtonElement | null ) => { + if ( typeof ref === 'function' ) { + ref( node ); + } else if ( ref ) { + ref.current = node; + } + itemRef( node ); + }; + + const isActive = activeIndex === index; + + const handleClick = () => { + if ( typeof onClick === 'function' ) { + onClick(); + } + + if ( clearAfterSelect ) { + setSearchTerm!( '' ); + } + + if ( closeAfterSelect ) { + onOpenChange!( false ); + } + }; + return ( -
{ icon && ( ( ) } { children } -
+ ); } ); @@ -589,5 +798,5 @@ SearchBox.List = SearchBoxList; SearchBox.Empty = SearchBoxEmpty; SearchBox.Group = SearchBoxGroup; SearchBox.Item = SearchBoxItem; - +SearchBox.Portal = SearchBoxPortal; export default SearchBox; diff --git a/src/components/search/styles.ts b/src/components/search/styles.ts index 02b48208..191a0b59 100644 --- a/src/components/search/styles.ts +++ b/src/components/search/styles.ts @@ -43,10 +43,10 @@ export const sizeClassNames = { }; export const variantClassNames = { primary: - 'bg-field-primary-background outline outline-1 outline-field-border hover:outline-border-strong', + 'bg-field-primary-background outline outline-1 outline-field-border hover:outline-border-strong focus-within:outline-focus-border focus-within:hover:outline-focus-border', secondary: - 'bg-field-secondary-background outline outline-1 outline-field-border hover:outline-border-strong', - ghost: 'bg-field-secondary-background outline outline-1 outline-transparent', + 'bg-field-secondary-background outline outline-1 outline-field-border hover:outline-border-strong focus-within:outline-focus-border focus-within:hover:outline-focus-border', + ghost: 'bg-field-secondary-background outline outline-1 outline-transparent focus-within:outline-focus-border', }; export const iconClasses = @@ -55,7 +55,7 @@ export const iconClasses = export const disabledClassNames = { ghost: 'cursor-not-allowed text-text-disabled placeholder:text-text-disabled', primary: - 'border-border-disabled hover:border-border-disabled bg-field-background-disabled cursor-not-allowed text-text-disabled placeholder:text-text-disabled', + 'outline-border-disabled hover:outline-border-disabled bg-field-background-disabled cursor-not-allowed text-text-disabled placeholder:text-text-disabled', secondary: - 'border-border-disabled hover:border-border-disabled cursor-not-allowed text-text-disabled placeholder:text-text-disabled', + 'outline-border-disabled hover:outline-border-disabled cursor-not-allowed text-text-disabled placeholder:text-text-disabled', };