diff --git a/.changeset/empty-beans-smell.md b/.changeset/empty-beans-smell.md new file mode 100644 index 000000000..ac8646c7d --- /dev/null +++ b/.changeset/empty-beans-smell.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add Picker component as a more advanced version of Select. diff --git a/src/components/fields/ComboBox/ComboBox.stories.tsx b/src/components/fields/ComboBox/ComboBox.stories.tsx index 58edc8f36..82213fc95 100644 --- a/src/components/fields/ComboBox/ComboBox.stories.tsx +++ b/src/components/fields/ComboBox/ComboBox.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { userEvent, within } from 'storybook/test'; import { baseProps } from '../../../stories/lists/baseProps'; @@ -772,13 +772,13 @@ export const ShowAllOnNoResults: StoryObj = { }; export const VirtualizedList = () => { + const [selected, setSelected] = useState(null); + interface Item { key: string; name: string; } - const [selected, setSelected] = useState(null); - // Generate a large list of items with varying content to test virtualization const items: Item[] = Array.from({ length: 1000 }, (_, i) => ({ key: `item-${i}`, diff --git a/src/components/fields/ComboBox/ComboBox.test.tsx b/src/components/fields/ComboBox/ComboBox.test.tsx index 1d9e5ed43..dfa26fca2 100644 --- a/src/components/fields/ComboBox/ComboBox.test.tsx +++ b/src/components/fields/ComboBox/ComboBox.test.tsx @@ -463,7 +463,7 @@ describe('', () => { }); }); - it('should clear selection on blur when clearOnBlur is true', async () => { + it('should clear invalid input on blur when clearOnBlur is true', async () => { const onSelectionChange = jest.fn(); const { getByRole, getAllByRole, queryByRole } = renderWithRoot( @@ -493,12 +493,18 @@ describe('', () => { expect(combobox).toHaveValue('Red'); }); + onSelectionChange.mockClear(); + + // Type invalid text to make input invalid + await userEvent.clear(combobox); + await userEvent.type(combobox, 'xyz'); + // Blur the input await act(async () => { combobox.blur(); }); - // Should clear selection on blur + // Should clear selection on blur because input is invalid await waitFor(() => { expect(onSelectionChange).toHaveBeenCalledWith(null); expect(combobox).toHaveValue(''); @@ -585,6 +591,131 @@ describe('', () => { }); }); + it('should auto-select when there is exactly one filtered result on blur', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole } = renderWithRoot( + + {items.map((item) => ( + {item.children} + ))} + , + ); + + const combobox = getByRole('combobox'); + + // Type partial match that results in one item (Violet is unique with 'vio') + await userEvent.type(combobox, 'vio'); + + // Blur the input + await act(async () => { + combobox.blur(); + }); + + // Should auto-select the single matching item + await waitFor(() => { + expect(onSelectionChange).toHaveBeenCalledWith('violet'); + expect(combobox).toHaveValue('Violet'); + }); + }); + + it('should reset to selected value on blur when clearOnBlur is false and input is invalid', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getAllByRole, queryByRole } = renderWithRoot( + + {items.map((item) => ( + {item.children} + ))} + , + ); + + const combobox = getByRole('combobox'); + + // Type to filter and open popover + await userEvent.type(combobox, 're'); + + await waitFor(() => { + expect(queryByRole('listbox')).toBeInTheDocument(); + }); + + // Click on first option (Red) + const options = getAllByRole('option'); + await userEvent.click(options[0]); + + // Verify selection was made + await waitFor(() => { + expect(onSelectionChange).toHaveBeenCalledWith('red'); + expect(combobox).toHaveValue('Red'); + }); + + onSelectionChange.mockClear(); + + // Type invalid text to make input invalid + await userEvent.clear(combobox); + await userEvent.type(combobox, 'xyz'); + + // Blur the input + await act(async () => { + combobox.blur(); + }); + + // Should reset input to selected value (Red) since clearOnBlur is false + await waitFor(() => { + expect(combobox).toHaveValue('Red'); + }); + + // Selection should not change + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should clear selection when input is empty on blur even with clearOnBlur false', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getAllByRole, queryByRole } = renderWithRoot( + + {items.map((item) => ( + {item.children} + ))} + , + ); + + const combobox = getByRole('combobox'); + + // Type to filter and open popover + await userEvent.type(combobox, 're'); + + await waitFor(() => { + expect(queryByRole('listbox')).toBeInTheDocument(); + }); + + // Click on first option (Red) + const options = getAllByRole('option'); + await userEvent.click(options[0]); + + // Verify selection was made + await waitFor(() => { + expect(onSelectionChange).toHaveBeenCalledWith('red'); + expect(combobox).toHaveValue('Red'); + }); + + onSelectionChange.mockClear(); + + // Clear the input completely + await userEvent.clear(combobox); + + // Blur the input + await act(async () => { + combobox.blur(); + }); + + // Should clear selection even though clearOnBlur is false + await waitFor(() => { + expect(onSelectionChange).toHaveBeenCalledWith(null); + expect(combobox).toHaveValue(''); + }); + }); + it('should show all items when opening with no results', async () => { const { getByRole, getAllByRole, queryByRole, getByTestId } = renderWithRoot( diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index e207484ad..3abd3a2f1 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -18,7 +18,7 @@ import { useOverlay, useOverlayPosition, } from 'react-aria'; -import { Section as BaseSection } from 'react-stately'; +import { Section as BaseSection, useListState } from 'react-stately'; import { useEvent } from '../../../_internal'; import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons'; @@ -326,19 +326,17 @@ function useComboBoxState({ // Hook: useComboBoxFiltering // ============================================================================ interface UseComboBoxFilteringProps { - children: ReactNode; effectiveInputValue: string; filter: FilterFn | false | undefined; } interface UseComboBoxFilteringReturn { - filteredChildren: ReactNode; + filterFn: (nodes: Iterable) => Iterable; isFilterActive: boolean; setIsFilterActive: (active: boolean) => void; } function useComboBoxFiltering({ - children, effectiveInputValue, filter, }: UseComboBoxFilteringProps): UseComboBoxFilteringReturn { @@ -351,79 +349,44 @@ function useComboBoxFiltering({ [filter, contains], ); - // Filter children based on input value - const filteredChildren = useMemo(() => { - const term = effectiveInputValue.trim(); - - if (!isFilterActive || !term || !children) { - return children; - } - - const nodeMatches = (node: any): boolean => { - if (!node?.props) return false; - - const textValue = - node.props.textValue || - (typeof node.props.children === 'string' ? node.props.children : '') || - String(node.props.children || ''); - - return textFilterFn(textValue, term); - }; + // Create a filter function for collection nodes + const filterFn = useCallback( + (nodes: Iterable) => { + const term = effectiveInputValue.trim(); - const filterChildren = (childNodes: ReactNode): ReactNode => { - if (!childNodes) return null; + // Don't filter if not active or no search term + if (!isFilterActive || !term) { + return nodes; + } - const childArray = Array.isArray(childNodes) ? childNodes : [childNodes]; - const filteredNodes: ReactNode[] = []; + // Filter nodes based on their textValue and preserve section structure + return [...nodes] + .map((node: any) => { + if (node.type === 'section' && node.childNodes) { + const filteredChildren = [...node.childNodes].filter((child: any) => + textFilterFn(child.textValue || '', term), + ); - childArray.forEach((child: any) => { - if (!child || typeof child !== 'object') { - return; - } + if (filteredChildren.length === 0) { + return null; + } - if ( - child.type === BaseSection || - child.type?.displayName === 'Section' - ) { - const sectionChildren = Array.isArray(child.props.children) - ? child.props.children - : [child.props.children]; - - const filteredSectionChildren = sectionChildren.filter( - (sectionChild: any) => { - return ( - sectionChild && - typeof sectionChild === 'object' && - nodeMatches(sectionChild) - ); - }, - ); - - if (filteredSectionChildren.length > 0) { - filteredNodes.push( - cloneElement(child, { - key: child.key, - children: filteredSectionChildren, - }), - ); - } - } else if (child.type === Item) { - if (nodeMatches(child)) { - filteredNodes.push(child); + return { + ...node, + childNodes: filteredChildren, + hasChildNodes: true, + }; } - } else if (nodeMatches(child)) { - filteredNodes.push(child); - } - }); - return filteredNodes; - }; - - return filterChildren(children); - }, [isFilterActive, children, effectiveInputValue, textFilterFn]); + return textFilterFn(node.textValue || '', term) ? node : null; + }) + .filter(Boolean); + }, + [isFilterActive, effectiveInputValue, textFilterFn], + ); return { - filteredChildren, + filterFn, isFilterActive, setIsFilterActive, }; @@ -613,6 +576,24 @@ function useComboBoxKeyboard({ selectionManager.setFocusedKey(nextKey); } } else if (e.key === 'Enter') { + // If popover is open, try to select the focused item first + if (isPopoverOpen) { + const listState = listStateRef.current; + if (listState) { + const keyToSelect = listState.selectionManager.focusedKey; + + if (keyToSelect != null) { + e.preventDefault(); + listState.selectionManager.select(keyToSelect, e); + // Ensure the popover closes even if selection stays the same + onClosePopover(); + inputRef.current?.focus(); + return; + } + } + } + + // If no results, handle empty input or custom values if (!hasResults) { e.preventDefault(); @@ -629,19 +610,12 @@ function useComboBoxKeyboard({ return; } - if (isPopoverOpen) { - const listState = listStateRef.current; - if (!listState) return; - - const keyToSelect = listState.selectionManager.focusedKey; - - if (keyToSelect != null) { - e.preventDefault(); - listState.selectionManager.select(keyToSelect, e); - // Ensure the popover closes even if selection stays the same - onClosePopover(); - inputRef.current?.focus(); - } + // Clear selection if input is empty and popover is closed (or no focused item) + const trimmed = (effectiveInputValue || '').trim(); + if (trimmed === '') { + e.preventDefault(); + onSelectionChange(null); + return; } } else if (e.key === 'Escape') { if (isPopoverOpen) { @@ -819,6 +793,7 @@ interface ComboBoxOverlayProps { onFocus: (e: React.FocusEvent) => void; onBlur: (e: React.FocusEvent) => void; }; + filter?: (nodes: Iterable) => Iterable; } function ComboBoxOverlay({ @@ -847,6 +822,7 @@ function ComboBoxOverlay({ label, ariaLabel, compositeFocusProps, + filter, }: ComboBoxOverlayProps) { // Overlay positioning const { @@ -895,7 +871,7 @@ function ComboBoxOverlay({ const placementDirection = placement?.split(' ')[0] || direction; const overlayContent = ( - + {({ phase, isShown, ref: transitionRef }) => ( ( }, [isPopoverOpen, effectiveSelectedKey]); // Filtering hook - const { filteredChildren, isFilterActive, setIsFilterActive } = - useComboBoxFiltering({ - children, - effectiveInputValue, - filter, - }); + const { filterFn, isFilterActive, setIsFilterActive } = useComboBoxFiltering({ + effectiveInputValue, + filter, + }); - // Freeze filtered children during close animation to prevent visual jumps - const frozenFilteredChildrenRef = useRef(null); + // Create local collection state for reading item data (labels, etc.) + // This allows us to read item labels even before the popover opens + const localCollectionState = useListState({ + children, + items: sortedItems, + selectionMode: 'none', // Don't manage selection in this state + }); - useEffect(() => { - // Update frozen children only when popover is open - if (isPopoverOpen) { - frozenFilteredChildrenRef.current = filteredChildren; + const { isFocused, focusProps } = useFocus({ isDisabled }); + + // Helper to check if current input value is valid + const checkInputValidity = useCallback(() => { + if (!effectiveInputValue.trim()) { + return { isValid: false, singleMatchKey: null, filteredCount: 0 }; } - }, [isPopoverOpen, filteredChildren]); - // Use frozen children during close animation, fresh children when open - const displayedFilteredChildren = isPopoverOpen - ? filteredChildren - : frozenFilteredChildrenRef.current ?? filteredChildren; + // Get filtered collection based on current input + const filteredNodes = filterFn(localCollectionState.collection); + const filteredItems: Array<{ key: Key; textValue: string }> = []; + + // Flatten filtered nodes (handle sections) + for (const node of filteredNodes) { + if (node.type === 'section' && node.childNodes) { + for (const child of node.childNodes) { + if (child.type === 'item') { + filteredItems.push({ + key: child.key, + textValue: child.textValue || '', + }); + } + } + } else if (node.type === 'item') { + filteredItems.push({ + key: node.key, + textValue: node.textValue || '', + }); + } + } - const { isFocused, focusProps } = useFocus({ isDisabled }); + const filteredCount = filteredItems.length; + + // Check for exact match + const exactMatch = filteredItems.find( + (item) => + item.textValue.toLowerCase() === + effectiveInputValue.trim().toLowerCase(), + ); + + if (exactMatch) { + return { isValid: true, singleMatchKey: exactMatch.key, filteredCount }; + } + + // If exactly one filtered result, consider it valid + if (filteredCount === 1) { + return { + isValid: true, + singleMatchKey: filteredItems[0].key, + filteredCount, + }; + } + + return { isValid: false, singleMatchKey: null, filteredCount }; + }, [effectiveInputValue, filterFn, localCollectionState.collection]); // Composite blur handler - fires when focus leaves the entire component const handleCompositeBlur = useEvent(() => { - // Always disable filter on blur - setIsFilterActive(false); + // NOTE: Do NOT disable filter yet; we need it active for validity check - // In allowsCustomValue mode with shouldCommitOnBlur, commit the input value - if ( - allowsCustomValue && - shouldCommitOnBlur && - effectiveInputValue && - effectiveSelectedKey == null - ) { - externalOnSelectionChange?.(effectiveInputValue as string); - if (!isControlledKey) { - setInternalSelectedKey(effectiveInputValue as Key); + // In allowsCustomValue mode + if (allowsCustomValue) { + // Commit the input value if it's non-empty and nothing is selected + if ( + shouldCommitOnBlur && + effectiveInputValue && + effectiveSelectedKey == null + ) { + externalOnSelectionChange?.(effectiveInputValue as string); + if (!isControlledKey) { + setInternalSelectedKey(effectiveInputValue as Key); + } + onBlur?.(); + setIsFilterActive(false); + return; + } + + // Clear selection if input is empty + if (!String(effectiveInputValue).trim()) { + externalOnSelectionChange?.(null); + if (!isControlledKey) { + setInternalSelectedKey(null); + } + if (!isControlledInput) { + setInternalInputValue(''); + } + onInputChange?.(''); + onBlur?.(); + setIsFilterActive(false); + return; } - // Call user's onBlur callback - onBlur?.(); - return; } - // In clearOnBlur mode (only for non-custom-value mode), clear selection and input - if (clearOnBlur && !allowsCustomValue) { - externalOnSelectionChange?.(null); - if (!isControlledKey) { - setInternalSelectedKey(null); + // In non-custom-value mode, validate input and handle accordingly + if (!allowsCustomValue) { + const { isValid, singleMatchKey } = checkInputValidity(); + + // If there's exactly one filtered result, auto-select it + if ( + isValid && + singleMatchKey != null && + singleMatchKey !== effectiveSelectedKey + ) { + const label = getItemLabel(singleMatchKey); + + if (!isControlledKey) { + setInternalSelectedKey(singleMatchKey); + } + if (!isControlledInput) { + setInternalInputValue(label); + } + onInputChange?.(label); + externalOnSelectionChange?.(singleMatchKey as string | null); + onBlur?.(); + setIsFilterActive(false); + return; } - if (!isControlledInput) { - setInternalInputValue(''); + + // If input is invalid (no exact match, not a single result) + if (!isValid) { + const trimmedInput = effectiveInputValue.trim(); + + // Clear if clearOnBlur is set or input is empty + if (clearOnBlur || !trimmedInput) { + externalOnSelectionChange?.(null); + if (!isControlledKey) { + setInternalSelectedKey(null); + } + if (!isControlledInput) { + setInternalInputValue(''); + } + onInputChange?.(''); + onBlur?.(); + setIsFilterActive(false); + return; + } + + // Reset input to current selected value (or empty if none) + const nextValue = + effectiveSelectedKey != null + ? getItemLabel(effectiveSelectedKey) + : ''; + + if (!isControlledInput) { + setInternalInputValue(nextValue); + } + onInputChange?.(nextValue); + onBlur?.(); + setIsFilterActive(false); + return; } - onInputChange?.(''); - // Call user's onBlur callback - onBlur?.(); - return; } - // Reset input to show current selection (or empty if none) + // Fallback: Reset input to show current selection (or empty if none) const nextValue = effectiveSelectedKey != null ? getItemLabel(effectiveSelectedKey) : ''; @@ -1244,9 +1327,8 @@ export const ComboBox = forwardRef(function ComboBox( setInternalInputValue(nextValue); } onInputChange?.(nextValue); - - // Call user's onBlur callback onBlur?.(); + setIsFilterActive(false); }); // Composite focus hook - handles focus tracking across wrapper and portaled popover @@ -1267,11 +1349,14 @@ export const ComboBox = forwardRef(function ComboBox( const listStateRef = useRef(null); const focusInitAttemptsRef = useRef(0); - // Helper to get label from collection item - const getItemLabel = useCallback((key: Key): string => { - const item = listStateRef.current?.collection?.getItem(key); - return item?.textValue || String(key); - }, []); + // Helper to get label from local collection + const getItemLabel = useCallback( + (key: Key): string => { + const item = localCollectionState?.collection?.getItem(key); + return item?.textValue || String(key); + }, + [localCollectionState?.collection], + ); // Selection change handler const handleSelectionChange = useEvent((selection: Key | Key[] | null) => { @@ -1359,9 +1444,6 @@ export const ComboBox = forwardRef(function ComboBox( // Priority 2: fall back to defaultSelectedKey's label if (defaultSelectedKey) { - // Wait for collection to be ready - if (!listStateRef.current?.collection) return; - const label = getItemLabel(defaultSelectedKey); setInternalInputValue(label); @@ -1377,12 +1459,21 @@ export const ComboBox = forwardRef(function ComboBox( ]); // Sync input value with controlled selectedKey + const lastSyncedSelectedKey = useRef(undefined); + useEffect(() => { // Only run when selectedKey is controlled but inputValue is uncontrolled if (!isControlledKey || isControlledInput) return; - // Wait for collection to be ready - if (!listStateRef.current?.collection) return; + // Skip if the key hasn't actually changed (prevents unnecessary resets when collection rebuilds) + if ( + lastSyncedSelectedKey.current !== undefined && + lastSyncedSelectedKey.current === effectiveSelectedKey + ) { + return; + } + + lastSyncedSelectedKey.current = effectiveSelectedKey; // Get the expected label for the current selection const expectedLabel = @@ -1413,12 +1504,27 @@ export const ComboBox = forwardRef(function ComboBox( : effectiveSelectedKey != null; let showClearButton = isClearable && hasValue && !isDisabled && !isReadOnly; - const hasResults = Boolean( - displayedFilteredChildren && - (Array.isArray(displayedFilteredChildren) - ? displayedFilteredChildren.length > 0 - : displayedFilteredChildren !== null), - ); + // Check if there are any results after filtering + const hasResults = useMemo(() => { + if (!children) return false; + if (!Array.isArray(children) && children === null) return false; + + // If we have a collection, check if filtering will produce any results + if (localCollectionState?.collection) { + const filteredNodes = filterFn(localCollectionState.collection); + const resultArray = Array.from(filteredNodes).flatMap((node: any) => { + if (node.type === 'section' && node.childNodes) { + return [...node.childNodes]; + } + + return [node]; + }); + return resultArray.length > 0; + } + + // Fallback: check if children exists + return Array.isArray(children) ? children.length > 0 : true; + }, [children, localCollectionState?.collection, filterFn]); // Clear function let clearValue = useEvent(() => { @@ -1674,10 +1780,11 @@ export const ComboBox = forwardRef(function ComboBox( label={label} ariaLabel={(props as any)['aria-label']} compositeFocusProps={compositeFocusProps} + filter={filterFn} onSelectionChange={handleSelectionChange} onClose={() => setIsPopoverOpen(false)} > - {displayedFilteredChildren} + {children} ); diff --git a/src/components/fields/FilterListBox/FilterListBox.stories.tsx b/src/components/fields/FilterListBox/FilterListBox.stories.tsx index c142c3c7d..3120e9254 100644 --- a/src/components/fields/FilterListBox/FilterListBox.stories.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.stories.tsx @@ -976,10 +976,14 @@ export const InDialog: StoryFn = () => { return ( - + diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index 9ee1738d6..c6d806f3a 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -57,10 +57,6 @@ const FilterListBoxWrapperElement = tasty({ 'invalid & focused': '#danger.50', focused: '#purple-03', }, - height: { - '': false, - popover: 'initial max-content (50vh - 4x)', - }, border: { '': true, focused: '#purple-text', @@ -73,6 +69,7 @@ const FilterListBoxWrapperElement = tasty({ }); const SearchWrapperElement = tasty({ + qa: 'FilterListBoxSearchWrapper', styles: { ...INPUT_WRAPPER_STYLES, border: 'bottom', @@ -111,7 +108,7 @@ const StyledHeaderWithoutBorder = tasty(StyledHeader, { }); export interface CubeFilterListBoxProps - extends CubeListBoxProps, + extends Omit, 'filter'>, FieldBaseProps { /** Placeholder text for the search input */ searchPlaceholder?: string; @@ -905,6 +902,7 @@ export const FilterListBox = forwardRef(function FilterListBox< )} ( ( const filterPickerField = ( @@ -845,7 +846,6 @@ export const FilterPicker = forwardRef(function FilterPicker( footer={footer} headerStyles={headerStyles} footerStyles={footerStyles} - qa={`${props.qa || 'FilterPicker'}ListBox`} allValueProps={allValueProps} customValueProps={customValueProps} newCustomValueProps={newCustomValueProps} diff --git a/src/components/fields/ListBox/ListBox.tsx b/src/components/fields/ListBox/ListBox.tsx index c94732e1d..3a27619fe 100644 --- a/src/components/fields/ListBox/ListBox.tsx +++ b/src/components/fields/ListBox/ListBox.tsx @@ -54,6 +54,8 @@ import { CubeItemProps, Item } from '../../Item'; import type { CollectionBase, Key } from '@react-types/shared'; import type { FieldBaseProps } from '../../../shared'; +type FirstArg = F extends (...args: infer A) => any ? A[0] : never; + const ListBoxWrapperElement = tasty({ qa: 'ListBox', styles: { @@ -294,6 +296,13 @@ export interface CubeListBoxProps * Props to apply to the "Select All" option. */ allValueProps?: Partial>; + + /** + * Filter function to apply to the collection nodes. + * Takes an iterable of nodes and returns a filtered iterable. + * Useful for implementing search/filter functionality. + */ + filter?: (nodes: Iterable) => Iterable; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -498,6 +507,7 @@ export const ListBox = forwardRef(function ListBox( showSelectAll, selectAllLabel, allValueProps, + filter, form, ...otherProps } = props; @@ -553,10 +563,12 @@ export const ListBox = forwardRef(function ListBox( ]); // Prepare props for useListState with correct selection props - const listStateProps: any = { + const listStateProps: FirstArg = { ...props, onSelectionChange: wrappedOnSelectionChange, isDisabled, + disabledBehavior: 'all', + filter, selectionMode: props.selectionMode || 'single', }; @@ -595,9 +607,7 @@ export const ListBox = forwardRef(function ListBox( delete listStateProps.defaultSelectedKey; } - const listState = useListState({ - ...listStateProps, - }); + const listState = useListState(listStateProps); useLayoutEffect(() => { const selected = listState.selectionManager.selectedKeys; @@ -716,6 +726,7 @@ export const ListBox = forwardRef(function ListBox( id: id, 'aria-label': props['aria-label'] || label?.toString(), isDisabled, + isVirtualized: true, shouldUseVirtualFocus: shouldUseVirtualFocus ?? false, escapeKeyBehavior: onEscape ? 'none' : 'clearSelection', }, @@ -843,7 +854,7 @@ export const ListBox = forwardRef(function ListBox( const listBoxField = ( @@ -876,6 +887,7 @@ export const ListBox = forwardRef(function ListBox( {/* Scroll container wrapper */} + +# Picker + +A versatile selection component that combines a trigger button with a dropdown list. It provides a space-efficient interface for selecting one or multiple items from a list, with support for sections, custom summaries, and various UI states. + +## When to Use + +- Creating selection interfaces where users need to choose from predefined options +- Implementing compact selection interfaces where space is limited +- Building user preference panels with organized option groups +- When you need single or multiple selection from a list +- When you don't need search/filter functionality (use ComboBox or FilterPicker for searchable lists) + +## Component + + + +--- + +### Properties + + + +### Base Properties + +Supports [Base properties](/docs/tasty-base-properties--docs) + +### Styling Properties + +#### styles + +Customizes the root wrapper element and the trigger button of the Picker component. + +**Sub-elements:** +- None + +#### triggerStyles + +Customizes the DialogTrigger wrapper that contains the trigger button and manages the popover. + +**Sub-elements:** +- None + +#### listBoxStyles + +Customizes the ListBox component styles within the popover. See [ListBox documentation](/docs/forms-listbox--docs) for available sub-elements. + +**Sub-elements:** +- `Label` - Item label text +- `Description` - Item description text +- `Content` - Item content wrapper +- `Checkbox` - Checkbox element in multiple selection mode +- `CheckboxWrapper` - Wrapper around checkbox + +#### popoverStyles + +Customizes the popover Dialog that contains the ListBox. See [Dialog documentation](/docs/overlays-dialog--docs) for available sub-elements. + +**Sub-elements:** +- None for Dialog in popover mode + +#### headerStyles + +Customizes the header area when `header` prop is provided. + +**Sub-elements:** +- None + +#### footerStyles + +Customizes the footer area when `footer` prop is provided. + +**Sub-elements:** +- None + +### Style Properties + +These properties allow direct style application without using the `styles` prop: `display`, `font`, `preset`, `hide`, `opacity`, `whiteSpace`, `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `color`, `fill`, `fade`. + +### Modifiers + +The `mods` property accepts the following modifiers you can override: + +| Modifier | Type | Description | +|----------|------|-------------| +| `placeholder` | `boolean` | Applied when no selection is made | +| `selected` | `boolean` | Applied when items are selected | + +## Sub-components + +### Picker.Item + +Represents individual selectable items within the Picker dropdown. Each item is rendered using [ItemBase](/docs/content-itembase--docs) and inherits all its properties. + +#### Key Properties + +See [ItemBase documentation](/docs/content-itembase--docs) for the complete API. Common properties include: + +| Property | Type | Description | +|----------|------|-------------| +| `key` | `string \| number` | Unique identifier for the item (required) | +| `children` | `ReactNode` | The main content/label for the item | +| `textValue` | `string` | Accessible text for screen readers (required for complex content) | +| `icon` | `ReactNode` | Icon displayed before the content | +| `rightIcon` | `ReactNode` | Icon displayed after the content | +| `description` | `ReactNode` | Secondary text below the main content | +| `descriptionPlacement` | `'inline' \| 'block'` | How the description is positioned relative to content | +| `prefix` | `ReactNode` | Content before the main text | +| `suffix` | `ReactNode` | Content after the main text | +| `isDisabled` | `boolean` | Whether the item is disabled | +| `tooltip` | `string \| boolean \| TooltipProps` | Tooltip configuration | +| `styles` | `Styles` | Custom styling for the item | +| `qa` | `string` | QA identifier for testing | + +#### Examples with Rich Items + +**With Icons and Descriptions:** +```jsx + + } + description="All active team members" + > + Active Users + + } + description="Admin users only" + > + Administrators + + +``` + +**With Prefix and Suffix:** +```jsx + + Free} + suffix="$0/mo" + > + Basic Plan + + Pro} + suffix="$29/mo" + > + Pro Plan + + +``` + +### Picker.Section + +Groups related items together under an optional section heading. Sections provide visual and semantic grouping for better organization. + +#### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `title` | `ReactNode` | - | Section heading text (optional) | +| `children` | `Picker.Item[]` | - | Collection of Picker.Item components (required) | + +#### Example + +```jsx + + + Apple + Banana + + + Carrot + Broccoli + + +``` + +**Note:** Sections disable virtualization. For large datasets (50+ items), use a flat structure with the `items` prop instead. + +## Content Patterns + +### Static Children Pattern + +The most common pattern for Picker is to provide static children using `Picker.Item` and `Picker.Section` components: + +```jsx + + Apple + Banana + + Carrot + + +``` + +### Dynamic Content Pattern + +For large datasets or dynamic content, use the `items` prop with a render function. This pattern enables automatic virtualization for performance: + +```jsx + + {(item) => ( + + {item.name} + + )} + +``` + +**Key Benefits:** +- **Virtualization**: Automatically enabled for large lists without sections +- **Performance**: Only renders visible items in the DOM +- **Dynamic Content**: Perfect for data fetched from APIs or changing datasets +- **Memory Efficient**: Handles thousands of items smoothly + +**When to Use:** +- Lists with 50+ items +- Dynamic data from APIs +- Content that changes frequently +- When virtualization performance is needed + +## Variants + +### Selection Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| `single` | Select only one item at a time | Category selection, single choice questions | +| `multiple` | Select multiple items with checkboxes | Tags, filters, permissions | + +### Trigger Button Types + +The trigger button supports various visual styles via the `type` prop: + +| Type | Description | Use Case | +|------|-------------|----------| +| `outline` | Outlined button with border (default) | Standard form inputs | +| `clear` | Transparent background | Toolbar actions, compact interfaces | +| `primary` | Primary brand color | Emphasized selections | +| `secondary` | Secondary color variant | Alternative emphasis | +| `neutral` | Neutral color scheme | Subtle selections | + +### Trigger Button Themes + +Control the color scheme with the `theme` prop: + +| Theme | Description | +|-------|-------------| +| `default` | Standard theme (default) | +| `danger` | Red/destructive color (also applied automatically when `validationState="invalid"`) | + +### Sizes + +| Size | Height | Use Case | +|------|--------|----------| +| `small` | ~28px | Dense interfaces, compact layouts | +| `medium` | ~32px | Standard forms (default) | +| `large` | ~40px | Emphasized selections, accessibility | + +## Examples + +### Basic Single Selection + + + +Standard single-selection picker with a placeholder and label. + +```jsx + + Apple + Banana + Orange + +``` + +### Multiple Selection + + + +Multiple selection mode with checkboxes for clarity. + +```jsx + + Apple + Banana + Orange + +``` + +### With Clear Button + + + +Enable users to clear their selection with a clear button that appears when an item is selected. + +```jsx + + Apple + Banana + Orange + +``` + +### Multiple Selection with Select All + + + +Add a "Select All" option for quick selection of all available items in multiple selection mode. + +```jsx + + Apple + Banana + Orange + +``` + +### Custom Summary + + + +Customize how the selected items are displayed in the trigger button using a custom render function. + +```jsx + { + if (!selectedLabels || selectedLabels.length === 0) return null; + if (selectedLabels.length === 1) return selectedLabels[0]; + return `${selectedLabels.length} fruits selected`; + }} +> + Apple + Banana + Orange + +``` + +### With Sections + + + +Organize items into logical groups with section headers. + +```jsx + + + Apple + Banana + Orange + + + Carrot + Broccoli + Spinach + + +``` + +### Different Sizes + + + +Picker supports three sizes: small, medium (default), and large. + +```jsx + + Apple + Banana + + + + Apple + Banana + + + + Apple + Banana + +``` + +### Disabled State + + + +Disable the picker to prevent user interaction. + +```jsx + + Apple + Banana + +``` + +### With Validation + + + +Display validation states and error messages. + +```jsx + + Apple + Banana + +``` + +### With Description + + + +Add helpful description text below the label. + +```jsx + + Apple + Banana + +``` + +## Accessibility + +The Picker component is built with React Aria hooks and provides comprehensive accessibility support out of the box. + +### Keyboard Navigation + +| Key | Action | +|-----|--------| +| `Tab` | Moves focus to/from the trigger button | +| `Space` or `Enter` | Opens the dropdown popover when focused on trigger | +| `Arrow Up` or `Arrow Down` | Opens the popover when closed; navigates through items when open | +| `Escape` | Closes the popover and returns focus to trigger | +| `Space` or `Enter` | Selects the focused item (single mode closes popover automatically) | + +**In the popover:** +- Arrow keys navigate through items +- Home/End keys jump to first/last item +- Type-ahead: typing characters focuses matching items +- In multiple selection mode: Space toggles selection without closing + +### Screen Reader Support + +- Trigger button announces as "button" with current selection state +- When empty: announces placeholder text +- When selected: announces selected item(s) or count +- Popover opening/closing is announced to screen readers +- Item selection changes are announced immediately +- Loading state announces "loading" to users +- Validation errors are associated and announced +- Section headers are properly announced when navigating + +### ARIA Properties + +The component automatically manages ARIA attributes: + +| Attribute | Usage | +|-----------|-------| +| `aria-label` | Labels the trigger when no visible label exists | +| `aria-labelledby` | Associates the label with the trigger | +| `aria-expanded` | Indicates whether the popover is open (true/false) | +| `aria-haspopup` | Indicates the button controls a listbox (listbox) | +| `aria-describedby` | Associates help text and error messages | +| `aria-invalid` | Indicates validation state (true when invalid) | +| `aria-required` | Indicates required fields (true when required) | +| `aria-disabled` | Indicates disabled state | + +### Best Practices for Accessibility + +1. **Always provide a label**: Use the `label` prop or `aria-label` for screen reader users + ```jsx + ... + ``` + +2. **Use meaningful placeholders**: Placeholders should describe the expected selection + ```jsx + ... + ``` + +3. **Provide help text**: Use `description` for additional context + ```jsx + ... + ``` + +4. **Handle validation properly**: Use `validationState` and `message` props + ```jsx + ... + ``` + +5. **Use `textValue` for complex items**: Ensures screen readers can announce item content + ```jsx + + + + ``` + +## Best Practices + +1. **Do**: Provide clear, descriptive labels and placeholders + ```jsx + + Electronics + Clothing + + ``` + +2. **Don't**: Use overly long option texts that will be truncated + ```jsx + // Avoid + + This is an extremely long option text that will be truncated and hard to read + + + // Instead, use description + + Short Label + + ``` + +3. **Do**: Use sections for logical grouping + ```jsx + + + Apple + + + Carrot + + + ``` + +4. **Do**: Use the `items` prop for large datasets to enable virtualization + ```jsx + + {(item) => {item.name}} + + ``` + +5. **Do**: Use `isClearable` for optional single selections + ```jsx + + Option 1 + + ``` + +6. **Do**: Use `showSelectAll` for efficient multiple selection + ```jsx + + Read + Write + + ``` + +7. **Do**: Use `isCheckable` in multiple selection mode for clarity + ```jsx + + Option 1 + + ``` + +8. **Accessibility**: Always provide meaningful `aria-label` when label is not visible +9. **Performance**: Use `textValue` prop for items with complex content +10. **UX**: Provide feedback for empty states and loading states + +## Integration with Forms + +This component supports all [Field properties](/docs/forms-field--docs) when used within a Form context. The Picker automatically handles form validation, touched states, error messages, and integrates seamlessly with form submission. + +### Basic Form Integration + +```jsx +import { Form, Picker } from '@cube-dev/ui-kit'; + +function MyForm() { + const handleSubmit = (values) => { + console.log('Form values:', values); + }; + + return ( +
+ + Electronics + Clothing + Books + + + +
+ ); +} +``` + +### Multiple Selection in Forms + +```jsx +
+ + Tag 1 + Tag 2 + Tag 3 + + + +
+``` + +### Key Form Properties + +| Property | Description | +|----------|-------------| +| `name` | Field name in form data (required for form integration) | +| `rules` | Validation rules array | +| `defaultValue` | Initial value (use with uncontrolled forms) | +| `value` | Controlled value (use with controlled forms) | +| `isRequired` | Marks field as required and adds visual indicator | +| `validationState` | Manual validation state control | +| `message` | Error or help message | + +## Advanced Features + +### Custom Summary Rendering + +Customize how the selection is displayed in the trigger button. The `renderSummary` function receives different parameters based on selection mode: + +```jsx +const renderSummary = ({ selectedLabels, selectedKeys, selectionMode }) => { + if (selectionMode === 'single') { + return selectedLabels?.[0] ? `Selected: ${selectedLabels[0]}` : null; + } + + if (!selectedKeys || selectedKeys.length === 0) return null; + if (selectedKeys === 'all') return 'All items selected'; + if (selectedKeys.length === 1) return selectedLabels[0]; + if (selectedKeys.length <= 3) return selectedLabels.join(', '); + return `${selectedKeys.length} items selected`; +}; + + + Apple + Banana + Orange + +``` + +### Icon-Only Mode + +For space-constrained interfaces, hide the summary text and show only an icon: + +```jsx +} + aria-label="Filter options" + type="clear" + selectionMode="multiple" +> + Option 1 + Option 2 + +``` + +### Controlled Mode + +Full control over selection state for integration with external state management: + +**Single Selection:** +```jsx +const [selectedKey, setSelectedKey] = useState(null); + + setSelectedKey(key)} +> + Apple + Banana + +``` + +**Multiple Selection:** +```jsx +const [selectedKeys, setSelectedKeys] = useState([]); + + { + if (keys === 'all') { + setSelectedKeys(['apple', 'banana', 'orange']); + } else { + setSelectedKeys(keys); + } + }} +> + Apple + Banana + Orange + +``` + +### Dynamic Items with Sorting + +Use the `items` prop with `sortSelectedToTop` to automatically sort selected items to the top when the picker opens: + +```jsx +const fruits = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'orange', label: 'Orange' }, + { key: 'mango', label: 'Mango' }, +]; + + + {(item) => {item.label}} + +``` + +## Performance + +### Optimization Tips + +1. **Use Dynamic Content Pattern for Large Lists** + - For lists with 50+ items, use the `items` prop with a render function + - This enables automatic virtualization for better performance + - Only visible items are rendered in the DOM + +2. **Avoid Sections with Large Datasets** + - Virtualization is disabled when sections are present + - For large lists, prefer flat structure or use filtering instead + +3. **Provide `textValue` for Complex Content** + - When items contain complex JSX, provide a `textValue` prop + - This helps with accessibility and potential search functionality + +4. **Consider Controlled Mode for Expensive Operations** + - Use controlled mode to debounce expensive operations + - Handle state updates efficiently in your own state management + +### Virtualization + +Virtualization is automatically enabled when using the `items` prop without sections: + +```jsx +// ✅ Virtualized - excellent performance with thousands of items + + {(item) => {item.name}} + + +// ❌ Not virtualized - sections disable virtualization + + + {largeArray.map(item => ( + {item.name} + ))} + + +``` + +### Content Optimization + +Optimize complex item content with the `textValue` prop for better accessibility: + +```jsx + + {(user) => ( + + + + )} + +``` + +## Related Components + +- [ComboBox](/docs/forms-combobox--docs) - Use when you need search/filter functionality or custom value entry +- [ListBox](/docs/forms-listbox--docs) - Use for always-visible list selection without a trigger +- [Select](/docs/forms-select--docs) - Use for native select behavior +- [Button](/docs/actions-button--docs) - The underlying trigger component +- [Dialog](/docs/overlays-dialog--docs) - The popover container component diff --git a/src/components/fields/Picker/Picker.stories.tsx b/src/components/fields/Picker/Picker.stories.tsx new file mode 100644 index 000000000..c84e9c4a5 --- /dev/null +++ b/src/components/fields/Picker/Picker.stories.tsx @@ -0,0 +1,252 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Picker } from './Picker'; + +const meta = { + title: 'Forms/Picker', + component: Picker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const fruits = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'orange', label: 'Orange' }, + { key: 'strawberry', label: 'Strawberry' }, + { key: 'mango', label: 'Mango' }, + { key: 'pineapple', label: 'Pineapple' }, +]; + +export const SingleSelection: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit', + selectionMode: 'single', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const MultipleSelection: Story = { + args: { + placeholder: 'Select fruits', + label: 'Favorite Fruits', + selectionMode: 'multiple', + isCheckable: true, + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const WithClearButton: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit', + selectionMode: 'single', + isClearable: true, + defaultSelectedKey: 'apple', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const WithSelectAll: Story = { + args: { + placeholder: 'Select fruits', + label: 'Favorite Fruits', + selectionMode: 'multiple', + isCheckable: true, + showSelectAll: true, + selectAllLabel: 'All Fruits', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const Disabled: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit', + selectionMode: 'single', + isDisabled: true, + defaultSelectedKey: 'apple', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const WithCustomRenderSummary: Story = { + args: { + placeholder: 'Select fruits', + label: 'Favorite Fruits', + selectionMode: 'multiple', + isCheckable: true, + defaultSelectedKeys: ['apple', 'banana'], + renderSummary: ({ selectedLabels }) => { + if (!selectedLabels || selectedLabels.length === 0) return null; + if (selectedLabels.length === 1) return selectedLabels[0]; + return `${selectedLabels.length} fruits selected`; + }, + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const WithSections: Story = { + args: { + placeholder: 'Select a food', + label: 'Favorite Food', + selectionMode: 'single', + children: ( + <> + + Apple + Banana + Orange + + + Carrot + Broccoli + Spinach + + + ), + }, +}; + +export const WithItemsArray: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit', + selectionMode: 'single', + items: fruits, + children: (item: { key: string; label: string }) => ( + {item.label} + ), + }, +}; + +export const Controlled = () => { + const [selectedKey, setSelectedKey] = useState(null); + + return ( +
+ setSelectedKey(key as string | null)} + > + {fruits.map((fruit) => ( + {fruit.label} + ))} + +
Selected: {selectedKey || 'None'}
+
+ ); +}; + +export const ControlledMultiple = () => { + const [selectedKeys, setSelectedKeys] = useState(['apple']); + + return ( +
+ { + if (keys === 'all') { + setSelectedKeys(fruits.map((f) => f.key)); + } else { + setSelectedKeys(keys as string[]); + } + }} + > + {(fruit) => {fruit.label}} + +
Selected: {selectedKeys.join(', ') || 'None'}
+
+ ); +}; + +export const DifferentSizes: Story = { + render: () => ( +
+ + {fruits.map((fruit) => ( + {fruit.label} + ))} + + + {fruits.map((fruit) => ( + {fruit.label} + ))} + + + {fruits.map((fruit) => ( + {fruit.label} + ))} + +
+ ), +}; + +export const WithValidation: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit (Required)', + selectionMode: 'single', + isRequired: true, + validationState: 'invalid', + message: 'Please select a fruit', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; + +export const WithDescription: Story = { + args: { + placeholder: 'Select a fruit', + label: 'Favorite Fruit', + description: 'Choose your favorite fruit from the list', + selectionMode: 'single', + children: fruits.map((fruit) => ( + {fruit.label} + )), + }, +}; diff --git a/src/components/fields/Picker/Picker.test.tsx b/src/components/fields/Picker/Picker.test.tsx new file mode 100644 index 000000000..adf5dea02 --- /dev/null +++ b/src/components/fields/Picker/Picker.test.tsx @@ -0,0 +1,829 @@ +import { createRef } from 'react'; + +import { Picker } from '../../../index'; +import { act, renderWithRoot, userEvent, within } from '../../../test'; + +jest.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + const basicItems = [ + Apple, + Banana, + Cherry, + Date, + Elderberry, + ]; + + const sectionsItems = [ + + Apple + Banana + Cherry + , + + Carrot + Broccoli + Spinach + , + ]; + + describe('Basic functionality', () => { + it('should render trigger button with placeholder', () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Choose fruits...'); + }); + + it('should open popover when clicked', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Click to open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify popover opened and options are visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + }); + + it('should close popover when item is selected in single mode', async () => { + const { getByRole, getByText, queryByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select an item + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + + // Wait a bit for the popover to close + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Verify popover closed (Banana option should not be visible) + expect(queryByText('Banana')).not.toBeInTheDocument(); + }); + + it('should open and close popover when trigger is clicked', async () => { + const { getByRole, getByText, queryByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + expect(getByText('Apple')).toBeInTheDocument(); + + // Close popover by clicking trigger again + await act(async () => { + await userEvent.click(trigger); + }); + + // Wait for animation + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(queryByText('Apple')).not.toBeInTheDocument(); + }); + + it('should display selected item in trigger for single selection', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select an item + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + + // Wait for popover to close + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Trigger should show selected item + expect(trigger).toHaveTextContent('Cherry'); + }); + + it('should display multiple selected items in trigger', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select multiple items + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + + // Trigger should show selected items + expect(trigger).toHaveTextContent('Apple, Cherry'); + }); + }); + + describe('Selection sorting functionality', () => { + it('should NOT sort selected items to top while popover is open', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify initial order + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Banana'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + + // Select Date (3rd item) + await act(async () => { + await userEvent.click(getByText('Date')); + }); + + // Select Banana (1st item) + await act(async () => { + await userEvent.click(getByText('Banana')); + }); + + // Order should remain the same while popover is open + listbox = getByRole('listbox'); + options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Banana'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + }); + + it('should sort selected items to top when popover reopens in multiple mode', async () => { + const itemsData = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'cherry', label: 'Cherry' }, + { key: 'date', label: 'Date' }, + { key: 'elderberry', label: 'Elderberry' }, + ]; + + const { getByRole, getByText } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select Cherry (2nd item) and Elderberry (4th item) + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + await act(async () => { + await userEvent.click(getByText('Elderberry')); + }); + + // Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Reopen popover - selected items should be sorted to top + await act(async () => { + await userEvent.click(trigger); + }); + + const listbox = getByRole('listbox'); + const reorderedOptions = within(listbox).getAllByRole('option'); + expect(reorderedOptions[0]).toHaveTextContent('Cherry'); + expect(reorderedOptions[1]).toHaveTextContent('Elderberry'); + expect(reorderedOptions[2]).toHaveTextContent('Apple'); + expect(reorderedOptions[3]).toHaveTextContent('Banana'); + expect(reorderedOptions[4]).toHaveTextContent('Date'); + }, 10000); + + // Skipping: sortSelectedToTop doesn't currently support sorting within sections + // TODO: Implement section-level sorting if needed + it.skip('should sort selected items to top within their sections', async () => { + const sectionsData = [ + { + key: 'fruits', + label: 'Fruits', + children: [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'cherry', label: 'Cherry' }, + ], + }, + { + key: 'vegetables', + label: 'Vegetables', + children: [ + { key: 'carrot', label: 'Carrot' }, + { key: 'broccoli', label: 'Broccoli' }, + { key: 'spinach', label: 'Spinach' }, + ], + }, + ]; + + const { getByRole, getByText } = renderWithRoot( + + {(section) => ( + + {section.children.map((item) => ( + {item.label} + ))} + + )} + , + ); + + const trigger = getByRole('button'); + + // Step 1: Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Step 1.5: Verify initial order within sections + let listbox = getByRole('listbox'); + let fruitsSection = within(listbox).getByText('Fruits').closest('li'); + let vegetablesSection = within(listbox) + .getByText('Vegetables') + .closest('li'); + + let fruitsOptions = within(fruitsSection!).getAllByRole('option'); + let vegetablesOptions = within(vegetablesSection!).getAllByRole('option'); + + expect(fruitsOptions[0]).toHaveTextContent('Apple'); + expect(fruitsOptions[1]).toHaveTextContent('Banana'); + expect(fruitsOptions[2]).toHaveTextContent('Cherry'); + + expect(vegetablesOptions[0]).toHaveTextContent('Carrot'); + expect(vegetablesOptions[1]).toHaveTextContent('Broccoli'); + expect(vegetablesOptions[2]).toHaveTextContent('Spinach'); + + // Step 2: Select items from each section + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + await act(async () => { + await userEvent.click(getByText('Spinach')); + }); + + // Step 3: Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Step 4: Reopen popover and verify sorting within sections + await act(async () => { + await userEvent.click(trigger); + }); + + listbox = getByRole('listbox'); + fruitsSection = within(listbox).getByText('Fruits').closest('li'); + vegetablesSection = within(listbox).getByText('Vegetables').closest('li'); + + fruitsOptions = within(fruitsSection!).getAllByRole('option'); + vegetablesOptions = within(vegetablesSection!).getAllByRole('option'); + + // Check that Cherry is first in Fruits section + expect(fruitsOptions[0]).toHaveTextContent('Cherry'); + expect(fruitsOptions[1]).toHaveTextContent('Apple'); + expect(fruitsOptions[2]).toHaveTextContent('Banana'); + + // Check that Spinach is first in Vegetables section + expect(vegetablesOptions[0]).toHaveTextContent('Spinach'); + expect(vegetablesOptions[1]).toHaveTextContent('Carrot'); + expect(vegetablesOptions[2]).toHaveTextContent('Broccoli'); + }, 10000); + + it('should work correctly in single selection mode', async () => { + const itemsData = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'cherry', label: 'Cherry' }, + { key: 'date', label: 'Date' }, + { key: 'elderberry', label: 'Elderberry' }, + ]; + + const { getByRole, getByText } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const trigger = getByRole('button'); + + // Open popover and select an item + await act(async () => { + await userEvent.click(trigger); + }); + + await act(async () => { + await userEvent.click(getByText('Date')); + }); + + // Wait for popover to close + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Reopen to check sorting + await act(async () => { + await userEvent.click(trigger); + }); + + const listbox = getByRole('listbox'); + const options = within(listbox).getAllByRole('option'); + + // In single mode, selected item should be sorted to top + expect(options[0]).toHaveTextContent('Date'); + expect(options[1]).toHaveTextContent('Apple'); + expect(options[2]).toHaveTextContent('Banana'); + expect(options[3]).toHaveTextContent('Cherry'); + expect(options[4]).toHaveTextContent('Elderberry'); + }, 10000); + }); + + describe('Clear button functionality', () => { + const itemsData = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'cherry', label: 'Cherry' }, + { key: 'date', label: 'Date' }, + { key: 'elderberry', label: 'Elderberry' }, + ]; + + it('should show clear button when isClearable is true and there is a selection', async () => { + const { getAllByRole, getByTestId } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const buttons = getAllByRole('button'); + const trigger = buttons[0]; // First button is the trigger + + // Trigger should show selected item + expect(trigger).toHaveTextContent('Apple'); + + // Clear button should be visible + const clearButton = getByTestId('PickerClearButton'); + expect(clearButton).toBeInTheDocument(); + }); + + it('should clear selection when clear button is clicked', async () => { + const onSelectionChange = jest.fn(); + const onClear = jest.fn(); + + const { getAllByRole, getByTestId } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const buttons = getAllByRole('button'); + const trigger = buttons[0]; // First button is the trigger + + // Trigger should show selected item + expect(trigger).toHaveTextContent('Apple'); + + // Click clear button + const clearButton = getByTestId('PickerClearButton'); + await act(async () => { + await userEvent.click(clearButton); + }); + + // Should call onClear and onSelectionChange with null + expect(onClear).toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith(null); + + // Trigger should now show placeholder + expect(trigger).toHaveTextContent('Choose a fruit...'); + }); + + it('should work with multiple selection mode', async () => { + const onSelectionChange = jest.fn(); + + const { getAllByRole, getByTestId } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const buttons = getAllByRole('button'); + const trigger = buttons[0]; // First button is the trigger + + // Trigger should show selected items + expect(trigger).toHaveTextContent('Apple, Banana'); + + // Click clear button + const clearButton = getByTestId('PickerClearButton'); + await act(async () => { + await userEvent.click(clearButton); + }); + + // Should clear all selections + expect(onSelectionChange).toHaveBeenCalledWith([]); + + // Trigger should show placeholder + expect(trigger).toHaveTextContent('Choose fruits...'); + }); + }); + + describe('isCheckable prop functionality', () => { + it('should show checkboxes when isCheckable is true in multiple selection mode', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Look for checkboxes + const listbox = getByRole('listbox'); + const checkboxes = within(listbox).getAllByTestId(/CheckIcon/); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + it('should handle different click behaviors: checkbox click keeps popover open, content click closes popover', async () => { + const { getByRole, getByText, queryByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Click on the content area of an option (not the checkbox) + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + // For checkable items in multiple mode, content click should close the popover + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Popover should be closed + expect(queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + describe('showSelectAll functionality', () => { + it('should show select all option when showSelectAll is true', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Should show "All Fruits" option + expect(getByText('All Fruits')).toBeInTheDocument(); + }); + + it('should select all items when select all is clicked', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Click select all + await act(async () => { + await userEvent.click(getByText('All Fruits')); + }); + + // Should call onSelectionChange with "all" + expect(onSelectionChange).toHaveBeenCalledWith('all'); + }); + }); + + describe('Menu synchronization (event bus)', () => { + it('should close one Picker when another Picker opens', async () => { + const { getByRole, getAllByRole, getByText } = renderWithRoot( +
+ + {basicItems} + + + {basicItems} + +
, + ); + + // Wait for components to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const triggers = getAllByRole('button'); + const firstTrigger = triggers[0]; + const secondTrigger = triggers[1]; + + // Open first Picker + await act(async () => { + await userEvent.click(firstTrigger); + }); + + // Verify first Picker is open + expect(getByText('Apple')).toBeInTheDocument(); + + // Open second Picker - this should close the first one + await act(async () => { + await userEvent.click(secondTrigger); + }); + + // Wait for the events to propagate + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + // There should be only one listbox visible + const listboxes = getAllByRole('listbox'); + expect(listboxes).toHaveLength(1); + }); + }); + + describe('Form integration', () => { + it('should work with form field wrapper', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + expect(trigger).toBeInTheDocument(); + }); + }); + + describe('Refs', () => { + it('should forward ref to wrapper element', async () => { + const ref = createRef(); + + const { container } = renderWithRoot( + + {basicItems} + , + ); + + // Check that the component renders properly + expect(container.firstChild).toBeInTheDocument(); + }); + }); + + describe('Custom renderSummary', () => { + it('should use custom renderSummary function', () => { + const renderSummary = jest.fn(({ selectedLabels }) => { + if (!selectedLabels || selectedLabels.length === 0) return null; + return `${selectedLabels.length} selected`; + }); + + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + expect(trigger).toHaveTextContent('2 selected'); + expect(renderSummary).toHaveBeenCalled(); + }); + + it('should hide summary when renderSummary is false', () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + // Should not show the selected items + expect(trigger).not.toHaveTextContent('Apple'); + expect(trigger).not.toHaveTextContent('Banana'); + }); + }); + + describe('Items prop functionality', () => { + const itemsWithLabels = [ + { key: 'apple', label: 'Red Apple' }, + { key: 'banana', label: 'Yellow Banana' }, + { key: 'cherry', label: 'Sweet Cherry' }, + ]; + + it('should display labels correctly when using items prop', () => { + const { getByRole } = renderWithRoot( + + {(item) => {item.label}} + , + ); + + const trigger = getByRole('button'); + + // Should display the label + expect(trigger).toHaveTextContent('Red Apple'); + }); + }); +}); diff --git a/src/components/fields/Picker/Picker.tsx b/src/components/fields/Picker/Picker.tsx new file mode 100644 index 000000000..1ae41296f --- /dev/null +++ b/src/components/fields/Picker/Picker.tsx @@ -0,0 +1,781 @@ +import { CollectionChildren } from '@react-types/shared'; +import { + ForwardedRef, + forwardRef, + MutableRefObject, + ReactElement, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FocusScope, Key, useKeyboard } from 'react-aria'; +import { Section as BaseSection, ListState, useListState } from 'react-stately'; + +import { useEvent } from '../../../_internal'; +import { useWarn } from '../../../_internal/hooks/use-warn'; +import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons'; +import { useProviderProps } from '../../../provider'; +import { + BASE_STYLES, + BasePropsWithoutChildren, + BaseStyleProps, + COLOR_STYLES, + ColorStyleProps, + extractStyles, + filterBaseProps, + OUTER_STYLES, + OuterStyleProps, + Styles, + 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 { CubeItemBaseProps } from '../../content/ItemBase'; +import { Text } from '../../content/Text'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +import { Dialog, DialogTrigger } from '../../overlays/Dialog'; +import { CubeListBoxProps, ListBox } from '../ListBox/ListBox'; + +import type { FieldBaseProps } from '../../../shared'; + +export interface CubePickerProps + extends Omit, 'size' | 'tooltip'>, + Omit, + BasePropsWithoutChildren, + BaseStyleProps, + OuterStyleProps, + ColorStyleProps, + Omit, + Pick< + CubeItemButtonProps, + 'type' | 'theme' | 'icon' | 'rightIcon' | 'prefix' | 'suffix' | 'hotkeys' + > { + /** Placeholder text when no selection is made */ + placeholder?: string; + /** Size of the picker component */ + size?: 'small' | 'medium' | 'large'; + /** Custom styles for the list box popover */ + listBoxStyles?: Styles; + /** Custom styles for the popover container */ + popoverStyles?: Styles; + /** Custom styles for the trigger button */ + triggerStyles?: Styles; + /** Whether to show checkboxes for multiple selection mode */ + isCheckable?: boolean; + /** Whether to flip the popover placement */ + shouldFlip?: boolean; + /** Tooltip for the trigger button (separate from field tooltip) */ + triggerTooltip?: CubeItemBaseProps['tooltip']; + /** Description for the trigger button (separate from field description) */ + triggerDescription?: CubeItemBaseProps['description']; + + /** + * Custom renderer for the summary shown inside the trigger when there is a selection. + * + * For `selectionMode="multiple"` the function receives: + * - `selectedLabels`: array of labels of the selected items. + * - `selectedKeys`: array of keys of the selected items or "all". + * + * For `selectionMode="single"` the function receives: + * - `selectedLabel`: label of the selected item. + * - `selectedKey`: key of the selected item. + * + * The function should return a `ReactNode` that will be rendered inside the trigger. + * Set to `false` to hide the summary text completely. + */ + renderSummary?: + | ((args: { + selectedLabels?: string[]; + selectedKeys?: 'all' | (string | number)[]; + selectedLabel?: string; + selectedKey?: string | number | null; + selectionMode?: 'single' | 'multiple'; + }) => ReactNode) + | false; + + /** Ref to access internal ListBox state */ + listStateRef?: MutableRefObject>; + /** Additional modifiers for styling the Picker */ + mods?: Record; + /** Whether the picker is clearable using a clear button in the rightIcon slot */ + isClearable?: boolean; + /** Callback called when the clear button is pressed */ + onClear?: () => void; + /** + * Sort selected item(s) to the top when the popover opens. + * Only works when using the `items` prop (data-driven mode). + * Supports both single and multiple selection modes. + * @default true when items are provided, false when using JSX children + */ + sortSelectedToTop?: boolean; +} + +const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; + +const PickerWrapper = tasty({ + qa: 'PickerWrapper', + styles: { + display: 'inline-grid', + flow: 'column', + gridRows: '1sf', + placeContent: 'stretch', + placeItems: 'stretch', + }, +}); + +export const Picker = forwardRef(function Picker( + props: CubePickerProps, + ref: ForwardedRef, +) { + props = useProviderProps(props); + props = useFormProps(props); + props = useFieldProps(props, { + valuePropsMapper: ({ value, onChange }) => { + const fieldProps: Record = {}; + + if (props.selectionMode === 'multiple') { + fieldProps.selectedKeys = value || []; + } else { + fieldProps.selectedKey = value ?? null; + } + + fieldProps.onSelectionChange = (key: Key | null | 'all' | Key[]) => { + if (props.selectionMode === 'multiple') { + // Handle "all" selection and array selections + if (key === 'all') { + onChange('all'); + } else { + onChange(key ? (Array.isArray(key) ? key : [key]) : []); + } + } else { + onChange(Array.isArray(key) ? key[0] : key); + } + }; + + return fieldProps; + }, + }); + + let { + id, + qa, + label, + extra, + icon, + rightIcon, + prefix, + suffix, + hotkeys, + triggerTooltip, + triggerDescription, + labelStyles, + isRequired, + necessityIndicator, + validationState, + isDisabled, + isLoading, + message, + mods: externalMods, + description, + descriptionPlacement, + placeholder, + size = 'medium', + styles, + listBoxStyles, + popoverStyles, + type = 'outline', + theme = 'default', + labelSuffix, + shouldFocusWrap, + children, + shouldFlip = true, + selectedKey, + defaultSelectedKey, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onSelectionChange, + selectionMode = 'single', + listStateRef, + focusOnHover, + showSelectAll, + selectAllLabel = 'All', + items, + header, + footer, + headerStyles, + footerStyles, + triggerStyles, + renderSummary, + isCheckable, + allValueProps, + listStyles, + optionStyles, + sectionStyles, + headingStyles, + listRef, + disallowEmptySelection, + shouldUseVirtualFocus, + onEscape, + onOptionClick, + isClearable, + onClear, + sortSelectedToTop, + listStateRef: externalListStateRef, + ...otherProps + } = props; + + styles = extractStyles(otherProps, PROP_STYLES, styles); + + // Generate a unique ID for this Picker instance + const pickerId = useMemo(() => generateRandomId(), []); + + // Get event bus for menu synchronization + const { emit, on } = useEventBus(); + + // Warn if isCheckable is false in single selection mode + useWarn(isCheckable === false && selectionMode === 'single', { + key: ['picker-checkable-single-mode'], + args: [ + 'CubeUIKit: isCheckable=false is not recommended in single selection mode as it may confuse users about selection behavior.', + ], + }); + + // Internal selection state (uncontrolled scenario) + const [internalSelectedKey, setInternalSelectedKey] = useState( + defaultSelectedKey ?? null, + ); + const [internalSelectedKeys, setInternalSelectedKeys] = useState< + 'all' | Key[] + >(defaultSelectedKeys ?? []); + + // Track popover open/close and capture children order for session + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const triggerRef = useRef(null); + + const isControlledSingle = selectedKey !== undefined; + const isControlledMultiple = selectedKeys !== undefined; + + const effectiveSelectedKey = isControlledSingle + ? selectedKey + : internalSelectedKey; + const effectiveSelectedKeys = isControlledMultiple + ? selectedKeys + : internalSelectedKeys; + + // Given an iterable of keys (array or Set) toggle membership for duplicates + const processSelectionArray = (iterable: Iterable): string[] => { + const resultSet = new Set(); + for (const key of iterable) { + const nKey = String(key); + if (resultSet.has(nKey)) { + resultSet.delete(nKey); // toggle off if clicked twice + } else { + resultSet.add(nKey); // select + } + } + return Array.from(resultSet); + }; + + // Ref to access internal ListBox state for collection API + const internalListStateRef = useRef>(null); + + // Sync internal ref with external ref if provided + useEffect(() => { + if (externalListStateRef && internalListStateRef.current) { + externalListStateRef.current = internalListStateRef.current; + } + }, [externalListStateRef]); + + // Cache for sorted items array when using `items` prop + const cachedItemsOrder = useRef(null); + const selectionWhenClosed = useRef<{ + single: string | null; + multiple: string[]; + }>({ single: null, multiple: [] }); + + // Track if sortSelectedToTop was explicitly provided + const sortSelectedToTopExplicit = sortSelectedToTop !== undefined; + // Default to true if items are provided, false otherwise + const shouldSortSelectedToTop = sortSelectedToTop ?? (items ? true : false); + + // Invalidate cache when items change + useEffect(() => { + cachedItemsOrder.current = null; + }, [items]); + + // Capture selection when popover closes + useEffect(() => { + if (!isPopoverOpen) { + selectionWhenClosed.current = { + single: + effectiveSelectedKey != null ? String(effectiveSelectedKey) : null, + multiple: + selectionMode === 'multiple' && effectiveSelectedKeys !== 'all' + ? (effectiveSelectedKeys || []).map(String) + : [], + }; + cachedItemsOrder.current = null; + } + }, [ + isPopoverOpen, + effectiveSelectedKey, + effectiveSelectedKeys, + selectionMode, + ]); + + // Sort items with selected on top if enabled + const getSortedItems = useCallback((): typeof items => { + if (!items || !shouldSortSelectedToTop) return items; + + // Reuse cached order if available + if (cachedItemsOrder.current) { + return cachedItemsOrder.current; + } + + // Warn if explicitly requested but JSX children used + if (sortSelectedToTopExplicit && !items) { + console.warn( + 'Picker: sortSelectedToTop only works with the items prop. ' + + 'Sorting will be skipped when using JSX children.', + ); + return items; + } + + const selectedKeys = new Set(); + + if (selectionMode === 'multiple') { + // Don't sort when "all" is selected + if ( + selectionWhenClosed.current.multiple.length === 0 || + effectiveSelectedKeys === 'all' + ) { + return items; + } + selectionWhenClosed.current.multiple.forEach((k) => selectedKeys.add(k)); + } else if (selectionWhenClosed.current.single) { + selectedKeys.add(selectionWhenClosed.current.single); + } + + if (selectedKeys.size === 0) return items; + + const itemsArray = Array.isArray(items) ? items : Array.from(items); + const selectedItems: T[] = []; + const unselectedItems: T[] = []; + + itemsArray.forEach((item) => { + const key = (item as any)?.key ?? (item as any)?.id; + if (key != null && selectedKeys.has(String(key))) { + selectedItems.push(item); + } else { + unselectedItems.push(item); + } + }); + + const sorted = [...selectedItems, ...unselectedItems]; + + if (isPopoverOpen) { + cachedItemsOrder.current = sorted; + } + + return sorted; + }, [ + items, + shouldSortSelectedToTop, + sortSelectedToTopExplicit, + selectionMode, + effectiveSelectedKeys, + isPopoverOpen, + ]); + + const finalItems = getSortedItems(); + + // Create local collection state for reading item data (labels, etc.) + // This allows us to read item labels even before the popover opens + const localCollectionState = useListState({ + children, + items: finalItems, // Use sorted items to match what's shown in popover + selectionMode: 'none', // Don't manage selection in this state + }); + + // Helper to get label from local collection + const getItemLabel = useCallback( + (key: Key): string => { + const item = localCollectionState?.collection?.getItem(key); + return item?.textValue || String(key); + }, + [localCollectionState?.collection], + ); + + const selectedLabels = useMemo(() => { + const keysToGet = + selectionMode === 'multiple' && effectiveSelectedKeys !== 'all' + ? effectiveSelectedKeys || [] + : effectiveSelectedKey != null + ? [effectiveSelectedKey] + : []; + + // Handle "all" selection + if (selectionMode === 'multiple' && effectiveSelectedKeys === 'all') { + if (!localCollectionState?.collection) return []; + const labels: string[] = []; + for (const item of localCollectionState.collection) { + if (item.type === 'item') { + labels.push(item.textValue || String(item.key)); + } + } + return labels; + } + + // Get labels for selected keys + return keysToGet.map((key) => getItemLabel(key)).filter(Boolean); + }, [ + selectionMode, + effectiveSelectedKeys, + effectiveSelectedKey, + getItemLabel, + localCollectionState?.collection, + ]); + + const hasSelection = selectedLabels.length > 0; + + const renderTriggerContent = () => { + // When there is a selection and a custom summary renderer is provided – use it. + if (hasSelection && typeof renderSummary === 'function') { + if (selectionMode === 'single') { + return renderSummary({ + selectedLabel: selectedLabels[0], + selectedKey: effectiveSelectedKey ?? null, + selectedLabels, + selectedKeys: effectiveSelectedKeys, + selectionMode: 'single', + }); + } + + return renderSummary({ + selectedLabels, + selectedKeys: effectiveSelectedKeys, + selectionMode: 'multiple', + }); + } else if (hasSelection && renderSummary === false) { + return null; + } + + let content: ReactNode = ''; + + if (!hasSelection) { + content = placeholder; + } else if (selectionMode === 'single') { + content = selectedLabels[0]; + } else if (effectiveSelectedKeys === 'all') { + content = selectAllLabel; + } else { + content = selectedLabels.join(', '); + } + + if (!content) { + return null; + } + + return ( + + {content} + + ); + }; + + const [shouldUpdatePosition, setShouldUpdatePosition] = useState(true); + + // The trigger is rendered as a function so we can access the dialog state + const renderTrigger = (state) => { + // Listen for other menus opening and close this one if needed + useEffect(() => { + const unsubscribe = on('popover:open', (data: { menuId: string }) => { + // If another menu is opening and this Picker is open, close this one + if (data.menuId !== pickerId && state.isOpen) { + state.close(); + } + }); + + return unsubscribe; + }, [on, pickerId, state]); + + // Emit event when this Picker opens + useEffect(() => { + if (state.isOpen) { + emit('popover:open', { menuId: pickerId }); + } + }, [state.isOpen, emit, pickerId]); + + // Track popover open/close state to control sorting + useEffect(() => { + if (state.isOpen !== isPopoverOpen) { + setIsPopoverOpen(state.isOpen); + } + }, [state.isOpen, isPopoverOpen]); + + // Add keyboard support for arrow keys to open the popover + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !state.isOpen) { + e.preventDefault(); + state.open(); + } + }, + }); + + useEffect(() => { + // Allow initial positioning & flipping when opening, then lock placement after transition + // Popover transition is ~120ms, give it a bit more time to finalize placement + if (state.isOpen) { + setShouldUpdatePosition(true); + const id = window.setTimeout(() => setShouldUpdatePosition(false), 160); + return () => window.clearTimeout(id); + } else { + setShouldUpdatePosition(true); + } + }, [state.isOpen]); + + // Clear button logic + let showClearButton = + isClearable && hasSelection && !isDisabled && !props.isReadOnly; + + // Clear function + let clearValue = useEvent(() => { + if (selectionMode === 'multiple') { + if (!isControlledMultiple) { + setInternalSelectedKeys([]); + } + onSelectionChange?.([]); + } else { + if (!isControlledSingle) { + setInternalSelectedKey(null); + } + onSelectionChange?.(null); + } + + if (state.isOpen) { + state.close(); + } + + triggerRef?.current?.focus?.(); + + onClear?.(); + + return false; + }); + + return ( + + ) : rightIcon !== undefined ? ( + rightIcon + ) : showClearButton ? ( + } + size={size} + theme={validationState === 'invalid' ? 'danger' : undefined} + qa="PickerClearButton" + mods={{ pressed: false }} + onPress={clearValue} + /> + ) : ( + + ) + } + prefix={prefix} + suffix={suffix} + hotkeys={hotkeys} + tooltip={triggerTooltip} + description={triggerDescription} + descriptionPlacement={descriptionPlacement} + styles={styles} + {...keyboardProps} + aria-label={`${props['aria-label'] ?? props.label ?? ''}`} + > + {renderTriggerContent()} + + ); + }; + + const pickerField = ( + + { + const menuTriggerEl = el.closest('[data-popover-trigger]'); + // If no menu trigger was clicked, allow closing + if (!menuTriggerEl) return true; + // If the same trigger that opened this popover was clicked, allow closing (toggle) + if (menuTriggerEl === (triggerRef as any)?.current) return true; + // Otherwise, don't close here. Let the event bus handle closing when the other opens. + return false; + }} + > + {renderTrigger} + {(close) => ( + + + close()} + onOptionClick={(key) => { + // For Picker, clicking the content area should close the popover + // in multiple selection mode (single mode already closes via onSelectionChange) + if ( + (selectionMode === 'multiple' && isCheckable) || + key === '__ALL__' + ) { + close(); + } + }} + onSelectionChange={(selection) => { + // No need to change any flags - children order is cached + + // Update internal state if uncontrolled + if (selectionMode === 'single') { + if (!isControlledSingle) { + setInternalSelectedKey(selection as Key | null); + } + } else { + if (!isControlledMultiple) { + let normalized: 'all' | Key[] = selection as + | 'all' + | Key[]; + + if (selection === 'all') { + normalized = 'all'; + } else if (Array.isArray(selection)) { + normalized = processSelectionArray(selection); + } else if ( + selection && + typeof selection === 'object' && + (selection as any) instanceof Set + ) { + normalized = processSelectionArray( + selection as Set, + ); + } + + setInternalSelectedKeys(normalized); + } + } + + onSelectionChange?.(selection); + + if (selectionMode === 'single') { + close(); + } + }} + > + {children as CollectionChildren} + + + + )} + + + ); + + return wrapWithField, 'children' | 'tooltip'>>( + pickerField, + ref as any, + mergeProps( + { + ...props, + children: undefined, + styles: undefined, + }, + {}, + ), + ); +}) as unknown as (( + props: CubePickerProps & { ref?: ForwardedRef }, +) => ReactElement) & { Item: typeof ListBox.Item; Section: typeof BaseSection }; + +Picker.Item = ListBox.Item; + +Picker.Section = BaseSection; + +Object.defineProperty(Picker, 'cubeInputType', { + value: 'Picker', + enumerable: false, + configurable: false, +}); diff --git a/src/components/fields/Picker/index.tsx b/src/components/fields/Picker/index.tsx new file mode 100644 index 000000000..16dddcc52 --- /dev/null +++ b/src/components/fields/Picker/index.tsx @@ -0,0 +1,2 @@ +export { Picker } from './Picker'; +export type { CubePickerProps } from './Picker'; diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 09502b340..813705ac8 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -16,4 +16,5 @@ export * from './LegacyComboBox'; export * from './ListBox'; export * from './FilterListBox'; export * from './FilterPicker'; +export * from './Picker'; export * from './TextInputMapper'; diff --git a/src/components/overlays/Dialog/Dialog.tsx b/src/components/overlays/Dialog/Dialog.tsx index e32f56224..9294d94c7 100644 --- a/src/components/overlays/Dialog/Dialog.tsx +++ b/src/components/overlays/Dialog/Dialog.tsx @@ -52,6 +52,7 @@ const DialogElement = tasty({ '[data-type="fullscreen"]': '90vh 90vh', '[data-type="fullscreenTakeover"] | [data-type="panel"]': '100vh 100vh', '[data-type="panel"]': 'auto', + '[data-type="popover"]': 'initial initial (50vh - 5x)', }, gap: 0, border: { diff --git a/src/test/setup.ts b/src/test/setup.ts index 600391f5c..148189a79 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -67,6 +67,13 @@ const suppressedConsoleError = (...args: any[]) => { ) { return; } + // Nested button warnings + if ( + msg.includes('cannot contain a nested') || + msg.includes('cannot be a descendant') + ) { + return; + } } return originalError.call(console, ...args); };