From 4700dc572d8f8d1c18246f4032bedfda444197a7 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:51:42 +0600 Subject: [PATCH 1/6] Delete component-data.json --- component-data.json | 3069 ------------------------------------------- 1 file changed, 3069 deletions(-) delete mode 100644 component-data.json diff --git a/component-data.json b/component-data.json deleted file mode 100644 index a703da1e..00000000 --- a/component-data.json +++ /dev/null @@ -1,3069 +0,0 @@ -{ - "components": { - "Accordion": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Custom class names for additional styling" - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the component" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Children components" - }, - "type": { - "type": "'simple' | 'separator' | 'boxed'", - "required": false, - "description": "Accordion type (same as parent)" - }, - "defaultValue": { - "type": "string | string[]", - "required": false, - "description": "Initial active item(s)" - }, - "autoClose": { - "type": "boolean", - "required": false, - "description": "Automatically close other items when one is opened" - }, - "isOpen": { - "type": "boolean", - "required": false, - "description": "Determines if the content is open" - }, - "onToggle": { - "type": "() => void", - "required": false, - "description": "Callback for toggling item state" - }, - "value": { - "type": "string", - "required": false, - "description": "The value associated with the accordion item" - }, - "onClick": { - "type": "() => void", - "required": false, - "description": "OnClick handler for the accordion trigger. This works only when collapsible is set to `false`." - }, - "iconType": { - "type": "'arrow' | 'plus-minus' | 'none'", - "required": false, - "description": "Type of icon to display" - }, - "tag": { - "type": "ElementType", - "required": false, - "description": "Element to render trigger as" - }, - "collapsible": { - "type": "boolean", - "required": false, - "description": "Specifies whether the accordion item can be collapsed." - } - }, - "subComponents": { - "Item": { - "props": { - "isOpen": { - "type": "boolean", - "required": false, - "description": "Determines if the item is open" - }, - "onToggle": { - "type": "() => void", - "required": false, - "description": "Callback to toggle the item's state" - }, - "type": { - "type": "'simple' | 'separator' | 'boxed'", - "required": false, - "description": "Accordion type (same as parent)" - }, - "value": { - "type": "string", - "required": false, - "description": "The value associated with the accordion item" - } - } - }, - "Trigger": { - "props": { - "onClick": { - "type": "() => void", - "required": false, - "description": "OnClick handler for the accordion trigger. This works only when collapsible is set to `false`." - }, - "onToggle": { - "type": "() => void", - "required": false, - "description": "Callback for toggling item state" - }, - "isOpen": { - "type": "boolean", - "required": false, - "description": "Indicates if the item is open" - }, - "iconType": { - "type": "'arrow' | 'plus-minus' | 'none'", - "required": false, - "description": "Type of icon to display" - }, - "tag": { - "type": "ElementType", - "required": false, - "description": "Element to render trigger as" - }, - "type": { - "type": "'simple' | 'separator' | 'boxed'", - "required": false, - "description": "Accordion type (same as parent)" - }, - "collapsible": { - "type": "boolean", - "required": false, - "description": "Specifies whether the accordion item can be collapsed." - } - } - }, - "Content": { - "props": { - "isOpen": { - "type": "boolean", - "required": false, - "description": "Determines if the content is open" - }, - "type": { - "type": "'simple' | 'separator' | 'boxed'", - "required": false, - "description": "Accordion type (same as parent)" - } - } - } - } - }, - "Alert": { - "props": { - "variant": { - "type": "'neutral' | 'info' | 'warning' | 'error' | 'success'", - "required": false, - "description": "Defines the style variant of the alert." - }, - "theme": { - "type": "'light' | 'dark'", - "required": false, - "description": "Defines the theme of the alert." - }, - "design": { - "type": "'inline' | 'stack'", - "required": false, - "description": "Defines the design of the alert." - }, - "title": { - "type": "React.ReactNode", - "required": false, - "description": "Defines the title of the alert." - }, - "content": { - "type": "React.ReactNode", - "required": false, - "description": "Defines the content of the alert." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the extra classes." - }, - "onClose": { - "type": "() => void", - "required": false, - "description": "Callback function for close event." - }, - "icon": { - "type": "React.ReactElement | null", - "required": false, - "description": "Custom Icon for the alert." - }, - "action": { - "type": "{", - "required": false, - "description": "Defines the action of the alert." - }, - "label": { - "type": "string", - "required": true, - "description": "" - }, - "onClick": { - "type": "( close: () => void ) => void", - "required": true, - "description": "" - }, - "type": { - "type": "'link' | 'button'", - "required": true, - "description": "" - } - }, - "subComponents": {} - }, - "AreaChart": { - "props": { - "data": { - "type": "DataItem[]", - "required": true, - "description": "An array of objects representing the source data for the chart." - }, - "dataKeys": { - "type": "string[]", - "required": true, - "description": "An array of strings representing the keys to access data in each data object. Used for identifying different data series." - }, - "colors": { - "type": "Color[]", - "required": false, - "description": "An array of color strings that determine the colors for each data series in the chart." - }, - "variant": { - "type": "'solid' | 'gradient'", - "required": false, - "description": "Defines the variant of Area Chart." - }, - "showXAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the x-axis." - }, - "showYAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the y-axis." - }, - "showTooltip": { - "type": "boolean", - "required": false, - "description": "Toggle the visibility of the tooltip on hover, displaying detailed information for each data point." - }, - "tooltipIndicator": { - "type": "'dot' | 'line' | 'dashed'", - "required": false, - "description": "The tooltip indicator. It can be `dot`, `line` or `dashed`." - }, - "tooltipLabelKey": { - "type": "string", - "required": false, - "description": "An array of objects representing the source data for the chart." - }, - "showLegend": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component to identify data series." - }, - "showCartesianGrid": { - "type": "boolean", - "required": false, - "description": "Whether to display the ``, adding horizontal and vertical grid lines." - }, - "xAxisTickFormatter": { - "type": "( value: string ) => string", - "required": false, - "description": "A function used to format the ticks on the x-axis, e.g., for formatting dates or numbers." - }, - "tickFormatter": { - "type": "( value: string ) => string", - "required": false, - "description": " A function used to format the ticks on the x-axis, e.g., for formatting dates or numbers. @deprecated Use `xAxisTickFormatter` instead. " - }, - "yAxisTickFormatter": { - "type": "( value: number ) => string", - "required": false, - "description": "A function used to format the ticks on the y-axis, e.g., for converting 1000 to 1K." - }, - "xAxisDataKey": { - "type": "string", - "required": false, - "description": "The key in the data objects representing values for the x-axis. This is used to access the x-axis values from each data entry." - }, - "yAxisDataKey": { - "type": "string", - "required": false, - "description": "The key in the data objects representing values for the y-axis. This is used to access the y-axis values from each data entry." - }, - "xAxisFontSize": { - "type": "'sm' | 'md' | 'lg'", - "required": false, - "description": "Font size for the labels on the x-axis." - }, - "xAxisFontColor": { - "type": "string", - "required": false, - "description": "Font color for the labels on the x-axis." - }, - "chartWidth": { - "type": "number", - "required": false, - "description": "Width of the chart container." - }, - "chartHeight": { - "type": "number", - "required": false, - "description": "Height of the chart container." - }, - "areaChartWrapperProps": { - "type": "Omit<", - "required": false, - "description": " Area chart Wrapper props to apply additional props to the wrapper component. Ex. `margin`, or `onClick` etc. @see https://recharts.org/en-US/api/AreaChart " - }, - "noDataComponent": { - "type": "ReactNode", - "required": false, - "description": " Custom component to display when no data is available. If not provided, a default \"No data available\" message will be displayed. " - } - }, - "subComponents": {} - }, - "Avatar": { - "props": { - "variant": { - "type": "'white' | 'gray' | 'primary' | 'primary-light' | 'dark'", - "required": false, - "description": "Defines the style variant of the avatar." - }, - "size": { - "type": "'xxs' | 'xs' | 'sm' | 'md' | 'lg'", - "required": false, - "description": "Defines the size of the avatar." - }, - "border": { - "type": "'none' | 'subtle' | 'ring'", - "required": false, - "description": "Defines the border of the avatar." - }, - "url": { - "type": "string", - "required": false, - "description": "The URL of the Avatar image" - }, - "children": { - "type": "ReactNode", - "required": false, - "description": "Defines the children of the avatar." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the extra classes" - }, - "src": { - "type": "string", - "required": false, - "description": "The URL of the Avatar image." - }, - "alt": { - "type": "string", - "required": false, - "description": "Alt text for the avatar image." - } - }, - "subComponents": {} - }, - "Badge": { - "props": { - "label": { - "type": "ReactNode", - "required": false, - "description": " Defines the Label of the badge. " - }, - "size": { - "type": "'xxs' | 'xs' | 'sm' | 'md' | 'lg'", - "required": false, - "description": " Defines the size of the badge. " - }, - "className": { - "type": "string", - "required": false, - "description": " Defines the extra classes " - }, - "type": { - "type": "'pill' | 'rounded'", - "required": false, - "description": " Defines the type of the badge. " - }, - "variant": { - "type": "'neutral' | 'red' | 'yellow' | 'green' | 'blue' | 'inverse'", - "required": false, - "description": " Defines the style variant of the badge. " - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": " Custom Icon for the badge. " - }, - "disableHover": { - "type": "boolean", - "required": false, - "description": " Disable hover effect. @default false " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": " Defines if the badge is disabled. " - }, - "onClose": { - "type": "( event: React.MouseEvent ) => void", - "required": false, - "description": " Callback function for close event " - }, - "closable": { - "type": "boolean", - "required": false, - "description": " Defines if the badge is closable. " - }, - "onMouseDown": { - "type": "() => void", - "required": false, - "description": " Callback function for mouse down event. " - } - }, - "subComponents": {} - }, - "BarChart": { - "props": { - "data": { - "type": "DataItem[]", - "required": true, - "description": "An array of objects representing the source data for the chart." - }, - "dataKeys": { - "type": "string[]", - "required": true, - "description": "An array of strings representing the keys to access data in each data object. Used for identifying different data series." - }, - "colors": { - "type": "Color[]", - "required": false, - "description": "An array of color strings that determine the colors for each data series in the chart." - }, - "layout": { - "type": "'horizontal' | 'vertical'", - "required": false, - "description": "Defines the layout of Bar Chart. if you want to check how layout `vertical` works, then you need to clear the xAxisDataKey value and set showCartesianGrid to false." - }, - "stacked": { - "type": "boolean", - "required": false, - "description": "Defines are the chart bars are stacked." - }, - "showXAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the x-axis." - }, - "showYAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the y-axis." - }, - "showTooltip": { - "type": "boolean", - "required": false, - "description": "Toggle the visibility of the tooltip on hover, displaying detailed information for each data point." - }, - "tooltipIndicator": { - "type": "'dot' | 'line' | 'dashed'", - "required": false, - "description": "The tooltip indicator. It can be `dot`, `line` or `dashed`." - } - }, - "subComponents": {} - }, - "Breadcrumb": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Defines the children of the breadcrumb." - }, - "size": { - "type": "'sm' | 'md'", - "required": false, - "description": "Defines the size of the breadcrumb." - }, - "href": { - "type": "string", - "required": true, - "description": "Defines the href of the link." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the class name of the link." - }, - "as": { - "type": "ElementType", - "required": false, - "description": "Defines the element type of the link." - }, - "type": { - "type": "'arrow' | 'slash'", - "required": true, - "description": " Defines the type of separator. Available options: - arrow - slash " - } - }, - "subComponents": { - "List": { - "props": {} - }, - "Item": { - "props": {} - }, - "Link": { - "props": { - "href": { - "type": "string", - "required": true, - "description": "Defines the href of the link." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the class name of the link." - }, - "as": { - "type": "ElementType", - "required": false, - "description": "Defines the element type of the link." - } - } - }, - "Separator": { - "props": { - "type": { - "type": "'arrow' | 'slash'", - "required": true, - "description": " Defines the type of separator. Available options: - arrow - slash " - } - } - }, - "Ellipsis": { - "props": {} - }, - "Page": { - "props": {} - } - } - }, - "Button": { - "props": { - "variant": { - "type": "'primary' | 'secondary' | 'outline' | 'ghost' | 'link'", - "required": false, - "description": " Defines the style variant of the button. " - }, - "size": { - "type": "'xs' | 'sm' | 'md' | 'lg'", - "required": false, - "description": " Defines the size of the button. " - }, - "type": { - "type": "'button' | 'submit' | 'reset'", - "required": false, - "description": " Defines the type of the button. " - }, - "tag": { - "type": "ElementType", - "required": false, - "description": " Defines the tag of the button. " - }, - "className": { - "type": "string", - "required": false, - "description": " The class name of the button " - }, - "children": { - "type": "ReactNode", - "required": false, - "description": " The children of the button " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": " Defines if the button is disabled. " - }, - "destructive": { - "type": "boolean", - "required": false, - "description": " Defines if the button is destructive. " - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": " Custom Icon for the button. " - }, - "iconPosition": { - "type": "'left' | 'right'", - "required": false, - "description": " Defines the position of the icon. " - }, - "loading": { - "type": "boolean", - "required": false, - "description": " Defines if the button is loading. " - }, - "onClick": { - "type": "( event: React.MouseEvent ) => void", - "required": false, - "description": "On click event." - } - }, - "subComponents": {} - }, - "ButtonGroup": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Additional class names for styling." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Child elements, typically Button components." - }, - "activeItem": { - "type": "string | null", - "required": false, - "description": "Active item slug in the group." - }, - "onChange": { - "type": "( value: ButtonValue ) => void", - "required": false, - "description": "Callback when the active item changes." - }, - "size": { - "type": "'xs' | 'sm' | 'md'", - "required": false, - "description": "Size of the buttons in the group." - }, - "iconPosition": { - "type": "'left' | 'right'", - "required": false, - "description": "Position of the icon inside the button." - }, - "slug": { - "type": "string", - "required": true, - "description": "Unique slug identifying the button." - }, - "text": { - "type": "string", - "required": true, - "description": "Text content of the button." - }, - "icon": { - "type": "ReactElement", - "required": false, - "description": "Icon displayed inside the button." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Marks the button as disabled." - }, - "isFirstChild": { - "type": "boolean", - "required": false, - "description": "Indicates if the button is the first child in the group." - }, - "isLastChild": { - "type": "boolean", - "required": false, - "description": "Indicates if the button is the last child in the group." - } - }, - "subComponents": {} - }, - "Checkbox": { - "props": {}, - "subComponents": {} - }, - "Container": { - "props": { - "containerType": { - "type": "TContainerType", - "required": false, - "description": "Defines the type of the container (default: 'flex')." - }, - "cols": { - "type": "TCols", - "required": true, - "description": "" - }, - "className": { - "type": "string", - "required": false, - "description": "Defines any additional CSS classes for the container." - }, - "children": { - "type": "ReactNode", - "required": false, - "description": "Defines the children of the container." - } - }, - "subComponents": { - "Item": { - "props": { - "containerType": { - "type": "TContainerType", - "required": true, - "description": "" - }, - "cols": { - "type": "TCols", - "required": true, - "description": "" - } - } - } - } - }, - "Dialog": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the dialog close button." - }, - "style": { - "type": "React.CSSProperties", - "required": false, - "description": "Additional inline styles." - }, - "open": { - "type": "boolean", - "required": false, - "description": "Control the dialog open state. If not provided, the dialog will be controlled internally." - }, - "setOpen": { - "type": "( open: boolean ) => void", - "required": false, - "description": "Control the dialog open state. If not provided, the dialog will be controlled internally." - }, - "children": { - "type": "ReactNode | ( ( props: { close: () => void", - "required": false, - "description": "Children of the dialog footer." - }, - "id": { - "type": "string", - "required": false, - "description": "Id of the dialog portal where the element will be rendered. If not provided, the dialog will be rendered in the body." - }, - "root": { - "type": "HTMLElement", - "required": false, - "description": "Root element of the dialog portal. If not provided, the dialog will be rendered in the body." - }, - "as": { - "type": "ElementType", - "required": false, - "description": "Additional class name for the dialog close button." - }, - "onClick": { - "type": "() => void", - "required": false, - "description": "On click event for the close button." - } - }, - "subComponents": { - "Panel": { - "props": { - "children": { - "type": "ReactNode | ( ( param: { close: () => void", - "required": true, - "description": "Children of the dialog panel." - } - } - }, - "Portal": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Children of the dialog portal." - }, - "id": { - "type": "string", - "required": false, - "description": "Id of the dialog portal where the element will be rendered. If not provided, the dialog will be rendered in the body." - }, - "root": { - "type": "HTMLElement", - "required": false, - "description": "Root element of the dialog portal. If not provided, the dialog will be rendered in the body." - } - } - }, - "Title": { - "props": {} - }, - "Description": { - "props": {} - }, - "CloseButton": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": "Children of the dialog close button." - }, - "as": { - "type": "ElementType", - "required": false, - "description": "Additional class name for the dialog close button." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the dialog close button." - }, - "onClick": { - "type": "() => void", - "required": false, - "description": "On click event for the close button." - } - } - }, - "Header": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Children of the dialog header." - } - } - }, - "Body": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Children of the dialog body." - } - } - }, - "Footer": { - "props": { - "children": { - "type": "ReactNode | ( ( props: { close: () => void", - "required": false, - "description": "Children of the dialog footer." - } - } - }, - "Backdrop": { - "props": {} - } - } - }, - "Drawer": { - "props": { - "open": { - "type": "boolean", - "required": false, - "description": "Open state of the drawer. Optional for uncontrolled component." - }, - "setOpen": { - "type": "( open: boolean ) => void", - "required": false, - "description": "Set open state of the drawer. Optional for uncontrolled component." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Drawer content." - }, - "trigger": { - "type": "ReactNode | ( ( props: { onClick: () => void", - "required": false, - "description": "Trigger element to open the drawer. Required for uncontrolled component." - } - }, - "subComponents": { - "Panel": { - "props": { - "children": { - "type": "ReactNode | ( ( props: { close: () => void", - "required": true, - "description": "Drawer content." - } - } - }, - "Header": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Header content." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - } - } - }, - "Title": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Title content." - }, - "as": { - "type": "ElementType", - "required": false, - "description": "HTML element to render." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - } - } - }, - "Description": { - "props": { - "as": { - "type": "ElementType", - "required": false, - "description": "Description tag." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Description content." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - } - } - }, - "Body": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Body content." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - } - } - }, - "CloseButton": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - }, - "onClick": { - "type": "() => void", - "required": true, - "description": "Click handler." - }, - "children": { - "type": "ReactNode | ( ( { close", - "required": false, - "description": "Button content." - } - } - }, - "Footer": { - "props": { - "children": { - "type": "ReactNode | ( ( { close", - "required": true, - "description": "Footer content." - } - } - }, - "Backdrop": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Additional class names." - } - } - }, - "Portal": { - "props": {} - } - } - }, - "DropdownMenu": { - "props": {}, - "subComponents": { - "Trigger": { - "props": {} - }, - "Content": { - "props": {} - }, - "List": { - "props": {} - }, - "Item": { - "props": {} - }, - "Separator": { - "props": {} - }, - "Portal": { - "props": {} - }, - "ContentWrapper": { - "props": {} - } - } - }, - "Dropzone": { - "props": { - "onFileUpload": { - "type": "( file: File ) => void", - "required": false, - "description": "Callback function when a file is uploaded" - }, - "inlineIcon": { - "type": "boolean", - "required": false, - "description": "Determines if the icon should be inline" - }, - "label": { - "type": "string", - "required": false, - "description": "Label for the dropzone" - }, - "helpText": { - "type": "string", - "required": false, - "description": "Help text for the dropzone" - }, - "size": { - "type": "'sm' | 'md' | 'lg'", - "required": false, - "description": "Size variant of the dropzone" - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Indicates if the component is disabled" - }, - "error": { - "type": "boolean", - "required": false, - "description": "Indicates if the component is in error state" - }, - "errorText": { - "type": "string", - "required": false, - "description": "Error text to display" - }, - "className": { - "type": "string", - "required": false, - "description": "Custom class name for the dropzone" - }, - "wrapperClassName": { - "type": "string", - "required": false, - "description": "Custom class name for the wrapper" - } - }, - "subComponents": {} - }, - "EditorInput": { - "props": { - "defaultValue": { - "type": "string", - "required": false, - "description": "Default value for the editor input field." - }, - "placeholder": { - "type": "string", - "required": false, - "description": "Placeholder text for the editor input field." - }, - "size": { - "type": "keyof typeof editorInputClassNames", - "required": false, - "description": "Defines the sizes of the editor input." - }, - "autoFocus": { - "type": "boolean", - "required": false, - "description": "Defines if the editor input is focused automatically." - }, - "options": { - "type": "T[]", - "required": true, - "description": "Array of options to be displayed in the editor input. Each option should be an object or string." - }, - "trigger": { - "type": "string", - "required": false, - "description": "The trigger to be used to show the mention options." - }, - "menuComponent": { - "type": "TMenuComponent", - "required": false, - "description": "The component to be used for the mention menu." - }, - "menuItemComponent": { - "type": "TMenuItemComponent", - "required": false, - "description": "The component to be used for the mention menu items." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names to be added to the editor input." - }, - "wrapperClassName": { - "type": "string", - "required": false, - "description": "Additional class names to be added to the editor input wrapper." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Defines if the editor input is disabled." - }, - "autoSpaceAfterMention": { - "type": "boolean", - "required": false, - "description": "Defines if the editor input should add a space after selecting a mention/tag option." - }, - "style": { - "type": "React.CSSProperties", - "required": false, - "description": " Override the default styles of the editor input. This prop allows you to apply custom styles using a React.CSSProperties object. Note that the editor utilizes inline styles, so to effectively override existing styles, you must provide the desired styles through this `style` prop. " - }, - "maxLength": { - "type": "number", - "required": false, - "description": "Defines the maximum character limit of the editor input." - } - }, - "subComponents": {} - }, - "FilePreview": { - "props": { - "file": { - "type": "FilePreviewFile", - "required": true, - "description": "The file to display. It can be a File object or an object with name, url, type, and size properties." - }, - "onRemove": { - "type": "( selectedFile: FilePreviewFile ) => void", - "required": true, - "description": "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." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Indicates whether the file preview is disabled." - }, - "size": { - "type": "'sm' | 'md' | 'lg'", - "required": false, - "description": "The size of the file preview." - }, - "error": { - "type": "boolean", - "required": false, - "description": "Indicates whether the file preview has an error." - } - }, - "subComponents": {} - }, - "HamburgerMenu": { - "props": { - "className": { - "type": "string", - "required": false, - "description": " Additional class name for styling. " - }, - "children": { - "type": "ReactNode", - "required": false, - "description": " Children elements. " - }, - "tag": { - "type": "T", - "required": false, - "description": " The tag or component to render the option as. " - }, - "active": { - "type": "boolean", - "required": false, - "description": " Whether the option is active. " - }, - "icon": { - "type": "React.ReactElement", - "required": false, - "description": " Icon component to display. " - }, - "iconPosition": { - "type": "'left' | 'right'", - "required": false, - "description": " Position of the icon. " - }, - "variants": { - "type": "{", - "required": true, - "description": "" - } - }, - "subComponents": { - "Options": { - "props": { - "children": { - "type": "React.ReactNode", - "required": true, - "description": " The children to render in the menu options. " - }, - "className": { - "type": "string", - "required": false, - "description": " The class name to apply to the menu options container. " - } - } - }, - "Option": { - "props": { - "tag": { - "type": "T", - "required": false, - "description": " The tag or component to render the option as. " - }, - "active": { - "type": "boolean", - "required": false, - "description": " Whether the option is active. " - }, - "icon": { - "type": "React.ReactElement", - "required": false, - "description": " Icon component to display. " - }, - "iconPosition": { - "type": "'left' | 'right'", - "required": false, - "description": " Position of the icon. " - }, - "className": { - "type": "string", - "required": false, - "description": " Additional class name for styling. " - }, - "children": { - "type": "ReactNode", - "required": false, - "description": " Children elements. " - } - } - }, - "Toggle": { - "props": { - "className": { - "type": "string", - "required": false, - "description": " The class name to apply to the hamburger menu toggle button. " - } - } - } - } - }, - "Input": { - "props": { - "id": { - "type": "string", - "required": false, - "description": "Unique identifier for the input element." - }, - "type": { - "type": "'text' | 'password' | 'email' | 'file'", - "required": false, - "description": "Specifies the type of the input element (e.g., text, file)." - }, - "defaultValue": { - "type": "string", - "required": false, - "description": "Initial value of the input element." - }, - "value": { - "type": "string", - "required": false, - "description": "Controlled value of the input element." - }, - "size": { - "type": "'sm' | 'md' | 'lg'", - "required": false, - "description": "Defines the size of the input (e.g., 'sm', 'md', 'lg')." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional custom classes for styling." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the input element when true." - }, - "onChange": { - "type": "( value: string | FileList | null ) => void", - "required": false, - "description": "Function called when the input value changes." - }, - "error": { - "type": "boolean", - "required": false, - "description": "Indicates whether the input has an error state." - }, - "onError": { - "type": "() => void", - "required": false, - "description": "Function called when the input encounters an error." - }, - "prefix": { - "type": "ReactNode", - "required": false, - "description": "React node displayed as a prefix inside the input." - }, - "suffix": { - "type": "ReactNode", - "required": false, - "description": "React node displayed as a suffix inside the input." - }, - "label": { - "type": "string", - "required": false, - "description": "Label displayed above the input field." - }, - "placeholder": { - "type": "string", - "required": false, - "description": "Placeholder text for the input field." - }, - "required": { - "type": "boolean", - "required": false, - "description": "Indicates whether the input is required." - } - }, - "subComponents": {} - }, - "Label": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "The content of the label." - }, - "tag": { - "type": "string | ElementType", - "required": false, - "description": "Defines the HTML tag to use for the label." - }, - "size": { - "type": "'xs' | 'sm' | 'md'", - "required": false, - "description": "Defines the size of the label." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the extra classes." - }, - "variant": { - "type": "'neutral' | 'help' | 'error' | 'disabled'", - "required": false, - "description": "Defines the style variant of the label." - }, - "required": { - "type": "boolean", - "required": false, - "description": "Defines if the label is required." - } - }, - "subComponents": {} - }, - "LineChart": { - "props": { - "data": { - "type": "DataItem[]", - "required": true, - "description": "An array of objects representing the source data for the chart." - }, - "dataKeys": { - "type": "string[]", - "required": true, - "description": "An array of strings representing the keys to access data in each data object. Used for identifying different data series." - }, - "colors": { - "type": "Color[]", - "required": false, - "description": "An array of color objects that determine the stroke colors for each data series in the chart." - }, - "showXAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the x-axis." - }, - "showYAxis": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component for the y-axis." - }, - "showTooltip": { - "type": "boolean", - "required": false, - "description": "Toggle the visibility of the tooltip on hover, displaying detailed information for each data point." - }, - "tooltipIndicator": { - "type": "'dot' | 'line' | 'dashed'", - "required": false, - "description": "The tooltip indicator. It can be `dot`, `line`, or `dashed`." - }, - "tooltipLabelKey": { - "type": "string", - "required": false, - "description": "The key to use for the tooltip label." - }, - "showCartesianGrid": { - "type": "boolean", - "required": false, - "description": "Whether to display the ``, adding horizontal and vertical grid lines." - }, - "xAxisTickFormatter": { - "type": "( value: string ) => string", - "required": false, - "description": "A function used to format the ticks on the x-axis, e.g., for formatting dates or numbers." - }, - "tickFormatter": { - "type": "( value: string ) => string", - "required": false, - "description": " A function used to format the ticks on the x-axis, e.g., for formatting dates or numbers. @deprecated Use `xAxisTickFormatter` instead. " - }, - "yAxisTickFormatter": { - "type": "( value: number ) => string", - "required": false, - "description": "A function used to format the ticks on the y-axis, e.g., for converting 1000 to 1K." - }, - "xAxisDataKey": { - "type": "string", - "required": false, - "description": "The key in the data objects representing values for the x-axis." - }, - "yAxisDataKey": { - "type": "string", - "required": false, - "description": "The key in the data objects representing values for the y-axis." - }, - "xAxisFontSize": { - "type": "'sm' | 'md' | 'lg'", - "required": false, - "description": "Font size for the labels on the x-axis." - }, - "xAxisFontColor": { - "type": "string", - "required": false, - "description": "Font color for the labels on the x-axis." - }, - "yAxisFontColor": { - "type": "string | string[]", - "required": false, - "description": " Font color for the labels on the y-axis. When biaxial is true, you can provide an array of two colors [leftAxisColor, rightAxisColor]. If a single color is provided, it will be used for both axes. " - }, - "chartWidth": { - "type": "number | string", - "required": false, - "description": "Width of the chart container." - }, - "chartHeight": { - "type": "number | string", - "required": false, - "description": "Height of the chart container." - }, - "withDots": { - "type": "boolean", - "required": false, - "description": "Determines whether dots are shown on each data point." - }, - "lineChartWrapperProps": { - "type": "Omit<", - "required": false, - "description": " Line chart Wrapper props to apply additional props to the wrapper component. Ex. `margin`, or `onClick` etc. @see https://recharts.org/en-US/api/LineChart " - }, - "strokeDasharray": { - "type": "string", - "required": false, - "description": " The stroke dasharray for the Cartesian grid. @default '3 3' @see https://recharts.org/en-US/api/CartesianGrid " - }, - "gridColor": { - "type": "string", - "required": false, - "description": " The color of the Cartesian grid lines. @default '#E5E7EB' " - }, - "biaxial": { - "type": "boolean", - "required": false, - "description": " Biaxial chart. " - }, - "noDataComponent": { - "type": "ReactNode", - "required": false, - "description": " Custom component to display when no data is available. If not provided, a default \"No data available\" message will be displayed. " - } - }, - "subComponents": {} - }, - "Loader": { - "props": { - "variant": { - "type": "'primary' | 'secondary'", - "required": false, - "description": "Defines the variant of the loader. Options are 'primary' or 'secondary'." - }, - "size": { - "type": "'sm' | 'md' | 'lg' | 'xl'", - "required": false, - "description": "Defines the size of the loader. Options are 'sm', 'md', 'lg', or 'xl'." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Optional icon to display instead of the default loader icon." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional custom classes for styling." - } - }, - "subComponents": {} - }, - "Pagination": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": "Defines the children of the pagination component." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional CSS classes." - }, - "size": { - "type": "PaginationSize", - "required": false, - "description": "Defines the size of pagination items." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the button." - }, - "isActive": { - "type": "boolean", - "required": false, - "description": "Marks the button as active." - }, - "onClick": { - "type": "React.MouseEventHandler", - "required": false, - "description": "Optional click handler for the button." - }, - "tag": { - "type": "'a' | 'button'", - "required": false, - "description": "The HTML tag to be rendered for the pagination button." - } - }, - "subComponents": { - "Content": { - "props": {} - }, - "Item": { - "props": { - "isActive": { - "type": "boolean", - "required": false, - "description": "Marks the pagination item as active." - } - } - }, - "Previous": { - "props": {} - }, - "Next": { - "props": {} - }, - "Ellipsis": { - "props": {} - } - } - }, - "PieChart": { - "props": { - "data": { - "type": "DataItem[]", - "required": true, - "description": "An array of objects representing the source data for the chart." - }, - "dataKey": { - "type": "string", - "required": true, - "description": "A string which representing the key to access data in each data object. Used for identifying different data series." - }, - "type": { - "type": "'simple' | 'donut'", - "required": false, - "description": "Type of pie chart. It can be `simple` or `donut`" - }, - "showTooltip": { - "type": "boolean", - "required": false, - "description": "Toggle the visibility of the tooltip on hover, displaying detailed information for each data point." - }, - "tooltipIndicator": { - "type": "'dot' | 'line' | 'dashed'", - "required": false, - "description": "The tooltip indicator. It can be `dot`, `line`, or `dashed`." - }, - "tooltipLabelKey": { - "type": "string", - "required": false, - "description": "The key to use for the tooltip label." - }, - "label": { - "type": "boolean", - "required": false, - "description": "When is true it show the label inside `donut` pie chart" - }, - "labelName": { - "type": "string", - "required": false, - "description": "Label name which will be displayed inside donut pie chart." - }, - "labelNameColor": { - "type": "string", - "required": false, - "description": "Label name color which will be displayed inside donut pie chart." - }, - "labelValue": { - "type": "number | string", - "required": false, - "description": "Label value which will be displayed inside donut pie chart." - }, - "showLegend": { - "type": "boolean", - "required": false, - "description": "Whether to render the `` component to identify data series." - }, - "chartWidth": { - "type": "number", - "required": false, - "description": "Width of the chart container." - }, - "pieOuterRadius": { - "type": "number", - "required": false, - "description": "Outer radius of the pie chart." - }, - "pieInnerRadius": { - "type": "number", - "required": false, - "description": "Inner radius of the pie chart." - } - }, - "subComponents": {} - }, - "ProgressBar": { - "props": { - "progress": { - "type": "number", - "required": false, - "description": "Current progress value (0 to 100)." - }, - "speed": { - "type": "number", - "required": false, - "description": "Speed of the progress transition in milliseconds." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional custom classes for styling." - } - }, - "subComponents": {} - }, - "ProgressSteps": { - "props": { - "children": { - "type": "ReactNode", - "required": true, - "description": "Defines the children of the progress steps." - }, - "className": { - "type": "string", - "required": false, - "description": "Defines the class name for the component." - }, - "variant": { - "type": "'dot' | 'number' | 'icon'", - "required": false, - "description": "Specifies the variant style: 'dot', 'number', or 'icon'." - }, - "size": { - "type": "'sm' | 'md' | 'lg'", - "required": true, - "description": "Defines the size of the step: 'sm', 'md', or 'lg'." - }, - "type": { - "type": "'inline' | 'stack'", - "required": false, - "description": "Defines the layout type: 'inline' or 'stack'." - }, - "currentStep": { - "type": "number", - "required": false, - "description": "Defines the current step number. `-1` keeps all steps completed." - }, - "lineClassName": { - "type": "string", - "required": false, - "description": "Additional class names for the connecting line." - }, - "completedVariant": { - "type": "CompletedVariant", - "required": false, - "description": "How to display completed steps" - }, - "completedIcon": { - "type": "ReactNode", - "required": false, - "description": "Custom icon for completed steps" - }, - "labelText": { - "type": "string", - "required": false, - "description": "Text label for the step." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Custom icon for the step." - }, - "isCurrent": { - "type": "boolean", - "required": false, - "description": "Indicates if this step is currently active." - }, - "isCompleted": { - "type": "boolean", - "required": false, - "description": "Indicates if this step has been completed." - }, - "sizeClasses": { - "type": "StepSizeClasses", - "required": false, - "description": "Size-specific CSS classes for the step." - }, - "isLast": { - "type": "boolean", - "required": false, - "description": "Indicates if this step is the last in the sequence." - }, - "index": { - "type": "number", - "required": false, - "description": "The index of the step in the sequence." - } - }, - "subComponents": { - "Step": { - "props": { - "variant": { - "type": "'dot' | 'number' | 'icon'", - "required": false, - "description": "Specifies the variant style: 'dot', 'number', or 'icon'." - }, - "size": { - "type": "'sm' | 'md' | 'lg'", - "required": true, - "description": "Defines the size of the step: 'sm', 'md', or 'lg'." - }, - "type": { - "type": "'inline' | 'stack'", - "required": false, - "description": "Defines the layout type: 'inline' or 'stack'." - }, - "currentStep": { - "type": "number", - "required": false, - "description": "Defines the current step number. `-1` keeps all steps completed." - }, - "lineClassName": { - "type": "string", - "required": false, - "description": "Additional class names for the connecting line." - }, - "completedVariant": { - "type": "CompletedVariant", - "required": false, - "description": "How to display completed steps" - }, - "completedIcon": { - "type": "ReactNode", - "required": false, - "description": "Custom icon for completed steps" - }, - "labelText": { - "type": "string", - "required": false, - "description": "Text label for the step." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Custom icon for the step." - }, - "isCurrent": { - "type": "boolean", - "required": false, - "description": "Indicates if this step is currently active." - }, - "isCompleted": { - "type": "boolean", - "required": false, - "description": "Indicates if this step has been completed." - }, - "sizeClasses": { - "type": "StepSizeClasses", - "required": false, - "description": "Size-specific CSS classes for the step." - }, - "isLast": { - "type": "boolean", - "required": false, - "description": "Indicates if this step is the last in the sequence." - }, - "index": { - "type": "number", - "required": false, - "description": "The index of the step in the sequence." - } - } - } - } - }, - "RadioButton": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Custom class names for additional styling" - }, - "as": { - "type": "ElementType", - "required": false, - "description": "HTML element or React component to render the element as" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "" - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "" - }, - "name": { - "type": "string", - "required": false, - "description": "Name used for form submission" - }, - "style": { - "type": "'simple' | 'tile'", - "required": false, - "description": "Style of the radio button group: 'simple' or 'tile'" - }, - "size": { - "type": "'sm' | 'md'", - "required": false, - "description": "Size of the radio buttons: 'sm' or 'md'" - }, - "value": { - "type": "string | string[]", - "required": false, - "description": "Controlled value of the group" - }, - "defaultValue": { - "type": "string | string[]", - "required": false, - "description": "Default value if the group is uncontrolled" - }, - "by": { - "type": "string", - "required": false, - "description": "Attribute to compare selected values, typically 'id'" - }, - "onChange": { - "type": "( value: string | string[] ) => void", - "required": false, - "description": "Handler invoked on value change" - }, - "disableGroup": { - "type": "boolean", - "required": false, - "description": "Disables all radio buttons in the group" - }, - "vertical": { - "type": "boolean", - "required": false, - "description": "Arranges the radio buttons vertically" - }, - "columns": { - "type": "number", - "required": false, - "description": "Number of columns for arranging the buttons" - }, - "multiSelection": { - "type": "boolean", - "required": false, - "description": "Enables multi-selection mode" - }, - "gapClassName": { - "type": "string", - "required": false, - "description": "Gap between radio buttons" - }, - "id": { - "type": "string", - "required": false, - "description": "Unique identifier for the radio button" - } - }, - "subComponents": { - "Group": { - "props": { - "name": { - "type": "string", - "required": false, - "description": "Name used for form submission" - }, - "style": { - "type": "'simple' | 'tile'", - "required": false, - "description": "Style of the radio button group: 'simple' or 'tile'" - }, - "size": { - "type": "'sm' | 'md'", - "required": false, - "description": "Size of the radio buttons: 'sm' or 'md'" - }, - "value": { - "type": "string | string[]", - "required": false, - "description": "Controlled value of the group" - }, - "defaultValue": { - "type": "string | string[]", - "required": false, - "description": "Default value if the group is uncontrolled" - }, - "by": { - "type": "string", - "required": false, - "description": "Attribute to compare selected values, typically 'id'" - }, - "onChange": { - "type": "( value: string | string[] ) => void", - "required": false, - "description": "Handler invoked on value change" - }, - "disableGroup": { - "type": "boolean", - "required": false, - "description": "Disables all radio buttons in the group" - }, - "vertical": { - "type": "boolean", - "required": false, - "description": "Arranges the radio buttons vertically" - }, - "columns": { - "type": "number", - "required": false, - "description": "Number of columns for arranging the buttons" - }, - "multiSelection": { - "type": "boolean", - "required": false, - "description": "Enables multi-selection mode" - }, - "gapClassName": { - "type": "string", - "required": false, - "description": "Gap between radio buttons" - }, - "id": { - "type": "string", - "required": false, - "description": "" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "" - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "" - } - } - }, - "Button": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Custom class names for additional styling" - }, - "as": { - "type": "ElementType", - "required": false, - "description": "HTML element or React component to render the element as" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "" - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "" - }, - "name": { - "type": "string", - "required": false, - "description": "Name used for form submission" - }, - "style": { - "type": "'simple' | 'tile'", - "required": false, - "description": "Style of the radio button group: 'simple' or 'tile'" - }, - "size": { - "type": "'sm' | 'md'", - "required": false, - "description": "" - }, - "value": { - "type": "string", - "required": true, - "description": "" - }, - "defaultValue": { - "type": "string | string[]", - "required": false, - "description": "Default value if the group is uncontrolled" - }, - "by": { - "type": "string", - "required": false, - "description": "Attribute to compare selected values, typically 'id'" - }, - "onChange": { - "type": "( value: string | string[] ) => void", - "required": false, - "description": "Handler invoked on value change" - }, - "disableGroup": { - "type": "boolean", - "required": false, - "description": "Disables all radio buttons in the group" - }, - "vertical": { - "type": "boolean", - "required": false, - "description": "Arranges the radio buttons vertically" - }, - "columns": { - "type": "number", - "required": false, - "description": "Number of columns for arranging the buttons" - }, - "multiSelection": { - "type": "boolean", - "required": false, - "description": "Enables multi-selection mode" - }, - "gapClassName": { - "type": "string", - "required": false, - "description": "Gap between radio buttons" - }, - "id": { - "type": "string", - "required": false, - "description": "Unique identifier for the radio button" - }, - "label": { - "type": "{ heading: string; description?: string", - "required": false, - "description": "Label content for the radio button" - } - } - } - } - }, - "Select": { - "props": { - "id": { - "type": "string", - "required": false, - "description": " Root element ID where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. " - }, - "size": { - "type": "SelectSizes", - "required": false, - "description": "Defines the size of the Select Component." - }, - "by": { - "type": "string", - "required": false, - "description": "When the value is an object, a key is required to compare the selected value. The default value is `id`." - }, - "children": { - "type": "MultiTypeChildren", - "required": false, - "description": "Expects the `Select.Button` children of the Select Component." - }, - "combobox": { - "type": "boolean", - "required": false, - "description": "Combobox mode." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the Select Component." - }, - "multiple": { - "type": "boolean", - "required": false, - "description": "Multi select mode." - }, - "value": { - "type": "SelectOptionValue", - "required": true, - "description": "Value of the option." - }, - "onChange": { - "type": "SelectOnChange", - "required": true, - "description": "onChange event to be triggered when the value of the Select Component changes." - }, - "defaultValue": { - "type": "SelectOptionValue | SelectOptionValue[]", - "required": false, - "description": "Defines the default value of the Select Component." - }, - "searchPlaceholder": { - "type": "string", - "required": false, - "description": "Placeholder text for search box." - }, - "searchFn": { - "type": "( keyword: string ) => Promise", - "required": false, - "description": "Function to fetch options. If provided, the search functionality will be handled outside of the select component." - }, - "debounceDelay": { - "type": "number", - "required": false, - "description": "Delay in milliseconds for debounced search. If the searchFn is provided, the debounceDelay will be used to debounce the search." - }, - "root": { - "type": "HTMLElement", - "required": false, - "description": " Root element where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. " - }, - "icon": { - "type": "ReactNode | null", - "required": false, - "description": "Option Icon to show at the right of the option trigger/button. By default it will show chevron down icon." - }, - "placeholder": { - "type": "string", - "required": false, - "description": "Placeholder text when no option is selected." - }, - "optionIcon": { - "type": "ReactNode | null", - "required": false, - "description": "Icon to show in the selected option badge (Multi-select mode only). By default it won't show unknown icon." - }, - "render": { - "type": "( selected: SelectOptionValue ) => ReactNode | string", - "required": false, - "description": " 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. " - }, - "label": { - "type": "string", - "required": false, - "description": "Label for the Select component." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the Select Button." - }, - "selected": { - "type": "boolean", - "required": false, - "description": "Selected state of the option." - } - }, - "subComponents": { - "Portal": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": "Expects the `Select.Options` children of the Select.Portal Component." - }, - "root": { - "type": "HTMLElement", - "required": false, - "description": " Root element where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. " - }, - "id": { - "type": "string", - "required": false, - "description": " Root element ID where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. " - } - } - }, - "Button": { - "props": { - "children": { - "type": "MultiTypeChildren", - "required": false, - "description": "Expects the `Select.Button` children of the Select Component." - }, - "icon": { - "type": "ReactNode | null", - "required": false, - "description": "Option Icon to show at the right of the option trigger/button. By default it will show chevron down icon." - }, - "placeholder": { - "type": "string", - "required": false, - "description": "Placeholder text when no option is selected." - }, - "optionIcon": { - "type": "ReactNode | null", - "required": false, - "description": "Icon to show in the selected option badge (Multi-select mode only). By default it won't show unknown icon." - }, - "render": { - "type": "( selected: SelectOptionValue ) => ReactNode | string", - "required": false, - "description": " 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. " - }, - "label": { - "type": "string", - "required": false, - "description": "Label for the Select component." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the Select Button." - } - } - }, - "Options": { - "props": { - "children": { - "type": "React.ReactNode", - "required": true, - "description": "Expects the `Select.Option` or `Select.OptionGroup` children" - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the Select Options wrapper." - } - } - }, - "Option": { - "props": { - "value": { - "type": "SelectOptionValue", - "required": true, - "description": "Value of the option." - }, - "selected": { - "type": "boolean", - "required": false, - "description": "Selected state of the option." - }, - "children": { - "type": "ReactNode", - "required": false, - "description": "Expects the `Select.Option` children of the Select Component." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the Select Option." - }, - "label": { - "type": "string", - "required": true, - "description": "Label for the option group" - } - } - }, - "OptionGroup": { - "props": { - "label": { - "type": "string", - "required": true, - "description": "Label for the option group" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Children options" - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class name for the option group" - } - } - } - } - }, - "Sidebar": { - "props": { - "isCollapsed": { - "type": "boolean", - "required": true, - "description": "" - }, - "setIsCollapsed": { - "type": "React.Dispatch>", - "required": true, - "description": "" - }, - "collapsible": { - "type": "boolean", - "required": false, - "description": "Determines if the Sidebar can be collapsed or not. If true, a collapse button is shown." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Content to render inside the Sidebar. Typically includes Sidebar.Header, Sidebar.Body, and Sidebar.Footer components." - }, - "className": { - "type": "string", - "required": false, - "description": "Optional custom CSS classes for styling the Sidebar item." - }, - "onCollapseChange": { - "type": "( isCollapsed: boolean ) => void", - "required": false, - "description": "Callback function triggered when the Sidebar collapse state changes." - }, - "borderOn": { - "type": "boolean", - "required": false, - "description": "Controls whether a border should appear on the right of the Sidebar." - }, - "collapsed": { - "type": "boolean", - "required": false, - "description": "Set the sidebar collapse state. This is useful when collapsible is false and you want to use the sidebar as collapsed by default." - } - }, - "subComponents": { - "Header": { - "props": {} - }, - "Body": { - "props": {} - }, - "Footer": { - "props": {} - }, - "Item": { - "props": { - "className": { - "type": "string", - "required": false, - "description": "Optional custom CSS classes for styling the Sidebar item." - }, - "onClick": { - "type": "() => void; // Add this line", - "required": false, - "description": "Click event handler" - } - } - } - } - }, - "Skeleton": { - "props": { - "variant": { - "type": "'rectangular' | 'circular'", - "required": false, - "description": "Defines the style variant of the skeleton." - }, - "className": { - "type": "string", - "required": false, - "description": "Allows you to pass custom classes to control the size and styles." - } - }, - "subComponents": {} - }, - "Switch": { - "props": { - "id": { - "type": "string", - "required": false, - "description": "Unique identifier for the switch component." - }, - "onChange": { - "type": "( checked: boolean ) => void", - "required": false, - "description": "Callback function triggered when the switch value changes." - }, - "value": { - "type": "boolean", - "required": false, - "description": "Controlled value of the switch (checked or unchecked)." - }, - "defaultValue": { - "type": "boolean", - "required": false, - "description": "Initial value of the switch (checked or unchecked) when used as an uncontrolled component." - }, - "size": { - "type": "'xs' | 'sm' | 'md'", - "required": false, - "description": " Defines the size of the switch (e.g., 'xs', 'sm', 'md'). @default 'sm' " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the switch if true." - }, - "label": { - "type": "{", - "required": false, - "description": "Defines the label for the switch, can include heading and description." - }, - "heading": { - "type": "string", - "required": false, - "description": "Heading for the label." - }, - "description": { - "type": "string", - "required": false, - "description": "Description for the label." - } - }, - "subComponents": {} - }, - "Table": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Child components to render within the table footer. " - }, - "className": { - "type": "string", - "required": false, - "description": " Class name to apply to the component. " - }, - "checkboxSelection": { - "type": "boolean", - "required": false, - "description": " Whether to show checkboxes for row selection. " - }, - "selected": { - "type": "boolean", - "required": false, - "description": " Whether any of the rows are selected. " - }, - "indeterminate": { - "type": "boolean", - "required": false, - "description": " Whether the checkbox is indeterminate. " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": " Whether the checkbox is disabled. " - }, - "onChangeSelection": { - "type": "( checked: boolean ) => void", - "required": false, - "description": " On checkbox change for bulk selection/deselection. @default undefined " - }, - "value": { - "type": "T | undefined", - "required": false, - "description": " value of the row. " - } - }, - "subComponents": { - "Head": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Child components to render within the table head. " - }, - "selected": { - "type": "boolean", - "required": false, - "description": " Whether any of the rows are selected. " - }, - "indeterminate": { - "type": "boolean", - "required": false, - "description": " Whether the checkbox is indeterminate. " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": " Whether the checkbox is disabled. " - }, - "onChangeSelection": { - "type": "( checked: boolean ) => void", - "required": false, - "description": " On checkbox change for bulk selection/deselection. @default undefined " - } - } - }, - "HeadCell": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Content to display in the header cell. " - } - } - }, - "Body": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Child components to render within the table body. " - } - } - }, - "Row": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Child components to render within the table row. " - }, - "value": { - "type": "T | undefined", - "required": false, - "description": " value of the row. " - }, - "selected": { - "type": "boolean", - "required": false, - "description": " Whether the row is selected. " - }, - "onChangeSelection": { - "type": "( checked: boolean, value: T ) => void", - "required": false, - "description": " On checkbox selection change. " - }, - "disabled": { - "type": "boolean", - "required": false, - "description": " Whether the row is disabled. " - } - } - }, - "Cell": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Content to display in the table cell. " - } - } - }, - "Footer": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": " Child components to render within the table footer. " - } - } - } - } - }, - "Tabs": { - "props": { - "activeItem": { - "type": "string | null", - "required": true, - "description": "The active tab value to identify active tab." - }, - "onChange": { - "type": "( {", - "required": false, - "description": "Callback when the active item changes." - }, - "slug": { - "type": "string", - "required": true, - "description": "Unique identifier for the tab panel that is used for the tab." - }, - "text": { - "type": "string", - "required": true, - "description": "Text to display in the tab." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Icon to display in the tab." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names for styling." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the tab." - }, - "badge": { - "type": "ReactNode", - "required": false, - "description": "Badge to display in the tab." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Content to display in the tab panel." - } - }, - "subComponents": { - "Group": { - "props": { - "activeItem": { - "type": "string | null", - "required": false, - "description": "Controls the active tab." - }, - "onChange": { - "type": "( {", - "required": false, - "description": "Callback when the active item changes." - } - } - }, - "Tab": { - "props": { - "slug": { - "type": "string", - "required": true, - "description": "Unique identifier for the tab." - }, - "text": { - "type": "string", - "required": true, - "description": "Text to display in the tab." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Icon to display in the tab." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names for styling." - }, - "disabled": { - "type": "boolean", - "required": false, - "description": "Disables the tab." - }, - "badge": { - "type": "ReactNode", - "required": false, - "description": "Badge to display in the tab." - }, - "activeItem": { - "type": "string | null", - "required": true, - "description": "The active tab value to identify active tab." - }, - "onChange": { - "type": "( {", - "required": false, - "description": "Callback when the active item changes." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Content to display in the tab panel." - } - } - }, - "Panel": { - "props": { - "slug": { - "type": "string", - "required": true, - "description": "Unique identifier for the tab panel that is used for the tab." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "Content to display in the tab panel." - } - } - } - } - }, - "Text": { - "props": { - "as": { - "type": "C", - "required": false, - "description": " The element to render the text as. @default 'p' " - }, - "ref": { - "type": "PolymorphicRef", - "required": false, - "description": "" - }, - "children": { - "type": "ReactNode", - "required": true, - "description": " The content of the text. " - }, - "weight": { - "type": "keyof typeof fontWeightClassNames", - "required": false, - "description": " The font weight of the text. " - }, - "size": { - "type": "keyof typeof fontSizeClassNames", - "required": false, - "description": " The font size of the text. " - }, - "lineHeight": { - "type": "keyof typeof lineHeightClassNames", - "required": false, - "description": " The line height of the text. " - }, - "letterSpacing": { - "type": "keyof typeof letterSpacingClassNames", - "required": false, - "description": " The letter spacing of the text. " - }, - "color": { - "type": "keyof typeof fontColorClassNames", - "required": false, - "description": " The font color of the text. " - }, - "className": { - "type": "string", - "required": false, - "description": " Additional class names to apply " - } - }, - "subComponents": {} - }, - "Title": { - "props": { - "title": { - "type": "string", - "required": false, - "description": "The main title text to render." - }, - "description": { - "type": "string", - "required": false, - "description": "Optional description text to display below the title." - }, - "icon": { - "type": "ReactNode", - "required": false, - "description": "Icon element to display alongside the title." - }, - "iconPosition": { - "type": "'left' | 'right'", - "required": false, - "description": "Determines the position of the icon relative to the title." - }, - "tag": { - "type": "'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'", - "required": false, - "description": "HTML tag to use for the title (e.g., h1, h2, h3)." - }, - "size": { - "type": "'xs' | 'sm' | 'md' | 'lg'", - "required": false, - "description": "Size variant of the title (affects both title and description styles) - xs, sm, md, lg." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional class names to apply to the root element." - } - }, - "subComponents": {} - }, - "Toaster": { - "props": { - "position": { - "type": "'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'", - "required": false, - "description": "Defines the position of the toaster." - }, - "design": { - "type": "'stack' | 'inline'", - "required": false, - "description": "Defines the design of the toast." - }, - "theme": { - "type": "'light' | 'dark'", - "required": false, - "description": "Defines the theme of the toast." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional classes to be added to the toaster." - }, - "autoDismiss": { - "type": "boolean", - "required": false, - "description": "Defines if the toast should be auto dismissed or not." - }, - "dismissAfter": { - "type": "number", - "required": false, - "description": "Time in milliseconds after which the toast will be dismissed." - }, - "toastItem": { - "type": "ToastType", - "required": true, - "description": "The toast item." - }, - "title": { - "type": "string | React.ReactElement", - "required": false, - "description": "The title of the toast." - }, - "content": { - "type": "string | React.ReactElement", - "required": false, - "description": "The content of the toast." - }, - "icon": { - "type": "React.ReactElement", - "required": false, - "description": "The icon for the toast." - }, - "variant": { - "type": "ToastType['type']", - "required": false, - "description": "The variant of the toast." - }, - "removeToast": { - "type": "( id: number ) => void", - "required": false, - "description": "Function to remove the toast." - } - }, - "subComponents": {} - }, - "Tooltip": { - "props": { - "variant": { - "type": "'light' | 'dark'", - "required": false, - "description": "Defines the visual variant of the tooltip." - }, - "title": { - "type": "string", - "required": false, - "description": "The title displayed at the top of the tooltip." - }, - "content": { - "type": "ReactNode", - "required": false, - "description": "The main content to be displayed within the tooltip." - }, - "arrow": { - "type": "boolean", - "required": false, - "description": "Indicates whether to show an arrow pointing to the target element." - }, - "open": { - "type": "boolean", - "required": false, - "description": "Controls the visibility of the tooltip in a controlled manner." - }, - "setOpen": { - "type": "( isOpen: boolean ) => void", - "required": false, - "description": "Function to set the visibility state of the tooltip." - }, - "children": { - "type": "ReactNode", - "required": true, - "description": "The child element to which the tooltip is attached." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional CSS classes to apply to the tooltip for custom styling." - }, - "tooltipPortalRoot": { - "type": "HTMLElement | null", - "required": false, - "description": "The root element where the tooltip will be rendered." - }, - "tooltipPortalId": { - "type": "string", - "required": false, - "description": "The ID of the tooltip portal." - }, - "strategy": { - "type": "'fixed' | 'absolute'", - "required": false, - "description": "Specifies the positioning strategy for the tooltip." - }, - "offset": { - "type": "number", - "required": false, - "description": "Offset distance (in pixels) from the target element to the tooltip." - }, - "triggers": { - "type": "( 'click' | 'hover' | 'focus' )[]", - "required": false, - "description": "Events that trigger the tooltip." - }, - "interactive": { - "type": "boolean", - "required": false, - "description": "Indicates whether the tooltip content is interactive. Keeps the tooltip open while the user interacts with its content." - }, - "boundary": { - "type": "'viewport' | 'clippingAncestors' | HTMLElement | null", - "required": false, - "description": "Defines the boundary for positioning the tooltip, accepting 'viewport', 'clippingAncestors', or an HTML element reference." - } - }, - "subComponents": {} - }, - "Topbar": { - "props": { - "children": { - "type": "ReactNode", - "required": false, - "description": "Children to be rendered inside the Topbar." - }, - "className": { - "type": "string", - "required": false, - "description": "Additional classes to be added to the Topbar." - }, - "gap": { - "type": "'0' | 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'", - "required": false, - "description": "Defines the gap between items." - }, - "align": { - "type": "'left' | 'center' | 'right'", - "required": false, - "description": "Defines how the content inside the Middle section is aligned." - } - }, - "subComponents": { - "Left": { - "props": {} - }, - "Middle": { - "props": { - "align": { - "type": "'left' | 'center' | 'right'", - "required": false, - "description": "Defines how the content inside the Middle section is aligned." - } - } - }, - "Right": { - "props": {} - }, - "Item": { - "props": {} - } - } - } - }, - "metadata": { - "totalComponents": 38, - "extractedAt": "2025-06-05T10:11:20.250Z", - "componentsWithSubcomponents": 15 - } -} \ No newline at end of file From 534c9de46b6a575a072dea0aee353bb7573892a3 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:05:31 +0600 Subject: [PATCH 2/6] Add CLAUDE.md --- .claude/settings.json | 25 +++++++++++++ .gitignore | 1 + CLAUDE.md | 84 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..877d9b9a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm view *)", + "Bash(npx tsc *)", + "Bash(npx eslint *)", + "Bash(npx stylelint *)", + "Bash(npx prettier *)", + "Bash(git status)", + "Bash(grep *)", + "Bash(find * -name *)", + "Bash(ls *)" + ] + }, + "statusLine": { + "type": "command", + "command": "bash \"/Users/jaieds/.claude/plugins/cache/caveman/caveman/63e797cd753b/hooks/caveman-statusline.sh\"" + } +} diff --git a/.gitignore b/.gitignore index d0bf53e9..30bcf05d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.claude/settings.local.json dist/* .DS_Store .cursor diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..71ae79fb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# force-ui + +React component library for BSF projects. Publishes to GitHub Package Registry as `@bsf/force-ui`. + +## Stack + +- **React 18** + **TypeScript** — component authoring +- **Vite** — build (CJS + ESM outputs) +- **Tailwind CSS 3** — styling via `cn()` utility +- **Storybook 10** — component dev + visual regression (Chromatic) +- **Vitest** — unit/interaction tests (Storybook play functions) +- **ESLint + Prettier + Stylelint** — linting + +## Commands + +```bash +npm run build # production build +npm run start # build in watch mode +npm run storybook # dev server at :6006 +npm run test # vitest (storybook project) +npm run test:watch # vitest in watch mode +npm run lint:js-fix # eslint + prettier fix +npm run lint:css-fix # stylelint fix +npx tsc --noEmit # type-check only +``` + +## Component Conventions + +Each component lives at `src/components/{name}/`: +``` +{name}.tsx # component implementation +{name}.stories.tsx # Storybook stories +index.ts # named exports +readme.md # usage docs +``` + +### Authoring pattern + +```tsx +import { forwardRef } from 'react'; +import { cn } from '@/utilities/functions'; + +export interface FooProps { + /** JSDoc for every prop — Storybook renders these as docs */ + variant?: 'primary' | 'secondary'; + className?: string; +} + +const Foo = forwardRef( + ({ variant = 'primary', className, ...props }, ref) => { + return ( +
+ ); + } +); +Foo.displayName = 'Foo'; +export default Foo; +``` + +- Always `forwardRef` + `displayName` +- Export props interface with component +- `cn()` from `@/utilities/functions` for class merging +- Path alias `@/` → `src/` +- Props interface JSDoc required — Storybook autodocs reads them + +## Exports + +New components go in `src/components/index.ts` and `src/index.ts`. + +## Testing + +Tests via Storybook `play` functions using `@storybook/test`. Run: `npm run test`. Add `play` to stories for interaction coverage. + +## Current Focus + +_Update this section with current sprint/task context._ + +## Gotchas + +- `peerDependencies` for `react`/`react-dom` — consumers supply; don't bundle +- Storybook on `:6006`; `test-storybook` needs Storybook running first +- Chromatic upload needs `CHROMATIC_PROJECT_TOKEN` env var +- GitHub Package Registry — `npm publish` needs `NPM_TOKEN` with `write:packages` scope +- Add `.claude/settings.local.json` to local `.gitignore` for personal settings \ No newline at end of file From b7ebf197aea9de3bca5bae7d7c46e80f1654d39c Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:03:47 +0600 Subject: [PATCH 3/6] feat(select): add inlineSearch variant with shadcn-style UX - Add `inlineSearch` prop: renders search input inside the trigger instead of the dropdown. Selected items show as badges (multi) or as the input value (single). Mutually exclusive with `combobox`. - Wire floating-ui combobox pattern: input is the interactive reference, wrapper div is the position reference. `virtual: true` in useListNavigation so items use aria-activedescendant without stealing DOM focus. - UX polish: selected label shown in input on open (no highlight), typing appends and filters, search clears on any close (Escape, outside-click, or selection), Backspace removes last badge in multi mode. - Add InlineSearchSingle, InlineSearchMulti, InlineSearchWithCombobox stories with interaction test coverage. - Update readme.md and select-types.ts for new prop. --- src/components/select/readme.md | 29 + src/components/select/select-atom.stories.tsx | 149 +++ src/components/select/select-types.ts | 4 + src/components/select/select.tsx | 887 ++++++++++++------ 4 files changed, 760 insertions(+), 309 deletions(-) diff --git a/src/components/select/readme.md b/src/components/select/readme.md index 31dcb33e..9031f53c 100644 --- a/src/components/select/readme.md +++ b/src/components/select/readme.md @@ -44,6 +44,35 @@ The `Select` component is a versatile, customizable select component built with - **Default:** `false` - **Description:** If true, it will show a search box. +### `inlineSearch` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** If true, renders the search input inside the trigger itself instead of inside the dropdown. Selected items render as badges (multi) or as the input value (single). Mutually exclusive with `combobox`; when both are passed, `inlineSearch` wins. + +#### Inline search — single +```jsx + +``` + +#### Inline search — multi +```jsx + +``` + ### `size` - **Type:** `string` - **Default:** `"md"` diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index bad1c166..225d3ebe 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -344,6 +344,155 @@ SelectWithSearchWithoutPortal.args = { disabled: false, }; +export const InlineSearchWithCombobox: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchWithCombobox.args = { + size: 'md', + disabled: false, +}; +InlineSearchWithCombobox.parameters = { + docs: { + description: { + story: 'When both `combobox` and `inlineSearch` are passed, `inlineSearch` wins — the search input appears inside the trigger, not the dropdown.', + }, + }, +}; + +export const InlineSearchSingle: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchSingle.args = { + size: 'md', + disabled: false, +}; + +export const InlineSearchMulti: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchMulti.args = { + size: 'md', + disabled: false, +}; + +InlineSearchMulti.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + + // Open dropdown by clicking the trigger wrapper + const triggerWrapper = await canvas.findByRole( 'combobox' ); + await userEvent.click( triggerWrapper ); + + // Type a query — 'r' matches Red and Orange + const input = await canvas.findByPlaceholderText( 'Search colors...' ); + await userEvent.type( input, 'r' ); + + const listbox = await screen.findByRole( 'listbox' ); + expect( listbox ).toHaveTextContent( 'Red' ); + expect( listbox ).toHaveTextContent( 'Orange' ); + expect( listbox ).not.toHaveTextContent( 'Cyan' ); + + // Clear and select two options + await userEvent.clear( input ); + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); // Red + + // Re-open and select Orange + await userEvent.click( triggerWrapper ); + const allOptions2 = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions2[ 1 ] ); // Orange + + // Two badges should be visible inside trigger + const redBadge = await canvas.findByText( 'Red' ); + const orangeBadge = await canvas.findByText( 'Orange' ); + expect( redBadge ).toBeTruthy(); + expect( orangeBadge ).toBeTruthy(); + + // Backspace on empty input removes last badge (Orange) + await userEvent.click( input ); + await userEvent.keyboard( '{Backspace}' ); + expect( canvas.queryByText( 'Orange' ) ).toBeNull(); + expect( canvas.queryByText( 'Red' ) ).not.toBeNull(); +}; + const GroupedSelectTemplate: Story = ( { size, multiple, diff --git a/src/components/select/select-types.ts b/src/components/select/select-types.ts index 4624eec9..a8b359fa 100644 --- a/src/components/select/select-types.ts +++ b/src/components/select/select-types.ts @@ -57,6 +57,8 @@ export type SelectProps = { children?: ReactNode; /** Combobox mode. */ combobox?: boolean; + /** Inline search mode — renders the search input inside the trigger instead of the dropdown. Mutually exclusive with `combobox`; `inlineSearch` wins if both are passed. Default `false`. */ + inlineSearch?: boolean; /** Disables the Select Component. */ disabled?: boolean; /** Multi select mode. */ @@ -147,6 +149,8 @@ export type SelectContextValue = { setSelected: ( selected: SelectOptionValue | SelectOptionValue[] ) => void; handleSelect: OnClick; combobox: boolean; + inlineSearch: boolean; + optionValuesRef: React.MutableRefObject; sizeValue: SelectSizes; multiple: boolean; isTypingRef: React.MutableRefObject; diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index a700e9bb..5ef7e9e8 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -64,228 +64,425 @@ const SelectContext = createContext( ); const useSelectContext = () => useContext( SelectContext ); -export const SelectButton = forwardRef( ( { - children, - icon = null, // Icon to show in the select button. - placeholder = 'Select an option', // Placeholder text. - optionIcon = null, // Icon to show in the selected option. - render, - label, // Label for the select component. - className, - ...props -}: SelectButtonProps, ref ) => { - const { - sizeValue, - getReferenceProps, - getValues, - selectId, - refs, - isOpen, - multiple, - combobox, - setSelected, - onChange, - isControlled, - disabled, - by, - } = useSelectContext(); +export const SelectButton = forwardRef( + ( + { + children, + icon = null, // Icon to show in the select button. + placeholder = 'Select an option', // Placeholder text. + optionIcon = null, // Icon to show in the selected option. + render, + label, // Label for the select component. + className, + ...props + }: SelectButtonProps, + ref + ) => { + const { + sizeValue, + getReferenceProps, + getValues, + selectId, + refs, + isOpen, + multiple, + combobox, + inlineSearch, + setSelected, + onChange, + isControlled, + disabled, + by, + searchKeyword, + setSearchKeyword, + searchPlaceholder, + context, + activeIndex, + optionValuesRef, + handleSelect, + } = useSelectContext(); + + const badgeSize = { + sm: 'xs', + md: 'sm', + lg: 'md', + }?.[ sizeValue as SelectSizes ]; + + const inputRef = useRef( null ); + const [ hasTyped, setHasTyped ] = useState( false ); + useEffect( () => { + if ( ! isOpen ) { + setHasTyped( false ); + } + }, [ isOpen ] ); - const badgeSize = { - sm: 'xs', - md: 'sm', - lg: 'md', - }?.[ sizeValue as SelectSizes ]; + // For inlineSearch single mode: derive string label of the selected value for input display. + const singleLabel = useMemo( () => { + if ( ! inlineSearch || multiple ) { + return ''; + } + const val = getValues(); + if ( ! val ) { + return ''; + } + if ( typeof render === 'function' ) { + const rendered = render( val as SelectOptionValue ); + if ( typeof rendered === 'string' ) { + return rendered; + } + } + if ( typeof val === 'string' || typeof val === 'number' ) { + return String( val ); + } + const nameKey = ( val as Record ).name; + return typeof nameKey === 'string' ? nameKey : ''; + }, [ inlineSearch, multiple, getValues, render ] ); + + // Get icon based on the Select component type and user provided icon. + const getIcon = useCallback( () => { + if ( icon ) { + return icon; + } - // Get icon based on the Select component type and user provided icon. - const getIcon = useCallback( () => { - if ( icon ) { - return icon; - } + const iconClassNames = + 'text-field-placeholder ' + disabledClassNames.icon; - const iconClassNames = - 'text-field-placeholder ' + disabledClassNames.icon; + return combobox ? ( + + ) : ( + + ); + }, [ icon ] ); - return combobox ? ( - - ) : ( - - ); - }, [ icon ] ); + const renderSelected = useCallback( () => { + const selectedValue = getValues(); - const renderSelected = useCallback( () => { - const selectedValue = getValues(); + if ( ! selectedValue ) { + return null; + } - if ( ! selectedValue ) { - return null; - } + if ( multiple ) { + return ( selectedValue as SelectOptionValue[] ).map( + ( valueItem: SelectOptionValue, index: number ) => ( + + ) + ); + } - if ( multiple ) { - return ( selectedValue as SelectOptionValue[] ).map( - ( valueItem: SelectOptionValue, index: number ) => ( - - ) - ); - } + : {} ), + }; + renderValue = children( childProps ); + } - let renderValue: ReactNode = - typeof selectedValue === 'string' ? selectedValue : ''; + if ( + ( isValidElement( children ) || typeof children === 'string' ) && + typeof render !== 'function' + ) { + renderValue = children; + } - if ( typeof render === 'function' ) { - renderValue = render( selectedValue as SelectOptionValue ); - } + return ( + + { renderValue as React.ReactNode } + + ); + }, [ getValues, disabled ] ); + + const handleOnCloseItem = + ( value: SelectOptionValue ) => + ( event?: React.MouseEvent ) => { + event?.preventDefault(); + event?.stopPropagation(); + + const selectedValues = [ + ...( ( getValues() as SelectOptionValue[] ) ?? [] ), + ]; + const selectedIndex = selectedValues.findIndex( ( val ) => { + if ( + val !== null && + value !== null && + typeof val === 'object' + ) { + return ( + ( val as Record )[ by ] === + ( value as Record )[ by ] + ); + } + return val === value; + } ); - if ( typeof children === 'function' && typeof render !== 'function' ) { - const childProps = { - value: selectedValue as SelectOptionValue, - ...( multiple - ? { - onClose: handleOnCloseItem( - selectedValue as SelectOptionValue - ), + if ( selectedIndex === -1 ) { + return; } - : {} ), - }; - renderValue = children( childProps ); - } - if ( - ( isValidElement( children ) || typeof children === 'string' ) && - typeof render !== 'function' - ) { - renderValue = children; - } + selectedValues.splice( selectedIndex, 1 ); - return ( - - { renderValue as React.ReactNode } - - ); - }, [ getValues, disabled ] ); - - const handleOnCloseItem = - ( value: SelectOptionValue ) => - ( event?: React.MouseEvent ) => { - event?.preventDefault(); - event?.stopPropagation(); - - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; - const selectedIndex = selectedValues.findIndex( ( val ) => { - if ( val !== null && value !== null && typeof val === 'object' ) { - return ( - ( val as Record )[ by ] === - ( value as Record )[ by ] - ); + if ( ! isControlled ) { + setSelected( selectedValues ); } - return val === value; - } ); - - if ( selectedIndex === -1 ) { - return; - } + if ( typeof onChange === 'function' ) { + onChange( selectedValues ); + } + }; - selectedValues.splice( selectedIndex, 1 ); + if ( inlineSearch ) { + let inputValue = ''; + if ( ! multiple && getValues() ) { + // Single with value: show label until user types, then show query. + inputValue = isOpen && hasTyped ? searchKeyword : singleLabel; + } else if ( isOpen ) { + inputValue = searchKeyword; + } - if ( ! isControlled ) { - setSelected( selectedValues ); - } - if ( typeof onChange === 'function' ) { - onChange( selectedValues ); - } - }; + const showPlaceholder = multiple + ? ! ( getValues() as SelectOptionValue[] )?.length + : ! getValues() && ! searchKeyword; - return ( -
- { !! label && ( - - ) } -
- -
- ); -} ); + { /* Input and selected item container */ } +
+ { /* Show Selected item/items (Multi-selector) */ } + { renderSelected() } + + { /* Placeholder */ } + { ( multiple + ? ! ( getValues() as SelectOptionValue[] )?.length + : ! getValues() ) && ( +
+ { placeholder } +
+ ) } +
+ { /* Suffix Icon */ } +
svg]:shrink-0', + sizeClassNames[ sizeValue as SelectSizes ].icon + ) } + > + { getIcon() } +
+ + + ); + } +); export function SelectOptionGroup( { label, @@ -350,6 +547,7 @@ export function SelectOptions( { context, refs, combobox, + inlineSearch, floatingStyles, getFloatingProps, sizeValue, @@ -366,6 +564,8 @@ export function SelectOptions( { activeIndex, searchFn, debounceDelay, + selectId, + optionValuesRef, } = useSelectContext(); const initialSelectedValueIndex = useMemo( () => { @@ -471,7 +671,8 @@ export function SelectOptions( { } ); // Show group if either group label matches or any child matches - hasVisibleChildren = groupLabelMatches || hasMatchingChildren; + hasVisibleChildren = + groupLabelMatches || hasMatchingChildren; } else { // No search term, show all groups hasVisibleChildren = true; @@ -486,6 +687,7 @@ export function SelectOptions( { totalGroups = Math.max( 0, visibleGroups - 1 ); // Subtract 1 since we don't need divider after last group let childIndex = 0; let groupIndex = 0; + optionValuesRef.current = []; // Process child to render const processChild = ( child: React.ReactNode ): React.ReactNode => { @@ -514,9 +716,14 @@ export function SelectOptions( { // If group label matches, show all children regardless of their content if ( groupLabelMatches ) { + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; const childProps = { ...( groupChild.props as SelectOptionProps ), - index: childIndex++, + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, }; return cloneElement( groupChild, childProps ); @@ -525,7 +732,11 @@ export function SelectOptions( { // Otherwise, apply normal filtering to individual options if ( searchKeyword && ! searchFn ) { const textContent = getTextContent( - ( groupChild.props as { children?: React.ReactNode } ).children + ( + groupChild.props as { + children?: React.ReactNode; + } + ).children )?.toLowerCase(); const searchTerm = searchKeyword.toLowerCase(); @@ -536,9 +747,14 @@ export function SelectOptions( { } } + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; const childProps = { ...( groupChild.props as SelectOptionProps ), - index: childIndex++, + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, }; return cloneElement( groupChild, childProps ); @@ -546,7 +762,9 @@ export function SelectOptions( { ); // Only render group if it has visible children - const hasChildren = groupChildren?.some( ( c: React.ReactNode ) => c !== null ); + const hasChildren = groupChildren?.some( + ( c: React.ReactNode ) => c !== null + ); if ( ! hasChildren ) { return null; @@ -577,14 +795,25 @@ export function SelectOptions( { } } + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = child.props.value; return cloneElement( child, { ...child.props, - index: childIndex++, + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, } ); }; return Children.map( children, processChild ); - }, [ searchKeyword, value, selected, children, searchFn ] ); + }, [ + searchKeyword, + value, + selected, + children, + searchFn, + selectId, + optionValuesRef, + ] ); const childrenCount = Children.count( renderChildren ); // Update the content list reference. @@ -657,110 +886,111 @@ export function SelectOptions( { initiateSearch(); }, [ initiateSearch ] ); + const dropdownContent = ( +
+ { /* Searchbox — combobox only; inlineSearch uses trigger input */ } + { combobox && ! inlineSearch && ( +
+ { searching ? ( + + ) : ( + + ) } + + setSearchKeyword( event.target.value ) + } + value={ searchKeyword } + autoComplete="off" + /> +
+ ) } + { /* Dropdown Items Wrapper */ } +
+ { /* Dropdown Items */ } + { !! childrenCount && renderChildren } + + { /* No items found */ } + { ! childrenCount && ( +
+ No items found +
+ ) } +
+
+ ); + return ( <> { /* Dropdown */ } { isOpen && ( <> - - { /* Dropdown Wrapper */ } -
- { /* Searchbox */ } - { combobox && ( -
- { searching ? ( - - ) : ( - - ) } - - setSearchKeyword( event.target.value ) - } - value={ searchKeyword } - autoComplete="off" - /> -
- ) } - { /* Dropdown Items Wrapper */ } -
- { /* Dropdown Items */ } - { !! childrenCount && renderChildren } - - { /* No items found */ } - { ! childrenCount && ( -
- No items found -
- ) } -
-
-
+ { dropdownContent } + + ) } ) } @@ -793,8 +1023,13 @@ export function SelectItem( { getValues, by, multiple, + inlineSearch, } = useSelectContext(); - const { index: indx } = props; + const { index: indx, id: optionId } = props as { + index: number; + id?: string; + [key: string]: unknown; + }; const initialIndxRef = useRef( indx ); const selectedIconClassName = { @@ -834,8 +1069,14 @@ export function SelectItem( { return indx === selectedIndex; }, [ multipleChecked, selectedIndex, selected ] ); + let itemTabIndex: number | undefined; + if ( ! inlineSearch ) { + itemTabIndex = indx === activeIndex ? 0 : -1; + } + return (
{ const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); + + if ( process.env.NODE_ENV !== 'production' && combobox && inlineSearch ) { + // eslint-disable-next-line no-console + console.warn( + 'force-ui Select: `inlineSearch` and `combobox` are mutually exclusive. `inlineSearch` will take precedence.' + ); + } const [ selected, setSelected ] = useState< SelectOptionValue | SelectOptionValue[] >( defaultValue! ); @@ -911,9 +1160,9 @@ const SelectComponent = ( { const [ selectedIndex, setSelectedIndex ] = useState( null ); const dropdownMaxHeightBySize = { - sm: combobox ? 256 : 172, - md: combobox ? 256 : 216, - lg: combobox ? 256 : 216, + sm: combobox && ! inlineSearch ? 256 : 172, + md: combobox && ! inlineSearch ? 256 : 216, + lg: combobox && ! inlineSearch ? 256 : 216, }; const { refs, floatingStyles, context } = useFloating( { @@ -940,8 +1189,19 @@ const SelectComponent = ( { const listRef = useRef>( [] ); const listContentRef = useRef( [] ); const isTypingRef = useRef( false ); + const optionValuesRef = useRef( [] ); - const click = useClick( context, { event: 'mousedown' } ); + // Clear search when dropdown closes (Escape, outside-click, or selection). + useEffect( () => { + if ( ! isOpen ) { + setSearchKeyword( '' ); + } + }, [ isOpen ] ); + + const click = useClick( context, { + event: 'mousedown', + enabled: ! inlineSearch, + } ); const dismiss = useDismiss( context ); const role = useRole( context, { role: 'listbox' } ); const listNav = useListNavigation( context, { @@ -949,8 +1209,9 @@ const SelectComponent = ( { activeIndex, selectedIndex, onNavigate: setActiveIndex, - // This is a large list, allow looping. loop: true, + // virtual: input is the reference, items use aria-activedescendant rather than DOM focus. + virtual: inlineSearch, } ); const typeahead = useTypeahead( context, { listRef: listContentRef, @@ -968,7 +1229,7 @@ const SelectComponent = ( { role, listNav, click, - ...( ! combobox ? [ typeahead ] : [] ), + ...( ! combobox && ! inlineSearch ? [ typeahead ] : [] ), ] ); const handleMultiSelect: OnClick = ( index, newValue ) => { @@ -998,7 +1259,10 @@ const SelectComponent = ( { setSelected( selectedValues ); } setSelectedIndex( index ); - ( refs.reference.current as HTMLElement ).focus(); + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); setIsOpen( false ); setSearchKeyword( '' ); if ( typeof onChange === 'function' ) { @@ -1014,7 +1278,10 @@ const SelectComponent = ( { if ( ! isControlled ) { setSelected( newValue ); } - ( refs.reference.current as HTMLElement ).focus(); + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); setIsOpen( false ); setSearchKeyword( '' ); if ( typeof onChange === 'function' ) { @@ -1054,6 +1321,8 @@ const SelectComponent = ( { setSelected, handleSelect, combobox, + inlineSearch, + optionValuesRef, sizeValue, multiple, onChange, From 92a322ae1f8f70e4535891996c37b59d9ad8787c Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:05:46 +0600 Subject: [PATCH 4/6] Add claude skills --- .claude/skills/accessibility | 1 + .claude/skills/frontend-design | 1 + .claude/skills/playwright-best-practices | 1 + .claude/skills/seo | 1 + .claude/skills/tailwind-css-patterns | 1 + .claude/skills/typescript-advanced-types | 1 + .claude/skills/vite | 1 + .claude/skills/vitest | 1 + .gitignore | 3 ++ skills-lock.json | 50 ++++++++++++++++++++++++ 10 files changed, 61 insertions(+) create mode 120000 .claude/skills/accessibility create mode 120000 .claude/skills/frontend-design create mode 120000 .claude/skills/playwright-best-practices create mode 120000 .claude/skills/seo create mode 120000 .claude/skills/tailwind-css-patterns create mode 120000 .claude/skills/typescript-advanced-types create mode 120000 .claude/skills/vite create mode 120000 .claude/skills/vitest create mode 100644 skills-lock.json diff --git a/.claude/skills/accessibility b/.claude/skills/accessibility new file mode 120000 index 00000000..33d9d92e --- /dev/null +++ b/.claude/skills/accessibility @@ -0,0 +1 @@ +../../.agents/skills/accessibility \ No newline at end of file diff --git a/.claude/skills/frontend-design b/.claude/skills/frontend-design new file mode 120000 index 00000000..712f694a --- /dev/null +++ b/.claude/skills/frontend-design @@ -0,0 +1 @@ +../../.agents/skills/frontend-design \ No newline at end of file diff --git a/.claude/skills/playwright-best-practices b/.claude/skills/playwright-best-practices new file mode 120000 index 00000000..e0bbc333 --- /dev/null +++ b/.claude/skills/playwright-best-practices @@ -0,0 +1 @@ +../../.agents/skills/playwright-best-practices \ No newline at end of file diff --git a/.claude/skills/seo b/.claude/skills/seo new file mode 120000 index 00000000..946efb74 --- /dev/null +++ b/.claude/skills/seo @@ -0,0 +1 @@ +../../.agents/skills/seo \ No newline at end of file diff --git a/.claude/skills/tailwind-css-patterns b/.claude/skills/tailwind-css-patterns new file mode 120000 index 00000000..03dab7dd --- /dev/null +++ b/.claude/skills/tailwind-css-patterns @@ -0,0 +1 @@ +../../.agents/skills/tailwind-css-patterns \ No newline at end of file diff --git a/.claude/skills/typescript-advanced-types b/.claude/skills/typescript-advanced-types new file mode 120000 index 00000000..4d2e5230 --- /dev/null +++ b/.claude/skills/typescript-advanced-types @@ -0,0 +1 @@ +../../.agents/skills/typescript-advanced-types \ No newline at end of file diff --git a/.claude/skills/vite b/.claude/skills/vite new file mode 120000 index 00000000..1216e951 --- /dev/null +++ b/.claude/skills/vite @@ -0,0 +1 @@ +../../.agents/skills/vite \ No newline at end of file diff --git a/.claude/skills/vitest b/.claude/skills/vitest new file mode 120000 index 00000000..76615364 --- /dev/null +++ b/.claude/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30bcf05d..bee12538 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ tsconfig.node.tsbuildinfo *.app.tsbuildinfo *.node.tsbuildinfo tsconfig.app.tsbuildinfo +.agents/skills +.kiro +.continue \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..971f2e3f --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "skills": { + "accessibility": { + "source": "addyosmani/web-quality-skills", + "sourceType": "autoskills-registry", + "computedHash": "bffe3d08cfe92ebad63699f74ce29e35c19850ebfbf474c1463183cfe34d6a09" + }, + "accessibility-compliance": { + "source": "wshobson/agents", + "sourceType": "github", + "computedHash": "36d3b046163c42251732b3127c042f6b8eb7d91fcbf7ef9ae79fde79e9c99369" + }, + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "autoskills-registry", + "computedHash": "82fb11a63fb1e35ee2469516ed02d54695f783115b1540c0e783197af4240a3a" + }, + "playwright-best-practices": { + "source": "currents-dev/playwright-best-practices-skill", + "sourceType": "autoskills-registry", + "computedHash": "dcc414410b81cfcc5c56490a7e450e664b11d170f3c336fe475c6e0a47def574" + }, + "seo": { + "source": "addyosmani/web-quality-skills", + "sourceType": "autoskills-registry", + "computedHash": "c184da724d1c61ad077f27418ea8e7e88fd54bcdf98165e18be7e4681cbd5e20" + }, + "tailwind-css-patterns": { + "source": "giuseppe-trisciuoglio/developer-kit", + "sourceType": "autoskills-registry", + "computedHash": "8fd534b64b3f305ade80c207e2f9ba4338d5e409e355af9f724c36764b6709c1" + }, + "typescript-advanced-types": { + "source": "wshobson/agents", + "sourceType": "autoskills-registry", + "computedHash": "5ca0e177c6aaaba1889255691224daafdb7d71f317cc70bede1590d3907ded42" + }, + "vite": { + "source": "antfu/skills", + "sourceType": "autoskills-registry", + "computedHash": "e0b52fe3ffea533e1e543ac8a8a20fccca3beed9f8bdc31a0934fe9f554439e8" + }, + "vitest": { + "source": "antfu/skills", + "sourceType": "autoskills-registry", + "computedHash": "be7329a28a492b802061206c2d75603ab294f25f541bc5421d5de8d41abe68b1" + } + } +} From c03b80a911b2dc30973fa42d72d41000cb629612 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:55:32 +0600 Subject: [PATCH 5/6] Add a few more test cases to the select component --- src/components/select/select-atom.stories.tsx | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index 225d3ebe..abd6959f 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -71,6 +71,7 @@ const SelectWithoutPortalTemplate: Story = ( { multiple, combobox, disabled, + ...args } ) => (
v} searchPlaceholder="Search colors..."> - s.name} /> - - - {options.map((o) => {o.name})} - - - -``` - -#### Inline search — multi -```jsx - -``` - -### `size` -- **Type:** `string` -- **Default:** `"md"` -- **Description:** Defines the style variant of the button. Options include: - - `"sm"` - - `"md"` - - `"lg"` - -### `disabled` -- **Type:** `boolean` -- **Default:** `false` -- **Description:** If true, the select will be disabled. -- **Default:** `false` - - -## `Select.Button` Props - -### `icon` -- **Type:** `ReactNode` -- **Default:** `null` -- **Description:** Icon to show in the select button. - -### `placeholder` -- **Type:** `string` -- **Default:** `"Select an option"` -- **Description:** Placeholder text when there's no option selected. - -### `optionIcon` -- **Type:** `ReactNode` -- **Default:** `null` -- **Description:** Icon to show in the selected option (for multi-select only). - -### `displayBy` -- **Type:** `string` -- **Default:** `"name"` -- **Description:** Key that will be used to display the value when selected value is an object. Default is `"name"`. - -### `label` -- **Type:** `string` -- **Default:** `undefined` -- **Description:** Label for the select component. - -## `Select.Portal` Props - -> **Note:** This component is used to render the dropdown outside the parent container. `Select.Portal` By default, renders content in the `document.body`. You can use the following props to render the dropdown in a specific container. Use this to wrap the `Select.Options` component to render the dropdown in a specific container when overflow is hidden in the parent container causing the dropdown to be hidden. - -### `root` -- **Type:** `HTMLElement | null` -- **Default:** `null` -- **Description:** Root element where the dropdown will be rendered. It's helpful when the dropdown is rendered outside the parent container and scopped Tailwind CSS styles. - -### `id` -- **Type:** `string` -- **Default:** `""` -- **Description:** Id of the dropdown portal where the dropdown will be rendered. It's helpful when the dropdown is rendered outside the parent container and scopped Tailwind CSS styles. - - -## `Select.Options` Props - -### `searchBy` -- **Type:** `string` -- **Default:** `"id"` -- **Description:** The key that will be used to identify searched value using the key. Default is `"id"`. - -### `searchPlaceholder` -- **Type:** `string` -- **Default:** `"Search..."` -- **Description:** Placeholder text for search box. -- **Default:** `"Search..."` - - -## `Select.Option` Props - -### `value` -- **Type:** `string | number | object` -- **Default:** `undefined` -- **Description:** Value of the option. - -### `selected` -- **Type:** `boolean | undefined` -- **Default:** `undefined` -- **Description:** If true, the option will be selected. - -### Basic Example - -```jsx -import Select from './Select'; - -const options = [ - 'Red', - 'Orange', - 'Yellow', - 'Green', - 'Cyan', - 'Blue', - 'Purple', - 'Pink', -]; - -const App = () => ( -
- - - - -
-); - -export default App; +# Select Component Documentation + +## Description + +The `Select` component is a versatile, customizable select component built with Tailwind CSS. It supports various sizes, types, and additional properties to suit different use cases. + +## `Select` Props + +### `id` +- **Type:** `string` +- **Default:** `undefined` +- **Description:** The `id` attribute of the select element. + +### `value` +- **Type:** `string | number | object | undefined` +- **Default:** `undefined` +- **Description:** Value of the select (for controlled component). + +### `defaultValue` +- **Type:** `string | number | object | undefined` +- **Default:** `undefined` +- **Description:** Default value of the select (for uncontrolled component). + +### `onChange` +- **Type:** `function` +- **Default:** `undefined` +- **Description:** Callback function to handle the change event. +- **Signature:** `function(value: string | number | object): void` +- **Parameters:** + - `value`: The selected value. + +### `by` +- **Type:** `string` +- **Default:** `"id"` +- **Description:** Used to identify the selected value when value type is an `object`. Default is `"id"`. + +### `multiple` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** If true, it will allow multiple selection. + +### `combobox` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** If true, it will show a search box. + +### `inlineSearch` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** If true, renders the search input inside the trigger itself instead of inside the dropdown. Selected items render as badges (multi) or as the input value (single). Mutually exclusive with `combobox`; when both are passed, `inlineSearch` wins. + +#### Inline search — single +```jsx + +``` + +#### Inline search — multi +```jsx + +``` + +### `size` +- **Type:** `string` +- **Default:** `"md"` +- **Description:** Defines the style variant of the button. Options include: + - `"sm"` + - `"md"` + - `"lg"` + +### `disabled` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** If true, the select will be disabled. +- **Default:** `false` + + +## `Select.Button` Props + +### `icon` +- **Type:** `ReactNode` +- **Default:** `null` +- **Description:** Icon to show in the select button. + +### `placeholder` +- **Type:** `string` +- **Default:** `"Select an option"` +- **Description:** Placeholder text when there's no option selected. + +### `optionIcon` +- **Type:** `ReactNode` +- **Default:** `null` +- **Description:** Icon to show in the selected option (for multi-select only). + +### `displayBy` +- **Type:** `string` +- **Default:** `"name"` +- **Description:** Key that will be used to display the value when selected value is an object. Default is `"name"`. + +### `label` +- **Type:** `string` +- **Default:** `undefined` +- **Description:** Label for the select component. + +## `Select.Portal` Props + +> **Note:** This component is used to render the dropdown outside the parent container. `Select.Portal` By default, renders content in the `document.body`. You can use the following props to render the dropdown in a specific container. Use this to wrap the `Select.Options` component to render the dropdown in a specific container when overflow is hidden in the parent container causing the dropdown to be hidden. + +### `root` +- **Type:** `HTMLElement | null` +- **Default:** `null` +- **Description:** Root element where the dropdown will be rendered. It's helpful when the dropdown is rendered outside the parent container and scopped Tailwind CSS styles. + +### `id` +- **Type:** `string` +- **Default:** `""` +- **Description:** Id of the dropdown portal where the dropdown will be rendered. It's helpful when the dropdown is rendered outside the parent container and scopped Tailwind CSS styles. + + +## `Select.Options` Props + +### `searchBy` +- **Type:** `string` +- **Default:** `"id"` +- **Description:** The key that will be used to identify searched value using the key. Default is `"id"`. + +### `searchPlaceholder` +- **Type:** `string` +- **Default:** `"Search..."` +- **Description:** Placeholder text for search box. +- **Default:** `"Search..."` + + +## `Select.Option` Props + +### `value` +- **Type:** `string | number | object` +- **Default:** `undefined` +- **Description:** Value of the option. + +### `selected` +- **Type:** `boolean | undefined` +- **Default:** `undefined` +- **Description:** If true, the option will be selected. + +### Basic Example + +```jsx +import Select from './Select'; + +const options = [ + 'Red', + 'Orange', + 'Yellow', + 'Green', + 'Cyan', + 'Blue', + 'Purple', + 'Pink', +]; + +const App = () => ( +
+ + + + +
+); + +export default App; ``` \ No newline at end of file diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index abd6959f..5220ed97 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -1,637 +1,637 @@ -import type { Meta, StoryFn } from '@storybook/react-vite'; -import Select from './select'; -import { expect, userEvent, within, screen } from 'storybook/test'; -import { SelectOptionValue } from './select-types'; -import { useState } from 'react'; - -const options = [ - { id: '1', name: 'Red' }, - { id: '2', name: 'Orange' }, - { id: '3', name: 'Yellow' }, - { id: '4', name: 'Green' }, - { id: '5', name: 'Cyan' }, - { id: '6', name: 'Blue' }, - { id: '7', name: 'Purple' }, - { id: '8', name: 'Pink' }, -]; - -const groupedOptions = [ - { - label: 'Warm Colors', - options: [ - { id: '1', name: 'Red' }, - { id: '2', name: 'Orange' }, - { id: '3', name: 'Yellow' }, - ], - }, - { - label: 'Cool Colors', - options: [ - { id: '4', name: 'Green' }, - { id: '5', name: 'Cyan' }, - { id: '6', name: 'Blue' }, - ], - }, - { - label: 'Other Colors', - options: [ - { id: '7', name: 'Purple' }, - { id: '8', name: 'Pink' }, - ], - }, -]; - -const meta: Meta = { - title: 'Atoms/Select', - component: Select, - subcomponents: { - 'Select.Button': Select.Button, - 'Select.Portal': Select.Portal, - 'Select.OptionGroup': Select.OptionGroup, - 'Select.Options': Select.Options, - 'Select.Option': Select.Option, - } as Record>, - parameters: { - layout: 'centered', - }, - tags: [ 'atoms', 'autodocs' ], - argTypes: { - size: { control: 'select' }, - children: { control: false }, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryFn; - -// Single Select without portal -const SelectWithoutPortalTemplate: Story = ( { - size, - multiple, - combobox, - disabled, - ...args -} ) => ( -
- -
-); - -export const SingleSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); -SingleSelectWithoutPortal.parameters = { - docs: { - description: { - story: `If you want to use the Select component inside a **Dialog**, **Drawer** or **Popover**, you can omit using the \`Select.Portal\` component. Using the portal is not required in this case and it might cause issues like the \`z-index\` problem and the dropdown menu not being visible. - -If you really need to use the portal and if you face \`z-index\` issues, in that case you can update the \`z-index\` of the Select dropdown to a higher value. - -#### When to use the \`Select.Portal\` component? - -Portal helps to render a floating element into a given container element. By default, outside of the app root and into the body. This is necessary to ensure the floating element can appear outside any potential parent containers that cause clipping (such as overflow: hidden), while retaining its location in the React tree. - -- When the dropdown is being cut off by parent elements. Ex. Parent container has \`overflow: hidden\` property. -- When you need to render the dropdown menu into a different part of the DOM except the parent container. -`, - }, - }, -}; -SingleSelectWithoutPortal.args = { - size: 'md', - multiple: false, - combobox: false, - disabled: false, -}; - -// Single Select Story -export const SingleSelect: Story = ( { size, multiple, combobox, disabled } ) => { - const [ selected, setSelected ] = useState( null ); - return ( -
- -
- ); -}; - -SingleSelect.args = { - size: 'md', - multiple: false, - combobox: false, - disabled: false, -}; -SingleSelect.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - // Click on the select button - const selectButton = await canvas.findByRole( 'combobox' ); - await userEvent.click( selectButton ); - - // Check if the listbox contains the option 'Red' - const listBox = await screen.findByRole( 'listbox' ); - expect( listBox ).toHaveTextContent( 'Red' ); - - // Click on the first option - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - // Check if the button text is updated - expect( selectButton ).toHaveTextContent( 'Red' ); -}; - -// Multi-select Story -export const MultiSelect: Story = ( { size, multiple, combobox, disabled } ) => { - const [ selected, setSelected ] = useState( [] ); - return ( -
- -
- ); -}; - -MultiSelect.args = { - size: 'md', - multiple: true, - combobox: false, - disabled: false, -}; - -MultiSelect.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - // Click on the select button - const selectButton = await canvas.findByRole( 'combobox' ); - await userEvent.click( selectButton ); - - // Check if the listbox contains the option 'Red' - const listBox = await screen.findByRole( 'listbox' ); - expect( listBox ).toHaveTextContent( 'Red' ); - - // Click on the first option - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - // Check if the listbox contains the option 'Orange' - await userEvent.click( selectButton ); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); - - // Check if the button text is updated - expect( selectButton ).toHaveTextContent( /Red.*Orange/ ); -}; - -export const MultiSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); -MultiSelectWithoutPortal.args = { - size: 'md', - multiple: true, - combobox: false, - disabled: false, -}; - -export const SelectWithSearch: Story = ( { - size, - multiple, - combobox, - disabled, -} ) => ( -
- -
-); - -SelectWithSearch.args = { - size: 'md', - multiple: false, - combobox: true, - disabled: false, -}; - -SelectWithSearch.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - // Click on the select button - const selectButton = await canvas.findByRole( 'combobox' ); - await userEvent.click( selectButton ); - - // Check if the listbox contains the option 'Red' and search input - const listBox = await screen.findByRole( 'listbox' ); - const searchInput = await screen.findByPlaceholderText( 'Search...' ); - expect( listBox ).toContainElement( searchInput ); - expect( listBox ).toHaveTextContent( 'Red' ); - - // Type 'Pink' in the search input - await userEvent.type( searchInput, 'Pink' ); - expect( listBox ).toHaveTextContent( 'Pink' ); - - // Click on the first option - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - // Check if the button text is updated - expect( selectButton ).toHaveTextContent( 'Pink' ); -}; - -export const SelectWithSearchWithoutPortal = SelectWithoutPortalTemplate.bind( - {} -); -SelectWithSearchWithoutPortal.args = { - size: 'md', - multiple: false, - combobox: true, - disabled: false, -}; - -export const InlineSearchWithCombobox: Story = ( { size, disabled } ) => ( -
- -
-); -InlineSearchWithCombobox.args = { - size: 'md', - disabled: false, -}; -InlineSearchWithCombobox.parameters = { - docs: { - description: { - story: 'When both `combobox` and `inlineSearch` are passed, `inlineSearch` wins — the search input appears inside the trigger, not the dropdown.', - }, - }, -}; -InlineSearchWithCombobox.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - const trigger = await canvas.findByRole( 'combobox' ); - await userEvent.click( trigger ); - - const input = await canvas.findByPlaceholderText( 'Search colors...' ); - const listbox = await screen.findByRole( 'listbox' ); - expect( listbox ).not.toContainElement( input ); -}; - -export const InlineSearchSingle: Story = ( { size, disabled } ) => ( -
- -
-); -InlineSearchSingle.args = { - size: 'md', - disabled: false, -}; -InlineSearchSingle.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - const trigger = await canvas.findByRole( 'combobox' ); - await userEvent.click( trigger ); - - const input = await canvas.findByPlaceholderText( 'Search colors...' ); - await userEvent.type( input, 'pi' ); - - const listbox = await screen.findByRole( 'listbox' ); - expect( listbox ).toHaveTextContent( 'Pink' ); - expect( listbox ).not.toHaveTextContent( 'Red' ); - - const pinkOption = await screen.findByRole( 'option', { name: 'Pink' } ); - await userEvent.click( pinkOption ); - - expect( screen.queryByRole( 'listbox' ) ).toBeNull(); - expect( input ).toHaveValue( 'Pink' ); -}; - -export const InlineSearchMulti: Story = ( { size, disabled } ) => ( -
- -
-); -InlineSearchMulti.args = { - size: 'md', - disabled: false, -}; - -InlineSearchMulti.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - - // Open dropdown by clicking the trigger wrapper - const triggerWrapper = await canvas.findByRole( 'combobox' ); - await userEvent.click( triggerWrapper ); - - // Type a query — 'r' matches Red and Orange - const input = await canvas.findByPlaceholderText( 'Search colors...' ); - await userEvent.type( input, 'r' ); - - const listbox = await screen.findByRole( 'listbox' ); - expect( listbox ).toHaveTextContent( 'Red' ); - expect( listbox ).toHaveTextContent( 'Orange' ); - expect( listbox ).not.toHaveTextContent( 'Cyan' ); - - // Clear and select two options - await userEvent.clear( input ); - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); // Red - - // Re-open and select Orange - await userEvent.click( triggerWrapper ); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); // Orange - - // Two badges should be visible inside trigger - const redBadge = await canvas.findByText( 'Red' ); - const orangeBadge = await canvas.findByText( 'Orange' ); - expect( redBadge ).toBeTruthy(); - expect( orangeBadge ).toBeTruthy(); - - // Backspace on empty input removes last badge (Orange) - await userEvent.click( input ); - await userEvent.keyboard( '{Backspace}' ); - expect( canvas.queryByText( 'Orange' ) ).toBeNull(); - expect( canvas.queryByText( 'Red' ) ).not.toBeNull(); - - // Re-open if closed after Backspace - if ( ! screen.queryByRole( 'listbox' ) ) { - await userEvent.click( triggerWrapper ); - } - await userEvent.clear( input ); - - // Case-insensitive filter - await userEvent.type( input, 'R' ); - const listboxR = await screen.findByRole( 'listbox' ); - expect( listboxR ).toHaveTextContent( 'Red' ); - expect( listboxR ).toHaveTextContent( 'Orange' ); - - // Empty query restores all options - await userEvent.clear( input ); - const listboxAll = await screen.findByRole( 'listbox' ); - expect( listboxAll ).toHaveTextContent( 'Cyan' ); - - // No-results state - await userEvent.type( input, 'zzz' ); - expect( screen.queryAllByRole( 'option' ) ).toHaveLength( 0 ); - - // Escape closes the dropdown - await userEvent.keyboard( '{Escape}' ); - expect( screen.queryByRole( 'listbox' ) ).toBeNull(); -}; - -const GroupedSelectTemplate: Story = ( { - size, - multiple, - combobox, - disabled, -} ) => { - const [ selectedValue, setSelectedValue ] = - useState( null ); - return ( -
- -
- ); -}; - -export const GroupedSelect = GroupedSelectTemplate.bind( {} ); -GroupedSelect.args = { - size: 'md', - multiple: false, - combobox: false, - disabled: false, -}; - -export const GroupedSelectWithSearch = GroupedSelectTemplate.bind( {} ); -GroupedSelectWithSearch.args = { - size: 'md', - multiple: false, - combobox: true, - disabled: false, -}; - -GroupedSelect.play = async ( { canvasElement } ) => { - const canvas = within( canvasElement ); - const selectButton = await canvas.findByRole( 'combobox' ); - await userEvent.click( selectButton ); - - const listBox = await screen.findByRole( 'listbox' ); - expect( listBox ).toHaveTextContent( 'Warm Colors' ); - expect( listBox ).toHaveTextContent( 'Cool Colors' ); - expect( listBox ).toHaveTextContent( 'Red' ); - - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - expect( selectButton ).toHaveTextContent( 'Red' ); -}; +import type { Meta, StoryFn } from '@storybook/react-vite'; +import Select from './select'; +import { expect, userEvent, within, screen } from 'storybook/test'; +import { SelectOptionValue } from './select-types'; +import { useState } from 'react'; + +const options = [ + { id: '1', name: 'Red' }, + { id: '2', name: 'Orange' }, + { id: '3', name: 'Yellow' }, + { id: '4', name: 'Green' }, + { id: '5', name: 'Cyan' }, + { id: '6', name: 'Blue' }, + { id: '7', name: 'Purple' }, + { id: '8', name: 'Pink' }, +]; + +const groupedOptions = [ + { + label: 'Warm Colors', + options: [ + { id: '1', name: 'Red' }, + { id: '2', name: 'Orange' }, + { id: '3', name: 'Yellow' }, + ], + }, + { + label: 'Cool Colors', + options: [ + { id: '4', name: 'Green' }, + { id: '5', name: 'Cyan' }, + { id: '6', name: 'Blue' }, + ], + }, + { + label: 'Other Colors', + options: [ + { id: '7', name: 'Purple' }, + { id: '8', name: 'Pink' }, + ], + }, +]; + +const meta: Meta = { + title: 'Atoms/Select', + component: Select, + subcomponents: { + 'Select.Button': Select.Button, + 'Select.Portal': Select.Portal, + 'Select.OptionGroup': Select.OptionGroup, + 'Select.Options': Select.Options, + 'Select.Option': Select.Option, + } as Record>, + parameters: { + layout: 'centered', + }, + tags: [ 'atoms', 'autodocs' ], + argTypes: { + size: { control: 'select' }, + children: { control: false }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryFn; + +// Single Select without portal +const SelectWithoutPortalTemplate: Story = ( { + size, + multiple, + combobox, + disabled, + ...args +} ) => ( +
+ +
+); + +export const SingleSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); +SingleSelectWithoutPortal.parameters = { + docs: { + description: { + story: `If you want to use the Select component inside a **Dialog**, **Drawer** or **Popover**, you can omit using the \`Select.Portal\` component. Using the portal is not required in this case and it might cause issues like the \`z-index\` problem and the dropdown menu not being visible. + +If you really need to use the portal and if you face \`z-index\` issues, in that case you can update the \`z-index\` of the Select dropdown to a higher value. + +#### When to use the \`Select.Portal\` component? + +Portal helps to render a floating element into a given container element. By default, outside of the app root and into the body. This is necessary to ensure the floating element can appear outside any potential parent containers that cause clipping (such as overflow: hidden), while retaining its location in the React tree. + +- When the dropdown is being cut off by parent elements. Ex. Parent container has \`overflow: hidden\` property. +- When you need to render the dropdown menu into a different part of the DOM except the parent container. +`, + }, + }, +}; +SingleSelectWithoutPortal.args = { + size: 'md', + multiple: false, + combobox: false, + disabled: false, +}; + +// Single Select Story +export const SingleSelect: Story = ( { size, multiple, combobox, disabled } ) => { + const [ selected, setSelected ] = useState( null ); + return ( +
+ +
+ ); +}; + +SingleSelect.args = { + size: 'md', + multiple: false, + combobox: false, + disabled: false, +}; +SingleSelect.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + // Click on the select button + const selectButton = await canvas.findByRole( 'combobox' ); + await userEvent.click( selectButton ); + + // Check if the listbox contains the option 'Red' + const listBox = await screen.findByRole( 'listbox' ); + expect( listBox ).toHaveTextContent( 'Red' ); + + // Click on the first option + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); + + // Check if the button text is updated + expect( selectButton ).toHaveTextContent( 'Red' ); +}; + +// Multi-select Story +export const MultiSelect: Story = ( { size, multiple, combobox, disabled } ) => { + const [ selected, setSelected ] = useState( [] ); + return ( +
+ +
+ ); +}; + +MultiSelect.args = { + size: 'md', + multiple: true, + combobox: false, + disabled: false, +}; + +MultiSelect.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + // Click on the select button + const selectButton = await canvas.findByRole( 'combobox' ); + await userEvent.click( selectButton ); + + // Check if the listbox contains the option 'Red' + const listBox = await screen.findByRole( 'listbox' ); + expect( listBox ).toHaveTextContent( 'Red' ); + + // Click on the first option + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); + + // Check if the listbox contains the option 'Orange' + await userEvent.click( selectButton ); + const allOptions2 = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions2[ 1 ] ); + + // Check if the button text is updated + expect( selectButton ).toHaveTextContent( /Red.*Orange/ ); +}; + +export const MultiSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); +MultiSelectWithoutPortal.args = { + size: 'md', + multiple: true, + combobox: false, + disabled: false, +}; + +export const SelectWithSearch: Story = ( { + size, + multiple, + combobox, + disabled, +} ) => ( +
+ +
+); + +SelectWithSearch.args = { + size: 'md', + multiple: false, + combobox: true, + disabled: false, +}; + +SelectWithSearch.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + // Click on the select button + const selectButton = await canvas.findByRole( 'combobox' ); + await userEvent.click( selectButton ); + + // Check if the listbox contains the option 'Red' and search input + const listBox = await screen.findByRole( 'listbox' ); + const searchInput = await screen.findByPlaceholderText( 'Search...' ); + expect( listBox ).toContainElement( searchInput ); + expect( listBox ).toHaveTextContent( 'Red' ); + + // Type 'Pink' in the search input + await userEvent.type( searchInput, 'Pink' ); + expect( listBox ).toHaveTextContent( 'Pink' ); + + // Click on the first option + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); + + // Check if the button text is updated + expect( selectButton ).toHaveTextContent( 'Pink' ); +}; + +export const SelectWithSearchWithoutPortal = SelectWithoutPortalTemplate.bind( + {} +); +SelectWithSearchWithoutPortal.args = { + size: 'md', + multiple: false, + combobox: true, + disabled: false, +}; + +export const InlineSearchWithCombobox: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchWithCombobox.args = { + size: 'md', + disabled: false, +}; +InlineSearchWithCombobox.parameters = { + docs: { + description: { + story: 'When both `combobox` and `inlineSearch` are passed, `inlineSearch` wins — the search input appears inside the trigger, not the dropdown.', + }, + }, +}; +InlineSearchWithCombobox.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + const trigger = await canvas.findByRole( 'combobox' ); + await userEvent.click( trigger ); + + const input = await canvas.findByPlaceholderText( 'Search colors...' ); + const listbox = await screen.findByRole( 'listbox' ); + expect( listbox ).not.toContainElement( input ); +}; + +export const InlineSearchSingle: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchSingle.args = { + size: 'md', + disabled: false, +}; +InlineSearchSingle.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + const trigger = await canvas.findByRole( 'combobox' ); + await userEvent.click( trigger ); + + const input = await canvas.findByPlaceholderText( 'Search colors...' ); + await userEvent.type( input, 'pi' ); + + const listbox = await screen.findByRole( 'listbox' ); + expect( listbox ).toHaveTextContent( 'Pink' ); + expect( listbox ).not.toHaveTextContent( 'Red' ); + + const pinkOption = await screen.findByRole( 'option', { name: 'Pink' } ); + await userEvent.click( pinkOption ); + + expect( screen.queryByRole( 'listbox' ) ).toBeNull(); + expect( input ).toHaveValue( 'Pink' ); +}; + +export const InlineSearchMulti: Story = ( { size, disabled } ) => ( +
+ +
+); +InlineSearchMulti.args = { + size: 'md', + disabled: false, +}; + +InlineSearchMulti.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + + // Open dropdown by clicking the trigger wrapper + const triggerWrapper = await canvas.findByRole( 'combobox' ); + await userEvent.click( triggerWrapper ); + + // Type a query — 'r' matches Red and Orange + const input = await canvas.findByPlaceholderText( 'Search colors...' ); + await userEvent.type( input, 'r' ); + + const listbox = await screen.findByRole( 'listbox' ); + expect( listbox ).toHaveTextContent( 'Red' ); + expect( listbox ).toHaveTextContent( 'Orange' ); + expect( listbox ).not.toHaveTextContent( 'Cyan' ); + + // Clear and select two options + await userEvent.clear( input ); + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); // Red + + // Re-open and select Orange + await userEvent.click( triggerWrapper ); + const allOptions2 = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions2[ 1 ] ); // Orange + + // Two badges should be visible inside trigger + const redBadge = await canvas.findByText( 'Red' ); + const orangeBadge = await canvas.findByText( 'Orange' ); + expect( redBadge ).toBeTruthy(); + expect( orangeBadge ).toBeTruthy(); + + // Backspace on empty input removes last badge (Orange) + await userEvent.click( input ); + await userEvent.keyboard( '{Backspace}' ); + expect( canvas.queryByText( 'Orange' ) ).toBeNull(); + expect( canvas.queryByText( 'Red' ) ).not.toBeNull(); + + // Re-open if closed after Backspace + if ( ! screen.queryByRole( 'listbox' ) ) { + await userEvent.click( triggerWrapper ); + } + await userEvent.clear( input ); + + // Case-insensitive filter + await userEvent.type( input, 'R' ); + const listboxR = await screen.findByRole( 'listbox' ); + expect( listboxR ).toHaveTextContent( 'Red' ); + expect( listboxR ).toHaveTextContent( 'Orange' ); + + // Empty query restores all options + await userEvent.clear( input ); + const listboxAll = await screen.findByRole( 'listbox' ); + expect( listboxAll ).toHaveTextContent( 'Cyan' ); + + // No-results state + await userEvent.type( input, 'zzz' ); + expect( screen.queryAllByRole( 'option' ) ).toHaveLength( 0 ); + + // Escape closes the dropdown + await userEvent.keyboard( '{Escape}' ); + expect( screen.queryByRole( 'listbox' ) ).toBeNull(); +}; + +const GroupedSelectTemplate: Story = ( { + size, + multiple, + combobox, + disabled, +} ) => { + const [ selectedValue, setSelectedValue ] = + useState( null ); + return ( +
+ +
+ ); +}; + +export const GroupedSelect = GroupedSelectTemplate.bind( {} ); +GroupedSelect.args = { + size: 'md', + multiple: false, + combobox: false, + disabled: false, +}; + +export const GroupedSelectWithSearch = GroupedSelectTemplate.bind( {} ); +GroupedSelectWithSearch.args = { + size: 'md', + multiple: false, + combobox: true, + disabled: false, +}; + +GroupedSelect.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + const selectButton = await canvas.findByRole( 'combobox' ); + await userEvent.click( selectButton ); + + const listBox = await screen.findByRole( 'listbox' ); + expect( listBox ).toHaveTextContent( 'Warm Colors' ); + expect( listBox ).toHaveTextContent( 'Cool Colors' ); + expect( listBox ).toHaveTextContent( 'Red' ); + + const allOptions = await screen.findAllByRole( 'option' ); + await userEvent.click( allOptions[ 0 ] ); + + expect( selectButton ).toHaveTextContent( 'Red' ); +}; diff --git a/src/components/select/select-types.ts b/src/components/select/select-types.ts index a8b359fa..f4311c71 100644 --- a/src/components/select/select-types.ts +++ b/src/components/select/select-types.ts @@ -1,180 +1,180 @@ -import type { - UseFloatingReturn, - UseInteractionsReturn, -} from '@floating-ui/react'; -import type { ReactNode, ReactElement, AriaAttributes } from 'react'; - -export type SelectOptionValue = string | number | Record; -export type SelectOnChange = ( - value: SelectOptionValue | SelectOptionValue[] -) => void; -export type SelectOnClose = ( event: React.MouseEvent ) => void; - -export type SelectFunctionChildren = ( { - value, - onClose, -}: { - value: SelectOptionValue; - onClose?: ( event: React.MouseEvent ) => void; -} ) => React.JSX.Element; - -export type MultiTypeChildren = - | ReactElement - | ReactNode - | SelectFunctionChildren; - -export type OnClick = ( index: number, value: SelectOptionValue ) => void; - -export type OnKeyDown = ( - event: React.KeyboardEvent, - index: number, - value: SelectOptionValue -) => void; - -export type SelectSizes = 'sm' | 'md' | 'lg'; - -export type SelectContextValueTypes = { - selected: unknown; - setSelected: ( selected: unknown ) => void; - multiple: boolean; - combobox: boolean; - disabled: boolean; - size: SelectSizes; - by: string; - displayBy: string; -}; - -export type SelectGetValues = () => SelectOptionValue | SelectOptionValue[]; - -export type SelectProps = { - /** Select Component unique ID. */ - id?: string; - /** Defines the size of the Select Component. */ - size?: SelectSizes; - /** When the value is an object, a key is required to compare the selected value. The default value is `id`. */ - by?: string; - /** Expects the `Select.Portal`/`Select.Options` and `Select.Button` children of the Select Component. */ - children?: ReactNode; - /** Combobox mode. */ - combobox?: boolean; - /** Inline search mode — renders the search input inside the trigger instead of the dropdown. Mutually exclusive with `combobox`; `inlineSearch` wins if both are passed. Default `false`. */ - inlineSearch?: boolean; - /** Disables the Select Component. */ - disabled?: boolean; - /** Multi select mode. */ - multiple?: boolean; - /** Defines the width of the Select Component. */ - value?: SelectOptionValue | SelectOptionValue[]; - /** onChange event to be triggered when the value of the Select Component changes. */ - onChange: SelectOnChange; - /** Defines the default value of the Select Component. */ - defaultValue?: SelectOptionValue | SelectOptionValue[]; - /** Placeholder text for search box. */ - searchPlaceholder?: string; - /** Function to fetch options. If provided, the search functionality will be handled outside of the select component. */ - searchFn?: ( keyword: string ) => Promise; - /** Delay in milliseconds for debounced search. If the searchFn is provided, the debounceDelay will be used to debounce the search. */ - debounceDelay?: number; -}; - -export interface SelectPortalProps { - /** Expects the `Select.Options` children of the Select.Portal Component. */ - children?: ReactNode; - /** - * Root element where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. - */ - root?: HTMLElement; - /** - * Root element ID where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. - */ - id?: string; -} - -export interface SelectButtonProps extends AriaAttributes { - /** Expects the `Select.Button` children of the Select Component. */ - children?: MultiTypeChildren; - /** Option Icon to show at the right of the option trigger/button. By default it will show chevron down icon. */ - icon?: ReactNode | null; - /** Placeholder text when no option is selected. */ - 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; - /** - * 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. */ - className?: string; -} - -export interface SelectOptionGroupProps { - /** Label for the option group */ - label: string; - /** Children options */ - children: ReactNode; - /** Additional class name for the option group */ - className?: string; -} - -export interface SelectOptionsProps { - /** Expects the `Select.Option` or `Select.OptionGroup` children */ - children: React.ReactNode; - /** Additional class name for the Select Options wrapper. */ - className?: string; -} - -export interface SelectOptionProps { - /** Value of the option. */ - value: SelectOptionValue; - /** Selected state of the option. */ - selected?: boolean; - /** Expects the `Select.Option` children of the Select Component. */ - children?: ReactNode; - /** Additional class name for the Select Option. */ - className?: string; - /** Additional Props */ - [key: string]: unknown; -} - -export type SelectContextValue = { - selectedIndex: number | null; - setSelectedIndex: ( index: number ) => void; - activeIndex: number | null; - setActiveIndex: ( index: number ) => void; - selected: SelectOptionValue | SelectOptionValue[]; - setSelected: ( selected: SelectOptionValue | SelectOptionValue[] ) => void; - handleSelect: OnClick; - combobox: boolean; - inlineSearch: boolean; - optionValuesRef: React.MutableRefObject; - sizeValue: SelectSizes; - multiple: boolean; - isTypingRef: React.MutableRefObject; - onClickItem: OnClick; - onKeyDownItem: OnKeyDown; - getValues: SelectGetValues; - selectId: string; - isOpen: boolean; - updateListRef: ( index: number, node: HTMLElement ) => void; - refs: UseFloatingReturn['refs']; - listContentRef: React.MutableRefObject; - by: string; - setSearchKeyword: ( keyword: string ) => void; - disabled: boolean; - isControlled: boolean; - getItemProps: UseInteractionsReturn['getItemProps']; - getReferenceProps: UseInteractionsReturn['getReferenceProps']; - getFloatingProps: UseInteractionsReturn['getFloatingProps']; - floatingStyles: UseFloatingReturn['floatingStyles']; - context: UseFloatingReturn['context']; - searchKeyword: string; - onChange: SelectOnChange; - value?: SelectOptionValue | SelectOptionValue[]; - searchPlaceholder?: string; - searchFn?: ( keyword: string ) => Promise; - debounceDelay?: number; -}; +import type { + UseFloatingReturn, + UseInteractionsReturn, +} from '@floating-ui/react'; +import type { ReactNode, ReactElement, AriaAttributes } from 'react'; + +export type SelectOptionValue = string | number | Record; +export type SelectOnChange = ( + value: SelectOptionValue | SelectOptionValue[] +) => void; +export type SelectOnClose = ( event: React.MouseEvent ) => void; + +export type SelectFunctionChildren = ( { + value, + onClose, +}: { + value: SelectOptionValue; + onClose?: ( event: React.MouseEvent ) => void; +} ) => React.JSX.Element; + +export type MultiTypeChildren = + | ReactElement + | ReactNode + | SelectFunctionChildren; + +export type OnClick = ( index: number, value: SelectOptionValue ) => void; + +export type OnKeyDown = ( + event: React.KeyboardEvent, + index: number, + value: SelectOptionValue +) => void; + +export type SelectSizes = 'sm' | 'md' | 'lg'; + +export type SelectContextValueTypes = { + selected: unknown; + setSelected: ( selected: unknown ) => void; + multiple: boolean; + combobox: boolean; + disabled: boolean; + size: SelectSizes; + by: string; + displayBy: string; +}; + +export type SelectGetValues = () => SelectOptionValue | SelectOptionValue[]; + +export type SelectProps = { + /** Select Component unique ID. */ + id?: string; + /** Defines the size of the Select Component. */ + size?: SelectSizes; + /** When the value is an object, a key is required to compare the selected value. The default value is `id`. */ + by?: string; + /** Expects the `Select.Portal`/`Select.Options` and `Select.Button` children of the Select Component. */ + children?: ReactNode; + /** Combobox mode. */ + combobox?: boolean; + /** Inline search mode — renders the search input inside the trigger instead of the dropdown. Mutually exclusive with `combobox`; `inlineSearch` wins if both are passed. Default `false`. */ + inlineSearch?: boolean; + /** Disables the Select Component. */ + disabled?: boolean; + /** Multi select mode. */ + multiple?: boolean; + /** Defines the width of the Select Component. */ + value?: SelectOptionValue | SelectOptionValue[]; + /** onChange event to be triggered when the value of the Select Component changes. */ + onChange: SelectOnChange; + /** Defines the default value of the Select Component. */ + defaultValue?: SelectOptionValue | SelectOptionValue[]; + /** Placeholder text for search box. */ + searchPlaceholder?: string; + /** Function to fetch options. If provided, the search functionality will be handled outside of the select component. */ + searchFn?: ( keyword: string ) => Promise; + /** Delay in milliseconds for debounced search. If the searchFn is provided, the debounceDelay will be used to debounce the search. */ + debounceDelay?: number; +}; + +export interface SelectPortalProps { + /** Expects the `Select.Options` children of the Select.Portal Component. */ + children?: ReactNode; + /** + * Root element where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. + */ + root?: HTMLElement; + /** + * Root element ID where the `Select.Options` will be rendered. If not provided Select.Options will be rendered in the body. + */ + id?: string; +} + +export interface SelectButtonProps extends AriaAttributes { + /** Expects the `Select.Button` children of the Select Component. */ + children?: MultiTypeChildren; + /** Option Icon to show at the right of the option trigger/button. By default it will show chevron down icon. */ + icon?: ReactNode | null; + /** Placeholder text when no option is selected. */ + 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; + /** + * 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. */ + className?: string; +} + +export interface SelectOptionGroupProps { + /** Label for the option group */ + label: string; + /** Children options */ + children: ReactNode; + /** Additional class name for the option group */ + className?: string; +} + +export interface SelectOptionsProps { + /** Expects the `Select.Option` or `Select.OptionGroup` children */ + children: React.ReactNode; + /** Additional class name for the Select Options wrapper. */ + className?: string; +} + +export interface SelectOptionProps { + /** Value of the option. */ + value: SelectOptionValue; + /** Selected state of the option. */ + selected?: boolean; + /** Expects the `Select.Option` children of the Select Component. */ + children?: ReactNode; + /** Additional class name for the Select Option. */ + className?: string; + /** Additional Props */ + [key: string]: unknown; +} + +export type SelectContextValue = { + selectedIndex: number | null; + setSelectedIndex: ( index: number ) => void; + activeIndex: number | null; + setActiveIndex: ( index: number ) => void; + selected: SelectOptionValue | SelectOptionValue[]; + setSelected: ( selected: SelectOptionValue | SelectOptionValue[] ) => void; + handleSelect: OnClick; + combobox: boolean; + inlineSearch: boolean; + optionValuesRef: React.MutableRefObject; + sizeValue: SelectSizes; + multiple: boolean; + isTypingRef: React.MutableRefObject; + onClickItem: OnClick; + onKeyDownItem: OnKeyDown; + getValues: SelectGetValues; + selectId: string; + isOpen: boolean; + updateListRef: ( index: number, node: HTMLElement ) => void; + refs: UseFloatingReturn['refs']; + listContentRef: React.MutableRefObject; + by: string; + setSearchKeyword: ( keyword: string ) => void; + disabled: boolean; + isControlled: boolean; + getItemProps: UseInteractionsReturn['getItemProps']; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + floatingStyles: UseFloatingReturn['floatingStyles']; + context: UseFloatingReturn['context']; + searchKeyword: string; + onChange: SelectOnChange; + value?: SelectOptionValue | SelectOptionValue[]; + searchPlaceholder?: string; + searchFn?: ( keyword: string ) => Promise; + debounceDelay?: number; +}; diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 5ef7e9e8..204202d2 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -1,1375 +1,1375 @@ -import { - useState, - useCallback, - useMemo, - useRef, - createContext, - useContext, - Children, - cloneElement, - isValidElement, - useEffect, - useLayoutEffect, - Fragment, - forwardRef, - type ReactNode, -} from 'react'; -import { cn } from '@/utilities/functions'; -import { CheckIcon, ChevronDown, ChevronsUpDown, Search } from 'lucide-react'; -import { - useFloating, - useClick, - useDismiss, - useRole, - useListNavigation, - useInteractions, - FloatingFocusManager, - useTypeahead, - offset, - flip, - size, - autoUpdate, - FloatingPortal, -} from '@floating-ui/react'; -import { Badge, Loader } from '@/components'; -import { nanoid } from 'nanoid'; -import { mergeRefs } from '@/components/toaster/utils'; -import { - disabledClassNames, - optionGroupDividerClassNames, - optionGroupDividerSizeClassNames, - selectItemClassNames, - sizeClassNames, -} from './component-style'; -import type { - OnClick, - OnKeyDown, - SelectButtonProps, - SelectContextValue, - SelectGetValues, - SelectOptionProps, - SelectOptionsProps, - SelectOptionValue, - SelectPortalProps, - SelectProps, - SelectSizes, - SelectOptionGroupProps, -} from './select-types'; -import { getTextContent } from './utils'; -import { useDebouncedCallback } from '@/utilities/hooks'; - -// Context to manage the state of the select component. -const SelectContext = createContext( - {} as SelectContextValue -); -const useSelectContext = () => useContext( SelectContext ); - -export const SelectButton = forwardRef( - ( - { - children, - icon = null, // Icon to show in the select button. - placeholder = 'Select an option', // Placeholder text. - optionIcon = null, // Icon to show in the selected option. - render, - label, // Label for the select component. - className, - ...props - }: SelectButtonProps, - ref - ) => { - const { - sizeValue, - getReferenceProps, - getValues, - selectId, - refs, - isOpen, - multiple, - combobox, - inlineSearch, - setSelected, - onChange, - isControlled, - disabled, - by, - searchKeyword, - setSearchKeyword, - searchPlaceholder, - context, - activeIndex, - optionValuesRef, - handleSelect, - } = useSelectContext(); - - const badgeSize = { - sm: 'xs', - md: 'sm', - lg: 'md', - }?.[ sizeValue as SelectSizes ]; - - const inputRef = useRef( null ); - const [ hasTyped, setHasTyped ] = useState( false ); - useEffect( () => { - if ( ! isOpen ) { - setHasTyped( false ); - } - }, [ isOpen ] ); - - // For inlineSearch single mode: derive string label of the selected value for input display. - const singleLabel = useMemo( () => { - if ( ! inlineSearch || multiple ) { - return ''; - } - const val = getValues(); - if ( ! val ) { - return ''; - } - if ( typeof render === 'function' ) { - const rendered = render( val as SelectOptionValue ); - if ( typeof rendered === 'string' ) { - return rendered; - } - } - if ( typeof val === 'string' || typeof val === 'number' ) { - return String( val ); - } - const nameKey = ( val as Record ).name; - return typeof nameKey === 'string' ? nameKey : ''; - }, [ inlineSearch, multiple, getValues, render ] ); - - // Get icon based on the Select component type and user provided icon. - const getIcon = useCallback( () => { - if ( icon ) { - return icon; - } - - const iconClassNames = - 'text-field-placeholder ' + disabledClassNames.icon; - - return combobox ? ( - - ) : ( - - ); - }, [ icon ] ); - - const renderSelected = useCallback( () => { - const selectedValue = getValues(); - - if ( ! selectedValue ) { - return null; - } - - if ( multiple ) { - return ( selectedValue as SelectOptionValue[] ).map( - ( valueItem: SelectOptionValue, index: number ) => ( - - ) - ); - } - - 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 ) || typeof children === 'string' ) && - typeof render !== 'function' - ) { - renderValue = children; - } - - return ( - - { renderValue as React.ReactNode } - - ); - }, [ getValues, disabled ] ); - - const handleOnCloseItem = - ( value: SelectOptionValue ) => - ( event?: React.MouseEvent ) => { - event?.preventDefault(); - event?.stopPropagation(); - - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; - const selectedIndex = selectedValues.findIndex( ( val ) => { - if ( - val !== null && - value !== null && - typeof val === 'object' - ) { - return ( - ( val as Record )[ by ] === - ( value as Record )[ by ] - ); - } - return val === value; - } ); - - if ( selectedIndex === -1 ) { - return; - } - - selectedValues.splice( selectedIndex, 1 ); - - if ( ! isControlled ) { - setSelected( selectedValues ); - } - if ( typeof onChange === 'function' ) { - onChange( selectedValues ); - } - }; - - if ( inlineSearch ) { - let inputValue = ''; - if ( ! multiple && getValues() ) { - // Single with value: show label until user types, then show query. - inputValue = isOpen && hasTyped ? searchKeyword : singleLabel; - } else if ( isOpen ) { - inputValue = searchKeyword; - } - - const showPlaceholder = multiple - ? ! ( getValues() as SelectOptionValue[] )?.length - : ! getValues() && ! searchKeyword; - - return ( -
- { !! label && ( - - ) } - { /* Visual chrome wrapper — POSITION reference only, not the interactive reference */ } - { /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */ } -
{ - // Click on chrome (badges/padding/icon) → focus input. - if ( e.target !== inputRef.current ) { - e.preventDefault(); - inputRef.current?.focus(); - } - } } - > - { /* Badges + inline input */ } -
- { multiple && renderSelected() } - { /* Input IS the floating-ui interactive reference. - useRole adds role/aria-expanded/aria-controls/aria-haspopup. - useListNavigation (virtual) adds aria-activedescendant + arrow key nav. - useDismiss adds Escape handling. */ } - { - if ( ! isOpen ) { - context.onOpenChange( true ); - } - }, - onClick: () => { - if ( ! isOpen ) { - context.onOpenChange( true ); - } - }, - onChange: ( - e: React.ChangeEvent - ) => { - setHasTyped( true ); - setSearchKeyword( e.target.value ); - if ( ! isOpen ) { - context.onOpenChange( true ); - } - }, - onKeyDown: ( e: React.KeyboardEvent ) => { - if ( - e.key === 'Enter' && - activeIndex !== null && - activeIndex >= 0 - ) { - e.preventDefault(); - const val = - optionValuesRef.current[ - activeIndex - ]; - if ( val !== undefined ) { - handleSelect( activeIndex, val ); - } - return; - } - if ( - e.key === 'Backspace' && - ! inputValue && - multiple - ) { - e.preventDefault(); - const arr = - ( getValues() as SelectOptionValue[] ) ?? - []; - if ( arr.length ) { - handleOnCloseItem( - arr[ arr.length - 1 ] - )(); - } - } - }, - } ) } - /> -
- { /* Suffix Icon */ } -
svg]:shrink-0', - sizeClassNames[ sizeValue as SelectSizes ].icon - ) } - > - { getIcon() } -
-
-
- ); - } - - return ( -
- { !! label && ( - - ) } - -
- ); - } -); - -export function SelectOptionGroup( { - label, - children, - className, - ...props -}: SelectOptionGroupProps ) { - const { index, totalGroups } = props as { - index: number; - totalGroups: number; - }; - const { sizeValue } = useSelectContext(); - - const groupClassNames = { - sm: 'text-xs', - md: 'text-xs', - lg: 'text-sm', - }; - - return ( - -
-
- { label } -
-
- { children } -
-
- { index < totalGroups && - !! ( children && Children.count( children ) > 0 ) && ( -
- ) } -
- ); -} - -export function SelectOptions( { - children, - className, // Additional class name for the dropdown. -}: SelectOptionsProps ) { - const { - isOpen, - context, - refs, - combobox, - inlineSearch, - floatingStyles, - getFloatingProps, - sizeValue, - setSearchKeyword, - setActiveIndex, - setSelectedIndex, - value, - selected, - getValues, - searchKeyword, - listContentRef, - by, - searchPlaceholder, - activeIndex, - searchFn, - debounceDelay, - selectId, - optionValuesRef, - } = useSelectContext(); - - const initialSelectedValueIndex = useMemo( () => { - const currentValue = getValues(); - let indexValue = -1; - - if ( 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, 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( () => { - // Track actual visible groups after filtering - let visibleGroups = 0; - let totalGroups = 0; - - // First pass - count total visible groups - Children.forEach( children, ( child ) => { - if ( isValidElement( child ) && child.type === SelectOptionGroup ) { - let hasVisibleChildren = false; - - // If there's a search term and no external search function - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const groupLabel = child.props.label?.toLowerCase() || ''; - - // Check if group label matches search term - const groupLabelMatches = groupLabel.includes( searchTerm ); - - // Check if any child option matches search term - const hasMatchingChildren = Children.toArray( - child.props.children - ).some( ( groupChild ) => { - if ( ! isValidElement( groupChild ) ) { - return false; - } - - const textContent = getTextContent( - groupChild.props.children - )?.toLowerCase(); - - return textContent.includes( searchTerm ); - } ); - - // Show group if either group label matches or any child matches - hasVisibleChildren = - groupLabelMatches || hasMatchingChildren; - } else { - // No search term, show all groups - hasVisibleChildren = true; - } - - if ( hasVisibleChildren ) { - visibleGroups++; - } - } - } ); - - totalGroups = Math.max( 0, visibleGroups - 1 ); // Subtract 1 since we don't need divider after last group - let childIndex = 0; - let groupIndex = 0; - optionValuesRef.current = []; - - // Process child to render - const processChild = ( child: React.ReactNode ): React.ReactNode => { - if ( ! isValidElement( child ) ) { - return null; - } - - // Handle option groups - if ( child.type === SelectOptionGroup ) { - let groupLabelMatches = false; - - // Check if group label matches search term - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const groupLabel = child.props.label?.toLowerCase() || ''; - groupLabelMatches = groupLabel.includes( searchTerm ); - } - - // Recursively process children of the option group - const groupChildren = Children.map( - child.props.children, - ( groupChild ) => { - if ( ! isValidElement( groupChild ) ) { - return null; - } - - // If group label matches, show all children regardless of their content - if ( groupLabelMatches ) { - const itemIndex = childIndex++; - optionValuesRef.current[ itemIndex ] = ( - groupChild.props as SelectOptionProps - ).value; - const childProps = { - ...( groupChild.props as SelectOptionProps ), - index: itemIndex, - id: `${ selectId }-option-${ itemIndex }`, - }; - - return cloneElement( groupChild, childProps ); - } - - // Otherwise, apply normal filtering to individual options - if ( searchKeyword && ! searchFn ) { - const textContent = getTextContent( - ( - groupChild.props as { - children?: React.ReactNode; - } - ).children - )?.toLowerCase(); - const searchTerm = searchKeyword.toLowerCase(); - - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return null; - } - } - - const itemIndex = childIndex++; - optionValuesRef.current[ itemIndex ] = ( - groupChild.props as SelectOptionProps - ).value; - const childProps = { - ...( groupChild.props as SelectOptionProps ), - index: itemIndex, - id: `${ selectId }-option-${ itemIndex }`, - }; - - return cloneElement( groupChild, childProps ); - } - ); - - // Only render group if it has visible children - const hasChildren = groupChildren?.some( - ( c: React.ReactNode ) => c !== null - ); - - if ( ! hasChildren ) { - return null; - } - - const groupProps = { - ...child.props, - children: groupChildren, - index: groupIndex, - totalGroups, - }; - - groupIndex++; - return cloneElement( child, groupProps ); - } - - // Handle regular options when searchFn is not provided - if ( searchKeyword && ! searchFn ) { - const textContent = getTextContent( - child.props?.children - )?.toLowerCase(); - const searchTerm = searchKeyword.toLowerCase(); - - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return null; - } - } - - const itemIndex = childIndex++; - optionValuesRef.current[ itemIndex ] = child.props.value; - return cloneElement( child, { - ...child.props, - index: itemIndex, - id: `${ selectId }-option-${ itemIndex }`, - } ); - }; - - return Children.map( children, processChild ); - }, [ - searchKeyword, - value, - selected, - children, - searchFn, - selectId, - optionValuesRef, - ] ); - const childrenCount = Children.count( renderChildren ); - - // Update the content list reference. - useEffect( () => { - listContentRef.current = []; - // Get all children as an array. - let allChildren = Children.toArray( children ); - // If it's an option group and has children. - if ( - allChildren && - isValidElement( allChildren[ 0 ] ) && - allChildren[ 0 ].type === SelectOptionGroup - ) { - allChildren = Children.toArray( allChildren ) - .map( ( child ) => - isValidElement( child ) ? child.props.children : null - ) - .filter( Boolean ); - } - // Update the list content reference. - Children.forEach( allChildren, ( child ) => { - if ( ! isValidElement( child ) ) { - return; - } - - const textContent = getTextContent( - child.props?.children - )?.toLowerCase(); - // Handle regular options when searchFn is not provided - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return; - } - } - - listContentRef.current.push( textContent ); - } ); - }, [ searchKeyword, searchFn ] ); - - const [ searching, setSearching ] = useState( false ); - - // Create a function to handle the search function. - const handleSearchFn = useCallback( async () => { - if ( ! searchFn || typeof searchFn !== 'function' || searching ) { - return; - } - - setSearching( true ); - try { - await searchFn( searchKeyword ); - } catch ( error ) { - // eslint-disable-next-line no-console - console.error( error ); - } finally { - setSearching( false ); - } - }, [ searchKeyword ] ); - - // Debounce the search function. - const initiateSearch = useDebouncedCallback( handleSearchFn, debounceDelay! ); - - // Initiate search when searchFn is a function. - useEffect( () => { - if ( typeof searchFn !== 'function' ) { - return; - } - initiateSearch(); - }, [ initiateSearch ] ); - - const dropdownContent = ( -
- { /* Searchbox — combobox only; inlineSearch uses trigger input */ } - { combobox && ! inlineSearch && ( -
- { searching ? ( - - ) : ( - - ) } - - setSearchKeyword( event.target.value ) - } - value={ searchKeyword } - autoComplete="off" - /> -
- ) } - { /* Dropdown Items Wrapper */ } -
- { /* Dropdown Items */ } - { !! childrenCount && renderChildren } - - { /* No items found */ } - { ! childrenCount && ( -
- No items found -
- ) } -
-
- ); - - return ( - <> - { /* Dropdown */ } - { isOpen && ( - <> - { inlineSearch ? ( - dropdownContent - ) : ( - - { dropdownContent } - - ) } - - ) } - - ); -} - -export function SelectPortal( { children, root, id }: SelectPortalProps ) { - return ( - - { children } - - ); -} - -export function SelectItem( { - value, - selected, - children, - className, - ...props -}: SelectOptionProps ) { - const { - sizeValue, - getItemProps, - onKeyDownItem, - onClickItem, - activeIndex, - selectedIndex, - updateListRef, - getValues, - by, - multiple, - inlineSearch, - } = useSelectContext(); - const { index: indx, id: optionId } = props as { - index: number; - id?: string; - [key: string]: unknown; - }; - const initialIndxRef = useRef( indx ); - - const selectedIconClassName = { - sm: 'size-4', - md: 'size-4', - lg: 'size-5', - }; - - const multipleChecked = useMemo( () => { - if ( ! multiple ) { - return false; - } - const currentValue = getValues(); - if ( ! currentValue ) { - return false; - } - return ( currentValue as SelectOptionValue[] ).some( ( val ) => { - if ( val !== null && value !== null && typeof val === 'object' ) { - return ( - ( val as Record )[ by ] === - ( value as Record )[ by ] - ); - } - return val === value; - } ); - }, [ value, getValues ] ); - - const isChecked = useMemo( () => { - if ( typeof selected === 'boolean' ) { - return selected; - } - - if ( multiple ) { - return multipleChecked; - } - - return indx === selectedIndex; - }, [ multipleChecked, selectedIndex, selected ] ); - - let itemTabIndex: number | undefined; - if ( ! inlineSearch ) { - itemTabIndex = indx === activeIndex ? 0 : -1; - } - - return ( -
{ - updateListRef( indx as number, node as HTMLElement ); - } } - role="option" - tabIndex={ itemTabIndex } - aria-selected={ isChecked && indx === activeIndex } - { ...getItemProps( { - // Handle pointer select. - onClick() { - onClickItem( initialIndxRef.current as number, value ); - }, - // Handle keyboard select. - onKeyDown( event: React.KeyboardEvent ) { - onKeyDownItem( - event, - initialIndxRef.current as number, - value - ); - }, - } ) } - > - { children } - { isChecked && ( - - ) } -
- ); -} - -const SelectComponent = ( { - id, - size: sizeValue = 'md', // sm, md, lg - value, // Value of the select (for controlled component). - defaultValue, // Default value of the select (for uncontrolled component). - onChange, // Callback function to handle the change event. - by = 'id', // Used to identify the select component. Default is 'id'. - children, - multiple = false, // If true, it will allow multiple selection. - combobox = false, // If true, it will show a search box. - inlineSearch = false, // If true, renders search input inside the trigger. - disabled = false, // If true, it will disable the select component. - searchPlaceholder = 'Search...', // Placeholder text for search box. - searchFn, // Function to handle the search. - debounceDelay = 500, // Debounce delay for the search. -}: SelectProps ) => { - const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); - const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); - - if ( process.env.NODE_ENV !== 'production' && combobox && inlineSearch ) { - // eslint-disable-next-line no-console - console.warn( - 'force-ui Select: `inlineSearch` and `combobox` are mutually exclusive. `inlineSearch` will take precedence.' - ); - } - const [ selected, setSelected ] = useState< - SelectOptionValue | SelectOptionValue[] - >( defaultValue! ); - const [ searchKeyword, setSearchKeyword ] = useState( '' ); - - const getValues = useCallback( () => { - if ( isControlled ) { - return value as string | number | Record; - } - return selected as string | number | Record; - }, [ isControlled, value, selected ] ); - - // Dropdown position related code (Start) - const [ isOpen, setIsOpen ] = useState( false ); - const [ activeIndex, setActiveIndex ] = useState( null ); - const [ selectedIndex, setSelectedIndex ] = useState( null ); - - const dropdownMaxHeightBySize = { - sm: combobox && ! inlineSearch ? 256 : 172, - md: combobox && ! inlineSearch ? 256 : 216, - lg: combobox && ! inlineSearch ? 256 : 216, - }; - - const { refs, floatingStyles, context } = useFloating( { - strategy: 'fixed', - placement: 'bottom-start', - open: isOpen, - onOpenChange: setIsOpen, - whileElementsMounted: autoUpdate, - middleware: [ - offset( 5 ), - flip( { padding: 10 } ), - size( { - apply( { rects, elements, availableHeight } ) { - Object.assign( elements.floating.style, { - maxHeight: `min(${ availableHeight }px, ${ dropdownMaxHeightBySize[ sizeValue as SelectSizes ] }px)`, - maxWidth: `${ rects.reference.width }px`, - } ); - }, - padding: 10, - } ), - ], - } ); - - const listRef = useRef>( [] ); - const listContentRef = useRef( [] ); - const isTypingRef = useRef( false ); - const optionValuesRef = useRef( [] ); - - // Clear search when dropdown closes (Escape, outside-click, or selection). - useEffect( () => { - if ( ! isOpen ) { - setSearchKeyword( '' ); - } - }, [ isOpen ] ); - - const click = useClick( context, { - event: 'mousedown', - enabled: ! inlineSearch, - } ); - const dismiss = useDismiss( context ); - const role = useRole( context, { role: 'listbox' } ); - const listNav = useListNavigation( context, { - listRef, - activeIndex, - selectedIndex, - onNavigate: setActiveIndex, - loop: true, - // virtual: input is the reference, items use aria-activedescendant rather than DOM focus. - virtual: inlineSearch, - } ); - const typeahead = useTypeahead( context, { - listRef: listContentRef, - activeIndex, - selectedIndex, - onMatch: isOpen ? setActiveIndex : setSelectedIndex, - onTypingChange( isTyping ) { - isTypingRef.current = isTyping; - }, - } ); - - const { getReferenceProps, getFloatingProps, getItemProps } = - useInteractions( [ - dismiss, - role, - listNav, - click, - ...( ! combobox && ! inlineSearch ? [ typeahead ] : [] ), - ] ); - - const handleMultiSelect: OnClick = ( index, newValue ) => { - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; - const valueIndex = selectedValues.findIndex( ( selectedValue ) => { - if ( - selectedValue !== null && - newValue !== null && - typeof selectedValue === 'object' - ) { - return ( - ( selectedValue as Record )[ by ] === - ( newValue as Record )[ by ] - ); - } - return selectedValue === newValue; - } ); - - if ( valueIndex !== -1 ) { - return; - } - selectedValues.push( newValue ); - - if ( ! isControlled ) { - setSelected( selectedValues ); - } - setSelectedIndex( index ); - ( - ( refs.domReference.current ?? - refs.reference.current ) as HTMLElement | null - )?.focus(); - setIsOpen( false ); - setSearchKeyword( '' ); - if ( typeof onChange === 'function' ) { - onChange( selectedValues ); - } - }; - - const handleSelect: OnClick = ( index, newValue ) => { - if ( multiple ) { - return handleMultiSelect( index, newValue ); - } - setSelectedIndex( index ); - if ( ! isControlled ) { - setSelected( newValue ); - } - ( - ( refs.domReference.current ?? - refs.reference.current ) as HTMLElement | null - )?.focus(); - setIsOpen( false ); - setSearchKeyword( '' ); - if ( typeof onChange === 'function' ) { - onChange( newValue ); - } - }; - // Dropdown position related code (End) - - const updateListRef = useCallback( ( index: number, node: HTMLElement ) => { - listRef.current[ index ] = node; - }, [] ); - - const onClickItem: OnClick = ( index, newValue ) => { - handleSelect( index, newValue ); - }; - - const onKeyDownItem: OnKeyDown = ( event, index, newValue ) => { - if ( event.key === 'Enter' ) { - event.preventDefault(); - handleSelect( index, newValue ); - } - - if ( event.key === ' ' && ! isTypingRef.current ) { - event.preventDefault(); - handleSelect( index, newValue ); - } - }; - - return ( - - { children } - - ); -}; - -SelectComponent.displayName = 'Select'; - -const Select = Object.assign( SelectComponent, { - Portal: SelectPortal, - Button: SelectButton, - Options: SelectOptions, - Option: SelectItem, - OptionGroup: SelectOptionGroup, -} ); - -SelectPortal.displayName = 'Select.Portal'; -SelectButton.displayName = 'Select.Button'; -SelectOptions.displayName = 'Select.Options'; -SelectItem.displayName = 'Select.Option'; -SelectOptionGroup.displayName = 'Select.OptionGroup'; - -export default Select; +import { + useState, + useCallback, + useMemo, + useRef, + createContext, + useContext, + Children, + cloneElement, + isValidElement, + useEffect, + useLayoutEffect, + Fragment, + forwardRef, + type ReactNode, +} from 'react'; +import { cn } from '@/utilities/functions'; +import { CheckIcon, ChevronDown, ChevronsUpDown, Search } from 'lucide-react'; +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + FloatingFocusManager, + useTypeahead, + offset, + flip, + size, + autoUpdate, + FloatingPortal, +} from '@floating-ui/react'; +import { Badge, Loader } from '@/components'; +import { nanoid } from 'nanoid'; +import { mergeRefs } from '@/components/toaster/utils'; +import { + disabledClassNames, + optionGroupDividerClassNames, + optionGroupDividerSizeClassNames, + selectItemClassNames, + sizeClassNames, +} from './component-style'; +import type { + OnClick, + OnKeyDown, + SelectButtonProps, + SelectContextValue, + SelectGetValues, + SelectOptionProps, + SelectOptionsProps, + SelectOptionValue, + SelectPortalProps, + SelectProps, + SelectSizes, + SelectOptionGroupProps, +} from './select-types'; +import { getTextContent } from './utils'; +import { useDebouncedCallback } from '@/utilities/hooks'; + +// Context to manage the state of the select component. +const SelectContext = createContext( + {} as SelectContextValue +); +const useSelectContext = () => useContext( SelectContext ); + +export const SelectButton = forwardRef( + ( + { + children, + icon = null, // Icon to show in the select button. + placeholder = 'Select an option', // Placeholder text. + optionIcon = null, // Icon to show in the selected option. + render, + label, // Label for the select component. + className, + ...props + }: SelectButtonProps, + ref + ) => { + const { + sizeValue, + getReferenceProps, + getValues, + selectId, + refs, + isOpen, + multiple, + combobox, + inlineSearch, + setSelected, + onChange, + isControlled, + disabled, + by, + searchKeyword, + setSearchKeyword, + searchPlaceholder, + context, + activeIndex, + optionValuesRef, + handleSelect, + } = useSelectContext(); + + const badgeSize = { + sm: 'xs', + md: 'sm', + lg: 'md', + }?.[ sizeValue as SelectSizes ]; + + const inputRef = useRef( null ); + const [ hasTyped, setHasTyped ] = useState( false ); + useEffect( () => { + if ( ! isOpen ) { + setHasTyped( false ); + } + }, [ isOpen ] ); + + // For inlineSearch single mode: derive string label of the selected value for input display. + const singleLabel = useMemo( () => { + if ( ! inlineSearch || multiple ) { + return ''; + } + const val = getValues(); + if ( ! val ) { + return ''; + } + if ( typeof render === 'function' ) { + const rendered = render( val as SelectOptionValue ); + if ( typeof rendered === 'string' ) { + return rendered; + } + } + if ( typeof val === 'string' || typeof val === 'number' ) { + return String( val ); + } + const nameKey = ( val as Record ).name; + return typeof nameKey === 'string' ? nameKey : ''; + }, [ inlineSearch, multiple, getValues, render ] ); + + // Get icon based on the Select component type and user provided icon. + const getIcon = useCallback( () => { + if ( icon ) { + return icon; + } + + const iconClassNames = + 'text-field-placeholder ' + disabledClassNames.icon; + + return combobox ? ( + + ) : ( + + ); + }, [ icon ] ); + + const renderSelected = useCallback( () => { + const selectedValue = getValues(); + + if ( ! selectedValue ) { + return null; + } + + if ( multiple ) { + return ( selectedValue as SelectOptionValue[] ).map( + ( valueItem: SelectOptionValue, index: number ) => ( + + ) + ); + } + + 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 ) || typeof children === 'string' ) && + typeof render !== 'function' + ) { + renderValue = children; + } + + return ( + + { renderValue as React.ReactNode } + + ); + }, [ getValues, disabled ] ); + + const handleOnCloseItem = + ( value: SelectOptionValue ) => + ( event?: React.MouseEvent ) => { + event?.preventDefault(); + event?.stopPropagation(); + + const selectedValues = [ + ...( ( getValues() as SelectOptionValue[] ) ?? [] ), + ]; + const selectedIndex = selectedValues.findIndex( ( val ) => { + if ( + val !== null && + value !== null && + typeof val === 'object' + ) { + return ( + ( val as Record )[ by ] === + ( value as Record )[ by ] + ); + } + return val === value; + } ); + + if ( selectedIndex === -1 ) { + return; + } + + selectedValues.splice( selectedIndex, 1 ); + + if ( ! isControlled ) { + setSelected( selectedValues ); + } + if ( typeof onChange === 'function' ) { + onChange( selectedValues ); + } + }; + + if ( inlineSearch ) { + let inputValue = ''; + if ( ! multiple && getValues() ) { + // Single with value: show label until user types, then show query. + inputValue = isOpen && hasTyped ? searchKeyword : singleLabel; + } else if ( isOpen ) { + inputValue = searchKeyword; + } + + const showPlaceholder = multiple + ? ! ( getValues() as SelectOptionValue[] )?.length + : ! getValues() && ! searchKeyword; + + return ( +
+ { !! label && ( + + ) } + { /* Visual chrome wrapper — POSITION reference only, not the interactive reference */ } + { /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */ } +
{ + // Click on chrome (badges/padding/icon) → focus input. + if ( e.target !== inputRef.current ) { + e.preventDefault(); + inputRef.current?.focus(); + } + } } + > + { /* Badges + inline input */ } +
+ { multiple && renderSelected() } + { /* Input IS the floating-ui interactive reference. + useRole adds role/aria-expanded/aria-controls/aria-haspopup. + useListNavigation (virtual) adds aria-activedescendant + arrow key nav. + useDismiss adds Escape handling. */ } + { + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onClick: () => { + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onChange: ( + e: React.ChangeEvent + ) => { + setHasTyped( true ); + setSearchKeyword( e.target.value ); + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onKeyDown: ( e: React.KeyboardEvent ) => { + if ( + e.key === 'Enter' && + activeIndex !== null && + activeIndex >= 0 + ) { + e.preventDefault(); + const val = + optionValuesRef.current[ + activeIndex + ]; + if ( val !== undefined ) { + handleSelect( activeIndex, val ); + } + return; + } + if ( + e.key === 'Backspace' && + ! inputValue && + multiple + ) { + e.preventDefault(); + const arr = + ( getValues() as SelectOptionValue[] ) ?? + []; + if ( arr.length ) { + handleOnCloseItem( + arr[ arr.length - 1 ] + )(); + } + } + }, + } ) } + /> +
+ { /* Suffix Icon */ } +
svg]:shrink-0', + sizeClassNames[ sizeValue as SelectSizes ].icon + ) } + > + { getIcon() } +
+
+
+ ); + } + + return ( +
+ { !! label && ( + + ) } + +
+ ); + } +); + +export function SelectOptionGroup( { + label, + children, + className, + ...props +}: SelectOptionGroupProps ) { + const { index, totalGroups } = props as { + index: number; + totalGroups: number; + }; + const { sizeValue } = useSelectContext(); + + const groupClassNames = { + sm: 'text-xs', + md: 'text-xs', + lg: 'text-sm', + }; + + return ( + +
+
+ { label } +
+
+ { children } +
+
+ { index < totalGroups && + !! ( children && Children.count( children ) > 0 ) && ( +
+ ) } +
+ ); +} + +export function SelectOptions( { + children, + className, // Additional class name for the dropdown. +}: SelectOptionsProps ) { + const { + isOpen, + context, + refs, + combobox, + inlineSearch, + floatingStyles, + getFloatingProps, + sizeValue, + setSearchKeyword, + setActiveIndex, + setSelectedIndex, + value, + selected, + getValues, + searchKeyword, + listContentRef, + by, + searchPlaceholder, + activeIndex, + searchFn, + debounceDelay, + selectId, + optionValuesRef, + } = useSelectContext(); + + const initialSelectedValueIndex = useMemo( () => { + const currentValue = getValues(); + let indexValue = -1; + + if ( 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, 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( () => { + // Track actual visible groups after filtering + let visibleGroups = 0; + let totalGroups = 0; + + // First pass - count total visible groups + Children.forEach( children, ( child ) => { + if ( isValidElement( child ) && child.type === SelectOptionGroup ) { + let hasVisibleChildren = false; + + // If there's a search term and no external search function + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const groupLabel = child.props.label?.toLowerCase() || ''; + + // Check if group label matches search term + const groupLabelMatches = groupLabel.includes( searchTerm ); + + // Check if any child option matches search term + const hasMatchingChildren = Children.toArray( + child.props.children + ).some( ( groupChild ) => { + if ( ! isValidElement( groupChild ) ) { + return false; + } + + const textContent = getTextContent( + groupChild.props.children + )?.toLowerCase(); + + return textContent.includes( searchTerm ); + } ); + + // Show group if either group label matches or any child matches + hasVisibleChildren = + groupLabelMatches || hasMatchingChildren; + } else { + // No search term, show all groups + hasVisibleChildren = true; + } + + if ( hasVisibleChildren ) { + visibleGroups++; + } + } + } ); + + totalGroups = Math.max( 0, visibleGroups - 1 ); // Subtract 1 since we don't need divider after last group + let childIndex = 0; + let groupIndex = 0; + optionValuesRef.current = []; + + // Process child to render + const processChild = ( child: React.ReactNode ): React.ReactNode => { + if ( ! isValidElement( child ) ) { + return null; + } + + // Handle option groups + if ( child.type === SelectOptionGroup ) { + let groupLabelMatches = false; + + // Check if group label matches search term + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const groupLabel = child.props.label?.toLowerCase() || ''; + groupLabelMatches = groupLabel.includes( searchTerm ); + } + + // Recursively process children of the option group + const groupChildren = Children.map( + child.props.children, + ( groupChild ) => { + if ( ! isValidElement( groupChild ) ) { + return null; + } + + // If group label matches, show all children regardless of their content + if ( groupLabelMatches ) { + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; + const childProps = { + ...( groupChild.props as SelectOptionProps ), + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + }; + + return cloneElement( groupChild, childProps ); + } + + // Otherwise, apply normal filtering to individual options + if ( searchKeyword && ! searchFn ) { + const textContent = getTextContent( + ( + groupChild.props as { + children?: React.ReactNode; + } + ).children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return null; + } + } + + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; + const childProps = { + ...( groupChild.props as SelectOptionProps ), + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + }; + + return cloneElement( groupChild, childProps ); + } + ); + + // Only render group if it has visible children + const hasChildren = groupChildren?.some( + ( c: React.ReactNode ) => c !== null + ); + + if ( ! hasChildren ) { + return null; + } + + const groupProps = { + ...child.props, + children: groupChildren, + index: groupIndex, + totalGroups, + }; + + groupIndex++; + return cloneElement( child, groupProps ); + } + + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return null; + } + } + + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = child.props.value; + return cloneElement( child, { + ...child.props, + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + } ); + }; + + return Children.map( children, processChild ); + }, [ + searchKeyword, + value, + selected, + children, + searchFn, + selectId, + optionValuesRef, + ] ); + const childrenCount = Children.count( renderChildren ); + + // Update the content list reference. + useEffect( () => { + listContentRef.current = []; + // Get all children as an array. + let allChildren = Children.toArray( children ); + // If it's an option group and has children. + if ( + allChildren && + isValidElement( allChildren[ 0 ] ) && + allChildren[ 0 ].type === SelectOptionGroup + ) { + allChildren = Children.toArray( allChildren ) + .map( ( child ) => + isValidElement( child ) ? child.props.children : null + ) + .filter( Boolean ); + } + // Update the list content reference. + Children.forEach( allChildren, ( child ) => { + if ( ! isValidElement( child ) ) { + return; + } + + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return; + } + } + + listContentRef.current.push( textContent ); + } ); + }, [ searchKeyword, searchFn ] ); + + const [ searching, setSearching ] = useState( false ); + + // Create a function to handle the search function. + const handleSearchFn = useCallback( async () => { + if ( ! searchFn || typeof searchFn !== 'function' || searching ) { + return; + } + + setSearching( true ); + try { + await searchFn( searchKeyword ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( error ); + } finally { + setSearching( false ); + } + }, [ searchKeyword ] ); + + // Debounce the search function. + const initiateSearch = useDebouncedCallback( handleSearchFn, debounceDelay! ); + + // Initiate search when searchFn is a function. + useEffect( () => { + if ( typeof searchFn !== 'function' ) { + return; + } + initiateSearch(); + }, [ initiateSearch ] ); + + const dropdownContent = ( +
+ { /* Searchbox — combobox only; inlineSearch uses trigger input */ } + { combobox && ! inlineSearch && ( +
+ { searching ? ( + + ) : ( + + ) } + + setSearchKeyword( event.target.value ) + } + value={ searchKeyword } + autoComplete="off" + /> +
+ ) } + { /* Dropdown Items Wrapper */ } +
+ { /* Dropdown Items */ } + { !! childrenCount && renderChildren } + + { /* No items found */ } + { ! childrenCount && ( +
+ No items found +
+ ) } +
+
+ ); + + return ( + <> + { /* Dropdown */ } + { isOpen && ( + <> + { inlineSearch ? ( + dropdownContent + ) : ( + + { dropdownContent } + + ) } + + ) } + + ); +} + +export function SelectPortal( { children, root, id }: SelectPortalProps ) { + return ( + + { children } + + ); +} + +export function SelectItem( { + value, + selected, + children, + className, + ...props +}: SelectOptionProps ) { + const { + sizeValue, + getItemProps, + onKeyDownItem, + onClickItem, + activeIndex, + selectedIndex, + updateListRef, + getValues, + by, + multiple, + inlineSearch, + } = useSelectContext(); + const { index: indx, id: optionId } = props as { + index: number; + id?: string; + [key: string]: unknown; + }; + const initialIndxRef = useRef( indx ); + + const selectedIconClassName = { + sm: 'size-4', + md: 'size-4', + lg: 'size-5', + }; + + const multipleChecked = useMemo( () => { + if ( ! multiple ) { + return false; + } + const currentValue = getValues(); + if ( ! currentValue ) { + return false; + } + return ( currentValue as SelectOptionValue[] ).some( ( val ) => { + if ( val !== null && value !== null && typeof val === 'object' ) { + return ( + ( val as Record )[ by ] === + ( value as Record )[ by ] + ); + } + return val === value; + } ); + }, [ value, getValues ] ); + + const isChecked = useMemo( () => { + if ( typeof selected === 'boolean' ) { + return selected; + } + + if ( multiple ) { + return multipleChecked; + } + + return indx === selectedIndex; + }, [ multipleChecked, selectedIndex, selected ] ); + + let itemTabIndex: number | undefined; + if ( ! inlineSearch ) { + itemTabIndex = indx === activeIndex ? 0 : -1; + } + + return ( +
{ + updateListRef( indx as number, node as HTMLElement ); + } } + role="option" + tabIndex={ itemTabIndex } + aria-selected={ isChecked && indx === activeIndex } + { ...getItemProps( { + // Handle pointer select. + onClick() { + onClickItem( initialIndxRef.current as number, value ); + }, + // Handle keyboard select. + onKeyDown( event: React.KeyboardEvent ) { + onKeyDownItem( + event, + initialIndxRef.current as number, + value + ); + }, + } ) } + > + { children } + { isChecked && ( + + ) } +
+ ); +} + +const SelectComponent = ( { + id, + size: sizeValue = 'md', // sm, md, lg + value, // Value of the select (for controlled component). + defaultValue, // Default value of the select (for uncontrolled component). + onChange, // Callback function to handle the change event. + by = 'id', // Used to identify the select component. Default is 'id'. + children, + multiple = false, // If true, it will allow multiple selection. + combobox = false, // If true, it will show a search box. + inlineSearch = false, // If true, renders search input inside the trigger. + disabled = false, // If true, it will disable the select component. + searchPlaceholder = 'Search...', // Placeholder text for search box. + searchFn, // Function to handle the search. + debounceDelay = 500, // Debounce delay for the search. +}: SelectProps ) => { + const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); + const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); + + if ( process.env.NODE_ENV !== 'production' && combobox && inlineSearch ) { + // eslint-disable-next-line no-console + console.warn( + 'force-ui Select: `inlineSearch` and `combobox` are mutually exclusive. `inlineSearch` will take precedence.' + ); + } + const [ selected, setSelected ] = useState< + SelectOptionValue | SelectOptionValue[] + >( defaultValue! ); + const [ searchKeyword, setSearchKeyword ] = useState( '' ); + + const getValues = useCallback( () => { + if ( isControlled ) { + return value as string | number | Record; + } + return selected as string | number | Record; + }, [ isControlled, value, selected ] ); + + // Dropdown position related code (Start) + const [ isOpen, setIsOpen ] = useState( false ); + const [ activeIndex, setActiveIndex ] = useState( null ); + const [ selectedIndex, setSelectedIndex ] = useState( null ); + + const dropdownMaxHeightBySize = { + sm: combobox && ! inlineSearch ? 256 : 172, + md: combobox && ! inlineSearch ? 256 : 216, + lg: combobox && ! inlineSearch ? 256 : 216, + }; + + const { refs, floatingStyles, context } = useFloating( { + strategy: 'fixed', + placement: 'bottom-start', + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset( 5 ), + flip( { padding: 10 } ), + size( { + apply( { rects, elements, availableHeight } ) { + Object.assign( elements.floating.style, { + maxHeight: `min(${ availableHeight }px, ${ dropdownMaxHeightBySize[ sizeValue as SelectSizes ] }px)`, + maxWidth: `${ rects.reference.width }px`, + } ); + }, + padding: 10, + } ), + ], + } ); + + const listRef = useRef>( [] ); + const listContentRef = useRef( [] ); + const isTypingRef = useRef( false ); + const optionValuesRef = useRef( [] ); + + // Clear search when dropdown closes (Escape, outside-click, or selection). + useEffect( () => { + if ( ! isOpen ) { + setSearchKeyword( '' ); + } + }, [ isOpen ] ); + + const click = useClick( context, { + event: 'mousedown', + enabled: ! inlineSearch, + } ); + const dismiss = useDismiss( context ); + const role = useRole( context, { role: 'listbox' } ); + const listNav = useListNavigation( context, { + listRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + // virtual: input is the reference, items use aria-activedescendant rather than DOM focus. + virtual: inlineSearch, + } ); + const typeahead = useTypeahead( context, { + listRef: listContentRef, + activeIndex, + selectedIndex, + onMatch: isOpen ? setActiveIndex : setSelectedIndex, + onTypingChange( isTyping ) { + isTypingRef.current = isTyping; + }, + } ); + + const { getReferenceProps, getFloatingProps, getItemProps } = + useInteractions( [ + dismiss, + role, + listNav, + click, + ...( ! combobox && ! inlineSearch ? [ typeahead ] : [] ), + ] ); + + const handleMultiSelect: OnClick = ( index, newValue ) => { + const selectedValues = [ + ...( ( getValues() as SelectOptionValue[] ) ?? [] ), + ]; + const valueIndex = selectedValues.findIndex( ( selectedValue ) => { + if ( + selectedValue !== null && + newValue !== null && + typeof selectedValue === 'object' + ) { + return ( + ( selectedValue as Record )[ by ] === + ( newValue as Record )[ by ] + ); + } + return selectedValue === newValue; + } ); + + if ( valueIndex !== -1 ) { + return; + } + selectedValues.push( newValue ); + + if ( ! isControlled ) { + setSelected( selectedValues ); + } + setSelectedIndex( index ); + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + setIsOpen( false ); + setSearchKeyword( '' ); + if ( typeof onChange === 'function' ) { + onChange( selectedValues ); + } + }; + + const handleSelect: OnClick = ( index, newValue ) => { + if ( multiple ) { + return handleMultiSelect( index, newValue ); + } + setSelectedIndex( index ); + if ( ! isControlled ) { + setSelected( newValue ); + } + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + setIsOpen( false ); + setSearchKeyword( '' ); + if ( typeof onChange === 'function' ) { + onChange( newValue ); + } + }; + // Dropdown position related code (End) + + const updateListRef = useCallback( ( index: number, node: HTMLElement ) => { + listRef.current[ index ] = node; + }, [] ); + + const onClickItem: OnClick = ( index, newValue ) => { + handleSelect( index, newValue ); + }; + + const onKeyDownItem: OnKeyDown = ( event, index, newValue ) => { + if ( event.key === 'Enter' ) { + event.preventDefault(); + handleSelect( index, newValue ); + } + + if ( event.key === ' ' && ! isTypingRef.current ) { + event.preventDefault(); + handleSelect( index, newValue ); + } + }; + + return ( + + { children } + + ); +}; + +SelectComponent.displayName = 'Select'; + +const Select = Object.assign( SelectComponent, { + Portal: SelectPortal, + Button: SelectButton, + Options: SelectOptions, + Option: SelectItem, + OptionGroup: SelectOptionGroup, +} ); + +SelectPortal.displayName = 'Select.Portal'; +SelectButton.displayName = 'Select.Button'; +SelectOptions.displayName = 'Select.Options'; +SelectItem.displayName = 'Select.Option'; +SelectOptionGroup.displayName = 'Select.OptionGroup'; + +export default Select; diff --git a/src/components/textarea/textarea.stories.tsx b/src/components/textarea/textarea.stories.tsx index 8e55b822..4c2de4a1 100644 --- a/src/components/textarea/textarea.stories.tsx +++ b/src/components/textarea/textarea.stories.tsx @@ -1,62 +1,62 @@ -import type { Meta, StoryFn } from '@storybook/react-vite'; -import TextArea, { TextAreaProps } from './textarea'; - -const meta: Meta = { - title: 'Atoms/TextArea', - component: TextArea, - parameters: { - layout: 'centered', - }, - tags: [ 'autodocs' ], - argTypes: { - size: { - control: 'select', - }, - }, -}; - -export default meta; - -const Template: StoryFn = ( args ) => { - return ( - <> -