diff --git a/package-lock.json b/package-lock.json index 62d9b904..00317149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@abelflopes/react-ck", - "version": "4.19.0", + "version": "4.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abelflopes/react-ck", - "version": "4.19.0", + "version": "4.20.0", "workspaces": [ "packages/*/*" ], diff --git a/packages/components/base/src/boolean-input/BooleaInput.tsx b/packages/components/base/src/boolean-input/BooleaInput.tsx index 6f537efa..df1228cd 100644 --- a/packages/components/base/src/boolean-input/BooleaInput.tsx +++ b/packages/components/base/src/boolean-input/BooleaInput.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef } from "react"; import classNames from "classnames"; import { type FormFieldContextProps, useFormFieldContext } from "../form-field"; import { type BooleanInputIconComponent } from "./types"; -import { megeRefs } from "@react-ck/react-utils"; +import { mergeRefs } from "@react-ck/react-utils"; /** * Props interface for the BooleanInput component. @@ -58,7 +58,7 @@ export const BooleaInput = ({ (disabled || formFieldContext?.disabled) && styles.disabled, )}>
{ - megeRefs(ref, containerRef)(el); + mergeRefs(ref, containerRef)(el); setFocusWrapperElement(el || undefined); }} tabIndex={0} diff --git a/packages/components/base/src/file-uploader/FileUploader.tsx b/packages/components/base/src/file-uploader/FileUploader.tsx index 40715c71..83e5cbce 100644 --- a/packages/components/base/src/file-uploader/FileUploader.tsx +++ b/packages/components/base/src/file-uploader/FileUploader.tsx @@ -4,7 +4,7 @@ import classNames from "classnames"; import { Text } from "../text"; import { Button, type ButtonProps } from "../button"; import { readFileList } from "./utils/read-file"; -import { megeRefs } from "@react-ck/react-utils"; +import { mergeRefs } from "@react-ck/react-utils"; // TODO: check https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file // TODO: add size limitation: https://stackoverflow.com/questions/5697605/limit-the-size-of-a-file-upload-html-input-element @@ -106,7 +106,7 @@ export const FileUploader = ({ )}> + * + * Apple + * Banana + * + * + * Carrot + * Broccoli + * + * + * ``` + * + * @param props - Component props {@link SelectGroupProps} + * @returns React element + */ +const SelectGroup = ({ + name, + children, +}: Readonly>): React.ReactElement => { + const { generateUniqueId } = useManagerContext(); + + const uniqueId = generateUniqueId(); + + return ( +
+
+ + {name} + +
+ {children} +
+ ); +}; + +SelectGroup[DISPLAY_NAME_ATTRIBUTE] = "SelectGroup"; + +export { SelectGroup }; diff --git a/packages/components/base/src/select/index.tsx b/packages/components/base/src/select/index.tsx index be8422b2..a14b2d5f 100644 --- a/packages/components/base/src/select/index.tsx +++ b/packages/components/base/src/select/index.tsx @@ -1,16 +1,20 @@ import styles from "./styles/index.module.scss"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SelectOption } from "./SelectOption"; +import { SelectGroup } from "./SelectGroup"; import { Menu } from "../menu"; import { Dropdown } from "../dropdown"; import { Input } from "../input"; import classNames from "classnames"; -import { megeRefs, raf } from "@react-ck/react-utils"; +import { mergeRefs, raf } from "@react-ck/react-utils"; import { EmptyState } from "../empty-state"; import { getChildrenData, simplifyString, valueAsArray } from "./utils"; import { type SelectProps, type ChangeHandler } from "./types"; import { SelectContext, type SelectContextProps } from "./context"; import { useFormFieldContext } from "../form-field"; +import { Spinner } from "../spinner"; +import { Icon } from "@react-ck/icon"; +import { IconChevronDown } from "@react-ck/icon/icons/IconChevronDown"; /** Default positions to exclude from auto-positioning */ const defaultExclude: SelectProps["excludeAutoPosition"] = [ @@ -75,6 +79,7 @@ const Select = ({ allowDeselect = true, required, disabled, + loading, displayValueDivider = ",", fullWidth, position, @@ -105,17 +110,47 @@ const Select = ({ const childrenData = useMemo(() => getChildrenData(children), [children]); /** Options list children filtered by user search */ - const filteredOptions = useMemo( - () => - childrenData - .filter( - (i) => - !search.length || - (i.textContent && simplifyString(i.textContent).includes(simplifyString(search))), - ) - .map((i) => i.element), - [childrenData, search], - ); + const filteredOptions = useMemo(() => { + const filtered = childrenData.filter( + (i) => + !search.length || + (i.textContent && simplifyString(i.textContent).includes(simplifyString(search))), + ); + + // Group options by groupName + const groupedOptions: { [key: string]: React.ReactNode[] } = {}; + const ungroupedOptions: React.ReactNode[] = []; + + filtered.forEach((item) => { + if (item.isSelectOption) { + if (item.groupName) { + if (!(item.groupName in groupedOptions)) { + groupedOptions[item.groupName] = []; + } + groupedOptions[item.groupName]?.push(item.element); + } else { + ungroupedOptions.push(item.element); + } + } + }); + + // Render grouped options + const result: React.ReactNode[] = []; + + // Add ungrouped options first + ungroupedOptions.forEach((option) => result.push(option)); + + // Add grouped options using SelectGroup component + Object.entries(groupedOptions).forEach(([groupName, options]) => { + result.push( + + {options} + , + ); + }); + + return result; + }, [childrenData, search]); /** Returns the internal value always as an array to facilitate operations */ const selectedValuesList = useMemo( @@ -256,7 +291,9 @@ const Select = ({ const resizeObserver = new ResizeObserver(() => { if (!valueSlotRefCurrent || !sizeSetterRef.current) return; - valueSlotRefCurrent.style.width = `${sizeSetterRef.current.clientWidth + 10}px`; + if (valueSlotRefCurrent.clientWidth < sizeSetterRef.current.clientWidth) { + valueSlotRefCurrent.style.width = `${sizeSetterRef.current.clientWidth + 10}px`; + } }); resizeObserver.observe(sizeSetterRef.current); @@ -278,7 +315,7 @@ const Select = ({ styles[`skin_${computedSkin}`], formFieldContext === undefined && styles.standalone, (disabled || formFieldContext?.disabled) && styles.disabled, - + loading && styles.loading, (fullWidth ?? formFieldContext?.fullWidth) && styles.full_width, className, )} @@ -291,11 +328,17 @@ const Select = ({ onBlur?.(e); }}>
- {displayValue || {placeholder}} +
+ {displayValue || {placeholder}} +
+ {loading && } + + +
{ onDeselected?: (value: string) => void; } +/** + * Props interface for Select group components. + * Used to group related options together with a label. + */ +export interface SelectGroupProps { + /** Name/label for the group */ + name: string; + /** Wrapper for select options */ + children?: React.ReactNode; +} + /** * Internal type for processing select children data. * Used to normalize and handle different types of child elements. @@ -109,6 +125,8 @@ export type SelectChildrenData = { isSelectOption: boolean; /** Props passed to the SelectOption if applicable */ selectOptionProps: SelectOptionProps | undefined; + /** Props passed to the SelectGroup if applicable */ + selectGroupProps: SelectGroupProps | undefined; /** Processed value for the option */ computedValue: string | undefined; /** Text content of the option */ @@ -117,4 +135,6 @@ export type SelectChildrenData = { element: React.ReactNode; /** Custom display value for selected state */ displayValue: SelectOptionProps["displayValue"]; + /** Group name if this is part of a group */ + groupName?: string; }; diff --git a/packages/components/base/src/select/utils.ts b/packages/components/base/src/select/utils.ts index 1df74c25..2efc1691 100644 --- a/packages/components/base/src/select/utils.ts +++ b/packages/components/base/src/select/utils.ts @@ -7,6 +7,7 @@ import { import { type SelectChildrenData, type SelectOptionProps, + type SelectGroupProps, type SelectedValues, type UserValue, } from "./types"; @@ -14,35 +15,53 @@ import { export const valueAsArray = (value: UserValue): SelectedValues => value ? (Array.isArray(value) ? value : [value]) : []; -export const getChildrenData = (children: React.ReactNode): SelectChildrenData[] => - getChildrenListWithoutFragments(children).map((i) => { - if (getDisplayName(i) === "SelectOption" && React.isValidElement(i)) { - if (!("children" in i.props || "value" in i.props)) - throw new Error("SelectOption has no computable value"); - - const { value, displayValue } = i.props; - const textContent = componentToText(i); - const computedValue = value ?? textContent; - - return { - isSelectOption: true, - element: i, - selectOptionProps: i.props, - textContent: textContent ?? value, - computedValue, - displayValue, - }; - } - - return { - isSelectOption: false, - element: i, - textContent: undefined, - computedValue: undefined, - selectOptionProps: undefined, - displayValue: undefined, - }; - }); +export const getChildrenData = (children: React.ReactNode): SelectChildrenData[] => { + const result: SelectChildrenData[] = []; + + const processChildren = (children: React.ReactNode, groupName?: string) => { + getChildrenListWithoutFragments(children).forEach((i) => { + if (getDisplayName(i) === "SelectOption" && React.isValidElement(i)) { + if (!("children" in i.props || "value" in i.props)) + throw new Error("SelectOption has no computable value"); + + const { value, displayValue, disabled } = i.props; + const textContent = componentToText(i); + const computedValue = value ?? textContent; + + result.push({ + isSelectOption: true, + element: i, + selectOptionProps: { + ...i.props, + disabled, + }, + selectGroupProps: undefined, + textContent: textContent ?? value, + computedValue, + displayValue, + groupName, + }); + } else if (getDisplayName(i) === "SelectGroup" && React.isValidElement(i)) { + const { name, children: groupChildren } = i.props; + processChildren(groupChildren, name); + } else { + result.push({ + isSelectOption: false, + element: i, + textContent: undefined, + computedValue: undefined, + selectOptionProps: undefined, + selectGroupProps: undefined, + displayValue: undefined, + groupName, + }); + } + }); + }; + + processChildren(children); + return result; +}; export const simplifyString = (s: string): string => { let r = s.toLowerCase().trim(); diff --git a/packages/docs/stories/src/form-select.stories.tsx b/packages/docs/stories/src/form-select.stories.tsx index e542265a..d20e0768 100644 --- a/packages/docs/stories/src/form-select.stories.tsx +++ b/packages/docs/stories/src/form-select.stories.tsx @@ -103,3 +103,60 @@ export const FullWidth: Story = { fullWidth: true, }, }; + +export const Loading: Story = { + args: { + ...args, + loading: true, + fullWidth: true, + }, +}; + +export const WithGroups: Story = { + args: { + placeholder: "Select a food item", + children: ( + <> + + Apple + Banana + Orange + Grape + + + Carrot + Broccoli + Spinach + Tomato + + + Milk + Cheese + Yogurt + + + ), + }, +}; + +export const WithGroupsAndSearch: Story = { + args: { + ...WithGroups.args, + search: { + placeholder: "Search food items", + emptyStateMessage: (value) => ( + <> + No food items found for "{value}" + + ), + }, + }, +}; + +export const WithGroupsMultiple: Story = { + args: { + ...WithGroupsAndSearch.args, + multiple: true, + placeholder: "Select multiple food items", + }, +}; diff --git a/packages/utils/react/src/index.ts b/packages/utils/react/src/index.ts index 25ba9f79..55296be7 100644 --- a/packages/utils/react/src/index.ts +++ b/packages/utils/react/src/index.ts @@ -8,7 +8,7 @@ export * from "./click-outside"; export * from "./children-without-fragments"; -export { mergeRefs, mergeRefs as megeRefs } from "./merge-refs"; +export * from "./merge-refs"; export * from "./raf";