diff --git a/.changeset/swift-colts-sing.md b/.changeset/swift-colts-sing.md new file mode 100644 index 000000000..f5dba1305 --- /dev/null +++ b/.changeset/swift-colts-sing.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix position of ComboBox and Select popovers. diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index ff0bd93c3..40d79640b 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -36,6 +36,7 @@ import { Styles, tasty, } from '../../../tasty'; +import { chainRaf } from '../../../utils/raf'; import { generateRandomId } from '../../../utils/random'; import { mergeProps, @@ -867,17 +868,16 @@ function ComboBoxOverlay({ popoverRef as any, ); - // Update position when overlay opens + // Update position when overlay opens or content changes useLayoutEffect(() => { - if (isOpen) { - // Use double RAF to ensure layout is complete before positioning - requestAnimationFrame(() => { - requestAnimationFrame(() => { - updatePosition?.(); - }); - }); + if (isOpen && updatePosition) { + // Use triple RAF to ensure layout is complete before positioning + // This gives enough time for the DisplayTransition and content to render + return chainRaf(() => { + updatePosition(); + }, 3); } - }, [isOpen, updatePosition]); + }, [isOpen]); // Extract primary placement direction for consistent styling const placementDirection = placement?.split(' ')[0] || direction; diff --git a/src/components/fields/LegacyComboBox/LegacyComboBox.docs.mdx b/src/components/fields/LegacyComboBox/LegacyComboBox.docs.mdx deleted file mode 100644 index 502c97eb8..000000000 --- a/src/components/fields/LegacyComboBox/LegacyComboBox.docs.mdx +++ /dev/null @@ -1,297 +0,0 @@ -import { Meta, Canvas, Story, Controls } from '@storybook/addon-docs/blocks'; -import { LegacyComboBox } from './LegacyComboBox'; -import * as LegacyComboBoxStories from './LegacyComboBox.stories'; - - - -# LegacyComboBox - -A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query. It supports both selecting from predefined options and entering custom values, making it versatile for various data entry scenarios. - -## When to Use - -- Allow users to select from a large list of options with filtering capability -- Enable custom value entry when predefined options aren't sufficient -- Provide autocomplete functionality for form inputs -- Offer a searchable dropdown for better user experience with extensive lists -- Support both structured selection and free-text input in one component - -## Component - - - ---- - -### Properties - - - -### Base Properties - -Supports [Base properties](/docs/tasty-base-properties--docs) - -### Styling Properties - -#### styles - -Customizes the root wrapper element of the component. - -**Sub-elements:** -- `Prefix` - Element displayed before the input (icon area) -- `Suffix` - Element displayed after the input (trigger area) -- `State` - Container for validation state indicators -- `InputIcon` - Icon displayed within the input area -- `ValidationIcon` - Icon displayed for validation state - -#### inputStyles - -Customizes the text input element specifically. - -#### popoverStyles - -Customizes the dropdown popover container. - -### Style Properties - -The LegacyComboBox component supports all standard style properties: - -`display`, `font`, `preset`, `hide`, `opacity`, `whiteSpace`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`, `reset`, `padding`, `paddingInline`, `paddingBlock`, `shadow`, `border`, `radius`, `overflow`, `scrollbar`, `outline`, `textAlign`, `color`, `fill`, `fade`, `textTransform`, `fontWeight`, `fontStyle`, `flow`, `placeItems`, `placeContent`, `alignItems`, `alignContent`, `justifyItems`, `justifyContent`, `align`, `justify`, `gap`, `columnGap`, `rowGap`, `gridColumns`, `gridRows`, `gridTemplate`, `gridAreas` - -### Modifiers - -The `mods` property accepts the following modifiers you can override: - -| Modifier | Type | Description | -|----------|------|-------------| -| opened | `boolean` | Whether the dropdown is open | -| focused | `boolean` | Whether the input has focus | -| disabled | `boolean` | Whether the combo box is disabled | -| invalid | `boolean` | Whether the combo box has validation errors | -| valid | `boolean` | Whether the combo box is valid | -| loading | `boolean` | Whether the combo box is in loading state | -| hovered | `boolean` | Whether the combo box is being hovered | -| inside-form | `boolean` | Whether the combo box is inside a form field | - -## Sub-components - -### LegacyComboBox.Item - -Individual option items within the combo box dropdown. Each item is rendered using [ItemBase](/docs/content-itembase--docs) and supports all ItemBase properties for layout, icons, descriptions, and interactive features. - -#### Item API - -For detailed information about all available item properties, see [ItemBase documentation](/docs/content-itembase--docs). Key properties include: - -| Property | Type | Description | -|----------|------|-------------| -| key | `string \| number` | Unique identifier for the item (required) | -| children | `ReactNode` | The main content/label for the option | -| 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 | -| prefix | `ReactNode` | Content before the main text | -| suffix | `ReactNode` | Content after the main text | -| tooltip | `string \| boolean \| object` | Tooltip configuration | -| styles | `Styles` | Custom styling for the item | -| qa | `string` | QA identifier for testing | - -#### Example with Rich Items - -```jsx - - } - description="JavaScript library" - > - React - - } - description="Progressive framework" - suffix="Popular" - > - Vue.js - - -``` - -### LegacyComboBox.Section - -Groups related options together with an optional heading. - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| title | `ReactNode` | - | Optional heading text for the section | -| children | `LegacyComboBox.Item[]` | - | Collection of LegacyComboBox.Item components | - -## Variants - -### Sizes - -- `small` - Compact size for dense interfaces (28px height) -- `medium` - Standard size for general interfaces (32px height, default) -- `large` - Emphasized size for important selections (40px height) - -## Examples - -### Basic Usage - -```jsx - - Red - Blue - Green - -``` - -### With Custom Values - -```jsx - - React - Vue - Angular - -``` - -### With Icon - -```jsx -} - placeholder="Search currencies..." -> - US Dollar - Euro - British Pound - -``` - -### Loading State - -```jsx - - {/* Items will be populated when loading completes */} - -``` - -### With Validation - -```jsx - - Option 1 - Option 2 - -``` - -### Disabled State - -```jsx - - Option 1 - -``` - -### Small Size - -```jsx - - Low - Medium - High - -``` - -## Accessibility - -### Keyboard Navigation - -- `Tab` - Moves focus to the combo box -- `Arrow Down` - Opens the dropdown and moves to the first/next option -- `Arrow Up` - Moves to the previous option -- `Enter` - Selects the focused option and closes dropdown -- `Escape` - Closes the dropdown without selecting -- `Home` - Moves to the first option -- `End` - Moves to the last option -- `Page Up/Down` - Moves by page in long lists -- Typing - Filters options based on input text - -### Screen Reader Support - -- Component announces as "combobox" to screen readers -- Current selection and filtered results are announced -- Loading and validation states are communicated -- Option count and position are announced during navigation - -### ARIA Properties - -- `aria-label` - Provides accessible label when no visible label exists -- `aria-labelledby` - References external label elements -- `aria-describedby` - References additional descriptive text -- `aria-expanded` - Indicates if dropdown is open -- `aria-activedescendant` - References the currently focused option -- `aria-autocomplete` - Indicates autocomplete behavior (list/both) -- `aria-required` - Indicates if selection is required -- `aria-invalid` - Indicates validation state - -## Best Practices - -1. **Do**: Provide clear, descriptive labels and placeholders - ```jsx - - ``` - -2. **Don't**: Use combo box for small lists where Select would be simpler - ```jsx - {/* Use Switch or RadioGroup instead */} - ``` - -3. **Performance**: Use dynamic loading for large datasets -4. **Filtering**: Provide meaningful filter results and handle empty states -5. **Custom Values**: Clearly indicate when custom values are allowed -6. **Validation**: Provide helpful error messages for validation failures - -## Integration with Forms - -This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. - -## Suggested Improvements - -- Add support for multi-selection with chips/tags display -- Implement grouping for categorized options -- Add support for option descriptions or secondary text -- Consider adding async loading with debounced search -- Implement keyboard shortcuts for common actions - -## Related Components - -- [Select](/docs/forms-select--docs) - For simple selection without filtering -- [TextInput](/docs/forms-textinput--docs) - For free-text input without options -- [SearchInput](/docs/forms-searchinput--docs) - For search-only functionality without selection diff --git a/src/components/fields/LegacyComboBox/LegacyComboBox.stories.tsx b/src/components/fields/LegacyComboBox/LegacyComboBox.stories.tsx deleted file mode 100644 index 70d06854c..000000000 --- a/src/components/fields/LegacyComboBox/LegacyComboBox.stories.tsx +++ /dev/null @@ -1,588 +0,0 @@ -import { Meta, StoryFn } from '@storybook/react-vite'; -import { IconCoin } from '@tabler/icons-react'; -import { userEvent, within } from 'storybook/test'; - -import { baseProps } from '../../../stories/lists/baseProps'; -import { wait } from '../../../test/utils/wait'; -import { Button } from '../../actions/index'; -import { Field, Form } from '../../form/index'; -import { Flow } from '../../layout/Flow'; - -import { CubeLegacyComboBoxProps, LegacyComboBox } from './LegacyComboBox'; - -export default { - title: 'Forms/LegacyComboBox', - component: LegacyComboBox, - subcomponents: { Item: LegacyComboBox.Item, Section: LegacyComboBox.Section }, - args: { id: 'name', width: '200px', label: 'Choose your favourite color' }, - parameters: { controls: { exclude: baseProps } }, - argTypes: { - /* Content */ - children: { - control: { type: null }, - description: - 'LegacyComboBox.Item elements that define the available options', - }, - placeholder: { - control: { type: 'text' }, - description: 'Placeholder text when input is empty', - }, - icon: { - control: { type: null }, - description: 'Icon element rendered before the input', - }, - inputValue: { - control: { type: 'text' }, - description: 'The current text value in controlled mode', - }, - defaultInputValue: { - control: { type: 'text' }, - description: 'The default text value in uncontrolled mode', - }, - - /* Selection */ - selectedKey: { - control: { type: 'text' }, - description: 'The currently selected key (controlled)', - }, - defaultSelectedKey: { - control: { type: 'text' }, - description: 'The key of the initially selected item (uncontrolled)', - }, - allowsCustomValue: { - control: { type: 'boolean' }, - description: 'Whether the combo box allows custom values to be entered', - table: { - defaultValue: { summary: false }, - }, - }, - isClearable: { - control: { type: 'boolean' }, - description: - 'Whether the combo box is clearable using ESC keyboard button or clear button inside the input', - table: { - defaultValue: { summary: false }, - }, - }, - - /* Behavior */ - menuTrigger: { - options: ['focus', 'input', 'manual'], - control: { type: 'radio' }, - description: 'How the menu is triggered', - table: { - defaultValue: { summary: 'input' }, - }, - }, - loadingState: { - options: [undefined, 'loading', 'filtering', 'loadingMore'], - control: { type: 'radio' }, - description: 'The current loading state of the LegacyComboBox', - }, - - /* Presentation */ - size: { - options: ['small', 'medium', 'large'], - control: { type: 'radio' }, - description: 'LegacyComboBox size', - table: { - defaultValue: { summary: 'medium' }, - }, - }, - - /* State */ - isDisabled: { - control: { type: 'boolean' }, - description: 'Whether the input is disabled', - table: { - defaultValue: { summary: false }, - }, - }, - isReadOnly: { - control: { type: 'boolean' }, - description: 'Whether the input can be selected but not changed', - table: { - defaultValue: { summary: false }, - }, - }, - isRequired: { - control: { type: 'boolean' }, - description: 'Whether user input is required before form submission', - table: { - defaultValue: { summary: false }, - }, - }, - validationState: { - options: [undefined, 'valid', 'invalid'], - control: { type: 'radio' }, - description: - 'Whether the input should display valid or invalid visual styling', - }, - autoFocus: { - control: { type: 'boolean' }, - description: 'Whether the element should receive focus on render', - table: { - defaultValue: { summary: false }, - }, - }, - - /* Events */ - onSelectionChange: { - action: 'selection-change', - description: 'Callback fired when the selected option changes', - control: { type: null }, - }, - onInputChange: { - action: 'input-change', - description: 'Callback fired when the input text changes', - control: { type: null }, - }, - onOpenChange: { - action: 'open-change', - description: 'Callback fired when the dropdown opens or closes', - control: { type: null }, - }, - onBlur: { - action: (e) => ({ type: 'blur', target: e?.target?.tagName }), - description: 'Callback fired when the input loses focus', - control: { type: null }, - }, - onFocus: { - action: (e) => ({ type: 'focus', target: e?.target?.tagName }), - description: 'Callback fired when the input receives focus', - control: { type: null }, - }, - }, -} as Meta>; - -const Template: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => ( - <> - - Red - Orange - Yellow - Green - Blue - Purple - Violet - - -); - -const TemplateForm: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => { - const [form] = Form.useForm(); - - return ( - -
console.log('! submit', data)} - > - - - {args.allowsCustomValue ? 'red' : 'Red'} - - - {args.allowsCustomValue ? 'orange' : 'Orange'} - - - {args.allowsCustomValue ? 'yellow' : 'Yellow'} - - - {args.allowsCustomValue ? 'green' : 'Green'} - - - {args.allowsCustomValue ? 'blue' : 'Blue'} - - - {args.allowsCustomValue ? 'purple' : 'Purple'} - - - {args.allowsCustomValue ? 'violet' : 'Violet'} - - -
- -
- ); -}; - -const TemplateFormPropagation: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => { - const [form] = Form.useForm(); - - return ( - -
console.log('! submit', data)} - > - - - {args.allowsCustomValue ? 'red' : 'Red'} - - - {args.allowsCustomValue ? 'orange' : 'Orange'} - - - {args.allowsCustomValue ? 'yellow' : 'Yellow'} - - - {args.allowsCustomValue ? 'green' : 'Green'} - - - {args.allowsCustomValue ? 'blue' : 'Blue'} - - - {args.allowsCustomValue ? 'purple' : 'Purple'} - - - {args.allowsCustomValue ? 'violet' : 'Violet'} - - - Submit -
-
- ); -}; - -const TemplateLegacyForm: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => { - const [form] = Form.useForm(); - - return ( - -
console.log('! Submit', data)} - > - - - - {args.allowsCustomValue ? 'red' : 'Red'} - - - {args.allowsCustomValue ? 'orange' : 'Orange'} - - - {args.allowsCustomValue ? 'yellow' : 'Yellow'} - - - {args.allowsCustomValue ? 'green' : 'Green'} - - - {args.allowsCustomValue ? 'blue' : 'Blue'} - - - {args.allowsCustomValue ? 'purple' : 'Purple'} - - - {args.allowsCustomValue ? 'violet' : 'Violet'} - - - -
- -
- ); -}; - -export const Default = Template.bind({}); -Default.args = {}; - -export const Small = Template.bind({}); -Small.args = { size: 'small' }; - -export const Large = Template.bind({}); -Large.args = { size: 'large' }; - -export const WithPlaceholder = Template.bind({}); -WithPlaceholder.args = { placeholder: 'Enter a value' }; - -export const WithDefaultValue = Template.bind({}); -WithDefaultValue.args = { defaultSelectedKey: 'purple' }; - -export const WithIcon = Template.bind({}); -WithIcon.args = { icon: }; - -export const Invalid = Template.bind({}); -Invalid.args = { selectedKey: 'yellow', validationState: 'invalid' }; - -export const Valid = Template.bind({}); -Valid.args = { selectedKey: 'yellow', validationState: 'valid' }; - -export const Disabled = Template.bind({}); -Disabled.args = { selectedKey: 'yellow', isDisabled: true }; - -export const Clearable = Template.bind({}); -Clearable.args = { - defaultSelectedKey: 'purple', - isClearable: true, - placeholder: 'Choose a color...', -}; - -export const Wide: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => ( - - - Red lorem ipsum dolor sit amet, consectetur adipiscing elit. - - - Blue lorem ipsum dolor sit amet, consectetur adipiscing elit. - - -); - -Wide.args = { width: '600px', defaultSelectedKey: 'red' }; - -export const With1LongOption: StoryFn> = ( - args: CubeLegacyComboBoxProps, -) => ( - - Red - Orange - Yellow - - green lorem ipsum dolor sit amet, consectetur adipiscing elit - - Blue - Purple - Violet - -); -With1LongOption.parameters = { layout: 'centered' }; -With1LongOption.play = async ({ canvasElement }) => { - const { getByRole } = within(canvasElement); - - const openButton = getByRole('button'); - - await userEvent.click(openButton); -}; - -export const With1LongOptionFiltered = With1LongOption.bind({}); -With1LongOptionFiltered.parameters = { layout: 'centered' }; -With1LongOptionFiltered.play = async ({ canvasElement }) => { - const { getByRole } = within(canvasElement); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 'Red'); -}; - -export const WithinForm = TemplateForm.bind({}); -WithinForm.play = async ({ canvasElement }) => { - const { getByRole, getByTestId } = within(canvasElement); - - const combobox = getByRole('combobox'); - const trigger = getByTestId('LegacyComboBoxTrigger'); - const button = getByTestId('Button'); - const input = getByTestId('Input'); - - await userEvent.click(combobox); - await userEvent.click(trigger); - await wait(250); - await userEvent.click(button); - await userEvent.type( - input, - '{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}blue', - ); -}; - -export const WithinFormSelected = TemplateForm.bind({}); -WithinFormSelected.play = async ({ canvasElement }) => { - const { getByRole, getByTestId } = within(canvasElement); - - const combobox = getByRole('combobox'); - const trigger = getByTestId('LegacyComboBoxTrigger'); - const button = getByTestId('Button'); - const input = getByTestId('Input'); - - await userEvent.click(combobox); - await userEvent.click(trigger); - await wait(250); - await userEvent.click(button); - await userEvent.type( - input, - '{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}blue{enter}', - ); -}; - -export const WithinFormWithCustomValue = TemplateForm.bind({}); -WithinFormWithCustomValue.play = WithinForm.play; -WithinFormWithCustomValue.args = { - ...TemplateForm.args, - allowsCustomValue: true, -}; - -export const WithinFormWithLegacyFieldAndCustomValue = TemplateLegacyForm.bind( - {}, -); -WithinFormWithLegacyFieldAndCustomValue.play = WithinForm.play; -WithinFormWithLegacyFieldAndCustomValue.args = { - ...TemplateForm.args, - allowsCustomValue: true, -}; - -export const WithinFormStopEnterPropagation = TemplateFormPropagation.bind({}); -WithinFormStopEnterPropagation.play = async ({ canvasElement }) => { - const { getByTestId } = within(canvasElement); - - const input = getByTestId('Input'); - - await userEvent.type( - input, - '{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}blurring{enter}', - ); -}; - -export const WithinFormStopBlurPropagation = TemplateFormPropagation.bind({}); -WithinFormStopBlurPropagation.play = async ({ canvasElement }) => { - const { getByTestId } = within(canvasElement); - - const input = getByTestId('Input'); - - await userEvent.type(input, '!'); - const button = getByTestId('Button'); - await userEvent.click(button); -}; - -export const ItemsWithDescriptions: StoryFn> = ( - args, -) => ( - - - Red - - - Orange - - - Yellow - - -); -ItemsWithDescriptions.args = {}; -ItemsWithDescriptions.play = async ({ canvasElement }) => { - const { getByTestId } = within(canvasElement); - - const trigger = getByTestId('LegacyComboBoxTrigger'); - - await userEvent.click(trigger); -}; - -// --------------------------------- -// Section stories for LegacyComboBox -// --------------------------------- - -export const SectionsStatic: StoryFn> = (args) => ( - - - Red - Orange - Yellow - - - Teal - Cyan - - - Blue - Purple - - -); - -SectionsStatic.storyName = 'Sections – static items'; -SectionsStatic.play = ItemsWithDescriptions.play; - -export const SectionsDynamic: StoryFn> = ( - args, -) => { - const groups = [ - { - name: 'Fruits', - children: [ - { id: 'apple', label: 'Apple' }, - { id: 'orange', label: 'Orange' }, - { id: 'banana', label: 'Banana' }, - ], - }, - { - name: 'Vegetables', - children: [ - { id: 'carrot', label: 'Carrot' }, - { id: 'peas', label: 'Peas' }, - { id: 'broccoli', label: 'Broccoli' }, - ], - }, - ]; - - return ( - - {(group: any) => ( - - {(item: any) => ( - - {item.label} - - )} - - )} - - ); -}; - -SectionsDynamic.storyName = 'Sections – dynamic collection'; -SectionsDynamic.play = ItemsWithDescriptions.play; diff --git a/src/components/fields/LegacyComboBox/LegacyComboBox.tsx b/src/components/fields/LegacyComboBox/LegacyComboBox.tsx deleted file mode 100644 index 9c591fd91..000000000 --- a/src/components/fields/LegacyComboBox/LegacyComboBox.tsx +++ /dev/null @@ -1,567 +0,0 @@ -import { - cloneElement, - ForwardedRef, - forwardRef, - ReactElement, - RefObject, - useEffect, - useMemo, -} from 'react'; -import { - AriaComboBoxProps, - AriaTextFieldProps, - useButton, - useComboBox, - useFilter, - useHover, - useOverlayPosition, -} from 'react-aria'; -import { Section as BaseSection, useComboBoxState } from 'react-stately'; - -import { useEvent } from '../../../_internal/index'; -import { CloseIcon, DownIcon, LoadingIcon } from '../../../icons'; -import { useProviderProps } from '../../../provider'; -import { FieldBaseProps } from '../../../shared'; -import { - BASE_STYLES, - COLOR_STYLES, - extractStyles, - OUTER_STYLES, - tasty, -} from '../../../tasty'; -import { generateRandomId } from '../../../utils/random'; -import { - mergeProps, - useCombinedRefs, - useLayoutEffect, -} from '../../../utils/react'; -import { useFocus } from '../../../utils/react/interactions'; -import { useEventBus } from '../../../utils/react/useEventBus'; -import { ItemAction } from '../../actions'; -import { useFieldProps, useFormProps, wrapWithField } from '../../form'; -import { Item } from '../../Item'; -import { OverlayWrapper } from '../../overlays/OverlayWrapper'; -import { InvalidIcon } from '../../shared/InvalidIcon'; -import { ValidIcon } from '../../shared/ValidIcon'; -import { DEFAULT_INPUT_STYLES, INPUT_WRAPPER_STYLES } from '../index'; -import { ListBoxPopup } from '../Select'; - -import type { KeyboardDelegate, LoadingState } from '@react-types/shared'; -import type { CubeSelectBaseProps } from '../Select'; - -type FilterFn = (textValue: string, inputValue: string) => boolean; - -export type MenuTriggerAction = 'focus' | 'input' | 'manual'; - -const LegacyComboBoxWrapperElement = tasty({ - styles: INPUT_WRAPPER_STYLES, -}); - -const InputElement = tasty({ - as: 'input', - styles: DEFAULT_INPUT_STYLES, -}); - -const TriggerElement = tasty({ - as: 'button', - type: 'neutral', - styles: { - display: 'grid', - placeItems: 'center', - placeContent: 'center', - placeSelf: 'stretch', - radius: '(1r - 1bw) right', - padding: '0', - width: '3x', - boxSizing: 'border-box', - color: { - '': '#dark-02', - hovered: '#dark-02', - pressed: '#purple', - disabled: '#dark.30', - }, - border: 'left', - reset: 'button', - margin: 0, - fill: { - '': '#dark.0', - hovered: '#dark.04', - pressed: '#purple.10', - disabled: '#clear', - }, - cursor: 'pointer', - }, -}); - -export interface CubeLegacyComboBoxProps - extends Omit< - CubeSelectBaseProps, - 'onOpenChange' | 'onBlur' | 'onFocus' | 'validate' | 'onSelectionChange' - >, - Omit, 'errorMessage'>, - Omit, - FieldBaseProps { - defaultSelectedKey?: string | null; - selectedKey?: string | null; - onSelectionChange?: (selectedKey: string | null) => void; - onInputChange?: (inputValue: string) => void; - inputValue?: string; - placeholder?: string; - icon?: ReactElement; - multiLine?: boolean; - autoComplete?: string; - wrapperRef?: RefObject; - inputRef?: RefObject; - /** The ref for the list box popover. */ - popoverRef?: RefObject; - /** The ref for the list box. */ - listBoxRef?: RefObject; - /** An optional keyboard delegate implementation, to override the default. */ - keyboardDelegate?: KeyboardDelegate; - loadingState?: LoadingState; - /** - * The filter function used to determine if a option should be included in the combo box list. - * Has no effect when `items` is provided. - */ - filter?: FilterFn; - size?: 'small' | 'medium' | 'large' | (string & {}); - suffixPosition?: 'before' | 'after'; - menuTrigger?: MenuTriggerAction; - allowsCustomValue?: boolean; - /** Whether the combo box is clearable using ESC keyboard button or clear button inside the input */ - isClearable?: boolean; - /** Callback called when the clear button is pressed */ - onClear?: () => void; -} - -const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; - -export const LegacyComboBox = forwardRef(function LegacyComboBox< - T extends object, ->(props: CubeLegacyComboBoxProps, ref: ForwardedRef) { - props = useProviderProps(props); - props = useFormProps(props); - props = useFieldProps(props, { - valuePropsMapper: ({ value, onChange }) => { - return { - selectedKey: !props.allowsCustomValue ? value ?? null : undefined, - inputValue: props.allowsCustomValue ? value ?? '' : undefined, - onInputChange(val) { - if (!props.allowsCustomValue) { - return; - } - - onChange(val); - }, - onSelectionChange(val: string) { - if (val == null && props.allowsCustomValue) { - return; - } - - onChange(val); - }, - }; - }, - }); - - let { - qa, - label, - extra, - labelStyles, - isRequired, - necessityIndicator, - validationState, - icon, - prefix, - isDisabled, - multiLine, - autoFocus, - wrapperRef, - inputRef, - triggerRef, - popoverRef, - listBoxRef, - isLoading, - loadingIndicator, - overlayOffset = 8, - inputStyles, - optionStyles, - triggerStyles, - listBoxStyles, - overlayStyles, - wrapperStyles, - suffix, - hideTrigger, - message, - description, - size = 'medium', - autoComplete = 'off', - direction = 'bottom', - shouldFlip = true, - menuTrigger = 'input', - suffixPosition = 'before', - loadingState, - filter, - styles, - labelSuffix, - selectedKey, - defaultSelectedKey, - isClearable, - form, - ...otherProps - } = props; - - let isAsync = loadingState != null; - let { contains } = useFilter({ sensitivity: 'base' }); - - let comboBoxStateProps: any = { - ...props, - defaultFilter: filter || contains, - filter: undefined, - allowsEmptyCollection: isAsync, - menuTrigger, - }; - - let state = useComboBoxState(comboBoxStateProps); - - // Generate a unique ID for this combobox instance - const comboBoxId = useMemo(() => generateRandomId(), []); - - // Get event bus for menu synchronization - const { emit, on } = useEventBus(); - - // 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 combobox is open, close this one - if (data.menuId !== comboBoxId && state.isOpen) { - state.close(); - } - }); - - return unsubscribe; - }, [on, comboBoxId, state]); - - // Emit event when this combobox opens - useEffect(() => { - if (state.isOpen) { - emit('popover:open', { menuId: comboBoxId }); - } - }, [state.isOpen, emit, comboBoxId]); - - styles = extractStyles(otherProps, PROP_STYLES, styles); - - ref = useCombinedRefs(ref); - wrapperRef = useCombinedRefs(wrapperRef); - inputRef = useCombinedRefs(inputRef); - triggerRef = useCombinedRefs(triggerRef); - popoverRef = useCombinedRefs(popoverRef); - listBoxRef = useCombinedRefs(listBoxRef); - - let { overlayProps, placement, updatePosition } = useOverlayPosition({ - targetRef: triggerRef, - overlayRef: popoverRef, - scrollRef: listBoxRef, - placement: `${direction} end`, - shouldFlip: shouldFlip, - isOpen: state.isOpen, - onClose: state.close, - offset: overlayOffset, - }); - - let { - labelProps, - inputProps, - listBoxProps, - buttonProps: triggerProps, - } = useComboBox( - { - ...comboBoxStateProps, - inputRef, - buttonRef: triggerRef, - listBoxRef, - popoverRef, - menuTrigger, - }, - state, - ); - - let { isFocused, focusProps } = useFocus({ isDisabled }); - let { hoverProps, isHovered } = useHover({ isDisabled }); - - // Get props for the button based on the trigger props from useComboBox - let { buttonProps, isPressed: isTriggerPressed } = useButton( - triggerProps, - triggerRef, - ); - let { hoverProps: triggerHoverProps, isHovered: isTriggerHovered } = useHover( - { isDisabled }, - ); - let { focusProps: triggerFocusProps, isFocused: isTriggerFocused } = useFocus( - { isDisabled }, - true, - ); - - useLayoutEffect(() => { - if (state.isOpen) { - updatePosition(); - } - }, [state.isOpen, state.collection.size]); - - let isInvalid = validationState === 'invalid'; - - let validationIcon = isInvalid ? InvalidIcon : ValidIcon; - let validation = cloneElement(validationIcon); - - // Clear button logic - let hasValue = props.allowsCustomValue - ? state.inputValue !== '' - : state.selectedKey != null; - let showClearButton = - isClearable && hasValue && !isDisabled && !props.isReadOnly; - - // Clear function - let clearValue = useEvent(() => { - // Always clear input value in state so UI resets to placeholder - state.setInputValue(''); - // Notify external input value only when custom value mode is enabled - if (props.allowsCustomValue) { - props.onInputChange?.(''); - } - props.onSelectionChange?.(null); - state.setSelectedKey(null); - - // Close the popup if it's open - if (state.isOpen) { - state.close(); - } - // Focus back to the input - inputRef.current?.focus(); - - props.onClear?.(); - }); - - let comboBoxWidth = wrapperRef?.current?.offsetWidth; - - if (icon) { - icon =
{icon}
; - - if (prefix) { - prefix = ( - <> - {icon} - {prefix} - - ); - } else { - prefix = icon; - } - } - - let mods = useMemo( - () => ({ - invalid: isInvalid, - valid: validationState === 'valid', - disabled: isDisabled, - hovered: isHovered, - focused: isFocused, - loading: isLoading, - prefix: !!prefix, - suffix: true, - clearable: showClearButton, - }), - [ - isInvalid, - validationState, - isDisabled, - isHovered, - isFocused, - isLoading, - prefix, - showClearButton, - ], - ); - - // If input is not full and the user presses Enter, pick the first option. - let onKeyPress = useEvent((e: KeyboardEvent) => { - if (!props.onSelectionChange) { - return; - } - - if (e.key === 'Enter') { - if (!props.allowsCustomValue) { - if (state.isOpen) { - // If there is a selected option then do nothing. It will be selected on Enter anyway. - if (listBoxRef.current?.querySelector('li[aria-selected="true"]')) { - return; - } - - const option = [...state.collection][0]?.key; - - if (option && selectedKey !== option) { - props.onSelectionChange?.(option); - - e.stopPropagation(); - e.preventDefault(); - } - } else if ( - inputRef.current?.value && - ![...state.collection] - .map((i) => i.textValue) - .includes(inputRef.current?.value) - ) { - // If the input value is not in the collection, we need to prevent the submitting of the form. - // Also, we reset value manually. - e.preventDefault(); - props.onSelectionChange?.(null); - } - // If a custom value is allowed, we need to check if the input value is in the collection. - } else if (props.allowsCustomValue) { - const inputValue = inputRef?.current?.value; - - const item = [...state.collection].find( - (item) => item.textValue.toLowerCase() === inputValue?.toLowerCase(), - ); - - props.onSelectionChange?.( - item ? item.key : inputRef?.current?.value ?? '', - ); - } - } - }); - - let onBlur = useEvent((e: FocusEvent) => { - // If the input value is not in the collection, we need to reset the value. - if ( - !props.allowsCustomValue && - inputRef.current?.value && - ![...state.collection] - .map((i) => i.textValue) - .includes(inputRef.current?.value) - ) { - props.onSelectionChange?.(null); - } - }); - - useEffect(() => { - inputRef.current?.addEventListener('keydown', onKeyPress, true); - inputRef.current?.addEventListener('blur', onBlur, true); - - return () => { - inputRef.current?.removeEventListener('keydown', onKeyPress, true); - inputRef.current?.removeEventListener('blur', onBlur, true); - }; - }, []); - - let allInputProps = useMemo( - () => mergeProps(inputProps, hoverProps, focusProps), - [inputProps, hoverProps, focusProps], - ); - - let comboBoxField = ( - - {prefix ?
{prefix}
: null} - -
- {suffixPosition === 'before' ? suffix : null} - {validationState || isLoading ? ( - <> - {validationState && !isLoading ? validation : null} - {isLoading ? : null} - - ) : null} - {suffixPosition === 'after' ? suffix : null} - {showClearButton && ( - } - size={size} - theme={validationState === 'invalid' ? 'danger' : undefined} - qa="LegacyComboBoxClearButton" - data-no-trigger={hideTrigger ? '' : undefined} - onPress={clearValue} - /> - )} - {!hideTrigger ? ( - - - - ) : null} -
- - - -
- ); - - return wrapWithField, 'children'>>( - comboBoxField, - ref, - mergeProps({ ...props, styles }, { labelProps }), - ); -}) as unknown as (( - props: CubeLegacyComboBoxProps & { ref?: ForwardedRef }, -) => ReactElement) & { Item: typeof Item; Section: typeof BaseSection }; - -type SectionComponentCB = typeof BaseSection; - -const LegacyComboBoxSectionComponent = Object.assign(BaseSection, { - displayName: 'Section', -}) as SectionComponentCB; - -LegacyComboBox.Item = Item; - -LegacyComboBox.Section = LegacyComboBoxSectionComponent; - -Object.defineProperty(LegacyComboBox, 'cubeInputType', { - value: 'LegacyComboBox', - enumerable: false, - configurable: false, -}); diff --git a/src/components/fields/LegacyComboBox/index.ts b/src/components/fields/LegacyComboBox/index.ts deleted file mode 100644 index 52610f9f2..000000000 --- a/src/components/fields/LegacyComboBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './LegacyComboBox'; diff --git a/src/components/fields/LegacyComboBox/legacy-combobox.test.tsx b/src/components/fields/LegacyComboBox/legacy-combobox.test.tsx deleted file mode 100644 index 443140eec..000000000 --- a/src/components/fields/LegacyComboBox/legacy-combobox.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { renderWithForm, renderWithRoot, userEvent } from '../../../test/index'; -import { Field } from '../../form'; - -import { LegacyComboBox } from './LegacyComboBox'; - -const items = [ - { key: 'red', children: 'Red' }, - { key: 'orange', children: 'Orange' }, - { key: 'yellow', children: 'Yellow' }, - { key: 'green', children: 'Green' }, - { key: 'blue', children: 'Blue' }, - { key: 'purple', children: 'Purple' }, - { key: 'violet', children: 'Violet' }, -]; - -jest.mock('../../../_internal/hooks/use-warn'); - -describe.skip('', () => { - it('should provide suggestions', async () => { - const { getByRole, getAllByRole } = renderWithRoot( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 're'); - - const listbox = getByRole('listbox'); - const options = getAllByRole('option', listbox); - - expect(options).toHaveLength(2); - expect(options[0]).toHaveTextContent('Red'); - expect(options[1]).toHaveTextContent('Green'); - }); - - it('should select an option', async () => { - const { getByRole, getAllByRole, queryByRole } = renderWithRoot( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 're'); - await userEvent.click(getAllByRole('option')[0]); - - expect(combobox).toHaveValue('Red'); - expect(combobox).toHaveAttribute('aria-expanded', 'false'); - expect(queryByRole('listbox')).not.toBeInTheDocument(); - }); - - it.skip('should interop with ', async () => { - const { getByRole, getAllByRole, formInstance } = renderWithForm( - - - {items.map((item) => ( - - {item.children} - - ))} - - , - ); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 're'); - await userEvent.click(getAllByRole('option')[0]); - - expect(formInstance.getFieldValue('test')).toBe('red'); - }); - - it('should interop with
', async () => { - const { getByRole, getAllByRole, formInstance } = renderWithForm( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 're'); - - const listbox = getByRole('listbox'); - const options = getAllByRole('option', listbox); - - expect(options).toHaveLength(2); - - await userEvent.click(getAllByRole('option')[0]); - - expect(formInstance.getFieldValue('test')).toBe('red'); - }); - - it('should support custom filter', async () => { - const filterFn = jest.fn((textValue, inputValue) => - textValue.toLowerCase().startsWith(inputValue.toLowerCase()), - ); - - const { getByRole, getAllByRole } = renderWithForm( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - const combobox = getByRole('combobox'); - - await userEvent.type(combobox, 're'); - - const listbox = getByRole('listbox'); - const options = getAllByRole('option', listbox); - - expect(filterFn).toHaveBeenCalled(); - expect(options).toHaveLength(1); - }); - - it('should have qa', () => { - const { getByTestId } = renderWithRoot( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - expect(getByTestId('test')).toBeInTheDocument(); - }); - - it('should have data-qa', () => { - const { getByTestId } = renderWithRoot( - - {items.map((item) => ( - - {item.children} - - ))} - , - ); - - expect(getByTestId('test')).toBeInTheDocument(); - }); -}); diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index f70263b6b..b97d1204b 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -42,6 +42,7 @@ import { Styles, tasty, } from '../../../tasty/index'; +import { chainRaf } from '../../../utils/raf'; import { generateRandomId } from '../../../utils/random'; import { forwardRefWithGenerics, @@ -525,15 +526,14 @@ export function ListBoxPopup({ // Update position when overlay opens useLayoutEffect(() => { - if (state.isOpen) { - // Use double RAF to ensure layout is complete before positioning - requestAnimationFrame(() => { - requestAnimationFrame(() => { - updatePosition?.(); - }); - }); + if (state.isOpen && updatePosition) { + // Use triple RAF to ensure layout is complete before positioning + // This gives enough time for the DisplayTransition and content to render + return chainRaf(() => { + updatePosition(); + }, 3); } - }, [state.isOpen, updatePosition]); + }, [state.isOpen]); // Get props for the listbox let { listBoxProps } = useListBox( diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 813705ac8..955f03dd4 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -12,7 +12,6 @@ export * from './Slider'; export * from './Switch/Switch'; export * from './Select'; export * from './ComboBox'; -export * from './LegacyComboBox'; export * from './ListBox'; export * from './FilterListBox'; export * from './FilterPicker'; diff --git a/src/components/overlays/OverlayWrapper.tsx b/src/components/overlays/OverlayWrapper.tsx index 87149ba3f..4d58b8e11 100644 --- a/src/components/overlays/OverlayWrapper.tsx +++ b/src/components/overlays/OverlayWrapper.tsx @@ -2,7 +2,6 @@ import { ReactNode, useRef } from 'react'; import { Placement } from 'react-aria'; import { CSSTransition } from 'react-transition-group'; -import { OverlayTransitionCSSProps } from '../../utils/transitions'; import { Portal } from '../portal'; export interface CubeOverlayWrapperProps { @@ -24,23 +23,6 @@ export function OverlayWrapper({ }: CubeOverlayWrapperProps) { const containerRef = useRef(container); const nodeRef = useRef(null); - const options: OverlayTransitionCSSProps = {}; - - if (typeof minOffset === 'number') { - minOffset = `${minOffset}px`; - } - - if (placement != null) { - options.placement = placement; - } - - if (minScale != null) { - options.minScale = minScale; - } - - if (minOffset != null) { - options.minOffset = minOffset; - } const contents = ( { + * updatePosition(); + * }, 3); + * + * // Later, if needed: + * cancel(); + */ +export function chainRaf(callback: () => void, count: number = 1): () => void { + let rafId: number | null = null; + let cancelled = false; + + const scheduleNext = (remaining: number) => { + if (cancelled) return; + + if (remaining <= 0) { + callback(); + return; + } + + rafId = requestAnimationFrame(() => { + scheduleNext(remaining - 1); + }); + }; + + scheduleNext(count); + + return () => { + cancelled = true; + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; +} diff --git a/src/utils/transitions.ts b/src/utils/transitions.ts deleted file mode 100644 index d97ff2f14..000000000 --- a/src/utils/transitions.ts +++ /dev/null @@ -1,72 +0,0 @@ -const DIRECTION_MAP = { - initial: 'top center', - top: 'bottom center', - right: 'center left', - left: 'center right', - bottom: 'top center', -}; -const TRANSLATE_MAP = { - initial: 'translate(0px, calc(-1 * var(--overlay-offset)))', - top: 'translate(0px, calc(1 * var(--overlay-offset)))', - right: 'translate(calc(-1 * var(--overlay-offset)), 0px)', - left: 'translate(calc(1 * var(--overlay-offset)), 0px)', - bottom: 'translate(0px, calc(-1 * var(--overlay-offset)))', -}; - -export interface OverlayTransitionCSSProps { - suffix?: string; - placement?: string; - minScale?: string | number; - minOffset?: string; - forChild?: boolean; -} - -export const getOverlayTransitionCSS = ({ - suffix = '', - placement = 'initial', - minScale = 0.8, - minOffset = '8px', -}: OverlayTransitionCSSProps = {}) => ` - &${suffix} { - transform: var(--overlay-position); - transform-origin: ${DIRECTION_MAP[placement]}; - --overlay-offset: ${minOffset}; - --overlay-hidden-x-scale: ${ - placement === 'top' || placement === 'bottom' || placement === 'initial' - ? '1' - : minScale - }; - --overlay-hidden-y-scale: ${ - placement === 'left' || placement === 'right' ? '1' : minScale - }; - --overlay-translate-visible: translate(0px, 0px); - --overlay-translate-hidden: ${TRANSLATE_MAP[placement]}; - --overlay-transition: 120ms; - --overlay-hidden-scale: scale(var(--overlay-hidden-x-scale), var(--overlay-hidden-y-scale)); - --overlay-normal-scale: scale(1, 1); - } - - &.cube-overlay-transition-enter${suffix} { - opacity: 0; - transform: var(--overlay-translate-hidden) var(--overlay-hidden-scale); - } - - &.cube-overlay-transition-enter-active${suffix} { - opacity: 1; - transform: var(--overlay-translate-visible) var(--overlay-normal-scale); - transition: transform var(--overlay-transition) cubic-bezier(0, .66, 0, .66), opacity var(--overlay-transition) cubic-bezier(0, .66, 0, .66); - pointer-events: none; - } - - &.cube-overlay-transition-exit${suffix} { - opacity: 1; - transform: var(--overlay-translate-visible) var(--overlay-normal-scale); - } - - &.cube-overlay-transition-exit-active${suffix} { - opacity: 0; - transform: var(--overlay-translate-hidden) var(--overlay-hidden-scale); - pointer-events: none; - transition: transform var(--overlay-transition) cubic-bezier(.66, 0, .66, 0), opacity var(--overlay-transition) cubic-bezier(.66, 0, .66, 0); - } -`;