diff --git a/.changeset/giant-drinks-love.md b/.changeset/giant-drinks-love.md deleted file mode 100644 index 7a4ac767..00000000 --- a/.changeset/giant-drinks-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cube-dev/ui-kit": patch ---- - -Add `tooltip` prop to menu items. You can pass a `string` or a `TooltipProps` object with `title` prop there for advanced customization. diff --git a/.changeset/serious-meals-work.md b/.changeset/serious-meals-work.md new file mode 100644 index 00000000..166fb144 --- /dev/null +++ b/.changeset/serious-meals-work.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add CommandMenu component. diff --git a/.cursor/rules/guidelines.mdc b/.cursor/rules/guidelines.mdc new file mode 100644 index 00000000..2b7a43f3 --- /dev/null +++ b/.cursor/rules/guidelines.mdc @@ -0,0 +1,287 @@ +--- +alwaysApply: true +--- + +# Description + +Package name: `@cube-dev/ui-kit` + +# Project Structure + +## Component file structure (preferable) + +/src/components/{category}/{ComponentName}/ +- {ComponentName}.tsx – implementation of the component +- {ComponentName}.docs.mdx - documentation +- {ComponentName}.stories.tsx - Storybook stories +- {ComponentName}.test.tsx - Unit tests +- index.tsx - re-export of all instances + +## Icons + +/src/icons/ + +# Commands + +## Test + +All tests: `$ pnpm test` +Specific test: `$ pnpm test -- {TestFileName}` + +## Build + +`pnpm run build` + +## Lint + Fix + +`pnpm run fix` + +# Stack + +- `tasty` style helper. + - `src/tasty` - sources + - `src/stories/Tasty.docs.mdx` - documentation + - `src/stories/Styles.docs.mdx` - custom tasty styles documentation + - `src/stories/CreateComponent.docs.mdx` - create components using tasty helper. +- Storybook v8.6 +- React and React DOM v18 +- `styled-components` v6 +- `react-aria` and `react-stately` with the latest versions. +- `tabler/icons-react` - icons. + +# Recomendations + +- Use `DOCUMENTATION_GUIDELINES.md` for writing documentation for components. +- Use icons from `/src/icons` if they have a required one. If not - use `tabler/icons-react`. If we need to customize the size or color of the icon, then wrap it with `` component and pass all required props there. Do not add any props to the tabler icons directly. + +## Form System + +- Form validation uses async rule-based system with built-in validators: + - `required` - field is required + - `type` - validates data type (email, url, number, etc.) + - `pattern` - regex pattern validation + - `min`/`max` - length/value constraints + - `enum` - allowed values + - `whitespace` - non-empty content + - `validator` - custom async function +- Form fields support direct integration without Field wrapper +- Use `useForm` hook for form instance management +- Form state includes validation, touched state, and error handling + +## Testing + +- Testing setup: Jest + React Testing Library + `@testing-library/react-hooks` +- Test configuration: `src/test/setup.ts` with custom configurations +- Testing utilities: `src/test/render.tsx` provides `renderWithRoot` wrapper +- QA attributes: Use `qa` prop for e2e testing selectors (`data-qa`) +- Test environment: Uses `jsdom` with React 18 act() environment +- Coverage: Run `pnpm test-cover` for coverage reports + +## Accessibility + +- All components use React Aria hooks for accessibility +- Keyboard navigation patterns are consistent across components +- ARIA attributes are automatically managed by React Aria +- Screen reader support is built-in with proper announcements +- Focus management is handled automatically +- Components support all standard ARIA labeling props + +## TypeScript + +- Interface naming: Use descriptive names with `Props` suffix for component props +- Base props: Extend from `BaseProps` or `AllBaseProps` for standard properties +- Form types: Use `FieldTypes` interface for form field type definitions +- Style props: Use specific style prop interfaces (e.g., `ContainerStyleProps`) +- Generic constraints: Use `extends` for type safety in form and field components + +## Component Architecture + +- Use `filterBaseProps` to separate design system props from DOM props +- Export pattern: Use barrel exports with compound components (e.g., `Button.Group`) +- Style system: Use `extractStyles` for separating style props from other props +- Modifiers: Use `mods` prop for state-based styling +- Sub-elements: Use `data-element` attribute for targeting specific parts in styles + +## Style System (Tasty) + +- Use `tasty` documentation +- Use `tasty` custom styles with tasty syntax when possible. +- Use `style` property only for dynamic styles and tokens (css custom properties). +- Style categories: BASE, POSITION, BLOCK, COLOR, TEXT, DIMENSION, FLOW, CONTAINER, OUTER +- Responsive values: Use arrays for breakpoint-based styling +- Modifiers: Use object syntax for conditional styles +- Sub-elements: Target inner elements using capitalized keys in styles +- Style props: Direct style application without `styles` prop +- CSS custom properties: Use `@token-name` syntax for design tokens +- To declare a CSS animation use `styled-components` and then pass the animation name to the tasty styles. + +## Export Patterns + +- Compound components: Use `Object.assign` pattern for sub-components +- Barrel exports: Each category has index.ts for re-exports +- Main export: All components exported from `src/index.ts` +- Type exports: Export component prop types for external use + +## Development Workflow + +- Branch naming: `[type/(task-name | scope)]` (e.g., `feat/button-group`) +- Commit convention: `category: message` format +- Changesets: Use `pnpm changeset` for version management +- Code snippets: Use `jsx live=false` for documentation snippets +- Storybook: Two modes - `stories` and `docs` for different outputs + +## Performance + +- Icon optimization: Reuse icon components, wrap with `` for customization +- Style caching: Tasty system includes built-in style caching +- Bundle size: Monitor with `pnpm size` command +- Lazy loading: Use dynamic imports for large components + +## Error Handling + +- Form validation: Async error handling with Promise-based validation +- Console suppression: Test setup includes act() warning suppression +- Error boundaries: Use proper error boundaries in complex components +- Validation state: Use `validationState` prop for field error states + +# Description + +Package name: `@cube-dev/ui-kit` + +# Project Structure + +## Component file structure (preferable) + +/src/components/{category}/{ComponentName}/ +- {ComponentName}.tsx – implementation of the component +- {ComponentName}.docs.mdx - documentation +- {ComponentName}.stories.tsx - Storybook stories +- {ComponentName}.test.tsx - Unit tests +- index.tsx - re-export of all instances + +## Icons + +/src/icons/ + +# Commands + +## Test + +All tests: `$ pnpm test` +Specific test: `$ pnpm test -- {TestFileName}` + +## Build + +`pnpm run build` + +## Lint + Fix + +`pnpm run fix` + +# Stack + +- `tasty` style helper. + - `src/tasty` - sources + - `src/stories/Tasty.docs.mdx` - documentation + - `src/stories/Styles.docs.mdx` - custom tasty styles documentation + - `src/stories/CreateComponent.docs.mdx` - create components using tasty helper. +- Storybook v8.6 +- React and React DOM v18 +- `styled-components` v6 +- `react-aria` and `react-stately` with the latest versions. +- `tabler/icons-react` - icons. + +# Recomendations + +- Use `DOCUMENTATION_GUIDELINES.md` for writing documentation for components. +- Use icons from `/src/icons` if they have a required one. If not - use `tabler/icons-react`. If we need to customize the size or color of the icon, then wrap it with `` component and pass all required props there. Do not add any props to the tabler icons directly. + +## Form System + +- Form validation uses async rule-based system with built-in validators: + - `required` - field is required + - `type` - validates data type (email, url, number, etc.) + - `pattern` - regex pattern validation + - `min`/`max` - length/value constraints + - `enum` - allowed values + - `whitespace` - non-empty content + - `validator` - custom async function +- Form fields support direct integration without Field wrapper +- Use `useForm` hook for form instance management +- Form state includes validation, touched state, and error handling + +## Testing + +- Testing setup: Jest + React Testing Library + `@testing-library/react-hooks` +- Test configuration: `src/test/setup.ts` with custom configurations +- Testing utilities: `src/test/render.tsx` provides `renderWithRoot` wrapper +- QA attributes: Use `qa` prop for e2e testing selectors (`data-qa`) +- Test environment: Uses `jsdom` with React 18 act() environment +- Coverage: Run `pnpm test-cover` for coverage reports + +## Accessibility + +- All components use React Aria hooks for accessibility +- Keyboard navigation patterns are consistent across components +- ARIA attributes are automatically managed by React Aria +- Screen reader support is built-in with proper announcements +- Focus management is handled automatically +- Components support all standard ARIA labeling props + +## TypeScript + +- Interface naming: Use descriptive names with `Props` suffix for component props +- Base props: Extend from `BaseProps` or `AllBaseProps` for standard properties +- Form types: Use `FieldTypes` interface for form field type definitions +- Style props: Use specific style prop interfaces (e.g., `ContainerStyleProps`) +- Generic constraints: Use `extends` for type safety in form and field components + +## Component Architecture + +- Use `filterBaseProps` to separate design system props from DOM props +- Export pattern: Use barrel exports with compound components (e.g., `Button.Group`) +- Style system: Use `extractStyles` for separating style props from other props +- Modifiers: Use `mods` prop for state-based styling +- Sub-elements: Use `data-element` attribute for targeting specific parts in styles + +## Style System (Tasty) + +- Use `tasty` documentation +- Use `tasty` custom styles with tasty syntax when possible. +- Use `style` property only for dynamic styles and tokens (css custom properties). +- Style categories: BASE, POSITION, BLOCK, COLOR, TEXT, DIMENSION, FLOW, CONTAINER, OUTER +- Responsive values: Use arrays for breakpoint-based styling +- Modifiers: Use object syntax for conditional styles +- Sub-elements: Target inner elements using capitalized keys in styles +- Style props: Direct style application without `styles` prop +- CSS custom properties: Use `@token-name` syntax for design tokens +- To declare a CSS animation use `styled-components` and then pass the animation name to the tasty styles. + +## Export Patterns + +- Compound components: Use `Object.assign` pattern for sub-components +- Barrel exports: Each category has index.ts for re-exports +- Main export: All components exported from `src/index.ts` +- Type exports: Export component prop types for external use + +## Development Workflow + +- Branch naming: `[type/(task-name | scope)]` (e.g., `feat/button-group`) +- Commit convention: `category: message` format +- Changesets: Use `pnpm changeset` for version management +- Code snippets: Use `jsx live=false` for documentation snippets +- Storybook: Two modes - `stories` and `docs` for different outputs + +## Performance + +- Icon optimization: Reuse icon components, wrap with `` for customization +- Style caching: Tasty system includes built-in style caching +- Bundle size: Monitor with `pnpm size` command +- Lazy loading: Use dynamic imports for large components + +## Error Handling + +- Form validation: Async error handling with Promise-based validation +- Console suppression: Test setup includes act() warning suppression +- Error boundaries: Use proper error boundaries in complex components +- Validation state: Use `validationState` prop for field error states diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 00000000..a4b71bfa --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,141 @@ +# Description + +Package name: `@cube-dev/ui-kit` + +# Project Structure + +## Component file structure (preferable) + +/src/components/{category}/{ComponentName}/ +- {ComponentName}.tsx – implementation of the component +- {ComponentName}.docs.mdx - documentation +- {ComponentName}.stories.tsx - Storybook stories +- {ComponentName}.test.tsx - Unit tests +- index.tsx - re-export of all instances + +## Icons + +/src/icons/ + +# Commands + +## Test + +All tests: `$ pnpm test` +Specific test: `$ pnpm test -- {TestFileName}` + +## Build + +`pnpm run build` + +## Lint + Fix + +`pnpm run fix` + +# Stack + +- `tasty` style helper. + - `src/tasty` - sources + - `src/stories/Tasty.docs.mdx` - documentation + - `src/stories/Styles.docs.mdx` - custom tasty styles documentation + - `src/stories/CreateComponent.docs.mdx` - create components using tasty helper. +- Storybook v8.6 +- React and React DOM v18 +- `styled-components` v6 +- `react-aria` and `react-stately` with the latest versions. +- `tabler/icons-react` - icons. + +# Recomendations + +- Use `DOCUMENTATION_GUIDELINES.md` for writing documentation for components. +- Use icons from `/src/icons` if they have a required one. If not - use `tabler/icons-react`. If we need to customize the size or color of the icon, then wrap it with `` component and pass all required props there. Do not add any props to the tabler icons directly. + +## Form System + +- Form validation uses async rule-based system with built-in validators: + - `required` - field is required + - `type` - validates data type (email, url, number, etc.) + - `pattern` - regex pattern validation + - `min`/`max` - length/value constraints + - `enum` - allowed values + - `whitespace` - non-empty content + - `validator` - custom async function +- Form fields support direct integration without Field wrapper +- Use `useForm` hook for form instance management +- Form state includes validation, touched state, and error handling + +## Testing + +- Testing setup: Jest + React Testing Library + `@testing-library/react-hooks` +- Test configuration: `src/test/setup.ts` with custom configurations +- Testing utilities: `src/test/render.tsx` provides `renderWithRoot` wrapper +- QA attributes: Use `qa` prop for e2e testing selectors (`data-qa`) +- Test environment: Uses `jsdom` with React 18 act() environment +- Coverage: Run `pnpm test-cover` for coverage reports + +## Accessibility + +- All components use React Aria hooks for accessibility +- Keyboard navigation patterns are consistent across components +- ARIA attributes are automatically managed by React Aria +- Screen reader support is built-in with proper announcements +- Focus management is handled automatically +- Components support all standard ARIA labeling props + +## TypeScript + +- Interface naming: Use descriptive names with `Props` suffix for component props +- Base props: Extend from `BaseProps` or `AllBaseProps` for standard properties +- Form types: Use `FieldTypes` interface for form field type definitions +- Style props: Use specific style prop interfaces (e.g., `ContainerStyleProps`) +- Generic constraints: Use `extends` for type safety in form and field components + +## Component Architecture + +- Use `filterBaseProps` to separate design system props from DOM props +- Export pattern: Use barrel exports with compound components (e.g., `Button.Group`) +- Style system: Use `extractStyles` for separating style props from other props +- Modifiers: Use `mods` prop for state-based styling +- Sub-elements: Use `data-element` attribute for targeting specific parts in styles + +## Style System (Tasty) + +- Use `tasty` documentation +- Use `tasty` custom styles with tasty syntax when possible. +- Use `style` property only for dynamic styles and tokens (css custom properties). +- Style categories: BASE, POSITION, BLOCK, COLOR, TEXT, DIMENSION, FLOW, CONTAINER, OUTER +- Responsive values: Use arrays for breakpoint-based styling +- Modifiers: Use object syntax for conditional styles +- Sub-elements: Target inner elements using capitalized keys in styles +- Style props: Direct style application without `styles` prop +- CSS custom properties: Use `@token-name` syntax for design tokens +- To declare a CSS animation use `styled-components` and then pass the animation name to the tasty styles. + +## Export Patterns + +- Compound components: Use `Object.assign` pattern for sub-components +- Barrel exports: Each category has index.ts for re-exports +- Main export: All components exported from `src/index.ts` +- Type exports: Export component prop types for external use + +## Development Workflow + +- Branch naming: `[type/(task-name | scope)]` (e.g., `feat/button-group`) +- Commit convention: `category: message` format +- Changesets: Use `pnpm changeset` for version management +- Code snippets: Use `jsx live=false` for documentation snippets +- Storybook: Two modes - `stories` and `docs` for different outputs + +## Performance + +- Icon optimization: Reuse icon components, wrap with `` for customization +- Style caching: Tasty system includes built-in style caching +- Bundle size: Monitor with `pnpm size` command +- Lazy loading: Use dynamic imports for large components + +## Error Handling + +- Form validation: Async error handling with Promise-based validation +- Console suppression: Test setup includes act() warning suppression +- Error boundaries: Use proper error boundaries in complex components +- Validation state: Use `validationState` prop for field error states diff --git a/src/components/actions/CommandMenu/CommandMenu.docs.mdx b/src/components/actions/CommandMenu/CommandMenu.docs.mdx new file mode 100644 index 00000000..ceb6c5f3 --- /dev/null +++ b/src/components/actions/CommandMenu/CommandMenu.docs.mdx @@ -0,0 +1,357 @@ +import { Meta, Story, Controls } from '@storybook/blocks'; +import { CommandMenu } from './CommandMenu'; +import * as CommandMenuStories from './CommandMenu.stories'; + + + +# CommandMenu + +A searchable menu interface that combines the functionality of Menu and ListBox components. It provides a command-line-like experience for users to quickly find and execute actions through a searchable interface. + +## When to use + +- **Quick action access**: Enable users to quickly find and execute commands or actions +- **Large command sets**: When you have many available actions that benefit from search filtering +- **Keyboard-first workflows**: For power users who prefer keyboard navigation +- **Command-line interfaces**: When building developer tools or admin interfaces +- **Global search**: As a global command palette accessible via keyboard shortcuts + +## Examples + +### Default Usage + + + +## Props + + + +## Styling + +### Style Props + +The CommandMenu component supports all standard style properties: + +- **Layout**: `width`, `height`, `padding`, `margin` +- **Positioning**: `position`, `top`, `left`, `right`, `bottom` +- **Flexbox**: `flex`, `alignSelf`, `justifySelf` +- **Grid**: `gridArea`, `gridColumn`, `gridRow` +- **Spacing**: `gap`, `rowGap`, `columnGap` +- **Sizing**: `minWidth`, `maxWidth`, `minHeight`, `maxHeight` + +### Sub-elements + +The CommandMenu component has several sub-elements that can be styled: + +- `SearchWrapper` - Container for the search input area +- `SearchInput` - The search input field specifically +- `SearchIcon` - The search/loading icon +- `LoadingWrapper` - Container for loading state +- `EmptyState` - Container for empty state message +- `MenuWrapper` - Container for the menu content + +#### searchInputStyles + +Customizes the search input field specifically. + +### Modifiers + +The CommandMenu component supports the following modifiers: + +| Modifier | Type | Description | +|----------|------|-------------| +| loading | `boolean` | Whether the command palette is in loading state | + +## Accessibility + +### Keyboard Navigation + +The CommandMenu component provides comprehensive keyboard support: + +- **Search Input Focus**: The search input is automatically focused when the palette opens +- **Arrow Keys**: Navigate through filtered options while keeping search input focused +- **Enter**: Select the currently highlighted option +- **Escape**: Clear search term or close the palette +- **Tab**: Navigate between focusable elements + +### Screen Reader Support + +- Proper ARIA roles and labels for search and menu functionality +- Live region announcements for state changes +- Support for `aria-activedescendant` for virtual focus +- Descriptive labels for loading and empty states + +### Focus Management + +- Search input maintains focus during keyboard navigation +- Virtual focus pattern for menu items +- Proper focus restoration when closing + +## Usage Patterns + +### Basic Usage + +```jsx + + Copy + Paste + Cut + +``` + +### With MenuTrigger + +```jsx + + + + Copy + Paste + + +``` + +### With Sections and Keywords + +```jsx + + + + Copy + + + Paste + + + + + Zoom In + + + +``` + +### Controlled Search + +```jsx +const [searchValue, setSearchValue] = useState(''); + + + Action 1 + Action 2 + +``` + +### With Loading State + +```jsx + + {commands.map(command => ( + + {command.name} + + ))} + +``` + +### Custom Filtering + +```jsx + { + // Custom fuzzy search logic + return textValue.toLowerCase().includes(inputValue.toLowerCase()); + }} + searchPlaceholder="Fuzzy search..." +> + Action 1 + Action 2 + +``` + +### Force Mount Items + +```jsx + + + Help (always visible) + + Copy + Paste + +``` + +### With Selection + +```jsx +const [selectedKeys, setSelectedKeys] = useState(['copy']); + + + Copy + Paste + Cut + +``` + +### Multiple Selection + +```jsx +const [selectedKeys, setSelectedKeys] = useState(['copy', 'paste']); + + + Copy + Paste + Cut + +``` + +### With DialogTrigger + +Use CommandMenu inside a Dialog with DialogTrigger for modal command palette functionality: + +```jsx + + + + + + Copy + + + Paste + + + Cut + + + + +``` + +### With useDialogContainer Hook + +For programmatic control over the command menu dialog: + +```jsx +import { useDialogContainer } from '@cube-dev/ui-kit'; + +function CommandMenuDialogContent({ onClose, ...args }) { + const handleAction = (key) => { + console.log('Action selected:', key); + onClose(); + }; + + return ( + + + + Copy + + + Paste + + + Cut + + + + ); +} + +function App() { + const dialog = useDialogContainer(CommandMenuDialogContent); + + const handleOpenDialog = () => { + dialog.open({ + searchPlaceholder: 'Search commands...', + autoFocus: true, + onClose: dialog.close, + }); + }; + + return ( +
+ + {dialog.rendered} +
+ ); +} +``` + +## Advanced Features + +### Enhanced Search + +The CommandMenu supports enhanced search capabilities: + +- **Keywords**: Items can include additional keywords for better discoverability +- **Custom values**: Items can have custom search values separate from display text +- **Force mount**: Certain items can always be visible regardless of search filter +- **Custom filtering**: Override the default search algorithm with custom logic + +## Best Practices + +### Do's + +- **Provide clear placeholders**: Use descriptive placeholder text that indicates what users can search for +- **Use keywords**: Add relevant keywords to items for better discoverability +- **Group related commands**: Use sections to organize commands logically +- **Handle loading states**: Show loading indicators for async operations +- **Provide keyboard shortcuts**: Include hotkey hints in menu items when applicable + +### Don'ts + +- **Don't overload with options**: Too many commands can overwhelm users even with search +- **Don't use for simple menus**: Use regular Menu component for small, static option sets +- **Don't ignore empty states**: Always provide helpful empty state messages +- **Don't disable search**: The search functionality is core to the component's purpose + +## Related Components + +- [Menu](/docs/actions-menu--docs) - For static menu options without search +- [ListBox](/docs/forms-listbox--docs) - For searchable selection lists +- [Dialog](/docs/overlays-dialog--docs) - For modal command palette usage +- [MenuTrigger](/docs/actions-menu--docs) - For trigger-based command palette usage + +## Technical Notes + +### Performance + +- The component uses efficient filtering with React Stately's collection system +- Search filtering is debounced to prevent excessive re-renders +- Virtual focus is used to maintain performance with large option sets + +### Accessibility Compliance + +- Meets WCAG 2.1 AA standards for keyboard navigation +- Supports screen readers with proper ARIA attributes +- Implements virtual focus pattern for optimal accessibility + +### Browser Support + +- Modern browsers with ES2018+ support +- Requires React 18+ for concurrent features +- Uses React Aria for cross-browser accessibility diff --git a/src/components/actions/CommandMenu/CommandMenu.spec.md b/src/components/actions/CommandMenu/CommandMenu.spec.md new file mode 100644 index 00000000..9f91bace --- /dev/null +++ b/src/components/actions/CommandMenu/CommandMenu.spec.md @@ -0,0 +1,278 @@ +# CommandMenu Component Specification + +## Overview + +The CommandMenu component is a searchable menu interface that combines the functionality of Menu and ListBox components. It provides a command-line-like experience for users to quickly find and execute actions through a searchable interface. + +## Component Features + +### Core Functionality +- **Search-first interface**: Always includes a search input at the top for filtering options +- **Flexible usage**: Can be used standalone or within a popover/modal +- **Multiple trigger methods**: Can be opened via MenuTrigger or programmatically like a Dialog +- **Virtual focus**: Uses virtual focus for keyboard navigation while keeping search input focused +- **Header/Footer support**: Configurable header and footer sections +- **Accessibility**: Full keyboard navigation and screen reader support +- **Loading states**: Built-in loading indicator and state management +- **Force mount items**: Always render certain items regardless of search filter +- **Enhanced search**: Keywords-based matching and custom value support + +### Key Behaviors +- When CommandMenu gains focus, the search input is automatically focused +- Arrow keys navigate through filtered options while search input retains focus +- Enter key selects the currently highlighted option +- Escape key clears search or closes the palette +- Supports both single and multiple selection modes +- Real-time filtering as user types + +- **Loading state**: Shows loading indicator while async operations are in progress +- **Force mount**: Certain items always visible regardless of search filter +- **Keywords matching**: Items can be found by additional keywords beyond display text + +## Required Files + +### Core Component Files +1. **CommandMenu.tsx** - Main component implementation +2. **styled.tsx** - Styled components using tasty +3. **index.ts** - Export barrel (includes CommandMenu.Trigger alias) + +### Documentation & Testing +4. **CommandMenu.docs.mdx** - Component documentation +5. **CommandMenu.stories.tsx** - Storybook stories +6. **CommandMenu.test.tsx** - Unit tests (10-15 comprehensive tests) + +### Integration Files +7. **Update src/components/actions/index.ts** - Export new components +8. **Update src/index.ts** - Export new components + +## Implementation Approach + +The CommandMenu will **reuse the existing Menu component** and add search functionality on top. This approach ensures we inherit all Menu features (sections, descriptions, tooltips, hotkeys) while adding search-specific capabilities. The implementation will follow the React Aria command palette example pattern, wrapping Menu with search input and filtering logic. + +### Key Technical Insights + +1. **Reuse Menu component**: CommandMenu will wrap the existing Menu component to inherit all features (sections, descriptions, tooltips, hotkeys) +2. **Filter-based search**: Use React Stately's `filter` prop (like ListBox) to implement search functionality +3. **Virtual focus pattern**: Follow ListBox's search pattern - search input stays focused while arrow keys navigate menu items +4. **Reuse existing patterns**: Use `useDialogContainer(CommandMenu)` for programmatic usage - no need for a separate hook + +## Implementation Plan + +### Phase 1: Core Component Structure (CommandMenu.tsx) +1. **Setup component interface** + - Extend Menu props with search-specific additions + - Add `searchPlaceholder`, `emptyLabel`, `filter` props + - Add `searchInputStyles` for search input styling + - Add advanced features: `isLoading`, `shouldFilter` + - Support enhanced item structure with `keywords`, `value`, `forceMount` + - Reuse all existing Menu props (header, footer, itemStyles, etc.) + +2. **Implement search wrapper around Menu** + - Create search input similar to ListBox's searchable mode + - Implement filtering logic using React Stately's filter capability + - Add keywords-based search matching for enhanced findability + - Support force mount items that always appear regardless of filter + - Handle manual filtering mode (`shouldFilter={false}`) + - Pass filtered collection to Menu component + - Handle search state management + +3. **Setup keyboard navigation** + - Follow ListBox pattern for virtual focus + - Keep search input focused while navigating options + - Handle Enter/Space for selection, Escape for clearing/closing + + - Use `shouldUseVirtualFocus` for proper accessibility + +4. **Render structure** + - Header section (optional, from Menu) + - Search input (always present) + - Loading indicator (when `isLoading={true}`) + - Menu component with filtered items + - Footer section (optional, from Menu) + - Empty state when no results + +### Phase 2: MenuTrigger Integration and Alias +1. **Create CommandMenu.Trigger alias** + - Export MenuTrigger as CommandMenu.Trigger in index.ts + - Ensure CommandMenu works seamlessly with MenuTrigger + - Test compatibility with existing MenuTrigger features + +2. **Update documentation** + - Show usage with MenuTrigger + - Demonstrate the alias usage pattern + - Provide examples of both approaches + +### Phase 3: Styling (styled.tsx) +1. **Create styled components using tasty** + - CommandMenuWrapper: Main container + - SearchSection: Search input area + - LoadingSection: Loading indicator area + - ContentSection: Options list area + - HeaderSection: Optional header + - FooterSection: Optional footer + - EmptyState: No results message + +2. **Implement responsive design** + - Mobile-friendly layout + - Proper spacing and typography + - Theme integration + +### Phase 4: Documentation (CommandMenu.docs.mdx) +1. **Follow documentation guidelines** + - Component overview and when to use + - Complete props documentation + - Accessibility section with keyboard shortcuts + - Best practices and examples + - Integration patterns + +2. **Include comprehensive examples** + - Basic usage + - With header/footer + - Programmatic usage with `useDialogContainer(CommandMenu)` + - With MenuTrigger (using CommandMenu.Trigger alias) + - Custom filtering and keywords + - Multiple selection + - Loading states + - Force mount items + +### Phase 5: Stories (CommandMenu.stories.tsx) +1. **Create comprehensive stories** + - Default usage + - With header and footer + - Programmatic usage with `useDialogContainer(CommandMenu)` + - With MenuTrigger (CommandMenu.Trigger) + - Custom filtering and keywords + - Loading states + - Empty states + - Force mount items + - Complex example with multiple sections + +2. **Add play functions** + - Auto-open for trigger-based stories + - Demonstrate keyboard navigation + - Show filtering behavior + +### Phase 6: Testing (CommandMenu.test.tsx) +1. **Functional tests (10-15 tests)** + - Basic rendering and props + - Search functionality and filtering + - Keywords-based search matching + - Keyboard navigation (arrow keys, enter, escape) + - Selection handling (single/multiple) + - Header/footer rendering + - Empty state display + - Loading state display + - Force mount items behavior + - Accessibility attributes + - Focus management + - MenuTrigger integration + - Programmatic usage with `useDialogContainer` + - Custom filtering (`shouldFilter={false}`) + - Disabled states + - Event handlers + +2. **Accessibility tests** + - ARIA attributes + - Keyboard navigation + - Screen reader compatibility + - Focus management + +## Technical Specifications + +### Props Interface +```typescript +interface CommandMenuProps extends CubeMenuProps { + // Search-specific props + searchPlaceholder?: string; + searchValue?: string; + onSearchChange?: (value: string) => void; + filter?: (textValue: string, inputValue: string) => boolean; + emptyLabel?: ReactNode; + searchInputStyles?: Styles; + + // Advanced search features + isLoading?: boolean; + shouldFilter?: boolean; + + // Inherited from Menu: all Menu props are supported + // - header, footer (content props) + // - styles, itemStyles, sectionStyles, sectionHeadingStyles (styling) + // - selectionIcon, shouldFocusWrap, autoFocus (behavior) + // - onAction, onSelectionChange (handlers) + // - All React Aria menu props (isDisabled, disabledKeys, etc.) +} + +interface CommandMenuItem { + // Standard item props + id: string; + textValue: string; + + // Enhanced search features + keywords?: string[]; + value?: string; + forceMount?: boolean; + + // Standard Menu item props + // ... (all existing Menu item props) +} +``` + +### React Aria Hooks Usage +- **Reused from Menu**: `useTreeState`, `useMenu` (via Menu component) +- **Search-specific**: `useFilter` - For search filtering functionality +- **Navigation**: `useKeyboard` - For search input keyboard navigation +- **Trigger-based**: `useMenuTrigger`, `useMenuTriggerState` (via MenuTrigger) +- **Positioning**: `useOverlayPosition` (via MenuTrigger/Dialog) + +### Accessibility Requirements +- Full keyboard navigation support +- Screen reader announcements for state changes +- Proper ARIA labeling and relationships +- Focus management with virtual focus +- Support for aria-activedescendant +- Proper role assignments + +### Performance Considerations +- Efficient filtering with debouncing if needed +- Virtual scrolling for large datasets (future enhancement) +- Memoization of filtered results +- Optimized re-renders + +## Integration Points + +### With Menu Component +- **Direct reuse**: CommandMenu wraps Menu component completely +- **Inherit all features**: Sections, descriptions, tooltips, hotkeys, selection icons +- **Consistent API**: All Menu props work the same way +- **Styling compatibility**: All Menu styling props are supported + +### With ListBox Component +- **Search pattern**: Reuse ListBox's search input implementation and styling +- **Filtering logic**: Share the same collection filtering approach +- **Virtual focus**: Use the same keyboard navigation pattern + +### With Modal System +- **MenuTrigger integration**: Works seamlessly with existing MenuTrigger +- **Dialog integration**: Compatible with useDialogContainer pattern +- **Focus management**: Leverages existing overlay focus management + +## Success Criteria + +1. **Functionality**: All core features work as specified +2. **Accessibility**: Meets WCAG 2.1 AA standards +3. **Performance**: Smooth interaction with large datasets +4. **Documentation**: Complete and clear documentation +5. **Testing**: All tests pass with good coverage +6. **Integration**: Works seamlessly with existing components +7. **Consistency**: Follows established patterns and conventions + +## Future Enhancements + +1. **Virtual scrolling** for large datasets +2. **Nested command groups** with hierarchical navigation +3. **Recent commands** history +4. **useCommandState hook** for accessing internal state +5. **Custom renderers** for complex command items +6. **Async loading** with search suggestions +7. **Command categories** with visual grouping +8. **Fuzzy search** improvements \ No newline at end of file diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx new file mode 100644 index 00000000..ed1e5c96 --- /dev/null +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -0,0 +1,848 @@ +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { + IconArrowBack, + IconArrowForward, + IconClipboard, + IconCopy, + IconCut, + IconDeviceFloppy, + IconFile, + IconFileText, + IconFolder, + IconSearch, + IconSelect, + IconSettings, +} from '@tabler/icons-react'; +import React, { useState } from 'react'; + +import { + Dialog, + DialogTrigger, + useDialogContainer, +} from '../../overlays/Dialog'; +import { Button } from '../Button'; +import { Menu } from '../Menu/Menu'; + +import { CommandMenu, CubeCommandMenuProps } from './CommandMenu'; + +import type { StoryFn } from '@storybook/react'; + +export default { + title: 'Actions/CommandMenu', + component: CommandMenu, + parameters: { + docs: { + description: { + component: + 'A searchable menu interface that provides a command-line-like experience for users to quickly find and execute actions.', + }, + }, + }, + argTypes: { + /* Search */ + searchPlaceholder: { + control: 'text', + description: 'Placeholder text for the search input', + table: { + defaultValue: { summary: 'Search commands...' }, + }, + }, + searchValue: { + control: 'text', + description: 'The search value in controlled mode', + }, + onSearchChange: { + action: 'searchChanged', + description: 'Callback fired when search value changes', + }, + filter: { + description: 'Custom filter function for search', + }, + emptyLabel: { + control: 'text', + description: 'Label to show when no results are found', + table: { + defaultValue: { summary: 'No commands found' }, + }, + }, + searchInputStyles: { + description: 'Custom styles for the search input', + }, + + /* Advanced Features */ + isLoading: { + control: 'boolean', + description: 'Whether the command palette is loading', + table: { + defaultValue: { summary: 'false' }, + }, + }, + shouldFilter: { + control: 'boolean', + description: 'Whether to filter items based on search', + table: { + defaultValue: { summary: 'true' }, + }, + }, + autoFocus: { + control: 'boolean', + description: 'Whether to auto-focus the search input', + table: { + defaultValue: { summary: 'true' }, + }, + }, + + /* Menu Props */ + onAction: { + action: 'action', + description: 'Callback fired when an item is selected', + }, + onSelectionChange: { + action: 'selectionChange', + description: 'Callback fired when selection changes', + }, + selectionMode: { + control: 'select', + options: ['none', 'single', 'multiple'], + description: 'Selection mode for the command palette', + table: { + defaultValue: { summary: 'none' }, + }, + }, + isDisabled: { + control: 'boolean', + description: 'Whether the command palette is disabled', + }, + + /* Styling */ + size: { + options: ['small', 'medium'], + control: { type: 'radio' }, + description: 'Size of the select component', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'small' }, + }, + }, + styles: { + description: 'Custom styles for the command palette container', + }, + }, +}; + +const basicCommands = [ + { + key: 'copy', + label: 'Copy', + description: 'Copy selected text', + hotkeys: 'Ctrl+C', + icon: , + }, + { + key: 'paste', + label: 'Paste', + description: 'Paste from clipboard', + hotkeys: 'Ctrl+V', + icon: , + }, + { + key: 'cut', + label: 'Cut', + description: 'Cut selected text', + hotkeys: 'Ctrl+X', + icon: , + }, + { + key: 'undo', + label: 'Undo', + description: 'Undo last action', + hotkeys: 'Ctrl+Z', + icon: , + }, + { + key: 'redo', + label: 'Redo', + description: 'Redo last action', + hotkeys: 'Ctrl+Y', + icon: , + }, + { + key: 'select-all', + label: 'Select All', + description: 'Select all text', + hotkeys: 'Ctrl+A', + icon: , + }, +]; + +const extendedCommands = [ + // File operations + { + key: 'new-file', + label: 'New File', + description: 'Create a new file', + section: 'File', + hotkeys: 'Ctrl+N', + }, + { + key: 'open-file', + label: 'Open File', + description: 'Open an existing file', + section: 'File', + hotkeys: 'Ctrl+O', + }, + { + key: 'save-file', + label: 'Save File', + description: 'Save current file', + section: 'File', + hotkeys: 'Ctrl+S', + }, + { + key: 'save-as', + label: 'Save As...', + description: 'Save file with new name', + section: 'File', + }, + + // Edit operations + { + key: 'copy', + label: 'Copy', + description: 'Copy selected text', + section: 'Edit', + hotkeys: 'Ctrl+C', + keywords: ['duplicate', 'clone'], + }, + { + key: 'paste', + label: 'Paste', + description: 'Paste from clipboard', + section: 'Edit', + hotkeys: 'Ctrl+V', + keywords: ['insert'], + }, + { + key: 'cut', + label: 'Cut', + description: 'Cut selected text', + section: 'Edit', + hotkeys: 'Ctrl+X', + }, + { + key: 'find', + label: 'Find', + description: 'Search in document', + section: 'Edit', + hotkeys: 'Ctrl+F', + keywords: ['search', 'locate'], + }, + { + key: 'replace', + label: 'Find and Replace', + description: 'Find and replace text', + section: 'Edit', + hotkeys: 'Ctrl+H', + }, + + // View operations + { + key: 'zoom-in', + label: 'Zoom In', + description: 'Increase zoom level', + section: 'View', + keywords: ['magnify', 'enlarge'], + }, + { + key: 'zoom-out', + label: 'Zoom Out', + description: 'Decrease zoom level', + section: 'View', + keywords: ['shrink', 'reduce'], + }, + { + key: 'full-screen', + label: 'Toggle Full Screen', + description: 'Enter or exit full screen', + section: 'View', + hotkeys: 'F11', + }, + { + key: 'sidebar', + label: 'Toggle Sidebar', + description: 'Show or hide sidebar', + section: 'View', + }, + + // Help + { + key: 'help', + label: 'Help', + description: 'Open help documentation', + section: 'Help', + forceMount: true, + }, + { + key: 'about', + label: 'About', + description: 'About this application', + section: 'Help', + forceMount: true, + }, +]; + +export const Default: StoryFn> = (args) => ( + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +Default.args = { + searchPlaceholder: 'Search commands...', + autoFocus: true, +}; + +export const WithSections: StoryFn> = (args) => { + const commandsBySection = extendedCommands.reduce( + (acc, command) => { + const section = command.section || 'Other'; + if (!acc[section]) acc[section] = []; + acc[section].push(command); + return acc; + }, + {} as Record, + ); + + return ( + + {Object.entries(commandsBySection).map(([sectionName, commands]) => ( + + {commands.map((command) => ( + + {command.label} + + ))} + + ))} + + ); +}; + +WithSections.args = { + searchPlaceholder: 'Search all commands...', + autoFocus: true, +}; + +export const WithMenuTrigger: StoryFn> = (args) => ( + + + + {basicCommands.map((command) => ( + + {command.label} + + ))} + + +); + +WithMenuTrigger.args = { + searchPlaceholder: 'Search commands...', + autoFocus: true, +}; + +WithMenuTrigger.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByRole, findByPlaceholderText, queryByPlaceholderText } = + within(canvasElement); + + // Click the trigger button to open the command palette + await userEvent.click(await findByRole('button')); + + // Wait for the command palette to appear and verify the search input is present + const searchInput = await findByPlaceholderText('Search commands...'); + + // Verify the search input is focused by checking if it's the active element + await waitFor(() => { + if (document.activeElement !== searchInput) { + throw new Error('Search input should be focused'); + } + }); + + // // Test keyboard navigation and action triggering + // await userEvent.keyboard('{ArrowDown}'); // Navigate to first item + // await userEvent.keyboard('{Enter}'); // Trigger action + + // // Verify the command palette closes after action + // await waitFor(() => { + // if (queryByPlaceholderText('Search commands...')) { + // throw new Error('Command palette should close after action'); + // } + // }); +}; + +export const ControlledSearch: StoryFn> = (args) => { + const [searchValue, setSearchValue] = useState(''); + + return ( +
+
+ Current search: "{searchValue}" +
+ + {basicCommands.map((command) => ( + + {command.label} + + ))} + +
+ ); +}; + +ControlledSearch.args = { + searchPlaceholder: 'Type to search...', + autoFocus: true, +}; + +export const LoadingState: StoryFn> = (args) => ( + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +LoadingState.args = { + searchPlaceholder: 'Loading commands...', + isLoading: true, + autoFocus: true, +}; + +export const CustomFilter: StoryFn> = (args) => ( + { + // Custom fuzzy search - matches if all characters of input appear in order + const text = textValue.toLowerCase(); + const input = inputValue.toLowerCase(); + let textIndex = 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + const foundIndex = text.indexOf(char, textIndex); + if (foundIndex === -1) return false; + textIndex = foundIndex + 1; + } + + return true; + }} + > + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +CustomFilter.args = { + searchPlaceholder: 'Try fuzzy search (e.g., "cp" for Copy)...', + autoFocus: true, +}; + +export const WithKeywords: StoryFn> = (args) => ( + + + Copy + + + Paste + + + Save File + + + Open File + + +); + +WithKeywords.args = { + searchPlaceholder: 'Try searching "duplicate" or "insert"...', + autoFocus: true, +}; + +export const ForceMountItems: StoryFn> = (args) => ( + + + Help (always visible) + + + Settings (always visible) + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +ForceMountItems.args = { + searchPlaceholder: 'Help and Settings always visible...', + autoFocus: true, +}; + +export const EmptyState: StoryFn> = (args) => ( + + Copy + Paste + +); + +EmptyState.args = { + searchPlaceholder: 'Try searching for "xyz" to see empty state...', + emptyLabel: 'No matching commands found. Try a different search term.', + autoFocus: true, +}; + +export const MultipleSelection: StoryFn> = (args) => { + const [selectedKeys, setSelectedKeys] = useState(['copy', 'cut']); + + return ( +
+
+ Selected: {selectedKeys.join(', ') || 'None'} +
+ + {basicCommands.map((command) => ( + + {command.label} + + ))} + +
+ ); +}; + +MultipleSelection.args = { + searchPlaceholder: 'Select multiple commands...', + autoFocus: true, +}; + +export const SingleSelection: StoryFn> = (args) => { + const [selectedKey, setSelectedKey] = useState(null); + + return ( +
+
+ Selected: {selectedKey || 'None'} +
+ { + setSelectedKey(keys[0] || null); + }} + > + {basicCommands.map((command) => ( + + {command.label} + + ))} + +
+ ); +}; + +SingleSelection.args = { + searchPlaceholder: 'Select a single command...', + autoFocus: true, +}; + +export const CustomStyling: StoryFn> = (args) => ( + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +CustomStyling.args = { + searchPlaceholder: 'Custom styled command palette...', + autoFocus: true, +}; + +export const HotkeyTesting: StoryFn> = (args) => { + const [lastAction, setLastAction] = useState(null); + + const handleAction = (key: string) => { + setLastAction(`Action triggered: ${key}`); + console.log('Hotkey action triggered:', key); + // Clear the message after 3 seconds + setTimeout(() => setLastAction(null), 3000); + }; + + return ( +
+
+ Hotkey Test Instructions: +
    +
  • Try pressing Ctrl+C (Copy)
  • +
  • Try pressing Ctrl+V (Paste)
  • +
  • Try pressing Ctrl+X (Cut)
  • +
  • Try pressing Ctrl+Z (Undo)
  • +
+ {lastAction && ( +
+ {lastAction} +
+ )} +
+ + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +
+ ); +}; + +HotkeyTesting.args = { + searchPlaceholder: 'Test hotkeys while focused here...', + autoFocus: true, +}; + +export const MediumSize: StoryFn> = (args) => ( + + {basicCommands.map((command) => ( + + {command.label} + + ))} + +); + +MediumSize.args = { + searchPlaceholder: 'Medium size command palette...', + autoFocus: true, +}; + +export const WithDialog: StoryFn> = (args) => ( + + + + + {basicCommands.map((command) => ( + + {command.label} + + ))} + + + +); + +WithDialog.args = { + searchPlaceholder: 'Search commands...', + autoFocus: true, +}; + +WithDialog.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('Open Command Menu'); + await userEvent.click(button); + + // Wait for dialog to open + await waitFor(() => { + canvas.getByPlaceholderText('Search commands...'); + }); +}; + +function CommandMenuDialogContent({ + onClose, + ...args +}: CubeCommandMenuProps & { onClose: () => void }) { + const commandMenuProps = { + ...args, + onAction: (key: React.Key) => { + console.log('Action selected:', key); + onClose(); + }, + }; + + return ( + + + {basicCommands.map((command) => ( + + {command.label} + + ))} + + + ); +} + +export const WithDialogContainer: StoryFn> = ( + args, +) => { + const dialog = useDialogContainer(CommandMenuDialogContent); + + const handleOpenDialog = () => { + dialog.open({ + ...args, + onClose: dialog.close, + }); + }; + + return ( +
+ + {dialog.rendered} +
+ ); +}; + +WithDialogContainer.args = { + searchPlaceholder: 'Search commands...', + autoFocus: true, +}; + +WithDialogContainer.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('Open Command Menu (Hook)'); + await userEvent.click(button); + + // Wait for dialog to open + await waitFor(() => { + canvas.getByPlaceholderText('Search commands...'); + }); +}; diff --git a/src/components/actions/CommandMenu/CommandMenu.test.tsx b/src/components/actions/CommandMenu/CommandMenu.test.tsx new file mode 100644 index 00000000..aab7516f --- /dev/null +++ b/src/components/actions/CommandMenu/CommandMenu.test.tsx @@ -0,0 +1,628 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { CommandMenu } from './CommandMenu'; + +describe('CommandMenu', () => { + const items = [ + { id: '1', textValue: 'Create file' }, + { id: '2', textValue: 'Open folder' }, + { id: '3', textValue: 'Save document' }, + ]; + + it('renders with search input and menu items', () => { + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + expect( + screen.getByPlaceholderText('Search commands...'), + ).toBeInTheDocument(); + expect(screen.getByText('Create file')).toBeInTheDocument(); + expect(screen.getByText('Open folder')).toBeInTheDocument(); + expect(screen.getByText('Save document')).toBeInTheDocument(); + }); + + it('filters items based on search input', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'file'); + + expect(screen.getByText('Create file')).toBeInTheDocument(); + expect(screen.queryByText('Open folder')).not.toBeInTheDocument(); + expect(screen.queryByText('Save document')).not.toBeInTheDocument(); + }); + + it('navigates through items with arrow keys while search input retains focus', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + + // Focus the search input + await user.click(searchInput); + expect(searchInput).toHaveFocus(); + + // Press arrow down - should move virtual focus to first item + await user.keyboard('{ArrowDown}'); + + // Search input should still have focus + expect(searchInput).toHaveFocus(); + + // First item should be virtually focused (aria-activedescendant) + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + 'test-menu-menu-option-1', + ); + + // Press arrow down again - should move to second item + await user.keyboard('{ArrowDown}'); + + // Search input should still have focus + expect(searchInput).toHaveFocus(); + + // Second item should be virtually focused + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + 'test-menu-menu-option-2', + ); + + // Press arrow up - should move back to first item + await user.keyboard('{ArrowUp}'); + + // Search input should still have focus + expect(searchInput).toHaveFocus(); + + // First item should be virtually focused again + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + 'test-menu-menu-option-1', + ); + }); + + it('triggers action with Enter key', async () => { + const user = userEvent.setup(); + const onAction = jest.fn(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.click(searchInput); + + // Navigate to first item and trigger action + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(onAction).toHaveBeenCalledWith('1'); + }); + + it('supports selection mode when explicitly set', async () => { + const user = userEvent.setup(); + const onSelectionChange = jest.fn(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.click(searchInput); + + // Navigate to first item and select it + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const selectionArg = onSelectionChange.mock.calls[0][0]; + expect(selectionArg).toEqual(['1']); + }); + + it('clears search with Escape key', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'test'); + expect(searchInput).toHaveValue('test'); + + await user.keyboard('{Escape}'); + expect(searchInput).toHaveValue(''); + }); + + it('shows empty state when no items match search', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('No commands found')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('supports custom filter function', async () => { + const user = userEvent.setup(); + const customFilter = (textValue: string, inputValue: string) => { + return textValue.toLowerCase().includes(inputValue.toLowerCase()); + }; + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'FOLDER'); + + expect(screen.getByText('Open folder')).toBeInTheDocument(); + expect(screen.queryByText('Create file')).not.toBeInTheDocument(); + }); + + it('supports keywords-based search', async () => { + const user = userEvent.setup(); + + render( + + + Create file + + Open folder + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'new'); + + expect(screen.getByText('Create file')).toBeInTheDocument(); + expect(screen.queryByText('Open folder')).not.toBeInTheDocument(); + }); + + it('supports force mount items', async () => { + const user = userEvent.setup(); + + render( + + + Always visible + + Sometimes visible + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('Always visible')).toBeInTheDocument(); + expect(screen.queryByText('Sometimes visible')).not.toBeInTheDocument(); + }); + + it('supports sections', () => { + render( + + + Create file + Open folder + + + Save document + + , + ); + + expect(screen.getByText('File Operations')).toBeInTheDocument(); + expect(screen.getByText('Document Operations')).toBeInTheDocument(); + expect(screen.getByText('Create file')).toBeInTheDocument(); + expect(screen.getByText('Save document')).toBeInTheDocument(); + }); + + it('handles controlled search value', async () => { + const user = userEvent.setup(); + const onSearchChange = jest.fn(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + expect(searchInput).toHaveValue('test'); + + await user.type(searchInput, 'ing'); + // Check that onSearchChange was called multiple times and the final call was with the expected value + expect(onSearchChange).toHaveBeenCalledTimes(3); // 'i', 'n', 'g' + expect(onSearchChange).toHaveBeenLastCalledWith('testg'); + }); + + it('auto-focuses search input when autoFocus is true', async () => { + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + }); + + it('auto-focuses first item when search input is focused and has content', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + + // Initially no item should be focused when just clicking + await user.click(searchInput); + expect(searchInput.getAttribute('aria-activedescendant')).toBeFalsy(); + + // Type something to trigger auto-focus + await user.type(searchInput, 'C'); + + // Check that the first matching item is virtually focused + await waitFor(() => { + const activeDescendant = searchInput.getAttribute( + 'aria-activedescendant', + ); + expect(activeDescendant).toMatch(/CommandMenu-menu-option-1/); + }); + }); + + it('focuses first item in filtered results when search changes', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + + // Focus and type to filter items - "folder" should match "Open folder" (id: '2') + await user.click(searchInput); + await user.type(searchInput, 'folder'); + + // First verify that the item is actually filtered and visible + expect(screen.getByText('Open folder')).toBeInTheDocument(); + expect(screen.queryByText('Create file')).not.toBeInTheDocument(); + + // Check that the correct item is virtually focused + await waitFor(() => { + const activeDescendant = searchInput.getAttribute( + 'aria-activedescendant', + ); + expect(activeDescendant).toBeTruthy(); + // Since "Open folder" has id="2", it should be focused when filtered + expect(activeDescendant).toContain('menu-option-2'); + }); + }); + + it('clears focus when no items match search', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + + // First type something that matches to establish focus + await user.click(searchInput); + await user.type(searchInput, 'C'); + + // Verify an item is focused first + await waitFor(() => { + const activeDescendant = searchInput.getAttribute( + 'aria-activedescendant', + ); + expect(activeDescendant).toMatch(/CommandMenu-menu-option-1/); + }); + + // Clear and type something that won't match + await user.clear(searchInput); + await user.type(searchInput, 'nonexistent'); + + // Check that no item is focused + await waitFor(() => { + const activeDescendant = searchInput.getAttribute( + 'aria-activedescendant', + ); + expect(activeDescendant).toBeFalsy(); + }); + }); + + it('supports custom empty label', async () => { + const user = userEvent.setup(); + + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('Custom empty message')).toBeInTheDocument(); + }); + + it('supports custom search placeholder', () => { + render( + + {items.map((item) => ( + + {item.textValue} + + ))} + , + ); + + expect( + screen.getByPlaceholderText('Type to search...'), + ).toBeInTheDocument(); + }); + + it('renders hotkey elements correctly', async () => { + const onAction = jest.fn(); + + const { container } = render( + + + Copy + + + Paste + + , + ); + + // Verify that hotkey elements are rendered + expect(screen.getByText('Copy')).toBeInTheDocument(); + expect(screen.getByText('Paste')).toBeInTheDocument(); + + // Check that hotkey elements (kbd tags) are rendered + const keyElements = container.querySelectorAll('kbd'); + expect(keyElements.length).toBeGreaterThan(0); + + // Check that the hotkey text is present + expect(container.textContent).toContain('Ctrl'); + expect(container.textContent).toContain('C'); + expect(container.textContent).toContain('V'); + }); + + it('hotkeys work when menu item is clicked directly', async () => { + const user = userEvent.setup(); + const onAction = jest.fn(); + + render( + + + Copy + + , + ); + + // Click the menu item directly to verify action works + const copyItem = screen.getByText('Copy').closest('li'); + expect(copyItem).toBeInTheDocument(); + + await user.click(copyItem!); + + // Check that onAction was called with the correct key + expect(onAction).toHaveBeenCalledWith('copy'); + }); + + it('hotkeys trigger actions through direct mechanism', async () => { + const user = userEvent.setup(); + const onAction = jest.fn(); + + render( + + + Copy + + + Paste + + , + ); + + // Focus the search input to simulate real usage + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.click(searchInput); + + // Test that hotkey combinations are allowed to pass through + // by pressing Ctrl+C and checking that the event isn't prevented + await user.keyboard('{Control>}c{/Control}'); + + // In a real browser, this would trigger the useHotkeys handler + // Since we can't reliably test react-hotkeys-hook in Jest, + // we verify that the hotkey elements are rendered and + // the action mechanism works when triggered directly + expect(screen.getByText('Copy')).toBeInTheDocument(); + expect(screen.getByText('Paste')).toBeInTheDocument(); + + // Verify hotkey elements are present + const copyItem = screen.getByText('Copy').closest('li'); + expect(copyItem).toHaveTextContent('Ctrl'); + expect(copyItem).toHaveTextContent('C'); + }); + + it('handles multiple selection with string[] selectedKeys', async () => { + const user = userEvent.setup(); + const onSelectionChange = jest.fn(); + + // Create a component that properly handles string[] selectedKeys + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = React.useState([]); + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + onSelectionChange(keys); + }; + + return ( +
+
+ Selected: {selectedKeys.join(', ') || 'None'} +
+ + {items.map((item) => ( + + {item.textValue} + + ))} + +
+ ); + }; + + render(); + + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.click(searchInput); + + // Navigate to first item and select it + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + // Check that onSelectionChange was called with a string[] + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const selectionArg = onSelectionChange.mock.calls[0][0]; + expect(selectionArg).toEqual(['1']); + + // Verify the display shows the selected item + let selectedDisplay = screen.getByText(/Selected:/); + expect(selectedDisplay).toHaveTextContent(/Selected:.*1/); + + // Select another item + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + // Check that both items are selected + expect(onSelectionChange).toHaveBeenCalledTimes(2); + const secondSelectionArg = onSelectionChange.mock.calls[1][0]; + expect(secondSelectionArg).toEqual(expect.arrayContaining(['1', '2'])); + + // Verify the display shows both selected items + selectedDisplay = screen.getByText(/Selected:/); + expect(selectedDisplay).toHaveTextContent(/Selected:.*1.*2/); + }); +}); diff --git a/src/components/actions/CommandMenu/CommandMenu.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx new file mode 100644 index 00000000..200b4172 --- /dev/null +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -0,0 +1,703 @@ +import { useSyncRef } from '@react-aria/utils'; +import { useDOMRef } from '@react-spectrum/utils'; +import { DOMRef, FocusStrategy } from '@react-types/shared'; +import React, { + ReactElement, + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useFilter, useMenu } from 'react-aria'; +// Import Item and Section from Menu for CommandMenu compound component +import { Item, Section, useTreeState } from 'react-stately'; + +import { LoadingIcon } from '../../../icons'; +import { + BaseProps, + CONTAINER_STYLES, + ContainerStyleProps, + extractStyles, + filterBaseProps, + Styles, +} from '../../../tasty'; +import { mergeProps } from '../../../utils/react'; +import { TooltipProvider } from '../../overlays/Tooltip/TooltipProvider'; +import { useMenuContext } from '../Menu'; +import { CubeMenuProps } from '../Menu/Menu'; +import { MenuItem } from '../Menu/MenuItem'; +import { MenuSection } from '../Menu/MenuSection'; +import { MenuTrigger } from '../Menu/MenuTrigger'; +import { StyledDivider, StyledMenu } from '../Menu/styled'; + +import { + StyledCommandMenu, + StyledEmptyState, + StyledLoadingWrapper, + StyledMenuWrapper, + StyledSearchInput, +} from './styled'; + +export interface CommandMenuItem { + // Standard item props + id: string; + textValue: string; + + // Enhanced search features + keywords?: string[]; + forceMount?: boolean; + + // Standard Menu item props inherited + [key: string]: any; +} + +export interface CubeCommandMenuProps + extends BaseProps, + ContainerStyleProps, + Omit< + CubeMenuProps, + 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange' + > { + // Search-specific props + searchPlaceholder?: string; + searchValue?: string; + onSearchChange?: (value: string) => void; + filter?: (textValue: string, inputValue: string) => boolean; + emptyLabel?: ReactNode; + searchInputStyles?: Styles; + + // Advanced search features + isLoading?: boolean; + shouldFilter?: boolean; + + // Focus management - override the autoFocus from CubeMenuProps to allow boolean | FocusStrategy + autoFocus?: boolean | FocusStrategy; + + // Size prop + size?: 'small' | 'medium' | (string & {}); + + /** Currently selected keys (controlled) */ + selectedKeys?: string[]; + /** Initially selected keys (uncontrolled) */ + defaultSelectedKeys?: string[]; + /** Handler for selection changes */ + onSelectionChange?: (keys: string[]) => void; +} + +function CommandMenuBase( + props: CubeCommandMenuProps, + ref: DOMRef, +) { + const { + searchPlaceholder = 'Search commands...', + searchValue: controlledSearchValue, + onSearchChange, + filter: customFilter, + emptyLabel = 'No commands found', + searchInputStyles, + isLoading = false, + shouldFilter = true, + autoFocus = true, + size = 'small', + qa, + styles, + selectedKeys, + defaultSelectedKeys, + onSelectionChange, + ...restMenuProps + } = props; + + const domRef = useDOMRef(ref); + const searchInputRef = useRef(null); + const contextProps = useMenuContext(); + + // Convert string[] to Set for React Aria compatibility + const ariaSelectedKeys = selectedKeys ? new Set(selectedKeys) : undefined; + const ariaDefaultSelectedKeys = defaultSelectedKeys + ? new Set(defaultSelectedKeys) + : undefined; + + const handleSelectionChange = onSelectionChange + ? (keys: any) => { + if (keys === 'all') { + // Handle 'all' selection case - collect all available keys + const allKeys = Array.from(treeState.collection.getKeys()).map( + (key: any) => String(key), + ); + onSelectionChange(allKeys); + } else if (keys instanceof Set) { + onSelectionChange(Array.from(keys).map((key) => String(key))); + } else { + onSelectionChange([]); + } + } + : undefined; + + const completeProps = mergeProps(contextProps, restMenuProps, { + selectedKeys: ariaSelectedKeys, + defaultSelectedKeys: ariaDefaultSelectedKeys, + onSelectionChange: handleSelectionChange, + }); + + // Search state management + const [internalSearchValue, setInternalSearchValue] = useState(''); + const searchValue = controlledSearchValue ?? internalSearchValue; + + const handleSearchChange = useCallback( + (value: string) => { + if (controlledSearchValue === undefined) { + setInternalSearchValue(value); + } + onSearchChange?.(value); + }, + [controlledSearchValue, onSearchChange], + ); + + // Filter setup + const { contains } = useFilter({ sensitivity: 'base' }); + const textFilterFn = useMemo( + () => customFilter || contains, + [customFilter, contains], + ); + + // Enhanced filter function that supports keywords and forceMount + const enhancedFilter = useCallback( + (textValue: string, inputValue: string, item?: any) => { + // Always show force-mounted items + if (item?.forceMount) { + return true; + } + + // If shouldFilter is false, show all items + if (!shouldFilter) { + return true; + } + + // Check main text value + if (textFilterFn(textValue, inputValue)) { + return true; + } + + // Check keywords if available + if (item?.keywords && Array.isArray(item.keywords)) { + return item.keywords.some((keyword: string) => + textFilterFn(keyword, inputValue), + ); + } + + return false; + }, + [textFilterFn, shouldFilter], + ); + + // Collection filter for React Stately + const collectionFilter = useCallback( + (nodes: Iterable): Iterable => { + const term = searchValue.trim(); + + // If no search term, return all nodes + if (!term) { + return nodes; + } + + // Recursive helper to filter sections and items + const filterNodes = (iter: Iterable): any[] => { + const result: any[] = []; + + for (const node of iter) { + if (node.type === 'section') { + const filteredChildren = filterNodes(node.childNodes); + + if (filteredChildren.length) { + result.push({ + ...node, + childNodes: filteredChildren, + }); + } + } else { + const text = node.textValue ?? String(node.rendered ?? ''); + + if (enhancedFilter(text, term, node.props)) { + result.push(node); + } + } + } + + return result; + }; + + return filterNodes(nodes); + }, + [searchValue, enhancedFilter], + ); + + // Create tree state with filter for both keyboard navigation and rendering + const treeStateProps = { + ...completeProps, + filter: collectionFilter, + shouldUseVirtualFocus: true, // Always use virtual focus for CommandMenu + }; + + const treeState = useTreeState(treeStateProps); + + const collectionItems = [...treeState.collection]; + const hasSections = collectionItems.some((item) => item.type === 'section'); + + // Track focused key for aria-activedescendant + const [focusedKey, setFocusedKey] = React.useState(null); + + // Apply filtering to collection items for rendering and empty state checks + const filteredCollectionItems = useMemo(() => { + const term = searchValue.trim(); + if (!term) { + return collectionItems; + } + + const filterNodes = (items: any[]): any[] => { + const result: any[] = []; + + [...items].forEach((item) => { + if (item.type === 'section') { + const filteredChildren = filterNodes(item.childNodes); + if (filteredChildren.length) { + result.push({ + ...item, + childNodes: filteredChildren, + }); + } + } else { + const text = item.textValue ?? String(item.rendered ?? ''); + if (enhancedFilter(text, term, item.props)) { + result.push(item); + } + } + }); + + return result; + }; + + return filterNodes(collectionItems); + }, [collectionItems, searchValue, enhancedFilter]); + + const hasFilteredItems = filteredCollectionItems.length > 0; + const viewHasSections = filteredCollectionItems.some( + (item) => item.type === 'section', + ); + + // Helper function to find the first selectable item from filtered results + const findFirstSelectableItem = useCallback(() => { + // Use the filtered collection items instead of the full tree state collection + for (const item of filteredCollectionItems) { + if ( + item && + item.type === 'item' && + !treeState.selectionManager.isDisabled(item.key) + ) { + return item.key; + } + } + + return null; + }, [filteredCollectionItems, treeState.selectionManager]); + + // Create a ref for the menu container + const menuRef = useRef(null); + + // Use menu hook for accessibility + const { menuProps } = useMenu( + { + ...completeProps, + 'aria-label': 'Command palette menu', + filter: collectionFilter, + shouldUseVirtualFocus: true, + }, + treeState, + menuRef, + ); + + // Manual rendering of menu items (similar to Menu component) + const renderedItems = useMemo(() => { + const items: React.ReactNode[] = []; + let isFirstSection = true; + + filteredCollectionItems.forEach((item) => { + if (item.type === 'section') { + if (!isFirstSection) { + items.push( + , + ); + } + + items.push( + , + ); + + isFirstSection = false; + return; + } + + let menuItem = ( + + ); + + // Apply tooltip wrapper if tooltip property is provided + if (item.props.tooltip) { + const tooltipProps = + typeof item.props.tooltip === 'string' + ? { title: item.props.tooltip } + : item.props.tooltip; + + menuItem = ( + + {menuItem} + + ); + } + + // Apply custom wrapper if provided + 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; + }, [ + filteredCollectionItems, + treeState, + completeProps.sectionStyles, + completeProps.itemStyles, + completeProps.selectionIcon, + completeProps.sectionHeadingStyles, + ]); + + // Auto-focus search input + React.useEffect(() => { + if (autoFocus && searchInputRef.current) { + // Use a small timeout to ensure the element is visible and focusable + // This is especially important when the CommandMenu is opened in a popover + const timeoutId = setTimeout(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, 0); + + return () => clearTimeout(timeoutId); + } + }, [autoFocus]); + + // Also focus when the component becomes visible (for trigger/popover usage) + React.useEffect(() => { + // Check if autoFocus is enabled and we're in a trigger context + if (autoFocus && contextProps.autoFocus && searchInputRef.current) { + // Use a small timeout to ensure the popover is fully rendered + const timeoutId = setTimeout(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, 50); // Slightly longer timeout for popover context + + return () => clearTimeout(timeoutId); + } + }, [autoFocus, contextProps.autoFocus]); + + // Auto-focus first item when search value changes (but not on initial render) + React.useEffect(() => { + // Only auto-focus when search value changes, not on initial mount + if (searchValue.trim() !== '') { + const firstSelectableKey = findFirstSelectableItem(); + + if (firstSelectableKey && hasFilteredItems) { + // Focus the first item in the selection manager + treeState.selectionManager.setFocusedKey(firstSelectableKey); + setFocusedKey(firstSelectableKey); + } else { + // Clear focus if no items are available + treeState.selectionManager.setFocusedKey(null); + setFocusedKey(null); + } + } + }, [ + searchValue, + findFirstSelectableItem, + hasFilteredItems, + treeState.selectionManager, + ]); + + // Extract styles + const extractedStyles = useMemo( + () => extractStyles(props, CONTAINER_STYLES), + [props], + ); + + // Determine if we should show empty state based on actual filtered collection + const hasSearchTerm = searchValue.trim().length > 0; + const showEmptyState = hasSearchTerm && !hasFilteredItems && !isLoading; + + // Sync refs + useSyncRef(contextProps, menuRef); + + return ( + + {/* Search Input */} + handleSearchChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + + const isArrowDown = e.key === 'ArrowDown'; + const { selectionManager, collection } = treeState; + const currentKey = selectionManager.focusedKey; + + // Helper function to find next selectable key in a direction + const findNextSelectableKey = ( + startKey: any, + direction: 'forward' | 'backward', + ) => { + if (startKey == null) { + return null; + } + + // First check if the startKey itself is selectable + const startNode = collection.getItem(startKey); + if ( + startNode && + startNode.type === 'item' && + !selectionManager.isDisabled(startKey) + ) { + return startKey; + } + + // If startKey is not selectable, find the next selectable key + let keys = [...collection.getKeys()]; + + if (direction === 'backward') { + keys = keys.reverse(); + } + + let startIndex = keys.indexOf(startKey); + + if (startIndex === -1) { + return null; + } + + for (let i = startIndex + 1; i < keys.length; i++) { + const key = keys[i]; + const node = collection.getItem(key); + + if ( + node && + node.type === 'item' && + !selectionManager.isDisabled(key) + ) { + return key; + } + } + + return null; + }; + + // Helper function to find first or last selectable key + const findFirstLastSelectableKey = ( + direction: 'forward' | 'backward', + ) => { + const keys = [...collection.getKeys()]; + const keysToCheck = + direction === 'forward' ? keys : keys.reverse(); + + for (const key of keysToCheck) { + const node = collection.getItem(key); + + if ( + node && + node.type === 'item' && + !selectionManager.isDisabled(key) + ) { + return key; + } + } + + return null; + }; + + let nextKey; + const direction = isArrowDown ? 'forward' : 'backward'; + + if (currentKey == null) { + // No current focus, start from the first/last item + nextKey = findFirstLastSelectableKey(direction); + } else { + // Find next selectable item from current position + const candidateKey = + direction === 'forward' + ? collection.getKeyAfter(currentKey) + : collection.getKeyBefore(currentKey); + + nextKey = findNextSelectableKey(candidateKey, direction); + + // If no next key found and focus wrapping is enabled, wrap to first/last selectable item + if (nextKey == null) { + nextKey = findFirstLastSelectableKey(direction); + } + } + + if (nextKey != null) { + selectionManager.setFocusedKey(nextKey); + setFocusedKey(nextKey); + } + } else if ( + e.key === 'Enter' || + (e.key === ' ' && !searchValue.trim()) + ) { + const currentFocusedKey = + focusedKey || treeState.selectionManager.focusedKey; + if (currentFocusedKey != null) { + e.preventDefault(); + + // Trigger action for the focused item (like Menu does) + // First check if there's a selection mode, if so, handle selection + if (treeState.selectionManager.selectionMode !== 'none') { + treeState.selectionManager.select(currentFocusedKey, e); + } else { + // Default behavior: trigger action + const node = treeState.collection.getItem(currentFocusedKey); + if (node) { + // Call the tree state's action handler + const onAction = (completeProps as any).onAction; + if (onAction) { + onAction(currentFocusedKey); + } + // Also call the item's individual onAction if it exists + if (node.props?.onAction) { + node.props.onAction(currentFocusedKey); + } + } + } + + // Close the menu if we're in a trigger context and closeOnSelect is enabled (default behavior) + const { onClose, closeOnSelect } = contextProps; + if (onClose && closeOnSelect !== false) { + onClose(); + } + } + } else if (e.key === 'Escape') { + if (searchValue) { + e.preventDefault(); + handleSearchChange(''); + } + } + }} + /> + + {/* Loading State */} + {isLoading && ( + + + + )} + + {/* Menu Content - always render unless loading */} + {!isLoading && !showEmptyState && ( + + + {renderedItems} + + + )} + + {/* Empty State - show when search term exists but no results */} + {!isLoading && showEmptyState && ( + {emptyLabel} + )} + + ); +} + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +const _CommandMenu = React.forwardRef(CommandMenuBase) as ( + props: CubeCommandMenuProps & React.RefAttributes, +) => ReactElement; + +// Attach Trigger alias from MenuTrigger for consistent API +// Also attach Item and Section for compound component pattern +const __CommandMenu = Object.assign(_CommandMenu, { + Trigger: MenuTrigger, + Item, + Section, + displayName: 'CommandMenu', +}); + +export { __CommandMenu as CommandMenu }; diff --git a/src/components/actions/CommandMenu/index.ts b/src/components/actions/CommandMenu/index.ts new file mode 100644 index 00000000..5e3ca33d --- /dev/null +++ b/src/components/actions/CommandMenu/index.ts @@ -0,0 +1,4 @@ +// Barrel file for CommandMenu component +export { CommandMenu } from './CommandMenu'; + +export type { CubeCommandMenuProps, CommandMenuItem } from './CommandMenu'; diff --git a/src/components/actions/CommandMenu/styled.tsx b/src/components/actions/CommandMenu/styled.tsx new file mode 100644 index 00000000..652e55fd --- /dev/null +++ b/src/components/actions/CommandMenu/styled.tsx @@ -0,0 +1,95 @@ +import { tasty } from '../../../tasty'; + +export const StyledCommandMenu = tasty({ + qa: 'CommandMenu', + styles: { + display: 'grid', + flow: 'row', + gridColumns: '1fr', + gridRows: 'auto minmax(0, 1fr)', + fill: '#white', + border: '#border', + radius: '(1cr + 1bw)', + boxShadow: '0px 5px 15px #dark.05', + overflow: 'hidden', + minWidth: '20x', + maxWidth: '50x', + maxHeight: '40x', + }, +}); + +export const StyledSearchInput = tasty({ + qa: 'SearchInput', + as: 'input', + styles: { + display: 'flex', + width: '100%', + color: '#dark', + fill: '#white', + border: '#border bottom', + outline: 'none', + transition: 'theme', + radius: 0, + padding: '@vertical-padding @right-padding @vertical-padding @left-padding', + textAlign: 'left', + reset: 'input', + preset: 't3', + margin: 0, + boxSizing: 'border-box', + userSelect: 'auto', + + '@vertical-padding': { + '': '(.75x - 1bw)', + '[data-size="medium"]': '(1.25x - 1bw)', + }, + '@left-padding': { + '': '1.5x', + '[data-size="medium"]': '1.5x', + }, + '@right-padding': { + '': '1.5x', + '[data-size="medium"]': '1.5x', + }, + + '&::placeholder': { + color: '#dark-03', + }, + }, +}); + +export const StyledLoadingWrapper = tasty({ + qa: 'LoadingWrapper', + styles: { + display: 'flex', + padding: '2x', + placeContent: 'center', + placeItems: 'center', + color: '#dark-03', + }, +}); + +export const StyledEmptyState = tasty({ + qa: 'EmptyState', + styles: { + display: 'flex', + padding: '2x', + placeContent: 'center', + placeItems: 'center', + color: '#dark-03', + preset: 't3', + }, +}); + +export const StyledMenuWrapper = tasty({ + qa: 'MenuWrapper', + styles: { + display: 'grid', + flow: 'row', + gridColumns: 'minmax(0, 1fr)', + placeContent: 'stretch', + placeItems: 'stretch', + width: '100%', + overflow: 'auto', + scrollbar: 'styled', + }, +}); diff --git a/src/components/pickers/Menu/Menu.docs.mdx b/src/components/actions/Menu/Menu.docs.mdx similarity index 97% rename from src/components/pickers/Menu/Menu.docs.mdx rename to src/components/actions/Menu/Menu.docs.mdx index 9779fc5b..1324403b 100644 --- a/src/components/pickers/Menu/Menu.docs.mdx +++ b/src/components/actions/Menu/Menu.docs.mdx @@ -90,9 +90,9 @@ The `mods` property accepts the following modifiers you can override: | Property | Type | Default | Description | |----------|------|---------|-------------| | selectionMode | `'single' \| 'multiple'` | `'none'` | Type of selection allowed | -| selectedKeys | `Iterable` | - | Currently selected keys (controlled) | -| defaultSelectedKeys | `Iterable` | - | Initially selected keys (uncontrolled) | -| onSelectionChange | `(keys: Selection) => void` | - | Handler for selection changes | +| selectedKeys | `string[]` | - | Currently selected keys (controlled) | +| defaultSelectedKeys | `string[]` | - | Initially selected keys (uncontrolled) | +| onSelectionChange | `(keys: string[]) => void` | - | Handler for selection changes | | selectionIcon | `'checkbox' \| 'radio'` | - | Type of selection indicator | ### State Properties diff --git a/src/components/pickers/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx similarity index 93% rename from src/components/pickers/Menu/Menu.stories.tsx rename to src/components/actions/Menu/Menu.stories.tsx index 3e42d6b2..047e35fb 100644 --- a/src/components/pickers/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -124,6 +124,15 @@ export default { }, /* Styling */ + size: { + options: ['small', 'medium'], + control: { type: 'radio' }, + description: 'Size of the menu items', + table: { + type: { summary: "'small' | 'medium'" }, + defaultValue: { summary: 'small' }, + }, + }, styles: { control: { type: null }, description: 'Custom styles for the menu container', @@ -273,6 +282,42 @@ export const Default = ({ ...props }) => { ); }; +export const MediumSize = ({ ...props }) => { + const menu = ( + + + Copy + + + Paste + + + Cut + + + ); + + return ( + + {menu} + + +