diff --git a/.github/workflows/code-reviewer.yml b/.github/workflows/code-reviewer.yml index c1681125..60c464d5 100644 --- a/.github/workflows/code-reviewer.yml +++ b/.github/workflows/code-reviewer.yml @@ -1,43 +1,43 @@ name: BSF Code Reviewer on: - pull_request: - types: [opened, synchronize, edited] + pull_request: + types: [opened, synchronize, edited] permissions: write-all jobs: - CHECK_SHORTCODE: - if: contains(github.event.pull_request.body, '[BSF-PR-SUMMARY]') - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v3 + CHECK_SHORTCODE: + if: contains(github.event.pull_request.body, '[BSF-PR-SUMMARY]') + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 - - name: WRITE PR SUMMARY - uses: brainstormforce/pull-request-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ACTION_CONTEXT: 'CHECK_SHORTCODE' - EXCLUDE_EXTENSIONS: "md, yml, lock" - INCLUDE_EXTENSIONS: "php, js, jsx, ts, tsx, css, scss, html, json" - EXCLUDE_PATHS: "node_modules/" + - name: WRITE PR SUMMARY + uses: brainstormforce/pull-request-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ACTION_CONTEXT: 'CHECK_SHORTCODE' + EXCLUDE_EXTENSIONS: 'md, yml, lock' + INCLUDE_EXTENSIONS: 'php, js, jsx, ts, tsx, css, scss, html, json' + EXCLUDE_PATHS: 'node_modules/' - CODE_REVIEW: - needs: CHECK_SHORTCODE - if: always() - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v3 + CODE_REVIEW: + needs: CHECK_SHORTCODE + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 - - name: AI CODE REVIEW - uses: brainstormforce/pull-request-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ACTION_CONTEXT: "CODE_REVIEW" - EXCLUDE_EXTENSIONS: "md, yml, lock" - INCLUDE_EXTENSIONS: "php, js, jsx, ts, tsx, css, scss, html, json" - EXCLUDE_PATHS: "node_modules/" \ No newline at end of file + - name: AI CODE REVIEW + uses: brainstormforce/pull-request-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ACTION_CONTEXT: 'CODE_REVIEW' + EXCLUDE_EXTENSIONS: 'md, yml, lock' + INCLUDE_EXTENSIONS: 'php, js, jsx, ts, tsx, css, scss, html, json' + EXCLUDE_PATHS: 'node_modules/' diff --git a/README.md b/README.md index 9bed5786..6dc49af3 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.3.5" + "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.3.6" } ``` @@ -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.3.5 +npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.3.6 ```
diff --git a/changelog.txt b/changelog.txt index 557363ee..0a067a28 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +Version 1.3.6 - 9th January, 2025 +- Improvement: Optimized the Select component item search logic and reorganized props for improved usability. +- Improvement: Added a vertical dashed line feature to the LineChart. +- Fixed: LineChart component Tooltip does not show the required information. + Version 1.3.5 - 6th January, 2025 - Improvement: Display a light blue color on hover when selecting a date range. diff --git a/package.json b/package.json index 480f2431..931a383a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bsf/force-ui", - "version": "1.3.5", + "version": "1.3.6", "description": "Library of components for the BSF project", "main": "./dist/force-ui.js", "module": "./dist/force-ui.js", diff --git a/src/components/badge/badge.tsx b/src/components/badge/badge.tsx index 1f070bff..23d626e3 100644 --- a/src/components/badge/badge.tsx +++ b/src/components/badge/badge.tsx @@ -6,7 +6,7 @@ export interface BadgeProps { /** * Defines the Label of the badge. */ - label?: string; + label?: ReactNode; /** * Defines the size of the badge. */ diff --git a/src/components/line-chart/line-chart.tsx b/src/components/line-chart/line-chart.tsx index 9b714b59..3fdf2be4 100644 --- a/src/components/line-chart/line-chart.tsx +++ b/src/components/line-chart/line-chart.tsx @@ -81,14 +81,26 @@ interface LineChartProps { CategoricalChartProps, 'width' | 'height' | 'data' >; + /** + * The stroke dasharray for the Cartesian grid. + * @default '3 3' + * @see https://recharts.org/en-US/api/CartesianGrid + */ + strokeDasharray?: string; + + /** + * The color of the Cartesian grid lines. + * @default '#E5E7EB' + */ + gridColor?: string; } const LineChart = ( { data, dataKeys = [], colors = [], - showXAxis = true, - showYAxis = true, + showXAxis = false, + showYAxis = false, showTooltip = true, tooltipIndicator = 'dot', // dot, line, dashed tooltipLabelKey, @@ -103,6 +115,8 @@ const LineChart = ( { chartHeight = 200, withDots = false, lineChartWrapperProps, + strokeDasharray = '3 3', + gridColor = '#E5E7EB', }: LineChartProps ) => { const defaultColors = [ { stroke: '#2563EB' }, { stroke: '#38BDF8' } ]; @@ -127,32 +141,36 @@ const LineChart = ( { return ( - { showCartesianGrid && } - { showXAxis && ( - - ) } - { showYAxis && ( - ) } + + { showTooltip && ( = { subcomponents: { 'Select.Button': Select.Button, 'Select.Portal': Select.Portal, + 'Select.OptionGroup': Select.OptionGroup, 'Select.Options': Select.Options, 'Select.Option': Select.Option, - 'Select.OptionGroup': Select.OptionGroup, } as Record>, parameters: { layout: 'centered', @@ -64,34 +66,44 @@ export default meta; type Story = StoryFn; // Single Select Story -export const SingleSelect: Story = ( { size, multiple, combobox, disabled } ) => ( -
- -
-); +export const SingleSelect: Story = ( { size, multiple, combobox, disabled } ) => { + const [ selected, setSelected ] = useState( null ); + return ( +
+ +
+ ); +}; SingleSelect.args = { size: 'md', @@ -136,6 +148,9 @@ const SelectWithoutPortalTemplate: Story = ( { multiple ? 'Select multiple options' : 'Select an option' } label={ multiple ? 'Select Multiple Colors' : 'Select a Color' } + render={ ( selected ) => + ( selected as Record )?.name + } /> { options.map( ( option ) => ( @@ -157,33 +172,44 @@ SingleSelectWithoutPortal.args = { }; // Multi-select Story -export const MultiSelect: Story = ( { size, multiple, combobox, disabled } ) => ( -
- -
-); +export const MultiSelect: Story = ( { size, multiple, combobox, disabled } ) => { + const [ selected, setSelected ] = useState( [] ); + return ( +
+ +
+ ); +}; MultiSelect.args = { size: 'md', @@ -236,13 +262,17 @@ export const SelectWithSearch: Story = ( { combobox={ combobox } disabled={ disabled } onChange={ ( value ) => value } + searchPlaceholder="Search..." > + ( selected as Record )?.name + } /> - + { options.map( ( option ) => ( { option.name } @@ -300,41 +330,58 @@ const GroupedSelectTemplate: Story = ( { multiple, combobox, disabled, -} ) => ( -
- + setSelectedValue( value as SelectOptionValue ) } - label={ multiple ? 'Select Multiple Colors' : 'Select a Color' } - /> - - - { groupedOptions.map( ( group ) => ( - - { group.options.map( ( option ) => ( - - { option.name } - - ) ) } - - ) ) } - - - -
-); + value={ selectedValue as SelectOptionValue } + > + + ( selected as Record )?.name + } + /> + + + { groupedOptions.map( ( group ) => ( + + { group.options.map( ( option ) => ( + + { option.name } + + ) ) } + + ) ) } + + + + + ); +}; export const GroupedSelect = GroupedSelectTemplate.bind( {} ); GroupedSelect.args = { diff --git a/src/components/select/select-types.ts b/src/components/select/select-types.ts index b3dc0da9..768f3762 100644 --- a/src/components/select/select-types.ts +++ b/src/components/select/select-types.ts @@ -67,6 +67,8 @@ export type SelectProps = { onChange: SelectOnChange; /** Defines the default value of the Select Component. */ defaultValue?: SelectOptionValue | SelectOptionValue[]; + /** Placeholder text for search box. */ + searchPlaceholder?: string; }; export interface SelectPortalProps { @@ -91,8 +93,12 @@ export interface SelectButtonProps extends AriaAttributes { placeholder?: string; /** Icon to show in the selected option badge (Multi-select mode only). By default it won't show unknown icon. */ optionIcon?: ReactNode | null; - /** Key to display selected item when the selected value is an object. Default value is `name`. */ - displayBy?: string; + /** + * Render function to display the selected option (Must use for multi-select mode). + * For multi-select mode, the selected option will be displayed as a badge but the render function will be used to display the selected options. + * For single-select mode, the render function will be used to display the selected option. + */ + render?: ( selected: SelectOptionValue ) => ReactNode | string; /** Label for the Select component. */ label?: string; /** Additional class name for the Select Button. */ @@ -110,11 +116,7 @@ export interface SelectOptionGroupProps { export interface SelectOptionsProps { /** Expects the `Select.Option` or `Select.OptionGroup` children */ - children?: ReactNode; - /** Key used to identify searched value using the key. Default is 'id'. */ - searchBy?: string; - /** Placeholder text for search box. */ - searchPlaceholder?: string; + children: React.ReactNode; /** Additional class name for the Select Options wrapper. */ className?: string; } @@ -164,4 +166,5 @@ export type SelectContextValue = { searchKeyword: string; onChange: SelectOnChange; value?: SelectOptionValue | SelectOptionValue[]; + searchPlaceholder?: string; }; diff --git a/src/components/select/select.stories.tsx b/src/components/select/select.stories.tsx index de2aba34..f501658d 100644 --- a/src/components/select/select.stories.tsx +++ b/src/components/select/select.stories.tsx @@ -9,6 +9,7 @@ const meta: Meta = { subcomponents: { 'Select.Button': Select.Button, 'Select.Portal': Select.Portal, + 'Select.OptionGroup': Select.OptionGroup, 'Select.Options': Select.Options, 'Select.Option': Select.Option, } as Record>, diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 25ab0375..87dec5d9 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, Fragment, + type ReactNode, } from 'react'; import { cn } from '@/utilities/functions'; import { CheckIcon, ChevronDown, ChevronsUpDown, Search } from 'lucide-react'; @@ -51,6 +52,7 @@ import type { SelectSizes, SelectOptionGroupProps, } from './select-types'; +import { getTextContent } from './utils'; // Context to manage the state of the select component. const SelectContext = createContext( @@ -63,7 +65,7 @@ export function SelectButton( { icon = null, // Icon to show in the select button. placeholder = 'Select an option', // Placeholder text. optionIcon = null, // Icon to show in the selected option. - displayBy = 'name', // Used to display the value. Default is 'name'. + render, label, // Label for the select component. className, ...props @@ -113,20 +115,6 @@ export function SelectButton( { return null; } - if ( typeof children === 'function' ) { - const childProps = { - value: selectedValue as SelectOptionValue, - ...( multiple - ? { - onClose: handleOnCloseItem( - selectedValue as SelectOptionValue - ), - } - : {} ), - }; - return children( childProps ); - } - if ( multiple ) { return ( selectedValue as SelectOptionValue[] ).map( ( valueItem: SelectOptionValue, index: number ) => ( @@ -138,8 +126,8 @@ export function SelectButton( { size={ badgeSize as SelectSizes } onMouseDown={ handleOnCloseItem( valueItem ) } label={ - typeof valueItem === 'object' - ? valueItem[ displayBy ]?.toString() + typeof render === 'function' + ? render( valueItem as SelectOptionValue ) : valueItem.toString() } closable={ true } @@ -149,12 +137,31 @@ export function SelectButton( { ); } - let renderValue = - typeof selectedValue === 'object' - ? ( selectedValue as Record )[ displayBy ] - : selectedValue; + let renderValue: ReactNode = + typeof selectedValue === 'string' ? selectedValue : ''; + + if ( typeof render === 'function' ) { + renderValue = render( selectedValue as SelectOptionValue ); + } + + if ( typeof children === 'function' && typeof render !== 'function' ) { + const childProps = { + value: selectedValue as SelectOptionValue, + ...( multiple + ? { + onClose: handleOnCloseItem( + selectedValue as SelectOptionValue + ), + } + : {} ), + }; + renderValue = children( childProps ); + } - if ( isValidElement( children ) ) { + if ( + ( isValidElement( children ) || typeof children === 'string' ) && + typeof render !== 'function' + ) { renderValue = children; } @@ -243,7 +250,7 @@ export function SelectButton( { getValues() && 'flex flex-wrap' ) } > - { /* Show Selected item/items (Multiselector) */ } + { /* Show Selected item/items (Multi-selector) */ } { renderSelected() } { /* Placeholder */ } @@ -332,8 +339,6 @@ export function SelectOptionGroup( { export function SelectOptions( { children, - searchBy = 'name', // Used to identify searched value using the key. Default is 'id'. - searchPlaceholder = 'Search...', // Placeholder text for search box. className, // Additional class name for the dropdown. }: SelectOptionsProps ) { const { @@ -353,6 +358,8 @@ export function SelectOptions( { searchKeyword, listContentRef, by, + searchPlaceholder, + activeIndex, } = useSelectContext(); const initialSelectedValueIndex = useMemo( () => { @@ -360,29 +367,68 @@ export function SelectOptions( { let indexValue = -1; if ( currentValue ) { - indexValue = Children.toArray( children ).findIndex( - ( child: React.ReactNode ) => { - if ( ! isValidElement( child ) ) { - return false; - } - if ( typeof child.props.value === 'object' ) { - return ( - child.props.value[ by ] === - ( currentValue as Record )[ by ] - ); - } - return child.props.value === currentValue; + // Get all children as an array + let allChildren = Children.toArray( children ); + + // If it's an option group, flatten the children + if ( + allChildren.length > 0 && + isValidElement( allChildren[ 0 ] ) && + allChildren[ 0 ].type === SelectOptionGroup + ) { + allChildren = Children.toArray( children ) + .map( ( group ) => + isValidElement( group ) + ? Children.toArray( group.props.children ) + : [] + ) + .flat(); + } + + indexValue = allChildren.findIndex( ( child: React.ReactNode ) => { + if ( ! isValidElement( child ) ) { + return false; } - ); + + const childValue = child.props.value; + + if ( + typeof childValue === 'object' && + typeof currentValue === 'object' + ) { + return ( + childValue[ by ] === + ( currentValue as Record )[ by ] + ); + } + + // For non-object values, do a direct comparison + return childValue === currentValue; + } ); } return indexValue; - }, [ value, selected, children ] ); + }, [ value, selected, children, by ] ); + // Initialize active and selected index. useLayoutEffect( () => { + if ( isOpen ) { + return; + } setActiveIndex( initialSelectedValueIndex ); setSelectedIndex( initialSelectedValueIndex ); - }, [] ); + }, [ initialSelectedValueIndex, isOpen ] ); + + // Reset active index when search keyword changes. + useLayoutEffect( () => { + if ( ! isOpen ) { + return; + } + if ( combobox && [ -1, null ].includes( activeIndex ) ) { + return; + } + setActiveIndex( -1 ); + }, [ searchKeyword, isOpen ] ); // Render children based on the search keyword. const renderChildren = useMemo( () => { @@ -401,15 +447,12 @@ export function SelectOptions( { } if ( searchKeyword ) { - const valueProp = groupChild.props.value; - if ( typeof valueProp === 'object' ) { - return valueProp[ searchBy ] - .toLowerCase() - .includes( searchKeyword.toLowerCase() ); - } - return valueProp - .toLowerCase() - .includes( searchKeyword.toLowerCase() ); + const textContent = getTextContent( + groupChild.props.children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + return textContent.includes( searchTerm ); } return true; } ); @@ -457,20 +500,14 @@ export function SelectOptions( { // Handle regular options if ( searchKeyword ) { - const valueProp = child.props.value; - if ( typeof valueProp === 'object' ) { - if ( - ! valueProp[ searchBy ] - .toLowerCase() - .includes( searchKeyword.toLowerCase() ) - ) { - return null; - } - } else if ( - ! valueProp - .toLowerCase() - .includes( searchKeyword.toLowerCase() ) - ) { + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { return null; } } @@ -508,32 +545,19 @@ export function SelectOptions( { return; } - if ( child.props.value ) { - if ( searchKeyword ) { - const valueProp = child.props.value; - if ( typeof valueProp === 'object' ) { - if ( - valueProp[ searchBy ] - .toLowerCase() - .indexOf( searchKeyword.toLowerCase() ) === -1 - ) { - return; - } - } else if ( - valueProp - .toLowerCase() - .indexOf( searchKeyword.toLowerCase() ) === -1 - ) { - return; - } - } + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + if ( searchKeyword ) { + const searchTerm = searchKeyword.toLowerCase(); + const textMatch = textContent?.includes( searchTerm ); - listContentRef.current.push( - typeof child.props.value === 'object' - ? child.props.value[ searchBy || by ] - : child.props.value - ); + if ( ! textMatch ) { + return; + } } + + listContentRef.current.push( textContent ); } ); }, [ searchKeyword ] ); @@ -592,6 +616,7 @@ export function SelectOptions( { onChange={ ( event ) => setSearchKeyword( event.target.value ) } + value={ searchKeyword } autoComplete="off" /> @@ -748,6 +773,7 @@ const Select = ( { multiple = false, // If true, it will allow multiple selection. combobox = false, // If true, it will show a search box. disabled = false, // If true, it will disable the select component. + searchPlaceholder = 'Search...', // Placeholder text for search box. }: SelectProps ) => { const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); @@ -934,6 +960,7 @@ const Select = ( { setSearchKeyword, disabled, isControlled, + searchPlaceholder, } } > { children } diff --git a/src/components/select/utils.ts b/src/components/select/utils.ts new file mode 100644 index 00000000..23a0896e --- /dev/null +++ b/src/components/select/utils.ts @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react'; + +/** + * Get text content of a node + * @param {ReactNode} node - React node + * @return {string} text content of the node + */ +export const getTextContent = ( node: ReactNode ): string => { + if ( typeof node === 'string' ) { + return node; + } + + if ( typeof node === 'object' && 'textContent' in node! ) { + return node.textContent?.toString().toLowerCase() || ''; + } + + if ( typeof node === 'object' && 'children' in node! ) { + return getTextContent( node.children ); + } + + return ''; +}; diff --git a/src/templates/admin-settings-SureCart/admin-settings-SureCart.stories.jsx b/src/templates/admin-settings-SureCart/admin-settings-SureCart.stories.jsx index 672b50db..5757fbbe 100644 --- a/src/templates/admin-settings-SureCart/admin-settings-SureCart.stories.jsx +++ b/src/templates/admin-settings-SureCart/admin-settings-SureCart.stories.jsx @@ -490,16 +490,16 @@ const Template = ( args ) => { onChange={ () => {} } placeholder="Select an option" size="md" + by="value" combobox > + selected.label + } /> - + { CURRENCY_OPTIONS.map( ( optionItem ) => ( { placeholder="Select an option" size="md" combobox - by="label" + by="value" > + selected.label + } /> { placeholder="Select an option" size="md" combobox - by="label" + by="value" > + selected.label + } /> { onChange={ () => {} } placeholder="Select an option" size="md" + by="id" > - + selected.name } + /> { onChange={ () => {} } placeholder="Select an option" size="md" + by="id" > - - - - Personal Website - - - Company Website - - - Client Website - - + selected.name } + /> + + + + Personal Website + + + Company Website + + + Client Website + + +
@@ -134,34 +140,40 @@ const Template = ( args ) => { onChange={ () => {} } placeholder="Select an option" size="md" + by="id" > - - - - Personal Website - - - Company Website - - - Client Website - - + selected.name } + /> + + + + Personal Website + + + Company Website + + + Client Website + + +
@@ -169,34 +181,40 @@ const Template = ( args ) => { onChange={ () => {} } placeholder="Select an option" size="md" + by="id" > - - - - Personal Contact - - - Company Contact - - - Client Contact - - + selected.name } + /> + + + + Personal Contact + + + Company Contact + + + Client Contact + + +
diff --git a/version.json b/version.json index 7dd2216d..4797dc5a 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "force-ui": "1.3.5" + "force-ui": "1.3.6" }