diff --git a/.changeset/field-props.md b/.changeset/field-props.md new file mode 100644 index 000000000..f8a646dcc --- /dev/null +++ b/.changeset/field-props.md @@ -0,0 +1,8 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Added unified support for `fieldProps`, `fieldStyles`, `labelProps`, and `labelStyles` across all field components. The `fieldStyles` and `labelStyles` props serve as shorthands for `fieldProps.styles` and `labelProps.styles` respectively, with shorthand props taking priority. All merging logic is centralized in the `wrapWithField` helper. + +**Breaking changes:** +- Removed `wrapperStyles` prop from TextInputBase and Select components (use `styles` prop instead for the root element). diff --git a/.changeset/on-open-change.md b/.changeset/on-open-change.md new file mode 100644 index 000000000..05702bdb9 --- /dev/null +++ b/.changeset/on-open-change.md @@ -0,0 +1,6 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Added `onOpenChange` callback prop to Picker, FilterPicker, ComboBox, and Select components. This callback is invoked when the popover/overlay open state changes, receiving a boolean parameter indicating the new open state. + diff --git a/src/components/fields/Checkbox/Checkbox.tsx b/src/components/fields/Checkbox/Checkbox.tsx index e531d5bac..3fcc00af5 100644 --- a/src/components/fields/Checkbox/Checkbox.tsx +++ b/src/components/fields/Checkbox/Checkbox.tsx @@ -252,9 +252,7 @@ function Checkbox( return wrapWithField(checkboxField, domRef, { ...props, children: null, - labelStyles, inputStyles, - styles, }); } diff --git a/src/components/fields/Checkbox/CheckboxGroup.tsx b/src/components/fields/Checkbox/CheckboxGroup.tsx index 493fcace9..2d670fa7a 100644 --- a/src/components/fields/Checkbox/CheckboxGroup.tsx +++ b/src/components/fields/Checkbox/CheckboxGroup.tsx @@ -119,7 +119,6 @@ function CheckboxGroup(props: WithNullableValue, ref) { children: null, fieldProps: groupProps, labelProps: mergeProps(baseLabelProps, labelProps), - styles, }); } diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 675e5f5ef..ca5d4adda 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -207,8 +207,6 @@ export interface CubeComboBoxProps inputStyles?: Styles; /** Custom styles for the trigger button */ triggerStyles?: Styles; - /** Custom styles for the field wrapper */ - fieldStyles?: Styles; /** Custom styles for the listbox */ listBoxStyles?: Styles; /** Custom styles for the popover overlay */ @@ -247,6 +245,8 @@ export interface CubeComboBoxProps * @default true when items are provided, false when using JSX children */ sortSelectedToTop?: boolean; + /** Callback called when the popover open state changes */ + onOpenChange?: (isOpen: boolean) => void; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -1005,7 +1005,6 @@ export const ComboBox = forwardRef(function ComboBox( triggerStyles, listBoxStyles, overlayStyles, - fieldStyles, suffix, hideTrigger, message, @@ -1038,6 +1037,7 @@ export const ComboBox = forwardRef(function ComboBox( containerPadding = 8, onSelectionChange: externalOnSelectionChange, sortSelectedToTop: sortSelectedToTopProp, + onOpenChange, onFocus, onBlur, onKeyDown, @@ -1182,6 +1182,11 @@ export const ComboBox = forwardRef(function ComboBox( } }, [isPopoverOpen, effectiveSelectedKey]); + // Call onOpenChange when popover state changes + useEffect(() => { + onOpenChange?.(isPopoverOpen); + }, [isPopoverOpen]); + // Filtering hook const { filterFn, isFilterActive, setIsFilterActive } = useComboBoxFiltering({ effectiveInputValue, @@ -1852,15 +1857,11 @@ export const ComboBox = forwardRef(function ComboBox( ); const { children: _, ...propsWithoutChildren } = props; - const finalProps = { - ...propsWithoutChildren, - styles: fieldStyles, - }; return wrapWithField, 'children'>>( comboBoxField, ref, - finalProps, + propsWithoutChildren, ); }) as unknown as (( props: CubeComboBoxProps & { ref?: ForwardedRef }, diff --git a/src/components/fields/DatePicker/DateInput.tsx b/src/components/fields/DatePicker/DateInput.tsx index 5e88a3557..a079d5da7 100644 --- a/src/components/fields/DatePicker/DateInput.tsx +++ b/src/components/fields/DatePicker/DateInput.tsx @@ -112,7 +112,6 @@ function DateInput( return wrapWithField(component, domRef, { ...props, - styles, labelProps: mergeProps(props.labelProps, labelProps), }); } diff --git a/src/components/fields/DatePicker/DatePicker.tsx b/src/components/fields/DatePicker/DatePicker.tsx index 883ca0a02..6a4088b04 100644 --- a/src/components/fields/DatePicker/DatePicker.tsx +++ b/src/components/fields/DatePicker/DatePicker.tsx @@ -171,7 +171,6 @@ function DatePicker( return wrapWithField(component, domRef, { ...props, - styles, labelProps: mergeProps(props.labelProps, labelProps), }); } diff --git a/src/components/fields/DatePicker/DateRangePicker.tsx b/src/components/fields/DatePicker/DateRangePicker.tsx index 7787adaf6..b30c17ddb 100644 --- a/src/components/fields/DatePicker/DateRangePicker.tsx +++ b/src/components/fields/DatePicker/DateRangePicker.tsx @@ -199,7 +199,6 @@ function DateRangePicker( return wrapWithField(component, domRef, { ...props, - styles, labelProps: mergeProps(props.labelProps, labelProps), }); } diff --git a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx index cd087f4ee..6881ca31c 100644 --- a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx +++ b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx @@ -309,7 +309,6 @@ function DateRangeSeparatedPicker( return wrapWithField(component, domRef, { ...props, - styles, labelProps: mergeProps(props.labelProps, labelProps), }); } diff --git a/src/components/fields/DatePicker/TimeInput.tsx b/src/components/fields/DatePicker/TimeInput.tsx index 9fbd29392..db50d7621 100644 --- a/src/components/fields/DatePicker/TimeInput.tsx +++ b/src/components/fields/DatePicker/TimeInput.tsx @@ -121,7 +121,6 @@ function TimeInput( return wrapWithField(timeInput, domRef, { ...props, - styles, labelProps: mergeProps(labelProps, userLabelProps), }); } diff --git a/src/components/fields/FileInput/FileInput.tsx b/src/components/fields/FileInput/FileInput.tsx index b15c4f084..6fd509d45 100644 --- a/src/components/fields/FileInput/FileInput.tsx +++ b/src/components/fields/FileInput/FileInput.tsx @@ -272,7 +272,6 @@ function FileInput(props: CubeFileInputProps, ref) { return wrapWithField(fileInput, domRef, { ...props, - styles, }); } diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index d4debc799..dfc2e7569 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -953,12 +953,10 @@ export const FilterListBox = forwardRef(function FilterListBox< ); - const finalProps = { ...props, styles: undefined }; - return wrapWithField, 'children'>>( filterListBoxField, ref, - finalProps, + props, ); }) as unknown as (( props: CubeFilterListBoxProps & { ref?: ForwardedRef }, diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 0ee63d9d8..e495b89ec 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -128,6 +128,8 @@ export interface CubeFilterPickerProps * @default true when items are provided, false when using JSX children */ sortSelectedToTop?: boolean; + /** Callback called when the popover open state changes */ + onOpenChange?: (isOpen: boolean) => void; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -252,6 +254,7 @@ export const FilterPicker = forwardRef(function FilterPicker( searchValue, onSearchChange, sortSelectedToTop: sortSelectedToTopProp, + onOpenChange, isButton = false, form, ...otherProps @@ -665,8 +668,9 @@ export const FilterPicker = forwardRef(function FilterPicker( selectionsWhenClosed.current = { ...latestSelectionRef.current }; cachedItemsOrder.current = null; } + onOpenChange?.(state.isOpen); } - }, [state.isOpen, isPopoverOpen]); + }, [state.isOpen, isPopoverOpen, onOpenChange]); // Add keyboard support for arrow keys to open the popover const { keyboardProps } = useKeyboard({ @@ -951,15 +955,10 @@ export const FilterPicker = forwardRef(function FilterPicker( ); - const finalProps = { - ...props, - styles: undefined, - }; - return wrapWithField, 'children' | 'tooltip'>>( filterPickerField, ref as any, - finalProps, + props, ); }) as unknown as (( props: CubeFilterPickerProps & { ref?: ForwardedRef }, diff --git a/src/components/fields/ListBox/ListBox.tsx b/src/components/fields/ListBox/ListBox.tsx index e92a63b3f..cc54b0c69 100644 --- a/src/components/fields/ListBox/ListBox.tsx +++ b/src/components/fields/ListBox/ListBox.tsx @@ -1029,12 +1029,10 @@ export const ListBox = forwardRef(function ListBox( ); - const finalProps = { ...props, styles: undefined }; - return wrapWithField, 'children'>>( listBoxField, ref, - finalProps, + props, ); }) as unknown as (( props: CubeListBoxProps & { ref?: ForwardedRef }, diff --git a/src/components/fields/Picker/Picker.tsx b/src/components/fields/Picker/Picker.tsx index f4c52c11f..a64b9ad3e 100644 --- a/src/components/fields/Picker/Picker.tsx +++ b/src/components/fields/Picker/Picker.tsx @@ -32,7 +32,6 @@ import { tasty, } from '../../../tasty'; import { generateRandomId } from '../../../utils/random'; -import { mergeProps } from '../../../utils/react'; import { useEventBus } from '../../../utils/react/useEventBus'; import { CubeItemButtonProps, ItemAction, ItemButton } from '../../actions'; import { CubeItemProps } from '../../content/Item'; @@ -115,6 +114,8 @@ export interface CubePickerProps * @default true when items are provided, false when using JSX children */ sortSelectedToTop?: boolean; + /** Callback called when the popover open state changes */ + onOpenChange?: (isOpen: boolean) => void; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -229,6 +230,7 @@ export const Picker = forwardRef(function Picker( isClearable, onClear, sortSelectedToTop, + onOpenChange, isButton = false, listStateRef: externalListStateRef, ...otherProps @@ -333,6 +335,11 @@ export const Picker = forwardRef(function Picker( selectionMode, ]); + // Call onOpenChange when popover state changes + useEffect(() => { + onOpenChange?.(isPopoverOpen); + }, [isPopoverOpen]); + // Sort items with selected on top if enabled const getSortedItems = useCallback((): typeof items => { if (!items || !shouldSortSelectedToTop) return items; @@ -764,10 +771,7 @@ export const Picker = forwardRef(function Picker( return wrapWithField, 'children' | 'tooltip'>>( pickerField, ref as any, - { - ...props, - styles: undefined, - }, + props, ); }) as unknown as (( props: CubePickerProps & { ref?: ForwardedRef }, diff --git a/src/components/fields/README.md b/src/components/fields/README.md new file mode 100644 index 000000000..06a5704e7 --- /dev/null +++ b/src/components/fields/README.md @@ -0,0 +1,38 @@ +# Field Components + +Form field components with built-in validation, accessibility, and React Aria integration. + +## Text Input Fields +- **TextInput** - Basic text input +- **PasswordInput** - Password input with visibility toggle +- **SearchInput** - Search input with clear button +- **TextArea** - Multi-line text input +- **NumberInput** - Numeric input with step controls +- **Input** - Low-level input component + +## Selection Fields +- **Select** - Single selection dropdown +- **Picker** - Single/multi selection with search and customization +- **ComboBox** - Autocomplete text input with suggestions +- **ListBox** - Multi-select list with keyboard navigation +- **FilterListBox** - Multi-select with filtering and grouping +- **FilterPicker** - Dropdown multi-select with filtering + +## Choice Fields +- **Checkbox** / **CheckboxGroup** - Boolean or multi-choice selection +- **RadioGroup** / **Radio** - Single choice from multiple options +- **Switch** - Boolean toggle + +## Range & Date Fields +- **Slider** / **RangeSlider** - Single or range value selection +- **DatePicker** - Single date selection +- **DateRangePicker** - Date range selection +- **DateRangeSeparatedPicker** - Separate start/end date inputs +- **DateInput** / **TimeInput** - Manual date/time entry + +## Other +- **FileInput** - File upload with drag & drop +- **TextInputMapper** - Dynamic input type mapping + +All fields support form integration, validation rules, error states, and accessibility features. + diff --git a/src/components/fields/RadioGroup/RadioGroup.tsx b/src/components/fields/RadioGroup/RadioGroup.tsx index 56555c538..403abdb5f 100644 --- a/src/components/fields/RadioGroup/RadioGroup.tsx +++ b/src/components/fields/RadioGroup/RadioGroup.tsx @@ -163,7 +163,6 @@ function RadioGroup(props: WithNullableValue, ref) { children: null, fieldProps, labelProps: mergeProps(baseLabelProps, labelProps), - styles: props.fieldStyles, }); } diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index 792dba43d..a8b488741 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -198,10 +198,6 @@ export interface CubeSelectBaseProps triggerStyles?: Styles; listBoxStyles?: Styles; overlayStyles?: Styles; - /** - * @deprecated Use `styles` instead - */ - wrapperStyles?: Styles; direction?: 'top' | 'bottom'; shouldFlip?: boolean; /** Minimum padding in pixels between the popover and viewport edges */ @@ -218,6 +214,8 @@ export interface CubeSelectBaseProps * @default false */ isButton?: boolean; + /** Callback called when the popover open state changes */ + onOpenChange?: (isOpen: boolean) => void; } export interface CubeSelectProps extends CubeSelectBaseProps { @@ -267,7 +265,6 @@ function Select( inputStyles, triggerStyles, optionStyles, - wrapperStyles, listBoxStyles, overlayStyles, suffix, @@ -287,6 +284,7 @@ function Select( labelSuffix, suffixPosition = 'before', isClearable, + onOpenChange, isButton = false, form, ...otherProps @@ -318,6 +316,11 @@ function Select( } }, [state.isOpen, emit, selectId]); + // Call onOpenChange when open state changes + useEffect(() => { + onOpenChange?.(state.isOpen); + }, [state.isOpen]); + styles = extractStyles(otherProps, PROP_STYLES, styles); ref = useCombinedRefs(ref); @@ -416,7 +419,7 @@ function Select( let selectField = ( ( mergeProps( { ...props, - styles: labelStyles, }, { labelProps }, ), diff --git a/src/components/fields/Slider/Slider.stories.tsx b/src/components/fields/Slider/Slider.stories.tsx index 2d4e5f9ae..c22e74613 100644 --- a/src/components/fields/Slider/Slider.stories.tsx +++ b/src/components/fields/Slider/Slider.stories.tsx @@ -179,7 +179,9 @@ export default { minValue: 0, maxValue: 20, step: 2, - width: '200px', + fieldStyles: { + width: '200px', + }, }, } as Meta; diff --git a/src/components/fields/Slider/SliderBase.tsx b/src/components/fields/Slider/SliderBase.tsx index 3f25ab6de..32df29447 100644 --- a/src/components/fields/Slider/SliderBase.tsx +++ b/src/components/fields/Slider/SliderBase.tsx @@ -223,7 +223,6 @@ function SliderBase(allProps: SliderBaseProps, ref: DOMRef) { return wrapWithField(sliderField, ref, { ...props, - // styles, extra, labelProps: mergeProps(labelProps, userLabelProps), }); diff --git a/src/components/fields/Switch/Switch.tsx b/src/components/fields/Switch/Switch.tsx index cab989a61..340adb319 100644 --- a/src/components/fields/Switch/Switch.tsx +++ b/src/components/fields/Switch/Switch.tsx @@ -128,7 +128,6 @@ export interface CubeSwitchProps FieldBaseProps, AriaSwitchProps { inputStyles?: Styles; - fieldStyles?: Styles; isLoading?: boolean; size?: 'large' | 'medium' | 'small'; } @@ -156,7 +155,6 @@ function Switch(props: WithNullableSelected, ref) { isLoading, labelPosition, inputStyles, - fieldStyles, validationState, size = 'medium', form, @@ -223,9 +221,7 @@ function Switch(props: WithNullableSelected, ref) { for: id, }, children: null, - labelStyles, inputStyles, - styles: fieldStyles, }); } diff --git a/src/components/fields/TextInput/TextInputBase.tsx b/src/components/fields/TextInput/TextInputBase.tsx index f76712ab7..4db5146cc 100644 --- a/src/components/fields/TextInput/TextInputBase.tsx +++ b/src/components/fields/TextInput/TextInputBase.tsx @@ -18,11 +18,9 @@ import { BaseProps, BLOCK_STYLES, BlockStyleProps, - DIMENSION_STYLES, - DimensionStyleProps, extractStyles, - POSITION_STYLES, - PositionStyleProps, + OUTER_STYLES, + OuterStyleProps, Props, Styles, tasty, @@ -134,8 +132,6 @@ const InputWrapperElement = tasty({ styles: INPUT_WRAPPER_STYLES, }); -const STYLE_LIST = [...POSITION_STYLES, ...DIMENSION_STYLES]; - const INPUT_STYLE_PROPS_LIST = [...BLOCK_STYLES, 'resize']; export const DEFAULT_INPUT_STYLES: Styles = { @@ -183,8 +179,7 @@ const InputElement = tasty({ qa: 'Input', styles: DEFAULT_INPUT_STYLES }); export interface CubeTextInputBaseProps extends BaseProps, - PositionStyleProps, - DimensionStyleProps, + OuterStyleProps, BlockStyleProps, Omit, FieldBaseProps { @@ -215,8 +210,6 @@ export interface CubeTextInputBaseProps loadingIndicator?: ReactNode; /** Style map for the input */ inputStyles?: Styles; - /** Style map for the input wrapper */ - wrapperStyles?: Styles; /** The number of rows for the input. Only applies to textarea. */ rows?: number; /** The resize CSS property sets whether an element is resizable, and if so, in which directions. */ @@ -255,7 +248,6 @@ function _TextInputBase(props: CubeTextInputBaseProps, ref) { loadingIndicator, value, inputStyles = {}, - wrapperStyles = {}, suffix, suffixPosition = 'before', wrapperRef, @@ -270,7 +262,7 @@ function _TextInputBase(props: CubeTextInputBaseProps, ref) { ...otherProps } = props; - let styles = extractStyles(otherProps, STYLE_LIST); + let styles = extractStyles(otherProps, OUTER_STYLES); let type = otherProps.type; inputStyles = extractStyles(otherProps, INPUT_STYLE_PROPS_LIST, inputStyles); @@ -363,7 +355,7 @@ function _TextInputBase(props: CubeTextInputBaseProps, ref) { ref={wrapperRef} mods={modifiers} data-size={size} - styles={wrapperStyles} + styles={styles} {...wrapperProps} > {prefix ?
{prefix}
: null} @@ -403,7 +395,6 @@ function _TextInputBase(props: CubeTextInputBaseProps, ref) { return wrapWithField(textField, domRef, { ...props, form: undefined, - styles, }); } diff --git a/src/components/form/FieldWrapper/FieldWrapper.tsx b/src/components/form/FieldWrapper/FieldWrapper.tsx index af234d2fe..779d27383 100644 --- a/src/components/form/FieldWrapper/FieldWrapper.tsx +++ b/src/components/form/FieldWrapper/FieldWrapper.tsx @@ -2,7 +2,7 @@ import { forwardRef } from 'react'; import { InfoCircleIcon } from '../../../icons/index'; import { tasty } from '../../../tasty/index'; -import { wrapNodeIfPlain } from '../../../utils/react/index'; +import { mergeProps, wrapNodeIfPlain } from '../../../utils/react/index'; import { Text } from '../../content/Text'; import { Flex } from '../../layout/Flex'; import { Space } from '../../layout/Space'; @@ -188,6 +188,11 @@ export const FieldWrapper = forwardRef(function FieldWrapper( const displayMessage = errorMessage || message; const isErrorMessage = !!errorMessage; + // Merge fieldProps with styles to ensure both are applied + const mergedFieldProps = styles + ? mergeProps(fieldProps, { styles }) + : fieldProps; + return ( <> {labelComponent || descriptionForLabel ? (
diff --git a/src/components/form/wrapper.tsx b/src/components/form/wrapper.tsx index 533c64447..bfb557925 100644 --- a/src/components/form/wrapper.tsx +++ b/src/components/form/wrapper.tsx @@ -1,14 +1,13 @@ import { DOMRef, FocusableRef } from '@react-types/shared'; -import { ReactElement, ReactNode, RefObject } from 'react'; +import { ReactElement, RefObject } from 'react'; import { FieldBaseProps, FormBaseProps } from '../../shared/index'; -import { BaseProps, Styles } from '../../tasty/index'; +import { BaseProps } from '../../tasty/index'; +import { mergeProps } from '../../utils/react/index'; import { FieldWrapper } from './FieldWrapper/index'; -interface WrapWithFieldProps extends FieldBaseProps, BaseProps, FormBaseProps { - styles?: Styles; -} +interface WrapWithFieldProps extends FieldBaseProps, BaseProps, FormBaseProps {} export function wrapWithField( component: ReactElement, @@ -31,20 +30,29 @@ export function wrapWithField( validationState, labelProps, fieldProps, + fieldStyles, requiredMark = true, tooltip, isHidden, labelSuffix, - styles, - children, } = props; if (!label && !forceField) { return component; } + // Merge fieldStyles as shorthand for fieldProps.styles (fieldStyles takes priority) + const mergedFieldProps = fieldStyles + ? mergeProps(fieldProps, { styles: fieldStyles }) + : fieldProps; + + // Merge labelStyles as shorthand for labelProps.styles (labelStyles takes priority) + const mergedLabelProps = labelStyles + ? mergeProps(labelProps, { styles: labelStyles }) + : labelProps; + // Remove id from fieldProps to avoid duplication (id should be on the input element, not the field wrapper) - const { id: _, ...fieldPropsWithoutId } = fieldProps || {}; + const { id: _id, ...fieldPropsWithoutId } = (mergedFieldProps as any) || {}; return ( ( label, extra, labelPosition, - labelStyles, isRequired, isDisabled, necessityIndicator, - labelProps, + labelProps: mergedLabelProps, fieldProps: fieldPropsWithoutId, message, messageStyles, @@ -67,8 +74,6 @@ export function wrapWithField( tooltip, isHidden, labelSuffix, - styles, - children, Component: component, ref, }} diff --git a/src/shared/form.ts b/src/shared/form.ts index cfdc534c3..4bb0bb583 100644 --- a/src/shared/form.ts +++ b/src/shared/form.ts @@ -77,6 +77,7 @@ export interface FieldBaseProps extends FormBaseProps, FieldCoreProps { /** Whether the field is inside the form. Private field. */ insideForm?: boolean; fieldProps?: Props; + fieldStyles?: Styles; messageStyles?: Styles; /** If true, the input component will be wrapped in a field wrapper even if it doesn't have a label. */ forceField?: boolean;