diff --git a/README.md b/README.md
index 99059028..dfd305e1 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Using Force UI as a dependency in package.json -
```json
"dependencies": {
- "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.4.2"
+ "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.5.0"
}
```
@@ -28,7 +28,7 @@ npm install
Or you can directly run the following command to install the package -
```bash
-npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.4.2
+npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.5.0
```
diff --git a/changelog.txt b/changelog.txt
index 6f5311e3..51acbb05 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,3 +1,8 @@
+Version 1.5.0 - 14th March, 2025
+- New - Added new Atom, File Preview - 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.
- Improvement - Adjusted the ring width and padding of the Radio Button component.
diff --git a/package-lock.json b/package-lock.json
index 5cf010f5..cbdaf511 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@bsf/force-ui",
- "version": "1.4.2",
+ "version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bsf/force-ui",
- "version": "1.4.2",
+ "version": "1.5.0",
"license": "ISC",
"dependencies": {
"@emotion/is-prop-valid": "^1.3.0",
diff --git a/package.json b/package.json
index f5610754..5192e615 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@bsf/force-ui",
- "version": "1.4.2",
+ "version": "1.5.0",
"description": "Library of components for the BSF project",
"main": "./dist/force-ui.cjs.js",
"module": "./dist/force-ui.es.js",
diff --git a/src/components/file-preview/file-preview.stories.tsx b/src/components/file-preview/file-preview.stories.tsx
new file mode 100644
index 00000000..e1e022b2
--- /dev/null
+++ b/src/components/file-preview/file-preview.stories.tsx
@@ -0,0 +1,113 @@
+import { Meta, StoryFn } from '@storybook/react';
+import { FilePreview, FilePreviewFile, FilePreviewProps } from './file-preview';
+import { Input } from '@/index';
+import { useRef, useState } from 'react';
+
+type InputValue = string | FileList | null;
+
+const meta = {
+ title: 'Atoms/FilePreview',
+ component: FilePreview,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: [ 'autodocs' ],
+ argTypes: {
+ onRemove: { action: 'removed' },
+ },
+ decorators: [
+ ( Story: React.FC ) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryFn;
+
+const Template: Story = ( args: FilePreviewProps ) => ;
+
+const placeholderFile = {
+ name: 'example-file.png',
+ url: 'https://placehold.co/600x400.png',
+ type: 'image/png',
+ size: 102400,
+} as FilePreviewFile;
+
+export const Default: Story = Template.bind( {} );
+Default.args = {
+ file: placeholderFile,
+ onRemove: () => {},
+ disabled: false,
+ size: 'md',
+ error: false,
+};
+
+export const ErrorState = Template.bind( {} );
+ErrorState.args = {
+ file: placeholderFile,
+ onRemove: () => {},
+ disabled: false,
+ size: 'md',
+ error: true,
+};
+
+export const DisabledState = Template.bind( {} );
+DisabledState.args = {
+ file: placeholderFile,
+ onRemove: () => {},
+ disabled: true,
+ size: 'md',
+ error: false,
+};
+
+export const FileInputWithPreview: Story = ( args ) => {
+ const fileInputRef = useRef( null );
+ const [ selectedFile, setSelectedFile ] = useState( null );
+
+ const handleFileChange = ( value: InputValue ) => {
+ if ( ! value ) {
+ setSelectedFile( null );
+ return;
+ }
+
+ let files: FileList | null = null;
+
+ if ( value instanceof FileList ) {
+ files = value;
+ }
+
+ setSelectedFile( files?.[ 0 ] || null );
+ };
+
+ return (
+ <>
+
+ { selectedFile && (
+
+ {
+ setSelectedFile( null );
+ if ( fileInputRef.current ) {
+ fileInputRef.current.value = '';
+ }
+ } }
+ />
+
+ ) }
+ >
+ );
+};
diff --git a/src/components/file-preview/file-preview.tsx b/src/components/file-preview/file-preview.tsx
new file mode 100644
index 00000000..5877d6fb
--- /dev/null
+++ b/src/components/file-preview/file-preview.tsx
@@ -0,0 +1,135 @@
+import { cn, formatFileSize } from '@/utilities/functions';
+import { File, ImageOff, Trash } from 'lucide-react';
+
+export type FilePreviewFile =
+ | File
+ | { name: string; url: string; type: string; size: number };
+
+export interface FilePreviewProps {
+ /** The file to display. It can be a File object or an object with name, url, type, and size properties. */
+ file: FilePreviewFile;
+
+ /** Function called when the file is removed. The parameter is the selected file object, which can be a File object or an object with name, url, type, and size properties or null. */
+ onRemove: ( selectedFile: FilePreviewFile ) => void;
+
+ /** Indicates whether the file preview is disabled. */
+ disabled?: boolean;
+
+ /** The size of the file preview. */
+ size?: 'sm' | 'md' | 'lg';
+
+ /** Indicates whether the file preview has an error. */
+ error?: boolean;
+}
+
+const commonFilePreviewClasses = {
+ sm: {
+ image: 'w-8 h-8',
+ name: 'text-xs',
+ fileIcon: 'h-8',
+ uploadText: 'text-xs',
+ },
+ md: {
+ image: 'w-10 h-10',
+ name: 'text-sm',
+ fileIcon: 'h-10',
+ uploadText: 'text-xs',
+ },
+ lg: {
+ image: 'w-10 h-10',
+ name: 'text-sm',
+ fileIcon: 'h-10',
+ uploadText: 'text-xs',
+ },
+};
+
+export const FilePreview = ( {
+ file,
+ onRemove,
+ error,
+ disabled,
+ size = 'sm',
+}: FilePreviewProps ) => {
+ const renderFileIcon = () => (
+
+
+
+ );
+
+ return (
+
+
+ { file.type.startsWith( 'image' ) ? (
+
+ { error ? (
+
+ ) : (
+
+ ) }
+
+ ) : (
+ renderFileIcon()
+ ) }
+
+
+
+ { file.name }
+
+ { file.size && file.size > 0 && (
+
+ { formatFileSize( file.size ) }
+
+ ) }
+
+ { ! disabled && (
+
onRemove( file ) }
+ className="inline-flex cursor-pointer bg-transparent border-0 p-1 my-0 ml-auto mr-0 ring-0 focus:outline-none self-start"
+ aria-label="Remove file"
+ >
+
+
+ ) }
+
+
+ );
+};
+
+export default FilePreview;
diff --git a/src/components/file-preview/index.ts b/src/components/file-preview/index.ts
new file mode 100644
index 00000000..ff864038
--- /dev/null
+++ b/src/components/file-preview/index.ts
@@ -0,0 +1 @@
+export { default } from './file-preview';
diff --git a/src/components/index.ts b/src/components/index.ts
index f3a5bdef..43ff2878 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -40,3 +40,4 @@ export { default as PieChart } from './pie-chart';
export { default as AreaChart } from './area-chart';
export { default as Dropzone } from './dropzone';
export { default as Table } from './table';
+export { default as FilePreview } from './file-preview';
diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx
index 9de9b45d..8357b7fb 100644
--- a/src/components/input/input.tsx
+++ b/src/components/input/input.tsx
@@ -36,7 +36,7 @@ export declare interface InputProps {
disabled?: boolean;
/** Function called when the input value changes. */
- onChange?: ( value: string | null ) => void;
+ onChange?: ( value: string | FileList | null ) => void;
/** Indicates whether the input has an error state. */
error?: boolean;
@@ -77,7 +77,10 @@ export const InputComponent = (
label = '',
...props
}: InputProps &
- Omit, 'size' | 'prefix'>,
+ Omit<
+ React.InputHTMLAttributes,
+ 'size' | 'prefix' | 'onChange'
+ >,
ref: React.ForwardedRef
) => {
const inputRef = useRef( null );
@@ -96,7 +99,7 @@ export const InputComponent = (
return;
}
- let newValue: string | FileList | null;
+ let newValue: FileList | string | null;
if ( type === 'file' ) {
newValue = event.target.files;
if ( newValue && newValue.length > 0 ) {
@@ -115,7 +118,7 @@ export const InputComponent = (
if ( typeof onChange !== 'function' ) {
return;
}
- onChange( newValue as string );
+ onChange( newValue );
};
const handleReset = () => {
diff --git a/src/components/progress-steps/progress-steps.stories.tsx b/src/components/progress-steps/progress-steps.stories.tsx
index b3544815..18c41b98 100644
--- a/src/components/progress-steps/progress-steps.stories.tsx
+++ b/src/components/progress-steps/progress-steps.stories.tsx
@@ -1,5 +1,5 @@
import ProgressSteps, { ProgressStepsProps } from './progress-steps';
-import { Check, Home } from 'lucide-react';
+import { Check, Home, BadgeCheck } from 'lucide-react';
import type { Meta, StoryFn } from '@storybook/react';
// ProgressSteps.Step display name for better documentation in Storybook
@@ -108,3 +108,35 @@ export const StackType = {
},
render: Template,
};
+
+// Numbered Completed Steps Variant
+export const NumberedCompletedSteps = {
+ args: {
+ variant: 'number',
+ size: 'md',
+ type: 'inline',
+ currentStep: 3,
+ completedVariant: 'number',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows completed steps with step numbers in colored circles',
+ },
+ },
+ },
+ render: Template,
+};
+
+// custom icon in completed steps
+export const CustomIconInCompletedSteps = {
+ args: {
+ variant: 'icon',
+ size: 'md',
+ type: 'inline',
+ currentStep: 3,
+ completedVariant: 'icon',
+ completedIcon: ,
+ },
+ render: Template,
+};
diff --git a/src/components/progress-steps/progress-steps.tsx b/src/components/progress-steps/progress-steps.tsx
index 2976da09..885c678a 100644
--- a/src/components/progress-steps/progress-steps.tsx
+++ b/src/components/progress-steps/progress-steps.tsx
@@ -33,6 +33,9 @@ const sizeClassnames = {
type StepSizeClasses = typeof sizeClassnames;
+// Enhanced to include completed step variants
+export type CompletedVariant = 'icon' | 'number';
+
// Common props interface
export interface ProgressCommonProps {
/** Defines the children of the progress steps. */
@@ -53,6 +56,10 @@ export interface ProgressStepsProps extends ProgressCommonProps {
currentStep?: number;
/** Additional props for the connecting line. */
lineClassName?: string;
+ /** Defines how completed steps should be displayed */
+ completedVariant?: CompletedVariant;
+ /** Custom icon for completed steps when completedVariant is 'icon' */
+ completedIcon?: ReactNode;
}
// Progress Step props interface
@@ -89,6 +96,12 @@ export interface ProgressStepProps extends ProgressCommonProps {
/** Additional class names for the connecting line. */
lineClassName?: string;
+
+ /** How to display completed steps */
+ completedVariant?: CompletedVariant;
+
+ /** Custom icon for completed steps */
+ completedIcon?: ReactNode;
}
export const ProgressSteps = ( {
@@ -99,6 +112,8 @@ export const ProgressSteps = ( {
children,
className,
lineClassName = 'min-w-10',
+ completedVariant = 'icon',
+ completedIcon = ,
...rest
}: ProgressStepsProps ) => {
const totalSteps = React.Children.count( children );
@@ -120,6 +135,8 @@ export const ProgressSteps = ( {
isLast,
index,
lineClassName,
+ completedVariant,
+ completedIcon,
};
return (
@@ -159,6 +176,8 @@ export const ProgressStep = ( {
isLast,
index,
lineClassName,
+ completedVariant = 'icon',
+ completedIcon = ,
...rest
}: ProgressStepProps ) => {
const stepContent = createStepContent(
@@ -168,7 +187,9 @@ export const ProgressStep = ( {
sizeClasses!,
size,
icon,
- index as number
+ index as number,
+ completedVariant,
+ completedIcon
);
const stackSizeOffset = {
@@ -277,11 +298,27 @@ export const createStepContent = (
sizeClasses: StepSizeClasses,
size: 'sm' | 'md' | 'lg',
icon: ReactNode,
- index: number
+ index: number,
+ completedVariant: CompletedVariant = 'icon',
+ completedIcon: ReactNode =
) => {
if ( isCompleted ) {
+ if ( completedVariant === 'number' ) {
+ return (
+
+ { index + 1 }
+
+ );
+ }
return (
-
+
+ { completedIcon }
+
);
}
diff --git a/src/components/radio-button/radio-button.tsx b/src/components/radio-button/radio-button.tsx
index 24945136..96b39b0f 100644
--- a/src/components/radio-button/radio-button.tsx
+++ b/src/components/radio-button/radio-button.tsx
@@ -425,6 +425,7 @@ export const RadioButtonComponent = (
checkedValue &&
'outline-border-interactive',
paddingClasses,
+ 'pr-12',
isDisabled && 'cursor-not-allowed opacity-40',
buttonWrapperClasses
) }
diff --git a/src/components/search/search.stories.tsx b/src/components/search/search.stories.tsx
index a23061bf..f6806613 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 = [
() => (
-
-
+
+
),
];
@@ -119,6 +122,6 @@ DisabledSearchBox.decorators = [
export const LoadingSearchBox = Template.bind( {} );
LoadingSearchBox.args = {
- open: true,
+ open: false,
loading: true,
};
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',
};
diff --git a/version.json b/version.json
index 991287a5..3b7733d0 100644
--- a/version.json
+++ b/version.json
@@ -1,3 +1,3 @@
{
- "force-ui": "1.4.2"
+ "force-ui": "1.5.0"
}