diff --git a/.changeset/rare-spies-obey.md b/.changeset/rare-spies-obey.md new file mode 100644 index 000000000..636d319c2 --- /dev/null +++ b/.changeset/rare-spies-obey.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Unify the focused state in Menu component. diff --git a/.changeset/short-bottles-fold.md b/.changeset/short-bottles-fold.md new file mode 100644 index 000000000..d0680107d --- /dev/null +++ b/.changeset/short-bottles-fold.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix errorMessage type. diff --git a/src/components/fields/Checkbox/CheckboxGroup.tsx b/src/components/fields/Checkbox/CheckboxGroup.tsx index 742d05865..833fcf2f9 100644 --- a/src/components/fields/Checkbox/CheckboxGroup.tsx +++ b/src/components/fields/Checkbox/CheckboxGroup.tsx @@ -46,7 +46,7 @@ const CheckGroupElement = tasty({ export interface CubeCheckboxGroupProps extends BaseProps, - AriaCheckboxGroupProps, + Omit, FieldBaseProps, ContainerStyleProps { orientation?: 'vertical' | 'horizontal'; diff --git a/src/components/fields/ComboBox/ComboBox.stories.tsx b/src/components/fields/ComboBox/ComboBox.stories.tsx index ef30f4c13..12127dcd2 100644 --- a/src/components/fields/ComboBox/ComboBox.stories.tsx +++ b/src/components/fields/ComboBox/ComboBox.stories.tsx @@ -2,7 +2,6 @@ import { Meta, StoryFn } from '@storybook/react'; import { userEvent, within } from '@storybook/test'; import { IconCoin } from '@tabler/icons-react'; -import { SELECTED_KEY_ARG } from '../../../stories/FormFieldArgs'; import { baseProps } from '../../../stories/lists/baseProps'; import { wait } from '../../../test/utils/wait'; import { Button } from '../../actions/index'; @@ -12,7 +11,7 @@ import { Flow } from '../../layout/Flow'; import { ComboBox, CubeComboBoxProps } from './ComboBox'; export default { - title: 'Pickers/ComboBox', + title: 'Forms/ComboBox', component: ComboBox, subcomponents: { Item: ComboBox.Item, Section: ComboBox.Section }, args: { id: 'name', width: '200px', label: 'Choose your favourite color' }, diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index f60307d5a..0428deb26 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -22,6 +22,7 @@ import { Section as BaseSection, Item, useComboBoxState } from 'react-stately'; import { useEvent } from '../../../_internal/index'; import { DownIcon, LoadingIcon } from '../../../icons'; import { useProviderProps } from '../../../provider'; +import { FieldBaseProps } from '../../../shared'; import { BASE_STYLES, COLOR_STYLES, @@ -96,8 +97,9 @@ export interface CubeComboBoxProps CubeSelectBaseProps, 'onOpenChange' | 'onBlur' | 'onFocus' | 'validate' | 'onSelectionChange' >, - AriaComboBoxProps, - AriaTextFieldProps { + Omit, 'errorMessage'>, + Omit, + FieldBaseProps { defaultSelectedKey?: string | null; selectedKey?: string | null; onSelectionChange?: (selectedKey: string | null) => void; diff --git a/src/components/fields/DatePicker/DateInput.tsx b/src/components/fields/DatePicker/DateInput.tsx index 7f20b27ca..561bc456d 100644 --- a/src/components/fields/DatePicker/DateInput.tsx +++ b/src/components/fields/DatePicker/DateInput.tsx @@ -28,7 +28,7 @@ import { DEFAULT_DATE_PROPS } from './props'; import { formatSegments, useFocusManagerRef } from './utils'; export interface CubeDateInputProps - extends AriaDateFieldProps, + extends Omit, 'errorMessage'>, BaseProps, ContainerStyleProps, FieldBaseProps { diff --git a/src/components/fields/DatePicker/DatePicker.tsx b/src/components/fields/DatePicker/DatePicker.tsx index 0a932283a..52bd1b385 100644 --- a/src/components/fields/DatePicker/DatePicker.tsx +++ b/src/components/fields/DatePicker/DatePicker.tsx @@ -33,7 +33,7 @@ import { DateFieldBase } from './types'; import { useFocusManagerRef } from './utils'; export interface CubeDatePickerProps - extends AriaDatePickerProps, + extends Omit, 'errorMessage'>, DateFieldBase, BaseProps, ContainerStyleProps, diff --git a/src/components/fields/DatePicker/DatePickerInput.tsx b/src/components/fields/DatePicker/DatePickerInput.tsx index f41002d69..8fd7c321d 100644 --- a/src/components/fields/DatePicker/DatePickerInput.tsx +++ b/src/components/fields/DatePicker/DatePickerInput.tsx @@ -26,7 +26,7 @@ const DateInputElement = tasty({ }); interface CubeDatePickerInputProps - extends AriaDatePickerProps, + extends Omit, 'errorMessage'>, DateFieldBase { hideValidationIcon?: boolean; maxGranularity?: Granularity; diff --git a/src/components/fields/DatePicker/DateRangePicker.tsx b/src/components/fields/DatePicker/DateRangePicker.tsx index 74fe99468..8d2f6715b 100644 --- a/src/components/fields/DatePicker/DateRangePicker.tsx +++ b/src/components/fields/DatePicker/DateRangePicker.tsx @@ -44,7 +44,7 @@ const DateRangeDash = tasty({ }); export interface CubeDateRangePickerProps - extends AriaDateRangePickerProps, + extends Omit, 'errorMessage'>, BaseProps, DateFieldBase, ContainerStyleProps, diff --git a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx index 191ecb493..a705064b6 100644 --- a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx +++ b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx @@ -46,7 +46,7 @@ const DateRangeDash = tasty({ export interface CubeDateRangeSeparatedPickerProps< T extends DateValue = DateValue, -> extends AriaDateRangePickerProps, +> extends Omit, 'errorMessage'>, BaseProps, ContainerStyleProps, DateFieldBase, diff --git a/src/components/fields/DatePicker/TimeInput.tsx b/src/components/fields/DatePicker/TimeInput.tsx index 25b477f88..1c501f81f 100644 --- a/src/components/fields/DatePicker/TimeInput.tsx +++ b/src/components/fields/DatePicker/TimeInput.tsx @@ -26,7 +26,7 @@ import { Granularity } from './types'; import { useFocusManagerRef } from './utils'; export interface CubeTimeInputProps - extends AriaTimeFieldProps, + extends Omit, 'errorMessage'>, BaseProps, ContainerStyleProps, FieldBaseProps { diff --git a/src/components/fields/ListBox/ListBox.docs.mdx b/src/components/fields/ListBox/ListBox.docs.mdx new file mode 100644 index 000000000..9266e3c42 --- /dev/null +++ b/src/components/fields/ListBox/ListBox.docs.mdx @@ -0,0 +1,451 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/blocks'; +import { ListBox } from './ListBox'; +import * as ListBoxStories from './ListBox.stories'; + + + +# ListBox + +A list box component that allows users to select one or more items from a list of options. It supports sections, descriptions, optional search functionality, and full keyboard navigation with virtual focus. Built with React Aria's `useListBox` for accessibility and the Cube `tasty` style system for theming. + +## When to Use + +- Present a list of selectable options in a contained area +- Enable single or multiple selection from a set of choices +- Provide searchable selection for large lists of options +- Display structured data with sections and descriptions +- Create custom selection interfaces that need to remain visible +- Build form controls that require persistent option visibility + +## 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:** +- `ValidationState` - Container for validation and loading indicators + +#### searchInputStyles + +Customizes the search input when `isSearchable` is true. + +#### listStyles + +Customizes the list container element. + +#### optionStyles + +Customizes individual option elements. + +**Sub-elements:** +- `Label` - The main text of each option +- `Description` - Secondary descriptive text for options + +#### sectionStyles + +Customizes section wrapper elements. + +#### headingStyles + +Customizes section heading elements. + +### Style Properties + +The ListBox 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 | +|----------|------|-------------| +| invalid | `boolean` | Whether the ListBox has validation errors | +| valid | `boolean` | Whether the ListBox is valid | +| disabled | `boolean` | Whether the ListBox is disabled | +| focused | `boolean` | Whether the ListBox has focus | +| loading | `boolean` | Whether the ListBox is in loading state | +| searchable | `boolean` | Whether the ListBox includes search functionality | + +## Variants + +### Sizes + +- `small` - Compact size for dense interfaces +- `default` - Standard size +- `large` - Emphasized size for important selections + +## Examples + +### Basic Usage + +```jsx + + Apple + Banana + Cherry + +``` + +### With Search + + + +```jsx + + Apple + Banana + Cherry + {/* More items... */} + +``` + +### With Descriptions + + + +```jsx + + + React + + + Vue.js + + + Angular + + +``` + +### With Sections + + + +```jsx + + + Apple + Banana + + + Carrot + Broccoli + + +``` + +### Multiple Selection + + + +```jsx + + HTML + CSS + JavaScript + React + +``` + +### Controlled Selection + + + +```jsx +const [selectedKey, setSelectedKey] = useState('apple'); + + + Apple + Banana + Cherry + +``` + +### Different Sizes + +```jsx + + Option 1 + + + + Option 1 + +``` + +### Validation States + + + +```jsx + + Valid Option + + + + Option 1 + +``` + +### Disabled State + + + +```jsx + + Option 1 + Option 2 + +``` + +### Search with Loading State + + + +```jsx + + Option 1 + +``` + +## Accessibility + +### Keyboard Navigation + +#### Search Field Navigation (when `isSearchable` is true) + +- `Tab` - Moves focus to the search input +- `Arrow Down/Up` - Moves virtual focus through options while keeping input focused +- `Enter` - Selects the currently highlighted option +- `Space` - In multiple selection mode, toggles selection of the highlighted option +- `Left/Right Arrow` - Moves text cursor within the search input (normal text editing) +- `Escape` - Clears the search input + +#### Direct ListBox Navigation (when search is not used) + +- `Tab` - Moves focus to the ListBox +- `Arrow Down/Up` - Moves focus to the next/previous option + +### Screen Reader Support + +- Component announces as "listbox" to screen readers +- Current selection and total options are announced +- Section headings are properly associated with their options +- Option descriptions are read along with option labels +- When using search, the currently highlighted option is announced via `aria-activedescendant` +- Virtual focus ensures smooth navigation without focus jumps +- Loading and validation states are communicated + +### 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-multiselectable` - Indicates if multiple selection is allowed +- `aria-activedescendant` - References the currently highlighted option (especially with search) +- `aria-controls` - Links the search input to the listbox it controls +- `aria-required` - Indicates if selection is required +- `aria-invalid` - Indicates validation state + +## Best Practices + +1. **Do**: Provide clear, descriptive labels and option text + ```jsx + + + JavaScript + + + ``` + +2. **Don't**: Use ListBox for navigation or actions + ```jsx + {/* Use Menu or navigation components instead */} + Go Home + Logout + + ``` + +3. **Performance**: Enable search for lists with more than 10-15 options +4. **Organization**: Use sections to group related options logically +5. **Descriptions**: Provide helpful descriptions for complex or technical options +6. **Validation**: Provide clear error messages for validation failures +7. **Selection**: Consider multiple selection for scenarios where users might need several options +8. **Search Experience**: When using search, ensure the virtual focus behavior feels natural to users +9. **Keyboard Users**: Test that arrow key navigation works smoothly with the search input + +## Integration with Forms + + + +This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. + +```jsx +
+ + + React + Vue.js + + + Node.js + Python + + + + Submit +
+``` + +## Integration with Popover Dialog + + + +ListBox can be effectively used inside a popover Dialog controlled by DialogTrigger to create dropdown-style selection interfaces that provide more space and functionality than traditional Select components. By removing the Dialog's default padding and border, the ListBox appears directly as the popover content. + +```jsx +import { useState } from 'react'; +import { Button } from '../../actions/Button/Button'; +import { Dialog } from '../../overlays/Dialog/Dialog'; +import { DialogTrigger } from '../../overlays/Dialog/DialogTrigger'; + +const [selectedKey, setSelectedKey] = useState(null); + + + + + + + + React + + + Vue.js + + + Angular + + + + + Node.js + + + Python + + + Java + + + + + +``` + +### Benefits of ListBox in Popover + +- **More Space**: Unlike traditional Select dropdowns, popovers can accommodate larger lists with descriptions and sections +- **Rich Content**: Support for descriptions, icons, and complex layouts within options +- **Search Functionality**: Built-in search makes it easy to find options in large lists +- **Better Accessibility**: Full keyboard navigation and screen reader support +- **Custom Positioning**: Flexible placement options relative to the trigger button + +### Best Practices for Popover Integration + +1. **Clean Appearance**: Remove Dialog's default padding and border (`padding: 0, border: false`) and apply border/radius directly to ListBox for a seamless popover appearance +2. **Auto Focus**: Use `autoFocus` to automatically focus the search input when the popover opens, improving keyboard navigation +3. **Size Management**: Set appropriate width and height constraints to prevent the popover from becoming too large +4. **Placement**: Use `placement="bottom start"` or similar to ensure good positioning relative to the trigger +5. **Search**: Enable search for lists with many options to improve user experience +6. **Selection Feedback**: Display the current selection outside the popover so users know what's selected +7. **Mobile Considerations**: The popover will automatically convert to a modal on mobile devices + +## Suggested Improvements + +- Add support for custom option rendering with more complex layouts +- Implement virtual scrolling for very large lists (1000+ items) +- Add support for option groups with different selection behaviors +- Consider adding drag-and-drop reordering functionality +- Implement async loading with pagination for dynamic data +- Add support for option icons and avatars +- Consider adding keyboard shortcuts for common actions (select all, clear all) +- Enhance search with fuzzy matching and highlighting of matched text +- Add support for custom filtering functions beyond simple text matching + +## Related Components + +- [Select](/docs/pickers-select--docs) - For dropdown selection that saves space +- [ComboBox](/docs/pickers-combobox--docs) - For searchable selection with text input +- [RadioGroup](/docs/forms-radiogroup--docs) - For single selection with radio buttons +- [Checkbox](/docs/forms-checkbox--docs) - For multiple selection with checkboxes +- [Menu](/docs/pickers-menu--docs) - For action-oriented lists and navigation \ No newline at end of file diff --git a/src/components/fields/ListBox/ListBox.stories.tsx b/src/components/fields/ListBox/ListBox.stories.tsx new file mode 100644 index 000000000..8c87c7798 --- /dev/null +++ b/src/components/fields/ListBox/ListBox.stories.tsx @@ -0,0 +1,552 @@ +import { StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { baseProps } from '../../../stories/lists/baseProps'; +import { Button } from '../../actions/Button/Button'; +import { Content } from '../../content/Content'; +import { Header } from '../../content/Header'; +import { Title } from '../../content/Title'; +import { Form } from '../../form'; +import { Dialog } from '../../overlays/Dialog/Dialog'; +import { DialogTrigger } from '../../overlays/Dialog/DialogTrigger'; + +import { CubeListBoxProps, ListBox } from './ListBox'; + +export default { + title: 'Forms/ListBox', + component: ListBox, + parameters: { + controls: { + exclude: baseProps, + }, + }, + argTypes: { + /* Content */ + selectedKey: { + control: { type: 'text' }, + description: 'The selected key in controlled mode', + }, + defaultSelectedKey: { + control: { type: 'text' }, + description: 'The default selected key in uncontrolled mode', + }, + selectionMode: { + options: ['single', 'multiple', 'none'], + control: { type: 'radio' }, + description: 'Selection mode', + table: { + defaultValue: { summary: 'single' }, + }, + }, + + /* Search */ + isSearchable: { + control: { type: 'boolean' }, + description: 'Whether the ListBox includes a search input', + table: { + defaultValue: { summary: false }, + }, + }, + searchPlaceholder: { + control: { type: 'text' }, + description: 'Placeholder text for the search input', + table: { + defaultValue: { summary: 'Search...' }, + }, + }, + autoFocus: { + control: { type: 'boolean' }, + description: 'Whether the search input should have autofocus', + table: { + defaultValue: { summary: false }, + }, + }, + + /* Presentation */ + size: { + options: ['small', 'default', 'large'], + control: { type: 'radio' }, + description: 'ListBox size', + table: { + defaultValue: { summary: 'default' }, + }, + }, + + /* State */ + isDisabled: { + control: { type: 'boolean' }, + description: 'Whether the ListBox is disabled', + table: { + defaultValue: { summary: false }, + }, + }, + SearchLoadingState: { + control: { type: 'boolean' }, + description: 'Whether the listbox is loading. Works only with search.', + table: { + defaultValue: { summary: false }, + }, + }, + isRequired: { + control: { type: 'boolean' }, + description: 'Whether selection is required', + table: { + defaultValue: { summary: false }, + }, + }, + validationState: { + options: ['valid', 'invalid'], + control: { type: 'radio' }, + description: 'Validation state', + }, + + /* Events */ + onSelectionChange: { + action: 'selection changed', + description: 'Callback when selection changes', + }, + }, +}; + +const Template: StoryFn> = (args) => ( + + Apple + Banana + Cherry + Date + Elderberry + +); + +export const Default = Template.bind({}); +Default.args = { + label: 'Select a fruit', + selectionMode: 'single', +}; + +export const WithSearch: StoryFn> = (args) => ( + + Apple + Banana + Cherry + Date + Elderberry + Fig + Grape + Honeydew + Kiwi + Lemon + +); +WithSearch.args = { + label: 'Search fruits', + isSearchable: true, + searchPlaceholder: 'Type to search fruits...', + selectionMode: 'single', +}; + +export const WithDescriptions: StoryFn> = (args) => ( + + + React + + + Vue.js + + + Angular + + + Svelte + + +); +WithDescriptions.args = { + label: 'Choose a framework', + selectionMode: 'single', +}; + +export const WithSections: StoryFn> = (args) => ( + + + Apple + Banana + Cherry + + + Carrot + Broccoli + Spinach + + + Rice + Wheat + Oats + + +); +WithSections.args = { + label: 'Select food items', + selectionMode: 'single', +}; + +export const WithSearchAndSections: StoryFn> = (args) => ( + + + + React + + + Vue.js + + + Angular + + + + + Node.js + + + Python + + + Java + + + + + PostgreSQL + + + MongoDB + + + Redis + + + +); +WithSearchAndSections.args = { + label: 'Choose technologies', + isSearchable: true, + searchPlaceholder: 'Search technologies...', + selectionMode: 'single', +}; + +export const MultipleSelection: StoryFn> = (args) => ( + + HTML + CSS + JavaScript + TypeScript + React + Vue.js + Angular + +); +MultipleSelection.args = { + label: 'Select skills (multiple)', + selectionMode: 'multiple', + isSearchable: true, + searchPlaceholder: 'Search skills...', +}; + +export const DisabledState: StoryFn> = (args) => ( + + Option 1 + Option 2 + Option 3 + +); +DisabledState.args = { + label: 'Disabled ListBox', + isDisabled: true, + selectionMode: 'single', +}; + +export const SearchLoadingState: StoryFn> = (args) => ( + + Option 1 + Option 2 + Option 3 + +); +SearchLoadingState.args = { + label: 'Loading ListBox', + isSearchable: true, + searchPlaceholder: 'Search...', + isLoading: true, + selectionMode: 'single', +}; + +export const ValidationStates: StoryFn> = () => ( +
+ + Valid Option + Another Option + + + + Option 1 + Option 2 + +
+); + +export const ControlledExample: StoryFn> = () => { + const [selectedKey, setSelectedKey] = useState('apple'); + + return ( +
+ setSelectedKey(key as string | null)} + > + Apple + Banana + Cherry + Date + + +

Selected: {selectedKey || 'None'}

+ +
+ + +
+
+ ); +}; + +export const InForm: StoryFn> = () => { + const handleSubmit = (data: any) => { + console.log('Form submitted:', data); + alert(`Selected: ${data.technology || 'None'}`); + }; + + return ( +
+ + + + React + + + Vue.js + + + Angular + + + + + Node.js + + + Python + + + Java + + + + + Submit +
+ ); +}; + +export const InPopover: StoryFn> = () => { + const [selectedKey, setSelectedKey] = useState(null); + + return ( +
+

+ Selected technology: {selectedKey || 'None'} +

+ + + + + setSelectedKey(key as string | null)} + > + + + React + + + Vue.js + + + Angular + + + Svelte + + + + + Node.js + + + Python + + + Java + + + C# + + + + + PostgreSQL + + + MongoDB + + + Redis + + + MySQL + + + + + +
+ ); +}; + +InPopover.parameters = { + docs: { + description: { + story: + 'ListBox can be used inside a Dialog controlled by DialogTrigger to create popover-style selection interfaces.', + }, + }, +}; + +InPopover.play = async ({ canvasElement }) => { + const canvas = canvasElement; + const button = canvas.querySelector('button'); + + if (button) { + // Simulate clicking the button to open the popover + button.click(); + + // Wait a moment for the popover to open and autoFocus to take effect + await new Promise((resolve) => setTimeout(resolve, 100)); + } +}; diff --git a/src/components/fields/ListBox/ListBox.test.tsx b/src/components/fields/ListBox/ListBox.test.tsx new file mode 100644 index 000000000..3290cda4d --- /dev/null +++ b/src/components/fields/ListBox/ListBox.test.tsx @@ -0,0 +1,442 @@ +import { createRef } from 'react'; + +import { Field, ListBox } from '../../../index'; +import { act, render, renderWithForm, userEvent, waitFor } from '../../../test'; + +jest.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + const basicItems = [ + Apple, + Banana, + Cherry, + ]; + + it('should work in uncontrolled mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getByText } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + // Select a different option + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('banana'); + }); + + it('should work in controlled mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByText, rerender } = render( + + {basicItems} + , + ); + + // Check that Apple is initially selected + const appleOption = getByText('Apple'); + expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'true'); + + // Click on Banana + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('banana'); + + // Rerender with new selectedKey to simulate controlled update + rerender( + + {basicItems} + , + ); + + // Now Banana should be selected + expect(bananaOption.closest('li')).toHaveAttribute('aria-selected', 'true'); + expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'false'); + }); + + it('should work with legacy wrapper', async () => { + const { getByRole, getByText, formInstance, findByText } = renderWithForm( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + // Select an option + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + await waitFor(() => { + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + }); + + it('should work with
integration', async () => { + const { getByRole, getByText, formInstance } = renderWithForm( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + // Select an option + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(formInstance.getFieldValue('fruit')).toEqual('banana'); + }); + + it('should support multiple selection', async () => { + const onSelectionChange = jest.fn(); + + const { getByText } = render( + + {basicItems} + , + ); + + // Select multiple options + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + + await act(async () => { + await userEvent.click(appleOption); + }); + + // Check first call - should be array with 'apple' + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const firstCall = onSelectionChange.mock.calls[0][0]; + expect(Array.isArray(firstCall)).toBe(true); + expect(firstCall).toEqual(['apple']); + + await act(async () => { + await userEvent.click(bananaOption); + }); + + // Check second call - should be array with both items + expect(onSelectionChange).toHaveBeenCalledTimes(2); + const secondCall = onSelectionChange.mock.calls[1][0]; + expect(Array.isArray(secondCall)).toBe(true); + expect(secondCall.sort()).toEqual(['apple', 'banana']); + }); + + it('should support search functionality', async () => { + const { getByRole, getByText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByRole('searchbox'); + expect(searchInput).toBeInTheDocument(); + + // Type in search input + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + // Only Apple should be visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); + expect(queryByText('Cherry')).not.toBeInTheDocument(); + }); + + it('should handle disabled state', () => { + const { getByRole } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should support sections', () => { + const { getByText, getByRole } = render( + + + Apple + Banana + + + Carrot + Broccoli + + , + ); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByText('Fruits')).toBeInTheDocument(); + expect(getByText('Vegetables')).toBeInTheDocument(); + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Carrot')).toBeInTheDocument(); + }); + + it('should support items with descriptions', () => { + const { getByText } = render( + + + Apple + + + Banana + + , + ); + + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Red and sweet')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('Yellow and curved')).toBeInTheDocument(); + }); + + it('should correctly assign refs', () => { + const listRef = createRef(); + const searchInputRef = createRef(); + + const { getByRole } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + const searchInput = getByRole('searchbox'); + + expect(listRef.current).toBe(listbox); + expect(searchInputRef.current).toBe(searchInput); + }); + + it('should handle keyboard navigation', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + + // Focus the listbox and handle keyboard navigation + await act(async () => { + listbox.focus(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + }); + + // Should select the focused option (first one after arrow down) + expect(onSelectionChange).toHaveBeenCalled(); + }); + + it('should handle validation states', () => { + const { getByRole, rerender } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox.closest('[data-is-invalid]')).toBeInTheDocument(); + + // Test valid state + rerender( + + {basicItems} + , + ); + + expect(listbox.closest('[data-is-valid]')).toBeInTheDocument(); + }); + + it('should handle search loading state', () => { + const { container } = render( + + {basicItems} + , + ); + + // Check that LoadingIcon is rendered instead of SearchIcon + const loadingIcon = container.querySelector( + '[data-element="InputIcon"] svg', + ); + expect(loadingIcon).toBeInTheDocument(); + }); + + it('should filter sections when searching', async () => { + const { getByRole, getByText, queryByText } = render( + + + Apple + Banana + + + Carrot + Broccoli + + , + ); + + const searchInput = getByRole('searchbox'); + + // Search for "app" - should only show Apple and Fruits section + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + expect(getByText('Fruits')).toBeInTheDocument(); + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); + expect(queryByText('Vegetables')).not.toBeInTheDocument(); + expect(queryByText('Carrot')).not.toBeInTheDocument(); + }); + + it('should clear selection when null is passed', async () => { + const onSelectionChange = jest.fn(); + + const { getByText, rerender } = render( + + {basicItems} + , + ); + + // Apple should be selected initially + const appleOption = getByText('Apple'); + expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'true'); + + // Clear selection + rerender( + + {basicItems} + , + ); + + expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'false'); + }); + + it('should handle empty search results', async () => { + const { getByRole, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByRole('searchbox'); + + // Search for something that doesn't exist + await act(async () => { + await userEvent.type(searchInput, 'xyz'); + }); + + // No items should be visible + expect(queryByText('Apple')).not.toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); + expect(queryByText('Cherry')).not.toBeInTheDocument(); + }); + + it('should work with form integration and onSelectionChange handler together', async () => { + const onSelectionChangeMock = jest.fn(); + + const { getByText, formInstance } = renderWithForm( + + {basicItems} + , + ); + + // Select an option + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + // onSelectionChange handler should be called + expect(onSelectionChangeMock).toHaveBeenCalledWith('apple'); + + // Form should also be updated + expect(formInstance.getFieldValue('fruit')).toEqual('apple'); + + // Select another option + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + // onSelectionChange handler should be called again + expect(onSelectionChangeMock).toHaveBeenCalledWith('banana'); + + // Form should be updated + expect(formInstance.getFieldValue('fruit')).toEqual('banana'); + }); + + it('should pre-select option based on defaultSelectedKey', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const bananaOption = getByText('Banana'); + const appleOption = getByText('Apple'); + + // Banana should be aria-selected=true, others false + expect(bananaOption.closest('li')).toHaveAttribute('aria-selected', 'true'); + expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'false'); + }); +}); diff --git a/src/components/fields/ListBox/ListBox.tsx b/src/components/fields/ListBox/ListBox.tsx new file mode 100644 index 000000000..b4545654a --- /dev/null +++ b/src/components/fields/ListBox/ListBox.tsx @@ -0,0 +1,816 @@ +import { + Children, + cloneElement, + ForwardedRef, + forwardRef, + isValidElement, + ReactElement, + ReactNode, + RefObject, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + AriaListBoxProps, + useFilter, + useListBox, + useListBoxSection, + useOption, +} from 'react-aria'; +import { Section as BaseSection, Item, useListState } from 'react-stately'; + +import { LoadingIcon, SearchIcon } from '../../../icons'; +import { useProviderProps } from '../../../provider'; +import { + BASE_STYLES, + COLOR_STYLES, + extractStyles, + OUTER_STYLES, + Styles, + tasty, +} from '../../../tasty'; +import { mergeProps, modAttrs, useCombinedRefs } from '../../../utils/react'; +import { useFocus } from '../../../utils/react/interactions'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +import { InvalidIcon } from '../../shared/InvalidIcon'; +import { ValidIcon } from '../../shared/ValidIcon'; +import { + DEFAULT_INPUT_STYLES, + INPUT_WRAPPER_STYLES, +} from '../TextInput/TextInputBase'; + +import type { CollectionBase, Key } from '@react-types/shared'; +import type { FieldBaseProps } from '../../../shared'; + +type FilterFn = (textValue: string, inputValue: string) => boolean; + +const ListBoxWrapperElement = tasty({ + styles: { + display: 'flex', + flow: 'column', + gap: 0, + position: 'relative', + radius: true, + fill: '#white', + color: '#dark-02', + transition: 'theme', + outline: { + '': '#purple-03.0', + 'invalid & focused': '#danger.50', + focused: '#purple-03', + }, + border: { + '': true, + focused: '#purple-text', + valid: '#success-text.50', + invalid: '#danger-text.50', + disabled: true, + }, + }, +}); + +const SearchWrapperElement = tasty({ + styles: { + ...INPUT_WRAPPER_STYLES, + border: '#clear', + radius: '1r top', + borderBottom: '1bw solid #border', + }, +}); + +const SearchInputElement = tasty({ + as: 'input', + styles: DEFAULT_INPUT_STYLES, +}); + +const ListElement = tasty({ + as: 'ul', + styles: { + display: 'flex', + gap: '1bw', + flow: 'column', + margin: '0', + padding: '.5x', + listStyle: 'none', + height: 'auto', + overflow: 'auto', + scrollbar: 'styled', + }, +}); + +const OptionElement = tasty({ + as: 'li', + styles: { + display: 'flex', + flow: 'column', + gap: '.25x', + padding: '.75x 1x', + radius: '1r', + cursor: 'pointer', + transition: 'theme', + outline: 0, + border: 0, + userSelect: 'none', + color: { + '': '#dark-02', + 'selected | pressed': '#dark', + disabled: '#dark-04', + valid: '#success-text', + invalid: '#danger-text', + }, + fill: { + '': '#clear', + focused: '#dark.03', + selected: '#dark.06', + 'selected & focused': '#dark.09', + pressed: '#dark.06', + valid: '#success-bg', + invalid: '#danger-bg', + disabled: '#clear', + }, + + Label: { + preset: 't3', + color: 'inherit', + }, + + Description: { + preset: 't4', + color: { + '': '#dark-03', + }, + }, + }, +}); + +const SectionWrapperElement = tasty({ + as: 'li', + styles: { + display: 'block', + }, +}); + +const SectionHeadingElement = tasty({ + styles: { + preset: 't4 strong', + color: '#dark-03', + padding: '.5x 1x .25x', + userSelect: 'none', + }, +}); + +const SectionListElement = tasty({ + as: 'ul', + styles: { + display: 'flex', + gap: '1bw', + flow: 'column', + margin: '0', + padding: '0', + listStyle: 'none', + }, +}); + +const DividerElement = tasty({ + as: 'li', + styles: { + height: '1bw', + fill: '#border', + margin: '.5x 0', + }, +}); + +export interface CubeListBoxProps + extends Omit, 'onSelectionChange'>, + CollectionBase, + FieldBaseProps { + /** Whether the ListBox is searchable */ + isSearchable?: boolean; + /** Placeholder text for the search input */ + searchPlaceholder?: string; + /** Whether the search input should have autofocus */ + autoFocus?: boolean; + /** The filter function used to determine if an option should be included in the filtered list */ + filter?: FilterFn; + /** Custom styles for the search input */ + searchInputStyles?: Styles; + /** Custom styles for the list container */ + listStyles?: Styles; + /** Custom styles for options */ + optionStyles?: Styles; + /** Custom styles for sections */ + sectionStyles?: Styles; + /** Custom styles for section headings */ + headingStyles?: Styles; + /** Whether the ListBox is disabled */ + isDisabled?: boolean; + /** Whether the ListBox as a whole is loading (generic loading indicator) */ + isLoading?: boolean; + /** The selected key(s) */ + selectedKey?: Key | null; + selectedKeys?: Key[] | 'all'; + /** Default selected key(s) */ + defaultSelectedKey?: Key | null; + defaultSelectedKeys?: Key[] | 'all'; + /** Selection change handler */ + onSelectionChange?: (key: Key | null | Key[]) => void; + /** Ref for the search input */ + searchInputRef?: RefObject; + /** Ref for the list */ + listRef?: RefObject; +} + +const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; + +export const ListBox = forwardRef(function ListBox( + props: CubeListBoxProps, + ref: ForwardedRef, +) { + props = useProviderProps(props); + props = useFormProps(props); + props = useFieldProps(props, { + valuePropsMapper: ({ value, onChange }) => { + const fieldProps: any = {}; + + if (props.selectionMode === 'multiple') { + fieldProps.selectedKeys = Array.isArray(value) + ? value + : value + ? [value] + : []; + } else { + fieldProps.selectedKey = value ?? null; + } + + fieldProps.onSelectionChange = (key: any) => { + if (props.selectionMode === 'multiple') { + if (Array.isArray(key)) { + onChange(key); + } else if (key instanceof Set) { + onChange(Array.from(key)); + } else { + onChange(key ? [key] : []); + } + } else { + if (key instanceof Set) { + onChange(key.size === 0 ? null : Array.from(key)[0]); + } else { + onChange(key); + } + } + }; + + return fieldProps; + }, + }); + + let { + qa, + label, + extra, + labelStyles, + isRequired, + necessityIndicator, + validationState, + isDisabled, + isLoading, + isSearchable = false, + searchPlaceholder = 'Search...', + autoFocus, + filter, + searchInputStyles, + listStyles, + optionStyles, + sectionStyles, + headingStyles, + searchInputRef, + listRef, + message, + description, + styles, + labelSuffix, + selectedKey, + defaultSelectedKey, + selectedKeys, + defaultSelectedKeys, + onSelectionChange, + ...otherProps + } = props; + + const [searchValue, setSearchValue] = useState(''); + const { contains } = useFilter({ sensitivity: 'base' }); + + // Create filtered children based on search + const filteredChildren = useMemo(() => { + if (!isSearchable || !searchValue.trim() || !props.children) { + return props.children; + } + + const filterFn = filter || contains; + + // Returns `true` if the given element's text value matches the search. + const filterChild = (child: any): boolean => { + if (!isValidElement(child)) return false; + + const { textValue, children } = child.props as any; + + // Prefer an explicit textValue prop (React Aria's Item), then children, then key. + let candidate: string = ''; + + if (typeof textValue === 'string') { + candidate = textValue; + } else if (typeof children === 'string') { + candidate = children; + } else if (Array.isArray(children)) { + candidate = children.join(' '); + } else if (child.key != null) { + candidate = String(child.key); + } + + return filterFn(candidate, searchValue); + }; + + // Filters a Section element and returns a cloned element with only the matching children. + const filterSection = (section: any) => { + if (!isValidElement(section)) return null; + + const childrenArray = Children.toArray( + (section as any).props.children as any, + ); + const filteredSectionChildren = childrenArray.filter(filterChild); + + if (filteredSectionChildren.length === 0) return null; + + return cloneElement( + section as any, + { children: filteredSectionChildren } as any, + ); + }; + + const childrenArray = Children.toArray(props.children as any); + + const result = childrenArray + .map((child) => { + if ( + isValidElement(child) && + (child.type === BaseSection || (child.props as any)?.title) + ) { + return filterSection(child); + } + + return filterChild(child) ? child : null; + }) + .filter(Boolean); + + return result.length === 0 ? null : result; + }, [isSearchable, searchValue, props.children, filter, contains]); + + // Create filtered items based on search + const filteredItems = useMemo(() => { + if (!isSearchable || !searchValue.trim()) { + return props.items; + } + + const filterFn = filter || contains; + + if (props.items) { + return Array.from(props.items).filter((item: any) => { + const textValue = + typeof item === 'string' + ? item + : item?.textValue || item?.name || String(item); + return filterFn(textValue, searchValue); + }); + } + + return undefined; + }, [isSearchable, searchValue, props.items, filter, contains]); + + // Wrap onSelectionChange to prevent selection when disabled and handle React Aria's Set format + const externalSelectionHandler = onSelectionChange || (props as any).onChange; + + const wrappedOnSelectionChange = useMemo(() => { + if (!externalSelectionHandler) return undefined; + + return (keys: any) => { + // Don't allow selection changes when disabled + if (isDisabled) { + return; + } + + // React Aria always passes a Set for selection changes + // For single selection mode, we extract the first (and only) key + if (keys instanceof Set) { + if (keys.size === 0) { + externalSelectionHandler( + props.selectionMode === 'multiple' ? [] : null, + ); + } else if (props.selectionMode === 'multiple') { + externalSelectionHandler(Array.from(keys)); + } else { + externalSelectionHandler(Array.from(keys)[0]); + } + } else { + externalSelectionHandler(keys); + } + }; + }, [externalSelectionHandler, isDisabled, props.selectionMode]); + + // Prepare props for useListState with correct selection props + const listStateProps: any = { + ...props, + items: filteredItems, + children: filteredChildren, + onSelectionChange: wrappedOnSelectionChange, + isDisabled, + selectionMode: props.selectionMode || 'single', + }; + + // Set selection props based on mode + if (listStateProps.selectionMode === 'multiple') { + if (selectedKeys !== undefined) { + listStateProps.selectedKeys = + selectedKeys === 'all' ? 'all' : new Set(selectedKeys as Key[]); + } + if (defaultSelectedKeys !== undefined) { + listStateProps.defaultSelectedKeys = + defaultSelectedKeys === 'all' + ? 'all' + : new Set(defaultSelectedKeys as Key[]); + } + // Remove single-selection props if any + delete listStateProps.selectedKey; + delete listStateProps.defaultSelectedKey; + } else { + if (selectedKey !== undefined) { + listStateProps.selectedKey = selectedKey; + } + if (defaultSelectedKey !== undefined) { + // useListState expects a Set for uncontrolled selections, even in single-selection mode + // so convert the provided key into a Set. Passing an empty Set means no default selection. + listStateProps.defaultSelectedKeys = + defaultSelectedKey == null ? new Set() : new Set([defaultSelectedKey]); + } + // Remove set-based props if any + delete listStateProps.selectedKeys; + delete listStateProps.defaultSelectedKey; + } + + const listState = useListState(listStateProps); + + // Manually sync controlled selection if needed + useEffect(() => { + if (selectedKey !== undefined) { + const currentSelection = listState.selectionManager.selectedKeys; + const expectedSelection = + selectedKey !== null ? new Set([selectedKey]) : new Set(); + + // Check if the current selection matches the expected selection + const currentKeys = Array.from(currentSelection); + const expectedKeys = Array.from(expectedSelection); + + const selectionChanged = + currentKeys.length !== expectedKeys.length || + currentKeys.some((key) => !expectedSelection.has(key)) || + expectedKeys.some((key) => !currentSelection.has(key)); + + if (selectionChanged) { + listState.selectionManager.setSelectedKeys(expectedSelection); + } + } + }, [selectedKey, listState.selectionManager]); + + styles = extractStyles(otherProps, PROP_STYLES, styles); + + ref = useCombinedRefs(ref); + searchInputRef = useCombinedRefs(searchInputRef); + listRef = useCombinedRefs(listRef); + + const { listBoxProps } = useListBox( + { + ...props, + 'aria-label': props['aria-label'] || label?.toString(), + isDisabled, + shouldUseVirtualFocus: isSearchable, + shouldFocusWrap: true, + }, + listState, + listRef, + ); + + const { isFocused, focusProps } = useFocus({ isDisabled }); + const isInvalid = validationState === 'invalid'; + + const mods = useMemo( + () => ({ + invalid: isInvalid, + valid: validationState === 'valid', + disabled: isDisabled, + focused: isFocused, + loading: isLoading, + searchable: isSearchable, + }), + [ + isInvalid, + validationState, + isDisabled, + isFocused, + isLoading, + isSearchable, + ], + ); + + const searchInput = isSearchable ? ( + + setSearchValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + + const isArrowDown = e.key === 'ArrowDown'; + const keyGetter = isArrowDown + ? listState.collection.getKeyAfter.bind(listState.collection) + : listState.collection.getKeyBefore.bind(listState.collection); + + // Helper function to find next selectable item (skip section headers) + const findNextSelectableKey = (startKey: any) => { + let nextKey = startKey; + + // Keep looking for a selectable item (not a section header) + while (nextKey != null) { + const item = listState.collection.getItem(nextKey); + if (item && item.type !== 'section') { + return nextKey; + } + nextKey = keyGetter(nextKey); + } + + return null; + }; + + // Helper function to find first/last selectable item + const findFirstLastSelectableKey = () => { + const allKeys = Array.from(listState.collection.getKeys()); + const keysToCheck = isArrowDown ? allKeys : allKeys.reverse(); + + for (const key of keysToCheck) { + const item = listState.collection.getItem(key); + if (item && item.type !== 'section') { + return key; + } + } + + return null; + }; + + let nextKey; + const currentKey = listState.selectionManager.focusedKey; + + if (currentKey == null) { + // No current focus, find first/last selectable item + nextKey = findFirstLastSelectableKey(); + } else { + // Find next selectable item from current position + const candidateKey = keyGetter(currentKey); + nextKey = findNextSelectableKey(candidateKey); + + // If no next key found, wrap to first/last selectable item + if (nextKey == null) { + nextKey = findFirstLastSelectableKey(); + } + } + + if (nextKey != null) { + listState.selectionManager.setFocusedKey(nextKey); + } + } else if ( + e.key === 'Enter' || + (e.key === ' ' && props.selectionMode === 'multiple') + ) { + const focusedKey = listState.selectionManager.focusedKey; + if (focusedKey != null) { + e.preventDefault(); + if (props.selectionMode === 'multiple') { + (listState.selectionManager as any).toggleSelection(focusedKey); + } else { + (listState.selectionManager as any).select(focusedKey); + } + } + } + }} + {...modAttrs(mods)} + /> +
+
+ {isLoading ? : } +
+
+
+ ) : null; + + const listBoxField = ( + + {searchInput} + + {(() => { + const renderedItems: ReactNode[] = []; + let isFirstSection = true; + + for (const item of listState.collection) { + if (item.type === 'section') { + if (!isFirstSection) { + renderedItems.push( + , + ); + } + + renderedItems.push( + , + ); + + isFirstSection = false; + } else { + renderedItems.push( + + + ); + + return wrapWithField, 'children'>>( + listBoxField, + ref, + mergeProps({ ...props, styles: undefined }, {}), + ); +}) as unknown as (( + props: CubeListBoxProps & { ref?: ForwardedRef }, +) => ReactElement) & { Item: typeof Item; Section: typeof BaseSection }; + +function Option({ item, state, styles, isParentDisabled, validationState }) { + const ref = useRef(null); + const isDisabled = isParentDisabled || state.disabledKeys.has(item.key); + const isSelected = state.selectionManager.isSelected(item.key); + + const { + optionProps, + isPressed, + isFocused: optionFocused, + } = useOption( + { + key: item.key, + isDisabled, + isSelected, + shouldSelectOnPressUp: true, + shouldFocusOnHover: true, + }, + state, + ref, + ); + + const description = (item as any)?.props?.description; + + return ( + +
{item.rendered}
+ {description ?
{description}
: null} +
+ ); +} + +interface ListBoxSectionProps { + item: any; + state: any; + optionStyles?: Styles; + headingStyles?: Styles; + sectionStyles?: Styles; + isParentDisabled?: boolean; + validationState?: any; +} + +function ListBoxSection(props: ListBoxSectionProps) { + const { + item, + state, + optionStyles, + headingStyles, + sectionStyles, + isParentDisabled, + validationState, + } = props; + const heading = item.rendered; + + const { itemProps, headingProps, groupProps } = useListBoxSection({ + heading, + 'aria-label': item['aria-label'], + }); + + return ( + + {heading && ( + + {heading} + + )} + + {[...item.childNodes] + .filter((node: any) => state.collection.getItem(node.key)) + .map((node: any) => ( + + + ); +} + +type SectionComponent = typeof BaseSection; + +const ListBoxSectionComponent = Object.assign(BaseSection, { + displayName: 'Section', +}) as SectionComponent; + +ListBox.Item = Item as unknown as (props: { + description?: ReactNode; + [key: string]: any; +}) => ReactElement; + +ListBox.Section = ListBoxSectionComponent; + +Object.defineProperty(ListBox, 'cubeInputType', { + value: 'ListBox', + enumerable: false, + configurable: false, +}); diff --git a/src/components/fields/ListBox/index.ts b/src/components/fields/ListBox/index.ts new file mode 100644 index 000000000..7de30352b --- /dev/null +++ b/src/components/fields/ListBox/index.ts @@ -0,0 +1,2 @@ +export * from './ListBox'; +export type { CubeListBoxProps } from './ListBox'; diff --git a/src/components/fields/RadioGroup/RadioGroup.tsx b/src/components/fields/RadioGroup/RadioGroup.tsx index 5e495bc97..fb3eb9682 100644 --- a/src/components/fields/RadioGroup/RadioGroup.tsx +++ b/src/components/fields/RadioGroup/RadioGroup.tsx @@ -31,7 +31,7 @@ import { RadioContext } from './context'; export interface CubeRadioGroupProps extends BaseProps, - AriaRadioGroupProps, + Omit, BlockStyleProps, OuterStyleProps, FieldBaseProps { diff --git a/src/components/fields/Select/Select.docs.mdx b/src/components/fields/Select/Select.docs.mdx index 45c9f8512..e3cf2f9b8 100644 --- a/src/components/fields/Select/Select.docs.mdx +++ b/src/components/fields/Select/Select.docs.mdx @@ -70,6 +70,28 @@ The `mods` property accepts the following modifiers you can override: | hovered | `boolean` | Whether the select is being hovered | | inside-form | `boolean` | Whether the select is inside a form field | +## Sub-components + +### Select.Item + +Individual option items within the select dropdown. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| key | `string \| number` | - | Unique identifier for the item (required) | +| textValue | `string` | - | Text representation for accessibility and filtering | +| description | `ReactNode` | - | Secondary text displayed below the main label | +| children | `ReactNode` | - | The content to display as the option label | + +### Select.Section + +Groups related options together with an optional heading. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| title | `ReactNode` | - | Optional heading text for the section | +| children | `Select.Item[]` | - | Collection of Select.Item components | + ## Variants ### Themes diff --git a/src/components/fields/Select/Select.stories.tsx b/src/components/fields/Select/Select.stories.tsx index 59057aca4..a8c57bf6b 100644 --- a/src/components/fields/Select/Select.stories.tsx +++ b/src/components/fields/Select/Select.stories.tsx @@ -2,29 +2,355 @@ import { Meta, StoryFn } from '@storybook/react'; import { userEvent, within } from '@storybook/test'; import { IconCoin } from '@tabler/icons-react'; -import { SELECTED_KEY_ARG } from '../../../stories/FormFieldArgs'; import { baseProps } from '../../../stories/lists/baseProps'; import { Space } from '../../layout/Space'; import { CubeSelectProps, Select } from './Select'; export default { - title: 'Pickers/Select', + title: 'Forms/Select', component: Select, args: { width: '200px' }, - subcomponents: { Item: Select.Item, Section: Select.Section }, parameters: { controls: { exclude: baseProps } }, argTypes: { - ...SELECTED_KEY_ARG, - theme: { - defaultValue: undefined, - control: { type: 'radio', options: [undefined, 'special'] }, + /* Content */ + selectedKey: { + control: { type: 'text' }, + description: 'The selected value in controlled mode', + table: { + type: { summary: 'string' }, + }, + }, + defaultSelectedKey: { + control: { type: 'text' }, + description: 'The default selected value in uncontrolled mode', + table: { + type: { summary: 'string' }, + }, + }, + placeholder: { + control: { type: 'text' }, + description: 'Placeholder text when no option is selected', + table: { + type: { summary: 'string' }, + }, + }, + icon: { + control: { type: null }, + description: 'Icon element rendered before the select value', + table: { + type: { summary: 'ReactElement' }, + }, + }, + prefix: { + control: { type: null }, + description: 'Content rendered before the select value', + table: { + type: { summary: 'ReactNode' }, + }, + }, + suffix: { + control: { type: null }, + description: 'Content rendered after the select value', + table: { + type: { summary: 'ReactNode' }, + }, }, + suffixPosition: { + options: ['before', 'after'], + control: { type: 'radio' }, + description: 'Position of suffix relative to validation icons', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'before' }, + }, + }, + + /* Presentation */ type: { - defaultValue: undefined, - control: { - type: 'radio', - options: [undefined, 'secondary', 'primary', 'clear'], + options: ['outline', 'clear', 'primary', 'secondary', 'neutral', 'link'], + control: { type: 'radio' }, + description: 'Visual style variant of the select', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'outline' }, + }, + }, + theme: { + options: ['default', 'special'], + control: { type: 'radio' }, + description: 'Theme variant affecting overall styling', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'default' }, + }, + }, + size: { + options: ['small', 'default', 'large'], + control: { type: 'radio' }, + description: 'Size of the select component', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'default' }, + }, + }, + direction: { + options: ['top', 'bottom'], + control: { type: 'radio' }, + description: 'Preferred direction for the dropdown menu', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'bottom' }, + }, + }, + shouldFlip: { + control: { type: 'boolean' }, + description: 'Whether dropdown should flip to fit in viewport', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: true }, + }, + }, + overlayOffset: { + control: { type: 'number' }, + description: 'Distance between select and dropdown in pixels', + table: { + type: { summary: 'number' }, + defaultValue: { summary: 8 }, + }, + }, + + /* State */ + isDisabled: { + control: { type: 'boolean' }, + description: 'Whether the select is disabled', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + isRequired: { + control: { type: 'boolean' }, + description: 'Whether user selection is required before form submission', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + isLoading: { + control: { type: 'boolean' }, + description: 'Show loading spinner and disable interactions', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + validationState: { + options: [undefined, 'valid', 'invalid'], + control: { type: 'radio' }, + description: + 'Whether the select should display valid or invalid visual styling', + table: { + type: { summary: 'string' }, + }, + }, + autoFocus: { + control: { type: 'boolean' }, + description: 'Whether the element should receive focus on render', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + + /* Collection */ + items: { + control: { type: 'object' }, + description: 'Item objects used when rendering dynamic collections', + table: { + type: { summary: 'Iterable' }, + }, + }, + disabledKeys: { + control: { type: 'object' }, + description: 'Keys of items that are disabled', + table: { + type: { summary: 'Iterable' }, + }, + }, + children: { + control: { type: null }, + description: 'Static child items or render function for dynamic items', + table: { + type: { summary: 'ReactNode | (item: T) => ReactElement' }, + }, + }, + + /* Advanced */ + triggerRef: { + control: { type: null }, + description: 'Ref for the trigger button element', + table: { + type: { summary: 'RefObject' }, + }, + }, + popoverRef: { + control: { type: null }, + description: 'Ref for the popover overlay element', + table: { + type: { summary: 'RefObject' }, + }, + }, + listBoxRef: { + control: { type: null }, + description: 'Ref for the list box element', + table: { + type: { summary: 'RefObject' }, + }, + }, + loadingIndicator: { + control: { type: null }, + description: 'Custom loading indicator element', + table: { + type: { summary: 'ReactNode' }, + }, + }, + hideTrigger: { + control: { type: 'boolean' }, + description: 'Whether to hide the trigger button', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + + /* Styling */ + styles: { + control: { type: 'object' }, + description: 'Styles for the root wrapper element', + table: { + type: { summary: 'Styles' }, + }, + }, + inputStyles: { + control: { type: 'object' }, + description: 'Styles for the input trigger element', + table: { + type: { summary: 'Styles' }, + }, + }, + listBoxStyles: { + control: { type: 'object' }, + description: 'Styles for the dropdown list container', + table: { + type: { summary: 'Styles' }, + }, + }, + optionStyles: { + control: { type: 'object' }, + description: 'Styles for individual option items', + table: { + type: { summary: 'Styles' }, + }, + }, + overlayStyles: { + control: { type: 'object' }, + description: 'Styles for the dropdown overlay wrapper', + table: { + type: { summary: 'Styles' }, + }, + }, + wrapperStyles: { + control: { type: 'object' }, + description: 'Styles for the outer wrapper element', + table: { + type: { summary: 'Styles' }, + }, + }, + triggerStyles: { + control: { type: 'object' }, + description: 'Styles for the trigger button element', + table: { + type: { summary: 'Styles' }, + }, + }, + inputProps: { + control: { type: 'object' }, + description: 'Additional props for the input element', + table: { + type: { summary: 'Props' }, + }, + }, + + /* Events */ + onSelectionChange: { + action: 'selectionChange', + description: 'Callback fired when the selected option changes', + control: { type: null }, + table: { + type: { summary: '(key: Key) => void' }, + }, + }, + onOpenChange: { + action: 'openChange', + description: 'Callback fired when the dropdown opens or closes', + control: { type: null }, + table: { + type: { summary: '(isOpen: boolean) => void' }, + }, + }, + onFocus: { + action: 'focus', + description: 'Callback fired when the select receives focus', + control: { type: null }, + table: { + type: { summary: '(e: FocusEvent) => void' }, + }, + }, + onBlur: { + action: 'blur', + description: 'Callback fired when the select loses focus', + control: { type: null }, + table: { + type: { summary: '(e: FocusEvent) => void' }, + }, + }, + onFocusChange: { + action: 'focusChange', + description: 'Callback fired when focus state changes', + control: { type: null }, + table: { + type: { summary: '(isFocused: boolean) => void' }, + }, + }, + + /* Accessibility */ + 'aria-label': { + control: { type: 'text' }, + description: 'Accessible label when no visible label exists', + table: { + type: { summary: 'string' }, + }, + }, + 'aria-labelledby': { + control: { type: 'text' }, + description: 'ID of element that labels the select', + table: { + type: { summary: 'string' }, + }, + }, + 'aria-describedby': { + control: { type: 'text' }, + description: 'ID of element that describes the select', + table: { + type: { summary: 'string' }, + }, + }, + name: { + control: { type: 'text' }, + description: 'The name of the input element for form submission', + table: { + type: { summary: 'string' }, }, }, }, diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index 897ceac1b..3bc18d667 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -218,17 +218,16 @@ const OptionElement = tasty({ padding: '(1x - 1px) (1.5x - 1px)', cursor: 'pointer', radius: true, - fill: { - '': '#dark.0', - 'pressed | selected': '#purple.10', - 'hovered | focused': '#dark.04', - disabled: '#dark.0', - }, color: { '': '#dark-02', - 'hovered | focused': '#dark-02', - 'pressed | selected': '#purple', - disabled: '#dark.3', + selected: '#dark', + disabled: '#dark-04', + }, + fill: { + '': '#clear', + focused: '#dark.03', + selected: '#dark.06', + disabled: '#clear', }, preset: 't3', transition: 'theme', @@ -272,7 +271,7 @@ export interface CubeSelectBaseProps ColorStyleProps, FieldBaseProps, CollectionBase, - AriaSelectProps { + Omit, 'errorMessage'> { icon?: ReactElement; prefix?: ReactNode; suffix?: ReactNode; diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 42fd47268..315507222 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -12,4 +12,5 @@ export * from './Slider'; export * from './Switch/Switch'; export * from './Select'; export * from './ComboBox'; +export * from './ListBox'; export * from './TextInputMapper'; diff --git a/src/components/form/Form/field.test.tsx b/src/components/form/Form/field.test.tsx index 6d1227706..ae372358b 100644 --- a/src/components/form/Form/field.test.tsx +++ b/src/components/form/Form/field.test.tsx @@ -289,7 +289,7 @@ describe('Legacy ', () => { expect(input).toHaveValue('Hello, world!'); }); - it('should set to new default value after its change and form reset', () => { + it('should set to new default value after its change and form reset', async () => { const TestForm = ({ defaultValues, forceReset = false }) => { const [form] = Form.useForm(); @@ -297,7 +297,7 @@ describe('Legacy ', () => { if (forceReset) { form.resetFields(); } - }, []); + }, [form, forceReset]); return ( @@ -327,13 +327,69 @@ describe('Legacy ', () => { // Re-render with two inputs and updated default values rerender( , ); - waitFor(() => { + await waitFor(() => { // Check that default value is reset expect(input).toHaveValue('Goodbye, world!'); }); }); + + it('should display custom errorMessage when provided', async () => { + const customErrorMessage = 'Custom error message'; + + const { getByRole, getByText, formInstance } = renderWithForm( + , + ); + + const input = getByRole('textbox'); + + // Enter a short value to trigger validation errors + await act(async () => { + await userEvent.type(input, 'ab'); + await userEvent.tab(); // Trigger onBlur validation + }); + + // Should display the custom error message instead of validation errors + await waitFor(() => { + expect(getByText(customErrorMessage)).toBeInTheDocument(); + }); + }); + + it('should display first validation error when errorMessage is not provided', async () => { + const { getByRole, getByText, formInstance } = renderWithForm( + , + ); + + const input = getByRole('textbox'); + + // Enter a short value to trigger validation errors + await act(async () => { + await userEvent.type(input, 'ab'); + await userEvent.tab(); // Trigger onBlur validation + }); + + // Should display the first validation error + await waitFor(() => { + expect(getByText('Must be at least 5 characters')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/form/Form/types.ts b/src/components/form/Form/types.ts index d88189c6d..593cb7c35 100644 --- a/src/components/form/Form/types.ts +++ b/src/components/form/Form/types.ts @@ -13,6 +13,7 @@ export type CubeFieldData = { validating?: boolean; validationId?: number; status?: 'valid' | 'invalid'; + validationDetails?: ValidityState; }; export type Fields = Record>; diff --git a/src/components/form/Form/use-field/use-field-props.tsx b/src/components/form/Form/use-field/use-field-props.tsx index e6a3f4cd9..403731fa7 100644 --- a/src/components/form/Form/use-field/use-field-props.tsx +++ b/src/components/form/Form/use-field/use-field-props.tsx @@ -108,11 +108,20 @@ export function useFieldProps< } } + // Use errorMessage directly or fall back to validation errors + const compiledErrorMessage = + props.errorMessage !== undefined + ? props.errorMessage + : field?.field?.status === 'invalid' && field?.field?.errors?.length + ? field.field.errors[0] + : undefined; + const result: Props = isOutsideOfForm ? props : mergeProps(props, field, valueProps, { validateTrigger: field.validateTrigger ?? defaultValidationTrigger, onBlur: onBlurChained, + errorMessage: compiledErrorMessage, }); if (result.id) { diff --git a/src/components/form/Form/use-field/use-field.ts b/src/components/form/Form/use-field/use-field.ts index ec8a41a63..11e964cd0 100644 --- a/src/components/form/Form/use-field/use-field.ts +++ b/src/components/form/Form/use-field/use-field.ts @@ -199,7 +199,12 @@ export function useField>( ? message : field?.status === 'invalid' && field?.errors?.[0], description, - errorMessage, + errorMessage: + errorMessage !== undefined + ? errorMessage + : field?.status === 'invalid' && field?.errors?.length + ? field.errors[0] + : undefined, onBlur: onBlurHandler, onChange: onChangeHandler, }), diff --git a/src/components/form/wrapper.tsx b/src/components/form/wrapper.tsx index 9a9311747..c544746ae 100644 --- a/src/components/form/wrapper.tsx +++ b/src/components/form/wrapper.tsx @@ -1,5 +1,5 @@ import { FocusableRef } from '@react-types/shared'; -import { ReactElement, RefObject } from 'react'; +import { ReactElement, ReactNode, RefObject } from 'react'; import { FieldBaseProps, FormBaseProps } from '../../shared/index'; import { BaseProps, Styles } from '../../tasty/index'; @@ -26,6 +26,7 @@ export function wrapWithField( message, messageStyles, description, + errorMessage, validationState, labelProps, fieldProps, @@ -52,6 +53,7 @@ export function wrapWithField( message, messageStyles, description, + errorMessage, validationState, requiredMark, tooltip, diff --git a/src/components/pickers/Menu/Menu.stories.tsx b/src/components/pickers/Menu/Menu.stories.tsx index 11272ecdb..2344f8c66 100644 --- a/src/components/pickers/Menu/Menu.stories.tsx +++ b/src/components/pickers/Menu/Menu.stories.tsx @@ -24,7 +24,7 @@ import { import { baseProps } from '../../../stories/lists/baseProps'; export default { - title: 'Pickers/Menu', + title: 'Actions/Menu', component: Menu, parameters: { controls: { @@ -360,7 +360,7 @@ export const ItemCustomIcons = (props) => { }; return ( -
+
itemStyles?: Styles; sectionStyles?: Styles; sectionHeadingStyles?: Styles; - qa?: BaseProps['qa']; /** Keys that should appear disabled */ disabledKeys?: Iterable; /** Selection mode for the menu: 'single' | 'multiple' */ @@ -71,7 +69,10 @@ function Menu( const hasSections = collectionItems.some((item) => item.type === 'section'); const { menuProps } = useMenu(treeProps, state, domRef); - const styles = extractStyles(completeProps, CONTAINER_STYLES); + const styles = useMemo( + () => extractStyles(completeProps, CONTAINER_STYLES), + [completeProps], + ); const defaultProps = { qa, @@ -83,6 +84,9 @@ function Menu( }, }; + // Sync the ref stored in the context object with the DOM ref returned by useDOMRef. + // The helper from @react-aria/utils expects the context object as the first argument + // to keep it up-to-date, and a ref object as the second. useSyncRef(contextProps, domRef); return ( @@ -90,63 +94,70 @@ function Menu( {...mergeProps(defaultProps, menuProps, filterBaseProps(completeProps))} ref={domRef} > - {header && {header}} + {header && {header}} {(() => { // Build the list of menu elements, automatically inserting dividers between sections. - const renderedItems: React.ReactNode[] = []; - let isFirstSection = true; - - collectionItems.forEach((item) => { - if (item.type === 'section') { - // Insert a visual separator before every section except the first one. - if (!isFirstSection) { - renderedItems.push( - { + const items: React.ReactNode[] = []; + let isFirstSection = true; + + collectionItems.forEach((item) => { + if (item.type === 'section') { + if (!isFirstSection) { + items.push( + , + ); + } + + items.push( + , ); + + isFirstSection = false; + return; } - renderedItems.push( - , + onAction={item.onAction} + /> ); - isFirstSection = false; - return; - } - - let menuItem = ( - - ); - - if (item.props.wrapper) { - menuItem = item.props.wrapper(menuItem); - } - - renderedItems.push( - cloneElement(menuItem, { - key: item.key, - }), - ); - }); + if (item.props.wrapper) { + menuItem = item.props.wrapper(menuItem); + } + + // Ensure every child has a stable key, even if the wrapper component didn't set one. + items.push(React.cloneElement(menuItem, { key: item.key })); + }); + + return items; + }, [ + collectionItems, + state, + sectionStyles, + itemStyles, + selectionIcon, + sectionHeadingStyles, + ]); return renderedItems; })()} diff --git a/src/components/pickers/Menu/MenuButton.tsx b/src/components/pickers/Menu/MenuButton.tsx index ca744323d..85fcdbfdc 100644 --- a/src/components/pickers/Menu/MenuButton.tsx +++ b/src/components/pickers/Menu/MenuButton.tsx @@ -1,3 +1,4 @@ +import { IconPointFilled } from '@tabler/icons-react'; import { ReactElement, ReactNode } from 'react'; import { CheckIcon } from '../../../icons'; @@ -15,21 +16,18 @@ const StyledButton = tasty(Block, { ...DEFAULT_BUTTON_STYLES, ...DEFAULT_NEUTRAL_STYLES, height: 'min 4x', - border: { - '': '#clear', - pressed: '#clear', - focused: '#purple-text', - }, + border: '#clear', fill: { '': '#clear', - hovered: '#dark.03', - 'pressed | selected': '#dark.06', - disabled: '#dark.04', + focused: '#dark.03', + selected: '#dark.06', + 'selected & focused': '#dark.09', + pressed: '#dark.06', + disabled: '#clear', }, color: { '': '#dark-02', - hovered: '#dark-02', - pressed: '#dark', + 'selected | pressed': '#dark', disabled: '#dark-04', }, cursor: { @@ -41,16 +39,13 @@ const StyledButton = tasty(Block, { '': '0 (1.5x - 1px)', 'selectable & !selected': '0 (1.5x - 1px) 0 (1.5x - 1px)', 'selectionIcon & selectable & !selected': - '0 (1.5x - 1px) 0 (1.5x - 1px + 22px)', + '0 (1.5x - 1px) 0 (1.5x - 1px + 3x)', }, display: 'flex', flow: 'row', justifyContent: 'start', gap: '.75x', - outline: { - '': '#purple-03.0', - 'focused & focus-visible': '#purple-03', - }, + outline: false, ButtonIcon: { display: 'grid', @@ -76,23 +71,6 @@ const StyledButton = tasty(Block, { }, }); -const RadioIcon = tasty({ - styles: { - display: 'flex', - width: '1.875x', - placeContent: 'center', - - '&::before': { - display: 'block', - content: '""', - width: '1x', - height: '1x', - radius: 'round', - fill: '#current', - }, - }, -}); - const getPostfix = (postfix) => typeof postfix === 'string' ? ( @@ -120,7 +98,7 @@ const getSelectionTypeIcon = (selectionIcon?: MenuSelectionType) => { case 'checkbox': return ; case 'radio': - return ; + return ; default: return undefined; } diff --git a/src/components/pickers/Menu/MenuItem.tsx b/src/components/pickers/Menu/MenuItem.tsx index 70f45afc4..0b7de64e6 100644 --- a/src/components/pickers/Menu/MenuItem.tsx +++ b/src/components/pickers/Menu/MenuItem.tsx @@ -1,6 +1,6 @@ import { Key, Node } from '@react-types/shared'; import { useRef } from 'react'; -import { FocusRing, useFocusVisible, useHover, useMenuItem } from 'react-aria'; +import { FocusRing, useMenuItem } from 'react-aria'; import { TreeState } from 'react-stately'; import { Styles } from '../../../tasty'; @@ -30,10 +30,6 @@ export function MenuItem(props: MenuItemProps) { const isDisabledKey = state.disabledKeys.has(key); const ref = useRef(null); - const { hoverProps, isHovered } = useHover({ isDisabled: isDisabledKey }); - - // Determine if focus should be shown (keyboard modality only) - const { isFocusVisible } = useFocusVisible({}); const { menuItemProps, @@ -59,15 +55,11 @@ export function MenuItem(props: MenuItemProps) { ref, ); - // Show focused state only when focus is keyboard-visible - const isKeyboardFocused = isFocused && isFocusVisible; - const buttonProps = { qa: itemProps.qa ? itemProps.qa : `MenuButton-${key}`, mods: { ...itemProps.mods, - hovered: isHovered, - focused: isKeyboardFocused, + focused: isFocused, pressed: isPressed, }, }; @@ -88,15 +80,17 @@ export function MenuItem(props: MenuItemProps) { return ( diff --git a/src/index.ts b/src/index.ts index 63933e586..392ab03cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,8 @@ export type { CubeBadgeProps } from './components/content/Badge/Badge'; export { Tag } from './components/content/Tag/Tag'; export type { CubeTagProps } from './components/content/Tag/Tag'; export type { CubeSearchInputProps } from './components/fields/SearchInput/SearchInput'; +export type { CubeListBoxProps } from './components/fields/ListBox'; +export { ListBox } from './components/fields/ListBox'; export { Menu } from './components/pickers/Menu/Menu'; export type { CubeMenuProps } from './components/pickers/Menu/Menu'; export { MenuTrigger } from './components/pickers/Menu/MenuTrigger'; diff --git a/src/shared/form.ts b/src/shared/form.ts index 12afbd226..56b47871a 100644 --- a/src/shared/form.ts +++ b/src/shared/form.ts @@ -2,6 +2,16 @@ import { ReactNode } from 'react'; import { Props, Styles } from '../tasty'; +/** ValidationResult type for error message functions */ +export interface ValidationResult { + /** Whether the value is invalid */ + isInvalid: boolean; + /** List of validation error messages */ + validationErrors: string[]; + /** Native browser validation details */ + validationDetails: ValidityState; +} + /** Where to place label relative to input */ export type LabelPosition = 'side' | 'top'; /** The type of necessity indicator */ diff --git a/src/test/setup.ts b/src/test/setup.ts index db23b346c..0fcdf5ad5 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -10,3 +10,30 @@ global.AbortController = AbortController; config.disabled = true; configure({ testIdAttribute: 'data-qa', asyncUtilTimeout: 10000 }); + +// Configure React 18 testing environment to support act() +// This tells React that we're in a testing environment and should use act() for updates +global.IS_REACT_ACT_ENVIRONMENT = true; + +// Suppress act() warnings from @testing-library/react-hooks +// These warnings occur because the form system uses asynchronous updates that are hard to wrap in act() +const originalError = console.error; + +// Store the original console.error to restore it later +let isConsoleSuppressed = false; + +// Override console.error globally to suppress act warnings +const suppressedConsoleError = (...args: any[]) => { + if ( + typeof args[0] === 'string' && + args[0].includes( + 'Warning: The current testing environment is not configured to support act', + ) + ) { + return; + } + return originalError.call(console, ...args); +}; + +// Apply the suppression immediately +console.error = suppressedConsoleError;